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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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()
|
||||
{
|
||||
|
||||
+17
@@ -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)]
|
||||
|
||||
+14
@@ -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
@@ -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()
|
||||
{
|
||||
|
||||
+10
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user