diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..409579c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# AGENTS.md + +Guidelines for AI agents operating in this repository. + +## Project overview + +C# XML bindings for [SIRI](https://www.siri-cen.eu/) and [NeTEx](https://netex-cen.eu/) public transport schemas. A CLI generator (`Spillgebees.Transmodel.Generator`) downloads official XSD schemas and produces C# classes via a fork of `XmlSchemaClassGenerator`. Version-specific model projects (e.g. `Spillgebees.NeTEx.Models.V1_3_1`) are thin wrappers whose `Generated/` contents are created at build time. + +## Build / test / lint + +```bash +# Build (from repo root) +dotnet build Spillgebees.Transmodel.slnx + +# Run all tests +dotnet test --solution Spillgebees.Transmodel.slnx + +# Run a single test by name +dotnet test --solution Spillgebees.Transmodel.slnx --filter "FullyQualifiedName~Should_serialize_and_deserialize_stop_place" + +# Run tests in one project +dotnet test src/netex/Spillgebees.NeTEx.Models.Tests + +# Clean (removes Generated/ dirs; next build regenerates them) +dotnet clean Spillgebees.Transmodel.slnx +``` + +There is no separate lint command. `TreatWarningsAsErrors=True` is set globally in `src/General.targets`, so building IS linting. The `.editorconfig` at `src/.editorconfig` configures all Roslyn analyzers. + +## Critical rules + +1. **Never edit files under `Generated/` directories.** They are machine-generated, `.gitignore`d, and recreated every build. All fixes must go in the xscgen fork or the generator. +2. **Never manually set package versions in `.csproj` files.** Use `src/Directory.Packages.props` (central package management). +3. **Never add other target frameworks.** The project targets `net10.0` only. +4. **`.targets` files are shared MSBuild imports.** Changes to `General.targets`, `SIRI.Models.targets`, or `NeTEx.Models.targets` affect every project that imports them. +5. **The `packages/` directory contains vendored local `.nupkg` files** for a pre-release xscgen fork. These are intentionally committed. + +## Project structure + +``` +Spillgebees.Transmodel.slnx Solution file +global.json .NET 10 SDK pinning +nuget.config NuGet sources (nuget.org + local packages/) +packages/ Vendored local nupkgs (xscgen fork) +src/ + .editorconfig Code style / analyzer rules + Directory.Packages.props Central package version management + General.targets Shared: TFM, nullable, TreatWarningsAsErrors + generator/ + Spillgebees.Transmodel.Generator/ CLI tool (System.CommandLine) + siri/ + SIRI.Models.targets NuGet metadata + build-time generation + Spillgebees.SIRI.Models/ Meta-package (all SIRI versions) + Spillgebees.SIRI.Models.V2_1/ SIRI v2.1 bindings + Spillgebees.SIRI.Models.V2_2/ SIRI v2.2 bindings + Spillgebees.SIRI.Models.Tests/ Tests + netex/ + NeTEx.Models.targets NuGet metadata + build-time generation + Spillgebees.NeTEx.Models/ Meta-package (all NeTEx versions) + Spillgebees.NeTEx.Models.V1_*/ Version-specific bindings (5 versions) + Spillgebees.NeTEx.Models.Tests/ Tests +``` + +## Code style + +Enforced by `src/.editorconfig` and `TreatWarningsAsErrors`. Key rules: + +### Formatting +- **No region markers or decorative comment dividers.** Do not use comments like `// -- section name -----` or `#region`/`#endregion`. If a file needs sectioning, it should likely be split into separate files instead. +- **4 spaces** for C#; **2 spaces** for XML/csproj/json/yaml +- **Allman braces** (opening brace on new line for all constructs) +- **Always use braces**, even for single-line `if`/`for`/etc. +- **File-scoped namespaces** (`namespace Foo;` not `namespace Foo { }`) +- No multiple consecutive blank lines + +### Types and keywords +- **Use `var` everywhere** (built-in types, apparent types, elsewhere) +- **Use predefined type keywords** (`int` not `Int32`, `string` not `String`) +- **Nullable reference types** are enabled globally +- Prefer expression-bodied members, object/collection initializers, pattern matching, null propagation + +### Naming conventions +| Symbol | Convention | Example | +|---|---|---| +| Constants | PascalCase | `MaxRetries` | +| Private/internal fields | `_camelCase` | `_groupId` | +| Methods, properties | PascalCase | `GenerateCode()` | +| Local variables | camelCase | `rootNamespace` | + +- No `this.` qualifier +- Modifier order: `public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async` + +### Imports +- System namespaces first (`dotnet_sort_system_directives_first = true`) +- Placed outside the namespace declaration +- Implicit usings are enabled (`System`, `System.Collections.Generic`, `System.Linq`, etc. are available without explicit `using`) + +## Test conventions + +### Framework +- **TUnit** (not xUnit/NUnit/MSTest) with `[Test]` attribute +- **AwesomeAssertions** for fluent assertions (`.Should().Be(...)`, `.Should().NotBeNull()`) +- Test runner: `Microsoft.Testing.Platform` (configured in `global.json`) + +### Naming +Tests use `Should_describe_expected_behavior` in snake_case: +```csharp +[Test] +public void Should_serialize_and_deserialize_stop_place_with_stop_place_type() +``` + +### Structure +Use Arrange/Act/Assert with comments: +```csharp +[Test] +public void Should_round_trip_multilingual_string() +{ + // arrange + var serializer = new XmlSerializer(typeof(MultilingualString)); + var original = new MultilingualString { Value = "hello", Lang = "en" }; + + // act + using var writer = new StringWriter(); + serializer.Serialize(writer, original); + var xml = writer.ToString(); + + using var reader = new StringReader(xml); + var deserialized = (MultilingualString?)serializer.Deserialize(reader); + + // assert + deserialized.Should().NotBeNull(); + deserialized!.Value.Should().Be("hello"); +} +``` + +### Organization +- `Smoke/` -- Type existence and XML namespace verification +- `Serialization/` -- XmlSerializer round-trip tests +- `Deserialization/` -- XML parsing tests (with `TestData/` fixtures) +- Root level -- Cross-cutting concerns (choice groups, nullability, required modifiers) + +Test classes are plain `public class` with no base class or constructor injection. Test projects reference multiple versioned model assemblies. + +## Tooling + +- **SDK**: .NET 10.0 (`global.json` pins `10.0.102`, rolls forward within feature band) +- **Versioning**: MinVer (automatic from Git tags, no manual versions in csproj) +- **Reproducible builds**: `DotNet.ReproducibleBuilds` package +- **License**: EUPL-1.2 +- **CI**: GitHub Actions (`.github/workflows/`) -- build, test, pack, publish to nuget.org on release diff --git a/packages/XmlSchemaClassGenerator-beta.99.0.7-local.nupkg b/packages/XmlSchemaClassGenerator-beta.99.0.7-local.nupkg deleted file mode 100644 index 9380141..0000000 Binary files a/packages/XmlSchemaClassGenerator-beta.99.0.7-local.nupkg and /dev/null differ diff --git a/packages/XmlSchemaClassGenerator-beta.99.0.8-local.nupkg b/packages/XmlSchemaClassGenerator-beta.99.0.8-local.nupkg new file mode 100644 index 0000000..1ab031b Binary files /dev/null and b/packages/XmlSchemaClassGenerator-beta.99.0.8-local.nupkg differ diff --git a/packages/XmlSchemaClassGenerator.Analyzer.99.0.7-local.nupkg b/packages/XmlSchemaClassGenerator.Analyzer.99.0.7-local.nupkg deleted file mode 100644 index 369954e..0000000 Binary files a/packages/XmlSchemaClassGenerator.Analyzer.99.0.7-local.nupkg and /dev/null differ diff --git a/packages/XmlSchemaClassGenerator.Analyzer.99.0.8-local.nupkg b/packages/XmlSchemaClassGenerator.Analyzer.99.0.8-local.nupkg new file mode 100644 index 0000000..e1ed1f6 Binary files /dev/null and b/packages/XmlSchemaClassGenerator.Analyzer.99.0.8-local.nupkg differ diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 54d0b1d..3fa9c93 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -8,8 +8,8 @@ - - + + diff --git a/src/generator/Spillgebees.Transmodel.Generator/Services/CodeGenerator.cs b/src/generator/Spillgebees.Transmodel.Generator/Services/CodeGenerator.cs index a84eb01..81436c3 100644 --- a/src/generator/Spillgebees.Transmodel.Generator/Services/CodeGenerator.cs +++ b/src/generator/Spillgebees.Transmodel.Generator/Services/CodeGenerator.cs @@ -150,6 +150,8 @@ private static XscGenerator CreateBaseGenerator( UseShouldSerializePattern = true, GenerateChoiceGroupAttributes = true, ChoiceGroupAttributeNamespace = rootNamespace, + GenerateStrictFixedValues = true, + GenerateStrictRangeBounds = true, // Minimal attribute noise GenerateSerializableAttribute = false, diff --git a/src/netex/Spillgebees.NeTEx.Models.Tests/ChoiceGroupAttributeTests.cs b/src/netex/Spillgebees.NeTEx.Models.Tests/ChoiceGroupAttributeTests.cs index 030768f..fe8a4cb 100644 --- a/src/netex/Spillgebees.NeTEx.Models.Tests/ChoiceGroupAttributeTests.cs +++ b/src/netex/Spillgebees.NeTEx.Models.Tests/ChoiceGroupAttributeTests.cs @@ -12,8 +12,6 @@ namespace Spillgebees.NeTEx.Models.Tests; /// public class ChoiceGroupAttributeTests { - // -- helpers ------------------------------------------------------------------ - private static XmlChoiceGroupAttribute? GetChoiceGroupAttribute(string propertyName) => typeof(T).GetProperty(propertyName)? .GetCustomAttribute(); @@ -25,8 +23,6 @@ private static XmlChoiceGroupAttribute GetRequiredChoiceGroupAttribute(string return attr!; } - // -- DistanceMatrixElementDerivedViewStructure: two parallel choice groups ----- - [Test] public void Should_have_choice_group_on_start_stop_point_ref() { @@ -105,8 +101,6 @@ public void Should_not_have_choice_group_on_non_choice_property() attr.Should().BeNull(); } - // -- FareStructureElementVersionStructure: multiple choice groups in NeTEx ----- - [Test] public void Should_have_multiple_distinct_choice_groups_on_fare_structure_element() { diff --git a/src/netex/Spillgebees.NeTEx.Models.Tests/NullabilityAndRequiredTests.cs b/src/netex/Spillgebees.NeTEx.Models.Tests/NullabilityAndRequiredTests.cs index 3181e09..d2f4a74 100644 --- a/src/netex/Spillgebees.NeTEx.Models.Tests/NullabilityAndRequiredTests.cs +++ b/src/netex/Spillgebees.NeTEx.Models.Tests/NullabilityAndRequiredTests.cs @@ -16,8 +16,6 @@ namespace Spillgebees.NeTEx.Models.Tests; /// public class NullabilityAndRequiredTests { - // -- behavioral: required properties ------------------------------------------ - [Test] public void Should_construct_publication_delivery_with_required_properties() { @@ -64,8 +62,6 @@ public void Should_construct_natural_language_string_with_required_value() str.Value.Should().Be("hello"); } - // -- behavioral: optional nullable properties --------------------------------- - [Test] public void Should_allow_null_on_optional_reference_type_properties() { @@ -101,8 +97,6 @@ public void Should_allow_null_on_optional_string_attribute() str.Lang.Should().BeNull(); } - // -- behavioral: XmlSerializer bypasses required ------------------------------ - [Test] public void Should_round_trip_required_properties_via_xml_serializer() { @@ -125,8 +119,6 @@ public void Should_round_trip_required_properties_via_xml_serializer() result.Lang.Should().BeNull(); } - // -- behavioral: collections are not required --------------------------------- - [Test] public void Should_allow_creating_organisation_without_setting_collection() { @@ -142,8 +134,6 @@ public void Should_allow_creating_organisation_without_setting_collection() org.OrganisationType.Should().NotBeNull(); } - // -- metadata: required modifier via reflection ------------------------------- - [Test] public void Should_have_required_modifier_on_publication_timestamp() { @@ -246,8 +236,6 @@ public void Should_not_have_required_modifier_on_collection_property() isRequired.Should().BeFalse(); } - // -- metadata: nullable annotations via reflection ---------------------------- - [Test] public void Should_have_nullable_annotation_on_optional_reference_type_property() { @@ -344,8 +332,6 @@ public void Should_not_have_nullable_annotation_on_min_length_constrained_xml_te info.ReadState.Should().Be(NullabilityState.NotNull); } - // -- metadata: #nullable enable directive ------------------------------------- - [Test] public void Should_have_nullable_context_enabled_on_generated_types() { diff --git a/src/netex/Spillgebees.NeTEx.Models.Tests/XsdTypeMappingTests.cs b/src/netex/Spillgebees.NeTEx.Models.Tests/XsdTypeMappingTests.cs new file mode 100644 index 0000000..cbb9108 --- /dev/null +++ b/src/netex/Spillgebees.NeTEx.Models.Tests/XsdTypeMappingTests.cs @@ -0,0 +1,242 @@ +using System.ComponentModel; +using System.Reflection; +using System.Xml.Serialization; +using AwesomeAssertions; +using Spillgebees.NeTEx.Models.V1_3_1.NeTEx; + +namespace Spillgebees.NeTEx.Models.Tests; + +/// +/// Tests verifying that the xscg fork correctly maps XSD types to C# types, +/// generates default values for XSD fixed/default attributes, applies the +/// ShouldSerialize pattern for optional value types, uses init setters on +/// collections, and renames substitution group heads via NetexNamingProvider. +/// +public class XsdTypeMappingTests +{ + [Test] + public void Should_map_xs_date_to_date_only_for_required_property() + { + // arrange & act — OperatingDayVersionStructure.CalendarDate is xs:date + // with minOccurs=1, mapped to System.DateOnly with the required modifier. + var property = typeof(OperatingDayVersionStructure) + .GetProperty(nameof(OperatingDayVersionStructure.CalendarDate))!; + + // assert + property.PropertyType.Should().Be(typeof(DateOnly)); + } + + [Test] + public void Should_map_xs_date_to_nullable_date_only_for_optional_property() + { + // arrange & act — CustomerVersionStructure.DateOfBirth is an optional xs:date, + // mapped to Nullable. + var property = typeof(CustomerVersionStructure) + .GetProperty(nameof(CustomerVersionStructure.DateOfBirth))!; + + // assert + property.PropertyType.Should().Be(typeof(DateOnly?)); + } + + [Test] + public void Should_map_xs_date_time_to_date_time_offset_for_required_property() + { + // arrange & act — ClosedTimestampRangeStructure.StartTime is xs:dateTime + // with minOccurs=1, mapped to DateTimeOffset with the required modifier. + var property = typeof(ClosedTimestampRangeStructure) + .GetProperty(nameof(ClosedTimestampRangeStructure.StartTime))!; + + // assert + property.PropertyType.Should().Be(typeof(DateTimeOffset)); + } + + [Test] + public void Should_map_xs_date_time_to_nullable_date_time_offset_for_optional_property() + { + // arrange & act — CustomerVersionStructure.EmailVerified is an optional + // xs:dateTime, mapped to Nullable. + var property = typeof(CustomerVersionStructure) + .GetProperty(nameof(CustomerVersionStructure.EmailVerified))!; + + // assert + property.PropertyType.Should().Be(typeof(DateTimeOffset?)); + } + + [Test] + public void Should_round_trip_closed_timestamp_range_via_xml_serializer() + { + // arrange + var serializer = new XmlSerializer(typeof(ClosedTimestampRangeStructure)); + var original = new ClosedTimestampRangeStructure + { + StartTime = new DateTimeOffset(2026, 6, 1, 8, 0, 0, TimeSpan.FromHours(2)), + EndTime = new DateTimeOffset(2026, 6, 1, 18, 0, 0, TimeSpan.FromHours(2)), + }; + + // act + using var writer = new StringWriter(); + serializer.Serialize(writer, original); + var xml = writer.ToString(); + + using var reader = new StringReader(xml); + var deserialized = serializer.Deserialize(reader) as ClosedTimestampRangeStructure; + + // assert + deserialized.Should().NotBeNull(); + deserialized.StartTime.Should().Be(original.StartTime); + deserialized.EndTime.Should().Be(original.EndTime); + } + + [Test] + public void Should_generate_default_value_for_xsd_default_version_attribute() + { + // arrange & act — PublicationDeliveryStructure.Version has default="1.0" + // in the XSD, which the generator emits as a backing field initialized + // to "1.0" and a [DefaultValue("1.0")] attribute. + var delivery = new PublicationDeliveryStructure + { + PublicationTimestamp = DateTimeOffset.UtcNow, + ParticipantRef = "test", + }; + + // assert — version is automatically set to the XSD default + delivery.Version.Should().Be("1.0"); + } + + [Test] + public void Should_have_default_value_attribute_on_version_property() + { + // arrange + var property = typeof(PublicationDeliveryStructure) + .GetProperty(nameof(PublicationDeliveryStructure.Version))!; + + // act + var attr = property.GetCustomAttribute(); + + // assert + attr.Should().NotBeNull(); + attr!.Value.Should().Be("1.0"); + } + + [Test] + public void Should_generate_should_serialize_method_for_nullable_date_only() + { + // arrange — CustomerVersionStructure has a ShouldSerializeDateOfBirth() method + // generated because DateOfBirth is Nullable. + var method = typeof(CustomerVersionStructure) + .GetMethod("ShouldSerializeDateOfBirth"); + + // assert + method.Should().NotBeNull(); + method!.ReturnType.Should().Be(typeof(bool)); + } + + [Test] + public void Should_return_false_from_should_serialize_when_nullable_date_only_is_null() + { + // arrange + var customer = new CustomerVersionStructure(); + + // act & assert + customer.DateOfBirth.Should().BeNull(); + customer.ShouldSerializeDateOfBirth().Should().BeFalse(); + } + + [Test] + public void Should_return_true_from_should_serialize_when_nullable_date_only_is_set() + { + // arrange + var customer = new CustomerVersionStructure + { + DateOfBirth = new DateOnly(1990, 1, 15), + }; + + // act & assert + customer.ShouldSerializeDateOfBirth().Should().BeTrue(); + } + + [Test] + public void Should_use_init_setter_on_collection_properties() + { + // arrange — Organisation.OrganisationType is a List<> with an init setter, + // meaning it can be set via object initializer but not reassigned afterwards. + var property = typeof(Organisation) + .GetProperty(nameof(Organisation.OrganisationType))!; + var setter = property.GetSetMethod(); + + // assert — init-only setters have the IsExternalInit modreq + setter.Should().NotBeNull(); + setter!.ReturnParameter.GetRequiredCustomModifiers() + .Should().Contain(typeof(System.Runtime.CompilerServices.IsExternalInit)); + } + + [Test] + public void Should_rename_substitution_group_head_to_base_suffix() + { + // arrange — The NeTEx XSD defines StopPlace_ as an abstract dummy element + // used as a substitution group head. The NetexNamingProvider renames it to + // StopPlaceBase so the concrete StopPlace class gets the clean name. + + // act + var baseType = typeof(StopPlaceBase); + var xmlType = baseType.GetCustomAttribute(); + + // assert — C# class is "StopPlaceBase", but XmlType still references "StopPlace_" + baseType.Name.Should().Be("StopPlaceBase"); + xmlType.Should().NotBeNull(); + xmlType!.TypeName.Should().Be("StopPlace_"); + } + + [Test] + public void Should_give_concrete_type_the_clean_name() + { + // arrange & act — StopPlace is the concrete type with the clean name + var concreteType = typeof(StopPlace); + var xmlType = concreteType.GetCustomAttribute(); + + // assert + concreteType.Name.Should().Be("StopPlace"); + xmlType.Should().NotBeNull(); + xmlType!.TypeName.Should().Be("StopPlace"); + } + + [Test] + public void Should_generate_should_serialize_for_nullable_time_only() + { + // arrange — OperatingDayVersionStructure.EarliestTime is Nullable + var operatingDay = new OperatingDayVersionStructure + { + CalendarDate = new DateOnly(2026, 6, 15), + }; + + // act & assert — null by default, ShouldSerialize returns false + operatingDay.EarliestTime.Should().BeNull(); + operatingDay.ShouldSerializeEarliestTime().Should().BeFalse(); + + // act — set the value + operatingDay.EarliestTime = new TimeOnly(4, 30); + + // assert — ShouldSerialize now returns true + operatingDay.ShouldSerializeEarliestTime().Should().BeTrue(); + } + + [Test] + public void Should_generate_should_serialize_for_nullable_time_span() + { + // arrange — OperatingDayVersionStructure.DayLength is Nullable + var operatingDay = new OperatingDayVersionStructure + { + CalendarDate = new DateOnly(2026, 6, 15), + }; + + // act & assert — null by default, ShouldSerialize returns false + operatingDay.DayLength.Should().BeNull(); + operatingDay.ShouldSerializeDayLength().Should().BeFalse(); + + // act — set the value + operatingDay.DayLength = TimeSpan.FromHours(28); + + // assert — ShouldSerialize now returns true + operatingDay.ShouldSerializeDayLength().Should().BeTrue(); + } +} diff --git a/src/siri/Spillgebees.SIRI.Models.Tests/ChoiceGroupAttributeTests.cs b/src/siri/Spillgebees.SIRI.Models.Tests/ChoiceGroupAttributeTests.cs index 0d36024..3b329a4 100644 --- a/src/siri/Spillgebees.SIRI.Models.Tests/ChoiceGroupAttributeTests.cs +++ b/src/siri/Spillgebees.SIRI.Models.Tests/ChoiceGroupAttributeTests.cs @@ -12,8 +12,6 @@ namespace Spillgebees.SIRI.Models.Tests; /// public class ChoiceGroupAttributeTests { - // -- helpers ------------------------------------------------------------------ - private static XmlChoiceGroupAttribute? GetChoiceGroupAttribute(string propertyName) => typeof(T).GetProperty(propertyName)? .GetCustomAttribute(); @@ -25,8 +23,6 @@ private static XmlChoiceGroupAttribute GetRequiredChoiceGroupAttribute(string return attr!; } - // -- MonitoredCountingStructure: simple choice (Count vs Percentage) ----------- - [Test] public void Should_have_choice_group_attribute_on_monitored_counting_count() { @@ -56,8 +52,6 @@ public void Should_have_same_group_id_for_monitored_counting_choice() countAttr.GroupId.Should().Be(percentageAttr.GroupId); } - // -- LocationStructure: sequence arm (Lon/Lat/Alt share arm, Coordinates is other) -- - [Test] public void Should_have_choice_group_attribute_on_location_longitude() { @@ -117,8 +111,6 @@ public void Should_have_same_group_id_for_location_choice() coordAttr.ArmId.Should().NotBe(lonAttr.ArmId); } - // -- PlannedStopAssignmentStructure: both arms are sequences ------------------- - [Test] public void Should_have_choice_group_attribute_on_planned_stop_assignment_quay_arm() { @@ -162,8 +154,6 @@ public void Should_have_same_group_id_across_planned_stop_assignment_choice() quayRef.GroupId.Should().Be(bpRef.GroupId); } - // -- non-choice properties should NOT have the attribute ----------------------- - [Test] public void Should_not_have_choice_group_attribute_on_non_choice_property() { @@ -182,8 +172,6 @@ public void Should_not_have_choice_group_attribute_on_location_id() attr.Should().BeNull(); } - // -- ControlActionStructure: multiple choice groups in same type --------------- - [Test] public void Should_have_multiple_distinct_choice_groups_on_control_action_structure() { diff --git a/src/siri/Spillgebees.SIRI.Models.Tests/NullabilityAndRequiredTests.cs b/src/siri/Spillgebees.SIRI.Models.Tests/NullabilityAndRequiredTests.cs index a904eb4..82ea2c4 100644 --- a/src/siri/Spillgebees.SIRI.Models.Tests/NullabilityAndRequiredTests.cs +++ b/src/siri/Spillgebees.SIRI.Models.Tests/NullabilityAndRequiredTests.cs @@ -14,8 +14,6 @@ namespace Spillgebees.SIRI.Models.Tests; /// public class NullabilityAndRequiredTests { - // -- behavioral: required properties ------------------------------------------ - [Test] public void Should_construct_key_value_with_required_properties() { @@ -58,8 +56,6 @@ public void Should_construct_accessibility_assessment_with_required_bool() assessment.MobilityImpairedAccess.Should().BeTrue(); } - // -- behavioral: optional nullable properties --------------------------------- - [Test] public void Should_allow_null_on_optional_reference_type_properties() { @@ -91,8 +87,6 @@ public void Should_allow_null_on_optional_string_attribute() str.Lang.Should().BeNull(); } - // -- behavioral: ShouldSerialize pattern for nullable value types -------------- - [Test] public void Should_serialize_nullable_value_type_when_set() { @@ -122,8 +116,6 @@ public void Should_not_serialize_nullable_value_type_when_null() link.ShouldSerializeLinkContent().Should().BeFalse(); } - // -- behavioral: collections -------------------------------------------------- - [Test] public void Should_initialize_collection_properties_via_constructor() { @@ -153,8 +145,6 @@ public void Should_not_serialize_empty_collections() delivery.ShouldSerializeEstimatedTimetableDelivery().Should().BeFalse(); } - // -- behavioral: XmlSerializer bypasses required ------------------------------ - [Test] public void Should_round_trip_required_properties_via_xml_serializer() { @@ -176,8 +166,6 @@ public void Should_round_trip_required_properties_via_xml_serializer() result.Lang.Should().BeNull(); } - // -- metadata: required modifier via reflection ------------------------------- - [Test] public void Should_have_required_modifier_on_key_value_key_property() { @@ -220,8 +208,6 @@ public void Should_not_have_required_modifier_on_optional_property() isRequired.Should().BeFalse(); } - // -- metadata: nullable annotations via reflection ---------------------------- - [Test] public void Should_have_nullable_annotation_on_optional_reference_type_property() { @@ -269,8 +255,6 @@ public void Should_have_nullable_annotation_on_optional_string_attribute() info.WriteState.Should().Be(NullabilityState.Nullable); } - // -- metadata: #nullable enable directive ------------------------------------- - [Test] public void Should_have_nullable_context_enabled_on_generated_types() { diff --git a/src/siri/Spillgebees.SIRI.Models.Tests/Serialization/IfoptSerializationTests.cs b/src/siri/Spillgebees.SIRI.Models.Tests/Serialization/IfoptSerializationTests.cs index 2a34f29..12d0825 100644 --- a/src/siri/Spillgebees.SIRI.Models.Tests/Serialization/IfoptSerializationTests.cs +++ b/src/siri/Spillgebees.SIRI.Models.Tests/Serialization/IfoptSerializationTests.cs @@ -31,13 +31,11 @@ public void Should_serialize_and_deserialize_stop_place_ref_structure() deserialized.Value.Should().Be("NSR:StopPlace:1234"); } - // -- CountryRefStructure (enum base: CountryCodeType) -- - // XmlSerializer cannot handle Nullable with [XmlText], so the generator - // must emit a non-nullable enum property. These tests verify that. - [Test] public void Should_construct_xml_serializer_for_country_ref_structure() { + // XmlSerializer cannot handle Nullable with [XmlText], so the generator + // must emit a non-nullable enum property. This verifies that. var act = () => new XmlSerializer(typeof(CountryRefStructure)); act.Should().NotThrow(); @@ -85,8 +83,6 @@ public void Should_round_trip_country_ref_structure() deserialized.Value.Should().Be(CountryCodeType.De); } - // -- AccessibilityStructure (enum base: AccessibilityEnumeration) -- - [Test] public void Should_construct_xml_serializer_for_accessibility_structure() { @@ -119,8 +115,6 @@ public void Should_round_trip_accessibility_structure() xml.Should().Contain(">true<"); } - // -- MeasureType (double base + required uom attribute) -- - [Test] public void Should_construct_xml_serializer_for_measure_type() { diff --git a/src/siri/Spillgebees.SIRI.Models.Tests/XsdTypeMappingTests.cs b/src/siri/Spillgebees.SIRI.Models.Tests/XsdTypeMappingTests.cs new file mode 100644 index 0000000..7bcb595 --- /dev/null +++ b/src/siri/Spillgebees.SIRI.Models.Tests/XsdTypeMappingTests.cs @@ -0,0 +1,281 @@ +using System.ComponentModel; +using System.Reflection; +using System.Xml.Serialization; +using AwesomeAssertions; +using Spillgebees.SIRI.Models.V2_2.SIRI; + +namespace Spillgebees.SIRI.Models.Tests; + +/// +/// Tests verifying that the xscg fork correctly maps XSD types to C# types, +/// generates default values for XSD fixed/default attributes, applies the +/// ShouldSerialize pattern, uses init setters on collections, and handles +/// xs:list enum collection serialization for SIRI models. +/// +public class XsdTypeMappingTests +{ + [Test] + public void Should_map_xs_date_time_to_date_time_offset_for_required_property() + { + // arrange & act — HalfOpenTimestampOutputRangeStructure.StartTime is xs:dateTime + // with minOccurs=1, mapped to DateTimeOffset with the required modifier. + var property = typeof(HalfOpenTimestampOutputRangeStructure) + .GetProperty(nameof(HalfOpenTimestampOutputRangeStructure.StartTime))!; + + // assert + property.PropertyType.Should().Be(typeof(DateTimeOffset)); + } + + [Test] + public void Should_map_xs_date_time_to_nullable_date_time_offset_for_optional_property() + { + // arrange & act — HalfOpenTimestampOutputRangeStructure.EndTime is an optional + // xs:dateTime, mapped to Nullable. + var property = typeof(HalfOpenTimestampOutputRangeStructure) + .GetProperty(nameof(HalfOpenTimestampOutputRangeStructure.EndTime))!; + + // assert + property.PropertyType.Should().Be(typeof(DateTimeOffset?)); + } + + [Test] + public void Should_round_trip_half_open_timestamp_range_via_xml_serializer() + { + // arrange + var serializer = new XmlSerializer(typeof(HalfOpenTimestampOutputRangeStructure)); + var original = new HalfOpenTimestampOutputRangeStructure + { + StartTime = new DateTimeOffset(2026, 3, 15, 9, 0, 0, TimeSpan.FromHours(1)), + EndTime = new DateTimeOffset(2026, 3, 15, 17, 30, 0, TimeSpan.FromHours(1)), + }; + + // act + using var writer = new StringWriter(); + serializer.Serialize(writer, original); + var xml = writer.ToString(); + + using var reader = new StringReader(xml); + var deserialized = serializer.Deserialize(reader) as HalfOpenTimestampOutputRangeStructure; + + // assert + deserialized.Should().NotBeNull(); + deserialized.StartTime.Should().Be(original.StartTime); + deserialized.EndTime.Should().Be(original.EndTime); + } + + [Test] + public void Should_not_serialize_null_end_time_in_half_open_range() + { + // arrange — EndTime is optional; when null it should not appear in XML + var serializer = new XmlSerializer(typeof(HalfOpenTimestampOutputRangeStructure)); + var original = new HalfOpenTimestampOutputRangeStructure + { + StartTime = new DateTimeOffset(2026, 3, 15, 9, 0, 0, TimeSpan.Zero), + }; + + // act + using var writer = new StringWriter(); + serializer.Serialize(writer, original); + var xml = writer.ToString(); + + // assert — the ShouldSerialize pattern prevents serialization + original.ShouldSerializeEndTime().Should().BeFalse(); + xml.Should().NotContain(""); + } + + [Test] + public void Should_generate_default_value_for_xsd_default_version_attribute() + { + // arrange & act — Siri.Version has default="2.1" in the XSD, which the + // generator emits as a backing field initialized to "2.1" and a + // [DefaultValue("2.1")] attribute. + var siri = new Siri(); + + // assert — version is automatically set to the XSD default + siri.Version.Should().Be("2.1"); + } + + [Test] + public void Should_have_default_value_attribute_on_siri_version_property() + { + // arrange + var property = typeof(Siri) + .GetProperty(nameof(Siri.Version))!; + + // act + var attr = property.GetCustomAttribute(); + + // assert + attr.Should().NotBeNull(); + attr!.Value.Should().Be("2.1"); + } + + [Test] + public void Should_generate_default_value_for_xsd_default_enum_property() + { + // arrange & act — HalfOpenTimestampOutputRangeStructure.EndTimeStatus has + // default="undefined" in the XSD, mapped to EndTimeStatusEnumeration.Undefined. + var range = new HalfOpenTimestampOutputRangeStructure + { + StartTime = DateTimeOffset.UtcNow, + }; + + // assert + range.EndTimeStatus.Should().Be(EndTimeStatusEnumeration.Undefined); + } + + [Test] + public void Should_have_default_value_attribute_on_enum_property() + { + // arrange + var property = typeof(HalfOpenTimestampOutputRangeStructure) + .GetProperty(nameof(HalfOpenTimestampOutputRangeStructure.EndTimeStatus))!; + + // act + var attr = property.GetCustomAttribute(); + + // assert + attr.Should().NotBeNull(); + attr!.Value.Should().Be(EndTimeStatusEnumeration.Undefined); + } + + [Test] + public void Should_use_init_setter_on_collection_properties() + { + // arrange — TrainElementStructure.MaximumPassengerCapacities is a List<> + // with an init setter. + var property = typeof(TrainElementStructure) + .GetProperty(nameof(TrainElementStructure.MaximumPassengerCapacities))!; + var setter = property.GetSetMethod(); + + // assert — init-only setters have the IsExternalInit modreq + setter.Should().NotBeNull(); + setter!.ReturnParameter.GetRequiredCustomModifiers() + .Should().Contain(typeof(System.Runtime.CompilerServices.IsExternalInit)); + } + + [Test] + public void Should_initialize_enum_collection_in_constructor() + { + // arrange & act — TrainElementStructure constructor initializes FareClasses + var element = new TrainElementStructure + { + TrainElementCode = "TE:001", + }; + + // assert + element.FareClasses.Should().NotBeNull(); + element.FareClasses.Should().HaveCount(0); + } + + [Test] + public void Should_serialize_enum_collection_as_space_separated_xsd_list() + { + // arrange — FareClasses is an xs:list of FareClassEnumeration values. + // The xscg fork generates a proxy *Xml property that serializes as a + // single element with space-separated values. + var serializer = new XmlSerializer(typeof(TrainElementStructure)); + var element = new TrainElementStructure + { + TrainElementCode = "TE:001", + FareClasses = + [ + FareClassEnumeration.FirstClass, + FareClassEnumeration.StandardClass + ], + }; + + // act + using var writer = new StringWriter(); + serializer.Serialize(writer, element); + var xml = writer.ToString(); + + // assert — xs:list serializes as space-separated values in one element + xml.Should().Contain("firstClass standardClass"); + } + + [Test] + public void Should_round_trip_enum_collection_via_xml_serializer() + { + // arrange + var serializer = new XmlSerializer(typeof(TrainElementStructure)); + var original = new TrainElementStructure + { + TrainElementCode = "TE:002", + FareClasses = + [ + FareClassEnumeration.BusinessClass, + FareClassEnumeration.EconomyClass + ], + }; + + // act + using var writer = new StringWriter(); + serializer.Serialize(writer, original); + var xml = writer.ToString(); + + using var reader = new StringReader(xml); + var deserialized = serializer.Deserialize(reader) as TrainElementStructure; + + // assert + deserialized.Should().NotBeNull(); + deserialized.FareClasses.Should().HaveCount(2); + deserialized.FareClasses[0].Should().Be(FareClassEnumeration.BusinessClass); + deserialized.FareClasses[1].Should().Be(FareClassEnumeration.EconomyClass); + } + + [Test] + public void Should_not_serialize_empty_enum_collection() + { + // arrange — empty FareClasses list should not appear in XML + var serializer = new XmlSerializer(typeof(TrainElementStructure)); + var element = new TrainElementStructure + { + TrainElementCode = "TE:003", + }; + + // act + using var writer = new StringWriter(); + serializer.Serialize(writer, element); + var xml = writer.ToString(); + + // assert + element.ShouldSerializeFareClasses().Should().BeFalse(); + xml.Should().NotContain(""); + } + + [Test] + public void Should_generate_should_serialize_for_nullable_value_type() + { + // arrange — TrainElementStructure.TrainElementType is Nullable + var element = new TrainElementStructure + { + TrainElementCode = "TE:004", + }; + + // act & assert — null by default + element.TrainElementType.Should().BeNull(); + element.ShouldSerializeTrainElementType().Should().BeFalse(); + + // act — set the value + element.TrainElementType = TrainElementTypeEnumeration.Carriage; + + // assert + element.ShouldSerializeTrainElementType().Should().BeTrue(); + } + + [Test] + public void Should_have_default_value_for_boolean_property_with_xsd_default() + { + // arrange & act — TrainElementStructure.ReversingDirection defaults to true + // per the XSD default value. + var element = new TrainElementStructure + { + TrainElementCode = "TE:005", + }; + + // assert + element.ReversingDirection.Should().BeTrue(); + element.SelfPropelled.Should().BeTrue(); + } +}