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;
///
/// PR 2.3 — proactive Symbol-Version invalidation listener. Verifies the contract
/// mirrored on : ConnectAsync registers a
/// listener, wipes the
/// handle cache + bumps the diagnostic counter, the next read recreates the
/// handle, and Dispose unregisters cleanly.
///
///
/// The fake's state machine is a high-fidelity mirror of AdsTwinCATClient's
/// RegisterSymbolVersionChangedAsync / OnAdsSymbolVersionChanged
/// wiring; the production class is exercised end-to-end on a real PLC by the
/// integration-tier
/// online-change scenario, which is gated on the operator triggering an actual
/// activate-config from XAE.
///
[Trait("Category", "Unit")]
public sealed class TwinCATSymbolVersionTests
{
private const string DevA = "ads://5.23.91.23.1.1:851";
[Fact]
public async Task ConnectAsync_registers_symbol_version_listener()
{
var fake = new FakeTwinCATClient();
fake.SymbolVersionRegistered.ShouldBeFalse("listener should not be armed before connect");
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
fake.SymbolVersionRegistered.ShouldBeTrue();
fake.SymbolVersionRegistrationCount.ShouldBe(1);
}
[Fact]
public async Task FireSymbolVersionChange_clears_handle_cache()
{
var fake = new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2, ["MAIN.C"] = 3 } };
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
await fake.ReadValueAsync("MAIN.A", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
await fake.ReadValueAsync("MAIN.B", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
await fake.ReadValueAsync("MAIN.C", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
fake.HandleCacheCount.ShouldBe(3);
fake.FireSymbolVersionChange();
fake.HandleCacheCount.ShouldBe(0);
// Each cached handle gets a delete record (mirror of AdsTwinCATClient's
// best-effort DeleteVariableHandleAsync fan-out on cache-wipe).
fake.HandleDeleteInvocations.Count.ShouldBe(3);
}
[Fact]
public async Task After_bump_next_read_recreates_handle()
{
var fake = new FakeTwinCATClient { Values = { ["MAIN.X"] = 42 } };
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
await fake.ReadValueAsync("MAIN.X", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
fake.HandleCreateInvocations.Count.ShouldBe(1);
fake.FireSymbolVersionChange();
// Cache is cold — next read pays the CreateVariableHandle cost again.
await fake.ReadValueAsync("MAIN.X", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken);
fake.HandleCreateInvocations.Count.ShouldBe(2);
fake.HandleCacheCount.ShouldBe(1);
}
[Fact]
public async Task SymbolVersionBumps_counter_increments_per_bump()
{
var fake = new FakeTwinCATClient();
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
fake.SymbolVersionBumps.ShouldBe(0);
fake.FireSymbolVersionChange();
fake.SymbolVersionBumps.ShouldBe(1);
fake.FireSymbolVersionChange();
fake.FireSymbolVersionChange();
fake.SymbolVersionBumps.ShouldBe(3);
}
[Fact]
public async Task Dispose_unregisters_listener()
{
var fake = new FakeTwinCATClient();
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
fake.SymbolVersionRegistered.ShouldBeTrue();
fake.Dispose();
fake.SymbolVersionRegistered.ShouldBeFalse();
fake.SymbolVersionUnregistrationCount.ShouldBe(1);
}
[Fact]
public async Task Reconnect_re_registers_listener()
{
// Production marks the listener as needing re-registration on every (re)connect
// because the device-side notification subscription is per-AMS-session — same
// lifecycle as the handle cache itself.
var fake = new FakeTwinCATClient();
await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
fake.SymbolVersionRegistrationCount.ShouldBe(1);
fake.SimulateReconnect();
fake.SymbolVersionRegistered.ShouldBeTrue();
fake.SymbolVersionRegistrationCount.ShouldBe(2);
fake.SymbolVersionUnregistrationCount.ShouldBe(1);
}
[Fact]
public async Task Bump_through_driver_invalidates_handle_cache_for_subsequent_per_tag_reads()
{
// Drive the bump from outside the driver and verify the per-tag (handle-cached)
// path resumes with a fresh handle on the next read. Whole-array reads route
// through the per-tag path, so they're the cleanest way to assert the contract
// through the public driver surface.
var factory = new FakeTwinCATClientFactory();
var captured = new FakeTwinCATClient
{
Values = { ["MAIN.Recipe"] = new int[] { 1, 2, 3, 4 } },
};
factory.Customise = () => captured;
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(DevA)],
Tags =
[
new TwinCATTagDefinition("Recipe", DevA, "MAIN.Recipe", TwinCATDataType.DInt,
ArrayDimensions: [4]),
],
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-symver", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
await drv.ReadAsync(["Recipe"], TestContext.Current.CancellationToken);
captured.HandleCreateInvocations.Count.ShouldBe(1);
// Simulate the PLC publishing a Symbol-Version-changed event mid-flight.
captured.FireSymbolVersionChange();
captured.SymbolVersionBumps.ShouldBe(1);
captured.HandleCacheCount.ShouldBe(0);
// Next read recreates the handle from cold.
await drv.ReadAsync(["Recipe"], TestContext.Current.CancellationToken);
captured.HandleCreateInvocations.Count.ShouldBe(2);
}
}