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