using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; /// /// Regression guard for the systemic driver-config enum-serialization bug found 2026-06-19. /// Every driver-config editor serialised enum config fields (e.g. S7 CpuType, Modbus /// Family, AbCip PlcFamily) as JSON numbers when 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 /// editor serialise enums as strings (matching the factory + the long-correct OpcUaClient template). /// v3 Batch-2 (WP3): the config serializer moved from the routed *DriverPage into the /// embeddable *DriverForm body (shared by the page + the raw-tree DriverConfigModal), so this /// guard now reflects over the *DriverForm fleet. It fails if any driver form loses its /// string-enum converter again. /// public sealed class DriverPageJsonConverterTests { /// /// Driver forms whose config serializer does NOT live in a private static _jsonOpts field /// on the form, mapped to the options instance they actually use. Each entry is an explicit, /// reviewed exception — the guard below still asserts the converter on whatever instance is /// named here, so this narrows where the options live, never whether they are /// checked. /// MqttDriverForm: the MQTT driver deliberately keeps one /// for all five of its config seams (runtime factory, /// Test-connect probe, driver re-parse, address-picker browser, this form) in /// MqttJson.Options — a per-form copy would be a fifth divergent instance, i.e. exactly /// the bug this whole file guards. The form itself is a shell over the pure /// MqttDriverFormModel, which serialises through that shared instance. /// private static IReadOnlyDictionary ExternalConfigSerializers { get; } = new Dictionary { [typeof(MqttDriverForm)] = MqttJson.Options, }; /// Every concrete *DriverForm in the AdminUI assembly. private static IReadOnlyList DriverFormTypes { get; } = typeof(ModbusDriverForm).Assembly.GetTypes() .Where(t => t.Name.EndsWith("DriverForm", StringComparison.Ordinal) && !t.IsAbstract) .OrderBy(t => t.Name) .ToList(); /// Resolves a form's config serializer — its own _jsonOpts field, else the /// explicitly-registered external instance — or null when it has neither. /// A driver form component type. /// The options the form serialises config through, or null. private static JsonSerializerOptions? ResolveConfigSerializer(Type formType) { if (formType.GetField("_jsonOpts", BindingFlags.NonPublic | BindingFlags.Static) is { } field) { return (JsonSerializerOptions?)field.GetValue(null); } return ExternalConfigSerializers.GetValueOrDefault(formType); } /// xUnit theory source over the driver-form types discovered by reflection. public static TheoryData DriverFormsWithJsonOpts() { var data = new TheoryData(); foreach (var t in DriverFormTypes) data.Add(t); return data; } /// Verifies every driver form'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 form component type discovered by reflection. [Theory] [MemberData(nameof(DriverFormsWithJsonOpts))] public void Driver_form_json_options_register_string_enum_converter(Type formType) { var opts = ResolveConfigSerializer(formType); opts.ShouldNotBeNull( $"{formType.Name} must serialise config through a private static _jsonOpts field, or be listed in " + "ExternalConfigSerializers with the shared options instance it uses — otherwise its enum handling " + "is unguarded."); opts.Converters.OfType().ShouldNotBeEmpty( $"{formType.Name}'s config serializer 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 *DriverForm routes config serialization through a /// serializer the guard above can reach — otherwise a new form that serialised config a different way /// would slip past it. Also a floor check so a rename can't silently shrink the set. [Fact] public void Every_driver_form_uses_a_guarded_jsonOpts_serializer() { DriverFormTypes.Count.ShouldBeGreaterThanOrEqualTo(9, "reflection should discover the full driver-form fleet"); var unguarded = DriverFormTypes.Where(t => ResolveConfigSerializer(t) is null).Select(t => t.Name).ToList(); unguarded.ShouldBeEmpty( "every *DriverForm must expose its config serializer (a _jsonOpts field, or an ExternalConfigSerializers " + "entry) so the string-enum converter guard covers it"); } }