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