222 lines
9.8 KiB
C#
222 lines
9.8 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,
|
|
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, 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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|