fix(adminui): AB forms read/write the factory-bound *Ms duration keys, migrate legacy TimeSpan strings (#576)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 23:03:38 -04:00
parent 3d081ad29c
commit bafbcbee7e
10 changed files with 288 additions and 35 deletions
@@ -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<string>().ShouldBe("00:00:09");
root["timeoutMs"]!.GetValue<int>().ShouldBe(9000);
root.ContainsKey("timeout").ShouldBeFalse();
root["maxConcurrentReads"]!.GetValue<int>().ShouldBe(3); // was reset to 8 before #576
root["transport"]!.GetValue<string>().ShouldBe("Native");
root["enableControllerBrowse"]!.GetValue<bool>().ShouldBeTrue();
root["enableDeclarationOnlyUdtGrouping"]!.GetValue<bool>().ShouldBeTrue();
root["enableAlarmProjection"]!.GetValue<bool>().ShouldBeTrue();
root["alarmPollInterval"]!.GetValue<string>().ShouldBe("00:00:02");
root["alarmPollIntervalMs"]!.GetValue<int>().ShouldBe(2000);
root["probeTimeoutSeconds"]!.GetValue<int>().ShouldBe(12);
root["probe"]!["enabled"]!.GetValue<bool>().ShouldBeTrue();
root["probe"]!["interval"]!.GetValue<string>().ShouldBe("00:00:10");
root["probe"]!["timeout"]!.GetValue<string>().ShouldBe("00:00:03");
root["probe"]!["intervalMs"]!.GetValue<int>().ShouldBe(10000);
root["probe"]!["timeoutMs"]!.GetValue<int>().ShouldBe(3000);
root["probe"]!["probeTagPath"]!.GetValue<string>().ShouldBe("Program:Main.Hb");
// unknown keys at every level survive
root["probe"]!["futureProbeKnob"]!.GetValue<int>().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<AbCipDriverOptions>(saved, TestJsonOpts);
opts.ShouldNotBeNull();
opts.Devices[0].HostAddress.ShouldBe("ab://10.0.0.1/1,0");
}
}
@@ -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<string>().ShouldBe("00:00:09");
root["timeoutMs"]!.GetValue<int>().ShouldBe(9000);
root.ContainsKey("timeout").ShouldBeFalse();
root["transport"]!.GetValue<string>().ShouldBe("Native");
root["probeTimeoutSeconds"]!.GetValue<int>().ShouldBe(12);
root["probe"]!["enabled"]!.GetValue<bool>().ShouldBeTrue();
root["probe"]!["interval"]!.GetValue<string>().ShouldBe("00:00:10");
root["probe"]!["timeout"]!.GetValue<string>().ShouldBe("00:00:03");
root["probe"]!["intervalMs"]!.GetValue<int>().ShouldBe(10000);
root["probe"]!["timeoutMs"]!.GetValue<int>().ShouldBe(3000);
root["probe"]!["probeAddress"]!.GetValue<string>().ShouldBe("S:1");
root["probe"]!["futureProbeKnob"]!.GetValue<int>().ShouldBe(7);
root["devices"]![0]!["futureDeviceKey"]!.GetValue<string>().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));
}
}
@@ -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. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<!-- #576: the AB form tests deserialise the saved JSON through the drivers' own factory parse. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
</ItemGroup>
</Project>