docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -47,6 +47,7 @@ public sealed class PermissionTrieBuilderTests
Kind = NodeHierarchyKind.Equipment,
};
/// <summary>Verifies that Build with ScopePaths places UnsLine row at correct multi-level node.</summary>
[Fact]
public void Build_With_ScopePaths_Places_UnsLine_Row_At_Correct_Multi_Level_Node()
{
@@ -73,6 +74,7 @@ public sealed class PermissionTrieBuilderTests
"grant anchored at line-42 must not leak to sibling line-99 under the same area");
}
/// <summary>Verifies that Build without ScopePaths falls back to root child for deterministic tests.</summary>
[Fact]
public void Build_Without_ScopePaths_Falls_Back_To_Root_Child_For_Tests()
{
@@ -117,6 +119,7 @@ public sealed class PermissionTrieBuilderTests
diagnostics[0].Reason.ShouldBe(PermissionTrieBuildDiagnosticReason.MissingScopePath);
}
/// <summary>Verifies that no diagnostic is emitted when all sub-cluster rows have ScopePaths.</summary>
[Fact]
public void Build_No_Diagnostic_When_All_Sub_Cluster_Rows_Have_ScopePaths()
{
@@ -138,6 +141,7 @@ public sealed class PermissionTrieBuilderTests
diagnostics.ShouldBeEmpty("no rows are missing a scope-path entry");
}
/// <summary>Verifies that diagnostic callback is optional when ScopePaths is null.</summary>
[Fact]
public void Build_Diagnostic_Callback_Optional_When_ScopePaths_Null()
{
@@ -13,12 +13,14 @@ public sealed class PermissionTrieCacheTests
GenerationId = generation,
};
/// <summary>Verifies that GetTrie returns null when the cache is empty.</summary>
[Fact]
public void GetTrie_Empty_ReturnsNull()
{
new PermissionTrieCache().GetTrie("c1").ShouldBeNull();
}
/// <summary>Verifies that a trie installed can be retrieved with matching generation id.</summary>
[Fact]
public void Install_ThenGet_RoundTrips()
{
@@ -29,6 +31,7 @@ public sealed class PermissionTrieCacheTests
cache.CurrentGenerationId("c1").ShouldBe(5);
}
/// <summary>Verifies that installing a new generation makes it the current generation.</summary>
[Fact]
public void NewGeneration_BecomesCurrent()
{
@@ -41,6 +44,7 @@ public sealed class PermissionTrieCacheTests
cache.GetTrie("c1", 2).ShouldNotBeNull();
}
/// <summary>Verifies that out-of-order installs do not downgrade the current generation.</summary>
[Fact]
public void OutOfOrder_Install_DoesNotDowngrade_Current()
{
@@ -52,6 +56,7 @@ public sealed class PermissionTrieCacheTests
cache.GetTrie("c1", 1).ShouldNotBeNull("but older is still retrievable by explicit lookup");
}
/// <summary>Verifies that Invalidate drops only the specified cluster.</summary>
[Fact]
public void Invalidate_DropsCluster()
{
@@ -65,6 +70,7 @@ public sealed class PermissionTrieCacheTests
cache.GetTrie("c2").ShouldNotBeNull("sibling cluster unaffected");
}
/// <summary>Verifies that Prune retains the most recent generations.</summary>
[Fact]
public void Prune_RetainsMostRecent()
{
@@ -79,6 +85,7 @@ public sealed class PermissionTrieCacheTests
cache.GetTrie("c1", 1).ShouldBeNull();
}
/// <summary>Verifies that Prune with keepLatest greater than the cache size is a no-op.</summary>
[Fact]
public void Prune_LessThanKeep_IsNoOp()
{
@@ -91,6 +98,7 @@ public sealed class PermissionTrieCacheTests
cache.CachedTrieCount.ShouldBe(2);
}
/// <summary>Verifies that different clusters maintain independent generation tracking.</summary>
[Fact]
public void ClusterIsolation()
{
@@ -125,6 +133,7 @@ public sealed class PermissionTrieCacheTests
cache.GetTrie("c1", 4).ShouldBeNull("generation 4 was pruned");
}
/// <summary>Verifies that the current generation pointer is preserved after pruning.</summary>
[Fact]
public void Prune_Current_Pointer_Survives_Pruning()
{
@@ -43,6 +43,7 @@ public sealed class PermissionTrieTests
Kind = NodeHierarchyKind.SystemPlatform,
};
/// <summary>Verifies cluster-level grant cascades to every tag.</summary>
[Fact]
public void ClusterLevelGrant_Cascades_ToEveryTag()
{
@@ -58,6 +59,7 @@ public sealed class PermissionTrieTests
matches[0].Scope.ShouldBe(NodeAclScopeKind.Cluster);
}
/// <summary>Verifies equipment-scope grant does not leak to sibling.</summary>
[Fact]
public void EquipmentScope_DoesNotLeak_ToSibling()
{
@@ -75,6 +77,7 @@ public sealed class PermissionTrieTests
matchB.ShouldBeEmpty("grant at eq-A must not apply to sibling eq-B");
}
/// <summary>Verifies multiple groups union their permission flags.</summary>
[Fact]
public void MultiGroup_Union_OrsPermissionFlags()
{
@@ -94,6 +97,7 @@ public sealed class PermissionTrieTests
combined.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
}
/// <summary>Verifies no matching group returns empty.</summary>
[Fact]
public void NoMatchingGroup_ReturnsEmpty()
{
@@ -107,6 +111,7 @@ public sealed class PermissionTrieTests
matches.ShouldBeEmpty();
}
/// <summary>Verifies Galaxy folder segment grant does not leak to sibling folder.</summary>
[Fact]
public void Galaxy_FolderSegment_Grant_DoesNotLeak_To_Sibling_Folder()
{
@@ -147,6 +152,7 @@ public sealed class PermissionTrieTests
"the trie walk reports the structural level where the grant was found — FolderSegment for Galaxy, not Equipment");
}
/// <summary>Verifies Galaxy deep folder path all segments report folder segment scope.</summary>
[Fact]
public void Galaxy_DeepFolderPath_AllSegments_Report_FolderSegment_Scope()
{
@@ -173,6 +179,7 @@ public sealed class PermissionTrieTests
"every matched folder level must report FolderSegment, never Equipment");
}
/// <summary>Verifies cross-cluster grant does not leak.</summary>
[Fact]
public void CrossCluster_Grant_DoesNotLeak()
{
@@ -186,6 +193,7 @@ public sealed class PermissionTrieTests
matches.ShouldBeEmpty("rows for cluster c-other must not land in c1's trie");
}
/// <summary>Verifies build is idempotent.</summary>
[Fact]
public void Build_IsIdempotent()
{
@@ -15,7 +15,10 @@ public sealed class TriePermissionEvaluatorTests
private sealed class FakeTimeProvider : TimeProvider
{
/// <summary>Gets or sets the current UTC time.</summary>
public DateTime Utc { get; set; } = Now;
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => new(Utc, TimeSpan.Zero);
}
@@ -62,6 +65,7 @@ public sealed class TriePermissionEvaluatorTests
return new TriePermissionEvaluator(cache, _time);
}
/// <summary>Verifies that authorization is allowed when required permission flags are matched.</summary>
[Fact]
public void Allow_When_RequiredFlag_Matched()
{
@@ -73,6 +77,7 @@ public sealed class TriePermissionEvaluatorTests
decision.Provenance.Count.ShouldBe(1);
}
/// <summary>Verifies that authorization is denied when no matching group is found.</summary>
[Fact]
public void NotGranted_When_NoMatchingGroup()
{
@@ -84,6 +89,7 @@ public sealed class TriePermissionEvaluatorTests
decision.Provenance.ShouldBeEmpty();
}
/// <summary>Verifies that authorization is denied when permission flags are insufficient.</summary>
[Fact]
public void NotGranted_When_FlagsInsufficient()
{
@@ -94,6 +100,7 @@ public sealed class TriePermissionEvaluatorTests
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
}
/// <summary>Verifies that HistoryRead permission requires its own flag and is not implied by Read.</summary>
[Fact]
public void HistoryRead_Requires_Its_Own_Bit()
{
@@ -107,6 +114,7 @@ public sealed class TriePermissionEvaluatorTests
historyRead.IsAllowed.ShouldBeFalse("HistoryRead uses its own NodePermissions flag, not Read");
}
/// <summary>Verifies that cross-cluster sessions are denied.</summary>
[Fact]
public void CrossCluster_Session_Denied()
{
@@ -118,6 +126,7 @@ public sealed class TriePermissionEvaluatorTests
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
}
/// <summary>Verifies that stale sessions fail closed.</summary>
[Fact]
public void StaleSession_FailsClosed()
{
@@ -130,6 +139,7 @@ public sealed class TriePermissionEvaluatorTests
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
}
/// <summary>Verifies that authorization is denied when no cached trie exists for the cluster.</summary>
[Fact]
public void NoCachedTrie_ForCluster_Denied()
{
@@ -141,6 +151,7 @@ public sealed class TriePermissionEvaluatorTests
decision.Verdict.ShouldBe(AuthorizationVerdict.NotGranted);
}
/// <summary>Verifies that stale generations evaluate against their bound session generation.</summary>
[Fact]
public void StaleGeneration_EvaluatesAgainst_SessionBoundGeneration()
{
@@ -161,6 +172,7 @@ public sealed class TriePermissionEvaluatorTests
decision.Provenance.Count.ShouldBe(1);
}
/// <summary>Verifies that stale generations fail closed when the bound generation is pruned.</summary>
[Fact]
public void StaleGeneration_FailsClosed_WhenBoundGenerationPruned()
{
@@ -179,6 +191,7 @@ public sealed class TriePermissionEvaluatorTests
"a session bound to a generation absent from the cache must fail closed");
}
/// <summary>Verifies that the operation-to-permission mapping is total.</summary>
[Fact]
public void OperationToPermission_Mapping_IsTotal()
{
@@ -19,6 +19,7 @@ public sealed class UserAuthorizationStateTests
MembershipVersion = 1,
};
/// <summary>Verifies that freshly resolved authorization state is neither stale nor needs refresh.</summary>
[Fact]
public void FreshlyResolved_Is_NotStale_NorNeedsRefresh()
{
@@ -28,6 +29,7 @@ public sealed class UserAuthorizationStateTests
session.NeedsRefresh(Now.AddMinutes(1)).ShouldBeFalse();
}
/// <summary>Verifies that refresh flag fires after the freshness interval expires.</summary>
[Fact]
public void NeedsRefresh_FiresAfter_FreshnessInterval()
{
@@ -36,6 +38,7 @@ public sealed class UserAuthorizationStateTests
session.NeedsRefresh(Now.AddMinutes(16)).ShouldBeFalse("past freshness but also past the 15-min staleness ceiling — should be Stale, not NeedsRefresh");
}
/// <summary>Verifies that the refresh flag fires within the production default freshness and staleness windows.</summary>
[Fact]
public void NeedsRefresh_FiresWithin_ProductionDefault_Windows()
{
@@ -50,6 +53,7 @@ public sealed class UserAuthorizationStateTests
session.IsStale(Now.AddMinutes(10)).ShouldBeFalse("10 min is still within the 15-min staleness ceiling");
}
/// <summary>Verifies that the refresh flag is true between the freshness and staleness windows.</summary>
[Fact]
public void NeedsRefresh_TrueBetween_Freshness_And_Staleness_Windows()
{
@@ -64,6 +68,7 @@ public sealed class UserAuthorizationStateTests
session.IsStale(Now.AddMinutes(5)).ShouldBeFalse();
}
/// <summary>Verifies that the stale flag is true after the staleness window expires.</summary>
[Fact]
public void IsStale_TrueAfter_StalenessWindow()
{
@@ -10,11 +10,21 @@ public sealed class DriverHostTests
{
private sealed class StubDriver(string id, bool failInit = false) : IDriver
{
/// <summary>Gets the driver instance identifier.</summary>
public string DriverInstanceId { get; } = id;
/// <summary>Gets the driver type name.</summary>
public string DriverType => "Stub";
/// <summary>Gets a value indicating whether the driver has been initialized.</summary>
public bool Initialized { get; private set; }
/// <summary>Gets a value indicating whether the driver has been shut down.</summary>
public bool ShutDown { get; private set; }
/// <summary>Initializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in stub).</param>
/// <param name="ct">The cancellation token.</param>
public Task InitializeAsync(string _, CancellationToken ct)
{
if (failInit) throw new InvalidOperationException("boom");
@@ -22,14 +32,28 @@ public sealed class DriverHostTests
return Task.CompletedTask;
}
/// <summary>Reinitializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in stub).</param>
/// <param name="ct">The cancellation token.</param>
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; }
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
/// <summary>Verifies that registering a driver initializes it and tracks its health.</summary>
[Fact]
public async Task Register_initializes_driver_and_tracks_health()
{
@@ -43,6 +67,7 @@ public sealed class DriverHostTests
host.GetHealth("d-1")!.State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies that registration rethrows initialization failures but keeps the driver registered.</summary>
[Fact]
public async Task Register_rethrows_init_failure_but_keeps_driver_registered()
{
@@ -55,6 +80,7 @@ public sealed class DriverHostTests
host.RegisteredDriverIds.ShouldContain("d-bad");
}
/// <summary>Verifies that duplicate driver registration throws an exception.</summary>
[Fact]
public async Task Duplicate_registration_throws()
{
@@ -65,6 +91,7 @@ public sealed class DriverHostTests
host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None));
}
/// <summary>Verifies that unregistering a driver shuts it down and removes it.</summary>
[Fact]
public async Task Unregister_shuts_down_and_removes()
{
@@ -109,6 +136,7 @@ public sealed class DriverHostTests
"RegisterAsync's awaited driver call must use ConfigureAwait(false) so the continuation does not post back to the captured context");
}
/// <summary>Verifies that UnregisterAsync does not capture the synchronization context.</summary>
[Fact]
public async Task UnregisterAsync_Does_Not_Capture_SynchronizationContext()
{
@@ -136,6 +164,7 @@ public sealed class DriverHostTests
"UnregisterAsync's awaited shutdown call must use ConfigureAwait(false)");
}
/// <summary>Verifies that DisposeAsync does not capture the synchronization context.</summary>
[Fact]
public async Task DisposeAsync_Does_Not_Capture_SynchronizationContext()
{
@@ -196,14 +225,34 @@ public sealed class DriverHostTests
/// <summary>Driver whose Initialize / Shutdown completions are caller-controlled via TCS.</summary>
private sealed class TcsDriver(string id, TaskCompletionSource initTcs, TaskCompletionSource? shutdownTcs = null) : IDriver
{
/// <summary>Gets the driver instance identifier.</summary>
public string DriverInstanceId { get; } = id;
/// <summary>Gets the driver type name.</summary>
public string DriverType => "Tcs";
/// <summary>Initializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in TCS driver).</param>
/// <param name="ct">The cancellation token.</param>
public Task InitializeAsync(string _, CancellationToken ct) => initTcs.Task;
/// <summary>Reinitializes the driver asynchronously.</summary>
/// <param name="_">Configuration data (unused in TCS driver).</param>
/// <param name="ct">The cancellation token.</param>
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task ShutdownAsync(CancellationToken ct) => (shutdownTcs ?? CompletedTcs).Task;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
@@ -222,17 +271,28 @@ public sealed class DriverHostTests
public int PostCount;
public int SendCount;
/// <summary>Posts a callback to the work queue.</summary>
/// <inheritdoc />
public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref PostCount);
_queue.Enqueue(() => d(state));
}
/// <summary>Sends a callback synchronously.</summary>
/// <inheritdoc />
public override void Send(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref SendCount);
d(state);
}
/// <summary>Attempts to dequeue a work item from the queue.</summary>
/// <param name="work">The dequeued work item if one was available.</param>
/// <returns>True if a work item was dequeued; otherwise false.</returns>
public bool TryDequeue(out Action work) => _queue.TryDequeue(out work!);
/// <summary>Resets the post and send counts.</summary>
public void Reset() { Interlocked.Exchange(ref PostCount, 0); Interlocked.Exchange(ref SendCount, 0); }
}
}
@@ -44,6 +44,7 @@ public sealed class GenericDriverNodeManagerTests
builder.Alarms["Heater.OverTemp"].Received.Count.ShouldBe(0);
}
/// <summary>Verifies that non-alarm variables do not register sinks in the alarm tracker.</summary>
[Fact]
public async Task Non_alarm_variables_do_not_register_sinks()
{
@@ -57,6 +58,7 @@ public sealed class GenericDriverNodeManagerTests
nm.TrackedAlarmSources.ShouldNotContain("Tank.Level"); // the plain one
}
/// <summary>Verifies that alarm events with unknown source node IDs are silently dropped.</summary>
[Fact]
public async Task Unknown_source_node_id_is_dropped_silently()
{
@@ -71,6 +73,7 @@ public sealed class GenericDriverNodeManagerTests
builder.Alarms.Values.All(s => s.Received.Count == 0).ShouldBeTrue();
}
/// <summary>Verifies that disposing the node manager unsubscribes from alarm events.</summary>
[Fact]
public async Task Dispose_unsubscribes_from_OnAlarmEvent()
{
@@ -117,6 +120,7 @@ public sealed class GenericDriverNodeManagerTests
"the original alarm forwarder must be unsubscribed on the second build");
}
/// <summary>Verifies that a second call to BuildAddressSpaceAsync clears the old sink registry.</summary>
[Fact]
public async Task Second_BuildAddressSpaceAsync_Clears_Old_Sink_Registry()
{
@@ -132,6 +136,7 @@ public sealed class GenericDriverNodeManagerTests
countAfterSecond.ShouldBe(2, "second build must re-register exactly the same sources, not accumulate");
}
/// <summary>Verifies that calling BuildAddressSpaceAsync after disposal throws ObjectDisposedException.</summary>
[Fact]
public async Task BuildAddressSpaceAsync_After_Dispose_Throws_ObjectDisposedException()
{
@@ -164,16 +169,33 @@ public sealed class GenericDriverNodeManagerTests
/// <summary>Driver whose DiscoverAsync throws — exercises the exception-isolation boundary.</summary>
private sealed class ThrowingDiscoveryDriver : IDriver, ITagDiscovery
{
/// <summary>Gets the driver instance identifier.</summary>
public string DriverInstanceId => "throwing";
/// <summary>Gets the driver type name.</summary>
public string DriverType => "Throwing";
/// <summary>Initializes the driver with configuration.</summary>
/// <param name="_">Configuration JSON (unused in test double).</param>
/// <param name="__">Cancellation token (unused in test double).</param>
public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
/// <summary>Reinitializes the driver with new configuration.</summary>
/// <param name="_">Configuration JSON (unused in test double).</param>
/// <param name="__">Cancellation token (unused in test double).</param>
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
/// <summary>Shuts down the driver.</summary>
/// <param name="_">Cancellation token (unused in test double).</param>
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="_">Cancellation token (unused in test double).</param>
public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask;
/// <summary>Discovers the address space by throwing an exception.</summary>
/// <param name="builder">The builder used to construct the address space.</param>
/// <param name="ct">Cancellation token.</param>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
=> throw new InvalidOperationException("discovery boom");
}
@@ -182,17 +204,35 @@ public sealed class GenericDriverNodeManagerTests
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource
{
/// <summary>Gets the driver instance identifier.</summary>
public string DriverInstanceId => "fake";
/// <summary>Gets the driver type name.</summary>
public string DriverType => "Fake";
/// <summary>Occurs when an alarm event is raised.</summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
/// <summary>Initializes the driver with configuration.</summary>
/// <param name="driverConfigJson">Configuration JSON.</param>
/// <param name="ct">Cancellation token.</param>
public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
/// <summary>Reinitializes the driver with new configuration.</summary>
/// <param name="driverConfigJson">Configuration JSON.</param>
/// <param name="ct">Cancellation token.</param>
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver.</summary>
/// <param name="ct">Cancellation token.</param>
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="ct">Cancellation token.</param>
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Discovers the address space and registers alarm conditions.</summary>
/// <param name="builder">The builder used to construct the address space.</param>
/// <param name="ct">Cancellation token.</param>
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
var folder = builder.Folder("Tank", "Tank");
@@ -209,33 +249,63 @@ public sealed class GenericDriverNodeManagerTests
return Task.CompletedTask;
}
/// <summary>Raises an alarm event with the given arguments.</summary>
/// <param name="args">The alarm event arguments.</param>
public void RaiseAlarm(AlarmEventArgs args) => OnAlarmEvent?.Invoke(this, args);
/// <summary>Subscribes to alarm events.</summary>
/// <param name="_">Tag references to subscribe to (unused in test double).</param>
/// <param name="__">Cancellation token (unused in test double).</param>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(IReadOnlyList<string> _, CancellationToken __)
=> Task.FromResult<IAlarmSubscriptionHandle>(new FakeHandle("sub"));
/// <summary>Unsubscribes from alarm events.</summary>
/// <param name="_">The subscription handle (unused in test double).</param>
/// <param name="__">Cancellation token (unused in test double).</param>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle _, CancellationToken __) => Task.CompletedTask;
/// <summary>Acknowledges alarm notifications.</summary>
/// <param name="_">Alarm acknowledgement requests (unused in test double).</param>
/// <param name="__">Cancellation token (unused in test double).</param>
public Task AcknowledgeAsync(IReadOnlyList<AlarmAcknowledgeRequest> _, CancellationToken __) => Task.CompletedTask;
}
/// <summary>Test double for IAlarmSubscriptionHandle.</summary>
private sealed class FakeHandle(string diagnosticId) : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this subscription.</summary>
public string DiagnosticId { get; } = diagnosticId;
}
/// <summary>Test double for IAddressSpaceBuilder that records alarm sinks.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the map of alarm sources to their sinks.</summary>
public Dictionary<string, RecordingSink> Alarms { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Creates a folder in the address space.</summary>
/// <param name="_">The contained name (unused in test double).</param>
/// <param name="__">The display name (unused in test double).</param>
public IAddressSpaceBuilder Folder(string _, string __) => this;
/// <summary>Creates a variable in the address space.</summary>
/// <param name="_">The contained name (unused in test double).</param>
/// <param name="__">The display name (unused in test double).</param>
/// <param name="info">The driver attribute information.</param>
public IVariableHandle Variable(string _, string __, DriverAttributeInfo info)
=> new Handle(info.FullName, Alarms);
/// <summary>Adds a property to the current variable.</summary>
/// <param name="_">The property name (unused in test double).</param>
/// <param name="__">The data type (unused in test double).</param>
/// <param name="___">The initial value (unused in test double).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
/// <summary>Test double for IVariableHandle.</summary>
public sealed class Handle(string fullRef, Dictionary<string, RecordingSink> alarms) : IVariableHandle
{
/// <summary>Gets the full reference name for this variable.</summary>
public string FullReference { get; } = fullRef;
/// <summary>Marks this variable as an alarm condition and registers its sink.</summary>
/// <param name="_">The alarm condition info (unused in test double).</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo _)
{
var sink = new RecordingSink();
@@ -244,9 +314,13 @@ public sealed class GenericDriverNodeManagerTests
}
}
/// <summary>Test double for IAlarmConditionSink that records transitions.</summary>
public sealed class RecordingSink : IAlarmConditionSink
{
/// <summary>Gets the list of alarm transitions received by this sink.</summary>
public List<AlarmEventArgs> Received { get; } = new();
/// <summary>Records an alarm transition.</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
}
}
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
[Trait("Category", "Integration")]
public sealed class CapabilityInvokerEnrichmentTests
{
/// <summary>Verifies that InvokerExecute logs inside call site with structured properties.</summary>
[Fact]
public async Task InvokerExecute_LogsInsideCallSite_CarryStructuredProperties()
{
@@ -43,6 +44,7 @@ public sealed class CapabilityInvokerEnrichmentTests
evt.Properties.ShouldContainKey("CorrelationId");
}
/// <summary>Verifies that InvokerExecute does not leak context outside the call site.</summary>
[Fact]
public async Task InvokerExecute_DoesNotLeak_ContextOutsideCallSite()
{
@@ -66,7 +68,10 @@ public sealed class CapabilityInvokerEnrichmentTests
private sealed class InMemorySink : ILogEventSink
{
/// <summary>Gets the list of captured log events.</summary>
public List<LogEvent> Events { get; } = [];
/// <summary>Emits a log event by adding it to the captured events list.</summary>
/// <param name="logEvent">The log event to emit.</param>
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
}
@@ -8,12 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
[Trait("Category", "Unit")]
public sealed class DriverHealthReportTests
{
/// <summary>Verifies that an empty fleet is healthy.</summary>
[Fact]
public void EmptyFleet_IsHealthy()
{
DriverHealthReport.Aggregate([]).ShouldBe(ReadinessVerdict.Healthy);
}
/// <summary>Verifies that a fleet with all healthy drivers is healthy.</summary>
[Fact]
public void AllHealthy_Fleet_IsHealthy()
{
@@ -24,6 +26,7 @@ public sealed class DriverHealthReportTests
verdict.ShouldBe(ReadinessVerdict.Healthy);
}
/// <summary>Verifies that any faulted driver trumps other states.</summary>
[Fact]
public void AnyFaulted_TrumpsEverything()
{
@@ -36,6 +39,8 @@ public sealed class DriverHealthReportTests
verdict.ShouldBe(ReadinessVerdict.Faulted);
}
/// <summary>Verifies that any not-ready driver without faults results in NotReady verdict.</summary>
/// <param name="initializingState">The driver state representing a not-ready condition.</param>
[Theory]
[InlineData(DriverState.Unknown)]
[InlineData(DriverState.Initializing)]
@@ -48,6 +53,7 @@ public sealed class DriverHealthReportTests
verdict.ShouldBe(ReadinessVerdict.NotReady);
}
/// <summary>Verifies that any degraded driver without faults or not-ready results in Degraded verdict.</summary>
[Fact]
public void Any_Degraded_WithoutFaultedOrNotReady_IsDegraded()
{
@@ -58,6 +64,9 @@ public sealed class DriverHealthReportTests
verdict.ShouldBe(ReadinessVerdict.Degraded);
}
/// <summary>Verifies that HTTP status codes match the readiness verdict state matrix.</summary>
/// <param name="verdict">The readiness verdict to test.</param>
/// <param name="expected">The expected HTTP status code.</param>
[Theory]
[InlineData(ReadinessVerdict.Healthy, 200)]
[InlineData(ReadinessVerdict.Degraded, 200)]
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
[Trait("Category", "Unit")]
public sealed class LogContextEnricherTests
{
/// <summary>Verifies that the scope attaches all four log context properties.</summary>
[Fact]
public void Scope_Attaches_AllFour_Properties()
{
@@ -32,6 +33,7 @@ public sealed class LogContextEnricherTests
evt.Properties["CorrelationId"].ToString().ShouldBe("\"abc123\"");
}
/// <summary>Verifies that scope disposal pops the log context properties.</summary>
[Fact]
public void Scope_Dispose_Pops_Properties()
{
@@ -52,6 +54,7 @@ public sealed class LogContextEnricherTests
captured.Events[1].Properties.ContainsKey("DriverInstanceId").ShouldBeFalse();
}
/// <summary>Verifies that NewCorrelationId returns a 12-character hexadecimal string.</summary>
[Fact]
public void NewCorrelationId_Returns_12_Hex_Chars()
{
@@ -60,6 +63,8 @@ public sealed class LogContextEnricherTests
id.ShouldMatch("^[0-9a-f]{12}$");
}
/// <summary>Verifies that Push throws when DriverInstanceId is missing or empty.</summary>
/// <param name="id">The driver instance ID value to test, or null.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -72,7 +77,10 @@ public sealed class LogContextEnricherTests
private sealed class InMemorySink : ILogEventSink
{
/// <summary>Gets the list of captured log events.</summary>
public List<LogEvent> Events { get; } = [];
/// <summary>Emits a log event to the sink.</summary>
/// <param name="logEvent">The log event to emit.</param>
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
}
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
[Trait("Category", "Unit")]
public sealed class EquipmentNodeWalkerTests
{
/// <summary>Verifies that walking empty content emits no nodes.</summary>
[Fact]
public void Walk_EmptyContent_EmitsNothing()
{
@@ -18,6 +19,7 @@ public sealed class EquipmentNodeWalkerTests
rec.Children.ShouldBeEmpty();
}
/// <summary>Verifies that walking emits Area, Line, and Equipment folders in unsorted order.</summary>
[Fact]
public void Walk_EmitsArea_Line_Equipment_Folders_In_UnsOrder()
{
@@ -36,6 +38,7 @@ public sealed class EquipmentNodeWalkerTests
warsaw.Children[0].Children.Select(c => c.BrowseName).ShouldBe(["oven-3"]);
}
/// <summary>Verifies that walking adds five identifier properties on equipment nodes, skipping null ZTag and SAPID.</summary>
[Fact]
public void Walk_AddsFiveIdentifierProperties_OnEquipmentNode_Skipping_NullZTagSapid()
{
@@ -61,6 +64,7 @@ public sealed class EquipmentNodeWalkerTests
equipmentNode.Properties.First(p => p.BrowseName == "EquipmentUuid").Value.ShouldBe(uuid.ToString());
}
/// <summary>Verifies that walking adds ZTag and SAPID properties when present.</summary>
[Fact]
public void Walk_Adds_ZTag_And_SAPID_When_Present()
{
@@ -78,6 +82,7 @@ public sealed class EquipmentNodeWalkerTests
equipmentNode.Properties.First(p => p.BrowseName == "SAPID").Value.ShouldBe("10000042");
}
/// <summary>Verifies that walking materializes an Identification subfolder when any identification field is present.</summary>
[Fact]
public void Walk_Materializes_Identification_Subfolder_When_AnyFieldPresent()
{
@@ -97,6 +102,7 @@ public sealed class EquipmentNodeWalkerTests
identification.Properties.Select(p => p.BrowseName).ShouldContain("Model");
}
/// <summary>Verifies that walking omits the Identification subfolder when all identification fields are null.</summary>
[Fact]
public void Walk_Omits_Identification_Subfolder_When_AllFieldsNull()
{
@@ -111,6 +117,7 @@ public sealed class EquipmentNodeWalkerTests
equipmentNode.Children.ShouldNotContain(c => c.BrowseName == "Identification");
}
/// <summary>Verifies that walking emits a variable for each bound tag under equipment.</summary>
[Fact]
public void Walk_Emits_Variable_Per_BoundTag_Under_Equipment()
{
@@ -132,6 +139,7 @@ public sealed class EquipmentNodeWalkerTests
equipmentNode.Variables.First(v => v.BrowseName == "Setpoint").AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
}
/// <summary>Verifies that walking falls back to String type for unparseable data types.</summary>
[Fact]
public void Walk_FallsBack_To_String_For_Unparseable_DataType()
{
@@ -147,6 +155,7 @@ public sealed class EquipmentNodeWalkerTests
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
}
/// <summary>Verifies that walking emits virtual tag variables with Virtual source discriminator.</summary>
[Fact]
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
{
@@ -173,6 +182,7 @@ public sealed class EquipmentNodeWalkerTests
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
}
/// <summary>Verifies that walking emits scripted alarm variables with ScriptedAlarm source and IsAlarm flag.</summary>
[Fact]
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
{
@@ -199,6 +209,7 @@ public sealed class EquipmentNodeWalkerTests
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
}
/// <summary>Verifies that walking skips disabled virtual tags and alarms.</summary>
[Fact]
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
{
@@ -226,6 +237,7 @@ public sealed class EquipmentNodeWalkerTests
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
}
/// <summary>Verifies that walking with null virtual tags and scripted alarms is safe.</summary>
[Fact]
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
{
@@ -240,6 +252,7 @@ public sealed class EquipmentNodeWalkerTests
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
}
/// <summary>Verifies that driver tag default NodeSourceKind is Driver.</summary>
[Fact]
public void Driver_tag_default_NodeSourceKind_is_Driver()
{
@@ -258,6 +271,7 @@ public sealed class EquipmentNodeWalkerTests
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
}
/// <summary>Verifies that ExtractFullName unwraps a JSON object with FullName field.</summary>
[Fact]
public void ExtractFullName_unwraps_json_object_with_FullName_field()
{
@@ -266,6 +280,7 @@ public sealed class EquipmentNodeWalkerTests
.ShouldBe("MESReceiver_001.MoveInBatchID");
}
/// <summary>Verifies that ExtractFullName handles S7-style extra fields.</summary>
[Fact]
public void ExtractFullName_handles_S7_style_extra_fields()
{
@@ -274,6 +289,7 @@ public sealed class EquipmentNodeWalkerTests
.ShouldBe("DB1_DBW0");
}
/// <summary>Verifies that ExtractFullName returns raw string when input is not JSON.</summary>
[Fact]
public void ExtractFullName_returns_raw_when_not_json()
{
@@ -282,6 +298,7 @@ public sealed class EquipmentNodeWalkerTests
EquipmentNodeWalker.ExtractFullName("raw-tag-ref").ShouldBe("raw-tag-ref");
}
/// <summary>Verifies that ExtractFullName returns raw string when JSON is missing FullName field.</summary>
[Fact]
public void ExtractFullName_returns_raw_when_json_missing_FullName_field()
{
@@ -289,6 +306,7 @@ public sealed class EquipmentNodeWalkerTests
.ShouldBe("{\"Address\":\"DB1.DBW0\"}");
}
/// <summary>Verifies that driver tag FullName passes through from TagConfig JSON.</summary>
[Fact]
public void Driver_tag_FullName_passes_through_from_TagConfig_json()
{
@@ -347,13 +365,21 @@ public sealed class EquipmentNodeWalkerTests
// ----- recording IAddressSpaceBuilder -----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
private sealed class RecordingBuilder(string browseName) : IAddressSpaceBuilder
{
/// <summary>Gets the browse name of this node.</summary>
public string BrowseName { get; } = browseName;
/// <summary>Gets the list of child nodes.</summary>
public List<RecordingBuilder> Children { get; } = new();
/// <summary>Gets the list of variables.</summary>
public List<RecordingVariable> Variables { get; } = new();
/// <summary>Gets the list of properties.</summary>
public List<RecordingProperty> Properties { get; } = new();
/// <summary>Creates a folder child node.</summary>
/// <param name="name">The browse name of the folder.</param>
/// <param name="_">The display name (unused).</param>
public IAddressSpaceBuilder Folder(string name, string _)
{
var child = new RecordingBuilder(name);
@@ -361,6 +387,10 @@ public sealed class EquipmentNodeWalkerTests
return child;
}
/// <summary>Creates a variable node.</summary>
/// <param name="name">The browse name of the variable.</param>
/// <param name="_">The display name (unused).</param>
/// <param name="attr">The attribute information for the variable.</param>
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
{
var v = new RecordingVariable(name, attr);
@@ -368,15 +398,24 @@ public sealed class EquipmentNodeWalkerTests
return v;
}
/// <summary>Adds a property to the node.</summary>
/// <param name="name">The browse name of the property.</param>
/// <param name="_">The data type (unused).</param>
/// <param name="value">The value of the property.</param>
public void AddProperty(string name, DriverDataType _, object? value) =>
Properties.Add(new RecordingProperty(name, value));
}
/// <summary>Recorded property for test verification.</summary>
private sealed record RecordingProperty(string BrowseName, object? Value);
/// <summary>Recorded variable for test verification.</summary>
private sealed record RecordingVariable(string BrowseName, DriverAttributeInfo AttributeInfo) : IVariableHandle
{
/// <summary>Gets the full reference of the variable.</summary>
public string FullReference => AttributeInfo.FullName;
/// <summary>Marks the variable as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
}
}
@@ -9,20 +9,35 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.OpcUa;
[Trait("Category", "Unit")]
public sealed class IdentificationFolderBuilderTests
{
/// <summary>Records folder and property additions for test verification.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets or sets the list of added folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
/// <summary>Gets or sets the list of added properties.</summary>
public List<(string BrowseName, DriverDataType DataType, object? Value)> Properties { get; } = [];
/// <summary>Records a folder and returns this builder for chaining.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add((browseName, displayName));
return this; // flat recording — identification fields land in the same bucket
}
/// <summary>Not supported in test context.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="attributeInfo">The attribute information.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> throw new NotSupportedException("Identification fields use AddProperty, not Variable");
/// <summary>Records a property addition.</summary>
/// <param name="browseName">The browse name of the property.</param>
/// <param name="dataType">The data type of the property.</param>
/// <param name="value">The property value.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> Properties.Add((browseName, dataType, value));
}
@@ -54,12 +69,14 @@ public sealed class IdentificationFolderBuilderTests
DeviceManualUri = "https://siemens.example/manual",
};
/// <summary>Verifies that HasAnyFields returns false when all fields are null.</summary>
[Fact]
public void HasAnyFields_AllNull_ReturnsFalse()
{
IdentificationFolderBuilder.HasAnyFields(EmptyEquipment()).ShouldBeFalse();
}
/// <summary>Verifies that HasAnyFields returns true when at least one field is non-null.</summary>
[Fact]
public void HasAnyFields_OneNonNull_ReturnsTrue()
{
@@ -68,6 +85,7 @@ public sealed class IdentificationFolderBuilderTests
IdentificationFolderBuilder.HasAnyFields(eq).ShouldBeTrue();
}
/// <summary>Verifies that Build returns null and emits no folder when all fields are null.</summary>
[Fact]
public void Build_AllNull_ReturnsNull_AndDoesNotEmit_Folder()
{
@@ -80,6 +98,7 @@ public sealed class IdentificationFolderBuilderTests
builder.Properties.ShouldBeEmpty();
}
/// <summary>Verifies that Build emits all nine fields when fully populated.</summary>
[Fact]
public void Build_FullyPopulated_EmitsAllNineFields()
{
@@ -98,6 +117,7 @@ public sealed class IdentificationFolderBuilderTests
"property order matches decision #139 exactly");
}
/// <summary>Verifies that Build emits only non-null fields.</summary>
[Fact]
public void Build_OnlyNonNull_Are_Emitted()
{
@@ -114,6 +134,7 @@ public sealed class IdentificationFolderBuilderTests
["Manufacturer", "SerialNumber", "YearOfConstruction"]);
}
/// <summary>Verifies that YearOfConstruction maps short to Int32 DriverDataType.</summary>
[Fact]
public void YearOfConstruction_Maps_Short_To_Int32_DriverDataType()
{
@@ -128,6 +149,7 @@ public sealed class IdentificationFolderBuilderTests
prop.Value.ShouldBe(2023, "short is widened to int for OPC UA Int32 representation");
}
/// <summary>Verifies that string values round-trip through Build.</summary>
[Fact]
public void Build_StringValues_RoundTrip()
{
@@ -140,6 +162,7 @@ public sealed class IdentificationFolderBuilderTests
builder.Properties.Single(p => p.BrowseName == "DeviceManualUri").Value.ShouldBe("https://siemens.example/manual");
}
/// <summary>Verifies that field names match decision 139 exactly.</summary>
[Fact]
public void FieldNames_Match_Decision139_Exactly()
{
@@ -150,6 +173,7 @@ public sealed class IdentificationFolderBuilderTests
"ManufacturerUri", "DeviceManualUri"]);
}
/// <summary>Verifies that the folder name is Identification.</summary>
[Fact]
public void FolderName_Is_Identification()
{
@@ -10,6 +10,7 @@ public sealed class AlarmSurfaceInvokerTests
{
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
/// <summary>Verifies SubscribeAsync on an empty list returns empty without calling the driver.</summary>
[Fact]
public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall()
{
@@ -22,6 +23,7 @@ public sealed class AlarmSurfaceInvokerTests
driver.SubscribeCallCount.ShouldBe(0);
}
/// <summary>Verifies SubscribeAsync with no resolver routes through the default host.</summary>
[Fact]
public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost()
{
@@ -35,6 +37,7 @@ public sealed class AlarmSurfaceInvokerTests
driver.LastSubscribedIds.ShouldBe(["src-1", "src-2"]);
}
/// <summary>Verifies SubscribeAsync fans out correctly to multiple hosts based on resolver.</summary>
[Fact]
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
{
@@ -53,6 +56,7 @@ public sealed class AlarmSurfaceInvokerTests
driver.SubscribeCallCount.ShouldBe(2); // one driver call per host
}
/// <summary>Verifies AcknowledgeAsync does not retry on failure.</summary>
[Fact]
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
{
@@ -65,6 +69,7 @@ public sealed class AlarmSurfaceInvokerTests
driver.AcknowledgeCallCount.ShouldBe(1, "AlarmAcknowledge must not retry — decision #143");
}
/// <summary>Verifies SubscribeAsync retries on transient failures.</summary>
[Fact]
public async Task SubscribeAsync_Retries_Transient_Failures()
{
@@ -106,6 +111,7 @@ public sealed class AlarmSurfaceInvokerTests
driver.UnsubscribeCallCount.ShouldBe(2, "one unsubscribe per subscription handle (per host)");
}
/// <summary>Verifies UnsubscribeAsync with no resolver uses the default host.</summary>
[Fact]
public async Task UnsubscribeAsync_SingleHost_UsesDefaultHost()
{
@@ -131,15 +137,31 @@ public sealed class AlarmSurfaceInvokerTests
return new AlarmSurfaceInvoker(invoker, driver, defaultHost, resolver);
}
/// <summary>Fake alarm source for testing.</summary>
private sealed class FakeAlarmSource : IAlarmSource
{
/// <summary>Gets the number of times SubscribeAlarmsAsync was called.</summary>
public int SubscribeCallCount { get; private set; }
/// <summary>Gets the number of times UnsubscribeAlarmsAsync was called.</summary>
public int UnsubscribeCallCount { get; private set; }
/// <summary>Gets the number of times AcknowledgeAsync was called.</summary>
public int AcknowledgeCallCount { get; private set; }
/// <summary>Gets or sets the number of failures before SubscribeAlarmsAsync succeeds.</summary>
public int SubscribeFailuresBeforeSuccess { get; set; }
/// <summary>Gets or sets whether AcknowledgeAsync should throw.</summary>
public bool AcknowledgeShouldThrow { get; set; }
/// <summary>Gets the source node IDs from the most recent SubscribeAlarmsAsync call.</summary>
public IReadOnlyList<string> LastSubscribedIds { get; private set; } = [];
/// <summary>Subscribes to alarms.</summary>
/// <param name="sourceNodeIds">The source node IDs to subscribe to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An alarm subscription handle.</returns>
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
@@ -150,12 +172,20 @@ public sealed class AlarmSurfaceInvokerTests
return Task.FromResult<IAlarmSubscriptionHandle>(new StubHandle($"h-{SubscribeCallCount}"));
}
/// <summary>Unsubscribes from alarms.</summary>
/// <param name="handle">The subscription handle to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
{
UnsubscribeCallCount++;
return Task.CompletedTask;
}
/// <summary>Acknowledges alarms.</summary>
/// <param name="acknowledgements">The alarm acknowledgements to process.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A completed task.</returns>
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
@@ -164,13 +194,21 @@ public sealed class AlarmSurfaceInvokerTests
return Task.CompletedTask;
}
/// <summary>Occurs when an alarm event is raised.</summary>
public event EventHandler<AlarmEventArgs>? OnAlarmEvent { add { } remove { } }
}
/// <summary>Stub alarm subscription handle for testing.</summary>
/// <param name="DiagnosticId">Diagnostic identifier for the handle.</param>
private sealed record StubHandle(string DiagnosticId) : IAlarmSubscriptionHandle;
/// <summary>Stub host resolver for testing multi-host scenarios.</summary>
/// <param name="map">The map of source node IDs to host names.</param>
private sealed class StubResolver(Dictionary<string, string> map) : IPerCallHostResolver
{
/// <summary>Resolves the host for the given full reference.</summary>
/// <param name="fullReference">The full reference to resolve.</param>
/// <returns>The resolved host name.</returns>
public string ResolveHost(string fullReference) => map[fullReference];
}
}
@@ -13,6 +13,7 @@ public sealed class CapabilityInvokerTests
DriverResilienceOptions options) =>
new(builder, "drv-test", () => options);
/// <summary>Verifies that the capability invoker returns the value from the call site.</summary>
[Fact]
public async Task Read_ReturnsValue_FromCallSite()
{
@@ -27,6 +28,7 @@ public sealed class CapabilityInvokerTests
result.ShouldBe(42);
}
/// <summary>Verifies that the capability invoker retries on transient failures.</summary>
[Fact]
public async Task Read_Retries_OnTransientFailure()
{
@@ -49,6 +51,7 @@ public sealed class CapabilityInvokerTests
attempts.ShouldBe(2);
}
/// <summary>Verifies that non-idempotent writes do not retry even when the policy has retries configured.</summary>
[Fact]
public async Task Write_NonIdempotent_DoesNotRetry_EvenWhenPolicyHasRetries()
{
@@ -81,6 +84,7 @@ public sealed class CapabilityInvokerTests
attempts.ShouldBe(1, "non-idempotent write must never replay");
}
/// <summary>Verifies that idempotent writes retry when the policy has retries configured.</summary>
[Fact]
public async Task Write_Idempotent_Retries_WhenPolicyHasRetries()
{
@@ -111,6 +115,7 @@ public sealed class CapabilityInvokerTests
attempts.ShouldBe(2);
}
/// <summary>Verifies that writes do not retry when the policy has zero retries configured.</summary>
[Fact]
public async Task Write_Default_DoesNotRetry_WhenPolicyHasZeroRetries()
{
@@ -137,6 +142,7 @@ public sealed class CapabilityInvokerTests
attempts.ShouldBe(1, "tier-A default for Write is RetryCount=0");
}
/// <summary>Verifies that different hosts are honored independently in the resilience pipeline.</summary>
[Fact]
public async Task Execute_HonorsDifferentHosts_Independently()
{
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Unit")]
public sealed class DriverResilienceOptionsParserTests
{
/// <summary>Verifies that null JSON returns pure tier defaults.</summary>
[Fact]
public void NullJson_ReturnsPureTierDefaults()
{
@@ -19,6 +20,7 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
/// <summary>Verifies that whitespace JSON returns defaults.</summary>
[Fact]
public void WhitespaceJson_ReturnsDefaults()
{
@@ -26,6 +28,7 @@ public sealed class DriverResilienceOptionsParserTests
diag.ShouldBeNull();
}
/// <summary>Verifies that malformed JSON falls back with diagnostic.</summary>
[Fact]
public void MalformedJson_FallsBack_WithDiagnostic()
{
@@ -38,6 +41,7 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
/// <summary>Verifies that empty object returns defaults.</summary>
[Fact]
public void EmptyObject_ReturnsDefaults()
{
@@ -48,6 +52,7 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
/// <summary>Verifies that Read override is merged into tier defaults.</summary>
[Fact]
public void ReadOverride_MergedIntoTierDefaults()
{
@@ -72,6 +77,7 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Write]);
}
/// <summary>Verifies that partial policy fills missing fields from tier default.</summary>
[Fact]
public void PartialPolicy_FillsMissingFieldsFromTierDefault()
{
@@ -92,6 +98,7 @@ public sealed class DriverResilienceOptionsParserTests
read.BreakerFailureThreshold.ShouldBe(tierDefault.BreakerFailureThreshold);
}
/// <summary>Verifies that bulkhead overrides are honored.</summary>
[Fact]
public void BulkheadOverrides_AreHonored()
{
@@ -105,6 +112,7 @@ public sealed class DriverResilienceOptionsParserTests
options.BulkheadMaxQueue.ShouldBe(500);
}
/// <summary>Verifies that unknown capability surfaces in diagnostic but does not fail.</summary>
[Fact]
public void UnknownCapability_Surfaces_InDiagnostic_ButDoesNotFail()
{
@@ -125,6 +133,7 @@ public sealed class DriverResilienceOptionsParserTests
DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
/// <summary>Verifies that property names are case insensitive.</summary>
[Fact]
public void PropertyNames_AreCaseInsensitive()
{
@@ -137,6 +146,7 @@ public sealed class DriverResilienceOptionsParserTests
options.BulkheadMaxConcurrent.ShouldBe(42);
}
/// <summary>Verifies that capability name is case insensitive.</summary>
[Fact]
public void CapabilityName_IsCaseInsensitive()
{
@@ -150,6 +160,8 @@ public sealed class DriverResilienceOptionsParserTests
options.Resolve(DriverCapability.Read).RetryCount.ShouldBe(99);
}
/// <summary>Verifies that every tier with empty JSON round-trips its defaults.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -164,6 +176,7 @@ public sealed class DriverResilienceOptionsParserTests
options.Resolve(cap).ShouldBe(DriverResilienceOptions.GetTierDefaults(tier)[cap]);
}
/// <summary>Verifies that RecycleIntervalSeconds on Tier C with positive value parses and surfaces.</summary>
[Fact]
public void RecycleIntervalSeconds_TierC_PositiveValue_ParsesAndSurfaces()
{
@@ -174,6 +187,7 @@ public sealed class DriverResilienceOptionsParserTests
options.RecycleIntervalSeconds.ShouldBe(3600);
}
/// <summary>Verifies that RecycleIntervalSeconds when null defaults to null.</summary>
[Fact]
public void RecycleIntervalSeconds_Null_DefaultsToNull()
{
@@ -181,6 +195,8 @@ public sealed class DriverResilienceOptionsParserTests
options.RecycleIntervalSeconds.ShouldBeNull();
}
/// <summary>Verifies that RecycleIntervalSeconds on Tier A or B is rejected with diagnostic.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -196,6 +212,7 @@ public sealed class DriverResilienceOptionsParserTests
diag.ShouldContain("Tier C only");
}
/// <summary>Verifies that RecycleIntervalSeconds with non-positive value is rejected with diagnostic.</summary>
[Fact]
public void RecycleIntervalSeconds_NonPositive_Rejected_With_Diagnostic()
{
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Unit")]
public sealed class DriverResilienceOptionsTests
{
/// <summary>Verifies that tier defaults cover every capability.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -20,6 +22,8 @@ public sealed class DriverResilienceOptionsTests
defaults.ShouldContainKey(capability);
}
/// <summary>Verifies that write never retries by default.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -30,6 +34,8 @@ public sealed class DriverResilienceOptionsTests
defaults[DriverCapability.Write].RetryCount.ShouldBe(0);
}
/// <summary>Verifies that alarm acknowledge never retries by default.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -40,6 +46,9 @@ public sealed class DriverResilienceOptionsTests
defaults[DriverCapability.AlarmAcknowledge].RetryCount.ShouldBe(0);
}
/// <summary>Verifies that idempotent capabilities retry by default.</summary>
/// <param name="tier">The driver tier to test.</param>
/// <param name="capability">The driver capability to test.</param>
[Theory]
[InlineData(DriverTier.A, DriverCapability.Read)]
[InlineData(DriverTier.A, DriverCapability.HistoryRead)]
@@ -52,6 +61,7 @@ public sealed class DriverResilienceOptionsTests
defaults[capability].RetryCount.ShouldBeGreaterThan(0);
}
/// <summary>Verifies that TierC disables circuit breaker deferring to supervisor.</summary>
[Fact]
public void TierC_DisablesCircuitBreaker_DeferringToSupervisor()
{
@@ -61,6 +71,8 @@ public sealed class DriverResilienceOptionsTests
policy.BreakerFailureThreshold.ShouldBe(0, "Tier C breaker is handled by the Proxy supervisor (decision #68)");
}
/// <summary>Verifies that TierA and TierB enable circuit breaker.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -72,6 +84,7 @@ public sealed class DriverResilienceOptionsTests
policy.BreakerFailureThreshold.ShouldBeGreaterThan(0);
}
/// <summary>Verifies that resolve uses tier defaults when no override is set.</summary>
[Fact]
public void Resolve_Uses_TierDefaults_When_NoOverride()
{
@@ -82,6 +95,7 @@ public sealed class DriverResilienceOptionsTests
resolved.ShouldBe(DriverResilienceOptions.GetTierDefaults(DriverTier.A)[DriverCapability.Read]);
}
/// <summary>Verifies that resolve uses override when configured.</summary>
[Fact]
public void Resolve_Uses_Override_When_Configured()
{
@@ -106,6 +120,7 @@ public sealed class DriverResilienceOptionsTests
/// enum-only addition that forgets to update <c>GetTierDefaults</c> would otherwise blow up
/// on the hot path with <see cref="KeyNotFoundException"/>.
/// </summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -12,6 +12,7 @@ public sealed class DriverResiliencePipelineBuilderTests
{
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
/// <summary>Verifies that read operations retry transient failures.</summary>
[Fact]
public async Task Read_Retries_Transient_Failures()
{
@@ -29,6 +30,7 @@ public sealed class DriverResiliencePipelineBuilderTests
attempts.ShouldBe(3);
}
/// <summary>Verifies that write operations do not retry on failure.</summary>
[Fact]
public async Task Write_DoesNotRetry_OnFailure()
{
@@ -50,6 +52,7 @@ public sealed class DriverResiliencePipelineBuilderTests
ex.Message.ShouldBe("boom");
}
/// <summary>Verifies that alarm acknowledge operations do not retry on failure.</summary>
[Fact]
public async Task AlarmAcknowledge_DoesNotRetry_OnFailure()
{
@@ -70,6 +73,7 @@ public sealed class DriverResiliencePipelineBuilderTests
attempts.ShouldBe(1);
}
/// <summary>Verifies that pipelines are isolated per host.</summary>
[Fact]
public void Pipeline_IsIsolated_PerHost()
{
@@ -83,6 +87,7 @@ public sealed class DriverResiliencePipelineBuilderTests
builder.CachedPipelineCount.ShouldBe(2);
}
/// <summary>Verifies that pipelines are reused for the same driver, host, and capability triple.</summary>
[Fact]
public void Pipeline_IsReused_ForSameTriple()
{
@@ -96,6 +101,7 @@ public sealed class DriverResiliencePipelineBuilderTests
builder.CachedPipelineCount.ShouldBe(1);
}
/// <summary>Verifies that pipelines are isolated per capability.</summary>
[Fact]
public void Pipeline_IsIsolated_PerCapability()
{
@@ -108,6 +114,7 @@ public sealed class DriverResiliencePipelineBuilderTests
read.ShouldNotBeSameAs(write);
}
/// <summary>Verifies that a dead host does not open the breaker for a sibling host.</summary>
[Fact]
public async Task DeadHost_DoesNotOpenBreaker_ForSiblingHost()
{
@@ -138,6 +145,7 @@ public sealed class DriverResiliencePipelineBuilderTests
liveAttempts.ShouldBe(1, "healthy sibling host must not be affected by dead peer");
}
/// <summary>Verifies that the circuit breaker opens after the failure threshold on tier A.</summary>
[Fact]
public async Task CircuitBreaker_Opens_AfterFailureThreshold_OnTierA()
{
@@ -162,6 +170,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}));
}
/// <summary>Verifies that timeout cancels slow operations.</summary>
[Fact]
public async Task Timeout_Cancels_SlowOperation()
{
@@ -183,6 +192,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}));
}
/// <summary>Verifies that invalidate removes only the matching instance.</summary>
[Fact]
public void Invalidate_Removes_OnlyMatchingInstance()
{
@@ -200,6 +210,7 @@ public sealed class DriverResiliencePipelineBuilderTests
builder.CachedPipelineCount.ShouldBe(2);
}
/// <summary>Verifies that cancellation is not retried.</summary>
[Fact]
public async Task Cancellation_IsNot_Retried()
{
@@ -220,6 +231,7 @@ public sealed class DriverResiliencePipelineBuilderTests
attempts.ShouldBeLessThanOrEqualTo(1);
}
/// <summary>Verifies that the tracker records failure on every retry.</summary>
[Fact]
public async Task Tracker_RecordsFailure_OnEveryRetry()
{
@@ -240,6 +252,7 @@ public sealed class DriverResiliencePipelineBuilderTests
snap!.ConsecutiveFailures.ShouldBe(retryCount);
}
/// <summary>Verifies that the tracker stamps the breaker open when it trips.</summary>
[Fact]
public async Task Tracker_StampsBreakerOpen_WhenBreakerTrips()
{
@@ -263,6 +276,7 @@ public sealed class DriverResiliencePipelineBuilderTests
snap!.LastBreakerOpenUtc.ShouldNotBeNull();
}
/// <summary>Verifies that the tracker isolates counters per host.</summary>
[Fact]
public async Task Tracker_IsolatesCounters_PerHost()
{
@@ -9,6 +9,7 @@ public sealed class DriverResilienceStatusTrackerTests
{
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Verifies that TryGet returns null before any write operations.</summary>
[Fact]
public void TryGet_Returns_Null_Before_AnyWrite()
{
@@ -17,6 +18,7 @@ public sealed class DriverResilienceStatusTrackerTests
tracker.TryGet("drv", "host").ShouldBeNull();
}
/// <summary>Verifies that RecordFailure accumulates consecutive failures.</summary>
[Fact]
public void RecordFailure_Accumulates_ConsecutiveFailures()
{
@@ -29,6 +31,7 @@ public sealed class DriverResilienceStatusTrackerTests
tracker.TryGet("drv", "host")!.ConsecutiveFailures.ShouldBe(3);
}
/// <summary>Verifies that RecordSuccess resets consecutive failures to zero.</summary>
[Fact]
public void RecordSuccess_Resets_ConsecutiveFailures()
{
@@ -41,6 +44,7 @@ public sealed class DriverResilienceStatusTrackerTests
tracker.TryGet("drv", "host")!.ConsecutiveFailures.ShouldBe(0);
}
/// <summary>Verifies that RecordBreakerOpen populates the LastBreakerOpenUtc timestamp.</summary>
[Fact]
public void RecordBreakerOpen_Populates_LastBreakerOpenUtc()
{
@@ -51,6 +55,7 @@ public sealed class DriverResilienceStatusTrackerTests
tracker.TryGet("drv", "host")!.LastBreakerOpenUtc.ShouldBe(Now);
}
/// <summary>Verifies that RecordRecycle populates the LastRecycleUtc timestamp.</summary>
[Fact]
public void RecordRecycle_Populates_LastRecycleUtc()
{
@@ -61,6 +66,7 @@ public sealed class DriverResilienceStatusTrackerTests
tracker.TryGet("drv", "host")!.LastRecycleUtc.ShouldBe(Now);
}
/// <summary>Verifies that RecordFootprint captures baseline and current memory usage.</summary>
[Fact]
public void RecordFootprint_CapturesBaselineAndCurrent()
{
@@ -73,6 +79,7 @@ public sealed class DriverResilienceStatusTrackerTests
snap.CurrentFootprintBytes.ShouldBe(150_000_000);
}
/// <summary>Verifies that different hosts are tracked independently.</summary>
[Fact]
public void DifferentHosts_AreIndependent()
{
@@ -86,6 +93,7 @@ public sealed class DriverResilienceStatusTrackerTests
tracker.TryGet("drv", "host-b")!.ConsecutiveFailures.ShouldBe(1);
}
/// <summary>Verifies that Snapshot returns all tracked driver-host pairs.</summary>
[Fact]
public void Snapshot_ReturnsAll_TrackedPairs()
{
@@ -99,6 +107,7 @@ public sealed class DriverResilienceStatusTrackerTests
snapshot.Count.ShouldBe(3);
}
/// <summary>Verifies that concurrent writes do not lose failure records.</summary>
[Fact]
public void ConcurrentWrites_DoNotLose_Failures()
{
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Integration")]
public sealed class FlakeyDriverIntegrationTests
{
/// <summary>Verifies read succeeds after transient failures with retries.</summary>
[Fact]
public async Task Read_SurfacesSuccess_AfterTransientFailures()
{
@@ -41,6 +42,7 @@ public sealed class FlakeyDriverIntegrationTests
result[0].StatusCode.ShouldBe(0u);
}
/// <summary>Verifies non-idempotent write fails on first failure without replay.</summary>
[Fact]
public async Task Write_NonIdempotent_FailsOnFirstFailure_NoReplay()
{
@@ -65,6 +67,7 @@ public sealed class FlakeyDriverIntegrationTests
flaky.WriteAttempts.ShouldBe(1, "non-idempotent write must never replay (decision #44)");
}
/// <summary>Verifies idempotent write retries until success.</summary>
[Fact]
public async Task Write_Idempotent_RetriesUntilSuccess()
{
@@ -89,6 +92,7 @@ public sealed class FlakeyDriverIntegrationTests
results[0].StatusCode.ShouldBe(0u);
}
/// <summary>Verifies multiple hosts have independent failure counts and circuit breakers.</summary>
[Fact]
public async Task MultipleHosts_OnOneDriver_HaveIndependentFailureCounts()
{
@@ -116,20 +120,31 @@ public sealed class FlakeyDriverIntegrationTests
liveAttempts.ShouldBe(1);
}
/// <summary>Driver that fails reads/writes for a configurable number of attempts.</summary>
private sealed class FlakeyDriver : IReadable, IWritable
{
private readonly int _failReadsBeforeIndex;
private readonly int _failWritesBeforeIndex;
/// <summary>Gets the number of read attempts made.</summary>
public int ReadAttempts { get; private set; }
/// <summary>Gets the number of write attempts made.</summary>
public int WriteAttempts { get; private set; }
/// <summary>Initializes a flaky driver with configurable failure counts.</summary>
/// <param name="failReadsBeforeIndex">Fail reads until this attempt number.</param>
/// <param name="failWritesBeforeIndex">Fail writes until this attempt number.</param>
public FlakeyDriver(int failReadsBeforeIndex = 0, int failWritesBeforeIndex = 0)
{
_failReadsBeforeIndex = failReadsBeforeIndex;
_failWritesBeforeIndex = failWritesBeforeIndex;
}
/// <summary>Reads values, failing transiently until the threshold.</summary>
/// <param name="fullReferences">Full references to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Data value snapshots.</returns>
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences,
CancellationToken cancellationToken)
@@ -145,6 +160,10 @@ public sealed class FlakeyDriverIntegrationTests
return Task.FromResult(result);
}
/// <summary>Writes values, failing transiently until the threshold.</summary>
/// <param name="writes">The write requests.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Write results.</returns>
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken)
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
[Trait("Category", "Unit")]
public sealed class InFlightCounterTests
{
/// <summary>Verifies that starting and completing a call nets to zero.</summary>
[Fact]
public void StartThenComplete_NetsToZero()
{
@@ -18,6 +19,7 @@ public sealed class InFlightCounterTests
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
}
/// <summary>Verifies that nested starts sum the depth.</summary>
[Fact]
public void NestedStarts_SumDepth()
{
@@ -32,6 +34,7 @@ public sealed class InFlightCounterTests
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(2);
}
/// <summary>Verifies that completing before start is clamped to zero.</summary>
[Fact]
public void CompleteBeforeStart_ClampedToZero()
{
@@ -42,6 +45,7 @@ public sealed class InFlightCounterTests
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(0);
}
/// <summary>Verifies that different hosts track independently.</summary>
[Fact]
public void DifferentHosts_TrackIndependently()
{
@@ -54,6 +58,7 @@ public sealed class InFlightCounterTests
tracker.TryGet("drv", "host-b")!.CurrentInFlight.ShouldBe(1);
}
/// <summary>Verifies that concurrent starts do not lose count.</summary>
[Fact]
public void ConcurrentStarts_DoNotLose_Count()
{
@@ -63,6 +68,7 @@ public sealed class InFlightCounterTests
tracker.TryGet("drv", "host-a")!.CurrentInFlight.ShouldBe(500);
}
/// <summary>Verifies that CapabilityInvoker increments the tracker during execution.</summary>
[Fact]
public async Task CapabilityInvoker_IncrementsTracker_DuringExecution()
{
@@ -90,6 +96,7 @@ public sealed class InFlightCounterTests
tracker.TryGet("drv-live", "plc-1")!.CurrentInFlight.ShouldBe(0, "post-call, counter decremented");
}
/// <summary>Verifies that CapabilityInvoker decrements the counter on exception.</summary>
[Fact]
public async Task CapabilityInvoker_ExceptionPath_DecrementsCounter()
{
@@ -111,6 +118,7 @@ public sealed class InFlightCounterTests
"finally-block must decrement even when call-site throws");
}
/// <summary>Verifies that CapabilityInvoker without a tracker does not throw.</summary>
[Fact]
public async Task CapabilityInvoker_WithoutTracker_DoesNotThrow()
{
@@ -17,11 +17,18 @@ public sealed class PerCallHostResolverDispatchTests
private sealed class StaticResolver : IPerCallHostResolver
{
private readonly Dictionary<string, string> _map;
/// <summary>Initializes a new instance of StaticResolver with a predefined mapping.</summary>
/// <param name="map">The mapping of full references to host names.</param>
public StaticResolver(Dictionary<string, string> map) => _map = map;
/// <summary>Resolves a host name from the static mapping.</summary>
/// <param name="fullReference">The full reference to resolve.</param>
public string ResolveHost(string fullReference) =>
_map.TryGetValue(fullReference, out var host) ? host : string.Empty;
}
/// <summary>Verifies that a dead PLC does not open the breaker for healthy PLCs when using a per-call resolver.</summary>
[Fact]
public async Task DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver()
{
@@ -59,6 +66,7 @@ public sealed class PerCallHostResolverDispatchTests
aliveAttempts.ShouldBe(1, "decision #144 — per-PLC isolation keeps healthy PLCs serving");
}
/// <summary>Verifies that empty string from resolver is treated as single-host fallback.</summary>
[Fact]
public void Resolver_EmptyString_Treated_As_Single_Host_Fallback()
{
@@ -71,6 +79,7 @@ public sealed class PerCallHostResolverDispatchTests
resolver.ResolveHost("not-in-map").ShouldBe("", "unknown refs return empty so dispatch falls back to single-host");
}
/// <summary>Verifies that without a resolver, the same host shares one resilience pipeline.</summary>
[Fact]
public async Task WithoutResolver_SameHost_Shares_One_Pipeline()
{
@@ -88,6 +97,7 @@ public sealed class PerCallHostResolverDispatchTests
builder.CachedPipelineCount.ShouldBe(1, "single-host drivers share one pipeline");
}
/// <summary>Verifies that with a resolver, different hosts get separate resilience pipelines.</summary>
[Fact]
public async Task WithResolver_TwoHosts_Get_Two_Pipelines()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability;
[Trait("Category", "Unit")]
public sealed class MemoryRecycleTests
{
/// <summary>Verifies that Tier C hard memory breach requests supervisor recycle.</summary>
[Fact]
public async Task TierC_HardBreach_RequestsSupervisorRecycle()
{
@@ -22,6 +23,8 @@ public sealed class MemoryRecycleTests
supervisor.LastReason.ShouldContain("hard-breach");
}
/// <summary>Verifies that Tier A and B hard memory breach never request recycle.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -36,6 +39,7 @@ public sealed class MemoryRecycleTests
supervisor.RecycleCount.ShouldBe(0);
}
/// <summary>Verifies that Tier C without supervisor hard breach is a no-op.</summary>
[Fact]
public async Task TierC_WithoutSupervisor_HardBreach_NoOp()
{
@@ -46,6 +50,8 @@ public sealed class MemoryRecycleTests
requested.ShouldBeFalse("no supervisor → no recycle path; action logged only");
}
/// <summary>Verifies that soft memory breach never requests recycle at any tier.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -61,6 +67,8 @@ public sealed class MemoryRecycleTests
supervisor.RecycleCount.ShouldBe(0);
}
/// <summary>Verifies that non-breach memory actions are no-ops.</summary>
/// <param name="action">The non-breach memory tracking action to test.</param>
[Theory]
[InlineData(MemoryTrackingAction.None)]
[InlineData(MemoryTrackingAction.Warming)]
@@ -77,10 +85,16 @@ public sealed class MemoryRecycleTests
private sealed class FakeSupervisor : IDriverSupervisor
{
/// <summary>Gets the driver instance identifier.</summary>
public string DriverInstanceId => "fake-tier-c";
/// <summary>Gets the count of recycle operations.</summary>
public int RecycleCount { get; private set; }
/// <summary>Gets the reason from the last recycle operation.</summary>
public string? LastReason { get; private set; }
/// <summary>Recycles the driver asynchronously.</summary>
/// <param name="reason">The reason for recycling.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
{
RecycleCount++;
@@ -10,6 +10,7 @@ public sealed class MemoryTrackingTests
{
private static readonly DateTime T0 = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Verifies that warming phase returns Warming until the time window elapses.</summary>
[Fact]
public void WarmingUp_Returns_Warming_UntilWindowElapses()
{
@@ -23,6 +24,7 @@ public sealed class MemoryTrackingTests
tracker.BaselineBytes.ShouldBe(0);
}
/// <summary>Verifies that when the window elapses, baseline is captured as median and phase transitions to steady.</summary>
[Fact]
public void WindowElapsed_CapturesBaselineAsMedian_AndTransitionsToSteady()
{
@@ -38,6 +40,10 @@ public sealed class MemoryTrackingTests
first.ShouldBe(MemoryTrackingAction.None, "150 MB is the baseline itself, well under soft threshold");
}
/// <summary>Verifies that tier constants match Decision 146 specification.</summary>
/// <param name="tier">The driver tier to test.</param>
/// <param name="expectedMultiplier">Expected growth multiplier.</param>
/// <param name="expectedFloorMB">Expected floor in megabytes.</param>
[Theory]
[InlineData(DriverTier.A, 3, 50)]
[InlineData(DriverTier.B, 3, 100)]
@@ -49,6 +55,7 @@ public sealed class MemoryTrackingTests
floor.ShouldBe(expectedFloorMB * 1024 * 1024);
}
/// <summary>Verifies that soft threshold uses the maximum of multiplier and floor for small baselines.</summary>
[Fact]
public void SoftThreshold_UsesMax_OfMultiplierAndFloor_SmallBaseline()
{
@@ -57,6 +64,7 @@ public sealed class MemoryTrackingTests
tracker.SoftThresholdBytes.ShouldBe(60L * 1024 * 1024);
}
/// <summary>Verifies that soft threshold uses the maximum of multiplier and floor for large baselines.</summary>
[Fact]
public void SoftThreshold_UsesMax_OfMultiplierAndFloor_LargeBaseline()
{
@@ -65,6 +73,7 @@ public sealed class MemoryTrackingTests
tracker.SoftThresholdBytes.ShouldBe(600L * 1024 * 1024);
}
/// <summary>Verifies that hard threshold is twice the soft threshold.</summary>
[Fact]
public void HardThreshold_IsTwiceSoft()
{
@@ -72,6 +81,7 @@ public sealed class MemoryTrackingTests
tracker.HardThresholdBytes.ShouldBe(tracker.SoftThresholdBytes * 2);
}
/// <summary>Verifies that samples below soft threshold return None.</summary>
[Fact]
public void Sample_Below_Soft_Returns_None()
{
@@ -80,6 +90,7 @@ public sealed class MemoryTrackingTests
tracker.Sample(200L * 1024 * 1024, T0.AddMinutes(10)).ShouldBe(MemoryTrackingAction.None);
}
/// <summary>Verifies that samples at soft threshold return SoftBreach.</summary>
[Fact]
public void Sample_AtSoft_Returns_SoftBreach()
{
@@ -90,6 +101,7 @@ public sealed class MemoryTrackingTests
.ShouldBe(MemoryTrackingAction.SoftBreach);
}
/// <summary>Verifies that samples at hard threshold return HardBreach.</summary>
[Fact]
public void Sample_AtHard_Returns_HardBreach()
{
@@ -99,6 +111,7 @@ public sealed class MemoryTrackingTests
.ShouldBe(MemoryTrackingAction.HardBreach);
}
/// <summary>Verifies that samples above hard threshold return HardBreach.</summary>
[Fact]
public void Sample_AboveHard_Returns_HardBreach()
{
@@ -12,6 +12,8 @@ public sealed class ScheduledRecycleSchedulerTests
private static readonly DateTime T0 = new(2026, 4, 19, 0, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Weekly = TimeSpan.FromDays(7);
/// <summary>Verifies constructor throws for Tier A or B.</summary>
/// <param name="tier">The driver tier to test.</param>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -22,6 +24,7 @@ public sealed class ScheduledRecycleSchedulerTests
tier, Weekly, T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
}
/// <summary>Verifies constructor throws for zero or negative intervals.</summary>
[Fact]
public void ZeroOrNegativeInterval_Throws()
{
@@ -32,6 +35,7 @@ public sealed class ScheduledRecycleSchedulerTests
DriverTier.C, TimeSpan.FromSeconds(-1), T0, supervisor, NullLogger<ScheduledRecycleScheduler>.Instance));
}
/// <summary>Verifies Tick before the next recycle time is a no-op.</summary>
[Fact]
public async Task Tick_BeforeNextRecycle_NoOp()
{
@@ -44,6 +48,7 @@ public sealed class ScheduledRecycleSchedulerTests
supervisor.RecycleCount.ShouldBe(0);
}
/// <summary>Verifies Tick at or after the next recycle time fires once and advances.</summary>
[Fact]
public async Task Tick_AtOrAfterNextRecycle_FiresOnce_AndAdvances()
{
@@ -57,6 +62,7 @@ public sealed class ScheduledRecycleSchedulerTests
sch.NextRecycleUtc.ShouldBe(T0 + Weekly + Weekly);
}
/// <summary>Verifies RequestRecycleNow fires immediately without advancing the schedule.</summary>
[Fact]
public async Task RequestRecycleNow_Fires_Immediately_WithoutAdvancingSchedule()
{
@@ -71,6 +77,7 @@ public sealed class ScheduledRecycleSchedulerTests
sch.NextRecycleUtc.ShouldBe(nextBefore, "ad-hoc recycle doesn't shift the cron schedule");
}
/// <summary>Verifies multiple ticks across the recycle interval each advance by one interval.</summary>
[Fact]
public async Task MultipleFires_AcrossTicks_AdvanceOneIntervalEach()
{
@@ -85,12 +92,22 @@ public sealed class ScheduledRecycleSchedulerTests
sch.NextRecycleUtc.ShouldBe(T0 + TimeSpan.FromDays(4));
}
/// <summary>Fake driver supervisor for testing.</summary>
private sealed class FakeSupervisor : IDriverSupervisor
{
/// <summary>Gets the driver instance ID.</summary>
public string DriverInstanceId => "tier-c-fake";
/// <summary>Gets the number of times RecycleAsync was called.</summary>
public int RecycleCount { get; private set; }
/// <summary>Gets the reason from the most recent recycle call.</summary>
public string? LastReason { get; private set; }
/// <summary>Simulates a driver recycle operation.</summary>
/// <param name="reason">The reason for the recycle.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A completed task.</returns>
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
{
RecycleCount++;
@@ -11,6 +11,7 @@ public sealed class WedgeDetectorTests
private static readonly DateTime Now = new(2026, 4, 19, 12, 0, 0, DateTimeKind.Utc);
private static readonly TimeSpan Threshold = TimeSpan.FromSeconds(120);
/// <summary>Verifies that thresholds below 60 seconds are clamped to 60 seconds.</summary>
[Fact]
public void SubSixtySecondThreshold_ClampsToSixty()
{
@@ -18,6 +19,7 @@ public sealed class WedgeDetectorTests
detector.Threshold.ShouldBe(TimeSpan.FromSeconds(60));
}
/// <summary>Verifies that unhealthy drivers always return NotApplicable verdict.</summary>
[Fact]
public void Unhealthy_Driver_AlwaysNotApplicable()
{
@@ -29,6 +31,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Initializing, demand, Now).ShouldBe(WedgeVerdict.NotApplicable);
}
/// <summary>Verifies that an idle subscription-only driver stays Idle.</summary>
[Fact]
public void Idle_Subscription_Only_StaysIdle()
{
@@ -40,6 +43,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle);
}
/// <summary>Verifies that pending work with recent progress stays Healthy.</summary>
[Fact]
public void PendingWork_WithRecentProgress_StaysHealthy()
{
@@ -49,6 +53,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
}
/// <summary>Verifies that pending work with stale progress is detected as Faulted.</summary>
[Fact]
public void PendingWork_WithStaleProgress_IsFaulted()
{
@@ -58,6 +63,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted);
}
/// <summary>Verifies that active monitored items without recent publishes are detected as Faulted.</summary>
[Fact]
public void MonitoredItems_Active_ButNoRecentPublish_IsFaulted()
{
@@ -70,6 +76,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Faulted);
}
/// <summary>Verifies that active monitored items with fresh publishes stay Healthy.</summary>
[Fact]
public void MonitoredItems_Active_WithFreshPublish_StaysHealthy()
{
@@ -79,6 +86,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
}
/// <summary>Verifies that slow history backfill with progress stays Healthy.</summary>
[Fact]
public void HistoryBackfill_SlowButMakingProgress_StaysHealthy()
{
@@ -89,6 +97,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Healthy);
}
/// <summary>Verifies that write-only burst stays Idle when the bulkhead is empty.</summary>
[Fact]
public void WriteOnlyBurst_StaysIdle_WhenBulkheadEmpty()
{
@@ -101,6 +110,7 @@ public sealed class WedgeDetectorTests
detector.Classify(DriverState.Healthy, demand, Now).ShouldBe(WedgeVerdict.Idle);
}
/// <summary>Verifies that DemandSignal.HasPendingWork is true for any non-zero counter.</summary>
[Fact]
public void DemandSignal_HasPendingWork_TrueForAnyNonZeroCounter()
{