7377daa9b0
Review 07 L7: each index value an indirect tag (N7:[N7:0]) swept through left a per-runtime and a creation SemaphoreSlim in the device's gate tables until shutdown. A moved index now drops the stale resolution's gates along with its handle, and sweeps any other stale resolution of the same tag a concurrent reader cached, so the tables stay bounded by the authored set. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
160 lines
6.8 KiB
C#
160 lines
6.8 KiB
C#
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review 07 L7: every index value an indirect tag sweeps through used to leave one per-runtime semaphore and
|
|
/// one creation semaphore behind until shutdown. They are pruned with the stale resolution's handle, so the
|
|
/// per-key gate tables stay bounded by the authored set however far the index sweeps.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Sweeping_index_does_not_accumulate_per_key_gates()
|
|
{
|
|
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
|
Seed(f, ("N7:0", (short)10));
|
|
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
|
var state = drv.GetDeviceState(Host)!;
|
|
var baseline = state.GateCount;
|
|
|
|
for (short i = 11; i < 60; i++)
|
|
{
|
|
f.Tags["N7:0"].Value = i;
|
|
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)(p.TagName == "N7:0" ? i : 1000 + i) };
|
|
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
|
|
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: every resolution really read
|
|
r.Value.ShouldBe((short)(1000 + i));
|
|
}
|
|
|
|
state.GateCount.ShouldBe(baseline);
|
|
// Only the live resolution's handle (plus the index word's parent runtime) remains cached.
|
|
state.Runtimes.Keys.Count(k => k.StartsWith("Ind=>", StringComparison.Ordinal)).ShouldBe(1);
|
|
}
|
|
}
|