docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Unit tests for <see cref="AlarmSummaryService"/> (M7 T13 — Operator Alarm
/// Unit tests for <see cref="AlarmSummaryService"/> (Operator Alarm
/// Summary). The snapshot client and instance repository are substituted so the
/// fan-out + best-effort degradation + roll-up math are exercised without any
/// cluster traffic.
@@ -56,6 +56,8 @@ public class AlarmSummaryServiceTests
private static DebugViewSnapshot Snapshot(string instance, params AlarmStateChanged[] alarms) =>
new(instance, Array.Empty<AttributeValueChanged>(), alarms, Now);
/// <summary>Verifies that site alarms are aggregated from reporting instances and the failed instance is recorded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteAlarmsAsync_AggregatesReportingInstances_AndRecordsTheFailedOne()
{
@@ -104,6 +106,8 @@ public class AlarmSummaryServiceTests
.GetSnapshotAsync(SiteIdentifier, "inst-e", Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an instance-not-found snapshot goes to the not-reporting list.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSiteAlarmsAsync_InstanceNotFoundSnapshot_GoesToNotReporting()
{
@@ -122,6 +126,7 @@ public class AlarmSummaryServiceTests
Assert.Equal(new[] { "ghost" }, result.NotReportingInstances);
}
/// <summary>Verifies that the rollup computes the worst severity, the active and unacknowledged counts and the kind counts.</summary>
[Fact]
public void ComputeRollup_ComputesWorstSeverityActiveAndUnackedAndKindCounts()
{
@@ -153,6 +158,7 @@ public class AlarmSummaryServiceTests
Assert.Equal(2, rollup.CountsByKind[AlarmKind.Computed]);
}
/// <summary>Verifies that a rollup with no active rows has a worst severity of zero.</summary>
[Fact]
public void ComputeRollup_NoActiveRows_WorstSeverityZero()
{
@@ -168,8 +174,9 @@ public class AlarmSummaryServiceTests
Assert.Equal(0, rollup.UnackedCount);
}
// ── BuildFromLiveAlarms (plan #10, Task 5) ────────────────────────────────
// ── BuildFromLiveAlarms ────────────────────────────────
/// <summary>Verifies that building from live alarms flattens and sorts the same way as the poll path, with an empty not-reporting list.</summary>
[Fact]
public void BuildFromLiveAlarms_FlattensAndSorts_SameAsPollPath_WithEmptyNotReporting()
{
@@ -195,6 +202,7 @@ public class AlarmSummaryServiceTests
Assert.Empty(result.NotReportingInstances);
}
/// <summary>Verifies that building from an empty live snapshot yields no rows.</summary>
[Fact]
public void BuildFromLiveAlarms_EmptySnapshot_YieldsNoRows()
{
@@ -19,6 +19,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests
private static IReadOnlyDictionary<string, string> Names(params (string KeyId, string Name)[] entries) =>
entries.ToDictionary(e => e.KeyId, e => e.Name, StringComparer.Ordinal);
/// <summary>Verifies that approve adds method to key preserving existing scopes.</summary>
[Fact]
public void Approve_AddsMethodToKey_PreservingExistingScopes()
{
@@ -35,6 +36,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests
Assert.Equal(new[] { "GetStatus", "PlaceOrder" }, update.NewMethods);
}
/// <summary>Verifies that approve key with no existing scopes gets just this method.</summary>
[Fact]
public void Approve_KeyWithNoExistingScopes_GetsJustThisMethod()
{
@@ -49,6 +51,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests
Assert.Equal(new[] { "PlaceOrder" }, update.NewMethods);
}
/// <summary>Verifies that revoke removes method leaving other scopes intact.</summary>
[Fact]
public void Revoke_RemovesMethod_LeavingOtherScopesIntact()
{
@@ -65,6 +68,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests
Assert.Equal(new[] { "GetStatus" }, update.NewMethods);
}
/// <summary>Verifies that revoke last scope reported as empty conflict and not in updates.</summary>
[Fact]
public void Revoke_LastScope_ReportedAsEmptyConflict_AndNotInUpdates()
{
@@ -80,6 +84,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests
Assert.Equal("Key One", emptyName);
}
/// <summary>Verifies that mixed approve one revoke another produces both updates.</summary>
[Fact]
public void Mixed_ApproveOneRevokeAnother_ProducesBothUpdates()
{
@@ -102,6 +107,7 @@ public sealed class ApiMethodKeyScopeReconcilerTests
Assert.Equal(new[] { "Ping", "PlaceOrder" }, k2.NewMethods);
}
/// <summary>Verifies that no change produces no updates.</summary>
[Fact]
public void NoChange_ProducesNoUpdates()
{
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Tests for <see cref="AuditLogExportService"/> (#23 M7-T14 / Bundle F). The
/// Tests for <see cref="AuditLogExportService"/>. The
/// service streams the filtered Audit Log query to a destination stream as
/// RFC 4180 CSV. These tests pin:
/// <list type="bullet">
@@ -22,7 +22,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// </summary>
public class AuditLogExportServiceTests
{
// C3 (Task 2.5): the export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows
// The export service reads canonical ZB.MOM.WW.Audit.AuditEvent rows
// from IAuditLogRepository and decomposes each into the flat 21-column CSV shape;
// build the canonical rows via the shared factory (domain fields ride in DetailsJson).
private static AuditEvent SimpleEvent(string id, string? target = null, string? error = null)
@@ -39,6 +39,8 @@ public class AuditLogExportServiceTests
errorMessage: error,
ingestedAtUtc: new DateTimeOffset(new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc)));
/// <summary>Verifies that exporting five rows writes a header line plus five data lines.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_FiveRows_WritesHeaderPlusFiveRows()
{
@@ -74,6 +76,8 @@ public class AuditLogExportServiceTests
Assert.Equal(string.Empty, lines[6]);
}
/// <summary>Verifies that the CSV header contains all 24 columns in specification order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_HeaderHasAll24Columns_InSpecOrder()
{
@@ -89,7 +93,7 @@ public class AuditLogExportServiceTests
var header = csv.Split("\r\n")[0];
var columns = header.Split(',');
// arch-review 2026-08-19 P6-7: ExecutionId/ParentExecutionId/SourceNode
// ExecutionId/ParentExecutionId/SourceNode
// were missing from this CSV (JSON/JSONL exports already carried them).
Assert.Equal(24, columns.Length);
Assert.Equal(new[]
@@ -103,6 +107,8 @@ public class AuditLogExportServiceTests
}, columns);
}
/// <summary>Verifies that a field containing a comma is quoted and escaped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_FieldWithComma_QuotedAndEscaped()
{
@@ -139,10 +145,12 @@ public class AuditLogExportServiceTests
Assert.Contains("\"boom\r\nthen again\"", csv);
}
/// <summary>Verifies that free-text cells starting with formula characters are prefixed with a single quote.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_FreeTextLeadingFormulaCharacters_ArePrefixedWithQuote()
{
// arch-review 2026-08-19 P6-7 (CWE-1236): a free-text cell starting with
// CSV formula injection guard: a free-text cell starting with
// =, +, -, @, or TAB must be prefixed with a literal single quote so a
// spreadsheet application renders it as inert text, not a formula/DDE
// launch. Target, ErrorMessage, ResponseSummary, and Extra are
@@ -176,6 +184,8 @@ public class AuditLogExportServiceTests
Assert.Contains(",'@SUM(A1:A9)", dataLine);
}
/// <summary>Verifies that numeric and date columns are never prefixed with a quote.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_NumericAndDateColumns_NeverPrefixed()
{
@@ -210,6 +220,8 @@ public class AuditLogExportServiceTests
Assert.StartsWith("2026-05-20", fields[1]); // OccurredAtUtc, unprefixed
}
/// <summary>Verifies that a null field is written as an empty cell.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_NullField_WrittenAsEmpty()
{
@@ -254,6 +266,8 @@ public class AuditLogExportServiceTests
Assert.Equal(string.Empty, fields[23]); // ForwardState null
}
/// <summary>Verifies that a row count above the cap is truncated and a cap footer is appended.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_RowCountAboveCap_Truncates_AppendsCapFooter()
{
@@ -279,6 +293,8 @@ public class AuditLogExportServiceTests
Assert.Equal("# Capped at 3 rows. Use the CLI for larger exports.", lines[4]);
}
/// <summary>Verifies that cancelling the token stops the export mid-stream.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_CancellationToken_StopsMidStream()
{
@@ -310,11 +326,13 @@ public class AuditLogExportServiceTests
await sut.ExportAsync(new AuditLogQueryFilter(), maxRows: 1000, ms, cts.Token));
}
/// <summary>Verifies that the export paginates using the last row of each page as the cursor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExportAsync_PaginatesUsingLastRowAsCursor()
{
// Two pages of 2 rows each, then empty. The service must pass the last
// row of page 1 as the cursor on the page-2 call.
// row of page 1 as the cursor on the second-page call.
AuditEvent Row(string id, DateTime occurredAt) => ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
/// Service-level tests for <see cref="AuditLogQueryService"/>. The
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
/// these tests pin the filter forwarding contract and the 100-row default-page-size
/// rule the grid relies on.
@@ -30,13 +30,15 @@ public class AuditLogQueryServiceTests
return agg;
}
/// <summary>Verifies that QueryAsync forwards the filter and paging to the repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
{
var repo = Substitute.For<IAuditLogRepository>();
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
var paging = new AuditLogPaging(PageSize: 25);
// C3 (Task 2.5): the repository returns canonical ZB.MOM.WW.Audit.AuditEvent rows;
// The repository returns canonical ZB.MOM.WW.Audit.AuditEvent rows;
// the service decomposes each into a flat AuditEventView for the UI.
var eventId = Guid.NewGuid();
var repoRows = new List<AuditEvent>
@@ -64,6 +66,8 @@ public class AuditLogQueryServiceTests
await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that QueryAsync applies the default page size when none is specified.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
{
@@ -84,9 +88,11 @@ public class AuditLogQueryServiceTests
}
// ─────────────────────────────────────────────────────────────────────────
// M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator
// GetKpiSnapshotAsync — composes repo + health-aggregator
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that GetKpiSnapshotAsync forwards to the repository and adds the backlog reported by the health aggregator.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator()
{
@@ -127,6 +133,8 @@ public class AuditLogQueryServiceTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a site without a backlog snapshot contributes zero to the KPI backlog.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
{
@@ -153,7 +161,7 @@ public class AuditLogQueryServiceTests
}
// ─────────────────────────────────────────────────────────────────────────
// #23 M7 — DbContext concurrency race regression (Bundle H follow-up)
// DbContext concurrency race regression
//
// The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized
// auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME
@@ -164,6 +172,8 @@ public class AuditLogQueryServiceTests
// context — these tests pin that contract.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that concurrent queries do not race on a shared database context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace()
{
@@ -205,6 +215,8 @@ public class AuditLogQueryServiceTests
Assert.All(results, Assert.NotNull);
}
/// <summary>Verifies that QueryAsync opens a fresh service scope for each call rather than sharing one across calls.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls()
{
@@ -236,11 +248,13 @@ public class AuditLogQueryServiceTests
}
// ─────────────────────────────────────────────────────────────────────────
// Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
// Audit Log ParentExecutionId feature: GetExecutionTreeAsync —
// a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
// QueryAsync's scope-per-call contract on the production path.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that GetExecutionTreeAsync forwards the execution ID to the repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
{
@@ -265,6 +279,8 @@ public class AuditLogQueryServiceTests
await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that GetExecutionTreeAsync opens a fresh scope for each call when built with the production constructor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
{
@@ -301,9 +317,11 @@ public class AuditLogQueryServiceTests
}
// ─────────────────────────────────────────────────────────────────────────
// Task 15: SourceNode filter forwarding + distinct-nodes service contract.
// SourceNode filter forwarding + distinct-nodes service contract.
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Verifies that QueryAsync forwards the source nodes filter to the repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository()
{
@@ -329,6 +347,8 @@ public class AuditLogQueryServiceTests
Arg.Any<CancellationToken>());
}
/// <summary>Verifies that GetDistinctSourceNodesAsync forwards to the repository on the first call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
{
@@ -345,6 +365,8 @@ public class AuditLogQueryServiceTests
await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
}
/// <summary>Verifies that GetDistinctSourceNodesAsync caches the snapshot across repeated calls.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls()
{
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
public class AuditTreeScopeTests
{
/// <summary>Verifies that the production tree read resolves a fresh caller scope on every call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProductionTreeRead_UsesFreshCallerScope_EveryTime()
{
@@ -32,7 +34,9 @@ public class AuditTreeScopeTests
private sealed class ChangingAuth : AuthenticationStateProvider
{
/// <summary>Gets or sets the site id claim that the next authentication state carries.</summary>
public string Site { get; set; } = "42";
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity(new[] {
new Claim(JwtTokenService.RoleClaimType, Roles.Viewer),
@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
public class AuthorizedManualFailoverTests
{
/// <summary>Verifies that an administrator whose role has been revoked cannot dispatch a manual failover to central or a site.</summary>
/// <param name="site">True to target a site failover, or false to target the central cluster.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -7,6 +7,7 @@ public class BindingGroupBuilderTests
private static BindingRow Row(string canonical, string? dflt, int? conn, string? ovr) =>
new(canonical, dflt, conn, ovr);
/// <summary>Verifies that groups list direct attributes first, then modules in name order.</summary>
[Fact]
public void Groups_DirectAttributesFirst_ThenModules_InNameOrder()
{
@@ -21,6 +22,7 @@ public class BindingGroupBuilderTests
groups.Select(g => g.Key));
}
/// <summary>Verifies that a nested module folds into its first path segment and keeps the nested tail.</summary>
[Fact]
public void NestedModule_FoldsIntoFirstSegment_AndKeepsNestedTail()
{
@@ -36,6 +38,7 @@ public class BindingGroupBuilderTests
Assert.Equal("Left_002", g.InferredObject);
}
/// <summary>Verifies that the inferred object is the nested object path.</summary>
[Fact]
public void InferredObject_NestedObjectPath()
{
@@ -47,6 +50,7 @@ public class BindingGroupBuilderTests
Assert.Equal("Left_002.DelmiaReceiver", Assert.Single(BindingGroupBuilder.Build(rows)).InferredObject);
}
/// <summary>Verifies that the inferred object is null and the state is mixed when members disagree.</summary>
[Fact]
public void InferredObject_NullWhenMixed_AndStateMixed()
{
@@ -60,6 +64,7 @@ public class BindingGroupBuilderTests
Assert.True(g.IsObjectMixed);
}
/// <summary>Verifies that the inferred object is null and the group is not mixed when there are no overrides.</summary>
[Fact]
public void InferredObject_NullWhenNoOverrides_NotMixed()
{
@@ -69,6 +74,7 @@ public class BindingGroupBuilderTests
Assert.False(g.IsObjectMixed);
}
/// <summary>Verifies that an instance group infers its object from the overrides.</summary>
[Fact]
public void InstanceGroup_InfersObjectFromOverrides()
{
@@ -78,6 +84,7 @@ public class BindingGroupBuilderTests
Assert.Equal("Z28061", g.InferredObject);
}
/// <summary>Verifies that the unbound count and the connection state are computed correctly.</summary>
[Fact]
public void Counts_UnboundAndConnectionState()
{
@@ -94,6 +101,7 @@ public class BindingGroupBuilderTests
Assert.Null(g.CommonConnectionId);
}
/// <summary>Verifies that the connection state is single when all members agree.</summary>
[Fact]
public void ConnectionState_SingleWhenAllAgree()
{
@@ -103,6 +111,7 @@ public class BindingGroupBuilderTests
Assert.Equal(1, g.CommonConnectionId);
}
/// <summary>Verifies that a member override that does not end with the tail counts as mixed.</summary>
[Fact]
public void MemberTail_OverrideThatDoesNotEndWithTail_CountsAsMixed()
{
@@ -112,6 +121,7 @@ public class BindingGroupBuilderTests
Assert.True(Assert.Single(BindingGroupBuilder.Build(rows)).IsObjectMixed);
}
/// <summary>Verifies that applying an object rewrites every member and sets the connection.</summary>
[Fact]
public void ApplyObject_RewritesEveryMember_AndSetsConnection()
{
@@ -126,6 +136,7 @@ public class BindingGroupBuilderTests
Assert.Equal(("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_022.ScanStateCmd"), (changes[1].CanonicalName, changes[1].ConnectionId, changes[1].Override));
}
/// <summary>Verifies that applying an object with a nested tail keeps the nesting.</summary>
[Fact]
public void ApplyObject_NestedTail_KeepsNesting()
{
@@ -134,6 +145,7 @@ public class BindingGroupBuilderTests
Assert.Equal("Left_002.LeakTest.DeltaVac", c.Override);
}
/// <summary>Verifies that applying an empty object clears the overrides and keeps the connection.</summary>
[Fact]
public void ApplyObject_EmptyObject_ClearsOverrides_KeepsConnection()
{
@@ -145,6 +157,7 @@ public class BindingGroupBuilderTests
// ── Review fixes 2026-08-20 ──
/// <summary>Verifies that applying a null connection keeps each member's current connection.</summary>
[Fact]
public void ApplyObject_NullConnection_KeepsEachMembersCurrentConnection()
{
@@ -161,6 +174,7 @@ public class BindingGroupBuilderTests
Assert.All(changes, c => Assert.StartsWith("New.", c.Override!));
}
/// <summary>Verifies that the inferred object is mixed when some members are still on the bare default.</summary>
[Fact]
public void InferredObject_MixedWhenSomeMembersStillOnBareDefault()
{
@@ -172,6 +186,7 @@ public class BindingGroupBuilderTests
Assert.True(g.IsObjectMixed);
}
/// <summary>Verifies that the inferred object uses the effective reference, so a default that carries the prefix is not mixed.</summary>
[Fact]
public void InferredObject_UsesEffectiveReference_DefaultThatCarriesPrefixIsNotMixed()
{
@@ -181,6 +196,7 @@ public class BindingGroupBuilderTests
Assert.False(g.IsObjectMixed);
}
/// <summary>Verifies that with no overrides the object is inferred from the defaults when they share a prefix.</summary>
[Fact]
public void InferredObject_NoOverrides_InfersFromDefaultsWhenTheyShareAPrefix()
{
@@ -190,6 +206,7 @@ public class BindingGroupBuilderTests
Assert.False(g.IsObjectMixed);
}
/// <summary>Verifies that a group is unbound when its connection id is not a known connection.</summary>
[Fact]
public void IsUnbound_WhenConnectionIdIsNotAKnownConnection()
{
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// M9-T25: unit tests for <see cref="ConnectionHealthQueryService"/>, which projects
/// Unit tests for <see cref="ConnectionHealthQueryService"/>, which projects
/// the site health reports' name-keyed <see cref="SiteHealthReport.DataConnectionStatuses"/>
/// onto a connection-id → <see cref="ConnectionHealth"/> map for the design
/// DataConnections page. The site repository + health aggregator are substituted so
@@ -62,6 +62,8 @@ public sealed class ConnectionHealthQueryServiceTests
EnabledInstanceCount: 0,
DisabledInstanceCount: 0);
/// <summary>Verifies that connection names in a health report are mapped to connection ids.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Maps_ConnectionNameStatuses_ToConnectionIds()
{
@@ -81,6 +83,8 @@ public sealed class ConnectionHealthQueryServiceTests
Assert.Equal(2, map.Count);
}
/// <summary>Verifies that a site without a health report yields no entries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MissingReport_YieldsNoEntriesForThatSite()
{
@@ -95,6 +99,8 @@ public sealed class ConnectionHealthQueryServiceTests
Assert.Empty(map);
}
/// <summary>Verifies that a site state without a latest report yields no entries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StateWithoutLatestReport_YieldsNoEntries()
{
@@ -110,6 +116,8 @@ public sealed class ConnectionHealthQueryServiceTests
Assert.Empty(map);
}
/// <summary>Verifies that a connection name that cannot be resolved is skipped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnknownConnectionName_IsSkipped()
{
@@ -129,10 +137,12 @@ public sealed class ConnectionHealthQueryServiceTests
Assert.Single(map);
}
/// <summary>Verifies that connection names are resolved per site rather than globally.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolvesNamesPerSite_NotGloballyAmbiguous()
{
// Two sites each own a connection named "PLC-1". The name→id resolution
// Two sites each own a connection with the same name. The name→id resolution
// must be scoped to the report's own site so the right id is stamped.
var siteA = new Site("Plant-A", "plant-a") { Id = 1 };
var siteB = new Site("Plant-B", "plant-b") { Id = 2 };
@@ -18,6 +18,7 @@ public class CreateBindingPlannerTests
new BindingRow("Recv.State", "MESReceiver_023.State", null, null),
]);
/// <summary>Verifies that prefill fills blank objects only.</summary>
[Fact]
public void Prefill_fills_blank_objects_only()
{
@@ -34,6 +35,7 @@ public class CreateBindingPlannerTests
Assert.Equal(CreateBindingPlanner.ObjectSource.FromTemplate, CreateBindingPlanner.SourceOf(Groups()[2], result["Recv"]));
}
/// <summary>Verifies that prefill suggests root dot module for bare defaults.</summary>
[Fact]
public void Prefill_suggests_root_dot_module_for_bare_defaults()
{
@@ -42,6 +44,7 @@ public class CreateBindingPlannerTests
Assert.Equal(CreateBindingPlanner.ObjectSource.Suggested, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
}
/// <summary>Verifies that prefill with blank root sets connection only.</summary>
[Fact]
public void Prefill_with_blank_root_sets_connection_only()
{
@@ -51,6 +54,9 @@ public class CreateBindingPlannerTests
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]);
}
/// <summary>Verifies that object from pick strips member tail.</summary>
/// <param name="picked">The picked value for the case.</param>
/// <param name="expected">The expected value for the case.</param>
[Theory]
[InlineData("Z28063.Left.MoveInFlag", "Z28063.Left")] // leaf under the group → strip tail
[InlineData("Z28063.Left", "Z28063.Left")] // object node → as-is
@@ -59,6 +65,7 @@ public class CreateBindingPlannerTests
public void ObjectFromPick_strips_member_tail(string picked, string expected)
=> Assert.Equal(expected, CreateBindingPlanner.ObjectFromPick(Groups()[1], picked));
/// <summary>Verifies that build bindings skips groups without connection or object.</summary>
[Fact]
public void BuildBindings_skips_groups_without_connection_or_object()
{
@@ -73,6 +80,7 @@ public class CreateBindingPlannerTests
Assert.Equal(("Running", 7, "Z28063.Running"), (single.AttributeName, single.DataConnectionId, single.DataSourceReferenceOverride));
}
/// <summary>Verifies that build bindings rewrites every member of a bound group.</summary>
[Fact]
public void BuildBindings_rewrites_every_member_of_a_bound_group()
{
@@ -65,6 +65,7 @@ public sealed class FlowDebugServiceTests : IDisposable
private bool _siteReachable = true;
private string? _siteCallError;
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _system.Terminate().Wait(TimeSpan.FromSeconds(5));
private FlowDebugService CreateService(object? reply, string role = "Deployer", string? permittedSite = null)
@@ -113,6 +114,10 @@ public sealed class FlowDebugServiceTests : IDisposable
null, null, null, siteId, "node-a", "Sim01", "ERP.GetOrder", null,
Lifecycle: null, CorrelationId: correlationId);
/// <summary>Verifies that a scoped caller can relay only for the sites it has been granted.</summary>
/// <param name="siteId">The resolved site id of the step.</param>
/// <param name="expected">Whether the relay is expected to be sent.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(42, true)]
[InlineData(99, false)]
@@ -128,6 +133,9 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal(expected ? 1 : 0, _siteCallRequests.Count);
}
/// <summary>Verifies that a scoped caller cannot relay for a site that cannot be resolved.</summary>
/// <param name="retry">True to retry the outbound call, false to discard it.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(true)]
[InlineData(false)]
@@ -140,6 +148,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_siteCallRequests);
}
/// <summary>Verifies that retrying a parked API call relays a retry site call carrying the correlation id and site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_ApiCallParked_RelaysRetrySiteCallWithCorrelationAndSite()
{
@@ -158,6 +168,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_outboxRequests);
}
/// <summary>Verifies that retrying a notification relays a retry notification request.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_Notify_RelaysRetryNotification()
{
@@ -174,6 +186,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_siteCallRequests);
}
/// <summary>Verifies that discarding a database call relays a discard site call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardOutbound_DbCall_RelaysDiscardSiteCall()
{
@@ -191,6 +205,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal("tester", request.RequestedBy);
}
/// <summary>Verifies that retrying a step that is not parked is refused without relaying.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_NotParked_RefusesWithoutRelay()
{
@@ -204,6 +220,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_outboxRequests);
}
/// <summary>Verifies that retrying a step with no correlation id is refused.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_NoCorrelation_Refuses()
{
@@ -217,6 +235,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_outboxRequests);
}
/// <summary>Verifies that retrying a site-kind step without a site id is refused.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_SiteKindWithoutSiteId_Refuses()
{
@@ -230,6 +250,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_outboxRequests);
}
/// <summary>Verifies that retrying a step that is not an outbound call is refused.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_NonOutboundStep_Refuses()
{
@@ -243,6 +265,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_outboxRequests);
}
/// <summary>Verifies that an unreachable site maps to a failure message on retry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_SiteUnreachable_MapsToFailureMessage()
{
@@ -257,6 +281,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Single(_siteCallRequests);
}
/// <summary>Verifies that an operation that is not parked at the site maps to a failure message on retry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RetryOutbound_NotParkedAtSite_MapsToFailureMessage()
{
@@ -269,6 +295,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Contains("no longer parked", result.Message);
}
/// <summary>Verifies that a failed discard surfaces the site's own error message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DiscardOutbound_OperationFailed_UsesTheSitesOwnErrorMessage()
{
@@ -307,6 +335,8 @@ public sealed class FlowDebugServiceTests : IDisposable
return JsonSerializer.Serialize(result, ServerJson);
}
/// <summary>Verifies that the logs-truncated flag is carried onto both the outcome and the graph.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_LogsTruncated_RidesOntoTheOutcomeAndTheGraph()
{
@@ -322,6 +352,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.True(outcome.Raw!.LogsTruncated);
}
/// <summary>Verifies that a run without log truncation leaves the flag false.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_NoLogTruncation_LeavesTheFlagFalse()
{
@@ -333,6 +365,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.False(outcome.Graph!.LogsTruncated);
}
/// <summary>Verifies that running a method with no management actor holder reports the service as unavailable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_HolderNull_ReportsServiceUnavailable()
{
@@ -346,6 +380,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(_received);
}
/// <summary>Verifies that a management error is mapped with its code and message unchanged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_ManagementError_MapsCodeAndMessageVerbatim()
{
@@ -358,6 +394,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal("This node is standby.", outcome.Error);
}
/// <summary>Verifies that an unauthorized management reply maps to the unauthorized code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_Unauthorized_MapsToUnauthorizedCode()
{
@@ -369,6 +407,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal("Deployer role required.", outcome.Error);
}
/// <summary>Verifies that a successful run builds the graph from the returned steps and sends the typed command for the calling principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunMethod_Success_BuildsGraphWithTheReturnedSteps_AndSendsTheTypedCommandForThePrincipal()
{
@@ -393,6 +433,9 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Contains("Deployer", envelope.User.Roles);
}
/// <summary>Verifies that the run ask timeout is the method timeout plus the run grace and client slack, floored at the minimum.</summary>
/// <param name="timeoutSeconds">The method timeout in seconds.</param>
/// <param name="expectedSeconds">The expected ask timeout in seconds.</param>
[Theory]
[InlineData(180, 205)]
[InlineData(1, 30)]
@@ -403,6 +446,7 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), timeout);
}
/// <summary>Verifies that the value-change ask timeout is the wait seconds plus the client slack.</summary>
[Fact]
public void ValueChangeAskTimeoutFor_IsWaitSecondsPlusClientSlack()
{
@@ -411,6 +455,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal(TimeSpan.FromSeconds(60), timeout);
}
/// <summary>Verifies that running a write-kind script and replaying a run each send their commands.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunWriteKindScript_AndReplay_SendTheirCommands()
{
@@ -433,6 +479,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Equal(exec, rpCmd.ExecutionId);
}
/// <summary>Verifies that a truncated run result flags the graph.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Run_TruncatedResult_FlagsTheGraph()
{
@@ -444,6 +492,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.True(outcome.Graph!.Truncated);
}
/// <summary>Verifies that predicting a method resolves constant route targets through the instance template chain.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PredictMethod_ResolvesConstantRouteTargets_ThroughInstanceTemplateChain()
{
@@ -522,6 +572,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Contains(flow.Nodes, n => n.Kind == FlowStepKind.AttributeWrite && n.Title == "Sim01.Cmd");
}
/// <summary>Verifies that nested route targets are resolved across successive prediction passes.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PredictMethod_NestedRouteTargets_ResolveAcrossPasses()
{
@@ -560,10 +612,16 @@ public sealed class FlowDebugServiceTests : IDisposable
{
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
/// <inheritdoc />
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
/// <inheritdoc />
public override void Send(SendOrPostCallback d, object? state) => throw new NotSupportedException();
/// <summary>Runs the given asynchronous body on this pump, draining posted callbacks until it completes.</summary>
/// <typeparam name="T">The result type of the body.</typeparam>
/// <param name="body">The asynchronous body to run.</param>
/// <returns>The result of the body.</returns>
public T Run<T>(Func<Task<T>> body)
{
var previous = Current;
@@ -586,6 +644,8 @@ public sealed class FlowDebugServiceTests : IDisposable
}
}
/// <summary>Verifies that predicting an unknown method yields an empty result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PredictMethod_UnknownMethod_IsEmpty()
{
@@ -597,6 +657,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Empty(flow.Nodes);
}
/// <summary>Verifies that instance scripts are projected from the flattened configuration with their trigger attribute and kind, ordered by name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceScripts_ProjectsFlattenedScripts_WithTriggerAttributeAndKind_OrderedByName()
{
@@ -615,12 +677,16 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.False(options.Single(o => o.CanonicalName == "Mod.Tick").IsWriteKind);
}
/// <summary>Verifies that requesting scripts for an unknown instance yields an empty result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceScripts_UnknownInstance_IsEmpty()
{
Assert.Empty(await CreateService(reply: null).GetInstanceScriptsAsync(404));
}
/// <summary>Verifies that predicting a write-kind script roots at the write and appends the script.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PredictScript_WriteKind_RootsAtTheWrite_AndAppendsTheScript()
{
@@ -637,6 +703,8 @@ public sealed class FlowDebugServiceTests : IDisposable
], flow.Nodes.Select(n => (n.Kind, n.Title)).ToList());
}
/// <summary>Verifies that predicting a call-kind script roots at a route call followed by the script.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PredictScript_CallKind_RootsAtARouteCall_ThenTheScript()
{
@@ -652,6 +720,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.True(flow.Edges[0].CrossesBoundary);
}
/// <summary>Verifies that predicting an unknown script yields an empty result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PredictScript_UnknownScript_IsEmpty()
{
@@ -659,6 +729,8 @@ public sealed class FlowDebugServiceTests : IDisposable
Assert.Same(PredictedFlow.Empty, await CreateService(reply: null).PredictScriptAsync(9, "Nope"));
}
/// <summary>Verifies that running a script sends the command carrying the script shape.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunScript_SendsTheCommand_WithTheScriptShape()
{
@@ -683,6 +755,7 @@ public sealed class FlowDebugServiceTests : IDisposable
/// entry <see cref="IFlowDebugService.GetInstanceScriptsAsync"/> just refreshed (design
/// 2026-09-08 §5). A regression here means every keystroke re-walks the template chain.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceScripts_ThenPredict_ThenRun_FlattenOnce()
{
@@ -702,6 +775,7 @@ public sealed class FlowDebugServiceTests : IDisposable
/// re-flatten — otherwise a template edit or a redeploy mid-session leaves the picker, the
/// parameter form and the Ask sizing stale for the rest of the session.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetInstanceScripts_CalledAgain_ReflattensAndRefreshes()
{
@@ -729,12 +803,18 @@ public sealed class FlowDebugServiceTests : IDisposable
private sealed class StubAuth : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
/// <summary>Initializes the provider with the principal to report.</summary>
/// <param name="user">The principal reported as the authenticated user.</param>
public StubAuth(ClaimsPrincipal user) => _user = user;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user));
}
private sealed class ScriptedManagementActor : ReceiveActor
{
/// <summary>Initializes the actor with the reply to send and the list that records received envelopes.</summary>
/// <param name="reply">The reply sent back for every received envelope.</param>
/// <param name="received">The list that collects each received envelope.</param>
public ScriptedManagementActor(object reply, List<ManagementEnvelope> received)
{
Receive<ManagementEnvelope>(env =>
@@ -748,6 +828,8 @@ public sealed class FlowDebugServiceTests : IDisposable
/// <summary>Stands in for the Site Call Audit singleton proxy: records the relay request, replies as scripted.</summary>
private sealed class ScriptedSiteCallAuditActor : ReceiveActor
{
/// <summary>Initializes the actor to record and answer site call audit requests on the owning test.</summary>
/// <param name="test">The owning test whose recorded requests and scripted outcome the actor uses.</param>
public ScriptedSiteCallAuditActor(FlowDebugServiceTests test)
{
Receive<RetrySiteCallRequest>(r =>
@@ -768,6 +850,8 @@ public sealed class FlowDebugServiceTests : IDisposable
/// <summary>Stands in for the Notification Outbox singleton proxy: records the request, replies success.</summary>
private sealed class ScriptedOutboxActor : ReceiveActor
{
/// <summary>Initializes the actor to record and answer notification outbox requests on the owning test.</summary>
/// <param name="test">The owning test whose recorded requests the actor appends to.</param>
public ScriptedOutboxActor(FlowDebugServiceTests test)
{
Receive<RetryNotificationRequest>(r =>
@@ -9,7 +9,7 @@ using ZB.MOM.WW.ScadaBridge.KpiHistory;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Service-level tests for <see cref="KpiHistoryQueryService"/> (M6 K11). The
/// Service-level tests for <see cref="KpiHistoryQueryService"/>. The
/// service fetches a raw series via <see cref="IKpiHistoryRepository.GetRawSeriesAsync"/>
/// and reduces it with <see cref="KpiSeriesBucketer"/>; these tests pin the
/// argument-forwarding contract, the bucketer pass-through, and the
@@ -30,6 +30,8 @@ public class KpiHistoryQueryServiceTests
private static IReadOnlyList<KpiSeriesPoint> Series(params (int minute, double value)[] pts) =>
pts.Select(p => new KpiSeriesPoint(From.AddMinutes(p.minute), p.value)).ToList();
/// <summary>Verifies that all arguments are forwarded to the repository.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_ForwardsAllArgs_ToRepository()
{
@@ -50,6 +52,8 @@ public class KpiHistoryQueryServiceTests
From, To, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a null scope key is forwarded to the repository for a global scope.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_ForwardsNullScopeKey_ForGlobalScope()
{
@@ -70,6 +74,8 @@ public class KpiHistoryQueryServiceTests
From, To, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the raw series is returned unchanged when its point count is within the maximum.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_ReturnsRawUnchanged_WhenCountWithinMax()
{
@@ -89,6 +95,8 @@ public class KpiHistoryQueryServiceTests
Assert.Same(raw, result);
}
/// <summary>Verifies that a null maximum point count falls back to the default from the options.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_NullMaxPoints_UsesDefaultFromOptions()
{
@@ -110,6 +118,8 @@ public class KpiHistoryQueryServiceTests
Assert.True(result.Count <= 2);
}
/// <summary>Verifies that an explicit maximum point count overrides the default.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_ExplicitMaxPoints_OverridesDefault()
{
@@ -132,6 +142,8 @@ public class KpiHistoryQueryServiceTests
Assert.Same(raw, result);
}
/// <summary>Verifies that an empty series yields an empty result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_EmptySeries_ReturnsEmpty()
{
@@ -148,6 +160,8 @@ public class KpiHistoryQueryServiceTests
Assert.Empty(result);
}
/// <summary>Verifies that the production constructor opens a fresh service scope for each call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_OpensFreshScopePerCall_OnProductionCtor()
{
@@ -181,7 +195,7 @@ public class KpiHistoryQueryServiceTests
Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
}
// ---- Task 5: raw-vs-rollup range routing by RollupThresholdHours ----------------
// ---- raw-vs-rollup range routing by RollupThresholdHours ----------------
private static IKpiHistoryRepository RepoReturningEmptyForBoth()
{
@@ -197,6 +211,8 @@ public class KpiHistoryQueryServiceTests
return repo;
}
/// <summary>Verifies that a 24-hour window uses the raw query path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_24hWindow_UsesRawPath()
{
@@ -215,6 +231,8 @@ public class KpiHistoryQueryServiceTests
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a window exactly at the threshold uses the raw query path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_WindowExactlyAtThreshold_UsesRawPath()
{
@@ -234,6 +252,8 @@ public class KpiHistoryQueryServiceTests
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a window just over the threshold uses the rollup query path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_WindowJustOverThreshold_UsesRollupPath()
{
@@ -253,6 +273,8 @@ public class KpiHistoryQueryServiceTests
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a 90-day window uses the rollup query path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_90dWindow_UsesRollupPath()
{
@@ -271,6 +293,8 @@ public class KpiHistoryQueryServiceTests
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the rollup path still buckets the series when it exceeds the maximum point count.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_RollupPath_StillBucketsWhenOverMax()
{
@@ -303,6 +327,8 @@ public class KpiHistoryQueryServiceTests
Assert.True(result.Count <= 3);
}
/// <summary>Verifies that a long window uses the rollup path when built through the production constructor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_LongWindow_UsesRollupPath_OnProductionCtor()
{
@@ -331,8 +357,10 @@ public class KpiHistoryQueryServiceTests
Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
}
// ---- Task 6: catalog-driven aggregation at the bucketer boundary (R3) -----------
// ---- catalog-driven aggregation at the bucketer boundary (R3) -----------
/// <summary>Verifies that a rate metric is summed per bucket on the rollup path.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_RateMetric_SumsPerBucket_OnRollupPath()
{
@@ -361,6 +389,8 @@ public class KpiHistoryQueryServiceTests
Assert.All(result, p => Assert.Equal(2.0, p.Value));
}
/// <summary>Verifies that a rate metric preserves its total across the raw-to-rollup routing boundary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_RateMetric_PreservesTotal_AcrossRoutingBoundary()
{
@@ -399,6 +429,8 @@ public class KpiHistoryQueryServiceTests
Assert.Equal(rawResult.Sum(p => p.Value), rollupResult.Sum(p => p.Value));
}
/// <summary>Verifies that a gauge metric keeps the last value in each bucket.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetSeriesAsync_GaugeMetric_KeepsLastValuePerBucket()
{
@@ -22,6 +22,7 @@ public class PendingUpdatesCacheTests : IDisposable
private readonly IPendingUpdatesEvaluator _evaluator = Substitute.For<IPendingUpdatesEvaluator>();
private readonly ServiceProvider _provider;
/// <summary>Initializes a service provider that resolves the substitute pending-updates evaluator.</summary>
public PendingUpdatesCacheTests()
{
var services = new ServiceCollection();
@@ -40,6 +41,8 @@ public class PendingUpdatesCacheTests : IDisposable
private static PendingUpdatesSnapshot Snapshot() =>
new(T0, Array.Empty<SitePendingUpdates>());
/// <summary>Verifies that concurrent callers share a single evaluation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConcurrentCallers_ShareOneEvaluation()
{
@@ -56,6 +59,8 @@ public class PendingUpdatesCacheTests : IDisposable
Assert.All(results, r => Assert.Same(results[0], r));
}
/// <summary>Verifies that a call within the TTL serves the memoized value without re-evaluating.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WithinTtl_ServesTheMemoizedValue_WithoutReEvaluating()
{
@@ -69,6 +74,8 @@ public class PendingUpdatesCacheTests : IDisposable
Assert.Same(first, second);
}
/// <summary>Verifies that a forced refresh bypasses a still-fresh cached value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ForceRefresh_BypassesAFreshValue()
{
@@ -85,6 +92,8 @@ public class PendingUpdatesCacheTests : IDisposable
Assert.Same(snapshotB, forced);
}
/// <summary>Verifies that a forced refresh still joins an evaluation that is already in flight.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ForceRefresh_StillJoinsAnInFlightRound()
{
@@ -106,6 +115,8 @@ public class PendingUpdatesCacheTests : IDisposable
await _evaluator.Received(1).EvaluateAsync(Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a failed evaluation surfaces to the caller and is not memoized.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FailedEvaluation_SurfacesToTheCaller_AndIsNotMemoized()
{
@@ -125,5 +136,6 @@ public class PendingUpdatesCacheTests : IDisposable
await _evaluator.Received(2).EvaluateAsync(Arg.Any<CancellationToken>());
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose() => _provider.Dispose();
}
@@ -12,7 +12,7 @@ using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Unit tests for <see cref="SharedAlarmSummaryService"/> (arch-review WP2.4). The Alarm
/// Unit tests for <see cref="SharedAlarmSummaryService"/>. The Alarm
/// Summary page polls per circuit; the shared façade must collapse those into ONE per-site
/// fan-out per freshness window, and must widen that window while the live alarm cache is
/// serving the site (where the poll only still supplies the not-reporting list).
@@ -32,6 +32,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable
private DateTimeOffset _now = T0;
/// <summary>Initializes a new instance of the <see cref="SharedAlarmSummaryServiceTests"/> class.</summary>
public SharedAlarmSummaryServiceTests()
{
_siteRepo.GetSiteByIdAsync(SiteId, Arg.Any<CancellationToken>())
@@ -59,6 +60,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable
private SharedAlarmSummaryService CreateSut() =>
new(_provider.GetRequiredService<IServiceScopeFactory>(), _liveCache, () => _now);
/// <summary>Verifies that concurrent circuits share a single fan-out.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConcurrentCircuits_ShareOneFanOut()
{
@@ -70,6 +73,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable
Assert.All(results, r => Assert.Single(r.Alarms));
}
/// <summary>Verifies that a cold live cache refreshes within the page tick.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ColdLiveCache_RefreshesWithinThePageTick()
{
@@ -85,12 +90,14 @@ public class SharedAlarmSummaryServiceTests : IDisposable
await _instanceRepo.Received(2).GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that serving from the live cache skips the fan-out entirely.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LiveCacheServing_SkipsTheFanOutEntirely()
{
// The aggregator's seed/reconcile already ran this exact fan-out and publishes both
// halves of the answer, so the façade must not run a second one — not now, not after
// any elapsed window (arch-review phase-2 residual #4).
// any elapsed window.
var sut = CreateSut();
_liveCache.Live = true;
_liveCache.NotReporting = new[] { "inst-silent" };
@@ -116,6 +123,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable
}
}
/// <summary>Verifies that when the live cache goes cold the service falls back to the fan-out.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LiveCacheGoingCold_FallsBackToTheFanOut()
{
@@ -133,6 +142,8 @@ public class SharedAlarmSummaryServiceTests : IDisposable
Assert.Single(result.Alarms);
}
/// <summary>Verifies that different sites do not share a memo slot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DifferentSites_DoNotShareAMemoSlot()
{
@@ -151,6 +162,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable
await _instanceRepo.Received(1).GetInstancesBySiteIdAsync(otherSite, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the pure methods return the same results as the direct implementation.</summary>
[Fact]
public void PureMethods_MatchTheDirectImplementation()
{
@@ -176,6 +188,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable
Assert.Equal(expectedRollup.CountsByKind, sharedRollup.CountsByKind);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose() => _provider.Dispose();
/// <summary>
@@ -184,20 +197,28 @@ public class SharedAlarmSummaryServiceTests : IDisposable
/// </summary>
private sealed class FakeLiveCache : ISiteAlarmLiveCache
{
/// <summary>Gets or sets whether the fake cache reports the site as live.</summary>
public bool Live { get; set; }
/// <summary>Gets or sets the alarms returned as the site's current alarms.</summary>
public IReadOnlyList<AlarmStateChanged> Current { get; set; } = Array.Empty<AlarmStateChanged>();
/// <summary>Gets or sets the instances returned as not reporting.</summary>
public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>();
/// <inheritdoc />
public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp();
/// <inheritdoc />
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => Current;
/// <inheritdoc />
public bool IsLive(int siteId) => Live;
/// <inheritdoc />
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting;
private sealed class NoOp : IDisposable
{
/// <summary>Does nothing; the no-op subscription holds no resources.</summary>
public void Dispose() { }
}
}
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
/// <summary>
/// Unit tests for <see cref="SingleFlightMemo{T}"/> — the primitive behind the process-level
/// KPI snapshot cache and the shared Alarm Summary fan-out (arch-review WP2.4). The two
/// KPI snapshot cache and the shared Alarm Summary fan-out. The two
/// properties the whole optimisation rests on are tested here: <b>single-flight</b> (N
/// concurrent circuits asking the same question produce ONE query) and <b>TTL</b> (a query
/// answered inside the freshness window is served from memory, and one outside it is not).
@@ -13,6 +13,8 @@ public class SingleFlightMemoTests
{
private static readonly DateTimeOffset T0 = new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero);
/// <summary>Verifies that concurrent callers share a single in-flight query.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConcurrentCallers_ShareOneFlight()
{
@@ -37,6 +39,8 @@ public class SingleFlightMemoTests
Assert.All(results, r => Assert.Equal(42, r));
}
/// <summary>Verifies that a call within the TTL serves the memoized value without querying again.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WithinTtl_ServesTheMemoizedValue_WithoutRequerying()
{
@@ -54,6 +58,8 @@ public class SingleFlightMemoTests
Assert.Equal(1, memo.FlightCount);
}
/// <summary>Verifies that a call after the TTL expires queries exactly once more.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AfterTtl_RequeriesOnce()
{
@@ -70,6 +76,8 @@ public class SingleFlightMemoTests
Assert.Equal(2, memo.FlightCount);
}
/// <summary>Verifies that a forced refresh bypasses a value that is still fresh.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ForceRefresh_BypassesAFreshValue()
{
@@ -84,6 +92,8 @@ public class SingleFlightMemoTests
Assert.Equal(2, memo.FlightCount);
}
/// <summary>Verifies that a forced refresh still joins a query that is already in flight.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ForceRefresh_StillJoinsAnInFlightRound()
{
@@ -107,6 +117,8 @@ public class SingleFlightMemoTests
Assert.Equal(1, invocations);
}
/// <summary>Verifies that a failure is not memoized and the next caller retries.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failure_IsNotMemoized_AndTheNextCallerRetries()
{
@@ -129,6 +141,8 @@ public class SingleFlightMemoTests
Assert.Equal(2, memo.FlightCount);
}
/// <summary>Verifies that a TTL override applies to the flight it was supplied for.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TtlOverride_AppliesToThatFlight()
{
@@ -146,6 +160,8 @@ public class SingleFlightMemoTests
Assert.Equal(1, memo.FlightCount);
}
/// <summary>Verifies that cancelling one caller does not cancel the shared flight.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CallerCancellation_DoesNotCancelTheSharedFlight()
{
@@ -7,6 +7,7 @@ public class TemplatePathFormatterTests
{
private static Dictionary<int, TemplateFolder> Folders(params TemplateFolder[] f) => f.ToDictionary(x => x.Id);
/// <summary>Verifies that a root template is formatted as its name only.</summary>
[Fact]
public void RootTemplate_IsNameOnly()
{
@@ -14,6 +15,7 @@ public class TemplatePathFormatterTests
Assert.Equal("Motor Controller", TemplatePathFormatter.Format(t, Folders()));
}
/// <summary>Verifies that a nested template is formatted with its folder chain joined by slashes.</summary>
[Fact]
public void NestedTemplate_JoinsFolderChainWithSlashes()
{
@@ -24,6 +26,7 @@ public class TemplatePathFormatterTests
TemplatePathFormatter.Format(t, Folders(controllers, conveyor)));
}
/// <summary>Verifies that an unknown folder id falls back to the name only without throwing.</summary>
[Fact]
public void UnknownFolderId_FallsBackToNameOnly_WithoutThrowing()
{
@@ -31,6 +34,7 @@ public class TemplatePathFormatterTests
Assert.Equal("Orphan", TemplatePathFormatter.Format(t, Folders()));
}
/// <summary>Verifies that a cycle in the folder chain terminates instead of looping forever.</summary>
[Fact]
public void FolderCycle_Terminates()
{