diff --git a/docs/drivers/AbCip.md b/docs/drivers/AbCip.md index ff97cbb4..d3c6e69b 100644 --- a/docs/drivers/AbCip.md +++ b/docs/drivers/AbCip.md @@ -76,7 +76,7 @@ the driver's `DriverConfig` JSON. Key fields: - **`Devices`** — one `AbCipDeviceOptions` per PLC (`HostAddress`, `PlcFamily`, optional `DeviceName`, per-device `AllowPacking` / `ConnectionSize` overrides). - **`Tags`** — pre-declared `AbCipTagDefinition` list; `Members` for UDT fan-out, `SafetyTag` for GuardLogix safety-partition tags. - **`Probe`** — connectivity-probe `Enabled` / `Interval` / `Timeout` / `ProbeTagPath`. A driver created from the AdminUI starts with the probe **off** (an enabled probe with no tag path only logs a warning); an absent `probe` key in a stored config still means enabled to the driver. -- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). +- AdminUI driver forms (AbCip/AbLegacy) overlay only the keys they edit onto the stored JSON, so unmodelled keys survive a save (#576). They also write durations as the integer-millisecond keys the factory binds (`timeoutMs`, `alarmPollIntervalMs`, `probe.intervalMs`, `probe.timeoutMs`); a legacy TimeSpan-string key (`timeout`: "00:00:05") is loaded and migrated on save. - **`Transport`** — absent (default ⇒ `Native`), `Native`, or `Libplctag` (fallback); see [Transport](#transport-transport-option). - **Discovery** — `EnableControllerBrowse` (`@tags` walk) and `EnableDeclarationOnlyUdtGrouping` (whole-UDT read fast path). - **Alarms** — `EnableAlarmProjection` + `AlarmPollInterval` for the ALMD projection. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj index 59436b5e..8a42ca70 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj index b4fe82f3..fa98205f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs index 09901ccd..ddc10468 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigJsonOverlay.cs @@ -16,15 +16,37 @@ internal static class DriverConfigJsonOverlay /// The stored config as loaded; blank or non-object input yields unchanged. /// The form's own serialised options. /// Dot-separated, case-insensitive property paths the form edits (e.g. transport, probe.enabled). - public static string Apply(string? originalJson, string typedJson, IEnumerable ownedPaths) + /// + /// (TimeSpan-string path, integer-millisecond path) pairs. The typed options serialise durations as + /// "00:00:05" but the driver factories bind *Ms integers (#576); each pair is rewritten + /// in the typed output (and the legacy TimeSpan path becomes owned, so a stale one is removed). + /// + public static string Apply(string? originalJson, string typedJson, IEnumerable ownedPaths, + IReadOnlyList<(string TimeSpanPath, string MsPath)>? timeSpanToMs = null) { - if (string.IsNullOrWhiteSpace(originalJson)) return typedJson; - JsonObject? original; - try { original = JsonNode.Parse(originalJson) as JsonObject; } - catch (JsonException) { return typedJson; } - if (original is null || JsonNode.Parse(typedJson) is not JsonObject typed) return typedJson; + if (JsonNode.Parse(typedJson) is not JsonObject typed) return typedJson; + var owned = ownedPaths.ToList(); + foreach (var (tsPath, msPath) in timeSpanToMs ?? []) + { + owned.Add(tsPath); + owned.Add(msPath); + if (Get(typed, tsPath) is JsonValue v && v.TryGetValue(out var text) + && TimeSpan.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, out var ts)) + { + RemovePath(typed, tsPath); + Set(typed, msPath, (int)ts.TotalMilliseconds); + } + } - foreach (var path in ownedPaths) + JsonObject? original = null; + if (!string.IsNullOrWhiteSpace(originalJson)) + { + try { original = JsonNode.Parse(originalJson) as JsonObject; } + catch (JsonException) { /* unusable original: fall through to the typed output */ } + } + if (original is null) return typed.ToJsonString(); + + foreach (var path in owned) { var segments = path.Split('.'); JsonObject? from = typed; @@ -50,6 +72,35 @@ internal static class DriverConfigJsonOverlay return original.ToJsonString(); } + private static JsonNode? Get(JsonObject root, string path) + { + JsonNode? cur = root; + foreach (var seg in path.Split('.')) + cur = cur is JsonObject o ? Find(o, seg) : null; + return cur; + } + + private static void RemovePath(JsonObject root, string path) + { + var segs = path.Split('.'); + JsonObject? cur = root; + for (var i = 0; i < segs.Length - 1 && cur is not null; i++) + cur = Find(cur, segs[i]) as JsonObject; + if (cur is not null) Remove(cur, segs[^1]); + } + + private static void Set(JsonObject root, string path, JsonNode value) + { + var segs = path.Split('.'); + var cur = root; + for (var i = 0; i < segs.Length - 1; i++) + { + if (Find(cur, segs[i]) is not JsonObject next) { next = []; cur[segs[i]] = next; } + cur = next; + } + cur[segs[^1]] = value; + } + private static JsonNode? Find(JsonObject o, string key) { foreach (var kv in o) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigMs.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigMs.cs new file mode 100644 index 00000000..34250323 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigMs.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers; + +/// +/// Shared millisecond helpers for the AB forms (#576): the driver factories bind *Ms integers +/// while the forms edit whole seconds, so a sub-second stored value must survive an edit of an +/// unrelated field. +/// +internal static class DriverConfigMs +{ + /// Reads an integer-millisecond key at from , or null. + public static int? Read(string? json, string path) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try + { + JsonNode? cur = JsonNode.Parse(json); + foreach (var seg in path.Split('.')) + { + if (cur is not JsonObject o) return null; + cur = o.FirstOrDefault(kv => string.Equals(kv.Key, seg, StringComparison.OrdinalIgnoreCase)).Value; + } + return cur is JsonValue v && v.TryGetValue(out var ms) ? ms : null; + } + catch (JsonException) { return null; } + } + + /// Whole seconds for display (truncating, like the pre-#576 forms). + public static int ToSeconds(TimeSpan t) => (int)t.TotalSeconds; + + /// + /// The duration to save: the loaded value when the operator left the seconds field unchanged + /// (keeps 500 ms from collapsing to 0 s), else the edited whole seconds. + /// + public static TimeSpan Save(int seconds, TimeSpan? loaded) + => loaded is { } l && ToSeconds(l) == seconds ? l : TimeSpan.FromSeconds(seconds); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor index e3e765c7..093822d8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor @@ -139,9 +139,16 @@ /// Top-level/nested JSON paths this form edits; everything else in the stored config is preserved. internal static readonly string[] OwnedPaths = [ - "timeout", "transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping", - "enableAlarmProjection", "alarmPollInterval", "maxConcurrentReads", "probeTimeoutSeconds", - "probe.enabled", "probe.interval", "probe.timeout", "probe.probeTagPath", + "transport", "enableControllerBrowse", "enableDeclarationOnlyUdtGrouping", + "enableAlarmProjection", "maxConcurrentReads", "probeTimeoutSeconds", + "probe.enabled", "probe.probeTagPath", + ]; + + /// The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576). + internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs = + [ + ("timeout", "timeoutMs"), ("alarmPollInterval", "alarmPollIntervalMs"), + ("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"), ]; // Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints; @@ -154,7 +161,7 @@ if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) { var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions(); - _form = FormModel.FromOptions(opts); + _form = FormModel.FromJson(DriverConfigJson, opts); // #577: a brand-new driver (nothing authored yet) must not default the probe ON with no tag path — // that logs an operator warning per driver and runs no probe. An existing config keeps whatever // it stores (an absent "probe" key still means "enabled" to the driver, so don't flip it on edit). @@ -173,7 +180,7 @@ /// Serializes options with the form's JSON settings (camelCase, enums as strings). internal static string Serialize(AbCipDriverOptions options, string? originalJson = null) - => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths); + => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs); private async Task EmitAsync() { @@ -222,8 +229,31 @@ // Admin UI probe timeout public int AdminProbeTimeoutSeconds { get; set; } = 5; + // The loaded durations, so an unchanged seconds field saves the exact stored value (sub-second safe). + private TimeSpan? _loadedTimeout, _loadedAlarmPoll, _loadedProbeInterval, _loadedProbeTimeout; + + /// Loads from the stored JSON: the factory's *Ms keys win; a legacy TimeSpan-string key is the fallback. + public static FormModel FromJson(string? json, AbCipDriverOptions o) + { + static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null; + var f = FromOptions(o); + f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout; + f._loadedAlarmPoll = Ms(json, "alarmPollIntervalMs") ?? f._loadedAlarmPoll; + f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval; + f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout; + f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout); + f.AlarmPollIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedAlarmPoll ?? o.AlarmPollInterval); + f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval); + f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout); + return f; + } + public static FormModel FromOptions(AbCipDriverOptions o) => new() { + _loadedTimeout = o.Timeout, + _loadedAlarmPoll = o.AlarmPollInterval, + _loadedProbeInterval = o.Probe.Interval, + _loadedProbeTimeout = o.Probe.Timeout, TimeoutSeconds = (int)o.Timeout.TotalSeconds, Transport = o.Transport, MaxConcurrentReads = o.MaxConcurrentReads, @@ -245,16 +275,16 @@ Probe = new AbCipProbeOptions { Enabled = ProbeEnabled, - Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), - Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval), + Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout), ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath, }, - Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout), Transport = Transport, MaxConcurrentReads = MaxConcurrentReads, EnableControllerBrowse = EnableControllerBrowse, EnableAlarmProjection = EnableAlarmProjection, - AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds), + AlarmPollInterval = DriverConfigMs.Save(AlarmPollIntervalSeconds, _loadedAlarmPoll), EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor index 972648de..9db2085a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -97,8 +97,14 @@ /// JSON paths this form edits; everything else in the stored config is preserved. internal static readonly string[] OwnedPaths = [ - "timeout", "transport", "probeTimeoutSeconds", - "probe.enabled", "probe.interval", "probe.timeout", "probe.probeAddress", + "transport", "probeTimeoutSeconds", + "probe.enabled", "probe.probeAddress", + ]; + + /// The factory binds integer-millisecond keys, not the TimeSpan strings the typed options serialise (#576). + internal static readonly (string TimeSpanPath, string MsPath)[] TimeSpanToMs = + [ + ("timeout", "timeoutMs"), ("probe.interval", "probe.intervalMs"), ("probe.timeout", "probe.timeoutMs"), ]; // Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing @@ -111,7 +117,7 @@ if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal)) { var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions(); - _form = FormModel.FromOptions(opts); + _form = FormModel.FromJson(DriverConfigJson, opts); _originalJson = DriverConfigJson; _preservedDevices = opts.Devices; _lastParsed = DriverConfigJson; @@ -125,7 +131,7 @@ /// Serializes options with the form's JSON settings (camelCase, enums as strings). internal static string Serialize(AbLegacyDriverOptions options, string? originalJson = null) - => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths); + => DriverConfigJsonOverlay.Apply(originalJson, JsonSerializer.Serialize(options, _jsonOpts), OwnedPaths, TimeSpanToMs); private async Task EmitAsync() { @@ -171,8 +177,27 @@ public string? ResilienceConfig { get; set; } public byte[] RowVersion { get; set; } = []; + private TimeSpan? _loadedTimeout, _loadedProbeInterval, _loadedProbeTimeout; + + /// Loads from the stored JSON: the factory's *Ms keys win; a legacy TimeSpan-string key is the fallback. + public static FormModel FromJson(string? json, AbLegacyDriverOptions o) + { + static TimeSpan? Ms(string? j, string path) => DriverConfigMs.Read(j, path) is int ms ? TimeSpan.FromMilliseconds(ms) : null; + var f = FromOptions(o); + f._loadedTimeout = Ms(json, "timeoutMs") ?? f._loadedTimeout; + f._loadedProbeInterval = Ms(json, "probe.intervalMs") ?? f._loadedProbeInterval; + f._loadedProbeTimeout = Ms(json, "probe.timeoutMs") ?? f._loadedProbeTimeout; + f.TimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedTimeout ?? o.Timeout); + f.ProbeIntervalSeconds = DriverConfigMs.ToSeconds(f._loadedProbeInterval ?? o.Probe.Interval); + f.ProbeTimeoutSeconds = DriverConfigMs.ToSeconds(f._loadedProbeTimeout ?? o.Probe.Timeout); + return f; + } + public static FormModel FromOptions(AbLegacyDriverOptions o) => new() { + _loadedTimeout = o.Timeout, + _loadedProbeInterval = o.Probe.Interval, + _loadedProbeTimeout = o.Probe.Timeout, TimeoutSeconds = (int)o.Timeout.TotalSeconds, Transport = o.Transport, ProbeEnabled = o.Probe.Enabled, @@ -189,11 +214,11 @@ Probe = new AbLegacyProbeOptions { Enabled = ProbeEnabled, - Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds), - Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds), + Interval = DriverConfigMs.Save(ProbeIntervalSeconds, _loadedProbeInterval), + Timeout = DriverConfigMs.Save(ProbeTimeoutSeconds, _loadedProbeTimeout), ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress, }, - Timeout = TimeSpan.FromSeconds(TimeoutSeconds), + Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout), Transport = Transport, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs index 426d0bb4..9ac7ee98 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbCipDriverPageFormSerializationTests.cs @@ -141,7 +141,7 @@ public sealed class AbCipDriverPageFormSerializationTests var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices)); json.ShouldContain($"\"transport\":\"{savedValue}\""); - json.ShouldContain("\"timeout\":\"00:00:07\""); + json.ShouldContain("\"timeoutMs\":7000"); } [Fact] @@ -158,7 +158,7 @@ public sealed class AbCipDriverPageFormSerializationTests form.TimeoutSeconds = 7; var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices)); json.ShouldNotContain("transport", Case.Insensitive); - json.ShouldContain("\"timeout\":\"00:00:07\""); + json.ShouldContain("\"timeoutMs\":7000"); } [Fact] @@ -195,17 +195,18 @@ public sealed class AbCipDriverPageFormSerializationTests var root = System.Text.Json.Nodes.JsonNode.Parse( AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), FullConfig))!.AsObject(); - root["timeout"]!.GetValue().ShouldBe("00:00:09"); + root["timeoutMs"]!.GetValue().ShouldBe(9000); + root.ContainsKey("timeout").ShouldBeFalse(); root["maxConcurrentReads"]!.GetValue().ShouldBe(3); // was reset to 8 before #576 root["transport"]!.GetValue().ShouldBe("Native"); root["enableControllerBrowse"]!.GetValue().ShouldBeTrue(); root["enableDeclarationOnlyUdtGrouping"]!.GetValue().ShouldBeTrue(); root["enableAlarmProjection"]!.GetValue().ShouldBeTrue(); - root["alarmPollInterval"]!.GetValue().ShouldBe("00:00:02"); + root["alarmPollIntervalMs"]!.GetValue().ShouldBe(2000); root["probeTimeoutSeconds"]!.GetValue().ShouldBe(12); root["probe"]!["enabled"]!.GetValue().ShouldBeTrue(); - root["probe"]!["interval"]!.GetValue().ShouldBe("00:00:10"); - root["probe"]!["timeout"]!.GetValue().ShouldBe("00:00:03"); + root["probe"]!["intervalMs"]!.GetValue().ShouldBe(10000); + root["probe"]!["timeoutMs"]!.GetValue().ShouldBe(3000); root["probe"]!["probeTagPath"]!.GetValue().ShouldBe("Program:Main.Hb"); // unknown keys at every level survive root["probe"]!["futureProbeKnob"]!.GetValue().ShouldBe(7); @@ -245,4 +246,64 @@ public sealed class AbCipDriverPageFormSerializationTests json.ShouldContain("\"transport\":\"Libplctag\""); json.ShouldContain("futureOption"); } + + // ---- #576: the saved JSON must be what the driver FACTORY binds (integer-ms keys) ---- + + [Fact] + public void An_edited_timeout_reaches_the_factory_bound_options() + { + var original = """{"timeoutMs":5000,"alarmPollIntervalMs":500,"probe":{"enabled":true,"intervalMs":7000,"timeoutMs":1500,"probeTagPath":"Program:Main.Hb"},"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}]}"""; + var loaded = AbCipDriverForm.TryDeserialize(original)!; + var form = AbCipDriverForm.FormModel.FromJson(original, loaded); + form.TimeoutSeconds.ShouldBe(5); // the form SEES the factory-keyed value + form.TimeoutSeconds = 11; + + var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original); + var bound = ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriverFactoryExtensions.ParseOptions("t", saved); + + bound.Timeout.ShouldBe(TimeSpan.FromSeconds(11)); + // untouched durations keep their exact stored value, including sub-second ones + bound.AlarmPollInterval.ShouldBe(TimeSpan.FromMilliseconds(500)); + bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(7)); + bound.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500)); + bound.Probe.ProbeTagPath.ShouldBe("Program:Main.Hb"); + } + + [Fact] + public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed() + { + var original = """{"timeout":"00:00:05","alarmPollInterval":"00:00:02","probe":{"interval":"00:00:10","timeout":"00:00:03"}}"""; + var loaded = AbCipDriverForm.TryDeserialize(original)!; + var form = AbCipDriverForm.FormModel.FromJson(original, loaded); + form.TimeoutSeconds.ShouldBe(5); + form.TimeoutSeconds = 6; + + var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original); + var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject(); + root.ContainsKey("timeout").ShouldBeFalse(); + root.ContainsKey("alarmPollInterval").ShouldBeFalse(); + root["probe"]!.AsObject().ContainsKey("interval").ShouldBeFalse(); + root["probe"]!.AsObject().ContainsKey("timeout").ShouldBeFalse(); + + var bound = ZB.MOM.WW.OtOpcUa.Driver.AbCip.AbCipDriverFactoryExtensions.ParseOptions("t", saved); + bound.Timeout.ShouldBe(TimeSpan.FromSeconds(6)); + bound.AlarmPollInterval.ShouldBe(TimeSpan.FromSeconds(2)); + bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(10)); + bound.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + } + + [Fact] + public void A_form_saved_config_is_still_accepted_by_the_Test_Connect_probe_parse() + { + // The probe deserialises the typed options and skips unmapped keys, so the ms keys must not break it. + var original = """{"timeoutMs":5000,"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}]}"""; + var loaded = AbCipDriverForm.TryDeserialize(original)!; + var form = AbCipDriverForm.FormModel.FromJson(original, loaded); + form.TimeoutSeconds = 8; + var saved = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original); + + var opts = System.Text.Json.JsonSerializer.Deserialize(saved, TestJsonOpts); + opts.ShouldNotBeNull(); + opts.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0"); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index 2df3c36b..f4297127 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -137,7 +137,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices)); json.ShouldContain($"\"transport\":\"{savedValue}\""); - json.ShouldContain("\"timeout\":\"00:00:07\""); + json.ShouldContain("\"timeoutMs\":7000"); } [Fact] @@ -153,7 +153,7 @@ public sealed class AbLegacyDriverPageFormSerializationTests form.TimeoutSeconds = 7; var json = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices)); json.ShouldNotContain("transport", Case.Insensitive); - json.ShouldContain("\"timeout\":\"00:00:07\""); + json.ShouldContain("\"timeoutMs\":7000"); } [Fact] @@ -208,12 +208,13 @@ public sealed class AbLegacyDriverPageFormSerializationTests var root = System.Text.Json.Nodes.JsonNode.Parse( AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), FullConfig))!.AsObject(); - root["timeout"]!.GetValue().ShouldBe("00:00:09"); + root["timeoutMs"]!.GetValue().ShouldBe(9000); + root.ContainsKey("timeout").ShouldBeFalse(); root["transport"]!.GetValue().ShouldBe("Native"); root["probeTimeoutSeconds"]!.GetValue().ShouldBe(12); root["probe"]!["enabled"]!.GetValue().ShouldBeTrue(); - root["probe"]!["interval"]!.GetValue().ShouldBe("00:00:10"); - root["probe"]!["timeout"]!.GetValue().ShouldBe("00:00:03"); + root["probe"]!["intervalMs"]!.GetValue().ShouldBe(10000); + root["probe"]!["timeoutMs"]!.GetValue().ShouldBe(3000); root["probe"]!["probeAddress"]!.GetValue().ShouldBe("S:1"); root["probe"]!["futureProbeKnob"]!.GetValue().ShouldBe(7); root["devices"]![0]!["futureDeviceKey"]!.GetValue().ShouldBe("keep"); @@ -234,4 +235,45 @@ public sealed class AbLegacyDriverPageFormSerializationTests root.ContainsKey("transport").ShouldBeFalse(); root["futureOption"].ShouldNotBeNull(); } + + // ---- #576: the saved JSON must be what the driver FACTORY binds (integer-ms keys) ---- + + [Fact] + public void An_edited_timeout_reaches_the_factory_bound_options() + { + var original = """{"timeoutMs":5000,"probe":{"enabled":true,"intervalMs":7000,"timeoutMs":1500,"probeAddress":"S:1"},"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Slc500"}]}"""; + var loaded = AbLegacyDriverForm.TryDeserialize(original)!; + var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded); + form.TimeoutSeconds.ShouldBe(5); + form.TimeoutSeconds = 11; + + var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original); + var bound = ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved); + + bound.Timeout.ShouldBe(TimeSpan.FromSeconds(11)); + bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(7)); + bound.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(1500)); + bound.Probe.ProbeAddress.ShouldBe("S:1"); + } + + [Fact] + public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed() + { + var original = """{"timeout":"00:00:05","probe":{"interval":"00:00:10","timeout":"00:00:03"}}"""; + var loaded = AbLegacyDriverForm.TryDeserialize(original)!; + var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded); + form.TimeoutSeconds.ShouldBe(5); + form.TimeoutSeconds = 6; + + var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original); + var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject(); + root.ContainsKey("timeout").ShouldBeFalse(); + root["probe"]!.AsObject().ContainsKey("interval").ShouldBeFalse(); + root["probe"]!.AsObject().ContainsKey("timeout").ShouldBeFalse(); + + var bound = ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved); + bound.Timeout.ShouldBe(TimeSpan.FromSeconds(6)); + bound.Probe.Interval.ShouldBe(TimeSpan.FromSeconds(10)); + bound.Probe.Timeout.ShouldBe(TimeSpan.FromSeconds(3)); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index ba5aba32..742ab993 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -37,6 +37,9 @@ project referencing a runtime driver is fine — tests are not in the AdminUI's deploy graph, which is the whole thing #526 was about. --> + + +