f4b065b9f6
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.
360 lines
18 KiB
C#
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;
|
|
}
|
|
}
|