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
@@ -14,6 +14,7 @@ public class DebugTreeBuilderTests
private static AttributeValueChanged Attr(string name, object? value = null, string quality = "Good")
=> new("Inst", name, name, value, quality, DateTimeOffset.UtcNow);
/// <summary>Verifies that a root-level attribute becomes a single leaf.</summary>
[Fact]
public void RootLevelAttribute_BecomesSingleLeaf()
{
@@ -27,6 +28,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Speed", node.Attribute!.AttributeName);
}
/// <summary>Verifies two sibling leaves share single branch sorted by segment.</summary>
[Fact]
public void TwoSiblingLeaves_ShareSingleBranch_SortedBySegment()
{
@@ -47,6 +49,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Temp", branch.Children[1].Segment);
}
/// <summary>Verifies that a deep path nests branches by accumulated prefix.</summary>
[Fact]
public void DeepPath_NestsBranchesByAccumulatedPrefix()
{
@@ -70,6 +73,7 @@ public class DebugTreeBuilderTests
Assert.NotNull(pump.Attribute);
}
/// <summary>Verifies that branches are deduplicated across deep paths sharing a prefix.</summary>
[Fact]
public void BranchDedupe_SharedPrefixAcrossDeepPaths()
{
@@ -95,6 +99,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.Compressor.Valve", compressor.Children[1].Key);
}
/// <summary>Verifies that a bad-quality descendant marks all ancestor branches as bad.</summary>
[Fact]
public void RollUp_BadQualityDescendant_MarksAllAncestorBranches()
{
@@ -113,6 +118,7 @@ public class DebugTreeBuilderTests
Assert.True(compressor.HasBadQuality);
}
/// <summary>Verifies that a branch whose leaves are all good is not marked as bad quality.</summary>
[Fact]
public void RollUp_AllGood_LeavesBranchHasBadQualityFalse()
{
@@ -123,6 +129,8 @@ public class DebugTreeBuilderTests
Assert.False(motor.HasBadQuality);
}
/// <summary>Verifies that any non-good quality counts as bad when rolling up.</summary>
/// <param name="quality">The test input.</param>
[Theory]
[InlineData("Uncertain")]
[InlineData("Bad")]
@@ -134,6 +142,7 @@ public class DebugTreeBuilderTests
Assert.True(Assert.Single(tree).HasBadQuality);
}
/// <summary>Verifies that filtering keeps only the matching leaves and their ancestors.</summary>
[Fact]
public void Filter_KeepsOnlyMatchingLeaves_AndTheirAncestors()
{
@@ -153,6 +162,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.Temp", leaf.Key);
}
/// <summary>Verifies that filtering is case insensitive.</summary>
[Fact]
public void Filter_IsCaseInsensitive()
{
@@ -163,6 +173,8 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.Temp", Assert.Single(motor.Children).Key);
}
/// <summary>Verifies that an empty or whitespace filter returns the full forest.</summary>
/// <param name="filter">The test input.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -177,6 +189,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor2", tree[1].Key);
}
/// <summary>Verifies that a filter with no matches returns an empty forest.</summary>
[Fact]
public void Filter_NoMatches_ReturnsEmptyForest()
{
@@ -186,6 +199,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that the top-level forest is sorted by segment.</summary>
[Fact]
public void TopLevelForest_SortedBySegment()
{
@@ -198,6 +212,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Zeta", tree[2].Key);
}
/// <summary>Verifies that empty input returns an empty forest.</summary>
[Fact]
public void EmptyInput_ReturnsEmptyForest()
{
@@ -205,6 +220,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that bad quality four levels deep rolls up to the root.</summary>
[Fact]
public void RollUp_FourLevelDeepBadQuality_ReachesRoot()
{
@@ -221,6 +237,7 @@ public class DebugTreeBuilderTests
Assert.True(d.HasBadQuality);
}
/// <summary>Verifies that a filter matching a deep leaf retains all its ancestor branches.</summary>
[Fact]
public void Filter_DeepLeafMatch_RetainsAllAncestorBranches()
{
@@ -235,7 +252,7 @@ public class DebugTreeBuilderTests
}
// ---------------------------------------------------------------------
// DV-4: BuildAlarmTree — computed leaves, native condition groupings,
// BuildAlarmTree — computed leaves, native condition groupings,
// configured-placeholder binding nodes, roll-up, filter.
// ---------------------------------------------------------------------
@@ -261,6 +278,7 @@ public class DebugTreeBuilderTests
IsConfiguredPlaceholder = placeholder,
};
/// <summary>Verifies that a computed alarm with a path-qualified name becomes a leaf under a branch.</summary>
[Fact]
public void Computed_PathQualifiedName_BecomesLeafUnderBranch()
{
@@ -282,6 +300,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.HighTemp", leaf.Alarm!.AlarmName);
}
/// <summary>Verifies that a computed alarm with a root-level name becomes a single leaf.</summary>
[Fact]
public void Computed_RootLevelName_BecomesSingleLeaf()
{
@@ -294,6 +313,7 @@ public class DebugTreeBuilderTests
Assert.NotNull(node.Alarm);
}
/// <summary>Verifies that a native condition nests under its binding node with the source reference segment.</summary>
[Fact]
public void Native_Condition_NestsUnderBindingNode_WithSourceReferenceSegment()
{
@@ -318,6 +338,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Level.HiHi", condition.Alarm!.SourceReference);
}
/// <summary>Verifies that two native conditions share the same binding node.</summary>
[Fact]
public void Native_TwoConditions_ShareSameBindingNode()
{
@@ -340,6 +361,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.LoLo", binding.Children[1].Key);
}
/// <summary>Verifies that a native placeholder yields a binding node without children.</summary>
[Fact]
public void Native_Placeholder_YieldsChildlessBindingNode()
{
@@ -354,10 +376,11 @@ public class DebugTreeBuilderTests
Assert.Empty(binding.Children);
}
/// <summary>Verifies that real conditions win over a placeholder under the same native binding.</summary>
[Fact]
public void Native_PlaceholderAndRealConditions_RealConditionsWin()
{
// DV-2 shouldn't emit both, but be safe: placeholder adds nothing.
// Shouldn't emit both, but be safe: placeholder adds nothing.
var tree = DebugTreeBuilder.BuildAlarmTree(
new[]
{
@@ -373,6 +396,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key);
}
/// <summary>Verifies that an active descendant sets the worst state to active and is counted when rolling up.</summary>
[Fact]
public void RollUp_ActiveDescendant_SetsWorstStateActiveAndCounts()
{
@@ -394,6 +418,7 @@ public class DebugTreeBuilderTests
Assert.Equal(1, binding.ActiveCount);
}
/// <summary>Verifies that when every alarm is normal the worst state is normal with a zero active count.</summary>
[Fact]
public void RollUp_AllNormal_WorstStateNormalAndZeroCount()
{
@@ -405,6 +430,7 @@ public class DebugTreeBuilderTests
Assert.Equal(0, tank.ActiveCount);
}
/// <summary>Verifies that a placeholder never counts as active when rolling up.</summary>
[Fact]
public void RollUp_Placeholder_NeverCountsAsActive()
{
@@ -423,6 +449,7 @@ public class DebugTreeBuilderTests
Assert.Equal(0, binding.ActiveCount);
}
/// <summary>Verifies that filtering on the source reference keeps the condition under its binding and ancestors.</summary>
[Fact]
public void Filter_OnSourceReference_KeepsConditionUnderBindingAndAncestors()
{
@@ -443,6 +470,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", condition.Key);
}
/// <summary>Verifies that filtering on a binding canonical name keeps the binding node.</summary>
[Fact]
public void Filter_OnBindingCanonicalName_KeepsBindingNode()
{
@@ -463,6 +491,7 @@ public class DebugTreeBuilderTests
Assert.Single(binding.Children);
}
/// <summary>Verifies that filtering on a binding name that matches a placeholder keeps the binding node.</summary>
[Fact]
public void Filter_OnBindingName_MatchingPlaceholder_KeepsBindingNode()
{
@@ -477,6 +506,7 @@ public class DebugTreeBuilderTests
Assert.False(binding.HasChildren);
}
/// <summary>Verifies that filtering on a computed alarm name is case insensitive.</summary>
[Fact]
public void Filter_OnComputedAlarmName_IsCaseInsensitive()
{
@@ -488,6 +518,8 @@ public class DebugTreeBuilderTests
Assert.Equal("Motor1.HighTemp", Assert.Single(motor.Children).Key);
}
/// <summary>Verifies that an empty or whitespace filter returns the full alarm forest.</summary>
/// <param name="filter">The test input.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -503,6 +535,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1", tree[1].Key);
}
/// <summary>Verifies that a filter with no matches returns an empty alarm forest.</summary>
[Fact]
public void Filter_NoMatches_ReturnsEmptyAlarmForest()
{
@@ -512,6 +545,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that mixed computed and native alarms produce both subtrees.</summary>
[Fact]
public void MixedComputedAndNative_ProduceBothSubtrees()
{
@@ -536,6 +570,7 @@ public class DebugTreeBuilderTests
Assert.Equal("Tank1.Levels::Tank1.Level.HiHi", Assert.Single(binding.Children).Key);
}
/// <summary>Verifies that empty alarm input returns an empty forest.</summary>
[Fact]
public void EmptyAlarmInput_ReturnsEmptyForest()
{
@@ -543,6 +578,7 @@ public class DebugTreeBuilderTests
Assert.Empty(tree);
}
/// <summary>Verifies that computed and native alarms sharing a prefix keep distinct binding and leaf nodes.</summary>
[Fact]
public void Native_ComputedAndNativeSharePrefix_DistinctBindingAndLeaf()
{
@@ -20,7 +20,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// DV-5: the Debug View renders a tabbed (Attributes / Alarms) layout, each tab
/// The Debug View renders a tabbed (Attributes / Alarms) layout, each tab
/// hosting a composition <c>TreeView</c>. Computed alarms and attributes nest as
/// leaves under their composition-member branches; native conditions nest under
/// their source-binding node. Branch nodes surface a roll-up (active-count /
@@ -62,7 +62,10 @@ public class DebugViewAlarmTableTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the fixed authentication state to return.</summary>
/// <param name="state">The authentication state returned to every caller.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state);
}
@@ -98,6 +101,7 @@ public class DebugViewAlarmTableTests : BunitContext
cut.Render();
}
/// <summary>Verifies that both tabs are present, the Attributes tab is the default, and it shows the attribute tree.</summary>
[Fact]
public void BothTabsPresent_AttributesTabIsDefault_AndShowsAttributeTree()
{
@@ -132,6 +136,7 @@ public class DebugViewAlarmTableTests : BunitContext
Assert.NotNull(motor1.QuerySelector("ul[role='group'] li[role='treeitem']"));
}
/// <summary>Verifies that the Alarms tab groups a computed alarm under its branch with an active roll-up badge.</summary>
[Fact]
public void AlarmsTab_GroupsComputedAlarmUnderBranch_WithActiveRollUpBadge()
{
@@ -168,6 +173,7 @@ public class DebugViewAlarmTableTests : BunitContext
Assert.NotNull(highTemp.QuerySelector(".badge.bg-danger")); // Active state badge
}
/// <summary>Verifies that the Alarms tab renders a native condition with enriched badges under its binding node.</summary>
[Fact]
public void AlarmsTab_RendersNativeCondition_WithEnrichedBadgesUnderBindingNode()
{
@@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for arch-review 2026-08-19 P6-6. <c>Connect()</c> awaits
/// Regression tests for a dispose race in the debug view. <c>Connect()</c> awaits
/// <c>StartStreamAsync</c> — which can take up to 30s — and then publishes the result into
/// <c>_session</c>. If the circuit was torn down inside that window, <c>Dispose()</c> had
/// already run, found <c>_session</c> still null and stopped nothing, so the just-created
@@ -40,12 +40,17 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<string> _stopped = new();
/// <summary>Creates a debug stream service whose stream start is held until released.</summary>
/// <param name="comms">The communication service.</param>
/// <param name="provider">The service provider.</param>
/// <param name="factory">The gRPC client factory.</param>
public GatedDebugStreamService(
CommunicationService comms, IServiceProvider provider, SiteStreamGrpcClientFactory factory)
: base(comms, provider, factory, NullLogger<DebugStreamService>.Instance)
{
}
/// <summary>The id of the session this service hands out.</summary>
public string SessionId => "session-under-test";
/// <summary>Completes once <see cref="StartStreamAsync"/> has been entered.</summary>
@@ -57,8 +62,10 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
get { lock (_stopped) { return _stopped.ToList(); } }
}
/// <summary>Releases the held stream start so it can complete.</summary>
public void ReleaseStart() => _gate.TrySetResult();
/// <inheritdoc />
public override async Task<DebugStreamSession> StartStreamAsync(
int instanceId, Action<object> onEvent, Action onTerminated, CancellationToken ct = default)
{
@@ -73,6 +80,7 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
DateTimeOffset.UtcNow));
}
/// <inheritdoc />
public override void StopStream(string sessionId)
{
lock (_stopped) { _stopped.Add(sessionId); }
@@ -82,7 +90,10 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Creates a provider that always returns the given authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -141,6 +152,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
return task!;
}
/// <summary>Verifies that disposing during a pending connect stops the just-created session.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_DisposedMidStart_StopsTheJustCreatedSession()
{
@@ -162,6 +175,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
Assert.Null(GetField(cut.Instance, "_session"));
}
/// <summary>Verifies that connecting and then disposing stops the session exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Connect_ThenDispose_StopsTheSessionExactlyOnce()
{
@@ -186,6 +201,8 @@ public class DebugViewConnectDisposeRaceTests : BunitContext
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
}
/// <summary>Verifies that disconnecting after a connect stops the session once and clears the session slot.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Disconnect_AfterConnect_StopsTheSessionOnceAndClearsTheSlot()
{
@@ -16,7 +16,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-009. The <c>DebugView</c> stream callbacks
/// Regression tests for the debug view disposal guard. The <c>DebugView</c> stream callbacks
/// (<c>onEvent</c>/<c>onTerminated</c>) run on an Akka/gRPC thread and capture
/// <c>this</c> and <c>_toast</c>. If the user navigates away, an in-flight
/// callback could still call <c>_toast.ShowError(...)</c> /
@@ -69,11 +69,15 @@ public class DebugViewDisposalTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Creates a provider that always returns the given authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
/// <summary>Verifies that the debug view exposes a boolean disposal guard field.</summary>
[Fact]
public void DebugView_HasDisposalGuardField()
{
@@ -86,6 +90,7 @@ public class DebugViewDisposalTests : BunitContext
Assert.Equal(typeof(bool), field!.FieldType);
}
/// <summary>Verifies that disposing the debug view sets the disposal guard and that disposing twice does not throw.</summary>
[Fact]
public void DebugView_Dispose_SetsDisposedFlag_AndIsIdempotent()
{
@@ -81,7 +81,10 @@ public class DebugViewInstancePickerTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with a fixed authentication state.</summary>
/// <param name="state">The state.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -101,6 +104,7 @@ public class DebugViewInstancePickerTests : BunitContext
.First(r => r.TextContent.Trim() == text)
.Click();
/// <summary>Verifies that the instance selector is the picker component rather than a flat select.</summary>
[Fact]
public void InstanceSelector_IsThePicker_NotAFlatSelect()
{
@@ -111,6 +115,7 @@ public class DebugViewInstancePickerTests : BunitContext
Assert.Empty(cut.FindAll("select[data-test='debug-instance-select']"));
}
/// <summary>Verifies that opening the picker shows only the root area.</summary>
[Fact]
public void OpeningThePicker_ShowsOnlyTheRootArea()
{
@@ -122,6 +127,7 @@ public class DebugViewInstancePickerTests : BunitContext
Assert.False(rows[0].HasAttribute("data-instance-id"));
}
/// <summary>Verifies that drilling down reveals only the enabled instance.</summary>
[Fact]
public void DrillingDown_RevealsTheEnabledInstanceOnly()
{
@@ -139,6 +145,7 @@ public class DebugViewInstancePickerTests : BunitContext
r => r.TextContent.Contains("Off01"));
}
/// <summary>Verifies that picking an instance fills the field and enables the Connect button.</summary>
[Fact]
public void PickingAnInstance_FillsTheField_AndEnablesConnect()
{
@@ -211,6 +218,7 @@ public class DebugViewInstancePickerTests : BunitContext
JSInterop.VerifyInvoke("localStorage.removeItem");
}
/// <summary>Verifies that the picker is disabled while the view is connected.</summary>
[Fact]
public void WhileConnected_ThePickerIsDisabled()
{
@@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for the Debug View render coalescing (arch-review WP2.4). Every streamed
/// Regression tests for the Debug View render coalescing. Every streamed
/// event used to marshal onto the circuit dispatcher and call <c>StateHasChanged</c> on its
/// own, so a chatty instance drove one full render — and two full composition-tree rebuilds —
/// per value change. Events are now buffered and applied once per ~250 ms window: a burst
@@ -60,7 +60,10 @@ public class DebugViewRenderCoalescingTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state to return.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -78,6 +81,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
new AttributeValueChanged("Inst-1", name, name, value, "Good", DateTimeOffset.UtcNow),
});
/// <summary>Verifies that a burst of events triggers a single render while every event is still applied.</summary>
[Fact]
public void BurstOfEvents_RendersOnce_ButAppliesEveryEvent()
{
@@ -99,6 +103,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
Assert.InRange(renders, 1, 5);
}
/// <summary>Verifies that repeated updates to one tag collapse to the latest value.</summary>
[Fact]
public void RepeatedUpdatesToOneTag_CollapseToTheLatestValue()
{
@@ -115,6 +120,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
Assert.Equal(49, applied.Value);
}
/// <summary>Verifies that an event arriving after a flush still arms a new coalescing window.</summary>
[Fact]
public void EventArrivingAfterAFlush_StillArmsANewWindow()
{
@@ -130,6 +136,7 @@ public class DebugViewRenderCoalescingTests : BunitContext
cut.WaitForState(() => dict.Count == 2, TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that events arriving after disposal are dropped without throwing.</summary>
[Fact]
public void EventsAfterDispose_AreDroppedWithoutThrowing()
{
@@ -18,7 +18,7 @@ using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploymen
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-021. The <c>DebugView</c> stream callback runs
/// Regression tests for the DebugView stream race. The <c>DebugView</c> stream callback runs
/// on an Akka/gRPC thread; it used to call <c>UpsertWithCap</c> directly on that
/// thread, mutating the <c>_attributeValues</c>/<c>_alarmStates</c>
/// <see cref="Dictionary{TKey,TValue}"/> while the render thread enumerated the
@@ -67,7 +67,10 @@ public class DebugViewStreamRaceTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes a new instance of the <c>StubAuthStateProvider</c> class.</summary>
/// <param name="state">The authentication state the provider returns.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -83,6 +86,7 @@ public class DebugViewStreamRaceTests : BunitContext
typeof(DebugViewPage).GetProperty("FilteredAttributeValues",
BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(c)!;
/// <summary>Verifies that a stream event is applied once the dispatcher runs its queued work.</summary>
[Fact]
public void HandleStreamEvent_AppliesUpdate_OnceDispatcherRuns()
{
@@ -98,10 +102,12 @@ public class DebugViewStreamRaceTests : BunitContext
Assert.True(dict.Contains("Pump.Speed"));
}
/// <summary>Verifies that stream events raised from background threads do not fault concurrent dispatcher reads.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HandleStreamEvent_OffThreadEvents_DoNotFaultDispatcherReads()
{
// CentralUI-021 reproduction. Writers fire stream events from background
// Stream race reproduction. Writers fire stream events from background
// threads (the Akka/gRPC callback threads). The reader enumerates
// FilteredAttributeValues *through the renderer's dispatcher* — exactly
// as the real render thread does. Pre-fix the writers mutated the
@@ -15,7 +15,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for CentralUI-006. Component-CentralUI "Real-Time Updates"
/// Regression tests for deployment status push updates. Component-CentralUI "Real-Time Updates"
/// states deployment status transitions push to the UI immediately via SignalR
/// with no polling. The page previously ran a 10-second <c>Timer</c> that
/// reloaded every deployment record + instance map per tick. The fix removes
@@ -61,11 +61,15 @@ public class DeploymentsPushUpdateTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state it returns.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
/// <summary>Verifies that the Deployments page does not poll and has no refresh timer.</summary>
[Fact]
public void Deployments_DoesNotPoll_HasNoRefreshTimer()
{
@@ -76,6 +80,7 @@ public class DeploymentsPushUpdateTests : BunitContext
Assert.Null(timerField);
}
/// <summary>Verifies that a deployment status change notification triggers a reload of the page.</summary>
[Fact]
public void Deployments_StatusChange_TriggersReload()
{
@@ -95,6 +100,7 @@ public class DeploymentsPushUpdateTests : BunitContext
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
}
/// <summary>Verifies that disposing the page unsubscribes it from the status notifier.</summary>
[Fact]
public void Deployments_Dispose_UnsubscribesFromNotifier()
{
@@ -225,7 +231,7 @@ public class DeploymentsPushUpdateTests : BunitContext
}
/// <summary>
/// Regression test for CentralUI-022. The notifier is a process singleton:
/// Regression test for the dispose race. The notifier is a process singleton:
/// it can read its subscriber list and begin invoking
/// <c>OnDeploymentStatusChanged</c> on the DeploymentManager thread an
/// instant before the component is disposed. The handler must no-op against
@@ -242,6 +248,7 @@ public class DeploymentsPushUpdateTests : BunitContext
Assert.Equal(typeof(bool), field!.FieldType);
}
/// <summary>Verifies that a status change delivered after disposal neither throws nor reloads the page.</summary>
[Fact]
public void Deployments_StatusChangeAfterDispose_DoesNotThrowOrReload()
{
@@ -14,7 +14,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for the Deployment Status push coalescing (arch-review WP2.4). The
/// Regression tests for the Deployment Status push coalescing. The
/// notifier fires per status write, so a multi-instance deploy produced a stampede of
/// reloads per circuit. The reload is leading-edge debounced: the first push after an
/// idle gap is still immediate, and a burst behind it collapses into one trailing reload.
@@ -57,7 +57,10 @@ public class DeploymentsReloadDebounceTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes a new instance of the <see cref="StubAuthStateProvider"/> class with a fixed authentication state.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -67,6 +70,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
_deployRepo.Received().QueryDeploymentListPageAsync(
Arg.Any<DeploymentListFilter>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()));
/// <summary>Verifies that a burst of status writes collapses into far fewer page reloads.</summary>
[Fact]
public void BurstOfStatusWrites_CollapsesIntoFarFewerReloads()
{
@@ -93,6 +97,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
Assert.InRange(reloads, 1, 4);
}
/// <summary>Verifies that the first push after an idle period reloads the page immediately.</summary>
[Fact]
public void FirstPushAfterIdle_ReloadsImmediately()
{
@@ -112,6 +117,7 @@ public class DeploymentsReloadDebounceTests : BunitContext
TimeSpan.FromMilliseconds(400));
}
/// <summary>Verifies that disposing the page during a coalesce window does not trigger a reload.</summary>
[Fact]
public void DisposeDuringACoalesceWindow_DoesNotReload()
{
@@ -15,7 +15,7 @@ using DeploymentsPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deploym
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for arch-review 2026-08-19 P6-11. Three independent initiators —
/// Regression tests for stale request generations on the Deployment Status page. Three independent initiators —
/// search, paging, and the push/coalesce reload — can each have a
/// <c>LoadDataAsync</c> flight in flight on the Deployment Status page at once. Every
/// flight awaits its own round-trip to central MS SQL, so COMPLETION order is not
@@ -92,7 +92,10 @@ public class DeploymentsRequestGenerationTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state it will return.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -146,18 +149,20 @@ public class DeploymentsRequestGenerationTests : BunitContext
.GetField("_currentPage", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page)!;
/// <summary>Verifies that a stale page 2 flight arriving after a newer flight does not overwrite the grid.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleFlightArrivingAfterANewerOne_DoesNotOverwriteTheGrid()
{
RegisterServices();
var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList();
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // first call: initial render
var stale = Gate(); // call #2: page-2 flight — held open, the SLOW, STALE flight
var stale = Gate(); // second call: page 2 flight — held open, the SLOW, STALE flight
_pageResponses.Add(stale);
// call #3: status-filter flight — resolves immediately, the FAST, NEWER flight
// third call: status-filter flight — resolves immediately, the FAST, NEWER flight
_pageResponses.Add(CompletedGate(
new DeploymentListPage(new[] { Row(999, "TileFilteredFreshRow") }, TotalCount: 1)));
@@ -182,7 +187,7 @@ public class DeploymentsRequestGenerationTests : BunitContext
stale.SetResult(new DeploymentListPage(new[] { Row(2, "PageTwoStaleRow") }, TotalCount: 100));
await flightA.WaitAsync(TimeSpan.FromSeconds(5));
// Pre-fix, flight A's late arrival clobbers _rows with the stale page-2 data.
// Pre-fix, flight A's late arrival clobbers _rows with the stale page 2 data.
// Post-fix, its generation is no longer current, so it discards its own
// results and the newer grid survives untouched.
Assert.DoesNotContain(GetRows(cut.Instance), r => r.InstanceUniqueName == "PageTwoStaleRow");
@@ -190,7 +195,7 @@ public class DeploymentsRequestGenerationTests : BunitContext
}
/// <summary>
/// The "does the timer refresh reset the page mid-search" half of P6-11. The
/// The "does the timer refresh reset the page mid-search" half of that regression. The
/// filter-debounce ("timer") reload fires first, as if 500ms had elapsed after a
/// keystroke, and is held open. While it is still in flight the operator
/// navigates to page 2 by other means (a fresh, faster flight). The debounce
@@ -204,18 +209,19 @@ public class DeploymentsRequestGenerationTests : BunitContext
/// deterministically instead of racing bUnit's render pump.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaleFilterDebounceFlight_DoesNotResetThePageAfterANewerNavigation()
{
RegisterServices();
var initialRows = Enumerable.Range(1, 25).Select(i => Row(i, $"Init-{i}")).ToList();
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // call #1: initial render
_pageResponses.Add(CompletedGate(new DeploymentListPage(initialRows, TotalCount: 100))); // first call: initial render
var staleSearch = Gate(); // call #2: the debounce-triggered ("timer") flight — held open, STALE
var staleSearch = Gate(); // second call: the debounce-triggered ("timer") flight — held open, STALE
_pageResponses.Add(staleSearch);
// call #3: page-2 click — resolves immediately, NEWER.
// third call: page 2 click — resolves immediately, NEWER.
_pageResponses.Add(CompletedGate(
new DeploymentListPage(new[] { Row(2, "PageTwoFreshRow") }, TotalCount: 100)));
@@ -243,7 +249,7 @@ public class DeploymentsRequestGenerationTests : BunitContext
await staleFlight.WaitAsync(TimeSpan.FromSeconds(5));
// Pre-fix, the stale flight unconditionally wrote _currentPage = 1 up front
// and then overwrote the grid with page-1 data once it resolved — resetting
// and then overwrote the grid with page 1 data once it resolved — resetting
// the operator's page mid an already-completed navigation. Post-fix, its
// generation is no longer current, so both the page and the grid are left
// untouched.
@@ -79,7 +79,10 @@ public class DeploymentsServerPagingTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state it will return.</summary>
/// <param name="state">The authentication state returned to callers.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
@@ -100,6 +103,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Paging ────────────────────────────────────────────────────────────────
/// <summary>Verifies that the initial load requests page one from the server and never reads every instance.</summary>
[Fact]
public void InitialLoad_RequestsPageOneFromTheServer_AndNeverReadsEveryInstance()
{
@@ -115,6 +119,7 @@ public class DeploymentsServerPagingTests : BunitContext
_templateRepo.DidNotReceiveWithAnyArgs().GetAllInstancesAsync();
}
/// <summary>Verifies that the server-reported total count drives the pager rather than the rows on screen.</summary>
[Fact]
public void TotalCountFromServer_DrivesThePager_NotTheRowsOnScreen()
{
@@ -133,6 +138,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Contains("6", cut.Find("ul.pagination").TextContent);
}
/// <summary>Verifies that moving to the next page re-queries the server for page two.</summary>
[Fact]
public void NextPage_RequeriesTheServerForPageTwo()
{
@@ -148,6 +154,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal(2, call.Page);
}
/// <summary>Verifies that a single-page result renders no pager.</summary>
[Fact]
public void SinglePageResult_RendersNoPager()
{
@@ -160,6 +167,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Status tiles ──────────────────────────────────────────────────────────
/// <summary>Verifies that the status tiles show server-computed counts rather than counts of the visible page.</summary>
[Fact]
public void Tiles_RenderServerComputedCounts_NotCountsOfTheVisiblePage()
{
@@ -179,6 +187,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Contains("400", cut.Find("[data-test=\"deploy-tile-all\"]").TextContent);
}
/// <summary>Verifies that clicking a status tile applies the filter on the server and resets to page one.</summary>
[Fact]
public void ClickingAStatusTile_AppliesTheFilterServerSide_AndResetsToPageOne()
{
@@ -197,6 +206,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal("true", cut.Find("[data-test=\"deploy-tile-failed\"]").GetAttribute("aria-pressed"));
}
/// <summary>Verifies that clicking the active status tile again clears the status filter.</summary>
[Fact]
public void ClickingTheActiveTileAgain_ClearsTheStatusFilter()
{
@@ -214,6 +224,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Site scoping ──────────────────────────────────────────────────────────
/// <summary>Verifies that a system-wide user queries with no site scope.</summary>
[Fact]
public void SystemWideUser_QueriesWithNoSiteScope()
{
@@ -224,6 +235,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Null(Assert.Single(_pageCalls).Filter.SiteIdScope);
}
/// <summary>Verifies that a site-scoped user's permitted site ids are pushed into the query.</summary>
[Fact]
public void SiteScopedUser_PushesThePermittedSiteIdsIntoTheQuery()
{
@@ -238,6 +250,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Filter debounce ───────────────────────────────────────────────────────
/// <summary>Verifies that search input is debounced so a burst of keystrokes issues a single query.</summary>
[Fact]
public void SearchInput_IsDebounced_SoABurstOfKeystrokesIssuesOneQuery()
{
@@ -263,6 +276,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal(1, call.Page);
}
/// <summary>Verifies that clearing the filters resets the search text and status and re-queries the server.</summary>
[Fact]
public void ClearFilters_ResetsSearchAndStatus_AndRequeries()
{
@@ -280,6 +294,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.Equal(1, call.Page);
}
/// <summary>Verifies that the clear-filters button is enabled as soon as the operator types, not only once the debounce fires.</summary>
[Fact]
public void ClearFilters_IsEnabledAsSoonAsTheOperatorTypes_NotOnlyOnceTheDebounceFires()
{
@@ -294,6 +309,7 @@ public class DeploymentsServerPagingTests : BunitContext
Assert.False(cut.Find("[data-test=\"deploy-clear-filters\"]").HasAttribute("disabled"));
}
/// <summary>Verifies that disposing the page with an armed filter timer does not issue a query.</summary>
[Fact]
public void DisposeWithAnArmedFilterTimer_DoesNotQuery()
{
@@ -311,6 +327,7 @@ public class DeploymentsServerPagingTests : BunitContext
// ── Rendering ─────────────────────────────────────────────────────────────
/// <summary>Verifies that the instance name comes from the joined server row rather than a client-side lookup table.</summary>
[Fact]
public void InstanceName_ComesFromTheJoinedRow_NotAClientSideLookupTable()
{
@@ -45,7 +45,7 @@ public class FlowDebuggerPageTests : BunitContext
};
/// <summary>
/// #37: the MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane
/// The MesMoveOut shape — a REQUIRED array of objects — which the old flat text-box pane
/// could not take at all (the array reached the site empty).
/// </summary>
private static readonly ApiMethod MesMoveOut = new("MesMoveOut", "return 1;")
@@ -69,6 +69,7 @@ public class FlowDebuggerPageTests : BunitContext
],
[new("n1", "n2", false), new("n2", "n3", false)]);
/// <summary>Verifies that a refresh merge keeps the replay row order and then appends rows that exist only in the live run.</summary>
[Fact]
public void RefreshMerge_UsesReplayOrderThenLiveOnlyRows()
{
@@ -140,10 +141,14 @@ public class FlowDebuggerPageTests : BunitContext
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
/// <summary>Initializes the provider with the authentication state to return.</summary>
/// <param name="state">The authentication state to return.</param>
public StubAuthStateProvider(AuthenticationState state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(_state);
}
/// <summary>Verifies that the page renders the toolbar and an empty flow for a deployer.</summary>
[Fact]
public void Renders_ForDeployer_WithToolbarAndEmptyFlow()
{
@@ -164,6 +169,7 @@ public class FlowDebuggerPageTests : BunitContext
// ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ----------------
/// <summary>Verifies that the page renders replay-only for a viewer, with no live run controls.</summary>
[Fact]
public void Renders_ForViewer_ReplayOnly_WithNoLiveControls()
{
@@ -186,6 +192,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
}
/// <summary>Verifies that a viewer can load a replay but gets no retry or discard actions on a parked node.</summary>
[Fact]
public void Viewer_LoadsAReplay_ButGetsNoRetryOrDiscardOnAParkedNode()
{
@@ -209,6 +216,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.Received(1).ReplayAsync(root, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a viewer drilling in with a method id does not open the live run surface.</summary>
[Fact]
public void Viewer_DrillInWithAMethodId_DoesNotOpenTheLiveRunSurface()
{
@@ -225,6 +233,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.NotNull(cut.Find("input[data-test='flow-execution-id']"));
}
/// <summary>Verifies that choosing a method shows the predicted graph and the inputs form.</summary>
[Fact]
public void ChoosingAMethod_ShowsThePredictedGraph_AndTheInputsForm()
{
@@ -236,7 +245,7 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.Equal(3, cut.FindAll("g[data-test='flow-node']").Count));
Assert.Equal("Predicted", cut.Find("[data-test='flow-state']").TextContent.Trim());
Assert.NotNull(cut.Find("[data-test='flow-predicted-note']"));
// #37: the method pane is the shared ParameterValueForm (typed inputs by field id), and
// The method pane is the shared ParameterValueForm (typed inputs by field id), and
// Run is gated on its validity — MachineCode is required, so Run stays off until it is set.
Assert.NotEmpty(cut.FindAll("[data-test='flow-method-params']"));
Assert.NotNull(cut.Find("#param-MachineCode"));
@@ -246,6 +255,7 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
/// <summary>Verifies that a run sends the typed inputs and renders the returned graph.</summary>
[Fact]
public void Run_SendsTypedInputs_AndRendersTheReturnedGraph()
{
@@ -274,6 +284,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains("POST /api/DelmiaRecipeDownload", cut.Find("[data-test='flow-step-title']").TextContent);
}
/// <summary>Verifies that a standby node refusal is shown verbatim in an inline alert.</summary>
[Fact]
public void Run_StandbyNodeRefusal_ShowsInlineAlertVerbatim()
{
@@ -293,6 +304,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("Failed", cut.Find("[data-test='flow-state']").TextContent.Trim());
}
/// <summary>Verifies that an execution id query parameter triggers a replay on the first render.</summary>
[Fact]
public void ExecutionIdQueryParameter_TriggersReplayOnFirstRender()
{
@@ -310,6 +322,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains("btn-primary", cut.Find("button[data-test='flow-mode-replay']").ClassName);
}
/// <summary>Verifies that a run with no site steps and no child execution id shows the site-not-traced label.</summary>
[Fact]
public void Run_WithNoSiteStepsAndNoChildId_ShowsSiteNotTracedLabel()
{
@@ -325,7 +338,7 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForElement("[data-test='flow-site-not-traced']");
}
// ---- Node actions (follow-ups plan 2026-09-06, Task 8): Retry / Discard on a Parked node -------
// ---- Node actions (follow-ups plan 2026-09-06): Retry / Discard on a Parked node -------
/// <summary>Same events as <see cref="OutboundOutcome"/> but the cached call ends Parked; the second
/// outcome shares the event objects (stable ids, as real audit rows are) and adds a Delivered resolve.</summary>
@@ -379,6 +392,7 @@ public class FlowDebuggerPageTests : BunitContext
return cut;
}
/// <summary>Verifies that retrying a parked node confirms, relays the retry and re-polls until the node is no longer parked.</summary>
[Fact]
public void ParkedNode_Retry_ConfirmsRelaysAndRepollsUntilNotParked()
{
@@ -401,6 +415,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']")); // the selected node is no longer parked
}
/// <summary>Verifies that discarding a parked node uses a danger confirmation and does not poll when the confirmation is refused.</summary>
[Fact]
public void ParkedNode_Discard_UsesDangerConfirm_AndDoesNotPollWhenRefused()
{
@@ -420,6 +435,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.NotNull(cut.Find("button[data-test='flow-step-retry']"));
}
/// <summary>Verifies that declining the confirmation for a parked node never relays the action.</summary>
[Fact]
public void ParkedNode_ConfirmDeclined_NeverRelays()
{
@@ -436,6 +452,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.DidNotReceive().ReplayAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an action on a parked node at an out-of-scope site is refused before any confirmation.</summary>
[Fact]
public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm()
{
@@ -452,7 +469,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.DidNotReceive().RetryOutboundAsync(Arg.Any<FlowStep>(), Arg.Any<CancellationToken>());
}
// ---- Outbound calls (2026-09-06 plan, Task 9): pending refresh after a Live run ----------
// ---- Outbound calls (2026-09-06 plan): pending refresh after a Live run ----------
/// <summary>
/// Inbound → route → site run → cached API call; the call is Pending unless <paramref name="resolved"/>.
@@ -498,6 +515,7 @@ public class FlowDebuggerPageTests : BunitContext
};
}
/// <summary>Verifies that a live run with a pending outbound call polls the replay until the call reaches a terminal state.</summary>
[Fact]
public void LiveRun_WithPendingOutbound_PollsReplayUntilTerminal()
{
@@ -560,6 +578,7 @@ public class FlowDebuggerPageTests : BunitContext
return new FlowDebugOutcome(FlowGraphBuilder.Build(events), raw, null, null);
}
/// <summary>Verifies that a pending refresh keeps the steps the live run already had when the replay lags behind telemetry.</summary>
[Fact]
public void PendingRefresh_KeepsStepsTheLiveRunAlreadyHad_WhenReplayLagsBehindTelemetry()
{
@@ -593,6 +612,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains(cut.FindAll("[data-test='flow-node-pill']"), p => p.TextContent.Trim() == "Ok");
}
/// <summary>Verifies that a live run with no pending step never polls.</summary>
[Fact]
public void LiveRun_WithNoPendingStep_NeverPolls()
{
@@ -611,6 +631,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("[data-test='flow-pending-count']"));
}
/// <summary>Verifies that replay mode never polls but the refresh button replays once.</summary>
[Fact]
public void ReplayMode_NeverPolls_ButRefreshButtonReplaysOnce()
{
@@ -665,6 +686,7 @@ public class FlowDebuggerPageTests : BunitContext
_flow.PredictScriptAsync(7, Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(ThreeNodePrediction());
}
/// <summary>Verifies that the script entry lists scripts with their trigger labels once a site and instance are chosen.</summary>
[Fact]
public void ScriptEntry_ListsScriptsWithTriggerLabels_AfterSiteAndInstance()
{
@@ -679,6 +701,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("select[data-test='flow-attribute-select']"));
}
/// <summary>Verifies that a write-kind script shows a value field for the trigger attribute and gates Run on it.</summary>
[Fact]
public void ScriptEntry_WriteKind_ShowsTheValueField_ForTheTriggerAttribute_AndGatesRunOnIt()
{
@@ -697,6 +720,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that a call-kind script shows the parameter form and runs with the entered parameters.</summary>
[Fact]
public void ScriptEntry_CallKind_ShowsTheParameterForm_AndRunsWithParameters()
{
@@ -717,6 +741,7 @@ public class FlowDebuggerPageTests : BunitContext
_ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a call-kind script without parameters says so and can be run.</summary>
[Fact]
public void ScriptEntry_CallKindWithoutParameters_SaysSo_AndCanRun()
{
@@ -731,6 +756,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that a deep link with an instance and a script preselects both and shows the prediction.</summary>
[Fact]
public void DeepLink_InstanceAndScript_PreselectsAndPredicts()
{
@@ -774,6 +800,7 @@ public class FlowDebuggerPageTests : BunitContext
return cut;
}
/// <summary>Verifies that switching between call-kind scripts resets the parameter form and its validity.</summary>
[Fact]
public void ScriptEntry_SwitchingBetweenCallKindScripts_ResetsTheFormAndValidity()
{
@@ -792,6 +819,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that switching from an invalid form to a script without parameters enables Run.</summary>
[Fact]
public void ScriptEntry_SwitchingFromAnInvalidFormToANoParameterScript_EnablesRun()
{
@@ -805,6 +833,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that the instance picker shows areas collapsed until they are expanded.</summary>
[Fact]
public void ScriptEntry_InstancePicker_ShowsAreasCollapsed_UntilExpanded()
{
@@ -838,6 +867,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal(["— choose —", "Manual (Call)", "OnFlag (ValueChange: Flag)", "Tick (Interval)"], options);
}
/// <summary>Verifies that a deep link with an instance id shows the instance name in the picker.</summary>
[Fact]
public void ScriptEntry_DeepLink_InstanceId_ShowsItsNameInThePicker()
{
@@ -913,6 +943,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.False(cut.Find("[data-test='flow-instance-input']").HasAttribute("disabled")));
}
/// <summary>Verifies that changing the site clears the picked instance.</summary>
[Fact]
public void ScriptEntry_SiteChange_ClearsThePickedInstance()
{
@@ -940,7 +971,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal(["— choose —"], cut.FindAll("select[data-test='flow-script-select'] option").Select(o => o.TextContent.Trim()).ToList());
}
// ---- Step logs (plan 2026-09-09 D5/D7, Task 13): status-strip counts, truncation notes, cross-step hint ----
// ---- Step logs (plan 2026-09-09 D5/D7): status-strip counts, truncation notes, cross-step hint ----
/// <summary>One <c>ScriptTrace.ScriptLog</c> row — a line the builder folds onto a step, never a node of its own.</summary>
private static AuditEvent LogLine(Guid execution, string level, int ms, Guid? stepEventId = null, string message = "hello") =>
@@ -995,6 +1026,7 @@ public class FlowDebuggerPageTests : BunitContext
return cut;
}
/// <summary>Verifies that the status strip shows log counts by level.</summary>
[Fact]
public void StatusStrip_ShowsLogCountsByLevel()
{
@@ -1005,6 +1037,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("1 errors · 2 warnings · 3 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that the status strip omits log counts when the run logged nothing.</summary>
[Fact]
public void StatusStrip_OmitsLogCounts_WhenTheRunLoggedNothing()
{
@@ -1017,6 +1050,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Empty(cut.FindAll("[data-test='flow-unattached-logs']"));
}
/// <summary>Verifies that the status strip shows the inline log truncation badge.</summary>
[Fact]
public void StatusStrip_ShowsTheInlineLogTruncationBadge()
{
@@ -1028,6 +1062,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Contains("inline log lines capped", cut.Find("[data-test='flow-logs-truncated']").TextContent);
}
/// <summary>Verifies that the status strip notes log lines that could not be placed on a step.</summary>
[Fact]
public void StatusStrip_NotesLinesThatCouldNotBePlacedOnAStep()
{
@@ -1040,6 +1075,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("0 errors · 0 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that the state chip stays completed when the only errors are log lines.</summary>
[Fact]
public void StateChip_StaysCompleted_WhenTheOnlyErrorsAreLogLines()
{
@@ -1052,6 +1088,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("2 errors · 0 warnings · 0 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that the step detail log hint selects the sibling step that carries the other log lines.</summary>
[Fact]
public void StepDetail_LogHint_SelectsTheSiblingStepCarryingTheOtherLines()
{
@@ -1134,6 +1171,7 @@ public class FlowDebuggerPageTests : BunitContext
return (run, refreshed, root);
}
/// <summary>Verifies that refreshing outcomes clears the inline cap badge once it merges in more log rows.</summary>
[Fact]
public void RefreshOutcomes_ClearsTheInlineCapBadge_OnceItMergesInMoreLogRows()
{
@@ -1150,6 +1188,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("0 errors · 1 warnings · 1 info", cut.Find("[data-test='flow-log-counts']").TextContent.Trim());
}
/// <summary>Verifies that refreshing outcomes keeps the inline cap badge when the re-read brings back no new lines.</summary>
[Fact]
public void RefreshOutcomes_KeepsTheInlineCapBadge_WhenTheReReadBringsBackNoNewLines()
{
@@ -1170,6 +1209,7 @@ public class FlowDebuggerPageTests : BunitContext
// ---- CountLogs: the level buckets, straight ---------------------------------------------------
/// <summary>Verifies that log counting buckets lines by level case-insensitively and counts everything else as info.</summary>
[Fact]
public void CountLogs_BucketsByLevel_CaseInsensitively_WithEverythingElseAsInfo()
{
@@ -1189,6 +1229,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal((1, 1, 3, 5), FlowDebuggerPage.CountLogs(graph));
}
/// <summary>Verifies that counting logs for a null graph yields all zeroes.</summary>
[Fact]
public void CountLogs_NullGraph_IsAllZeroes()
{
@@ -1197,7 +1238,7 @@ public class FlowDebuggerPageTests : BunitContext
/// <summary>
/// Waits for the method pane's <c>ParameterValueForm</c> and fills the fixture method's one
/// required field, so Run is enabled (#37 gates Run on the form's validity).
/// required field, so Run is enabled (Run is gated on the form's validity).
/// </summary>
private static void FillRequiredMethodInput(IRenderedComponent<FlowDebuggerPage> cut)
{
@@ -1205,8 +1246,9 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
// ── #37: structured method parameters ───────────────────────────────────────────────────
// ── Structured method parameters ───────────────────────────────────────────────────
/// <summary>Verifies that an array of objects entered in the item editors round-trips into the run payload.</summary>
[Fact]
public void MethodEntry_ArrayOfObjects_RoundTripsFromItemEditorsToTheRunPayload()
{
@@ -1241,6 +1283,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("WO-2", orders[1].GetProperty("WorkOrderNumber").GetString());
}
/// <summary>Verifies that leaving a required list item field empty keeps Run disabled.</summary>
[Fact]
public void MethodEntry_RequiredListItemField_LeftEmpty_KeepsRunDisabled()
{
@@ -1258,6 +1301,8 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
/// <summary>Verifies that an invalid raw JSON document blocks Run and shows the error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MethodEntry_RawJson_InvalidDocument_BlocksRun_AndShowsTheError()
{
@@ -1289,6 +1334,8 @@ public class FlowDebuggerPageTests : BunitContext
_ = _flow.DidNotReceive().RunMethodAsync(Arg.Any<int>(), Arg.Any<JsonElement?>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a valid raw JSON document is sent verbatim and survives switching back to the form.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MethodEntry_RawJson_ValidDocument_IsSentVerbatim_AndSurvivesTheSwitchBackToForm()
{
@@ -1322,6 +1369,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.Equal("P-1", sent.Value.GetProperty("WorkOrders")[0].GetProperty("PartNumber").GetString());
}
/// <summary>Verifies that switching methods resets the form and its validity.</summary>
[Fact]
public void MethodEntry_SwitchingMethods_ResetsTheFormAndItsValidity()
{
@@ -1339,6 +1387,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
/// <summary>Verifies that a persisted value tree is restored into the form.</summary>
[Fact]
public void MethodEntry_PersistedValueTree_IsRestoredIntoTheForm()
{
@@ -1353,11 +1402,12 @@ public class FlowDebuggerPageTests : BunitContext
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
/// <summary>Verifies that a legacy flat string entry that no longer validates is dropped.</summary>
[Fact]
public void MethodEntry_LegacyFlatStringEntry_ThatNoLongerValidates_IsDropped()
{
RegisterServices();
// Pre-#37 entries stored every field as a string; "7" is not an Integer, so the whole
// Earlier entries stored every field as a string; "7" is not an Integer, so the whole
// entry is discarded rather than painted into the form as an error.
JSInterop.Setup<string?>("localStorage.getItem", "flowDebugger.inputs.4")
.SetResult("{\"MachineCode\":\"Z9\",\"Qty\":\"7\"}");
@@ -5,16 +5,16 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Flow Debugger outbound-calls plan (2026-09-06) Task 8: the step detail panel's Lifecycle tab,
/// outbound "Where this ran" fields and kind-specific Input tab labels. Follow-ups plan (2026-09-06)
/// Task 6: report deep links from a correlated outbound step and Retry/Discard on a parked one (D2/D5).
/// Step-logs plan (2026-09-09) Task 12: the Log tab — exception block, level-filtered lines and the
/// cross-step hint (design D8).
/// Tests for the Flow Debugger step detail panel: the Lifecycle tab, outbound "Where this ran" fields
/// and kind-specific Input tab labels; report deep links from a correlated outbound step and
/// Retry/Discard on a parked one (D2/D5); and the Log tab — exception block, level-filtered lines and
/// the cross-step hint (design D8).
/// </summary>
public class FlowStepDetailTests : BunitContext
{
private static readonly DateTimeOffset T0 = new(2026, 9, 6, 12, 0, 0, TimeSpan.Zero);
/// <summary>Initializes the test context with loose JS interop so the Log tab's clipboard calls are accepted.</summary>
public FlowStepDetailTests()
{
// The Log tab's Copy buttons call navigator.clipboard through IJSRuntime.
@@ -59,6 +59,7 @@ public class FlowStepDetailTests : BunitContext
new(Guid.NewGuid(), "CachedResolve", terminalStatus, T0.AddMilliseconds(4100), "node-b", null, null),
];
/// <summary>Verifies that the Lifecycle tab renders its rows in time order and shows who delivered the operation.</summary>
[Fact]
public void LifecycleTab_RendersRowsInTimeOrder_AndDeliveredBy()
{
@@ -84,6 +85,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-parked-hint]"));
}
/// <summary>Verifies that a step without lifecycle rows hides the Lifecycle tab.</summary>
[Fact]
public void NoLifecycle_HidesTheTab()
{
@@ -93,6 +95,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal("Input", cut.Find("[data-test=flow-step-tab-input]").TextContent.Trim());
}
/// <summary>Verifies that a parked notification step shows the hint, the list label and a parked badge.</summary>
[Fact]
public void ParkedNotify_ShowsHint_AndListLabel_AndParkedBadge()
{
@@ -115,6 +118,10 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains("Site Calls / Notification", cut.Find("[data-test=flow-step-parked-hint]").TextContent);
}
/// <summary>Verifies that each outbound step kind labels its Input tab and target field appropriately.</summary>
/// <param name="kind">The outbound step kind under test.</param>
/// <param name="inputLabel">The expected label of the Input tab.</param>
/// <param name="targetLabel">The expected label of the target field.</param>
[Theory]
[InlineData(FlowStepKind.ApiCall, "Request", "System")]
[InlineData(FlowStepKind.DbCall, "SQL + parameters", "Connection")]
@@ -127,6 +134,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains(targetLabel, cut.Find("[data-test=flow-step-where]").TextContent);
Assert.Equal("Pending", cut.Find("[data-test=flow-step-outcome]").TextContent.Trim());
}
/// <summary>Verifies that an outbound step with a correlation id links to its report page.</summary>
[Fact]
public void OutboundStepWithCorrelation_LinksToTheReportPage()
{
@@ -149,6 +157,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=open-report]"));
}
/// <summary>Verifies that a parked step with callbacks shows Retry and Discard actions.</summary>
[Fact]
public void ParkedStep_WithCallbacks_ShowsRetryAndDiscard()
{
@@ -187,6 +196,7 @@ public class FlowStepDetailTests : BunitContext
Assert.True(cut.Find("button[data-test=flow-step-discard]").HasAttribute("disabled"));
}
/// <summary>Verifies that a parked step without callbacks shows only the hint link.</summary>
[Fact]
public void ParkedStep_WithoutCallbacks_ShowsOnlyTheHintLink()
{
@@ -205,6 +215,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal($"/notifications/report?correlationId={c:N}", hintLink!.GetAttribute("href"));
}
/// <summary>Verifies that a successful step never shows Retry or Discard actions.</summary>
[Fact]
public void OkStep_NeverShowsActions()
{
@@ -218,6 +229,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-discard]"));
}
/// <summary>Verifies that a step with no log lines and no error detail hides the Log tab.</summary>
[Fact]
public void NoLinesAndNoDetail_HidesTheLogTab()
{
@@ -225,6 +237,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-tab-log]"));
}
/// <summary>Verifies that the Log tab shows a count of its lines.</summary>
[Fact]
public void LogTab_CountsItsLines()
{
@@ -239,6 +252,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal("Log (3)", cut.Find("[data-test=flow-step-tab-log]").TextContent.Trim());
}
/// <summary>Verifies that a failed step with error detail opens on the Log tab and renders the exception block.</summary>
[Fact]
public void FailedStepWithDetail_OpensOnLog_AndRendersTheExceptionBlock()
{
@@ -254,10 +268,11 @@ public class FlowStepDetailTests : BunitContext
Assert.NotNull(cut.Find("[data-test=flow-step-exception-copy]"));
}
/// <summary>Verifies that the exception block header names the innermost exception type in full for a wrapped exception.</summary>
[Fact]
public void WrappedException_TheBlockHeaderNamesTheINNERMOSTType_InFull()
{
// Final review M1: the panel used to parse the OUTERMOST exception of a " ---> " chain
// The panel used to parse the OUTERMOST exception of a " ---> " chain
// while the graph node's subtitle showed the innermost — the two now share
// ExceptionHeader, which always follows the chain to its end. The panel still shows the
// type with its namespace (only the node badge shortens it), and the raw detail is
@@ -275,6 +290,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains("System.AggregateException", exc.QuerySelector("pre")!.TextContent);
}
/// <summary>Verifies that a successful step with log lines opens on the Input tab.</summary>
[Fact]
public void OkStepWithLines_OpensOnInput()
{
@@ -286,6 +302,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Empty(cut.FindAll("[data-test=flow-step-exception]"));
}
/// <summary>Verifies that the warning filter leaves only warning and error rows.</summary>
[Fact]
public void WarnFilter_LeavesOnlyWarnAndErrorRows()
{
@@ -311,6 +328,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Equal(4, cut.FindAll("[data-test=flow-step-log-lines] tr[data-level]").Count);
}
/// <summary>Verifies that the hint names another step of the same execution and selects it when clicked.</summary>
[Fact]
public void Hint_NamesAnotherStepOfTheSameExecution_AndSelectsIt()
{
@@ -336,6 +354,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Same(other, picked);
}
/// <summary>Verifies that a hint without a select callback is rendered as plain text.</summary>
[Fact]
public void Hint_WithoutASelectCallback_IsPlainText()
{
@@ -352,6 +371,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Null(hint.QuerySelector("button"));
}
/// <summary>Verifies that the see-exception link in the output alert switches to the Log tab.</summary>
[Fact]
public void OutputAlert_SeeExceptionLink_SwitchesToTheLogTab()
{
@@ -367,6 +387,7 @@ public class FlowStepDetailTests : BunitContext
Assert.NotNull(cut.Find("[data-test=flow-step-exception]"));
}
/// <summary>Verifies that a runtime-sourced log line shows its source and any attached exception.</summary>
[Fact]
public void RuntimeSourcedLine_ShowsItsSource_AndAnAttachedException()
{
@@ -384,6 +405,7 @@ public class FlowStepDetailTests : BunitContext
Assert.Contains("System.TimeoutException", row.QuerySelector("details pre")!.TextContent);
}
/// <summary>Verifies that switching steps keeps the selected tab when it is still valid for the new step.</summary>
[Fact]
public void SwitchingSteps_KeepsAStillValidTab()
{
@@ -400,6 +422,7 @@ public class FlowStepDetailTests : BunitContext
Assert.DoesNotContain("active", cut.Find("[data-test=flow-step-tab-input]").ClassName);
}
/// <summary>Verifies that refreshing the same step keeps the selected tab and the level filter.</summary>
[Fact]
public void RefreshingTheSameStep_KeepsTheTabAndTheLevelFilter()
{
@@ -19,7 +19,7 @@ using InstanceConfigurePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.D
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Bundle D drill-in test (#23 M7-T12) for the Instance Configure page. The
/// Drill-in test for the Instance Configure page. The
/// chip routes operators into the central Audit Log pre-filtered by
/// <c>?instance={Instance.UniqueName}</c>. Instance is UI-only on the filter
/// bar (the repository filter contract has no instance column), so the page
@@ -32,6 +32,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
/// <summary>Initializes the test context and registers the services the Instance Configure page depends on.</summary>
public InstanceConfigureAuditDrillinTests()
{
// Loose JS interop because shared components on the page render
@@ -65,6 +66,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
}
/// <summary>Verifies that the page renders a recent audit activity link carrying the instance unique name.</summary>
[Fact]
public void Page_HasRecentAuditActivityLink_WithInstanceUniqueName()
{
@@ -114,6 +116,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext
/// protocol == "OpcUa". Previously BuildTestableRows filtered to OPC UA
/// only, leaving the button greyed for MxGateway bindings.
/// </summary>
/// <param name="protocol">The data connection protocol of the bound attribute.</param>
[Theory]
[InlineData("MxGateway")]
[InlineData("OpcUa")]
@@ -33,6 +33,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
/// <summary>Creates the test class and registers the stubbed services the page needs.</summary>
public InstanceConfigureBindingBehaviourTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -85,6 +86,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
new() { CanonicalName = canonical, DataType = "Boolean", DataSourceReference = effective ?? dflt,
DefaultDataSourceReference = dflt, BoundDataConnectionId = conn };
/// <summary>Verifies that binding rows come from the flattened configuration with composed members grouped.</summary>
[Fact]
public void Rows_ComeFromFlattenedConfiguration_ComposedMembersGrouped()
{
@@ -110,6 +112,7 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
});
}
/// <summary>Verifies that a flatten failure falls back to the direct attributes and shows a banner.</summary>
[Fact]
public void FlattenFailure_FallsBackToDirectAttributes_WithBanner()
{
@@ -128,6 +131,8 @@ public class InstanceConfigureBindingBehaviourTests : BunitContext
});
}
/// <summary>Verifies that applying a connection to a mixed group keeps each member connection and that save posts the composed rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ApplyOnMixedConnectionGroup_KeepsEachMembersConnection_AndSavePostsComposedRows()
{
@@ -9,6 +9,7 @@ public class InstanceConfigureBindingGroupsTests
{
private static string Markup => InstanceConfigureBindingRowsTests.ReadMarkup();
/// <summary>Verifies that a group header has a connection selector, an object selector and an Apply button.</summary>
[Fact]
public void GroupHeader_HasConnectionObjectAndApply()
{
@@ -21,6 +22,7 @@ public class InstanceConfigureBindingGroupsTests
Assert.Contains("BindingGroupBuilder.ApplyObject(", m);
}
/// <summary>Verifies that a group header shows unbound and mixed status pills.</summary>
[Fact]
public void GroupHeader_ShowsUnboundAndMixedPills()
{
@@ -30,6 +32,7 @@ public class InstanceConfigureBindingGroupsTests
Assert.Contains("data-test=\"binding-total-unbound\"", m);
}
/// <summary>Verifies that the toolbar offers an unbound-only filter and an expand-all control.</summary>
[Fact]
public void Toolbar_HasUnboundFilterAndExpandAll()
{
@@ -39,18 +42,21 @@ public class InstanceConfigureBindingGroupsTests
Assert.Contains("data-test=\"binding-bulk-select\"", m); // existing all-groups connection select retained
}
/// <summary>Verifies that a composed member with a bare default binding is flagged.</summary>
[Fact]
public void ComposedBareDefault_IsFlagged()
{
Assert.Contains("data-test=\"binding-bare-default\"", Markup);
}
/// <summary>Verifies that the old flat bindings table is no longer rendered.</summary>
[Fact]
public void OldFlatTable_IsGone()
{
Assert.DoesNotContain("_bindingDataSourceAttrs", Markup);
}
/// <summary>Verifies that the per-row Browse button is retained.</summary>
[Fact]
public void PerRowBrowseButton_IsRetained()
{
@@ -8,6 +8,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// </summary>
public class InstanceConfigureBindingRowsTests
{
/// <summary>Reads the InstanceConfigure page markup from the source tree.</summary>
/// <returns>The full text of <c>InstanceConfigure.razor</c>.</returns>
internal static string ReadMarkup()
{
var dir = AppContext.BaseDirectory;
@@ -18,6 +20,7 @@ public class InstanceConfigureBindingRowsTests
private static string Markup => ReadMarkup();
/// <summary>Verifies that binding rows come from the flattened configuration rather than the template attributes.</summary>
[Fact]
public void BindingRows_ComeFromFlattenedConfiguration_NotTemplateAttributes()
{
@@ -29,6 +32,7 @@ public class InstanceConfigureBindingRowsTests
Assert.Contains("DefaultDataSourceReference", m);
}
/// <summary>Verifies that binding rows fall back to direct attributes with a warning banner when flattening fails.</summary>
[Fact]
public void BindingRows_FallBackToDirectAttributes_WithBanner_WhenFlattenFails()
{
@@ -37,6 +41,7 @@ public class InstanceConfigureBindingRowsTests
Assert.Contains("data-test=\"binding-source-warning\"", m);
}
/// <summary>Verifies that save and test cover every binding row.</summary>
[Fact]
public void SaveAndTest_CoverEveryRow()
{
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// MV-14: the Instance Configure attribute-override panel uses the shared
/// The Instance Configure attribute-override panel uses the shared
/// <c>AttributeListEditor</c> for a List attribute (whole-list replacement; the
/// element type is fixed by the base attribute, so the type select is hidden via
/// <c>ShowElementType="false"</c>). Loading an existing override decodes its JSON
@@ -30,6 +30,7 @@ public class InstanceConfigureListOverrideTests
}
}
/// <summary>Verifies that list override, reveals shared editor, with element type hidden.</summary>
[Fact]
public void ListOverride_RevealsSharedEditor_WithElementTypeHidden()
{
@@ -45,6 +46,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("RowsChanged=\"@(r => OnListRowsChanged(attr.Name, r))\"", markup);
}
/// <summary>Verifies that list override, decodes on load, and encodes on save with guard.</summary>
[Fact]
public void ListOverride_DecodesOnLoad_AndEncodesOnSaveWithGuard()
{
@@ -59,6 +61,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("_overrideErrors", markup);
}
/// <summary>Verifies that list override, clear removes the override row.</summary>
[Fact]
public void ListOverride_ClearRemovesTheOverrideRow()
{
@@ -70,6 +73,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("HasOverrideRow", markup);
}
/// <summary>Verifies that non list override, keeps single input ux.</summary>
[Fact]
public void NonListOverride_KeepsSingleInputUx()
{
@@ -79,6 +83,7 @@ public class InstanceConfigureListOverrideTests
Assert.Contains("OnOverrideChanged(attr.Name, e)", markup);
}
/// <summary>Verifies that encoded rows, round trip through codec, as the page does.</summary>
[Fact]
public void EncodedRows_RoundTripThroughCodec_AsThePageDoes()
{
@@ -103,6 +108,7 @@ public class InstanceConfigureListOverrideTests
Assert.Equal(json, reEncoded);
}
/// <summary>Verifies that malformed list element, surfaces format exception, for inline error.</summary>
[Fact]
public void MalformedListElement_SurfacesFormatException_ForInlineError()
{
@@ -34,6 +34,7 @@ public class InstanceConfigureMesReportableTests
private static string InstanceConfigureMarkup
=> File.ReadAllText(Path.Combine(DeploymentComponentsRoot, "InstanceConfigure.razor"));
/// <summary>Verifies that the override modal has a tri-state MES-reportable select.</summary>
[Fact]
public void OverrideModal_HasTriStateMesReportableSelect()
{
@@ -57,6 +58,10 @@ public class InstanceConfigureMesReportableTests
Assert.Contains("value=\"false\"", selectBody);
}
/// <summary>Verifies that setting only the MES-reportable flag counts as an override delta.</summary>
/// <param name="overrideJson">The override json.</param>
/// <param name="priority">The priority.</param>
/// <param name="mesReportable">The mes reportable.</param>
[Theory]
// The new term: a reportable-only override is a real delta in BOTH directions
// (false is not "no override" — it pins a reportable template alarm off).
@@ -66,6 +71,10 @@ public class InstanceConfigureMesReportableTests
string? overrideJson, int? priority, bool? mesReportable)
=> Assert.True(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable));
/// <summary>Verifies that an override with nothing set is not a delta.</summary>
/// <param name="overrideJson">The override json.</param>
/// <param name="priority">The priority.</param>
/// <param name="mesReportable">The mes reportable.</param>
[Theory]
[InlineData(null, null, null)]
[InlineData("", null, null)]
@@ -74,6 +83,10 @@ public class InstanceConfigureMesReportableTests
string? overrideJson, int? priority, bool? mesReportable)
=> Assert.False(InstanceConfigure.HasOverrideDelta(overrideJson, priority, mesReportable));
/// <summary>Verifies that the existing override terms still count as a delta.</summary>
/// <param name="overrideJson">The override json.</param>
/// <param name="priority">The priority.</param>
/// <param name="mesReportable">The mes reportable.</param>
[Theory]
[InlineData("{\"hi\":70}", null, null)]
[InlineData(null, 750, null)]
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Task 25: Instance Configure exposes a "Native Alarm Source Overrides" card —
/// Instance Configure exposes a "Native Alarm Source Overrides" card —
/// per-instance retarget of an inherited native alarm source binding (connection /
/// source reference / filter; blank = inherited). <c>InstanceConfigure</c> is a
/// heavyweight page (7 injected services incl. <c>InstanceService</c> and the
@@ -24,6 +24,7 @@ public class InstanceConfigureNativeAlarmTests
}
}
/// <summary>Verifies that the Instance Configure page has a native alarm source overrides card.</summary>
[Fact]
public void InstanceConfigure_HasNativeAlarmSourceOverridesCard()
{
@@ -33,6 +34,7 @@ public class InstanceConfigureNativeAlarmTests
Assert.Contains("HasNativeOverride", markup);
}
/// <summary>Verifies that a native override row has connection, source reference and filter editors.</summary>
[Fact]
public void NativeOverrideRow_HasConnectionSourceRefAndFilterEditors()
{
@@ -44,6 +46,7 @@ public class InstanceConfigureNativeAlarmTests
Assert.Contains("(inherited)", markup);
}
/// <summary>Verifies that a native override wires the repository upsert and clear operations.</summary>
[Fact]
public void NativeOverride_WiresRepositoryUpsertAndClear()
{
@@ -40,6 +40,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly DialogService _dialog = new();
/// <summary>Initializes a new instance of the <c>InstanceCreateTemplateFieldTests</c> class.</summary>
public InstanceCreateTemplateFieldTests()
{
Services.AddSingleton(_repo);
@@ -144,6 +145,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
/// <summary>Wrapper that renders InstanceCreate next to a DialogHost, as MainLayout does.</summary>
public sealed class TestHostWrapper : ComponentBase
{
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<InstanceCreatePage>(0);
@@ -158,6 +160,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
.First(li => li.QuerySelector(".tv-label")!.TextContent.Trim() == label)
.QuerySelector(".tv-toggle")!.Click();
/// <summary>Verifies that template field is read only with choose button and placeholder.</summary>
[Fact]
public void TemplateField_IsReadOnlyWithChooseButton_AndPlaceholder()
{
@@ -169,6 +172,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.NotNull(cut.Find("button[data-test='create-template-choose']"));
}
/// <summary>Verifies that template id query prefills field with folder path.</summary>
[Fact]
public void TemplateIdQuery_PrefillsFieldWithFolderPath()
{
@@ -177,6 +181,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.Equal("A / B / Deep", cut.Find("input[data-test='create-template']").GetAttribute("value"));
}
/// <summary>Verifies that unknown template id query leaves field empty and shows hint.</summary>
[Fact]
public void UnknownTemplateIdQuery_LeavesFieldEmpty_AndShowsHint()
{
@@ -186,6 +191,7 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.Contains("Template 999 no longer exists", cut.Markup);
}
/// <summary>Verifies that no templates disables choose and links to templates page.</summary>
[Fact]
public void NoTemplates_DisablesChoose_AndLinksToTemplatesPage()
{
@@ -195,6 +201,8 @@ public class InstanceCreateTemplateFieldTests : BunitContext
Assert.Contains("/design/templates", cut.Markup);
}
/// <summary>Verifies that choosing in picker sets template and re previews bindings when site set.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ChoosingInPicker_SetsTemplate_AndRePreviewsBindingsWhenSiteSet()
{
@@ -41,6 +41,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly DialogService _dialog = new();
/// <summary>Initializes the test host with stubbed services and the dialog service.</summary>
public TopologyDisableConfirmLabelTests()
{
Services.AddSingleton(_repo);
@@ -111,6 +112,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
/// <summary>Wrapper so the page and the dialog host share one render tree.</summary>
public sealed class TestHostWrapper : ComponentBase
{
/// <inheritdoc />
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<TopologyPage>(0);
@@ -151,6 +153,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
return (cut, cut.FindAll("div.modal-footer button").ToList());
}
/// <summary>Verifies that the disable confirmation button is labelled Disable rather than Delete.</summary>
[Fact]
public void Disable_confirm_button_says_disable_not_delete()
{
@@ -162,6 +165,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
Assert.Contains("Cancel", footer.Select(b => b.TextContent.Trim()));
}
/// <summary>Verifies that the disable confirmation body names the instance being disabled.</summary>
[Fact]
public void Disable_confirm_body_names_the_instance()
{
@@ -169,6 +173,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
Assert.Contains("Disable instance 'Pump-001'?", cut.Find(".modal-body").TextContent);
}
/// <summary>Verifies that the delete confirmation button still says Delete.</summary>
[Fact]
public void Delete_confirm_button_still_says_delete()
{