From 4098d72bbb9fd1f4be75eb8181cd6a82b4c2b4d9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 25 Apr 2026 22:16:05 -0400 Subject: [PATCH] =?UTF-8?q?Auto:=20twincat-2.3=20=E2=80=94=20symbol-versio?= =?UTF-8?q?n=20invalidation=20listener?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #312 --- docs/drivers/TwinCAT-Test-Fixture.md | 26 +-- .../AdsTwinCATClient.cs | 107 +++++++++++- .../TwinCATSymbolVersionTests.cs | 126 ++++++++++++++ .../TwinCatProject/README.md | 41 +++++ .../FakeTwinCATClient.cs | 70 ++++++++ .../TwinCATSymbolVersionTests.cs | 162 ++++++++++++++++++ 6 files changed, 518 insertions(+), 14 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATSymbolVersionTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolVersionTests.cs diff --git a/docs/drivers/TwinCAT-Test-Fixture.md b/docs/drivers/TwinCAT-Test-Fixture.md index daa1b40..7335e2f 100644 --- a/docs/drivers/TwinCAT-Test-Fixture.md +++ b/docs/drivers/TwinCAT-Test-Fixture.md @@ -172,17 +172,21 @@ the second pass issues zero new `CreateVariableHandleAsync` calls. It runs under the standard `[TwinCATFact]` gate (XAR reachable; no `TWINCAT_PERF` opt-in needed because 50 symbols is cheap). -**Staleness caveat**: handles can go stale after a TwinCAT online change -(POU edit + activate). Until PR 2.3 ships the proactive Symbol-Version -invalidation listener, the safety net is twofold: (1) the -`DeviceSymbolVersionInvalid` evict-and-retry path catches cases where the -descriptor moves but the symbol survives, and (2) operators can call -`ITwinCATClient.FlushOptionalCachesAsync` manually after a known online -change to wipe the cache without forcing a full reconnect. The bulk -Sum-read / Sum-write path remains on symbolic paths in PR 2.2 (the bulk -path's per-call symbol resolution is already amortised across N tags; -the perf delta vs. handle-batched bulk is marginal — tracked as a -follow-up for the Phase-2 perf sweep). +**Self-invalidation (PR 2.3)**: handle cache is now self-invalidating on +TwinCAT online changes. `AdsTwinCATClient` registers an +`AdsSymbolVersionChanged` event listener (Beckhoff's high-level wrapper +around the SymbolVersion ADS notification, IndexGroup `0xF008`) on connect; +when the PLC's symbol-version counter increments — full re-init after a +download / activate-config — the listener fires and wipes the handle cache +proactively. Three-layered defence in depth: (1) proactive listener +preempts the next read entirely on full re-inits, (2) the +`DeviceSymbolVersionInvalid` evict-and-retry path from PR 2.2 catches the +narrower "symbol survives but its descriptor moved" race, and (3) +operators can still call `ITwinCATClient.FlushOptionalCachesAsync` manually +for the truly-paranoid case. The bulk Sum-read / Sum-write path remains +on symbolic paths in PR 2.2 (the bulk path's per-call symbol resolution +is already amortised across N tags; the perf delta vs. handle-batched +bulk is marginal — tracked as a follow-up for the Phase-2 perf sweep). ## Follow-up candidates diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs index 1c054cf..0bb74c4 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs @@ -43,6 +43,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient private bool _wasConnected; private readonly object _connectionStateGate = new(); + // PR 2.3 — proactive Symbol-Version invalidation listener. The Beckhoff stack + // surfaces a high-level event + // (built on top of the SymbolVersion ADS notification, IndexGroup 0xF008) that + // fires when the PLC's symbol table version counter increments — i.e. on full + // re-initialisations after a download / activate. Registered after the AMS + // session is up so the device server actually accepts the registration; we + // unregister + clear the handle on Dispose. _symbolVersionRegistered guards + // against double-registration if EnsureSymbolVersionListenerAsync is called + // re-entrantly through ConnectAsync on a reconnect. + // + // Spec deviation: the original PR 2.3 plan called for a raw + // AddDeviceNotificationAsync(AdsReservedIndexGroup.SymbolVersion, ...). Beckhoff + // wrap that in IAdsSymbolChangedProvider on AdsClient so we get a typed + // + Dispose-aware unregister + // for free — same wire effect, smaller surface area. + private bool _symbolVersionRegistered; + private long _symbolVersionBumps; + // Test-only counter — number of CreateVariableHandleAsync calls actually issued // (i.e. cache misses). Integration tests assert this stays at the unique-symbol // count after a second pass over the same set. @@ -51,16 +69,26 @@ internal sealed class AdsTwinCATClient : ITwinCATClient /// Test-only — current size of the handle cache. internal int HandleCacheCount => _handleCache.Count; + /// Test-only — total Symbol-Version bumps observed since process start. + internal long SymbolVersionBumps => Interlocked.Read(ref _symbolVersionBumps); + public AdsTwinCATClient() { _client.AdsNotificationEx += OnAdsNotificationEx; + _client.AdsSymbolVersionChanged += OnAdsSymbolVersionChanged; } public bool IsConnected => _client.IsConnected; - public Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken) + public async Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken cancellationToken) { - if (_client.IsConnected) return Task.CompletedTask; + if (_client.IsConnected) + { + // Idempotent. Still ensure the Symbol-Version listener is registered — first + // ConnectAsync may have lost the registration if the AMS session dropped. + await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false); + return; + } _client.Timeout = (int)Math.Max(1_000, timeout.TotalMilliseconds); var netId = AmsNetId.Parse(address.NetId); @@ -78,10 +106,71 @@ internal sealed class AdsTwinCATClient : ITwinCATClient if (wasConnected || !_handleCache.IsEmpty) _handleCache.Clear(); + // PR 2.3 — a reconnect drops the device-side notification registration. Mark + // the listener as needing re-registration so EnsureSymbolVersionListenerAsync + // re-arms it against the new session. + _symbolVersionRegistered = false; + _client.Connect(netId, address.Port); lock (_connectionStateGate) _wasConnected = _client.IsConnected; - return Task.CompletedTask; + + // PR 2.3 — register the Symbol-Version listener now that the AMS session is up. + // Best-effort: a registration failure here doesn't fail the connect (the + // DeviceSymbolVersionInvalid evict-and-retry path from PR 2.2 stays as the safety + // net), it just means we won't get proactive cache invalidation until next reconnect. + await EnsureSymbolVersionListenerAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// PR 2.3 — register the Beckhoff AdsSymbolVersionChanged event listener + /// against the current AMS session. Idempotent: a second call while + /// is true is a no-op so reconnect + /// paths can call this freely without double-arming. Failures swallowed because + /// the PR 2.2 reactive evict-and-retry path is still in place — proactive + /// invalidation is an optimisation, not a correctness requirement. + /// + private async Task EnsureSymbolVersionListenerAsync(CancellationToken cancellationToken) + { + if (_symbolVersionRegistered) return; + try + { + await _client.RegisterSymbolVersionChangedAsync(OnAdsSymbolVersionChanged, cancellationToken) + .ConfigureAwait(false); + _symbolVersionRegistered = true; + } + catch (OperationCanceledException) { throw; } + catch + { + // Best-effort. The reactive evict-and-retry path (PR 2.2) catches the same + // staleness; this is just an optimisation that lets us preempt the wasted + // request that would otherwise come back DeviceSymbolVersionInvalid. + } + } + + /// + /// PR 2.3 — Beckhoff fires this when the PLC's symbol-version counter increments, + /// which happens on every full re-initialisation (download, activate-config, etc.). + /// Every cached handle is invalid against the new symbol table, so we wipe the + /// cache here. In-flight reads that already hold a handle will fall through to the + /// PR 2.2 evict-and-retry path, + /// which is exactly what we want — the proactive wipe just preempts the wasted + /// round-trip on the next read for any symbol that didn't already have an in-flight op. + /// + private void OnAdsSymbolVersionChanged(object? sender, AdsSymbolVersionChangedEventArgs e) + { + Interlocked.Increment(ref _symbolVersionBumps); + // Snapshot cache for best-effort wire-side cleanup, then clear so the next + // EnsureHandleAsync re-resolves. Wire deletes are fire-and-forget — the device + // server has already invalidated these handles, so the deletes typically just + // bounce back with an error code we don't care about. + var snapshot = _handleCache.ToArray(); + _handleCache.Clear(); + foreach (var kv in snapshot) + { + try { _ = _client.DeleteVariableHandleAsync(kv.Value, CancellationToken.None); } + catch { /* best-effort; the new symbol-table version makes these handles dead anyway */ } + } } public async Task<(object? value, uint status)> ReadValueAsync( @@ -590,6 +679,18 @@ internal sealed class AdsTwinCATClient : ITwinCATClient public void Dispose() { _client.AdsNotificationEx -= OnAdsNotificationEx; + + // PR 2.3 — unregister the Symbol-Version listener. Best-effort: by the time we're + // disposing, the AMS session is already shutting down so the device server may + // refuse the unregister. Either way, AdsClient.Dispose tears the underlying + // notification subscription down regardless. + if (_symbolVersionRegistered) + { + try { _client.UnregisterSymbolVersionChanged(OnAdsSymbolVersionChanged); } + catch { /* best-effort */ } + _symbolVersionRegistered = false; + } + _client.AdsSymbolVersionChanged -= OnAdsSymbolVersionChanged; _notifications.Clear(); // PR 2.2 — release every cached handle on the wire as a good citizen. Best-effort diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATSymbolVersionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATSymbolVersionTests.cs new file mode 100644 index 0000000..b3f2526 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATSymbolVersionTests.cs @@ -0,0 +1,126 @@ +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.IntegrationTests; + +/// +/// PR 2.3 integration test — exercises the proactive Symbol-Version invalidation +/// listener against a real XAR runtime. Reads 5 symbols to populate the handle +/// cache, polls until the operator triggers an online change in TwinCAT XAE +/// (which bumps the PLC symbol-version counter), then asserts the cache wiped +/// and a follow-up read recreates handles. +/// +/// +/// Manual gating: this test requires an operator to trigger the +/// online change from XAE while the test is polling — there's no programmatic ADS +/// surface for "edit + activate". Gated on env TWINCAT_MANUAL_ONLINE_CHANGE=1 +/// so the default integration pass skips it; operators flip it on when running +/// the scenario manually per TwinCatProject/README.md §Online-change test scenario. +/// +/// How to run: set TWINCAT_TARGET_HOST + TWINCAT_TARGET_NETID +/// + TWINCAT_MANUAL_ONLINE_CHANGE=1, kick off the test, then within ~60 s +/// open the project in XAE → add a dummy variable to GVL_Perf → Login + +/// Activate Configuration. The PLC re-initialises, the symbol-version counter +/// bumps, the listener fires, and the test passes. +/// +[Collection("TwinCATXar")] +[Trait("Category", "Integration")] +[Trait("Simulator", "TwinCAT-XAR")] +public sealed class TwinCATSymbolVersionTests(TwinCATXarFixture sim) +{ + private const string ManualOnlineChangeEnv = "TWINCAT_MANUAL_ONLINE_CHANGE"; + + [TwinCATFact] + public async Task Driver_invalidates_handle_cache_on_symbol_version_bump() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + if (Environment.GetEnvironmentVariable(ManualOnlineChangeEnv) != "1") + Assert.Skip( + $"Manual online-change scenario disabled. Set {ManualOnlineChangeEnv}=1 + " + + "follow TwinCatProject/README.md §Online-change test scenario to run."); + + var deviceAddress = $"ads://{sim.TargetNetId}:{sim.AmsPort}"; + + // Use 5 symbols out of GVL_Perf.aTags — same fixture state as the perf tests. + // ArrayDimensions = [1] forces the per-tag (handle-cached) path so the test + // actually exercises the cache the listener is meant to invalidate. + var tags = new TwinCATTagDefinition[5]; + var refs = new string[5]; + for (var i = 0; i < tags.Length; i++) + { + var name = $"Perf{i + 1}"; + refs[i] = name; + tags[i] = new TwinCATTagDefinition( + Name: name, + DeviceHostAddress: deviceAddress, + SymbolPath: $"GVL_Perf.aTags[{i + 1}]", + DataType: TwinCATDataType.DInt, + ArrayDimensions: [1]); + } + + var options = new TwinCATDriverOptions + { + Devices = [new TwinCATDeviceOptions(deviceAddress, "XAR-VM")], + Tags = tags, + UseNativeNotifications = false, + Timeout = TimeSpan.FromSeconds(15), + Probe = new TwinCATProbeOptions { Enabled = false }, + }; + + var capture = new CapturingFactory(); + await using var drv = new TwinCATDriver(options, "tc3-symbol-version", capture); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + + // First pass populates the cache. + var firstResults = await drv.ReadAsync(refs, TestContext.Current.CancellationToken); + firstResults.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good); + capture.Client.ShouldNotBeNull(); + var initialCreates = capture.Client!.HandleCreateCount; + initialCreates.ShouldBe(tags.Length); + capture.Client!.HandleCacheCount.ShouldBe(tags.Length); + var initialBumps = capture.Client!.SymbolVersionBumps; + + // Wait for an operator-triggered online change. Poll the bump counter at 500 ms + // intervals up to 60 s — long enough to cover the manual XAE workflow (open + // project → add var → Login → Activate). When the counter ticks, the listener + // has fired + wiped the cache. + var deadline = DateTime.UtcNow.AddSeconds(60); + while (DateTime.UtcNow < deadline) + { + if (capture.Client!.SymbolVersionBumps > initialBumps) break; + await Task.Delay(500, TestContext.Current.CancellationToken); + } + + capture.Client!.SymbolVersionBumps.ShouldBeGreaterThan(initialBumps, + "expected operator to trigger an online change within 60 s; " + + "see TwinCatProject/README.md §Online-change test scenario"); + capture.Client!.HandleCacheCount.ShouldBe(0, + "Symbol-Version listener should have wiped the handle cache on bump"); + + // Subsequent reads recreate handles — total CreateVariableHandle count grows. + var secondResults = await drv.ReadAsync(refs, TestContext.Current.CancellationToken); + secondResults.ShouldAllBe(s => s.StatusCode == TwinCATStatusMapper.Good); + capture.Client!.HandleCreateCount.ShouldBe(initialCreates + tags.Length, + "post-bump reads must recreate every handle from cold"); + } + + /// + /// Routes through the production + /// and snapshots the produced client so the + /// test can read its internal handle-cache + symbol-version counters. Mirror of + /// the CapturingFactory in . + /// + private sealed class CapturingFactory : ITwinCATClientFactory + { + public AdsTwinCATClient? Client { get; private set; } + + public ITwinCATClient Create() + { + var c = new AdsTwinCATClient(); + Client ??= c; + return c; + } + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md index cf2bd71..316947e 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCatProject/README.md @@ -185,6 +185,47 @@ Options to eliminate the manual step: the rotation permanently, worth it if the integration host is long-lived. +## Online-change test scenario + +PR 2.3 (proactive Symbol-Version invalidation listener) ships an +operator-gated integration test +(`TwinCATSymbolVersionTests.Driver_invalidates_handle_cache_on_symbol_version_bump`) +that verifies `AdsTwinCATClient`'s `AdsSymbolVersionChanged` listener +wipes the handle cache when the PLC re-initialises after an online +change. The test polls for up to 60 s waiting for the operator to +trigger the change from XAE. + +The fixture state (`GVL_Perf` + `aTags[1..1000]`) is the same one used by +the Sum-read perf test — no new project state required. + +### Manual workflow + +With the XAR runtime live + the test process polling: + +1. **Open the project in XAE** on the dev box (or wherever XAE runs). +2. **Add a dummy variable to `GVL_Perf`** — any new declaration triggers + a symbol-table rebuild. Example: append + `bSymVerProbe : BOOL := FALSE;` to the GVL. +3. **Login** (`Ctrl+F8`) — XAE prompts to load the change. +4. **Activate Configuration** (Yellow-arrow button, or `TwinCAT → Activate Configuration`). + The runtime re-initialises; the symbol-version counter increments; + `AdsTwinCATClient.OnAdsSymbolVersionChanged` fires; the handle cache + wipes; the test polls observe `SymbolVersionBumps > 0` + asserts the + post-bump read recreates handles. + +The test skips by default — opt in by setting +`TWINCAT_MANUAL_ONLINE_CHANGE=1` alongside the standard +`TWINCAT_TARGET_HOST` / `TWINCAT_TARGET_NETID` env vars before kicking +off the test run. + +```powershell +$env:TWINCAT_TARGET_HOST = '10.0.0.42' +$env:TWINCAT_TARGET_NETID = '5.23.91.23.1.1' +$env:TWINCAT_MANUAL_ONLINE_CHANGE = '1' +dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests ` + --filter "FullyQualifiedName~TwinCATSymbolVersionTests" +``` + ## How to run the TwinCAT-tier tests On the dev box: diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs index c05602d..82dc842 100644 --- a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs @@ -71,15 +71,32 @@ internal class FakeTwinCATClient : ITwinCATClient // wire-side delete needed. if (IsConnected) _handleCache.Clear(); IsConnected = true; + // PR 2.3 — production arms the Symbol-Version listener after the AMS session is up. + // Mirror so unit tests can assert "ConnectAsync registered the version listener". + // Default cache-wipe handler matches AdsTwinCATClient.OnAdsSymbolVersionChanged. + if (!SymbolVersionRegistered) + RegisterSymbolVersionListener(WipeHandleCacheOnVersionBump); return Task.CompletedTask; } + private void WipeHandleCacheOnVersionBump() + { + // Note: kv-snapshot delete already happened in FireSymbolVersionChange before + // this callback fires, so the cache is already empty. Kept as the default + // listener so the wiring contract is still observable through tests that pass + // a custom action via RegisterSymbolVersionListener. + } + /// Test helper — simulate a reconnect (ConnectAsync after the connection drops). public void SimulateReconnect() { IsConnected = false; _handleCache.Clear(); + // Production marks the listener as needing re-registration on reconnect. + UnregisterSymbolVersionListener(); IsConnected = true; + if (!SymbolVersionRegistered) + RegisterSymbolVersionListener(WipeHandleCacheOnVersionBump); } public virtual Task<(object? value, uint status)> ReadValueAsync( @@ -164,6 +181,57 @@ internal class FakeTwinCATClient : ITwinCATClient return Task.CompletedTask; } + // ---- PR 2.3: Symbol-Version invalidation listener ---- + // + // Mirror of the Beckhoff AdsClient.AdsSymbolVersionChanged surface. ConnectAsync + // arms the listener so test asserts can verify the production driver registered + // it on connect; FireSymbolVersionChange() drives the same handle-cache-wipe path + // AdsTwinCATClient runs on a real PLC online change. + public bool SymbolVersionRegistered { get; private set; } + public int SymbolVersionRegistrationCount { get; private set; } + public int SymbolVersionUnregistrationCount { get; private set; } + public long SymbolVersionBumps { get; private set; } + /// Externally-supplied callback (production wires this to the cache wipe). + private Action? _onSymbolVersionChanged; + + /// + /// Test helper exposed in lieu of the Beckhoff event surface — the production + /// AdsTwinCATClient registers via RegisterSymbolVersionChangedAsync + /// after connect; the fake records the registration here so tests can assert + /// "subscribed on connect". The callback (an rather than the + /// full shape) is the cache-wipe entry point. + /// + public void RegisterSymbolVersionListener(Action onChange) + { + _onSymbolVersionChanged = onChange; + SymbolVersionRegistered = true; + SymbolVersionRegistrationCount++; + } + + public void UnregisterSymbolVersionListener() + { + if (!SymbolVersionRegistered) return; + _onSymbolVersionChanged = null; + SymbolVersionRegistered = false; + SymbolVersionUnregistrationCount++; + } + + /// + /// Drive the Symbol-Version-changed callback path. Production wipes the handle + /// cache + bumps the diagnostic counter; mirror so unit tests can assert + /// post-bump state without standing up a real ADS device. Safe to call when no + /// listener is registered (no-op + still bumps the counter so test code can + /// assert "we tried but no-one was listening"). + /// + public void FireSymbolVersionChange() + { + SymbolVersionBumps++; + // Mirror production cache-wipe semantics: snapshot, clear, emit per-entry deletes. + foreach (var kv in _handleCache) HandleDeleteInvocations.Add(kv.Value); + _handleCache.Clear(); + _onSymbolVersionChanged?.Invoke(); + } + public virtual Task ProbeAsync(CancellationToken ct) { if (ThrowOnProbe) return Task.FromResult(false); @@ -236,6 +304,8 @@ internal class FakeTwinCATClient : ITwinCATClient // the fan-out delete count matches. foreach (var kv in _handleCache) HandleDeleteInvocations.Add(kv.Value); _handleCache.Clear(); + // PR 2.3 — production unregisters the Symbol-Version listener on Dispose. + UnregisterSymbolVersionListener(); } // ---- notification fake ---- diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolVersionTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolVersionTests.cs new file mode 100644 index 0000000..4b2f4d5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATSymbolVersionTests.cs @@ -0,0 +1,162 @@ +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); + } +} -- 2.49.1