feat(ab): native managed transport is the default for AbCip and AbLegacy; libplctag remains as fallback

Task 22 (spec section 8 P5), with the controller rulings that exclude PLC-5 and LogixPccc.

- Transport is nullable on both option records; null (key absent) means "use the default" and is
  never serialized. Factory DTO parse: absent -> null; a bad value still fails via ParseEnum.
- One resolver per driver (AbCipTransportResolver / AbLegacyTransportResolver), used by the driver
  constructor, the Test Connect probe and (through the driver) the CLI.
  AbCip: null -> Native. AbLegacy: null -> Native unless any device is Plc5 or LogixPccc, then the
  whole driver resolves to Libplctag. Explicit values always win; explicit Native with a
  Plc5/LogixPccc device logs one Warning at init.
- Init log shows configured vs effective, e.g. transport=Native (default).
- AdminUI forms: "Default (...)" / "Native (managed)" / "libplctag (fallback)". Default omits the
  key on save (reverses Task 19's explicit re-emit, which would have pinned every edited driver).
- CLIs: --transport default|native|libplctag, default "default" via the resolver, so
  --plc-type Plc5 with no --transport runs libplctag.
- Tests pinned to Libplctag where a default-path runtime was created: found by instrumenting the
  native factories with the current xUnit test name (one unit test, AbLegacyReinitConfigAdoption;
  plus the two fixture-gated AbCip probe E2E tests whose exact messages were proven on libplctag).
  TransportMatrix and the differential harness already pass explicit transports.
- Switch/resolver/CLI/form tests are falsified by mutating the resolver (default -> Libplctag,
  exclusion removed, AbLegacy default -> Libplctag): each mutation turns them red.
- Replaces the inline role-name literal in AbCipDriverFactoryExtensions' ParseEnum call with a named
  constant; RoleAuthorityGuardTests was already red on the base commit for that line.
- Docs: AbCip.md / AbLegacy.md default + release note; CLAUDE.md AB section.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 20:49:33 -04:00
parent 17e1170e61
commit ed0881d672
29 changed files with 653 additions and 131 deletions
@@ -14,26 +14,34 @@ public sealed class AbCipTransportOptionTests
private sealed class CapturingCommand : AbCipCommandBase
{
public static AbCipTransport? Captured;
public static bool Ran;
public override ValueTask ExecuteAsync(IConsole console)
{
Ran = true;
Captured = BuildOptions([]).Transport;
return default;
}
}
/// <summary>Verifies the default is libplctag when the flag is absent.</summary>
/// <summary>
/// With no flag the CLI leaves the option null ("use the default"), and the driver's resolver makes
/// that Native — the same path an absent <c>transport</c> key takes in a deployed config.
/// </summary>
[Fact]
public async Task Transport_defaults_to_libplctag()
public async Task Transport_defaults_to_the_resolver_default_native()
{
CapturingCommand.Captured = null;
CapturingCommand.Ran = false;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0"]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Captured.ShouldBe(AbCipTransport.Libplctag);
CapturingCommand.Ran.ShouldBeTrue();
CapturingCommand.Captured.ShouldBeNull();
AbCipTransportResolver.Resolve(CapturingCommand.Captured).ShouldBe(AbCipTransport.Native);
}
/// <summary>Verifies CliFx parses <c>--transport</c> (case-insensitive) into the options.</summary>
@@ -41,8 +49,10 @@ public sealed class AbCipTransportOptionTests
[InlineData("native", AbCipTransport.Native)]
[InlineData("Native", AbCipTransport.Native)]
[InlineData("libplctag", AbCipTransport.Libplctag)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport expected)
[InlineData("default", null)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbCipTransport? expected)
{
CapturingCommand.Ran = false;
CapturingCommand.Captured = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
@@ -50,6 +60,7 @@ public sealed class AbCipTransportOptionTests
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Ran.ShouldBeTrue();
CapturingCommand.Captured.ShouldBe(expected);
}
}
@@ -161,42 +161,79 @@ public sealed class BuildOptionsTests
options.Devices[0].PlcFamily.ShouldBe(AbLegacyPlcFamily.Plc5);
}
/// <summary>Verifies the transport defaults to libplctag and --transport reaches the options.</summary>
[Fact]
public void BuildOptions_defaults_to_libplctag_and_forwards_transport()
/// <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.
/// </summary>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, AbLegacyTransport.Native)]
[InlineData(AbLegacyPlcFamily.MicroLogix, AbLegacyTransport.Native)]
[InlineData(AbLegacyPlcFamily.Plc5, AbLegacyTransport.Libplctag)]
[InlineData(AbLegacyPlcFamily.LogixPccc, AbLegacyTransport.Libplctag)]
public void BuildOptions_default_transport_goes_through_the_resolver(AbLegacyPlcFamily family, AbLegacyTransport expected)
{
var def = new TestCommand { Gateway = "ab://192.168.1.20/1,0" };
def.Build([]).Transport.ShouldBe(AbLegacyTransport.Libplctag);
var options = new TestCommand { Gateway = "ab://192.168.1.20/1,0", PlcType = family }.Build([]);
options.Transport.ShouldBeNull();
AbLegacyTransportResolver.Resolve(options).ShouldBe(expected);
var native = new TestCommand { Gateway = "ab://192.168.1.20/1,0", Transport = AbLegacyTransport.Native };
native.Build([]).Transport.ShouldBe(AbLegacyTransport.Native);
var native = new TestCommand
{
Gateway = "ab://192.168.1.20/1,0", PlcType = family, Transport = AbLegacyCliTransport.Native,
}.Build([]);
native.Transport.ShouldBe(AbLegacyTransport.Native);
AbLegacyTransportResolver.Resolve(native).ShouldBe(AbLegacyTransport.Native);
}
/// <summary>Verifies CliFx really parses <c>--transport native</c> (case-insensitive) into the property.</summary>
/// <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).
/// </summary>
[Theory]
[InlineData("plc5", AbLegacyTransport.Libplctag)]
[InlineData("logixpccc", AbLegacyTransport.Libplctag)]
[InlineData("slc500", AbLegacyTransport.Native)]
public async Task Plc_type_without_transport_flag_resolves_through_the_resolver(string plcType, AbLegacyTransport expected)
{
CapturingCommand.Options = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--plc-type", plcType]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Options.ShouldNotBeNull();
CapturingCommand.Options.Transport.ShouldBeNull();
AbLegacyTransportResolver.Resolve(CapturingCommand.Options).ShouldBe(expected);
}
/// <summary>Verifies CliFx really parses <c>--transport</c> (case-insensitive) into the property.</summary>
[Theory]
[InlineData("native", AbLegacyTransport.Native)]
[InlineData("Native", AbLegacyTransport.Native)]
[InlineData("libplctag", AbLegacyTransport.Libplctag)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport expected)
[InlineData("default", null)]
public async Task Transport_flag_is_parsed_by_the_cli(string value, AbLegacyTransport? expected)
{
CapturingCommand.Captured = null;
CapturingCommand.Options = null;
using var console = new FakeInMemoryConsole();
var app = new CliApplicationBuilder().AddCommand(typeof(CapturingCommand)).UseConsole(console).Build();
var exit = await app.RunAsync(["capture-transport", "--gateway", "ab://1.2.3.4/1,0", "--transport", value]);
exit.ShouldBe(0, console.ReadErrorString());
CapturingCommand.Captured.ShouldBe(expected);
CapturingCommand.Options.ShouldNotBeNull();
CapturingCommand.Options.Transport.ShouldBe(expected);
}
[Command("capture-transport")]
private sealed class CapturingCommand : AbLegacyCommandBase
{
public static AbLegacyTransport? Captured;
public static AbLegacyDriverOptions? Options;
public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
{
Captured = BuildOptions([]).Transport;
Options = BuildOptions([]);
return default;
}
}