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;
///
/// #739 / R02:P1 — TwinCAT is an . 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 OnDataChangeBatch; the per-tag event
/// keeps firing for legacy consumers (the CLI).
///
[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);
/// The driver advertises the batch capability.
[Fact]
public void Driver_is_batch_subscribable() =>
NewDriver(new FakeTwinCATClientFactory(), native: false).ShouldBeAssignableTo();
/// A poll cycle that changes two tags raises ONE batch carrying both, and the per-tag event twice.
[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();
var single = new ConcurrentQueue();
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);
}
/// A native push and the native path's own Bad snapshot both reach the batch event.
[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();
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 condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}