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); } }