fix(abcip,adminui): ParseEnum device wording + libplctag probe assertion (#574); AB forms preserve unknown keys + MaxConcurrentReads (#576); probe off for new AbCip driver (#577); docs T4:0.DN (#584)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 22:53:04 -04:00
parent 04b6563286
commit e588443ced
9 changed files with 251 additions and 15 deletions
@@ -172,4 +172,77 @@ public sealed class AbCipDriverPageFormSerializationTests
form.Transport = null; // the "Default (...)" dropdown option
AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices)).ShouldNotContain("transport", Case.Insensitive);
}
// ---- #576: preserve-unknown + MaxConcurrentReads ----
private const string FullConfig = """
{"transport":"Native","timeout":"00:00:05","maxConcurrentReads":3,"enableControllerBrowse":true,
"enableDeclarationOnlyUdtGrouping":true,"enableAlarmProjection":true,"alarmPollInterval":"00:00:02",
"probeTimeoutSeconds":12,
"probe":{"enabled":true,"interval":"00:00:10","timeout":"00:00:03","probeTagPath":"Program:Main.Hb","futureProbeKnob":7},
"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"ControlLogix","deviceName":"PLC-1","futureDeviceKey":"keep"}],
"rawTags":[{"rawPath":"a/b"}],
"futureOption":{"nested":[1,2,3]},"anotherUnknown":"x"}
""";
[Fact]
public void Editing_one_field_preserves_every_other_option_and_unknown_keys()
{
var loaded = AbCipDriverForm.TryDeserialize(FullConfig)!;
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
form.TimeoutSeconds = 9; // the one edit
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["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["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"]!["probeTagPath"]!.GetValue<string>().ShouldBe("Program:Main.Hb");
// unknown keys at every level survive
root["probe"]!["futureProbeKnob"]!.GetValue<int>().ShouldBe(7);
root["devices"]![0]!["futureDeviceKey"]!.GetValue<string>().ShouldBe("keep");
root["rawTags"]![0]!["rawPath"]!.GetValue<string>().ShouldBe("a/b");
root["futureOption"]!["nested"]!.AsArray().Count.ShouldBe(3);
root["anotherUnknown"]!.GetValue<string>().ShouldBe("x");
}
[Fact]
public void Choosing_Default_transport_and_clearing_the_probe_path_removes_those_keys_from_the_overlay()
{
var loaded = AbCipDriverForm.TryDeserialize(FullConfig)!;
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
form.Transport = null;
form.ProbeTagPath = "";
var root = System.Text.Json.Nodes.JsonNode.Parse(
AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), FullConfig))!.AsObject();
root.ContainsKey("transport").ShouldBeFalse();
root["probe"]!.AsObject().ContainsKey("probeTagPath").ShouldBeFalse();
root["futureOption"].ShouldNotBeNull();
}
[Fact]
public void A_stored_PascalCase_key_is_replaced_not_duplicated()
{
var original = """{"Transport":"Native","TimeoutSeconds":1,"futureOption":true}""";
var loaded = AbCipDriverForm.TryDeserialize(original)!;
var form = AbCipDriverForm.FormModel.FromOptions(loaded);
form.Transport = AbCipTransport.Libplctag;
var json = AbCipDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
System.Text.RegularExpressions.Regex.Matches(json, "transport", System.Text.RegularExpressions.RegexOptions.IgnoreCase)
.Count.ShouldBe(1);
json.ShouldContain("\"transport\":\"Libplctag\"");
json.ShouldContain("futureOption");
}
}
@@ -187,4 +187,51 @@ public sealed class AbLegacyDriverPageFormSerializationTests
razor.ShouldContain("<option value=\"@AbLegacyTransport.Libplctag\">libplctag</option>");
razor.ShouldNotContain("Default (Native");
}
// ---- #576: preserve-unknown ----
private const string FullConfig = """
{"transport":"Native","timeout":"00:00:05","probeTimeoutSeconds":12,
"probe":{"enabled":true,"interval":"00:00:10","timeout":"00:00:03","probeAddress":"S:1","futureProbeKnob":7},
"devices":[{"hostAddress":"ab://10.0.0.1/1,0","plcFamily":"Slc500","futureDeviceKey":"keep"}],
"rawTags":[{"rawPath":"a/b"}],
"futureOption":{"nested":[1,2,3]}}
""";
[Fact]
public void Editing_one_field_preserves_every_other_option_and_unknown_keys()
{
var loaded = AbLegacyDriverForm.TryDeserialize(FullConfig)!;
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
form.TimeoutSeconds = 9;
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["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"]!["probeAddress"]!.GetValue<string>().ShouldBe("S:1");
root["probe"]!["futureProbeKnob"]!.GetValue<int>().ShouldBe(7);
root["devices"]![0]!["futureDeviceKey"]!.GetValue<string>().ShouldBe("keep");
root["rawTags"]![0]!["rawPath"]!.GetValue<string>().ShouldBe("a/b");
root["futureOption"]!["nested"]!.AsArray().Count.ShouldBe(3);
}
[Fact]
public void Choosing_Default_transport_removes_the_key_but_keeps_unknown_ones()
{
var loaded = AbLegacyDriverForm.TryDeserialize(FullConfig)!;
var form = AbLegacyDriverForm.FormModel.FromOptions(loaded);
form.Transport = null;
var root = System.Text.Json.Nodes.JsonNode.Parse(
AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), FullConfig))!.AsObject();
root.ContainsKey("transport").ShouldBeFalse();
root["futureOption"].ShouldNotBeNull();
}
}