Merge b640-646 (AbLegacy indirect addressing #646)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:57:59 -04:00
9 changed files with 370 additions and 51 deletions
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
/// <summary>
/// #646 against <c>ab_server</c>: <c>N7:[N7:0]</c> reads the index word, then the word it names, on both
/// transports. Writes N7:0 and N7:5 (simulator-only, gated by <see cref="IntegrationWriteGate"/>).
/// </summary>
[Collection(AbLegacyServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "ab_server-PCCC")]
public sealed class AbLegacyIndirectAddressingTests(AbLegacyServerFixture sim)
{
/// <summary>Seeds N7:0=5 and N7:5=42, then reads and writes through <c>N7:[N7:0]</c>.</summary>
/// <param name="transport">The transport stack the driver runs on.</param>
[AbLegacyTheory]
[MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))]
public async Task Slc500_indirect_word_read_and_write_resolve_through_the_index(AbLegacyTransport transport)
{
if (sim.SkipReasonFor(KnownProfiles.Slc500) is { } skip) Assert.Skip(skip);
IntegrationWriteGate.SkipUnlessWritesAllowed();
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(KnownProfiles.Slc500)}/{sim.CipPath}";
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(deviceUri, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("Index", deviceUri, "N7:0", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Target", deviceUri, "N7:5", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Indirect", deviceUri, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true)),
Timeout = TimeSpan.FromSeconds(5),
Probe = new AbLegacyProbeOptions { Enabled = false },
Transport = transport,
}, driverInstanceId: $"ablegacy-indirect-{transport}");
var ct = TestContext.Current.CancellationToken;
await drv.InitializeAsync("{}", ct);
var seeded = await drv.WriteAsync(
[new WriteRequest("Index", (short)5), new WriteRequest("Target", (short)42)], ct);
seeded.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
var read = (await drv.ReadAsync(["Indirect"], ct)).Single();
read.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
Convert.ToInt32(read.Value).ShouldBe(42);
// Write through the indirection lands on N7:5.
(await drv.WriteAsync([new WriteRequest("Indirect", (short)77)], ct)).Single()
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var direct = (await drv.ReadAsync(["Target"], ct)).Single();
Convert.ToInt32(direct.Value).ShouldBe(77);
}
}
@@ -181,28 +181,6 @@ public sealed class AbLegacyFamilyAddressingTests
factory.Tags["I:15/15"].ReadCount.ShouldBe(1);
}
/// <summary>An indirect tag is answered BadNotSupported on read and write, and no runtime is ever created for it.</summary>
[Fact]
public async Task Indirect_tag_is_BadNotSupported_and_creates_no_runtime()
{
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Ind", "ab://10.0.0.5/1,0", "N7:[N7:0]", AbLegacyDataType.Int, Writable: true),
new AbLegacyTagDefinition("Ok", "ab://10.0.0.5/1,0", "N7:1", AbLegacyDataType.Int, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5 };
var read = await drv.ReadAsync(["Ind", "Ok"], CancellationToken.None);
var write = await drv.WriteAsync(
[new WriteRequest("Ind", 1), new WriteRequest("Ok", 1)], CancellationToken.None);
read[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
write[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
// positive control: the neighbouring direct tag in the same batch still works, so the refusal is per-tag
read[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
write[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
/// <summary>A MicroLogix function-file tag reaches the transport under its libplctag name.</summary>
[Fact]
public async Task MicroLogix_function_file_tag_reaches_the_transport()
@@ -0,0 +1,131 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>#646: indirect addresses (N7:[N7:0], N[N7:0]:5) resolve at read/write time.</summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyIndirectAddressingTests
{
private const string Host = "ab://10.0.0.5/1,0";
private static async Task<(AbLegacyDriver drv, FakeAbLegacyTagFactory factory)> NewDriverAsync(
params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
RawTags = AbLegacyRawTags.Entries(tags),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return (drv, factory);
}
private static void Seed(FakeAbLegacyTagFactory f, params (string name, object value)[] values)
{
var map = values.ToDictionary(v => v.name, v => v.value, StringComparer.OrdinalIgnoreCase);
f.Customise = p => new FakeAbLegacyTag(p) { Value = map.TryGetValue(p.TagName, out var v) ? v : 0 };
}
[Fact]
public async Task Word_indirect_reads_index_then_composed_address()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)5), ("N7:5", (short)42));
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
r.Value.ShouldBe((short)42);
f.Tags["N7:0"].ReadCount.ShouldBe(1);
f.Tags["N7:5"].ReadCount.ShouldBe(1);
f.Tags.Keys.ShouldNotContain(k => k.Contains('['));
}
[Fact]
public async Task File_indirect_resolves_the_file_number()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N[N7:0]:5", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)9), ("N9:5", (short)77));
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
r.Value.ShouldBe((short)77);
f.Tags.ContainsKey("N9:5").ShouldBeTrue();
}
[Fact]
public async Task Index_is_re_read_every_poll_never_cached()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)5), ("N7:5", (short)42), ("N7:6", (short)43));
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().Value.ShouldBe((short)42);
f.Tags["N7:0"].Value = (short)6;
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().Value.ShouldBe((short)43);
f.Tags["N7:0"].ReadCount.ShouldBe(2);
// the stale resolution's handle is released when the index moves
f.Tags["N7:5"].Disposed.ShouldBeTrue();
}
[Theory]
[InlineData(-1)]
[InlineData(256)]
[InlineData(30000)]
public async Task Out_of_range_index_is_BadOutOfRange_without_health_impact(int index)
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
Seed(f, ("N7:0", (short)index));
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
drv.GetHealth().State.ShouldNotBe(DriverState.Degraded);
f.Tags.Keys.ShouldNotContain(k => k.StartsWith("N7:") && k != "N7:0");
}
[Fact]
public async Task Inner_read_failure_propagates_its_status()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)5, Status = p.TagName == "N7:0" ? -14 : 0 };
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
r.StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
r.StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.ContainsKey("N7:5").ShouldBeFalse();
}
[Fact]
public async Task Write_resolves_index_then_writes_the_composed_address()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true));
Seed(f, ("N7:0", (short)5));
var w = (await drv.WriteAsync([new WriteRequest("Ind", 99)], CancellationToken.None)).Single();
w.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
f.Tags["N7:5"].WriteCount.ShouldBe(1);
f.Tags["N7:5"].Value.ShouldBe((short)99);
f.Tags["N7:0"].WriteCount.ShouldBe(0);
}
[Fact]
public async Task Write_with_bad_index_is_BadOutOfRange_and_writes_nothing()
{
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int, Writable: true));
Seed(f, ("N7:0", (short)-3));
var w = (await drv.WriteAsync([new WriteRequest("Ind", 1)], CancellationToken.None)).Single();
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
}
}
@@ -28,10 +28,10 @@ public sealed class AbLegacyFamilyAddressGateTests
}
[Fact]
public void Gate_warns_about_indirect_addresses_which_never_read()
public void Gate_does_not_warn_about_indirect_addresses_which_the_driver_resolves()
{
EquipmentTagConfigInspector.Inspect(DriverTypeNames.AbLegacy, Cfg("N7:[N7:0]"), "{\"PlcFamily\":\"Slc500\"}")
.ShouldContain(x => x.Contains("indirect"));
.ShouldBeEmpty();
}
[Theory]