Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessHandleRegistryTests.cs
T
Joseph Doherty f4b065b9f6
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m18s
ci / java (push) Successful in 2m11s
ci / portable (push) Successful in 11m5s
docs(worker): reviewer follow-up comments and tests from the remediation reviews
The worker-side half of the review tail. Tests and comments only — nothing here
changes worker behavior, and none of it compiles on the macOS tree (net48/x86),
so it was reviewed line by line against the already-windev-validated files.

- MxAccessHandleRegistryTests gains the multi-candidate case behind
  MxAccessSession.TryGetCachedReadFor's fall-through: one tag under two item
  handles, the lower registered-but-unadvised and the higher advised. Asserted
  at the registry rather than the session because the session's read path needs
  a live MXAccess COM instance; what the registry owes the scan is the stable
  ascending candidate order and a per-item-handle (not per-tag) advice index,
  and both are pinned here along with the fall-through contract in prose.
- A single adversarial lifecycle test — register, advise, re-register the same
  item handle under a new tag, unadvise, unregister the server — asserting every
  index agrees after each step. The individual transitions were already covered;
  what was not was that they compose, and a stale entry in any one index
  resurrects a handle MXAccess has already retired.
- StaWaitHelperTests.WaitForSignalOrMessages_PreSignalledHandle_ReturnsImmediately
  drains pending messages first, like the other two wait tests. Without it a
  stale message can end the wait instead of the handle, failing the
  signal-consumed post-condition for an unrelated reason.
- GatewayTesting.md records the two findings from the Task 24 windev gate:
  SecretsStorePathGuardTests.CreateBuilder_AcceptsSecretsStoreOutsideContentRoot_AndCreatesIt
  fails deterministically on Windows on main too (SQLite pooling holds secrets.db
  open across the cleanup's recursive delete; pre-existing, tracked separately),
  and the StaWaitHelper timing tests' flake signature on a loaded box is a
  message wake — the helper working as designed — not a broken wait.
2026-08-15 17:56:16 -04:00

360 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Unit tests for <see cref="MxAccessHandleRegistry"/>. 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.
/// </summary>
public sealed class MxAccessHandleRegistryTests
{
/// <summary>
/// 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 <see cref="MxAccessHandleRegistry.ItemHandles"/>
/// would have visited them), and misses on the wrong server, a different
/// tag, and a case-differing tag.
/// </summary>
[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"));
}
/// <summary>
/// Verifies re-registering an item handle under a new item definition
/// retires the old reverse-index entry instead of leaving a stale hit.
/// </summary>
[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);
}
/// <summary>
/// Verifies the reverse index drops handles as they are removed, both for
/// a single item removal and for a whole-server teardown.
/// </summary>
[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"));
}
/// <summary>
/// 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.
/// </summary>
[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<RegisteredServerHandle> servers = registry.ServerHandles;
IReadOnlyList<RegisteredItemHandle> items = registry.ItemHandles;
IReadOnlyList<RegisteredAdviceHandle> 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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// Pins the registry half of <c>MxAccessSession.TryGetCachedReadFor</c>'s fall-through
/// contract. That scan walks <see cref="MxAccessHandleRegistry.GetItemHandlesForDefinition"/>
/// 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 <c>OnDataChange</c> 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.
/// </summary>
[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<int> 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));
}
/// <summary>
/// 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.
/// </summary>
[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<TResult> Map<TSource, TResult>(
IReadOnlyList<TSource> source,
Func<TSource, TResult> selector)
{
List<TResult> mapped = new(source.Count);
for (int index = 0; index < source.Count; index++)
{
mapped.Add(selector(source[index]));
}
return mapped;
}
}