Auto: twincat-2.3 — symbol-version invalidation listener

Closes #312
This commit is contained in:
Joseph Doherty
2026-04-25 22:16:05 -04:00
parent 569001364f
commit 4098d72bbb
6 changed files with 518 additions and 14 deletions

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para><b>Manual gating</b>: 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 <c>TWINCAT_MANUAL_ONLINE_CHANGE=1</c>
/// so the default integration pass skips it; operators flip it on when running
/// the scenario manually per <c>TwinCatProject/README.md §Online-change test scenario</c>.</para>
///
/// <para><b>How to run</b>: set <c>TWINCAT_TARGET_HOST</c> + <c>TWINCAT_TARGET_NETID</c>
/// + <c>TWINCAT_MANUAL_ONLINE_CHANGE=1</c>, kick off the test, then within ~60 s
/// open the project in XAE → add a dummy variable to <c>GVL_Perf</c> → Login +
/// Activate Configuration. The PLC re-initialises, the symbol-version counter
/// bumps, the listener fires, and the test passes.</para>
/// </remarks>
[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");
}
/// <summary>
/// Routes <see cref="ITwinCATClientFactory.Create"/> through the production
/// <see cref="AdsTwinCATClientFactory"/> and snapshots the produced client so the
/// test can read its internal handle-cache + symbol-version counters. Mirror of
/// the <c>CapturingFactory</c> in <see cref="TwinCATHandleCachePerfTests"/>.
/// </summary>
private sealed class CapturingFactory : ITwinCATClientFactory
{
public AdsTwinCATClient? Client { get; private set; }
public ITwinCATClient Create()
{
var c = new AdsTwinCATClient();
Client ??= c;
return c;
}
}
}

View File

@@ -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:

View File

@@ -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.
}
/// <summary>Test helper — simulate a reconnect (ConnectAsync after the connection drops).</summary>
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; }
/// <summary>Externally-supplied callback (production wires this to the cache wipe).</summary>
private Action? _onSymbolVersionChanged;
/// <summary>
/// Test helper exposed in lieu of the Beckhoff event surface — the production
/// <c>AdsTwinCATClient</c> registers via <c>RegisterSymbolVersionChangedAsync</c>
/// after connect; the fake records the registration here so tests can assert
/// "subscribed on connect". The callback (an <see cref="Action"/> rather than the
/// full <see cref="EventHandler{T}"/> shape) is the cache-wipe entry point.
/// </summary>
public void RegisterSymbolVersionListener(Action onChange)
{
_onSymbolVersionChanged = onChange;
SymbolVersionRegistered = true;
SymbolVersionRegistrationCount++;
}
public void UnregisterSymbolVersionListener()
{
if (!SymbolVersionRegistered) return;
_onSymbolVersionChanged = null;
SymbolVersionRegistered = false;
SymbolVersionUnregistrationCount++;
}
/// <summary>
/// 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").
/// </summary>
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<bool> 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 ----

View File

@@ -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;
/// <summary>
/// PR 2.3 — proactive Symbol-Version invalidation listener. Verifies the contract
/// mirrored on <see cref="FakeTwinCATClient"/>: <c>ConnectAsync</c> registers a
/// listener, <see cref="FakeTwinCATClient.FireSymbolVersionChange"/> wipes the
/// handle cache + bumps the diagnostic counter, the next read recreates the
/// handle, and <c>Dispose</c> unregisters cleanly.
/// </summary>
/// <remarks>
/// The fake's state machine is a high-fidelity mirror of <c>AdsTwinCATClient</c>'s
/// <c>RegisterSymbolVersionChangedAsync</c> / <c>OnAdsSymbolVersionChanged</c>
/// wiring; the production class is exercised end-to-end on a real PLC by the
/// integration-tier <see cref="ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests"/>
/// online-change scenario, which is gated on the operator triggering an actual
/// activate-config from XAE.
/// </remarks>
[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);
}
}