9beeed7a76
The poll engine already builds a per-cycle batch; TwinCAT now forwards it as OnDataChangeBatch, so DriverInstanceActor gets one message per poll cycle instead of one per changed tag. Because the actor subscribes ONLY the batch event of a batch-capable driver, every native-path data change (pushes, the BadNodeIdUnknown / refusal / unavailable snapshots) is raised through a single RaiseDataChange that fires both the per-tag event and a fresh one-item batch. Refs #739 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
90 lines
4.2 KiB
C#
90 lines
4.2 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;
|
|
|
|
/// <summary>
|
|
/// #739 / R02:P1 — TwinCAT is an <see cref="IBatchSubscribable"/>. DriverInstanceActor subscribes ONLY the
|
|
/// batch event of a batch-capable driver, so every data change — poll cycle AND native push, including the
|
|
/// Bad snapshots the native path publishes itself — must reach <c>OnDataChangeBatch</c>; the per-tag event
|
|
/// keeps firing for legacy consumers (the CLI).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATBatchSubscribeTests
|
|
{
|
|
private const string Host = "ads://5.23.91.23.1.1:851";
|
|
|
|
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool native) =>
|
|
new(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions(Host)],
|
|
RawTags = TwinCATRawTags.Entries(
|
|
[
|
|
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
|
|
]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
UseNativeNotifications = native,
|
|
}, "drv-1", factory);
|
|
|
|
/// <summary>The driver advertises the batch capability.</summary>
|
|
[Fact]
|
|
public void Driver_is_batch_subscribable() =>
|
|
NewDriver(new FakeTwinCATClientFactory(), native: false).ShouldBeAssignableTo<IBatchSubscribable>();
|
|
|
|
/// <summary>A poll cycle that changes two tags raises ONE batch carrying both, and the per-tag event twice.</summary>
|
|
[Fact]
|
|
public async Task A_poll_cycle_raises_one_batch_with_every_changed_tag()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory
|
|
{
|
|
Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 } },
|
|
};
|
|
var drv = NewDriver(factory, native: false);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
|
|
var single = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
|
|
drv.OnDataChange += (_, e) => single.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
|
await WaitForAsync(() => !batches.IsEmpty, TimeSpan.FromSeconds(3));
|
|
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
|
|
|
var first = batches.First();
|
|
first.SubscriptionHandle.ShouldBe(handle);
|
|
first.Items.Select(i => i.FullReference).ShouldBe(["A", "B"], ignoreOrder: true);
|
|
single.Count(e => e.FullReference is "A" or "B").ShouldBeGreaterThanOrEqualTo(2);
|
|
}
|
|
|
|
/// <summary>A native push and the native path's own Bad snapshot both reach the batch event.</summary>
|
|
[Fact]
|
|
public async Task Native_pushes_and_native_bad_snapshots_reach_the_batch_event()
|
|
{
|
|
var fake = new FakeTwinCATClient();
|
|
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, native: true);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
|
|
drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
|
|
|
|
_ = await drv.SubscribeAsync(["A", "Ghost"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
|
fake.FireNotification("MAIN.A", 42);
|
|
|
|
var items = batches.SelectMany(b => b.Items).ToList();
|
|
items.Single(i => i.FullReference == "Ghost").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
|
var a = items.Single(i => i.FullReference == "A");
|
|
a.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
a.Snapshot.Value.ShouldBe(42);
|
|
}
|
|
|
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (!condition() && DateTime.UtcNow < deadline)
|
|
await Task.Delay(20);
|
|
}
|
|
}
|