@@ -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 ----
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user