diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs index c5d4dad5..1621ecec 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -39,6 +39,7 @@ public sealed class AbCipDriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs index ffe9dff6..1256540c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs @@ -39,6 +39,7 @@ public sealed class AbLegacyDriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs index 807046d0..f04ee0aa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriverProbe.cs @@ -44,6 +44,7 @@ public sealed class FocasDriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs index eb940508..2155f639 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverProbe.cs @@ -35,6 +35,7 @@ public sealed class GalaxyDriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs index bf3bdee0..edd37bc1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client/WonderwareHistorianDriverProbe.cs @@ -24,6 +24,7 @@ public sealed class WonderwareHistorianDriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs index 8bb88235..38e7c5da 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs @@ -20,6 +20,7 @@ public sealed class ModbusDriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; // FC03 Read Holding Registers: function=0x03, addr-hi=0, addr-lo=0, qty-hi=0, qty-lo=1 diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs index 9868b78d..9d0b0380 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -24,6 +24,7 @@ public sealed class S7DriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs index fedf29cf..8300a2b4 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriverProbe.cs @@ -68,6 +68,7 @@ public sealed class TwinCATDriverProbe : IDriverProbe { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + Converters = { new JsonStringEnumConverter() }, }; /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor index 056c3b3b..d0e3ddb5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbCipDriverPage.razor @@ -224,6 +224,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor index 5cea4448..61abdeae 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/AbLegacyDriverPage.razor @@ -190,6 +190,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor index 7873f1fa..a56855cf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/FocasDriverPage.razor @@ -267,6 +267,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor index 64107c2e..7a9c7f91 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/GalaxyDriverPage.razor @@ -218,6 +218,7 @@ else PropertyNameCaseInsensitive = true, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor index 42c30d22..1b550668 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/HistorianWonderwareDriverPage.razor @@ -166,6 +166,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor index 59089643..c45a5768 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/ModbusDriverPage.razor @@ -330,6 +330,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor index ccffb20e..54773d44 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/OpcUaClientDriverPage.razor @@ -279,6 +279,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor index f8332eb1..c017a90d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/S7DriverPage.razor @@ -196,6 +196,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor index 80772120..469b08bf 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Clusters/Drivers/TwinCATDriverPage.razor @@ -206,6 +206,7 @@ else PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, WriteIndented = false, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }, }; private FormModel _form = new(); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverConfigEnumSerializationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverConfigEnumSerializationTests.cs new file mode 100644 index 00000000..2508b26d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverConfigEnumSerializationTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests; + +/// +/// Regression guard for the 2026-06-19 enum-serialization bug (the FB-9 Modbus-Int64 authoring +/// case). The AdminUI Modbus page now serialises tag enums (, +/// , byte-order) as STRINGS. This proves the factory parses that +/// AdminUI-shaped Int64-tag blob, and documents that the pre-fix NUMERIC form threw because the +/// ModbusTagDto enum fields are string?. +/// +public sealed class ModbusDriverConfigEnumSerializationTests +{ + // Mirrors the (now fixed) AdminUI ModbusDriverPage._jsonOpts: camelCase + string enums. + private static readonly JsonSerializerOptions _adminPageOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }; + + /// Verifies the factory parses an AdminUI-authored Modbus config carrying an Int64 + /// holding-register tag (string enums) without throwing. + [Fact] + public void Factory_parses_admin_authored_int64_string_enum_config() + { + var tag = new ModbusTagDefinition("Int64Tag", ModbusRegion.HoldingRegisters, (ushort)100, ModbusDataType.Int64); + var opts = new ModbusDriverOptions { Host = "10.0.0.5", Port = 502, UnitId = 1, Tags = new[] { tag } }; + var blob = JsonSerializer.Serialize(opts, _adminPageOpts); + // The fixed AdminUI page must emit tag enums as strings, not numbers. + blob.ShouldContain("\"dataType\":\"Int64\""); + + using var driver = ModbusDriverFactoryExtensions.CreateInstance("mb-test", blob); + driver.DriverType.ShouldBe("Modbus"); + } + + /// Documents the original bug: the pre-fix AdminUI page emitted numeric tag enums + /// ("dataType":5,"region":3) which the string-typed tag DTO cannot bind, so the factory throws. + [Fact] + public void Factory_throws_on_the_numeric_enum_form_the_pre_fix_page_emitted() + { + const string numericBlob = + "{\"host\":\"10.0.0.5\",\"port\":502,\"unitId\":1,\"tags\":[" + + "{\"name\":\"Int64Tag\",\"region\":3,\"address\":100,\"dataType\":5}]}"; + Should.Throw(() => ModbusDriverFactoryExtensions.CreateInstance("mb-test", numericBlob)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverConfigEnumSerializationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverConfigEnumSerializationTests.cs new file mode 100644 index 00000000..feda2cf0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverConfigEnumSerializationTests.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Regression guard for the 2026-06-19 enum-serialization bug. The AdminUI S7 page now +/// serialises as a STRING (its _jsonOpts gained a +/// ). This proves the factory parses that AdminUI-shaped +/// blob and round-trips the enum, and documents that the pre-fix NUMERIC form — which the old +/// page emitted — threw because S7DriverConfigDto.CpuType is string?. +/// +public sealed class S7DriverConfigEnumSerializationTests +{ + // Mirrors the (now fixed) AdminUI S7DriverPage._jsonOpts: camelCase + string enums. + private static readonly JsonSerializerOptions _adminPageOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() }, + }; + + /// Verifies the factory parses an AdminUI-authored (string-enum) S7 config and + /// preserves the CpuType. + [Fact] + public void Factory_parses_admin_authored_string_enum_config() + { + var opts = new S7DriverOptions { Host = "10.0.0.5", Port = 102, CpuType = S7CpuType.S71500, Rack = 0, Slot = 1 }; + var blob = JsonSerializer.Serialize(opts, _adminPageOpts); + // The fixed AdminUI page must emit the enum as a string, not a number. + blob.ShouldContain("\"cpuType\":\"S71500\""); + + var parsed = S7DriverFactoryExtensions.ParseOptions("s7-test", blob); + parsed.CpuType.ShouldBe(S7CpuType.S71500); + } + + /// Documents the original bug: the pre-fix AdminUI page emitted a numeric enum + /// ("cpuType":40) which the string-typed config DTO cannot bind, so the factory throws. + [Fact] + public void Factory_throws_on_the_numeric_enum_form_the_pre_fix_page_emitted() + { + // 40 == (int)S7CpuType.S71500 — exactly what the pre-fix page (no converter) wrote for S71500. + // The throw comes from binding a JSON number to the DTO's string? CpuType, so it fires for any number. + const string numericBlob = "{\"host\":\"10.0.0.5\",\"port\":102,\"cpuType\":40,\"rack\":0,\"slot\":1}"; + Should.Throw(() => S7DriverFactoryExtensions.ParseOptions("s7-test", numericBlob)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs new file mode 100644 index 00000000..8ede8c2a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/DriverPageJsonConverterTests.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19. +/// Every *DriverPage serialised enum config fields (e.g. S7 CpuType, Modbus +/// DataType/Region, AbCip PlcFamily) as JSON numbers because its +/// private static _jsonOpts had no . The driver +/// factories, however, deserialise into string-typed DTOs (+ lenient ParseEnum) and +/// throw when binding a JSON number to a string? — so an AdminUI-authored +/// config that contained any enum field produced a blob the driver could not parse, faulting +/// the driver on deploy. The fix makes every page serialise enums as strings (matching the +/// factory + the long-correct OpcUaClient template). This test fails if any driver page loses +/// its string-enum converter again. +/// +public sealed class DriverPageJsonConverterTests +{ + /// Every concrete *DriverPage in the AdminUI assembly that declares a + /// _jsonOpts config serializer. + private static IReadOnlyList DriverPageTypes { get; } = + typeof(S7DriverPage).Assembly.GetTypes() + .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract) + .Where(t => t.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is not null) + .OrderBy(t => t.Name) + .ToList(); + + /// xUnit theory source over the driver-page types discovered by reflection. + public static TheoryData DriverPagesWithJsonOpts() + { + var data = new TheoryData(); + foreach (var t in DriverPageTypes) + data.Add(t); + return data; + } + + /// Verifies every driver page's config serializer registers a string-enum converter so + /// enum config fields round-trip as strings (and the driver factory can parse the result). + /// A driver page component type discovered by reflection. + [Theory] + [MemberData(nameof(DriverPagesWithJsonOpts))] + public void Driver_page_json_options_register_string_enum_converter(Type pageType) + { + var field = pageType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static); + var opts = (JsonSerializerOptions)field!.GetValue(null)!; + opts.Converters.OfType().ShouldNotBeEmpty( + $"{pageType.Name}._jsonOpts must register a JsonStringEnumConverter; otherwise AdminUI-authored " + + "enum config fields serialise as numbers and the string-typed driver factory throws on parse."); + } + + /// Enforces that EVERY concrete *DriverPage routes config serialization through a + /// _jsonOpts field — otherwise a new page that serialised config a different way would slip + /// past the converter guard above. Also a floor check so a rename can't silently shrink the set. + [Fact] + public void Every_driver_page_uses_a_guarded_jsonOpts_serializer() + { + var allDriverPages = typeof(S7DriverPage).Assembly.GetTypes() + .Where(t => t.Name.EndsWith("DriverPage", StringComparison.Ordinal) && !t.IsAbstract) + .ToList(); + allDriverPages.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-page fleet"); + DriverPageTypes.Count.ShouldBe(allDriverPages.Count, + "every *DriverPage must declare a _jsonOpts config serializer so the string-enum converter guard covers it"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs index 6dbb1367..ceee9a94 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeHandshakeE2eTests.cs @@ -172,8 +172,16 @@ public sealed class DriverProbeHandshakeE2eTests public async Task AbCip_Green_AgainstSim() { SkipUnless(DockerHost, AbCipPort); + // Probe an explicit tag that the ab_server ControlLogix sim actually defines + // (`TestDINT:DINT[1]`). The no-tags fallback (`@raw_cpu_type`) is NOT exercised here: + // ab_server answers an unknown/unsupported tag with libplctag ErrorBadParam (a REAL + // ControlLogix instead returns ErrorNotFound, which the probe classifies as + // reachable). Whether the `@raw_cpu_type` system-tag fallback is valid on a real + // ControlLogix is a hardware-gated follow-up (AbCipDriverOptions.cs flags it deferred). var result = await new AbCipDriverProbe().ProbeAsync( - $"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]}}", Timeout, Ct); + $"{{\"Devices\":[{{\"HostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\"}}]," + + $"\"Tags\":[{{\"DeviceHostAddress\":\"ab://{DockerHost}:{AbCipPort}/1,0\",\"TagPath\":\"TestDINT\"}}]}}", + Timeout, Ct); result.Ok.ShouldBeTrue($"Probe message: {result.Message}"); result.Message!.ShouldContain("CIP session OK"); }