fe94a3bdb8
Ab.Transport.Tests: predecessor-wait counter instead of 300 ms (DeviceSessionPoolTests x2), throttle queue/interval-park hooks instead of 50/100 ms (ForwardOpenThrottleTests x2), UnobservedTaskProbe with a must-report control fault instead of N GC cycles (EncapsulationClientTests), re-armed keep-alive timer as the control for the never-opened keep-alive test. AbCip.Tests: still-polling sibling / control driver read counts instead of 300-500 ms (subscriptions x4, alarm projection x5), structural ProbeTask==null + enabled-probe control (host probe x2), predecessor wait (NativeAbCipLeaseReleaseTests), processed waits in the shutdown/logging tests. AbLegacy.Tests: sibling-subscription control (Unsubscribe_halts_polling), ProbeCts==null + control. AbCip.IntegrationTests differential safety: a post-phase control open through the same throttle instead of 1.5-2.5 s sleeps (via a native tag init, keeping the harness read-only guard green). Fake tag ReadCount made interlocked; CapturingLogger entries snapshotted under a lock. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
235 lines
12 KiB
C#
235 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbCipSubscriptionTests
|
|
{
|
|
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
|
|
{
|
|
var factory = new FakeAbCipTagFactory();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbCipRawTags.From(tags),
|
|
}, "drv-1", factory);
|
|
return (drv, factory);
|
|
}
|
|
|
|
/// <summary>The driver advertises the batched-subscription capability (which implies ISubscribable).</summary>
|
|
[Fact]
|
|
public void Driver_is_batch_subscribable()
|
|
{
|
|
var (drv, _) = NewDriver(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
|
(drv is IBatchSubscribable).ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>Verifies that the initial poll raises OnDataChange events for every subscribed tag.</summary>
|
|
[Fact]
|
|
public async Task Initial_poll_raises_OnDataChange_for_every_tag()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("Temp", "ab://10.0.0.5/1,0", "Temp", AbCipDataType.Real));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => p.TagName switch
|
|
{
|
|
"Speed" => new FakeAbCipTag(p) { Value = 1800 },
|
|
"Temp" => new FakeAbCipTag(p) { Value = 72.5f },
|
|
_ => new FakeAbCipTag(p),
|
|
};
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["Speed", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
|
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
|
|
|
events.Select(e => e.FullReference).ShouldContain("Speed");
|
|
events.Select(e => e.FullReference).ShouldContain("Temp");
|
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Verifies that unchanged values raise OnDataChange only once (on initial poll).</summary>
|
|
[Fact]
|
|
public async Task Unchanged_value_raises_only_once()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 1800 };
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
// Processed-count wait (#583): five polls have read the unchanged value, not "500 ms went by".
|
|
await WaitForReadsAsync(factory, "Speed", 5);
|
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
|
|
|
events.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>Verifies that value changes between polls raise OnDataChange events.</summary>
|
|
[Fact]
|
|
public async Task Value_change_between_polls_raises_OnDataChange()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 100 };
|
|
factory.Customise = _ => tagRef;
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
|
tagRef.Value = 200; // simulate PLC change
|
|
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
|
|
|
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
|
events.Count.ShouldBeGreaterThanOrEqualTo(2);
|
|
events.Last().Snapshot.Value.ShouldBe(200);
|
|
}
|
|
|
|
/// <summary>Verifies that unsubscribe halts polling and no further events are raised.</summary>
|
|
[Fact]
|
|
public async Task Unsubscribe_halts_polling()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
|
new AbCipTagDefinition("Temp", "ab://10.0.0.5/1,0", "Temp", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 1 };
|
|
factory.Customise = p => p.TagName == "Speed" ? tagRef : new FakeAbCipTag(p) { Value = 1 };
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
var controlHandle = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
await WaitForAsync(() => events.Any(e => e.FullReference == "Speed"), TimeSpan.FromSeconds(1));
|
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
|
|
|
var afterUnsub = events.Count(e => e.FullReference == "Speed");
|
|
var readsAfterUnsub = tagRef.ReadCount;
|
|
tagRef.Value = 999;
|
|
// Positive control (#583): the still-subscribed sibling, on the same interval, polls three more times —
|
|
// so Speed's loop, had it survived the unsubscribe, has had three chances to read the change.
|
|
await WaitForReadsAsync(factory, "Temp", factory.Tags["Temp"].ReadCount + 3);
|
|
|
|
events.Count(e => e.FullReference == "Speed").ShouldBe(afterUnsub);
|
|
tagRef.ReadCount.ShouldBe(readsAfterUnsub);
|
|
await drv.UnsubscribeAsync(controlHandle, CancellationToken.None);
|
|
}
|
|
|
|
/// <summary>Verifies that polling intervals below 100ms are floored to the minimum.</summary>
|
|
[Fact]
|
|
public async Task Interval_below_100ms_is_floored()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(5), CancellationToken.None);
|
|
// Processed-count wait (#583): three polls, then measure the spacing they actually ran at.
|
|
var started = System.Diagnostics.Stopwatch.StartNew();
|
|
await WaitForReadsAsync(factory, "Speed", 3);
|
|
var elapsed = started.Elapsed;
|
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
|
|
|
// Value is stable → only the initial-data push fires.
|
|
events.Count.ShouldBe(1);
|
|
// The floor: the initial read is immediate, so the two later ones took at least two 100 ms intervals.
|
|
// At the requested 5 ms they would have taken ~10 ms.
|
|
elapsed.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(180));
|
|
}
|
|
|
|
/// <summary>Verifies that ShutdownAsync cancels all active subscriptions.</summary>
|
|
[Fact]
|
|
public async Task ShutdownAsync_cancels_active_subscriptions()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
|
var speed = factory.Tags["Speed"];
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
|
|
var afterShutdown = events.Count;
|
|
var readsAfterShutdown = speed.ReadCount;
|
|
speed.Value = 2; // a change any surviving loop would raise
|
|
|
|
// Positive control (#583): a second, independent driver polling on the same interval reads three more
|
|
// times — so the shut-down driver's loop, had it survived, has had three chances to raise the change.
|
|
var (control, controlFactory) = NewDriver(
|
|
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
|
|
await control.InitializeAsync("{}", CancellationToken.None);
|
|
controlFactory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
|
|
_ = await control.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
await WaitForReadsAsync(controlFactory, "Speed", 3);
|
|
await control.ShutdownAsync(CancellationToken.None);
|
|
|
|
events.Count.ShouldBe(afterShutdown);
|
|
speed.ReadCount.ShouldBe(readsAfterShutdown);
|
|
}
|
|
|
|
/// <summary>Verifies that subscriptions on UDT members use the synthesized full reference.</summary>
|
|
[Fact]
|
|
public async Task Subscription_on_UDT_member_uses_synthesised_full_reference()
|
|
{
|
|
var factory = new FakeAbCipTagFactory();
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
|
RawTags = AbCipRawTags.From(
|
|
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
|
|
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]))
|
|
,
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = p => p.TagName == "Motor1.Speed"
|
|
? new FakeAbCipTag(p) { Value = 77 }
|
|
: new FakeAbCipTag(p);
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["Motor1.Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
|
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
|
|
|
events.First().Snapshot.Value.ShouldBe(77);
|
|
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
|
}
|
|
|
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (!condition() && DateTime.UtcNow < deadline)
|
|
await Task.Delay(20);
|
|
}
|
|
|
|
/// <summary>Waits until the fake runtime for <paramref name="tag"/> has served at least <paramref name="reads"/>
|
|
/// reads (a processed-count wait), failing — never passing vacuously — if it does not get there.</summary>
|
|
private static async Task WaitForReadsAsync(FakeAbCipTagFactory factory, string tag, int reads)
|
|
{
|
|
await WaitForAsync(() => factory.Tags.TryGetValue(tag, out var t) && t.ReadCount >= reads, TimeSpan.FromSeconds(5));
|
|
factory.Tags[tag].ReadCount.ShouldBeGreaterThanOrEqualTo(reads, $"'{tag}' should have been polled {reads} times");
|
|
}
|
|
}
|