using System; using System.Collections.Generic; using ZB.MOM.WW.MxGateway.Worker.MxAccess; namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// /// Unit tests for . The registry gained a /// reverse (server handle, item definition) index, memoized sorted views, and /// secondary removal indexes so bulk reads and bulk teardown stop rescanning /// the whole handle table. These tests pin the observable behaviour those /// structures must preserve: the same lookup semantics the old linear scan /// had, snapshot views that only rebuild after a mutation, and removals that /// leave every index consistent. /// public sealed class MxAccessHandleRegistryTests { /// /// Verifies the reverse index answers by server handle and ordinal item /// definition, returns every duplicate registration in ascending item /// handle order (the order a scan of /// would have visited them), and misses on the wrong server, a different /// tag, and a case-differing tag. /// [Fact] public void GetItemHandlesForDefinition_MatchesServerAndOrdinalTag_InAscendingHandleOrder() { MxAccessHandleRegistry registry = new(); registry.RegisterServerHandle(serverHandle: 1, clientName: "client"); registry.RegisterServerHandle(serverHandle: 2, clientName: "client"); // Same tag added twice under server 1 — MXAccess hands back a distinct // item handle per AddItem, so the index must keep both, lowest first. registry.RegisterItemHandle(1, itemHandle: 40, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false); registry.RegisterItemHandle(2, itemHandle: 12, "Tank1.PV", string.Empty, hasItemContext: false); Assert.Equal(new[] { 10, 40 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV")); Assert.Equal(new[] { 11 }, registry.GetItemHandlesForDefinition(1, "Tank1.SP")); Assert.Equal(new[] { 12 }, registry.GetItemHandlesForDefinition(2, "Tank1.PV")); // Misses: unknown server, unknown tag, and a case-differing tag — the // read path compares tag addresses ordinally, so casing must not match. Assert.Empty(registry.GetItemHandlesForDefinition(3, "Tank1.PV")); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank9.PV")); Assert.Empty(registry.GetItemHandlesForDefinition(1, "tank1.pv")); } /// /// Verifies re-registering an item handle under a new item definition /// retires the old reverse-index entry instead of leaving a stale hit. /// [Fact] public void RegisterItemHandle_ReusedHandleWithNewDefinition_RetiresStaleIndexEntry() { MxAccessHandleRegistry registry = new(); registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 10, "Tank2.PV", string.Empty, hasItemContext: false); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV")); Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV")); Assert.Single(registry.ItemHandles); } /// /// Verifies the reverse index drops handles as they are removed, both for /// a single item removal and for a whole-server teardown. /// [Fact] public void GetItemHandlesForDefinition_AfterRemoval_MissesRemovedHandles() { MxAccessHandleRegistry registry = new(); registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.PV", string.Empty, hasItemContext: false); registry.RemoveItemHandle(1, itemHandle: 10); Assert.Equal(new[] { 11 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV")); registry.UnregisterServerHandle(1); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV")); } /// /// Verifies each sorted view is memoized: repeated reads hand back the /// same instance until a mutation of that table invalidates it, and the /// rebuilt view reflects the mutation while keeping its sort order. /// [Fact] public void Views_AreMemoizedUntilTheirTableMutates() { MxAccessHandleRegistry registry = new(); registry.RegisterServerHandle(serverHandle: 2, clientName: "client"); registry.RegisterServerHandle(serverHandle: 1, clientName: "client"); registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.SP", string.Empty, hasItemContext: false); registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory); registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain); IReadOnlyList servers = registry.ServerHandles; IReadOnlyList items = registry.ItemHandles; IReadOnlyList advices = registry.AdviceHandles; // No mutation in between: the memoized arrays are handed back as-is. Assert.Same(servers, registry.ServerHandles); Assert.Same(items, registry.ItemHandles); Assert.Same(advices, registry.AdviceHandles); // Sort orders are unchanged by memoization. Assert.Equal(new[] { 1, 2 }, Map(servers, handle => handle.ServerHandle)); Assert.Equal(new[] { 10, 11 }, Map(items, handle => handle.ItemHandle)); Assert.Equal( new[] { MxAccessAdviceKind.Plain, MxAccessAdviceKind.Supervisory }, Map(advices, handle => handle.AdviceKind)); // A mutation of one table invalidates that view only. registry.RegisterServerHandle(serverHandle: 3, clientName: "client"); Assert.NotSame(servers, registry.ServerHandles); Assert.Equal(3, registry.ServerHandles.Count); Assert.Same(items, registry.ItemHandles); Assert.Same(advices, registry.AdviceHandles); registry.RemoveItemHandle(1, itemHandle: 11); Assert.NotSame(items, registry.ItemHandles); Assert.Single(registry.ItemHandles); registry.RemoveAdviceHandles(1, itemHandle: 10); Assert.NotSame(advices, registry.AdviceHandles); Assert.Empty(registry.AdviceHandles); } /// /// Verifies removing an item also removes every advice recorded for it and /// nothing recorded for a sibling item — the bulk unadvise/remove path /// leans on this to leave a consistent table. /// [Fact] public void RemoveItemHandle_RemovesOnlyThatItemsAdvices() { MxAccessHandleRegistry registry = new(); registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false); registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain); registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory); registry.RegisterAdviceHandle(1, itemHandle: 11, MxAccessAdviceKind.Plain); registry.RemoveItemHandle(1, itemHandle: 10); Assert.False(registry.ContainsItemHandle(1, 10)); Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory)); Assert.True(registry.ContainsItemHandle(1, 11)); Assert.True(registry.ContainsAdviceHandle(1, 11, MxAccessAdviceKind.Plain)); Assert.Single(registry.AdviceHandles); } /// /// Verifies a bulk unadvise followed by a bulk remove drains the registry /// completely, so the indexed removals cannot leave orphaned entries that /// a later lookup would resurrect. /// [Fact] public void BulkUnadviseThenRemove_LeavesRegistryEmpty() { MxAccessHandleRegistry registry = new(); registry.RegisterServerHandle(serverHandle: 1, clientName: "client"); for (int itemHandle = 1; itemHandle <= 50; itemHandle++) { registry.RegisterItemHandle(1, itemHandle, "Tank." + itemHandle, string.Empty, hasItemContext: false); registry.RegisterAdviceHandle(1, itemHandle, MxAccessAdviceKind.Plain); } for (int itemHandle = 1; itemHandle <= 50; itemHandle++) { registry.RemoveAdviceHandles(1, itemHandle); } Assert.Empty(registry.AdviceHandles); Assert.Equal(50, registry.ItemHandles.Count); for (int itemHandle = 1; itemHandle <= 50; itemHandle++) { registry.RemoveItemHandle(1, itemHandle); } Assert.Empty(registry.ItemHandles); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank.25")); Assert.True(registry.ContainsServerHandle(1)); } /// /// Verifies unregistering a server drops its items and advices and leaves /// every other server's handles untouched, including an advice registered /// for an item that was never added to the item table. /// [Fact] public void UnregisterServerHandle_RemovesOnlyThatServersHandles() { MxAccessHandleRegistry registry = new(); registry.RegisterServerHandle(serverHandle: 1, clientName: "client-one"); registry.RegisterServerHandle(serverHandle: 2, clientName: "client-two"); registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 11, "Tank1.SP", string.Empty, hasItemContext: false); registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain); // Advice without a matching item registration: the old scan removed it by // server handle, so the per-server index must reach it too. registry.RegisterAdviceHandle(1, itemHandle: 99, MxAccessAdviceKind.Supervisory); // Server 2 reuses the same item handle values — packing must keep them apart. registry.RegisterItemHandle(2, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterAdviceHandle(2, itemHandle: 10, MxAccessAdviceKind.Plain); registry.UnregisterServerHandle(1); Assert.False(registry.ContainsServerHandle(1)); Assert.False(registry.ContainsItemHandle(1, 10)); Assert.False(registry.ContainsItemHandle(1, 11)); Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); Assert.False(registry.ContainsAdviceHandle(1, 99, MxAccessAdviceKind.Supervisory)); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV")); Assert.True(registry.ContainsServerHandle(2)); Assert.True(registry.ContainsItemHandle(2, 10)); Assert.True(registry.ContainsAdviceHandle(2, 10, MxAccessAdviceKind.Plain)); Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(2, "Tank1.PV")); Assert.Single(registry.ItemHandles); Assert.Single(registry.AdviceHandles); Assert.Single(registry.ServerHandles); } /// /// Verifies re-registering an advice that is already present does not /// duplicate it in the per-item removal index — a duplicate would survive /// the removal that drains that index. /// [Fact] public void RegisterAdviceHandle_RegisteredTwice_StillRemovedByOneCall() { MxAccessHandleRegistry registry = new(); registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain); registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain); Assert.Single(registry.AdviceHandles); registry.RemoveAdviceHandles(1, itemHandle: 10); Assert.Empty(registry.AdviceHandles); Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); // Re-advising after the removal must work off a clean index. registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain); Assert.Single(registry.AdviceHandles); Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); } /// /// Pins the registry half of MxAccessSession.TryGetCachedReadFor's fall-through /// contract. That scan walks /// in order and skips any candidate carrying neither a plain nor a supervisory advice, /// because an added-but-unadvised item will never receive a fresh OnDataChange and so /// can only serve a stale cache entry. The registry has to make that skip possible: the /// duplicate registrations of one tag must come back in a stable ascending order, and the /// advice index must answer per item handle rather than per tag. Asserted here rather than /// on the session because the session's read path needs a live MXAccess COM instance. /// [Fact] public void GetItemHandlesForDefinition_MultipleCandidates_AdviceIndexDiscriminatesTheAdvisedOne() { MxAccessHandleRegistry registry = new(); registry.RegisterServerHandle(serverHandle: 1, clientName: "client"); // Same tag under two item handles: 10 is added but never advised, 20 is advised. Registered // out of order so the ascending-order guarantee is doing real work. registry.RegisterItemHandle(1, itemHandle: 20, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); registry.RegisterAdviceHandle(1, itemHandle: 20, MxAccessAdviceKind.Plain); IReadOnlyList candidates = registry.GetItemHandlesForDefinition(1, "Tank1.PV"); Assert.Equal(new[] { 10, 20 }, candidates); // The unadvised candidate is visited first and skipped; the advised one is the survivor. Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory)); Assert.True(registry.ContainsAdviceHandle(1, 20, MxAccessAdviceKind.Plain)); // Supervisory alone qualifies too, so a later advise on 10 makes it the first survivor. registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Supervisory); Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Supervisory)); } /// /// One adversarial lifecycle run over every index at once: register, advise, re-register the /// same item handle under a new tag, unadvise, then tear the server down. Each individual /// transition is covered above; this pins that they compose — the reverse definition index, /// the per-item advice index and the per-server removal index must agree after every step, /// since a stale entry in any one of them resurrects a handle MXAccess has already retired. /// [Fact] public void RegisterAdviseReregisterUnadviseUnregister_LeavesEveryIndexConsistent() { MxAccessHandleRegistry registry = new(); registry.RegisterServerHandle(serverHandle: 1, clientName: "client"); // Register. registry.RegisterItemHandle(1, itemHandle: 10, "Tank1.PV", string.Empty, hasItemContext: false); Assert.True(registry.ContainsItemHandle(1, 10)); Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank1.PV")); Assert.Empty(registry.AdviceHandles); // Advise. registry.RegisterAdviceHandle(1, itemHandle: 10, MxAccessAdviceKind.Plain); Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); Assert.Single(registry.AdviceHandles); // Re-register the SAME item handle under a new tag. The advice is keyed on the item handle, // not the tag, so it survives — but the old definition entry must not. registry.RegisterItemHandle(1, itemHandle: 10, "Tank2.PV", string.Empty, hasItemContext: false); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV")); Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV")); Assert.Single(registry.ItemHandles); Assert.True(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); // Unadvise: the item stays registered and still resolves by its current tag. registry.RemoveAdviceHandles(1, itemHandle: 10); Assert.False(registry.ContainsAdviceHandle(1, 10, MxAccessAdviceKind.Plain)); Assert.Empty(registry.AdviceHandles); Assert.True(registry.ContainsItemHandle(1, 10)); Assert.Equal(new[] { 10 }, registry.GetItemHandlesForDefinition(1, "Tank2.PV")); // Unregister the server: every index drains, including the definition index the re-register // rewrote. registry.UnregisterServerHandle(1); Assert.False(registry.ContainsServerHandle(1)); Assert.False(registry.ContainsItemHandle(1, 10)); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank1.PV")); Assert.Empty(registry.GetItemHandlesForDefinition(1, "Tank2.PV")); Assert.Empty(registry.ServerHandles); Assert.Empty(registry.ItemHandles); Assert.Empty(registry.AdviceHandles); } private static List Map( IReadOnlyList source, Func selector) { List mapped = new(source.Count); for (int index = 0; index < source.Count; index++) { mapped.Add(selector(source[index])); } return mapped; } }