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.2 — handle-based access with caching. Verifies the cache state machine /// mirrored on : cold-key resolves through /// -> CreateVariableHandle, warm-key reuses the cached /// handle, evicts + /// retries, and Dispose / reconnect / FlushOptionalCachesAsync wipe the cache. /// /// /// The fake's state machine is a high-fidelity mirror of AdsTwinCATClient: /// the production class is exercised through the same code paths in the integration /// tier (TwinCATHandleCachePerfTests) and through the fake at unit tier here — /// consistent with how the rest of this driver tests its AdsClient wrapper. /// [Trait("Category", "Unit")] public sealed class TwinCATHandleCacheTests { private const string DevA = "ads://5.23.91.23.1.1:851"; private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags) { var factory = new FakeTwinCATClientFactory(); var hosts = tags.Select(t => t.DeviceHostAddress).Distinct().ToArray(); if (hosts.Length == 0) hosts = [DevA]; var drv = new TwinCATDriver(new TwinCATDriverOptions { Devices = [.. hosts.Select(h => new TwinCATDeviceOptions(h))], Tags = tags, Probe = new TwinCATProbeOptions { Enabled = false }, }, "drv-handle", factory); return (drv, factory); } [Fact] public async Task Same_symbol_read_twice_creates_single_handle() { // Force the per-tag path (whole-array reads + bit-extracted BOOL skip the bulk // surface in PR 2.1; both still flow through the handle cache). Whole-array is // the simplest single-symbol read for this assertion — see TwinCATSumCommandTests // for the bulk-path's own handle-free path. var fake = new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 42 } }; await fake.ReadValueAsync("MAIN.Speed", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); await fake.ReadValueAsync("MAIN.Speed", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.ShouldBe(["MAIN.Speed"]); fake.ReadByHandleInvocations.Count.ShouldBe(2); fake.HandleCacheCount.ShouldBe(1); } [Fact] public async Task Two_distinct_symbols_create_two_handles() { var fake = new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 }, }; 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.A", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.ShouldBe(["MAIN.A", "MAIN.B"]); fake.HandleCacheCount.ShouldBe(2); } [Fact] public async Task SymbolVersionInvalid_evicts_and_retries_with_fresh_handle() { var fake = new FakeTwinCATClient { Values = { ["MAIN.Counter"] = 10 } }; // First read populates the cache. await fake.ReadValueAsync("MAIN.Counter", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.Count.ShouldBe(1); // Arm SymbolVersionInvalid for the next read; the fake's state machine evicts // the cached handle + retries once, exactly as AdsTwinCATClient does on the real wire. fake.SymbolVersionInvalidOnNextRead.Add("MAIN.Counter"); await fake.ReadValueAsync("MAIN.Counter", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); // Two creates total — original + retry — and one delete on eviction. fake.HandleCreateInvocations.ShouldBe(["MAIN.Counter", "MAIN.Counter"]); fake.HandleDeleteInvocations.Count.ShouldBe(1); // After the retry the cache is repopulated. fake.HandleCacheCount.ShouldBe(1); } [Fact] public async Task SymbolVersionInvalid_on_write_evicts_and_retries() { var fake = new FakeTwinCATClient(); await fake.WriteValueAsync("MAIN.Setpoint", TwinCATDataType.DInt, null, null, 100, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.Count.ShouldBe(1); fake.SymbolVersionInvalidOnNextWrite.Add("MAIN.Setpoint"); await fake.WriteValueAsync("MAIN.Setpoint", TwinCATDataType.DInt, null, null, 200, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.ShouldBe(["MAIN.Setpoint", "MAIN.Setpoint"]); fake.HandleDeleteInvocations.Count.ShouldBe(1); fake.HandleCacheCount.ShouldBe(1); } [Fact] public async Task Dispose_deletes_all_cached_handles() { var fake = new FakeTwinCATClient { Values = { ["A"] = 1, ["B"] = 2, ["C"] = 3 }, }; await fake.ReadValueAsync("A", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); await fake.ReadValueAsync("B", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); await fake.ReadValueAsync("C", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCacheCount.ShouldBe(3); fake.Dispose(); fake.HandleCacheCount.ShouldBe(0); fake.HandleDeleteInvocations.Count.ShouldBe(3); } [Fact] public async Task FlushOptionalCachesAsync_clears_cache_then_recreates_handle() { var fake = new FakeTwinCATClient { Values = { ["X"] = 1 } }; await fake.ReadValueAsync("X", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.Count.ShouldBe(1); fake.HasCachedHandle("X").ShouldBeTrue(); await fake.FlushOptionalCachesAsync(); fake.FlushOptionalCachesCount.ShouldBe(1); fake.HasCachedHandle("X").ShouldBeFalse(); fake.HandleDeleteInvocations.Count.ShouldBe(1); // Subsequent read recreates. await fake.ReadValueAsync("X", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.Count.ShouldBe(2); } [Fact] public async Task Reconnect_clears_handle_cache() { var fake = new FakeTwinCATClient { Values = { ["MAIN.Y"] = 9 } }; await fake.ConnectAsync(new TwinCATAmsAddress("5.23.91.23.1.1", 851), TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await fake.ReadValueAsync("MAIN.Y", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCacheCount.ShouldBe(1); // Simulate a connection drop + reconnect — handles from the prior session are dead. fake.SimulateReconnect(); fake.HandleCacheCount.ShouldBe(0); // Next read repopulates with a fresh handle. await fake.ReadValueAsync("MAIN.Y", TwinCATDataType.DInt, null, null, TestContext.Current.CancellationToken); fake.HandleCreateInvocations.Count.ShouldBe(2); } [Fact] public async Task Bulk_path_does_not_consume_handle_cache() { // PR 2.2 deviation note: bulk Sum-read / Sum-write stays on symbolic paths because // the perf win over the per-tag handle path is marginal vs the diff cost. This test // pins the contract — bulk does not create handles, the per-tag path does. var (drv, factory) = NewDriver( new TwinCATTagDefinition("X", DevA, "MAIN.X", TwinCATDataType.DInt), new TwinCATTagDefinition("Y", DevA, "MAIN.Y", TwinCATDataType.DInt)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1, ["MAIN.Y"] = 2 }, }; await drv.ReadAsync(["X", "Y"], TestContext.Current.CancellationToken); await drv.ReadAsync(["X", "Y"], TestContext.Current.CancellationToken); var client = factory.Clients[0]; client.BulkReadInvocations.Count.ShouldBe(2); client.HandleCreateInvocations.Count.ShouldBe(0); client.HandleCacheCount.ShouldBe(0); } [Fact] public async Task Per_tag_path_through_driver_uses_handle_cache() { // Whole-array reads route through the per-tag ReadValueAsync path — perfect for // exercising the handle cache via the public driver surface. var (drv, factory) = NewDriver( new TwinCATTagDefinition("Recipe", DevA, "MAIN.Recipe", TwinCATDataType.DInt, ArrayDimensions: [4])); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Recipe"] = new int[] { 1, 2, 3, 4 } }, }; await drv.ReadAsync(["Recipe"], TestContext.Current.CancellationToken); await drv.ReadAsync(["Recipe"], TestContext.Current.CancellationToken); await drv.ReadAsync(["Recipe"], TestContext.Current.CancellationToken); var client = factory.Clients[0]; client.HandleCreateInvocations.ShouldBe(["MAIN.Recipe"]); client.ReadByHandleInvocations.Count.ShouldBe(3); } [Fact] public async Task Bit_RMW_routes_parent_word_through_handle_cache() { // BOOL-in-word writes do read + write of the parent UDINT — both hops should // share the same cached handle for the parent. After three writes there should // still be only one handle for "Flags". var (drv, factory) = NewDriver( new TwinCATTagDefinition("Bit3", DevA, "GVL.Flags.3", TwinCATDataType.Bool)); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Flags"] = 0u } }; await drv.WriteAsync([new WriteRequest("Bit3", true)], TestContext.Current.CancellationToken); await drv.WriteAsync([new WriteRequest("Bit3", false)], TestContext.Current.CancellationToken); await drv.WriteAsync([new WriteRequest("Bit3", true)], TestContext.Current.CancellationToken); var client = factory.Clients[0]; client.HandleCreateInvocations.ShouldBe(["GVL.Flags"]); client.HandleCacheCount.ShouldBe(1); // Three RMWs = three reads + three writes by handle on the parent. client.ReadByHandleInvocations.Count(x => x == "GVL.Flags").ShouldBe(3); client.WriteByHandleInvocations.Count(x => x == "GVL.Flags").ShouldBe(3); } }