using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime; /// /// Tests for . /// The SDK session types are sealed with internal ctors and cannot be faked, so we /// drive the cache-seeding path through /// and verify the /// handle-count seams — the contract under test is purely that /// zeroes both dictionaries /// so the next write is forced to re-AddItem + re-AdviseSupervisory. /// public sealed class GatewayGalaxyDataWriterTests { private static GalaxyMxSession MinimalSession() => new(new GalaxyMxAccessOptions(ClientName: "OtOpcUa-Test")); /// /// Approach (b): seed the item-handle cache directly via the internal test seam, /// confirm the count is positive, call , /// and confirm both caches are cleared. /// The next write (not simulated here — needs a live gw) would therefore be forced /// to re-AddItem because the cache is empty. /// [Fact] public void InvalidateHandleCaches_clears_item_and_supervised_handle_caches() { var session = MinimalSession(); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); // Pre-seed both caches via the internal test seam so we can assert the // "after a write" state without spinning up a real gRPC gateway session. writer.SeedHandleCachesForTest("TestMachine_001.TestAttr", itemHandle: 42, supervised: true); writer.CachedItemHandleCount.ShouldBe(1); writer.CachedSupervisedHandleCount.ShouldBe(1); writer.InvalidateHandleCaches(); writer.CachedItemHandleCount.ShouldBe(0); writer.CachedSupervisedHandleCount.ShouldBe(0); } /// /// A second seed + invalidate cycle proves the method isn't one-shot — a reconnect /// followed by writes followed by another reconnect must also start fresh. /// [Fact] public void InvalidateHandleCaches_is_repeatable_across_multiple_reconnects() { var session = MinimalSession(); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); // First session cycle writer.SeedHandleCachesForTest("Tag.A", itemHandle: 1, supervised: false); writer.SeedHandleCachesForTest("Tag.B", itemHandle: 2, supervised: true); writer.CachedItemHandleCount.ShouldBe(2); writer.InvalidateHandleCaches(); writer.CachedItemHandleCount.ShouldBe(0); writer.CachedSupervisedHandleCount.ShouldBe(0); // Second session cycle — handles re-populated after the reconnect's replay writer.SeedHandleCachesForTest("Tag.A", itemHandle: 99, supervised: true); writer.CachedItemHandleCount.ShouldBe(1); writer.InvalidateHandleCaches(); writer.CachedItemHandleCount.ShouldBe(0); } /// /// on a fresh (never-used) /// writer must be a no-op rather than throwing — the reconnect supervisor may call it /// before any write has occurred. /// [Fact] public void InvalidateHandleCaches_on_empty_caches_is_a_noop() { var session = MinimalSession(); var writer = new GatewayGalaxyDataWriter(session, writeUserId: 0); // Caches are empty — must not throw. writer.CachedItemHandleCount.ShouldBe(0); writer.CachedSupervisedHandleCount.ShouldBe(0); writer.InvalidateHandleCaches(); writer.CachedItemHandleCount.ShouldBe(0); writer.CachedSupervisedHandleCount.ShouldBe(0); } // ===== subscribedHandleSource / TryResolveCachedOrBorrowed tests ===== /// /// When a subscribedHandleSource delegate returns a positive handle for the /// requested ref, must /// return that handle. Critically, the borrowed handle must NOT be stored in the writer's /// own item-handle cache (the subscription registry owns its lifecycle), and /// must remain zero (no real /// gateway round-trip issued). /// [Fact] public void TryResolveCachedOrBorrowed_returns_borrowed_handle_without_caching_it() { var writer = new GatewayGalaxyDataWriter( MinimalSession(), writeUserId: 0, logger: null, subscribedHandleSource: fr => fr == "Tag.X" ? 7 : (int?)null); var result = writer.TryResolveCachedOrBorrowed("Tag.X"); result.ShouldBe(7); writer.CachedItemHandleCount.ShouldBe(0); // borrow NOT stored in _itemHandles writer.AddItemCallCount.ShouldBe(0); // no real AddItem issued } /// /// When a handle is present in the writer's own _itemHandles cache (seeded via /// ), that cached handle must /// win over the subscribedHandleSource delegate — the writer's own earlier AddItem /// takes priority. /// [Fact] public void TryResolveCachedOrBorrowed_cached_handle_wins_over_source() { // Source would return 99 for "Tag.Y", but the writer already cached handle 42 for it. var writer = new GatewayGalaxyDataWriter( MinimalSession(), writeUserId: 0, logger: null, subscribedHandleSource: fr => fr == "Tag.Y" ? 99 : (int?)null); writer.SeedHandleCachesForTest("Tag.Y", itemHandle: 42, supervised: false); var result = writer.TryResolveCachedOrBorrowed("Tag.Y"); result.ShouldBe(42); // cached wins, not 99 from the source } /// /// When no subscribedHandleSource is provided (null), the method must /// return null for a ref that has never been cached — preserving today's /// AddItem-required behavior. /// [Fact] public void TryResolveCachedOrBorrowed_null_source_returns_null_when_not_cached() { var writer = new GatewayGalaxyDataWriter(MinimalSession(), writeUserId: 0); var result = writer.TryResolveCachedOrBorrowed("Tag.Z"); result.ShouldBeNull(); } /// /// A subscribedHandleSource that returns 0 (a sentinel meaning "subscribe /// pending / failed") must be treated as no borrow — the method returns null /// so the caller issues a fresh AddItem. /// [Fact] public void TryResolveCachedOrBorrowed_source_returning_zero_treated_as_no_borrow() { var writer = new GatewayGalaxyDataWriter( MinimalSession(), writeUserId: 0, logger: null, subscribedHandleSource: _ => 0); var result = writer.TryResolveCachedOrBorrowed("Tag.W"); result.ShouldBeNull(); } /// /// A subscribedHandleSource that returns a negative value (another failure /// sentinel) must also be treated as no borrow — the method returns null. /// [Fact] public void TryResolveCachedOrBorrowed_source_returning_negative_treated_as_no_borrow() { var writer = new GatewayGalaxyDataWriter( MinimalSession(), writeUserId: 0, logger: null, subscribedHandleSource: _ => -5); var result = writer.TryResolveCachedOrBorrowed("Tag.V"); result.ShouldBeNull(); } }