Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
T
Joseph Doherty 83a314e734 fix(twincat): string subscriptions, Windows-1252 STRING decode, and declared-type kind checks (ignitionoee 0.19.2-0.19.4)
Port of the ignitionoee TwinCAT string + type-reconciliation fixes onto the
Beckhoff-library client:

- STRING/WSTRING subscriptions never worked: AddDeviceNotificationExAsync was
  called with typeof(string) and no length, and the Beckhoff any-type
  marshaller throws ArgumentNullException for a string without one, so one
  string tag tore down its whole subscribe call. A string now registers the
  PLC's own byte width as raw bytes (0.19.2: STRING(n) of any n).
- Strings are read as raw bytes and decoded by TwinCATStringCodec. The
  Beckhoff marshaller already cut a read at the first NUL (0.19.3 / TwinCAT-007
  were not defects on the read path); the codec keeps that rule for both STRING
  and WSTRING and extends it to the new notification path. A STRING now decodes
  as Windows-1252 (TwinCAT-010) - or UTF-8 when the target reports UTF-8
  strings - instead of the host culture's ANSI code page the Beckhoff
  marshaller falls back to (1250 on a Polish host, 1251 Cyrillic, ...).
- TwinCATTypeGuard (TwinCAT-002/-008): a read refuses a numeric tag over a PLC
  string (and vice versa / STRING vs WSTRING / string over a container) with
  BadTypeMismatch instead of handing back a string object at Good; a
  notification - raw bytes decoded as the DECLARED type - additionally refuses
  width and float/integer mismatches (DINT over REAL reinterpreted the bits).
- A refused notification is isolated: that tag publishes BadTypeMismatch with
  one WARN, keeps a dead intent for the probe-tick retry, and the rest of the
  subscription registers.
2026-10-07 14:05:55 -04:00

280 lines
14 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")],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
return (drv, factory);
}
/// <summary>Verifies that native subscribe registers one notification per tag.</summary>
[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);
}
/// <summary>Verifies that native notification fires OnDataChange with pushed value.</summary>
[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
}
/// <summary>Verifies that native unsubscribe disposes all notifications.</summary>
[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();
}
/// <summary>Verifies that native unsubscribe halts future notifications.</summary>
[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);
}
/// <summary>Verifies that native subscribe failure mid-registration cleans up partial state.</summary>
[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")],
RawTags = TwinCATRawTags.Entries(
[
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
}
/// <summary>
/// A tag whose declared type cannot decode its PLC symbol (ignitionoee TwinCAT-002/-008) is refused
/// by the client with <see cref="TwinCATTypeMismatchException"/>. The refusal is ISOLATED: that
/// one tag goes Bad with <c>BadTypeMismatch</c> and every other tag in the same subscribe call
/// still registers — before, the exception tore the whole subscription down.
/// </summary>
[Fact]
public async Task Native_subscribe_isolates_a_type_mismatched_tag_to_BadTypeMismatch()
{
var fake = new FakeTwinCATClient();
fake.AddNotificationErrors["MAIN.Order"] =
new TwinCATTypeMismatchException("MAIN.Order", "declared DInt but the PLC symbol is the string STRING(3)");
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("Count", "ads://5.23.91.23.1.1:851", "MAIN.Count", TwinCATDataType.DInt),
new TwinCATTagDefinition("Order", "ads://5.23.91.23.1.1:851", "MAIN.Order", TwinCATDataType.DInt),
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.Real),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
UseNativeNotifications = true,
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Count", "Order", "Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.Count", "MAIN.Speed"], ignoreOrder: true);
var refused = events.ShouldHaveSingleItem();
refused.FullReference.ShouldBe("Order");
refused.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
refused.Snapshot.Value.ShouldBeNull();
// The good tags still deliver, and unsubscribe tears down cleanly.
fake.FireNotification("MAIN.Count", 7);
events.Last().Snapshot.Value.ShouldBe(7);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
fake.Notifications.ShouldBeEmpty();
}
private sealed class AbTagParamsIrrelevant { }
private sealed class FailAfterNAddsFake : FakeTwinCATClient
{
private readonly int _succeedBefore;
/// <summary>Gets the number of times AddNotificationAsync has been called.</summary>
public int AddCallCount { get; private set; }
/// <summary>Initializes a new instance of FailAfterNAddsFake.</summary>
/// <param name="_">Unused parameter for alignment with parent constructor.</param>
/// <param name="succeedBefore">The number of calls to succeed before failing.</param>
public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base()
{
_succeedBefore = succeedBefore;
}
/// <inheritdoc />
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);
}
}
/// <summary>Verifies that native shutdown disposes subscriptions.</summary>
[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();
}
/// <summary>Verifies that the poll path still works when UseNativeNotifications is false.</summary>
[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")],
RawTags = TwinCATRawTags.Entries([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);
}
/// <summary>Verifies that subscribe handle DiagnosticId indicates native vs poll.</summary>
[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")],
RawTags = TwinCATRawTags.Entries([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);
}
}