fix(ablegacy): review fixes - one family-aware address authority in Contracts, deploy gate + editor check, malformed address -> BadNodeIdUnknown warned once, probe normalised, octal/migration docs (#244 #245 #247)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:38:25 -04:00
parent 0dc09329b7
commit d8f3dd7394
10 changed files with 291 additions and 15 deletions
@@ -249,4 +249,72 @@ public sealed class AbLegacyFamilyAddressingTests
ex.Status.ShouldBe(AbStatus.Unsupported);
ex.Message.ShouldContain(why, Case.Insensitive);
}
private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger<AbLegacyDriver>
{
public readonly System.Collections.Concurrent.ConcurrentQueue<string> Warnings = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel level, Microsoft.Extensions.Logging.EventId id,
TState state, Exception? ex, Func<TState, Exception?, string> f)
{
if (level == Microsoft.Extensions.Logging.LogLevel.Warning) Warnings.Enqueue(f(state, ex));
}
}
/// <summary>
/// A PLC-5 address that no longer parses (the pre-octal-era decimal 8/9 digit) is a config error: BadNodeIdUnknown on
/// read and write, no runtime created, health untouched, and ONE warning per tag however often it is polled.
/// </summary>
[Fact]
public async Task Malformed_address_is_BadNodeIdUnknown_warned_once_and_never_throws()
{
var factory = new FakeAbLegacyTagFactory();
var logger = new CapturingLogger();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Bad", "ab://10.0.0.5/1,0", "I:018", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Good", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory, logger);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 };
var first = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
var second = await drv.ReadAsync(["Bad"], CancellationToken.None);
var write = await drv.WriteAsync([new WriteRequest("Bad", 1)], CancellationToken.None);
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
factory.Tags.Keys.ShouldNotContain(k => k.StartsWith("I:"));
logger.Warnings.Count(w => w.Contains("not valid for the device's PLC family") && w.Contains("I:018")).ShouldBe(1);
}
/// <summary>The connectivity-probe address is normalised like tag paths (PLC-5 octal I/O handed over decimal).</summary>
[Fact]
public async Task Probe_address_is_normalised_like_tag_paths()
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Plc5)],
Probe = new AbLegacyProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromMilliseconds(200),
ProbeAddress = "I:017",
},
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!factory.Tags.ContainsKey("I:15") && DateTime.UtcNow < deadline) await Task.Delay(20);
await drv.ShutdownAsync(CancellationToken.None);
factory.Tags.ContainsKey("I:15").ShouldBeTrue();
factory.Tags.ContainsKey("I:017").ShouldBeFalse();
}
}