feat(abcip): final review I1 — node-level Drivers:AbCip:DefaultTransport

The old AdminUI cannot author the per-driver transport key (#576), so the
'pin before upgrading' recipe had no working path. A node-level appsetting
(env Drivers__AbCip__DefaultTransport) now supplies the default for AbCip
drivers with no key: explicit key > node default > compiled Native. Bound
and validated unconditionally (bad value fails host start); the driver
factory and the Test Connect probe both read it so they agree. AbCip.md
upgrade recipe rewritten around the key; Configuration.md documents it.
Also documents first-wins for AllowPacking/probe and host-alias throttles
(final review M6) and moves the status-bit note out of AbCip.md (M5).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 21:34:48 -04:00
parent b66972b6f5
commit b75e013d0a
13 changed files with 447 additions and 44 deletions
@@ -191,4 +191,73 @@ public sealed class AbCipTransportSwitchTests
r.Ok.ShouldBeFalse();
r.Message!.ShouldContain("Config JSON is invalid");
}
/// <summary>
/// Final review I1 precedence: explicit key &gt; node default &gt; compiled Native. The pinned rows are
/// the falsification rows — a resolver that ignores the node default returns Native for them.
/// </summary>
[Theory]
[InlineData(null, AbCipTransport.Libplctag, AbCipTransport.Libplctag)]
[InlineData(null, AbCipTransport.Native, AbCipTransport.Native)]
[InlineData(AbCipTransport.Native, AbCipTransport.Libplctag, AbCipTransport.Native)]
[InlineData(AbCipTransport.Libplctag, AbCipTransport.Native, AbCipTransport.Libplctag)]
public void Explicit_key_beats_node_default_beats_compiled_default(
AbCipTransport? configured, AbCipTransport nodeDefault, AbCipTransport expected)
{
AbCipTransportResolver.Resolve(configured, nodeDefault).ShouldBe(expected);
var json = configured is null ? Config(null) : Config($"\"{configured}\"");
AbCipDriverFactoryExtensions.CreateInstance("ab1", json, loggerFactory: null, defaultTransport: nodeDefault)
.EffectiveTransport.ShouldBe(expected);
}
/// <summary>The init-log rendering distinguishes the compiled default from a node pin.</summary>
[Fact]
public void Describe_distinguishes_compiled_default_node_default_and_configured()
{
AbCipTransportResolver.Describe(null).ShouldBe("Native (default)");
AbCipTransportResolver.Describe(null, AbCipTransport.Libplctag).ShouldBe("Libplctag (node default)");
AbCipTransportResolver.Describe(AbCipTransport.Native, AbCipTransport.Libplctag).ShouldBe("Native (configured)");
}
[Theory]
[InlineData("Native", true)]
[InlineData("libplctag", true)]
[InlineData(" Libplctag ", true)]
[InlineData("1", false)]
[InlineData("Native,Libplctag", false)]
[InlineData("", false)]
[InlineData("Bogus", false)]
public void TryParse_accepts_names_only(string raw, bool ok) =>
AbCipTransportResolver.TryParse(raw, out _).ShouldBe(ok);
/// <summary>
/// The probe honours its node default for an absent key: pinned to Libplctag it does NOT produce the
/// native failure text against the same listener the unpinned probe does (falsification control).
/// </summary>
[Fact]
public async Task Probe_with_no_transport_uses_the_node_default()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
_ = Task.Run(async () =>
{
while (true)
{
try { (await listener.AcceptTcpClientAsync(TestContext.Current.CancellationToken)).Dispose(); }
catch { return; }
}
}, TestContext.Current.CancellationToken);
var cfg = $$"""{"devices":[{"hostAddress":"ab://127.0.0.1:{{port}}/1,0"}]}""";
var pinned = await new AbCipDriverProbe(defaultTransport: AbCipTransport.Libplctag)
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
var unpinned = await new AbCipDriverProbe()
.ProbeAsync(cfg, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
listener.Stop();
unpinned.Message!.ShouldContain("CIP handshake failed: Connection lost");
pinned.Ok.ShouldBeFalse();
pinned.Message!.ShouldNotContain("Connection lost");
}
}