docs: complete XML doc comments via fixdocs (2757 to 131 findings)

Add missing <returns>/<param>/<summary>/<typeparam> tags and clean up
misused inheritdoc across 481 files so the documented API surface is
complete. Documentation-only (zero code lines changed). The 131 remaining
findings are inheritdoc-style warnings deliberately left to preserve
hand-written implementation rationale (plan-decision notes, race-condition
explanations).
This commit is contained in:
Joseph Doherty
2026-06-03 12:34:34 -04:00
parent c6d9b20d9f
commit bd6c0b4d3d
481 changed files with 2550 additions and 1668 deletions
@@ -51,6 +51,7 @@ public sealed class ClusterAuditQueryTests : IDisposable
/// <summary>Structured rows (ClusterId null, NodeId set) for a node in the cluster are now
/// visible, alongside the SP-path rows that stamp ClusterId directly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Surfaces_both_clusterId_rows_and_structured_nodeId_rows()
{
@@ -77,6 +78,7 @@ public sealed class ClusterAuditQueryTests : IDisposable
}
/// <summary>An audit row stamped with another cluster's ClusterId never appears.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Does_not_surface_other_cluster_rows()
{
@@ -91,6 +93,7 @@ public sealed class ClusterAuditQueryTests : IDisposable
}
/// <summary>Respects the page-size cap, newest first.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Caps_to_page_size_newest_first()
{
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
{
/// <summary>Verifies that the composite key allows the same host across different nodes or drivers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Composite_key_allows_same_host_across_different_nodes_or_drivers()
{
@@ -58,6 +59,7 @@ public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
}
/// <summary>Verifies that the upsert pattern updates existing records in place.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upsert_pattern_for_same_key_updates_in_place()
{
@@ -97,6 +99,7 @@ public sealed class DriverHostStatusTests(SchemaComplianceFixture fixture)
}
/// <summary>Verifies that the State enum is persisted as a string, not an integer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Enum_persists_as_string_not_int()
{
@@ -33,6 +33,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
};
/// <summary>Verifies that reading a snapshot on first boot with no existing snapshot throws.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FirstBoot_NoSnapshot_ReadThrows()
{
@@ -43,6 +44,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that sealed snapshots can be read back correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SealThenRead_RoundTrips()
{
@@ -58,6 +60,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that sealed files are marked read-only on disk.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SealedFile_IsReadOnly_OnDisk()
{
@@ -71,6 +74,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that the current generation pointer advances when a new generation is sealed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SealingTwoGenerations_PointerAdvances_ToLatest()
{
@@ -84,6 +88,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that prior generation files are preserved after a new seal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PriorGenerationFile_Survives_AfterNewSeal()
{
@@ -97,6 +102,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that reading a corrupt sealed file fails safely.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CorruptSealedFile_ReadFailsClosed()
{
@@ -113,6 +119,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that reading with a missing sealed file fails safely.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MissingSealedFile_ReadFailsClosed()
{
@@ -129,6 +136,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that reading with a corrupt pointer file fails safely.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CorruptPointerFile_ReadFailsClosed()
{
@@ -143,6 +151,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that sealing the same generation twice is idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SealSameGenerationTwice_IsIdempotent()
{
@@ -155,6 +164,7 @@ public sealed class GenerationSealedCacheTests : IDisposable
}
/// <summary>Verifies that independent clusters do not interfere with each other.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task IndependentClusters_DoNotInterfere()
{
@@ -34,6 +34,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
};
/// <summary>Verifies that Create sets Id and CreatedAtUtc.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Create_SetsId_AndCreatedAtUtc()
{
@@ -47,6 +48,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that Create rejects empty LDAP group.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Create_Rejects_EmptyLdapGroup()
{
@@ -58,6 +60,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that Create rejects system-wide mapping with ClusterId.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Create_Rejects_SystemWide_With_ClusterId()
{
@@ -69,6 +72,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that Create rejects non-system-wide mapping without ClusterId.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
{
@@ -80,6 +84,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that GetByGroups returns only matching grants.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task GetByGroups_Returns_MatchingGrants_Only()
{
@@ -96,6 +101,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that GetByGroups returns empty when input is empty.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task GetByGroups_Empty_Input_ReturnsEmpty()
{
@@ -108,6 +114,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that ListAll orders results by group then cluster.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task ListAll_Orders_ByGroupThenCluster()
{
@@ -125,6 +132,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that Delete removes the matching row.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Delete_Removes_Matching_Row()
{
@@ -138,6 +146,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that Delete with unknown Id is a no-op.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Delete_Unknown_Id_IsNoOp()
{
@@ -148,6 +157,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable
}
/// <summary>Verifies that a system-wide row (IsSystemWide=true, ClusterId=null) appears in both ListAllAsync and GetByGroupsAsync.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task SystemWide_Row_AppearsIn_ListAll_And_GetByGroups()
{
@@ -24,6 +24,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
};
/// <summary>Verifies that payload is preserved through a write-then-read cycle.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Roundtrip_preserves_payload()
{
@@ -38,6 +39,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
}
/// <summary>Verifies that GetMostRecentAsync returns the latest generation when multiple exist.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task GetMostRecent_returns_latest_when_multiple_generations_present()
{
@@ -50,6 +52,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
}
/// <summary>Verifies that GetMostRecentAsync returns null for an unknown cluster.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task GetMostRecent_returns_null_for_unknown_cluster()
{
@@ -58,6 +61,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
}
/// <summary>Verifies that Prune keeps the latest N generations and drops older ones.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Prune_keeps_latest_N_and_drops_older()
{
@@ -81,6 +85,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
}
/// <summary>Verifies that writing the same cluster/generation twice replaces rather than duplicates.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Put_same_cluster_generation_twice_replaces_not_duplicates()
{
@@ -102,6 +107,7 @@ public sealed class LiteDbConfigCacheTests : IDisposable
// callers could both observe `existing is null` and both Insert.
// ------------------------------------------------------------------------------------
/// <summary>Verifies that concurrent PutAsync calls for the same cluster and generation do not create duplicates.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task PutAsync_concurrent_for_same_cluster_and_generation_does_not_duplicate()
{
@@ -26,6 +26,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
}
/// <summary>Verifies that successful central DB reads return value and mark fresh.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task CentralDbSucceeds_ReturnsValue_MarksFresh()
{
@@ -45,6 +46,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
}
/// <summary>Verifies that exhausted retries fall back to cache and mark stale.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale()
{
@@ -78,6 +80,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
}
/// <summary>Verifies that DB failure with unavailable cache throws.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
{
@@ -99,6 +102,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
}
/// <summary>Verifies that cancellation is not retried.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task Cancellation_NotRetried()
{
@@ -133,6 +137,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
// ------------------------------------------------------------------------------------
/// <summary>Verifies that command timeout TaskCanceledException falls back to cache.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task CommandTimeout_TaskCanceledException_FallsBackToCache()
{
@@ -163,6 +168,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
}
/// <summary>Verifies that Polly timeout rejection falls back to cache.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task PollyTimeout_TimeoutRejectedException_FallsBackToCache()
{
@@ -201,6 +207,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
// ------------------------------------------------------------------------------------
/// <summary>Verifies that fallback warnings do not log exceptions or password fragments.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task FallbackWarning_does_not_log_full_exception_object_or_password_fragment()
{
@@ -242,6 +249,7 @@ public sealed class ResilientConfigReaderTests : IDisposable
}
/// <summary>Verifies that caller cancellation propagates rather than falling back.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task CallerCancellation_Propagates_NotFallback()
{
@@ -35,6 +35,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that the initial poll forces an event for every subscribed tag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Initial_poll_force_raises_every_subscribed_tag()
{
@@ -54,6 +55,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that unchanged values are only raised once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unchanged_value_raises_only_once()
{
@@ -72,6 +74,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that value changes raise new events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Value_change_raises_new_event()
{
@@ -92,6 +95,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that unsubscribe halts the polling loop.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unsubscribe_halts_the_loop()
{
@@ -113,6 +117,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that intervals below the configured floor are clamped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Interval_below_floor_is_clamped()
{
@@ -134,6 +139,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that multiple subscriptions operate independently.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Multiple_subscriptions_are_independent()
{
@@ -165,6 +171,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that reader exceptions do not crash the polling loop.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reader_exception_does_not_crash_loop()
{
@@ -195,6 +202,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that unsubscribing an unknown handle returns false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Unsubscribe_unknown_handle_returns_false()
{
@@ -206,6 +214,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that the active subscription count tracks lifecycle changes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ActiveSubscriptionCount_tracks_lifecycle()
{
@@ -225,6 +234,7 @@ public sealed class PollGroupEngineTests
}
/// <summary>Verifies that disposing the engine cancels all active subscriptions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_cancels_all_subscriptions()
{
@@ -253,6 +263,7 @@ public sealed class PollGroupEngineTests
/// must fire only the initial change event, not a spurious event on every tick, even
/// when the driver produces a fresh array instance on each read.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Array_valued_tag_unchanged_contents_raises_only_once()
{
@@ -289,6 +300,7 @@ public sealed class PollGroupEngineTests
/// Core.Abstractions-001: an array-valued tag whose contents change between polls
/// must fire a change event for each distinct set of contents.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Array_valued_tag_changed_contents_raises_event()
{
@@ -320,6 +332,7 @@ public sealed class PollGroupEngineTests
/// violates the documented contract. The engine must throw a descriptive exception
/// rather than silently stalling.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reader_short_result_list_raises_descriptive_exception_and_loop_continues()
{
@@ -366,6 +379,7 @@ public sealed class PollGroupEngineTests
/// must continue to swallow exceptions (backward compatible). When an error callback IS
/// supplied, every exception caught during a poll cycle must be routed to it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reader_exception_is_reported_to_onError_callback()
{
@@ -401,6 +415,7 @@ public sealed class PollGroupEngineTests
/// must also be routed to the error callback so the driver health surface can observe
/// repeated contract violations.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reader_contract_violation_routes_to_onError_callback()
{
@@ -433,6 +448,7 @@ public sealed class PollGroupEngineTests
/// that itself throws — otherwise a buggy health-surface forwarder would crash the poll
/// loop and silently stall the subscription, defeating the whole point of the callback.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnError_handler_that_throws_does_not_crash_loop()
{
@@ -463,7 +479,7 @@ public sealed class PollGroupEngineTests
private sealed record DummyHandle : ISubscriptionHandle
{
/// <summary>Gets a diagnostic identifier for this handle.</summary>
/// <inheritdoc />
public string DiagnosticId => "dummy";
}
@@ -43,10 +43,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// <summary>Gets or sets an exception to throw once.</summary>
public Exception? ThrowOnce { get; set; }
/// <summary>Writes a batch of events.</summary>
/// <param name="batch">Events to write.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task returning the write outcomes.</returns>
/// <inheritdoc />
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
{
@@ -77,6 +74,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
TimestampUtc: ts ?? DateTime.UtcNow);
/// <summary>Verifies that acknowledged events are removed from the queue.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueThenDrain_Ack_removes_row()
{
@@ -98,6 +96,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that draining an empty queue is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Drain_with_empty_queue_is_noop()
{
@@ -111,6 +110,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that RetryPlease outcome bumps backoff and keeps the row queued.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryPlease_bumps_backoff_and_keeps_row()
{
@@ -128,6 +128,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that an Ack after RetryPlease resets backoff to the floor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Ack_after_Retry_resets_backoff()
{
@@ -147,6 +148,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that PermanentFail outcome dead-letters only the failed event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PermanentFail_dead_letters_one_row_only()
{
@@ -165,6 +167,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that writer exceptions trigger retry for the entire batch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Writer_exception_treated_as_retry_for_whole_batch()
{
@@ -185,6 +188,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that capacity eviction drops the oldest non-dead-lettered row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Capacity_eviction_drops_oldest_nondeadlettered_row()
{
@@ -209,6 +213,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that dead-lettered rows are purged after retention period expires.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Deadlettered_rows_are_purged_past_retention()
{
@@ -233,6 +238,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that RetryDeadLettered requeues dead-lettered rows for retry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryDeadLettered_requeues_for_retry()
{
@@ -253,6 +259,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that the exponential backoff ladder caps at 60 seconds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Backoff_ladder_caps_at_60s()
{
@@ -278,6 +285,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that NullAlarmHistorianSink silently swallows enqueue calls.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NullAlarmHistorianSink_swallows_enqueue()
{
@@ -298,6 +306,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
}
/// <summary>Verifies that a disposed sink rejects enqueue operations.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disposed_sink_rejects_enqueue()
{
@@ -316,6 +325,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// forever), and every good row's outcome is applied to the CORRECT RowId —
/// no good event is silently lost.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Drain_with_corrupt_payload_row_deadletters_it_and_keeps_good_rows_aligned()
{
@@ -347,6 +357,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// A corrupt row at the very head of the queue must be dead-lettered and not
/// prevent the good rows behind it from draining.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Drain_with_corrupt_head_row_does_not_stall_queue()
{
@@ -373,6 +384,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// hammer the writer. We assert that after the backoff ladder advances, the
/// observed inter-batch gap actually grows beyond the bare tick interval.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartDrainLoop_honors_backoff_and_slows_cadence_under_retry()
{
@@ -400,6 +412,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// steady cadence (backoff stays at the floor) — confirms the reschedule path
/// does not get stuck after a successful tick.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartDrainLoop_keeps_steady_cadence_when_writer_is_healthy()
{
@@ -425,6 +438,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// be recorded into the status surface (LastError) and the drain loop must
/// keep rescheduling rather than silently dying.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartDrainLoop_records_drain_fault_and_keeps_running()
{
@@ -454,6 +468,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// the busy_timeout + WAL pragmas in place the loser of the file-lock race
/// waits the lock out instead of failing fast.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Concurrent_enqueue_and_drain_do_not_throw_sqlite_busy()
{
@@ -501,10 +516,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// <summary>Gets the number of times WriteBatchAsync has been called.</summary>
public int CallCount { get; private set; }
/// <summary>Writes a batch of events, throwing once then recovering.</summary>
/// <param name="batch">Events to write.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task returning the write outcomes.</returns>
/// <inheritdoc />
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
{
@@ -525,6 +537,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// by the timer callback, but still left the rows stranded on the first
/// cardinality-mismatched tick.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Writer_returning_wrong_cardinality_outcomes_sets_backing_off_and_keeps_rows()
{
@@ -555,6 +568,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// evicted row count must be surfaced in <see cref="HistorianSinkStatus.EvictedCount"/>
/// so operators can detect bounded-durability overflow without log scraping.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Capacity_eviction_increments_evicted_count_on_status()
{
@@ -580,6 +594,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// snapshot of all status fields — no torn DateTime? or stale DrainState.
/// Drive status writes from one thread and reads from another concurrently.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetStatus_snapshot_is_consistent_under_concurrent_drain()
{
@@ -632,10 +647,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// <summary>Fixes the writer to return correct cardinality.</summary>
public void FixWriter() => _returnExtra = false;
/// <summary>Writes a batch of events, returning wrong cardinality until fixed.</summary>
/// <param name="batch">Events to write.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task returning the write outcomes.</returns>
/// <inheritdoc />
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
{
@@ -654,6 +666,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// capacity-probe count must stay bounded — not grow proportionally to the
/// enqueue count as the un-optimised path did.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnqueueAsync_does_not_count_all_rows_on_every_call_below_capacity()
{
@@ -677,6 +690,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// stay aligned with a fresh <c>COUNT(*)</c> against the database. Catches drift
/// bugs in the in-memory counter introduced by the perf optimisation.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Enqueue_and_drain_keep_queue_depth_consistent_with_storage()
{
@@ -724,6 +738,7 @@ public sealed class SqliteStoreAndForwardSinkTests : IDisposable
/// with storage. Catches drift bugs in the optimised path that would only show
/// up under contention.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Counter_remains_consistent_under_concurrent_enqueue_and_drain()
{
@@ -38,15 +38,12 @@ public sealed class FakeUpstream : ITagUpstreamSource
}
}
/// <summary>Reads the current value of a tag, or returns a bad-status snapshot if not set.</summary>
/// <param name="path">The tag path to read.</param>
/// <inheritdoc />
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v) ? v
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
/// <summary>Subscribes an observer to tag changes for the given path.</summary>
/// <param name="path">The tag path to subscribe to.</param>
/// <param name="observer">The observer callback to invoke on tag changes.</param>
/// <inheritdoc />
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
{
var list = _subs.GetOrAdd(path, _ => []);
@@ -32,6 +32,7 @@ public sealed class ScriptedAlarmEngineTests
PredicateScriptSource: predicate);
/// <summary>Verifies that LoadAsync compiles the alarm predicate and subscribes to all referenced upstream tags.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
{
@@ -47,6 +48,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that compile failures across multiple alarms are aggregated into a single error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Compile_failures_aggregated_into_one_error()
{
@@ -63,6 +65,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that an upstream tag change triggers predicate re-evaluation and emits an Activated event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
{
@@ -84,6 +87,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that clearing an upstream tag value emits a Cleared event and transitions the alarm to Inactive.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Clearing_upstream_emits_Cleared_event()
{
@@ -105,6 +109,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that the message template resolves current tag values at the moment of alarm emission.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Message_template_resolves_tag_values_at_emission()
{
@@ -130,6 +135,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that AcknowledgeAsync records the operator user and persists the ack state to the store.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Ack_records_user_and_persists_to_store()
{
@@ -150,6 +156,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that startup recovery restores the persisted ack state but re-derives the active state from the live predicate.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
{
@@ -198,6 +205,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that a shelved alarm transitions its internal state on activation but suppresses the Activated emission.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Shelved_active_transitions_state_but_suppresses_emission()
{
@@ -222,6 +230,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that a runtime exception thrown by a predicate script leaves the alarm state unchanged and does not affect other alarms.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Predicate_runtime_exception_does_not_transition_state()
{
@@ -239,6 +248,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that a disabled alarm does not activate on predicate change and resumes normally after being re-enabled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disable_prevents_activation_until_re_enabled()
{
@@ -260,6 +270,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that AddCommentAsync appends to the audit trail without changing the alarm's active or ack state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AddComment_appends_to_audit_without_state_change()
{
@@ -278,6 +289,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that predicate scripts are forbidden from calling SetVirtualTag, and that the exception is isolated without state change.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Predicate_scripts_cannot_SetVirtualTag()
{
@@ -302,6 +314,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that disposing the engine releases all upstream tag subscriptions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_releases_upstream_subscriptions()
{
@@ -317,6 +330,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that concurrent reads of alarm state during dictionary mutations do not throw (regression for Core.ScriptedAlarms-001).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Concurrent_reads_during_mutation_do_not_throw(/* Core.ScriptedAlarms-001 */)
{
@@ -386,6 +400,7 @@ public sealed class ScriptedAlarmEngineTests
// injectable clock — the clock and scriptTimeout constructor parameters
// exist for exactly this.
/// <summary>Verifies that a timed shelve automatically expires when the engine's shelving check runs past the unshelve time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TimedShelve_auto_expires_when_engine_shelving_check_runs(/* -012 (1) */)
{
@@ -423,6 +438,7 @@ public sealed class ScriptedAlarmEngineTests
// (2a) ConfirmAsync end-to-end through the engine.
/// <summary>Verifies that ConfirmAsync records the confirming user and emits a Confirmed event persisted to the store.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfirmAsync_records_user_and_emits_Confirmed(/* -012 (2) */)
{
@@ -450,6 +466,7 @@ public sealed class ScriptedAlarmEngineTests
// (2b) TimedShelveAsync / UnshelveAsync end-to-end through the engine.
/// <summary>Verifies that TimedShelveAsync shelves with a deadline and UnshelveAsync removes the shelve before the timer expires.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TimedShelveAsync_and_UnshelveAsync_round_trip(/* -012 (2) */)
{
@@ -478,6 +495,7 @@ public sealed class ScriptedAlarmEngineTests
// (2c) EnableAsync end-to-end through the engine.
/// <summary>Verifies that EnableAsync transitions the alarm back to Enabled state and emits an Enabled event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EnableAsync_re_enables_after_disable(/* -012 (2) */)
{
@@ -501,6 +519,7 @@ public sealed class ScriptedAlarmEngineTests
// the engine or prevent subsequent alarm state transitions. The engine logs
// the exception and continues operating; any later alarm changes still work.
/// <summary>Verifies that an exception thrown by an OnEvent subscriber is isolated and does not crash the engine or prevent further state transitions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnEvent_subscriber_exception_does_not_crash_engine(/* -012 (3) */)
{
@@ -534,6 +553,7 @@ public sealed class ScriptedAlarmEngineTests
// (4) IAlarmStateStore.SaveAsync failure — in-memory state must remain at the
// prior value after finding -007 fix (persist-before-update).
/// <summary>Verifies that a store SaveAsync failure leaves the in-memory alarm state at its prior value (persist-before-update invariant, finding -007).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Store_save_failure_leaves_in_memory_state_unchanged(/* -012 (4) */)
{
@@ -565,6 +585,7 @@ public sealed class ScriptedAlarmEngineTests
// (5) Re-entrant LoadAsync — the old timer must not keep firing after a second
// call (regression for finding -002: _shelvingTimer?.Dispose() fix).
/// <summary>Verifies that a second LoadAsync call disposes the prior shelving timer so it does not keep firing after reload (regression for finding -002).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Second_LoadAsync_does_not_leak_old_timer(/* -012 (5) */)
{
@@ -598,6 +619,7 @@ public sealed class ScriptedAlarmEngineTests
// (6) Cold-start AreInputsReady guard — null value, Bad status, and Uncertain
// status inputs are all handled correctly.
/// <summary>Verifies that AreInputsReady blocks predicate evaluation when inputs have null values or Bad status codes, while Uncertain quality is accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AreInputsReady_blocks_evaluation_for_null_and_bad_inputs(/* -012 (6) */)
{
@@ -635,6 +657,7 @@ public sealed class ScriptedAlarmEngineTests
// not deadlock against _evalGate. Both regressions are covered here.
// -------------------------------------------------------------------------
/// <summary>Verifies that an OnEvent subscriber can call engine methods (e.g. AcknowledgeAsync) without deadlocking against the evaluation gate (regression for Core.ScriptedAlarms-003).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OnEvent_subscriber_can_call_back_into_engine_without_deadlock(/* -003 */)
{
@@ -748,6 +771,7 @@ public sealed class ScriptedAlarmEngineTests
// to a (possibly disposed) store after the engine has returned.
// -------------------------------------------------------------------------
/// <summary>Verifies that Dispose blocks until in-flight background re-evaluation tasks complete, preventing the engine from outliving its store (regression for Core.ScriptedAlarms-006).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_drains_in_flight_reevaluation_tasks(/* -006 */)
{
@@ -794,6 +818,7 @@ public sealed class ScriptedAlarmEngineTests
// explicitly. The two policies are documented in docs/ScriptedAlarms.md.
// -------------------------------------------------------------------------
/// <summary>Verifies that Uncertain-quality inputs are accepted by the predicate but rendered as "{?}" in the operator-facing message template (Core.ScriptedAlarms-010).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Uncertain_quality_drives_predicate_but_renders_question_mark_in_message(/* -010 */)
{
@@ -842,6 +867,7 @@ public sealed class ScriptedAlarmEngineTests
// consumers).
// -------------------------------------------------------------------------
/// <summary>Verifies that the Comments collection is an ImmutableList, enabling O(log n) append and satisfying IReadOnlyList consumers (Core.ScriptedAlarms-008).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Comments_collection_uses_ImmutableList_for_efficient_append(/* -008 */)
{
@@ -907,29 +933,22 @@ public sealed class ScriptedAlarmEngineTests
/// <summary>Gets or sets a value indicating whether the next SaveAsync call should throw a simulated failure.</summary>
public bool FailSave { get; set; }
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> _inner.LoadAsync(alarmId, ct);
/// <summary>Loads all alarm condition states from the inner store.</summary>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> _inner.LoadAllAsync(ct);
/// <summary>Saves an alarm condition state, optionally throwing if FailSave is set.</summary>
/// <param name="state">The alarm condition state to save.</param>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
if (FailSave) throw new InvalidOperationException("Simulated store failure");
return _inner.SaveAsync(state, ct);
}
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public Task RemoveAsync(string alarmId, CancellationToken ct)
=> _inner.RemoveAsync(alarmId, ct);
}
@@ -946,20 +965,15 @@ public sealed class ScriptedAlarmEngineTests
/// <summary>Gets a value indicating whether a SaveAsync call is currently blocked waiting on BlockNextSave.</summary>
public bool SaveInProgress { get; private set; }
/// <summary>Loads an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to load.</param>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
=> _inner.LoadAsync(alarmId, ct);
/// <summary>Loads all alarm condition states from the inner store.</summary>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
=> _inner.LoadAllAsync(ct);
/// <summary>Saves an alarm condition state, optionally blocking on BlockNextSave gate.</summary>
/// <param name="state">The alarm condition state to save.</param>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public async Task SaveAsync(AlarmConditionState state, CancellationToken ct)
{
var gate = BlockNextSave;
@@ -973,9 +987,7 @@ public sealed class ScriptedAlarmEngineTests
await _inner.SaveAsync(state, ct).ConfigureAwait(false);
}
/// <summary>Removes an alarm condition state by ID from the inner store.</summary>
/// <param name="alarmId">The ID of the alarm condition state to remove.</param>
/// <param name="ct">A cancellation token.</param>
/// <inheritdoc />
public Task RemoveAsync(string alarmId, CancellationToken ct)
=> _inner.RemoveAsync(alarmId, ct);
}
@@ -983,6 +995,7 @@ public sealed class ScriptedAlarmEngineTests
// --- Core.ScriptedAlarms-009: per-alarm evaluation-scratch reuse ---
/// <summary>Verifies that re-evaluations reuse the same read cache dictionary instance instead of allocating a new one.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reevaluation_reuses_the_same_read_cache_dictionary()
{
@@ -1016,6 +1029,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that re-evaluations reuse the same predicate context instance across evaluations.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reevaluation_reuses_the_same_predicate_context()
{
@@ -1042,6 +1056,7 @@ public sealed class ScriptedAlarmEngineTests
}
/// <summary>Verifies that LoadAsync clears prior evaluation scratch so new alarms use fresh scratch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LoadAsync_drops_the_prior_generations_scratch()
{
@@ -41,6 +41,7 @@ public sealed class ScriptedAlarmSourceTests
}
/// <summary>Verifies that subscribing with an empty filter receives every alarm emission.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
{
@@ -66,6 +67,7 @@ public sealed class ScriptedAlarmSourceTests
}
/// <summary>Verifies that subscribing with an equipment prefix filters alarms by that prefix.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
{
@@ -89,6 +91,7 @@ public sealed class ScriptedAlarmSourceTests
}
/// <summary>Verifies that unsubscribing stops further alarm events.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task Unsubscribe_stops_further_events()
{
@@ -108,6 +111,7 @@ public sealed class ScriptedAlarmSourceTests
}
/// <summary>Verifies that AcknowledgeAsync routes to the engine with a default user.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
{
@@ -130,6 +134,7 @@ public sealed class ScriptedAlarmSourceTests
}
/// <summary>Verifies that null arguments are rejected.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task Null_arguments_rejected()
{
@@ -66,6 +66,7 @@ public sealed class CompiledScriptCacheTests
}
/// <summary>Verifies that a cached evaluator produces correct results when executed.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task Cached_evaluator_still_runs_correctly()
{
@@ -27,6 +27,7 @@ public sealed class ScriptSandboxTests
}
/// <summary>Verifies that a script can compile, run, and read a seeded tag.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Happy_path_script_runs_and_reads_seeded_tag()
{
@@ -39,6 +40,7 @@ public sealed class ScriptSandboxTests
}
/// <summary>Verifies that SetVirtualTag records write operations.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task SetVirtualTag_records_the_write()
{
@@ -316,6 +318,7 @@ public sealed class ScriptSandboxTests
}
/// <summary>Verifies that an allowed generic type argument still compiles.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Allowed_generic_type_argument_still_compiles()
{
@@ -331,6 +334,7 @@ public sealed class ScriptSandboxTests
}
/// <summary>Verifies that typeof an allowed type still compiles.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Allowed_typeof_still_compiles()
{
@@ -342,6 +346,7 @@ public sealed class ScriptSandboxTests
}
/// <summary>Verifies that script exceptions propagate unwrapped.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Script_exception_propagates_unwrapped()
{
@@ -370,6 +375,7 @@ public sealed class ScriptSandboxTests
}
/// <summary>Verifies that LINQ Enumerable is available from scripts.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Linq_Enumerable_is_available_from_scripts()
{
@@ -385,6 +391,7 @@ public sealed class ScriptSandboxTests
}
/// <summary>Verifies that DataValueSnapshot is usable in scripts.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task DataValueSnapshot_is_usable_in_scripts()
{
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
public sealed class TimedScriptEvaluatorTests
{
/// <summary>Verifies that fast scripts complete under timeout and return value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Fast_script_completes_under_timeout_and_returns_value()
{
@@ -28,6 +29,7 @@ public sealed class TimedScriptEvaluatorTests
}
/// <summary>Verifies that scripts longer than timeout throw ScriptTimeoutException.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
{
@@ -50,6 +52,7 @@ public sealed class TimedScriptEvaluatorTests
}
/// <summary>Verifies that caller cancellation takes precedence over timeout.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Caller_cancellation_takes_precedence_over_timeout()
{
@@ -108,6 +111,7 @@ public sealed class TimedScriptEvaluatorTests
}
/// <summary>Verifies that script exceptions propagate unwrapped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Script_exception_propagates_unwrapped()
{
@@ -124,6 +128,7 @@ public sealed class TimedScriptEvaluatorTests
}
/// <summary>Verifies that ScriptTimeoutException message points at diagnostic path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
{
@@ -143,6 +148,7 @@ public sealed class TimedScriptEvaluatorTests
}
/// <summary>Verifies that caller cancellation wins even when timeout fires first.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Caller_cancellation_wins_even_when_timeout_fires_first()
{
@@ -10,10 +10,10 @@ public sealed class DriverHostTests
{
private sealed class StubDriver(string id, bool failInit = false) : IDriver
{
/// <summary>Gets the driver instance identifier.</summary>
/// <inheritdoc />
public string DriverInstanceId { get; } = id;
/// <summary>Gets the driver type name.</summary>
/// <inheritdoc />
public string DriverType => "Stub";
/// <summary>Gets a value indicating whether the driver has been initialized.</summary>
@@ -22,9 +22,7 @@ public sealed class DriverHostTests
/// <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>
/// <inheritdoc />
public Task InitializeAsync(string _, CancellationToken ct)
{
if (failInit) throw new InvalidOperationException("boom");
@@ -32,28 +30,25 @@ 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>
/// <inheritdoc />
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; }
/// <summary>Gets the current health status of the driver.</summary>
/// <inheritdoc />
public DriverHealth GetHealth() =>
new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
/// <summary>Verifies that registering a driver initializes it and tracks its health.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Register_initializes_driver_and_tracks_health()
{
@@ -68,6 +63,7 @@ public sealed class DriverHostTests
}
/// <summary>Verifies that registration rethrows initialization failures but keeps the driver registered.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Register_rethrows_init_failure_but_keeps_driver_registered()
{
@@ -81,6 +77,7 @@ public sealed class DriverHostTests
}
/// <summary>Verifies that duplicate driver registration throws an exception.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Duplicate_registration_throws()
{
@@ -92,6 +89,7 @@ public sealed class DriverHostTests
}
/// <summary>Verifies that unregistering a driver shuts it down and removes it.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Unregister_shuts_down_and_removes()
{
@@ -113,6 +111,7 @@ public sealed class DriverHostTests
/// The driver awaits an unsettled TaskCompletionSource so it does not introduce its
/// own capture — only DriverHost's await of the returned Task can drive a post.
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task RegisterAsync_Does_Not_Capture_SynchronizationContext()
{
@@ -137,6 +136,7 @@ public sealed class DriverHostTests
}
/// <summary>Verifies that UnregisterAsync does not capture the synchronization context.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task UnregisterAsync_Does_Not_Capture_SynchronizationContext()
{
@@ -165,6 +165,7 @@ public sealed class DriverHostTests
}
/// <summary>Verifies that DisposeAsync does not capture the synchronization context.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task DisposeAsync_Does_Not_Capture_SynchronizationContext()
{
@@ -225,34 +226,28 @@ 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>
/// <inheritdoc />
public string DriverInstanceId { get; } = id;
/// <summary>Gets the driver type name.</summary>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken ct) => (shutdownTcs ?? CompletedTcs).Task;
/// <summary>Gets the current health status of the driver.</summary>
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
private static readonly TaskCompletionSource CompletedTcs = MakeCompleted();
@@ -271,7 +266,6 @@ 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)
{
@@ -279,7 +273,6 @@ public sealed class DriverHostTests
_queue.Enqueue(() => d(state));
}
/// <summary>Sends a callback synchronously.</summary>
/// <inheritdoc />
public override void Send(SendOrPostCallback d, object? state)
{
@@ -15,6 +15,7 @@ public sealed class GenericDriverNodeManagerTests
/// This is the plumbing that PR 16's concrete OPC UA builder will use to update the actual
/// AlarmConditionState nodes.
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Alarm_events_are_routed_to_the_sink_registered_for_the_matching_source_node_id()
{
@@ -45,6 +46,7 @@ public sealed class GenericDriverNodeManagerTests
}
/// <summary>Verifies that non-alarm variables do not register sinks in the alarm tracker.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Non_alarm_variables_do_not_register_sinks()
{
@@ -59,6 +61,7 @@ public sealed class GenericDriverNodeManagerTests
}
/// <summary>Verifies that alarm events with unknown source node IDs are silently dropped.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Unknown_source_node_id_is_dropped_silently()
{
@@ -74,6 +77,7 @@ public sealed class GenericDriverNodeManagerTests
}
/// <summary>Verifies that disposing the node manager unsubscribes from alarm events.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Dispose_unsubscribes_from_OnAlarmEvent()
{
@@ -96,6 +100,7 @@ public sealed class GenericDriverNodeManagerTests
/// must unsubscribe the old alarm forwarder and clear the sink registry before re-walking,
/// so alarm transitions are not delivered twice.
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Second_BuildAddressSpaceAsync_Does_Not_Double_Fire_Alarms()
{
@@ -121,6 +126,7 @@ public sealed class GenericDriverNodeManagerTests
}
/// <summary>Verifies that a second call to BuildAddressSpaceAsync clears the old sink registry.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Second_BuildAddressSpaceAsync_Clears_Old_Sink_Registry()
{
@@ -137,6 +143,7 @@ public sealed class GenericDriverNodeManagerTests
}
/// <summary>Verifies that calling BuildAddressSpaceAsync after disposal throws ObjectDisposedException.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task BuildAddressSpaceAsync_After_Dispose_Throws_ObjectDisposedException()
{
@@ -154,6 +161,7 @@ public sealed class GenericDriverNodeManagerTests
/// out of <c>BuildAddressSpaceAsync</c> unhandled so the Server layer's per-driver try/catch
/// (<c>OpcUaApplicationHost.PopulateAddressSpaces</c>) can mark the subtree Faulted.
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task BuildAddressSpaceAsync_Propagates_Discovery_Exceptions_To_Caller()
{
@@ -169,33 +177,25 @@ 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>
/// <inheritdoc />
public string DriverInstanceId => "throwing";
/// <summary>Gets the driver type name.</summary>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask;
/// <summary>Shuts down the driver.</summary>
/// <param name="_">Cancellation token (unused in test double).</param>
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, null, null);
/// <summary>Gets the memory footprint of the driver.</summary>
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="_">Cancellation token (unused in test double).</param>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
=> throw new InvalidOperationException("discovery boom");
}
@@ -204,35 +204,27 @@ public sealed class GenericDriverNodeManagerTests
private sealed class FakeDriver : IDriver, ITagDiscovery, IAlarmSource
{
/// <summary>Gets the driver instance identifier.</summary>
/// <inheritdoc />
public string DriverInstanceId => "fake";
/// <summary>Gets the driver type name.</summary>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask;
/// <summary>Shuts down the driver.</summary>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Gets the current health status of the driver.</summary>
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
/// <summary>Gets the memory footprint of the driver.</summary>
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <summary>Flushes optional caches in the driver.</summary>
/// <param name="ct">Cancellation token.</param>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct)
{
var folder = builder.Folder("Tank", "Tank");
@@ -253,25 +245,19 @@ public sealed class GenericDriverNodeManagerTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public string DiagnosticId { get; } = diagnosticId;
}
@@ -281,31 +267,22 @@ public sealed class GenericDriverNodeManagerTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo _)
{
var sink = new RecordingSink();
@@ -319,8 +296,7 @@ public sealed class GenericDriverNodeManagerTests
{
/// <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>
/// <inheritdoc />
public void OnTransition(AlarmEventArgs args) => Received.Add(args);
}
}
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Observability;
public sealed class CapabilityInvokerEnrichmentTests
{
/// <summary>Verifies that InvokerExecute logs inside call site with structured properties.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task InvokerExecute_LogsInsideCallSite_CarryStructuredProperties()
{
@@ -45,6 +46,7 @@ public sealed class CapabilityInvokerEnrichmentTests
}
/// <summary>Verifies that InvokerExecute does not leak context outside the call site.</summary>
/// <returns>A task that represents the asynchronous test.</returns>
[Fact]
public async Task InvokerExecute_DoesNotLeak_ContextOutsideCallSite()
{
@@ -377,9 +377,7 @@ public sealed class EquipmentNodeWalkerTests
/// <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>
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string name, string _)
{
var child = new RecordingBuilder(name);
@@ -387,10 +385,7 @@ 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>
/// <inheritdoc />
public IVariableHandle Variable(string name, string _, DriverAttributeInfo attr)
{
var v = new RecordingVariable(name, attr);
@@ -398,10 +393,7 @@ 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>
/// <inheritdoc />
public void AddProperty(string name, DriverDataType _, object? value) =>
Properties.Add(new RecordingProperty(name, value));
}
@@ -412,10 +404,9 @@ public sealed class EquipmentNodeWalkerTests
/// <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>
/// <inheritdoc />
public string FullReference => AttributeInfo.FullName;
/// <summary>Marks the variable as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
/// <inheritdoc />
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
}
}
@@ -18,26 +18,18 @@ public sealed class IdentificationFolderBuilderTests
/// <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>
/// <inheritdoc />
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>
/// <inheritdoc />
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>
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value)
=> Properties.Add((browseName, dataType, value));
}
@@ -11,6 +11,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>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAsync_EmptyList_ReturnsEmpty_WithoutDriverCall()
{
@@ -24,6 +25,7 @@ public sealed class AlarmSurfaceInvokerTests
}
/// <summary>Verifies SubscribeAsync with no resolver routes through the default host.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAsync_SingleHost_RoutesThroughDefaultHost()
{
@@ -38,6 +40,7 @@ public sealed class AlarmSurfaceInvokerTests
}
/// <summary>Verifies SubscribeAsync fans out correctly to multiple hosts based on resolver.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAsync_MultiHost_FansOutByResolvedHost()
{
@@ -57,6 +60,7 @@ public sealed class AlarmSurfaceInvokerTests
}
/// <summary>Verifies AcknowledgeAsync does not retry on failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AcknowledgeAsync_DoesNotRetry_OnFailure()
{
@@ -70,6 +74,7 @@ public sealed class AlarmSurfaceInvokerTests
}
/// <summary>Verifies SubscribeAsync retries on transient failures.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAsync_Retries_Transient_Failures()
{
@@ -87,6 +92,7 @@ public sealed class AlarmSurfaceInvokerTests
/// Verify by using a per-call resolver with two distinct hosts and checking which host
/// name reaches the driver's UnsubscribeAlarmsAsync.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnsubscribeAsync_Routes_Through_Same_Host_As_Subscribe()
{
@@ -112,6 +118,7 @@ public sealed class AlarmSurfaceInvokerTests
}
/// <summary>Verifies UnsubscribeAsync with no resolver uses the default host.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnsubscribeAsync_SingleHost_UsesDefaultHost()
{
@@ -158,10 +165,7 @@ public sealed class AlarmSurfaceInvokerTests
/// <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>
/// <inheritdoc />
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
{
@@ -172,20 +176,14 @@ 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>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
{
@@ -194,7 +192,7 @@ public sealed class AlarmSurfaceInvokerTests
return Task.CompletedTask;
}
/// <summary>Occurs when an alarm event is raised.</summary>
/// <inheritdoc />
public event EventHandler<AlarmEventArgs>? OnAlarmEvent { add { } remove { } }
}
@@ -206,9 +204,7 @@ public sealed class AlarmSurfaceInvokerTests
/// <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>
/// <inheritdoc />
public string ResolveHost(string fullReference) => map[fullReference];
}
}
@@ -14,6 +14,7 @@ public sealed class CapabilityInvokerTests
new(builder, "drv-test", () => options);
/// <summary>Verifies that the capability invoker returns the value from the call site.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Read_ReturnsValue_FromCallSite()
{
@@ -29,6 +30,7 @@ public sealed class CapabilityInvokerTests
}
/// <summary>Verifies that the capability invoker retries on transient failures.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Read_Retries_OnTransientFailure()
{
@@ -52,6 +54,7 @@ public sealed class CapabilityInvokerTests
}
/// <summary>Verifies that non-idempotent writes do not retry even when the policy has retries configured.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_NonIdempotent_DoesNotRetry_EvenWhenPolicyHasRetries()
{
@@ -85,6 +88,7 @@ public sealed class CapabilityInvokerTests
}
/// <summary>Verifies that idempotent writes retry when the policy has retries configured.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_Idempotent_Retries_WhenPolicyHasRetries()
{
@@ -116,6 +120,7 @@ public sealed class CapabilityInvokerTests
}
/// <summary>Verifies that writes do not retry when the policy has zero retries configured.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_Default_DoesNotRetry_WhenPolicyHasZeroRetries()
{
@@ -143,6 +148,7 @@ public sealed class CapabilityInvokerTests
}
/// <summary>Verifies that different hosts are honored independently in the resilience pipeline.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Execute_HonorsDifferentHosts_Independently()
{
@@ -161,6 +167,7 @@ public sealed class CapabilityInvokerTests
/// redundant options objects on the per-write hot path and creates a consistency hazard
/// where an Admin edit mid-call could observe two different snapshots.
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task ExecuteWriteAsync_NonIdempotent_Snapshots_Options_Once_Per_Call()
{
@@ -195,6 +202,7 @@ public sealed class CapabilityInvokerTests
/// two derived values (<c>with</c> base + <c>Resolve(Write)</c>) come from the same options
/// instance.
/// </summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task ExecuteWriteAsync_NonIdempotent_Uses_Consistent_Options_Snapshot()
{
@@ -13,6 +13,7 @@ public sealed class DriverResiliencePipelineBuilderTests
private static readonly DriverResilienceOptions TierAOptions = new() { Tier = DriverTier.A };
/// <summary>Verifies that read operations retry transient failures.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Read_Retries_Transient_Failures()
{
@@ -31,6 +32,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that write operations do not retry on failure.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_DoesNotRetry_OnFailure()
{
@@ -53,6 +55,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that alarm acknowledge operations do not retry on failure.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task AlarmAcknowledge_DoesNotRetry_OnFailure()
{
@@ -115,6 +118,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that a dead host does not open the breaker for a sibling host.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task DeadHost_DoesNotOpenBreaker_ForSiblingHost()
{
@@ -146,6 +150,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that the circuit breaker opens after the failure threshold on tier A.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task CircuitBreaker_Opens_AfterFailureThreshold_OnTierA()
{
@@ -171,6 +176,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that timeout cancels slow operations.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Timeout_Cancels_SlowOperation()
{
@@ -211,6 +217,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that cancellation is not retried.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Cancellation_IsNot_Retried()
{
@@ -232,6 +239,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that the tracker records failure on every retry.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Tracker_RecordsFailure_OnEveryRetry()
{
@@ -253,6 +261,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that the tracker stamps the breaker open when it trips.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Tracker_StampsBreakerOpen_WhenBreakerTrips()
{
@@ -277,6 +286,7 @@ public sealed class DriverResiliencePipelineBuilderTests
}
/// <summary>Verifies that the tracker isolates counters per host.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Tracker_IsolatesCounters_PerHost()
{
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
public sealed class FlakeyDriverIntegrationTests
{
/// <summary>Verifies read succeeds after transient failures with retries.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Read_SurfacesSuccess_AfterTransientFailures()
{
@@ -43,6 +44,7 @@ public sealed class FlakeyDriverIntegrationTests
}
/// <summary>Verifies non-idempotent write fails on first failure without replay.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_NonIdempotent_FailsOnFirstFailure_NoReplay()
{
@@ -68,6 +70,7 @@ public sealed class FlakeyDriverIntegrationTests
}
/// <summary>Verifies idempotent write retries until success.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task Write_Idempotent_RetriesUntilSuccess()
{
@@ -93,6 +96,7 @@ public sealed class FlakeyDriverIntegrationTests
}
/// <summary>Verifies multiple hosts have independent failure counts and circuit breakers.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task MultipleHosts_OnOneDriver_HaveIndependentFailureCounts()
{
@@ -141,10 +145,7 @@ public sealed class FlakeyDriverIntegrationTests
_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>
/// <inheritdoc />
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences,
CancellationToken cancellationToken)
@@ -160,10 +161,7 @@ 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>
/// <inheritdoc />
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken)
@@ -69,6 +69,7 @@ public sealed class InFlightCounterTests
}
/// <summary>Verifies that CapabilityInvoker increments the tracker during execution.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task CapabilityInvoker_IncrementsTracker_DuringExecution()
{
@@ -97,6 +98,7 @@ public sealed class InFlightCounterTests
}
/// <summary>Verifies that CapabilityInvoker decrements the counter on exception.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task CapabilityInvoker_ExceptionPath_DecrementsCounter()
{
@@ -119,6 +121,7 @@ public sealed class InFlightCounterTests
}
/// <summary>Verifies that CapabilityInvoker without a tracker does not throw.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task CapabilityInvoker_WithoutTracker_DoesNotThrow()
{
@@ -22,13 +22,13 @@ public sealed class PerCallHostResolverDispatchTests
/// <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>
/// <inheritdoc />
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>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task DeadPlc_DoesNotOpenBreaker_For_HealthyPlc_With_Resolver()
{
@@ -80,6 +80,7 @@ public sealed class PerCallHostResolverDispatchTests
}
/// <summary>Verifies that without a resolver, the same host shares one resilience pipeline.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task WithoutResolver_SameHost_Shares_One_Pipeline()
{
@@ -98,6 +99,7 @@ public sealed class PerCallHostResolverDispatchTests
}
/// <summary>Verifies that with a resolver, different hosts get separate resilience pipelines.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task WithResolver_TwoHosts_Get_Two_Pipelines()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Stability;
public sealed class MemoryRecycleTests
{
/// <summary>Verifies that Tier C hard memory breach requests supervisor recycle.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task TierC_HardBreach_RequestsSupervisorRecycle()
{
@@ -25,6 +26,7 @@ public sealed class MemoryRecycleTests
/// <summary>Verifies that Tier A and B hard memory breach never request recycle.</summary>
/// <param name="tier">The driver tier to test.</param>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -40,6 +42,7 @@ public sealed class MemoryRecycleTests
}
/// <summary>Verifies that Tier C without supervisor hard breach is a no-op.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Fact]
public async Task TierC_WithoutSupervisor_HardBreach_NoOp()
{
@@ -52,6 +55,7 @@ public sealed class MemoryRecycleTests
/// <summary>Verifies that soft memory breach never requests recycle at any tier.</summary>
/// <param name="tier">The driver tier to test.</param>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Theory]
[InlineData(DriverTier.A)]
[InlineData(DriverTier.B)]
@@ -69,6 +73,7 @@ public sealed class MemoryRecycleTests
/// <summary>Verifies that non-breach memory actions are no-ops.</summary>
/// <param name="action">The non-breach memory tracking action to test.</param>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Theory]
[InlineData(MemoryTrackingAction.None)]
[InlineData(MemoryTrackingAction.Warming)]
@@ -85,16 +90,14 @@ public sealed class MemoryRecycleTests
private sealed class FakeSupervisor : IDriverSupervisor
{
/// <summary>Gets the driver instance identifier.</summary>
/// <inheritdoc />
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>
/// <inheritdoc />
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
{
RecycleCount++;
@@ -36,6 +36,7 @@ public sealed class ScheduledRecycleSchedulerTests
}
/// <summary>Verifies Tick before the next recycle time is a no-op.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Tick_BeforeNextRecycle_NoOp()
{
@@ -49,6 +50,7 @@ public sealed class ScheduledRecycleSchedulerTests
}
/// <summary>Verifies Tick at or after the next recycle time fires once and advances.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Tick_AtOrAfterNextRecycle_FiresOnce_AndAdvances()
{
@@ -63,6 +65,7 @@ public sealed class ScheduledRecycleSchedulerTests
}
/// <summary>Verifies RequestRecycleNow fires immediately without advancing the schedule.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequestRecycleNow_Fires_Immediately_WithoutAdvancingSchedule()
{
@@ -78,6 +81,7 @@ public sealed class ScheduledRecycleSchedulerTests
}
/// <summary>Verifies multiple ticks across the recycle interval each advance by one interval.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MultipleFires_AcrossTicks_AdvanceOneIntervalEach()
{
@@ -95,7 +99,7 @@ public sealed class ScheduledRecycleSchedulerTests
/// <summary>Fake driver supervisor for testing.</summary>
private sealed class FakeSupervisor : IDriverSupervisor
{
/// <summary>Gets the driver instance ID.</summary>
/// <inheritdoc />
public string DriverInstanceId => "tier-c-fake";
/// <summary>Gets the number of times RecycleAsync was called.</summary>
@@ -104,10 +108,7 @@ public sealed class ScheduledRecycleSchedulerTests
/// <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>
/// <inheritdoc />
public Task RecycleAsync(string reason, CancellationToken cancellationToken)
{
RecycleCount++;
@@ -42,16 +42,13 @@ public sealed class FakeUpstream : ITagUpstreamSource
}
}
/// <summary>Reads the current value of a tag.</summary>
/// <param name="path">The path to the tag.</param>
/// <inheritdoc />
public DataValueSnapshot ReadTag(string path)
=> _values.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
/// <summary>Subscribes to tag value changes.</summary>
/// <param name="path">The path to the tag.</param>
/// <param name="observer">The callback to invoke when the tag value changes.</param>
/// <inheritdoc />
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
{
var list = _subs.GetOrAdd(path, _ => []);
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
public sealed class TimerTriggerSchedulerTests
{
/// <summary>Verifies that timer interval causes periodic reevaluation of virtual tags.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Timer_interval_causes_periodic_reevaluation()
{
@@ -46,6 +47,7 @@ public sealed class TimerTriggerSchedulerTests
}
/// <summary>Verifies that tags without TimerInterval are not scheduled.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Tags_without_TimerInterval_not_scheduled()
{
@@ -98,6 +100,7 @@ public sealed class TimerTriggerSchedulerTests
// ----- Core.VirtualTags-007: timer ticks must not block pool threads and must skip when prior tick is still running -----
/// <summary>Verifies that tick is skipped when the prior tick for the same group is still running.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Tick_skips_when_prior_tick_for_the_same_group_is_still_running()
{
@@ -32,6 +32,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that a simple virtual tag script can read an upstream tag and return a coerced value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Simple_script_reads_upstream_and_returns_coerced_value()
{
@@ -52,6 +53,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that an upstream tag change triggers a cascade re-evaluation through two levels of dependent virtual tags.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Upstream_change_triggers_cascade_through_two_levels()
{
@@ -84,6 +86,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that a circular dependency among virtual tags is rejected at load time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Cycle_in_virtual_tags_rejected_at_Load()
{
@@ -98,6 +101,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that script compilation errors surface at load time with all failures aggregated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Script_compile_error_surfaces_at_Load_with_all_failures()
{
@@ -116,6 +120,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that a runtime exception in one virtual tag's script is isolated and does not affect other tags.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Script_runtime_exception_isolates_to_owning_tag()
{
@@ -138,6 +143,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that script timeout is mapped to BadInternalError status without killing the engine.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Timeout_maps_to_BadInternalError_without_killing_the_engine()
{
@@ -159,6 +165,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that subscribers receive notifications when the engine emits value changes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Subscribers_receive_engine_emitted_changes()
{
@@ -180,6 +187,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that the historize flag routes virtual tag values to the history writer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Historize_flag_routes_to_history_writer()
{
@@ -202,6 +210,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that upstream pushes are ignored when change-driven is false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Change_driven_false_ignores_upstream_push()
{
@@ -224,6 +233,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that reloading the engine replaces existing tags and resubscribes to upstream sources cleanly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reload_replaces_existing_tags_and_resubscribes_cleanly()
{
@@ -248,6 +258,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that disposing the engine releases all upstream subscriptions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Dispose_releases_upstream_subscriptions()
{
@@ -264,6 +275,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that SetVirtualTag called within a script updates the target and triggers observers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetVirtualTag_within_script_updates_target_and_triggers_observers()
{
@@ -288,6 +300,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that SetVirtualTag within a script cascades to change-triggered dependents of the written tag.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetVirtualTag_within_script_cascades_to_dependents_of_the_written_tag()
{
@@ -321,6 +334,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that type coercion from script double to configured int32 works correctly.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Type_coercion_from_script_double_to_config_int32()
{
@@ -339,6 +353,7 @@ public sealed class VirtualTagEngineTests
// ----- Core.VirtualTags-012: previously-missing coverage -----
/// <summary>Verifies that the AreInputsReady guard publishes BadWaitingForInitialData when upstream tags have bad status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AreInputsReady_guard_publishes_BadWaitingForInitialData_when_upstream_is_bad()
{
@@ -361,6 +376,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that the AreInputsReady guard recovers when an upstream tag transitions from bad to good status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AreInputsReady_guard_publishes_BadWaitingForInitialData_then_recovers_when_upstream_becomes_good()
{
@@ -386,6 +402,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that SetVirtualTag cascades to change-triggered dependents.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetVirtualTag_cascades_to_change_triggered_dependent()
{
@@ -417,6 +434,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that SetVirtualTag calls targeting unregistered paths are caught at load time.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SetVirtualTag_on_non_registered_path_is_caught_at_Load()
{
@@ -441,6 +459,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that EvaluateOneAsync throws ArgumentException when called for an unregistered path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EvaluateOneAsync_throws_ArgumentException_for_unregistered_path()
{
@@ -453,6 +472,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that a type coercion failure maps to BadInternalError status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CoerceResult_failure_maps_to_BadInternalError()
{
@@ -475,6 +495,7 @@ public sealed class VirtualTagEngineTests
// ----- Core.VirtualTags-011: Writes target validation at Load time -----
/// <summary>Verifies that Load rejects scripts that write to unregistered virtual tag paths.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Load_rejects_script_writing_to_unregistered_virtual_tag_path()
{
@@ -499,6 +520,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that Load accepts scripts that write to registered virtual tag paths.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Load_accepts_script_writing_to_registered_virtual_tag_path()
{
@@ -559,6 +581,7 @@ public sealed class VirtualTagEngineTests
// ----- Core.VirtualTags-004: CoerceResult default arm leaks uncoerced values -----
/// <summary>Verifies that CoerceResult correctly handles Int16, UInt16, UInt32, and UInt64 types.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CoerceResult_handles_Int16_UInt16_UInt32_UInt64()
{
@@ -588,6 +611,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that Load rejects virtual tag definitions with unsupported DriverDataType values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Load_rejects_definition_with_unsupported_DriverDataType()
{
@@ -608,6 +632,7 @@ public sealed class VirtualTagEngineTests
}
/// <summary>Verifies that Load rejects duplicate virtual tag paths with an aggregated error message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Load_rejects_duplicate_path_with_aggregated_error()
{
@@ -684,9 +709,7 @@ public sealed class VirtualTagEngineTests
/// <param name="buf">The list to store recorded history entries.</param>
public TestHistory(List<(string, DataValueSnapshot)> buf) => _buf = buf;
/// <summary>Records a virtual tag path and value snapshot to the history buffer.</summary>
/// <param name="path">The virtual tag path.</param>
/// <param name="value">The data value snapshot to record.</param>
/// <inheritdoc />
public void Record(string path, DataValueSnapshot value)
{
lock (_buf) { _buf.Add((path, value)); }
@@ -28,6 +28,7 @@ public sealed class VirtualTagSourceTests
}
/// <summary>Verifies that ReadAsync returns cached engine values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadAsync_returns_engine_cached_values()
{
@@ -42,6 +43,7 @@ public sealed class VirtualTagSourceTests
}
/// <summary>Verifies that unknown paths return Bad status quality.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ReadAsync_unknown_path_returns_Bad_quality()
{
@@ -52,6 +54,7 @@ public sealed class VirtualTagSourceTests
}
/// <summary>Verifies that subscribe fires the initial data callback immediately.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAsync_fires_initial_data_callback()
{
@@ -75,6 +78,7 @@ public sealed class VirtualTagSourceTests
}
/// <summary>Verifies that subscription fires on upstream changes via engine cascade.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeAsync_fires_on_upstream_change_via_engine_cascade()
{
@@ -101,6 +105,7 @@ public sealed class VirtualTagSourceTests
}
/// <summary>Verifies that unsubscribe stops further event emissions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnsubscribeAsync_stops_further_events()
{
@@ -123,6 +128,7 @@ public sealed class VirtualTagSourceTests
}
/// <summary>Verifies that null arguments are rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Null_arguments_rejected()
{