Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
2026-04-26 01:45:12 -04:00

301 lines
13 KiB
C#

using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATNativeNotificationTests
{
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewNativeDriver(params TwinCATTagDefinition[] tags)
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = tags,
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
return (drv, factory);
}
[Fact]
public async Task Native_subscribe_registers_one_notification_per_tag()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
handle.DiagnosticId.ShouldStartWith("twincat-native-sub-");
factory.Clients[0].Notifications.Count.ShouldBe(2);
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.B"], ignoreOrder: true);
}
[Fact]
public async Task Native_notification_fires_OnDataChange_with_pushed_value()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].FireNotification("MAIN.Speed", 4200);
factory.Clients[0].FireNotification("MAIN.Speed", 4201);
events.Count.ShouldBe(2);
events.Last().Snapshot.Value.ShouldBe(4201);
events.Last().FullReference.ShouldBe("Speed"); // driver-side reference, not ADS symbol
}
[Fact]
public async Task Native_unsubscribe_disposes_all_notifications()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].Notifications.Count.ShouldBe(2);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
factory.Clients[0].Notifications.ShouldBeEmpty();
}
[Fact]
public async Task Native_unsubscribe_halts_future_notifications()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].FireNotification("MAIN.X", 1);
var snapshotFake = factory.Clients[0];
await drv.UnsubscribeAsync(handle, CancellationToken.None);
var afterUnsub = events.Count;
// After unsubscribe the fake's Notifications list is empty so FireNotification finds nothing
// to invoke. This mirrors the production contract — disposed handles no longer deliver.
snapshotFake.FireNotification("MAIN.X", 999);
events.Count.ShouldBe(afterUnsub);
}
[Fact]
public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state()
{
// Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws.
// Subscribe's catch block must tear the first one down before rethrowing so no zombie
// notification lingers.
var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1);
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags =
[
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt),
],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await Should.ThrowAsync<InvalidOperationException>(() =>
drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None));
// First registration succeeded then got torn down by the catch; second threw.
fake.AddCallCount.ShouldBe(2);
fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
}
private sealed class AbTagParamsIrrelevant { }
private sealed class FailAfterNAddsFake : FakeTwinCATClient
{
private readonly int _succeedBefore;
public int AddCallCount { get; private set; }
public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base()
{
_succeedBefore = succeedBefore;
}
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
int maxDelayMs,
Action<string, object?> onChange, CancellationToken cancellationToken)
{
AddCallCount++;
if (AddCallCount > _succeedBefore)
throw new InvalidOperationException($"fake fail on call #{AddCallCount}");
return base.AddNotificationAsync(symbolPath, type, bitIndex, cycleTime, maxDelayMs, onChange, cancellationToken);
}
}
[Fact]
public async Task Native_shutdown_disposes_subscriptions()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].Notifications.Count.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
factory.Clients[0].Notifications.ShouldBeEmpty();
}
[Fact]
public async Task Poll_path_still_works_when_UseNativeNotifications_false()
{
var factory = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 7 } },
};
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(150), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
events.First().Snapshot.Value.ShouldBe(7);
factory.Clients[0].Notifications.ShouldBeEmpty(); // no native notifications on poll path
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
// ---- PR 3.1 (#313) — per-tag MaxDelay tuning ----
[Fact]
public async Task Native_subscribe_default_MaxDelay_is_zero_per_tag()
{
// No MaxDelayMs on the tag → fake captures 0 (the pre-PR-3.1 "fire ASAP" default).
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].Notifications.Count.ShouldBe(1);
factory.Clients[0].Notifications[0].MaxDelayMs.ShouldBe(0);
}
[Fact]
public async Task Native_subscribe_plumbs_per_tag_MaxDelayMs_into_NotificationSettings()
{
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt,
MaxDelayMs: 500));
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
factory.Clients[0].Notifications.Count.ShouldBe(1);
factory.Clients[0].Notifications[0].MaxDelayMs.ShouldBe(500);
}
[Fact]
public async Task Native_subscribe_per_tag_MaxDelay_values_flow_independently()
{
// Mixing tags in a single Subscribe call: each tag's MaxDelayMs lands on its own
// NotificationSettings, no cross-talk between subscriptions.
var (drv, factory) = NewNativeDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt,
MaxDelayMs: 250),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt,
MaxDelayMs: 1000),
new TwinCATTagDefinition("C", "ads://5.23.91.23.1.1:851", "MAIN.C", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(50), CancellationToken.None);
factory.Clients[0].Notifications.Count.ShouldBe(3);
var bySymbol = factory.Clients[0].Notifications
.ToDictionary(n => n.SymbolPath, n => n.MaxDelayMs, StringComparer.OrdinalIgnoreCase);
bySymbol["MAIN.A"].ShouldBe(250);
bySymbol["MAIN.B"].ShouldBe(1000);
bySymbol["MAIN.C"].ShouldBe(0); // unset = default
}
[Fact]
public void TagDefinition_MaxDelayMs_default_is_null()
{
// Defaulting MaxDelayMs on the record itself — preserves the call sites that don't
// set it (every existing TwinCATTagDefinition usage in tests + production).
var def = new TwinCATTagDefinition(
"X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt);
def.MaxDelayMs.ShouldBeNull();
}
[Fact]
public void TagDefinition_MaxDelayMs_round_trips_via_record_with()
{
// The `with` expression is the closest thing to a DTO round-trip at this layer —
// confirms the new property is a regular init member that participates in equality
// / copy semantics. Caller-side serialisation layers (server / admin) layer their
// own DTOs on top of this record.
var def = new TwinCATTagDefinition(
"X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt);
var withDelay = def with { MaxDelayMs = 750 };
withDelay.MaxDelayMs.ShouldBe(750);
// sanity — original is untouched
def.MaxDelayMs.ShouldBeNull();
}
[Fact]
public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll()
{
var (drvNative, _) = NewNativeDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drvNative.InitializeAsync("{}", CancellationToken.None);
var nativeHandle = await drvNative.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
nativeHandle.DiagnosticId.ShouldContain("native");
var factoryPoll = new FakeTwinCATClientFactory
{
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } },
};
var drvPoll = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
Tags = [new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt)],
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = false,
}, "drv-1", factoryPoll);
await drvPoll.InitializeAsync("{}", CancellationToken.None);
var pollHandle = await drvPoll.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
pollHandle.DiagnosticId.ShouldNotContain("native");
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}