fix(ablegacy): final review I3 — default reverts to libplctag; Native is opt-in

On a wire the native PCCC path is proven only for N-file reads/writes and
one F8 read (ab_server). An absent transport key now resolves to Libplctag
for every family; explicit Native still wins and still warns once per
instance on PLC-5/LogixPccc. AdminUI dropdown, CLI help, evidence claims
(PcccFileType, AbLegacy.md wire-evidence table, release note) corrected:
S:0 (default probe) and B/T/C/R/S/L/ST/A/O/I are unverified natively.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 21:34:04 -04:00
parent a9fc933e3b
commit b66972b6f5
12 changed files with 166 additions and 130 deletions
@@ -162,13 +162,12 @@ public sealed class BuildOptionsTests
}
/// <summary>
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: Native
/// for an SLC 500, libplctag for <c>--plc-type Plc5</c> / LogixPccc (Task 22 exclusion). An explicit
/// choice is forwarded verbatim and wins.
/// With no <c>--transport</c> the CLI leaves the option null and the driver's resolver decides: libplctag
/// for every family (final review I3; Native is opt-in). An explicit choice is forwarded verbatim and wins.
/// </summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Native)]
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Native)]
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)]
public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected)
@@ -186,13 +185,15 @@ public sealed class BuildOptionsTests
}
/// <summary>
/// The plan's CLI case end to end through CliFx: <c>--plc-type plc5</c> with no <c>--transport</c>
/// resolves to libplctag; the same invocation for an SLC resolves Native (falsification control).
/// End to end through CliFx: no <c>--transport</c> resolves to libplctag for every <c>--plc-type</c>
/// (final review I3). The slc500 / micrologix rows are the falsification rows — they resolved Native
/// before the revert.
/// </summary>
[Theory]
[InlineData("plc5", AbLegacyTransport.Libplctag)]
[InlineData("logixpccc", AbLegacyTransport.Libplctag)]
[InlineData("slc500", AbLegacyTransport.Native)]
[InlineData("slc500", AbLegacyTransport.Libplctag)]
[InlineData("micrologix", AbLegacyTransport.Libplctag)]
public async Task Plc_type_without_transport_flag_resolves_through_the_resolver(string plcType, AbLegacyTransport expected)
{
CapturingCommand.Options = null;
@@ -15,12 +15,12 @@ public sealed class AbLegacyTransportInitLogTests
private static string Config(string? transport, string family = "Slc500") =>
$$"""{{{(transport is null ? "" : $"\"transport\":\"{transport}\",")}}"probe":{"enabled":false},"devices":[{"hostAddress":"ab://10.0.0.1:44818/1,0","plcFamily":"{{family}}"}]}""";
/// <summary>The init line names the configured-vs-effective transport (Task 22).</summary>
/// <summary>The init line names the configured-vs-effective transport (final review I3: default = Libplctag).</summary>
[Theory]
[InlineData("Native", "Slc500", "Native (configured)")]
[InlineData("Libplctag", "Slc500", "Libplctag (configured)")]
[InlineData(null, "Slc500", "Native (default)")]
[InlineData(null, "Plc5", "Libplctag (default; PLC-5/LogixPccc device present)")]
[InlineData(null, "Slc500", "Libplctag (default)")]
[InlineData(null, "Plc5", "Libplctag (default)")]
public async Task Init_logs_the_transport_through_the_registered_logger_factory(
string? json, string family, string expected)
{
@@ -37,6 +37,7 @@ public sealed class AbLegacyTransportInitLogTests
[InlineData("Native", "Plc5", true)]
[InlineData("Native", "LogixPccc", true)]
[InlineData(null, "Plc5", false)]
[InlineData(null, "Slc500", false)]
[InlineData("Libplctag", "LogixPccc", false)]
[InlineData("Native", "Slc500", false)]
public async Task Explicit_Native_on_a_limited_family_warns_once(string? json, string family, bool warns)
@@ -24,13 +24,13 @@ public sealed class AbLegacyTransportSwitchTests
/// <summary>
/// Transport values select the factory type; absent parses to null ("use the default") and the driver
/// resolves that to Native for an SLC 500 (Task 22). An explicit Libplctag still wins.
/// resolves that to Libplctag (final review I3 — Native is opt-in). An explicit Native still wins.
/// </summary>
[Theory]
[InlineData("\"Native\"", AbLegacyTransport.Native)]
[InlineData("\"native\"", AbLegacyTransport.Native)]
[InlineData("\"Libplctag\"", AbLegacyTransport.Libplctag)]
[InlineData(null, AbLegacyTransport.Native)]
[InlineData(null, AbLegacyTransport.Libplctag)]
public void Config_transport_selects_the_factory(string? json, AbLegacyTransport expected)
{
var parsed = AbLegacyDriverFactoryExtensions.ParseOptions("ab1", Config(json));
@@ -46,35 +46,42 @@ public sealed class AbLegacyTransportSwitchTests
}
/// <summary>
/// The exclusion: an absent key resolves the WHOLE driver to libplctag when any device is a PLC-5 or a
/// LogixPccc (limited native support); the factory is driver-level, so one such device among SLCs
/// is enough. The SLC/MicroLogix-only rows are the falsification control — they must resolve Native.
/// Final review I3: an absent key resolves EVERY family to libplctag — the SLC/MicroLogix rows are the
/// ones that change (they defaulted Native before), so they are the falsification rows: reverting the
/// resolver to "Native unless PLC-5/LogixPccc" turns them red.
/// </summary>
[Theory]
[InlineData(AbLegacyTransport.Libplctag, "Plc5")]
[InlineData(AbLegacyTransport.Libplctag, "LogixPccc")]
[InlineData(AbLegacyTransport.Libplctag, "Slc500", "Plc5")]
[InlineData(AbLegacyTransport.Libplctag, "MicroLogix", "LogixPccc")]
[InlineData(AbLegacyTransport.Native, "Slc500")]
[InlineData(AbLegacyTransport.Native, "MicroLogix")]
[InlineData(AbLegacyTransport.Native, "Slc500", "MicroLogix")]
public void Absent_transport_excludes_PLC5_and_LogixPccc(AbLegacyTransport expected, params string[] families)
[InlineData("Plc5")]
[InlineData("LogixPccc")]
[InlineData("Slc500", "Plc5")]
[InlineData("Slc500")]
[InlineData("MicroLogix")]
[InlineData("Slc500", "MicroLogix")]
public void Absent_transport_resolves_libplctag_for_every_family(params string[] families)
{
var json = Config(null, families);
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json)).ShouldBe(expected);
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("ab1", json))
.ShouldBe(AbLegacyTransport.Libplctag);
var driver = AbLegacyDriverFactoryExtensions.CreateInstance("ab1", json);
driver.EffectiveTransport.ShouldBe(expected);
if (expected == AbLegacyTransport.Native)
driver.TagFactory.ShouldBeOfType<NativeAbLegacyTagFactory>();
else
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
driver.EffectiveTransport.ShouldBe(AbLegacyTransport.Libplctag);
driver.TagFactory.ShouldBeOfType<LibplctagLegacyTagFactory>();
}
/// <summary>An explicit value always wins over the exclusion, in both directions.</summary>
/// <summary>The init-log rendering names the default as such, and an explicit choice as configured.</summary>
[Fact]
public void Describe_names_default_and_configured()
{
AbLegacyTransportResolver.Describe(null).ShouldBe("Libplctag (default)");
AbLegacyTransportResolver.Describe(AbLegacyTransport.Native).ShouldBe("Native (configured)");
AbLegacyTransportResolver.Describe(AbLegacyTransport.Libplctag).ShouldBe("Libplctag (configured)");
}
/// <summary>An explicit value always wins, for every family.</summary>
[Theory]
[InlineData("\"Native\"", "Plc5", AbLegacyTransport.Native)]
[InlineData("\"Native\"", "LogixPccc", AbLegacyTransport.Native)]
[InlineData("\"Native\"", "Slc500", AbLegacyTransport.Native)]
[InlineData("\"Libplctag\"", "Slc500", AbLegacyTransport.Libplctag)]
public void Explicit_transport_wins_over_the_exclusion(string json, string family, AbLegacyTransport expected)
{
@@ -171,8 +178,8 @@ public sealed class AbLegacyTransportSwitchTests
/// <summary>
/// The probe resolves an ABSENT transport through the same resolver as the driver: an SLC 500 with no
/// key takes the native path (native failure text), a PLC-5 with no key takes libplctag (no native
/// text) — against the same listener, so only the resolution differs.
/// key takes libplctag (no native failure text) exactly as a PLC-5 does, while an explicit Native on the
/// same listener produces the native text — the control that proves the text discriminates.
/// </summary>
[Fact]
public async Task Probe_with_no_transport_resolves_like_the_driver()
@@ -191,19 +198,23 @@ public sealed class AbLegacyTransportSwitchTests
string Cfg(string family) =>
$$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"{{family}}"}]}""";
var nativeCfg =
$$"""{"transport":"Native","devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0","plcFamily":"Slc500"}]}""";
var probe = new AbLegacyDriverProbe();
var slc = await probe.ProbeAsync(Cfg("Slc500"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
var plc5 = await probe.ProbeAsync(Cfg("Plc5"), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
var native = await probe.ProbeAsync(nativeCfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
listener.Stop();
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Slc500")))
.ShouldBe(AbLegacyTransport.Native);
.ShouldBe(AbLegacyTransport.Libplctag);
AbLegacyTransportResolver.Resolve(AbLegacyDriverFactoryExtensions.ParseOptions("p", Cfg("Plc5")))
.ShouldBe(AbLegacyTransport.Libplctag);
slc.Ok.ShouldBeFalse();
slc.Message!.ShouldContain("PCCC handshake failed: Connection lost");
slc.Message!.ShouldNotContain("Connection lost");
plc5.Message!.ShouldNotContain("Connection lost");
native.Message!.ShouldContain("PCCC handshake failed: Connection lost");
}
/// <summary>The probe deserialises with its own string-enum converter; a bad transport is a config error.</summary>