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
@@ -42,10 +42,11 @@ public class AccessibilityTests : BunitContext
// ── Test 1: TreeView chevron toggle a11y attributes ────────────────────────
/// <summary>Verifies that the tree view toggle exposes an accessible name and role.</summary>
[Fact]
public void TreeViewToggle_HasAccessibleNameAndRole()
{
// BranchRoots has one branch node ("root-1") and one leaf node ("leaf-1").
// BranchRoots has one branch node (the first root) and one leaf node (the first leaf).
// The branch node's toggle span is the only .tv-toggle rendered.
var cut = RenderTreeView();
@@ -57,7 +58,7 @@ public class AccessibilityTests : BunitContext
// tabindex="0" puts it in the natural tab order.
Assert.Equal("0", toggle.GetAttribute("tabindex"));
// aria-label must be non-empty (e.g. "Expand root-1").
// aria-label must be non-empty (e.g. "Expand" followed by the node name).
var label = toggle.GetAttribute("aria-label");
Assert.NotNull(label);
Assert.NotEmpty(label!);
@@ -66,6 +67,7 @@ public class AccessibilityTests : BunitContext
Assert.Equal("false", toggle.GetAttribute("aria-expanded"));
}
/// <summary>Verifies that the tree view toggle's aria-expanded attribute reflects the expanded state.</summary>
[Fact]
public void TreeViewToggle_AriaExpanded_ReflectsExpandedState()
{
@@ -90,6 +92,7 @@ public class AccessibilityTests : BunitContext
// ── Test 3: TreeView keyboard activation (Enter / Space) ──────────────────
/// <summary>Verifies that pressing Enter on the tree view toggle toggles the expanded state.</summary>
[Fact]
public void TreeViewToggle_EnterKey_TogglesExpanded()
{
@@ -105,6 +108,7 @@ public class AccessibilityTests : BunitContext
Assert.Equal("true", expandedToggle.GetAttribute("aria-expanded"));
}
/// <summary>Verifies that pressing Space on the tree view toggle toggles the expanded state.</summary>
[Fact]
public void TreeViewToggle_SpaceKey_TogglesExpanded()
{
@@ -122,6 +126,7 @@ public class AccessibilityTests : BunitContext
// ── Test 2: ToastNotification live-region regression lock ─────────────────
/// <summary>Verifies that the toast notification container is an aria-live polite region.</summary>
[Fact]
public void ToastNotification_Container_IsAriaLivePolite()
{
@@ -8,6 +8,7 @@ public class AlarmTriggerConfigCodecTests
{
// ── Parse: ValueMatch ──────────────────────────────────────────────────
/// <summary>Verifies that parsing a value-match configuration reads the canonical keys.</summary>
[Fact]
public void Parse_ValueMatch_ReadsCanonicalKeys()
{
@@ -20,6 +21,7 @@ public class AlarmTriggerConfigCodecTests
Assert.False(model.NotEquals);
}
/// <summary>Verifies that parsing a value-match configuration accepts the legacy attribute and value keys.</summary>
[Fact]
public void Parse_ValueMatch_AcceptsLegacyAttributeAndValueKeys()
{
@@ -32,6 +34,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal("Critical", model.MatchValue);
}
/// <summary>Verifies that a not-equals prefix on the match value sets the not-equals flag and is stripped from the value.</summary>
[Fact]
public void Parse_ValueMatch_NotEqualsPrefix_SetsFlagAndStripsPrefix()
{
@@ -43,6 +46,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal("Good", model.MatchValue);
}
/// <summary>Verifies that a value-match configuration without a match value leaves it null.</summary>
[Fact]
public void Parse_ValueMatch_MissingMatchValue_LeavesNull()
{
@@ -57,6 +61,7 @@ public class AlarmTriggerConfigCodecTests
// ── Parse: RangeViolation ──────────────────────────────────────────────
/// <summary>Verifies that parsing a range-violation configuration reads the canonical keys.</summary>
[Fact]
public void Parse_RangeViolation_ReadsCanonicalKeys()
{
@@ -68,6 +73,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal(100, model.Max);
}
/// <summary>Verifies that parsing a range-violation configuration accepts the legacy low and high keys.</summary>
[Fact]
public void Parse_RangeViolation_AcceptsLegacyLowHighKeys()
{
@@ -79,6 +85,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal(50, model.Max);
}
/// <summary>Verifies that canonical range-violation keys take precedence over legacy ones when both are present.</summary>
[Fact]
public void Parse_RangeViolation_CanonicalKeysWinOverLegacy()
{
@@ -91,6 +98,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal(100, model.Max);
}
/// <summary>Verifies that numeric values stored as strings in a range-violation configuration are parsed.</summary>
[Fact]
public void Parse_RangeViolation_StringNumericValues_AreParsed()
{
@@ -105,6 +113,7 @@ public class AlarmTriggerConfigCodecTests
// ── Parse: RateOfChange ────────────────────────────────────────────────
/// <summary>Verifies that parsing a rate-of-change configuration reads all of its fields.</summary>
[Fact]
public void Parse_RateOfChange_ReadsAllFields()
{
@@ -118,6 +127,9 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal("rising", model.Direction);
}
/// <summary>Verifies that direction aliases in a rate-of-change configuration are normalized.</summary>
/// <param name="input">The direction text as stored in the configuration.</param>
/// <param name="expected">The expected normalized direction.</param>
[Theory]
[InlineData("rising", "rising")]
[InlineData("Rising", "rising")]
@@ -138,6 +150,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal(expected, model.Direction);
}
/// <summary>Verifies that a rate-of-change configuration without a direction defaults to either.</summary>
[Fact]
public void Parse_RateOfChange_MissingDirection_DefaultsToEither()
{
@@ -152,6 +165,8 @@ public class AlarmTriggerConfigCodecTests
// ── Parse: misc ────────────────────────────────────────────────────────
/// <summary>Verifies that null, empty or whitespace input parses to the default model.</summary>
/// <param name="input">The null, empty or whitespace configuration text.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -166,6 +181,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal("either", model.Direction);
}
/// <summary>Verifies that malformed JSON parses to the default model without throwing.</summary>
[Fact]
public void Parse_MalformedJson_ReturnsDefaultModel_DoesNotThrow()
{
@@ -177,6 +193,7 @@ public class AlarmTriggerConfigCodecTests
// ── Serialize: ValueMatch ──────────────────────────────────────────────
/// <summary>Verifies that serializing a value-match model writes only the canonical keys.</summary>
[Fact]
public void Serialize_ValueMatch_WritesCanonicalKeysOnly()
{
@@ -201,6 +218,7 @@ public class AlarmTriggerConfigCodecTests
Assert.False(root.TryGetProperty("direction", out _));
}
/// <summary>Verifies that serializing a not-equals value-match model prepends the not-equals marker to the match value.</summary>
[Fact]
public void Serialize_ValueMatch_NotEquals_PrependsBangEqualsToMatchValue()
{
@@ -217,6 +235,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal("!=Good", doc.RootElement.GetProperty("matchValue").GetString());
}
/// <summary>Verifies that a null attribute name is serialized as an empty string.</summary>
[Fact]
public void Serialize_ValueMatch_NullAttributeName_WritesEmptyString()
{
@@ -232,6 +251,7 @@ public class AlarmTriggerConfigCodecTests
// ── Serialize: RangeViolation ──────────────────────────────────────────
/// <summary>Verifies that serializing a range-violation model writes the canonical numeric keys.</summary>
[Fact]
public void Serialize_RangeViolation_WritesCanonicalNumericKeys()
{
@@ -252,6 +272,7 @@ public class AlarmTriggerConfigCodecTests
Assert.False(root.TryGetProperty("matchValue", out _));
}
/// <summary>Verifies that a null range bound is omitted from the serialized output.</summary>
[Fact]
public void Serialize_RangeViolation_NullBound_OmitsKey()
{
@@ -266,6 +287,7 @@ public class AlarmTriggerConfigCodecTests
// ── Serialize: RateOfChange ────────────────────────────────────────────
/// <summary>Verifies that serializing a rate-of-change model writes the threshold, window and direction.</summary>
[Fact]
public void Serialize_RateOfChange_WritesThresholdWindowAndDirection()
{
@@ -286,6 +308,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal("falling", root.GetProperty("direction").GetString());
}
/// <summary>Verifies that serializing a rate-of-change model always includes the direction.</summary>
[Fact]
public void Serialize_RateOfChange_AlwaysIncludesDirection()
{
@@ -301,6 +324,7 @@ public class AlarmTriggerConfigCodecTests
// ── Round-trip ─────────────────────────────────────────────────────────
/// <summary>Verifies that a not-equals value-match model survives a serialize and parse round trip.</summary>
[Fact]
public void RoundTrip_ValueMatch_NotEquals_Preserved()
{
@@ -319,6 +343,7 @@ public class AlarmTriggerConfigCodecTests
Assert.True(round.NotEquals);
}
/// <summary>Verifies that a range-violation model survives a serialize and parse round trip.</summary>
[Fact]
public void RoundTrip_RangeViolation_Preserved()
{
@@ -336,6 +361,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal(original.Max, round.Max);
}
/// <summary>Verifies that a rate-of-change model survives a serialize and parse round trip.</summary>
[Fact]
public void RoundTrip_RateOfChange_Preserved()
{
@@ -358,6 +384,7 @@ public class AlarmTriggerConfigCodecTests
// ── Parse: HiLo ────────────────────────────────────────────────────────
/// <summary>Verifies that parsing a high-low configuration reads all setpoints and priorities.</summary>
[Fact]
public void Parse_HiLo_ReadsAllSetpointsAndPriorities()
{
@@ -376,6 +403,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Equal(900, model.HiHiPriority);
}
/// <summary>Verifies that a high-low configuration with only some setpoints parses, leaving the missing ones null.</summary>
[Fact]
public void Parse_HiLo_AcceptsPartialSetpoints_MissingOnesAreNull()
{
@@ -393,6 +421,7 @@ public class AlarmTriggerConfigCodecTests
// ── Serialize: HiLo ────────────────────────────────────────────────────
/// <summary>Verifies that serializing a high-low model omits null setpoints and priorities.</summary>
[Fact]
public void Serialize_HiLo_OmitsNullSetpointsAndPriorities()
{
@@ -418,6 +447,7 @@ public class AlarmTriggerConfigCodecTests
Assert.False(root.TryGetProperty("loPriority", out _));
}
/// <summary>Verifies that serializing a high-low model does not leak fields that belong to other trigger types.</summary>
[Fact]
public void Serialize_HiLo_DoesNotLeakForeignTriggerTypeFields()
{
@@ -443,6 +473,7 @@ public class AlarmTriggerConfigCodecTests
Assert.False(root.TryGetProperty("direction", out _));
}
/// <summary>Verifies that parsing a high-low configuration reads the deadbands.</summary>
[Fact]
public void Parse_HiLo_ReadsDeadbands()
{
@@ -456,6 +487,7 @@ public class AlarmTriggerConfigCodecTests
Assert.Null(model.LoLoDeadband);
}
/// <summary>Verifies that serializing a high-low model omits null deadbands.</summary>
[Fact]
public void Serialize_HiLo_OmitsNullDeadbands()
{
@@ -475,6 +507,7 @@ public class AlarmTriggerConfigCodecTests
Assert.False(doc.RootElement.TryGetProperty("loDeadband", out _));
}
/// <summary>Verifies that a high-low model preserves all of its fields through a serialize and parse round trip.</summary>
[Fact]
public void RoundTrip_HiLo_PreservesAllFields()
{
@@ -515,6 +548,9 @@ public class AlarmTriggerConfigCodecTests
// ── NormalizeDirection (direct) ────────────────────────────────────────
/// <summary>Verifies that direction normalization handles every alias and falls back to either for unknown input.</summary>
/// <param name="input">The direction text to normalize.</param>
/// <param name="expected">The expected normalized direction.</param>
[Theory]
[InlineData("rising", "rising")]
[InlineData("RISING", "rising")]
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class DarkModeToggleTests : BunitContext
{
/// <summary>Initializes the bUnit context with loose JavaScript interop for the dark mode toggle.</summary>
public DarkModeToggleTests()
{
// Loose mode: unconfigured interop returns default. We configure the two
@@ -19,6 +20,7 @@ public class DarkModeToggleTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Verifies that the toggle renders a button with the toggle dark mode aria label.</summary>
[Fact]
public void Renders_Button_WithToggleDarkModeAriaLabel()
{
@@ -30,6 +32,7 @@ public class DarkModeToggleTests : BunitContext
Assert.NotNull(button);
}
/// <summary>Verifies that a persisted light preference renders the button with aria-pressed false on first render.</summary>
[Fact]
public void OnFirstRender_PersistedLight_AriaPressedFalse()
{
@@ -44,6 +47,7 @@ public class DarkModeToggleTests : BunitContext
Assert.NotNull(cut.Find("i.bi-moon-stars"));
}
/// <summary>Verifies that a persisted dark preference renders the button with aria-pressed true on first render.</summary>
[Fact]
public void OnFirstRender_PersistedDark_AriaPressedTrue()
{
@@ -61,6 +65,7 @@ public class DarkModeToggleTests : BunitContext
});
}
/// <summary>Verifies that clicking the button invokes the toggle and reflects the new state.</summary>
[Fact]
public void Click_InvokesToggle_AndReflectsNewState()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-016. <c>DataTable</c> looped
/// Regression tests for the <c>DataTable</c> pager, which looped
/// <c>for i = 1..totalPages</c> and emitted one numbered <c>&lt;li&gt;</c>
/// button per page; a few thousand records at page size 25 rendered hundreds
/// of buttons into the diff on every state change. The fix windows the pager
@@ -27,6 +27,7 @@ public class DataTablePagerTests : BunitContext
=> cut.FindAll("ul.pagination li.page-item button")
.Count(b => int.TryParse(b.TextContent.Trim(), out _));
/// <summary>Verifies that a pager with thousands of pages renders a window of buttons rather than every page.</summary>
[Fact]
public void Pager_WithThousandsOfPages_RendersWindowedNotEveryPage()
{
@@ -40,6 +41,7 @@ public class DataTablePagerTests : BunitContext
$"Expected a windowed pager (<= 12 numbered buttons) but rendered {numbered}.");
}
/// <summary>Verifies that a pager over a small dataset still renders a button for every page.</summary>
[Fact]
public void Pager_SmallDataset_StillRendersEveryPage()
{
@@ -51,6 +53,7 @@ public class DataTablePagerTests : BunitContext
Assert.Equal(5, numbered);
}
/// <summary>Verifies that the windowed pager always includes the first and last pages around the current page.</summary>
[Fact]
public void Pager_WindowedAroundCurrentPage_AlwaysIncludesFirstAndLast()
{
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class DateTimeRangeFilterTests : BunitContext
{
/// <summary>Verifies that the filter renders two inputs with prefixed ids and test hooks.</summary>
[Fact]
public void Renders_TwoInputs_WithPrefixedIdsAndTestHooks()
{
@@ -26,6 +27,7 @@ public class DateTimeRangeFilterTests : BunitContext
Assert.Equal("sc-to", toInput.GetAttribute("id"));
}
/// <summary>Verifies that the filter renders its labels.</summary>
[Fact]
public void RendersLabels()
{
@@ -40,6 +42,7 @@ public class DateTimeRangeFilterTests : BunitContext
Assert.Contains("End", cut.Markup);
}
/// <summary>Verifies that setting the from input invokes the from-changed callback with the parsed value.</summary>
[Fact]
public void SettingFromInput_InvokesFromChanged_WithParsedValue()
{
@@ -59,6 +62,7 @@ public class DateTimeRangeFilterTests : BunitContext
Assert.Equal(30, captured.Value.Minute);
}
/// <summary>Verifies that clearing the from input invokes the from-changed callback with null.</summary>
[Fact]
public void ClearingFromInput_InvokesFromChanged_WithNull()
{
@@ -74,6 +78,7 @@ public class DateTimeRangeFilterTests : BunitContext
Assert.Null(captured);
}
/// <summary>Verifies that an initial value is rendered in the input.</summary>
[Fact]
public void InitialValue_RendersInInput()
{
@@ -22,6 +22,7 @@ public class DialogHostShowAsyncTests : BunitContext
{
private readonly DialogService _service = new();
/// <summary>Initializes the test class and registers the dialog services.</summary>
public DialogHostShowAsyncTests()
{
// Register the concrete service as the interface so DialogHost (which
@@ -32,6 +33,8 @@ public class DialogHostShowAsyncTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Verifies that ShowAsync renders a custom body and resolves the typed result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShowAsync_RendersCustomBody_AndResolvesTypedResult()
{
@@ -68,6 +71,8 @@ public class DialogHostShowAsyncTests : BunitContext
Assert.Equal("done", await pending);
}
/// <summary>Verifies that cancelling resolves ShowAsync with null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShowAsync_CancelResolvesNull()
{
@@ -94,6 +99,8 @@ public class DialogHostShowAsyncTests : BunitContext
Assert.Null(await pending);
}
/// <summary>Verifies that pressing Escape resolves ShowAsync with null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShowAsync_EscapeResolvesNull()
{
@@ -119,6 +126,8 @@ public class DialogHostShowAsyncTests : BunitContext
Assert.Null(await pending);
}
/// <summary>Verifies that ShowAsync applies the size class to the modal dialog.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShowAsync_AppliesSizeClassToModalDialog()
{
@@ -137,6 +146,8 @@ public class DialogHostShowAsyncTests : BunitContext
Assert.Contains("modal-lg", dialog.ClassList);
}
/// <summary>Verifies that the backdrop carries the token hook class.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Backdrop_HasTokenHookClass()
{
@@ -157,6 +168,8 @@ public class DialogHostShowAsyncTests : BunitContext
Assert.Contains("sb-modal-backdrop", backdrop.ClassList);
}
/// <summary>Verifies that Confirm still resolves true, with no regression.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Confirm_StillResolvesTrue_NoRegression()
{
@@ -3,8 +3,8 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Characterization tests for CentralUI-015 (re-triaged Won't Fix — see
/// findings.md). The finding claimed <c>ContinueWith(..., TaskScheduler.Default)</c>
/// Characterization tests for dialog service threading (a re-triaged
/// Won't Fix finding). The finding claimed <c>ContinueWith(..., TaskScheduler.Default)</c>
/// made callers resume off the render thread; that premise is incorrect — an
/// <c>await</c> always resumes on the awaiter's own captured
/// <see cref="SynchronizationContext"/> regardless of where the awaited task
@@ -25,6 +25,7 @@ public class DialogServiceThreadingTests
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
public int PostedCount;
/// <summary>Initializes a new instance of the <c>TrackingSyncContext</c> class.</summary>
public TrackingSyncContext()
{
_thread = new Thread(() =>
@@ -38,15 +39,19 @@ public class DialogServiceThreadingTests
_thread.Start();
}
/// <inheritdoc />
public override void Post(SendOrPostCallback d, object? state)
{
Interlocked.Increment(ref PostedCount);
_queue.Add((d, state));
}
/// <summary>Marks the synchronization context as complete.</summary>
public void Complete() => _queue.CompleteAdding();
}
/// <summary>Verifies that the confirm dialog's awaiter resumes on its captured synchronization context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfirmAsync_AwaiterResumesOnItsCapturedSyncContext()
{
@@ -82,6 +87,8 @@ public class DialogServiceThreadingTests
Assert.NotEqual(Environment.CurrentManagedThreadId, resumeThreadId);
}
/// <summary>Verifies that the confirm dialog resolves with the expected value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfirmAsync_ResolvesWithExpectedValue()
{
@@ -93,6 +100,8 @@ public class DialogServiceThreadingTests
Assert.True(await task);
}
/// <summary>Verifies that the prompt dialog resolves with the entered value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PromptAsync_ResolvesWithExpectedValue()
{
@@ -104,6 +113,8 @@ public class DialogServiceThreadingTests
Assert.Equal("typed value", await task);
}
/// <summary>Verifies that a cancelled prompt dialog resolves to null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PromptAsync_CancelledResolvesToNull()
{
@@ -28,6 +28,7 @@ public class DialogSeverityStylingTests : BunitContext
{
private readonly DialogService _dialog = new();
/// <summary>Initializes the test context with the dialog service and its dependencies.</summary>
public DialogSeverityStylingTests()
{
Services.AddSingleton<IDialogService>(_dialog);
@@ -52,10 +53,11 @@ public class DialogSeverityStylingTests : BunitContext
return footer.Single(b => b.TextContent.Trim() != "Cancel");
}
/// <summary>Verifies that a caution dialog renders amber, neither red nor primary.</summary>
[Fact]
public void Caution_rendersAmber_notRedAndNotPrimary()
{
// The row-75 case. Amber is the point: not red (which reads "delete"), and not
// The caution case. Amber is the point: not red (which reads "delete"), and not
// the primary blue (which reads "safe default" — wrong for a live device write).
var confirm = OpenConfirm(d => d.ConfirmAsync("Approve thing", "Sure?", DialogSeverity.Caution, "Approve"));
@@ -64,6 +66,7 @@ public class DialogSeverityStylingTests : BunitContext
Assert.DoesNotContain("btn-primary", confirm.ClassList);
}
/// <summary>Verifies that a destructive dialog still renders red.</summary>
[Fact]
public void Destructive_stillRendersRed()
{
@@ -74,6 +77,7 @@ public class DialogSeverityStylingTests : BunitContext
Assert.Contains("btn-danger", confirm.ClassList);
}
/// <summary>Verifies that a normal dialog still renders with the primary style.</summary>
[Fact]
public void Normal_stillRendersPrimary()
{
@@ -82,6 +86,9 @@ public class DialogSeverityStylingTests : BunitContext
Assert.Contains("btn-primary", confirm.ClassList);
}
/// <summary>Verifies that the boolean danger form maps onto the same two severities.</summary>
/// <param name="danger">Whether the dialog is requested as dangerous.</param>
/// <param name="expectedClass">The button CSS class the dialog is expected to render.</param>
[Theory]
[InlineData(true, "btn-danger")]
[InlineData(false, "btn-primary")]
@@ -99,6 +106,7 @@ public class DialogSeverityStylingTests : BunitContext
Assert.Contains(expectedClass, confirm.ClassList);
}
/// <summary>Verifies that a caution dialog without a label reads Confirm rather than Delete.</summary>
[Fact]
public void UnlabelledCaution_readsConfirm_notDelete()
{
@@ -110,10 +118,11 @@ public class DialogSeverityStylingTests : BunitContext
Assert.Equal("Confirm", confirm.TextContent.Trim());
}
/// <summary>Verifies that a destructive dialog without a label still falls back to Delete.</summary>
[Fact]
public void UnlabelledDestructive_stillFallsBackToDelete()
{
// The row-28 fallback is retained deliberately (the Playwright suites click the
// The "Delete" fallback is retained deliberately (the Playwright suites click the
// literal "Delete"). Pinned here alongside its new sibling so the pair is read
// together.
var confirm = OpenConfirm(d => d.ConfirmAsync("Delete thing", "Sure?", DialogSeverity.Destructive));
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-011. <c>DiffDialog.OpenAsync</c> returns the
/// Regression tests for disposing <c>DiffDialog</c> while it is open. <c>DiffDialog.OpenAsync</c> returns the
/// <c>TaskCompletionSource</c>'s task, completed only by <c>Close()</c>. If the
/// user navigated away while the dialog was open, <c>DisposeAsync</c> ran but
/// never completed the TCS — the awaiting caller was suspended forever and any
@@ -19,13 +19,15 @@ public class DiffDialogTests : BunitContext
/// so a path that <c>await</c>s them (e.g. <c>DisposeAsync</c> →
/// <c>TryUnlockBodyAsync</c>) resumes instead of hanging on a never-completed
/// planned invocation, and no strict-mode unplanned-invocation exception
/// surfaces through the narrowed CentralUI-023 catch blocks.
/// surfaces through the narrowed catch blocks.
/// </summary>
private void SetupBodyLockInterop()
{
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Verifies that disposing the dialog while it is open completes the pending task.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DisposeAsync_WhileOpen_CompletesPendingTask()
{
@@ -52,6 +54,8 @@ public class DiffDialogTests : BunitContext
Assert.False(result, "Dismiss-on-dispose should resolve to false (not confirmed).");
}
/// <summary>Verifies that closing the dialog completes the pending task with true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Close_CompletesPendingTaskWithTrue()
{
@@ -10,6 +10,7 @@ public class DurationInputTests
{
// ── Split: TimeSpan -> (value, unit) ───────────────────────────────────
/// <summary>Verifies that splitting a null duration returns a blank value with the seconds unit.</summary>
[Fact]
public void Split_Null_ReturnsBlankWithSecondsUnit()
{
@@ -19,6 +20,7 @@ public class DurationInputTests
Assert.Equal("sec", unit);
}
/// <summary>Verifies that splitting a zero duration returns a blank value.</summary>
[Fact]
public void Split_Zero_ReturnsBlank()
{
@@ -27,6 +29,7 @@ public class DurationInputTests
Assert.Null(value);
}
/// <summary>Verifies that a whole number of minutes splits into the minute unit.</summary>
[Fact]
public void Split_WholeMinutes_UsesMinuteUnit()
{
@@ -36,6 +39,7 @@ public class DurationInputTests
Assert.Equal("min", unit);
}
/// <summary>Verifies that a whole number of seconds splits into the second unit.</summary>
[Fact]
public void Split_WholeSeconds_UsesSecondUnit()
{
@@ -45,6 +49,7 @@ public class DurationInputTests
Assert.Equal("sec", unit);
}
/// <summary>Verifies that a sub-second duration splits into the millisecond unit.</summary>
[Fact]
public void Split_SubSecond_UsesMillisecondUnit()
{
@@ -56,32 +61,40 @@ public class DurationInputTests
// ── Compose: (value, unit) -> TimeSpan? ────────────────────────────────
/// <summary>Verifies that composing a blank value returns null.</summary>
[Fact]
public void Compose_Blank_ReturnsNull() =>
Assert.Null(DurationInput.Compose(null, "sec"));
/// <summary>Verifies that composing a zero value returns null.</summary>
[Fact]
public void Compose_Zero_ReturnsNull() =>
Assert.Null(DurationInput.Compose("0", "sec"));
/// <summary>Verifies that composing a negative value returns null.</summary>
[Fact]
public void Compose_Negative_ReturnsNull() =>
Assert.Null(DurationInput.Compose("-5", "sec"));
/// <summary>Verifies that composing a seconds value builds the matching duration.</summary>
[Fact]
public void Compose_SecondsValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromSeconds(30), DurationInput.Compose("30", "sec"));
/// <summary>Verifies that composing a minutes value builds the matching duration.</summary>
[Fact]
public void Compose_MinutesValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromMinutes(5), DurationInput.Compose("5", "min"));
/// <summary>Verifies that composing a milliseconds value builds the matching duration.</summary>
[Fact]
public void Compose_MillisecondsValue_BuildsDuration() =>
Assert.Equal(TimeSpan.FromMilliseconds(250), DurationInput.Compose("250", "ms"));
// ── Round-trip ─────────────────────────────────────────────────────────
/// <summary>Verifies that splitting and then composing a duration preserves it.</summary>
/// <param name="milliseconds">The duration in milliseconds.</param>
[Theory]
[InlineData(250)]
[InlineData(30000)]
@@ -48,6 +48,7 @@ public class FlowGraphViewTests : BunitContext
return FlowGraphBuilder.Build(events);
}
/// <summary>Verifies that laying out the Delmia graph assigns the expected lanes and columns.</summary>
[Fact]
public void Layout_DelmiaGraph_AssignsExpectedLanesAndColumns()
{
@@ -79,6 +80,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(FlowGraphView.ColumnOrigin + 7 * FlowGraphView.ColumnPitch + FlowGraphView.RightPad, layout.Width);
}
/// <summary>Verifies that independent sibling nodes in one lane never share a column.</summary>
[Fact]
public void Layout_IndependentSiblingsInOneLane_NeverShareAColumn()
{
@@ -102,6 +104,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(siteCols.Distinct().Count(), siteCols.Count);
}
/// <summary>Verifies that the selected node renders with the selected CSS class.</summary>
[Fact]
public void Render_SelectedNode_CarriesSelectedClass()
{
@@ -121,6 +124,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("0", only.GetAttribute("tabindex"));
}
/// <summary>Verifies that clicking a node raises the selection callback with its step.</summary>
[Fact]
public void Render_ClickingANode_RaisesOnSelectWithTheStep()
{
@@ -137,6 +141,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(FlowStepKind.ScriptRun, picked!.Kind);
}
/// <summary>Verifies that an empty graph renders the lanes and a no-steps note.</summary>
[Fact]
public void Render_EmptyGraph_ShowsLanesAndNoStepsNote()
{
@@ -150,6 +155,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Contains("External", cut.Markup);
}
/// <summary>Verifies that a prediction-only graph draws dashed predicted nodes and edges.</summary>
[Fact]
public void Render_PredictionOnly_DrawsDashedPredictedNodesAndEdges()
{
@@ -166,6 +172,7 @@ public class FlowGraphViewTests : BunitContext
Assert.DoesNotContain(nodes, n => n.ClassList.Contains("ghost"));
}
/// <summary>Verifies that a graph with a prediction greys out predicted nodes the run never produced.</summary>
[Fact]
public void Render_GraphWithPrediction_GreysPredictedNodesTheRunNeverProduced()
{
@@ -193,6 +200,7 @@ public class FlowGraphViewTests : BunitContext
Assert.True(int.Parse(ghost.GetAttribute("data-column")!) > realMax);
}
/// <summary>Verifies that when two route calls are predicted and only the second really ran, the overlay matches it by title and leaves the first as the ghost.</summary>
[Fact]
public void Overlay_TwoPredictedRouteCalls_RealSecondCallMatchesByTitle_FirstIsTheGhost()
{
@@ -217,6 +225,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("A.X", ghost.Predicted?.Title);
}
/// <summary>Verifies that rendered coordinates stay culture-invariant under the German culture.</summary>
[Fact]
public void Render_UnderGermanCulture_CoordinatesStayInvariant()
{
@@ -245,6 +254,9 @@ public class FlowGraphViewTests : BunitContext
// ── Node text readability (2026-09-06): shared-prefix stripping, width-based clipping, tooltips ──
/// <summary>Verifies that the common dotted prefix factors out only a shared dotted head that leaves every tail non-empty.</summary>
/// <param name="titles">The titles.</param>
/// <param name="expected">The expected.</param>
[Theory]
[InlineData(new[] { "DelmiaReceiver.RecipeDownloadFlag", "DelmiaReceiver.RecipeProcessedFlag" }, "DelmiaReceiver.")]
[InlineData(new[] { "A.B.C.X", "A.B.C.Y", "A.B.D.Z" }, "A.B.")]
@@ -258,6 +270,9 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(expected, FlowGraphView.CommonDottedPrefix(titles));
}
/// <summary>Verifies that wrap hints mark camel-case and separator boundaries.</summary>
/// <param name="input">The input.</param>
/// <param name="expected">The expected.</param>
[Theory]
[InlineData("DelmiaRecipeDownload", "Delmia\u200BRecipe\u200BDownload")]
[InlineData("DelmiaSim01.ProcessRecipeDownload", "Delmia\u200BSim01.\u200BProcess\u200BRecipe\u200BDownload")]
@@ -270,6 +285,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(expected, FlowGraphView.WrapHints(input));
}
/// <summary>Verifies that stripping the prefix removes the shared head from the title and from each subtitle item.</summary>
[Fact]
public void StripPrefix_RemovesTheHeadFromTheTitleAndFromEachSubtitleItem()
{
@@ -280,6 +296,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("kept", FlowGraphView.StripPrefix("kept", null));
}
/// <summary>Verifies that the tags lane layout factors the shared dotted head into the gutter.</summary>
[Fact]
public void Layout_TagsLane_FactorsTheSharedDottedHeadIntoTheGutter()
{
@@ -296,6 +313,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(wait.Title, wait.DisplayTitle);
}
/// <summary>Verifies that predicted-only nodes never get a lane prefix.</summary>
[Fact]
public void Layout_PredictedOnlyNodes_NeverGetALanePrefix()
{
@@ -307,6 +325,7 @@ public class FlowGraphViewTests : BunitContext
Assert.All(layout.LanePrefixes, p => Assert.Equal(string.Empty, p));
}
/// <summary>Verifies that a node body renders as HTML in a foreign object with a stripped title and a full-text tooltip.</summary>
[Fact]
public void Render_NodeBodyIsHtmlInAForeignObject_WithStrippedTitleAndFullTextTooltip()
{
@@ -378,6 +397,7 @@ public class FlowGraphViewTests : BunitContext
return FlowGraphBuilder.Build(events);
}
/// <summary>Verifies that outbound steps land in the external lane with a target sublabel.</summary>
[Fact]
public void Layout_OutboundSteps_LandInTheExternalLane_WithATargetSublabel()
{
@@ -394,6 +414,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Single(layout.Edges, e => e.Spawn);
}
/// <summary>Verifies that the external lane sublabel lists three targets and then switches to counts.</summary>
[Fact]
public void Layout_ExternalSublabel_ListsThreeTargetsThenCounts()
{
@@ -402,6 +423,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("Delmia.AckRecipe · MesDb · Planners +1", layout.LaneSublabels[(int)FlowLane.External]);
}
/// <summary>Verifies that an API call node renders an eyebrow and an OK pill with the sync mode.</summary>
[Fact]
public void Render_ApiCall_HasEyebrowAndOkPillWithSyncMode()
{
@@ -424,6 +446,7 @@ public class FlowGraphViewTests : BunitContext
Assert.NotNull(run.QuerySelector("[data-test='flow-node-subtitle']"));
}
/// <summary>Verifies that a pending cached call renders the pending class and pill.</summary>
[Fact]
public void Render_PendingCachedCall_HasPendingClassAndPill()
{
@@ -439,6 +462,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(string.Empty, db.QuerySelector(".fg-duration")!.TextContent.Trim());
}
/// <summary>Verifies that a parked notification renders the parked class and the submit-to-terminal duration.</summary>
[Fact]
public void Render_ParkedNotification_HasParkedClass_AndSubmitToTerminalDuration()
{
@@ -455,6 +479,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("+9 s", notify.QuerySelector(".fg-duration")!.TextContent.Trim());
}
/// <summary>Verifies that async edges render dashed, and a spawn edge keeps its arrowhead and gets a bolt badge.</summary>
[Fact]
public void Render_AsyncEdgesAreDashedClass_SpawnEdgeKeepsAnArrowheadAndGetsABoltBadge()
{
@@ -487,6 +512,7 @@ public class FlowGraphViewTests : BunitContext
Assert.InRange(y, Math.Min(a.Y, b.Y) + FlowGraphView.NodeHeight / 2, Math.Max(a.Y, b.Y) + FlowGraphView.NodeHeight / 2);
}
/// <summary>Verifies that the spawn badge point sits on the vertical run of each connector shape.</summary>
[Fact]
public void SpawnBadgePoint_SitsOnTheVerticalRunOfEachConnectorShape()
{
@@ -517,6 +543,10 @@ public class FlowGraphViewTests : BunitContext
Assert.InRange(ay, siteRun.Y + h, external.Y - FlowGraphView.LanePad / 2);
}
/// <summary>Verifies that the outbound mode text drops the status the pill already carries and names the call mode.</summary>
/// <param name="kind">The kind.</param>
/// <param name="subtitle">The subtitle.</param>
/// <param name="expected">The expected.</param>
[Theory]
[InlineData(FlowStepKind.ApiCall, "Delivered · sync", "sync")]
[InlineData(FlowStepKind.ApiCall, "Failed · sync", "sync")]
@@ -532,6 +562,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(expected, FlowGraphView.OutboundMode(kind, subtitle));
}
/// <summary>Verifies that predicted outbound nodes render as ghosts without pills.</summary>
[Fact]
public void Render_PredictedOutboundNodes_AreGhostsWithoutPills()
{
@@ -551,6 +582,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("API CALL", external[0].QuerySelector(".fg-eyebrow")!.TextContent.Trim());
}
/// <summary>Verifies that a predicted async edge renders with the async class.</summary>
[Fact]
public void PredictedAsyncEdge_RendersWithAsyncClass()
{
@@ -584,6 +616,7 @@ public class FlowGraphViewTests : BunitContext
return new FlowGraph([step], [], false, false);
}
/// <summary>Verifies that a step with no log lines renders no log badge.</summary>
[Fact]
public void Render_StepWithNoLogLines_HasNoLogBadge()
{
@@ -594,6 +627,7 @@ public class FlowGraphViewTests : BunitContext
Assert.NotNull(cut.Find("g[data-test='flow-node'] .fg-foot [data-test='flow-node-subtitle']"));
}
/// <summary>Verifies that a step with mixed log levels shows counts with the highest severity first and counts debug lines as info.</summary>
[Fact]
public void Render_StepWithMixedLevels_ShowsCountsHighestSeverityFirst_DebugCountedAsInfo()
{
@@ -610,6 +644,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("1 error, 2 warnings, 4 info", badge.GetAttribute("title"));
}
/// <summary>Verifies that a step with only info lines shows a single faint count.</summary>
[Fact]
public void Render_StepWithOnlyInfoLines_ShowsASingleFaintCount()
{
@@ -622,6 +657,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("2 info", badge.GetAttribute("title"));
}
/// <summary>Verifies that a failed step with error detail uses the exception type and message as its subtitle.</summary>
[Fact]
public void Render_FailedStepWithErrorDetail_SubtitleIsTheExceptionTypeAndMessage()
{
@@ -634,6 +670,7 @@ public class FlowGraphViewTests : BunitContext
cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
/// <summary>Verifies that a failed step without error detail falls back to the error message as its subtitle.</summary>
[Fact]
public void Render_FailedStepWithoutDetail_FallsBackToTheErrorMessage()
{
@@ -642,6 +679,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("timed out talking to MesDb", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
/// <summary>Verifies that an OK step carrying an error detail keeps its normal subtitle.</summary>
[Fact]
public void Render_OkStepWithAnErrorDetail_KeepsItsNormalSubtitle()
{
@@ -650,6 +688,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal("trigger: manual", cut.Find("[data-test='flow-node-subtitle']").TextContent.Trim());
}
/// <summary>Verifies that log counts fold levels case-insensitively and treat any unrecognized level as info.</summary>
[Fact]
public void LogCounts_FoldsLevelsCaseInsensitively_AndTreatsEverythingElseAsInfo()
{
@@ -660,6 +699,11 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal((2, 1, 3), FlowGraphView.LogCounts(step));
}
/// <summary>Verifies that the exception subtitle parses the first line of the detail only for a failed step.</summary>
/// <param name="outcome">The outcome.</param>
/// <param name="detail">The detail.</param>
/// <param name="message">The message.</param>
/// <param name="expected">The expected.</param>
[Theory]
[InlineData(FlowStepOutcome.Failed, "System.InvalidOperationException: Recipe 42 rejected\n at X", "boom", "InvalidOperationException: Recipe 42 rejected")]
[InlineData(FlowStepOutcome.TimedOut, "System.TimeoutException: took too long", "boom", "TimeoutException: took too long")]
@@ -685,6 +729,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(expected, FlowGraphView.ExceptionSubtitle(step));
}
/// <summary>Verifies that the log title names only the non-zero levels, using singular and plural forms correctly.</summary>
[Fact]
public void LogTitle_NamesOnlyTheNonZeroLevels_WithSingularAndPlural()
{
@@ -695,6 +740,7 @@ public class FlowGraphViewTests : BunitContext
Assert.Equal(string.Empty, FlowGraphView.LogTitle((0, 0, 0)));
}
/// <summary>Verifies that an outbound node with log lines shows both the pill and the log badge in its footer.</summary>
[Fact]
public void Render_OutboundNodeWithLogLines_ShowsBothThePillAndTheBadgeInTheFooter()
{
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class InstancePickerTests : BunitContext
{
/// <summary>Initializes the test context with loose JS interop.</summary>
public InstancePickerTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -69,6 +70,7 @@ public class InstancePickerTests : BunitContext
private static AngleSharp.Dom.IElement TreeItem(IRenderedComponent<InstancePicker> cut, int index) =>
cut.FindAll("[role='treeitem']")[index];
/// <summary>Verifies that a closed picker renders only the input field.</summary>
[Fact]
public void Closed_ShowsNothingButTheField()
{
@@ -81,6 +83,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("false", input.GetAttribute("aria-expanded"));
}
/// <summary>Verifies that toggling opens the panel with all areas collapsed.</summary>
[Fact]
public void Toggle_OpensPanel_WithAreasCollapsed()
{
@@ -102,6 +105,7 @@ public class InstancePickerTests : BunitContext
Assert.False(areaRow.HasAttribute("data-instance-id"));
}
/// <summary>Verifies that clicking an area label expands that area.</summary>
[Fact]
public void ClickingAnAreaLabel_ExpandsIt()
{
@@ -116,6 +120,7 @@ public class InstancePickerTests : BunitContext
Assert.Single(cut.FindAll("[data-instance-id='7']"));
}
/// <summary>Verifies that clicking an instance selects it, closes the panel and raises the selection callback.</summary>
[Fact]
public void ClickingAnInstance_Selects_Closes_AndRaisesTheCallback()
{
@@ -132,6 +137,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("P1", InputValue(cut));
}
/// <summary>Verifies that a disabled picker never opens.</summary>
[Fact]
public void Disabled_NeverOpens()
{
@@ -145,6 +151,7 @@ public class InstancePickerTests : BunitContext
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
/// <summary>Verifies that clicking the backdrop closes the panel without changing the selection.</summary>
[Fact]
public void Backdrop_ClickClosesThePanel_WithoutChangingSelection()
{
@@ -159,6 +166,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal(string.Empty, InputValue(cut));
}
/// <summary>Verifies that a selection set by the parent updates the field text.</summary>
[Fact]
public void ParentDrivenSelection_UpdatesTheFieldText()
{
@@ -171,6 +179,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal(string.Empty, InputValue(cut));
}
/// <summary>Verifies that supplying a new instances list after a site change clears the stale selection text.</summary>
[Fact]
public void SiteChange_NewInstancesList_ClearsAStaleSelectionText()
{
@@ -190,6 +199,7 @@ public class InstancePickerTests : BunitContext
Assert.Empty(cut.FindAll("[data-test='t-panel']"));
}
/// <summary>Verifies that the item label function is used for both the rows and the field text.</summary>
[Fact]
public void ItemLabel_IsUsedForRowsAndField()
{
@@ -207,6 +217,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("P1 (x)", InputValue(cut));
}
/// <summary>Verifies that typing filters the tree and expands it to the matches.</summary>
[Fact]
public void Typing_Filters_AndExpandsToTheMatches()
{
@@ -224,6 +235,7 @@ public class InstancePickerTests : BunitContext
Assert.DoesNotContain("Zulu", rows);
}
/// <summary>Verifies that clearing the filter restores the full tree in a collapsed state.</summary>
[Fact]
public void Clearing_RestoresTheFullTree_Collapsed()
{
@@ -239,6 +251,7 @@ public class InstancePickerTests : BunitContext
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
}
/// <summary>Verifies that typing opens a closed panel.</summary>
[Fact]
public void Typing_OpensTheClosedPanel()
{
@@ -251,6 +264,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal(new[] { "Zulu" }, Rows(cut));
}
/// <summary>Verifies that pressing Enter with no matching instance does nothing.</summary>
[Fact]
public void Enter_WithNoMatch_DoesNothing()
{
@@ -265,6 +279,7 @@ public class InstancePickerTests : BunitContext
Assert.Single(cut.FindAll("[data-test='t-panel']"));
}
/// <summary>Verifies that pressing Enter picks the first visible match.</summary>
[Fact]
public void Enter_PicksTheFirstVisibleMatch()
{
@@ -281,6 +296,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("Zulu", InputValue(cut));
}
/// <summary>Verifies that pressing Enter with no filter picks the first instance in document order.</summary>
[Fact]
public void Enter_WithNoFilter_PicksTheFirstInstanceInDocumentOrder()
{
@@ -296,6 +312,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("Zulu", InputValue(cut));
}
/// <summary>Verifies that pressing Escape closes the panel and restores the selected text.</summary>
[Fact]
public void Escape_ClosesAndRestoresTheSelectedText()
{
@@ -319,6 +336,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("P1", InputValue(cut));
}
/// <summary>Verifies that pressing ArrowDown moves focus into the tree.</summary>
[Fact]
public void ArrowDown_MovesFocusIntoTheTree()
{
@@ -345,6 +363,7 @@ public class InstancePickerTests : BunitContext
i => i.Identifier.Contains("focus", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that pressing ArrowDown on a closed picker opens the panel and focuses the first row.</summary>
[Fact]
public void ArrowDown_OnAClosedPicker_OpensThePanel_AndFocusesTheFirstRow()
{
@@ -359,6 +378,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("0", cut.FindAll("[role='treeitem']")[0].GetAttribute("tabindex"));
}
/// <summary>Verifies that pressing Escape on a closed picker has no effect.</summary>
[Fact]
public void Escape_WhenClosed_IsANoOp()
{
@@ -371,6 +391,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("Zulu", InputValue(cut));
}
/// <summary>Verifies that the filter text is not carried into the selection.</summary>
[Fact]
public void FilterIsNotCarriedIntoSelection()
{
@@ -390,6 +411,7 @@ public class InstancePickerTests : BunitContext
Assert.Empty(cut.FindAll("[data-instance-id='7']"));
}
/// <summary>Verifies that toggling while the panel is open closes it.</summary>
[Fact]
public void Toggle_WhileOpen_ClosesThePanel()
{
@@ -404,6 +426,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("false", cut.Find("[data-test='t-input']").GetAttribute("aria-expanded"));
}
/// <summary>Verifies that the field sits above the backdrop so it stays clickable while the panel is open.</summary>
[Fact]
public void TheFieldSitsAboveTheBackdrop_SoItStaysClickableWhileOpen()
{
@@ -419,6 +442,7 @@ public class InstancePickerTests : BunitContext
Assert.Single(cut.FindAll("[data-test='t-backdrop']"));
}
/// <summary>Verifies that two pickers on the same page get distinct panel ids.</summary>
[Fact]
public void TwoPickers_GetDistinctPanelIds()
{
@@ -439,6 +463,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal(secondPanel, second.Find("[data-test='t-input']").GetAttribute("aria-controls"));
}
/// <summary>Verifies that re-rendering with the same instances reference leaves the typed text and open panel unchanged.</summary>
[Fact]
public void SameInstancesReference_LeavesTheTypedTextAndOpenPanelAlone()
{
@@ -457,6 +482,7 @@ public class InstancePickerTests : BunitContext
// -- Keyboard activation on the tree itself --------------------------------
/// <summary>Verifies that keyboard activation on a tree row selects the instance.</summary>
[Fact]
public void KeyboardActivation_OnATreeRow_SelectsTheInstance()
{
@@ -475,6 +501,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("Zulu", InputValue(cut));
}
/// <summary>Verifies that keyboard activation on an area row toggles it.</summary>
[Fact]
public void KeyboardActivation_OnAnAreaRow_TogglesIt()
{
@@ -497,6 +524,7 @@ public class InstancePickerTests : BunitContext
Assert.DoesNotContain("Cell A", Rows(cut));
}
/// <summary>Verifies that keyboard activation followed by a mouse click raises exactly one selection.</summary>
[Fact]
public void KeyboardActivation_AndMouseClick_RaiseExactlyOneSelection()
{
@@ -516,6 +544,7 @@ public class InstancePickerTests : BunitContext
// -- Accessible name / labelling -------------------------------------------
/// <summary>Verifies that the input carries an accessible name by default and from the parameter.</summary>
[Fact]
public void Input_CarriesAnAccessibleName_ByDefaultAndFromTheParameter()
{
@@ -526,6 +555,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("Target instance", cut.Find("[data-test='t-input']").GetAttribute("aria-label"));
}
/// <summary>Verifies that the input id defaults to a generated id and honours the parameter.</summary>
[Fact]
public void InputId_DefaultsToAGeneratedId_AndHonoursTheParameter()
{
@@ -542,6 +572,7 @@ public class InstancePickerTests : BunitContext
Assert.Equal("flow-instance", first.Find("[data-test='t-input']").Id);
}
/// <summary>Verifies that aria-controls is rendered only while the panel is open.</summary>
[Fact]
public void AriaControls_IsRenderedOnlyWhileThePanelIsOpen()
{
@@ -15,6 +15,7 @@ public class InstanceTreeBuilderTests
private static Instance MakeInstance(int id, string uniqueName, int? areaId = null) =>
new(uniqueName) { Id = id, SiteId = 1, AreaId = areaId };
/// <summary>Verifies that areas are nested under their parents and root instances come after root areas.</summary>
[Fact]
public void Build_NestsAreasByParent_AndPutsRootInstancesAfterRootAreas()
{
@@ -54,6 +55,7 @@ public class InstanceTreeBuilderTests
Assert.Same(zulu, roots[2].Instance);
}
/// <summary>Verifies that areas and instances are sorted case-insensitively.</summary>
[Fact]
public void Build_SortsAreasAndInstancesCaseInsensitively()
{
@@ -67,6 +69,7 @@ public class InstanceTreeBuilderTests
Assert.Equal(new[] { "Alpha", "beta", "Apple", "zebra" }, roots.Select(n => n.Label));
}
/// <summary>Verifies that an area whose parent is missing becomes a root.</summary>
[Fact]
public void Build_OrphanedArea_BecomesARoot()
{
@@ -82,6 +85,7 @@ public class InstanceTreeBuilderTests
Assert.Equal("i:10", leaf.Key);
}
/// <summary>Verifies that an instance with an unknown area surfaces at the root.</summary>
[Fact]
public void Build_InstanceWithUnknownArea_SurfacesAtRoot()
{
@@ -96,6 +100,7 @@ public class InstanceTreeBuilderTests
Assert.Same(stranded, roots[1].Instance);
}
/// <summary>Verifies that an instance with an unknown area can still be found by the filter.</summary>
[Fact]
public void Build_InstanceWithUnknownArea_StaysFilterable()
{
@@ -108,6 +113,7 @@ public class InstanceTreeBuilderTests
Assert.Empty(InstanceTreeBuilder.Build([line1], [stranded], filter: "nomatch"));
}
/// <summary>Verifies that deeply nested areas wire their children at every level.</summary>
[Fact]
public void Build_DeepNesting_WiresChildrenAtEveryLevel()
{
@@ -129,6 +135,7 @@ public class InstanceTreeBuilderTests
Assert.False(leaf.HasChildren);
}
/// <summary>Verifies that cyclic parent area ids neither loop forever nor throw.</summary>
[Fact]
public void Build_CyclicParentAreaIds_DoNotLoopOrThrow()
{
@@ -144,6 +151,7 @@ public class InstanceTreeBuilderTests
Assert.Empty(roots);
}
/// <summary>Verifies that the filter matches instance names only, so an area name match alone is not enough.</summary>
[Fact]
public void Build_Filter_MatchesInstanceNamesOnly_AreaNameMatchIsNotEnough()
{
@@ -160,6 +168,7 @@ public class InstanceTreeBuilderTests
Assert.Equal("i:11", Assert.Single(root.Children).Key);
}
/// <summary>Verifies that the filter keeps matching instances with their ancestors and drops empty branches.</summary>
[Fact]
public void Build_Filter_KeepsMatchingInstancesWithAncestors_DropsEmptyBranches()
{
@@ -180,6 +189,7 @@ public class InstanceTreeBuilderTests
Assert.Equal("i:10", leaf.Key);
}
/// <summary>Verifies that a whitespace-only filter is treated as no filter.</summary>
[Fact]
public void Build_Filter_WhitespaceMeansNoFilter()
{
@@ -193,6 +203,7 @@ public class InstanceTreeBuilderTests
Assert.Single(roots[0].Children);
}
/// <summary>Verifies that null or empty inputs produce an empty tree.</summary>
[Fact]
public void Build_NullOrEmptyInputs_ReturnEmpty()
{
@@ -207,6 +218,7 @@ public class InstanceTreeBuilderTests
Assert.Equal("a:1", Assert.Single(areasOnly).Key);
}
/// <summary>Verifies that an area with no instances is still shown when no filter is applied.</summary>
[Fact]
public void Build_AreaWithNoInstancesAndNoFilter_IsStillShown()
{
@@ -18,37 +18,59 @@ using ParkedMessagesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Moni
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-023. <c>DiffDialog.TryLockBodyAsync</c> /
/// Regression tests for JS-interop failure logging. <c>DiffDialog.TryLockBodyAsync</c> /
/// <c>TryUnlockBodyAsync</c> and <c>ParkedMessages.CopyAsync</c> wrapped JS
/// interop in bare <c>catch { }</c> blocks: a genuine <see cref="JSException"/>
/// was indistinguishable from an expected <see cref="JSDisconnectedException"/>
/// and neither was logged. The fix narrows the catch and logs real interop
/// failures via <c>ILogger</c>, consistent with the CentralUI-018 fixes.
/// failures via <c>ILogger</c>, consistent with the earlier JS-interop logging fixes.
/// </summary>
public class JsInteropLoggingTests : BunitContext
{
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
/// <summary>Gets the log entries captured by the factory's loggers.</summary>
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
/// <summary>Creates a logger that records into the shared entry list.</summary>
/// <param name="categoryName">The logger category name, which is ignored.</param>
/// <returns>A logger that captures its entries.</returns>
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
/// <summary>Does nothing; there is nothing to release.</summary>
public void Dispose() { }
private sealed class CapturingLogger : ILogger
{
private readonly List<(LogLevel, string, Exception?)> _entries;
/// <summary>Initializes a new instance of the <c>CapturingLogger</c> class.</summary>
/// <param name="entries">The list that receives captured log entries.</param>
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
/// <summary>Returns no scope because the fake does not track scopes.</summary>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <param name="state">The scope or log state.</param>
/// <returns>A no-op scope.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports whether logging is enabled for the given level.</summary>
/// <param name="logLevel">The log level.</param>
/// <returns>True if the level is enabled; otherwise false.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the log entry for later assertions.</summary>
/// <typeparam name="TState">The type of the state object.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event id.</param>
/// <param name="state">The scope or log state.</param>
/// <param name="exception">The exception associated with the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message text.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
=> _entries.Add((logLevel, formatter(state, exception), exception));
}
}
/// <summary>Verifies that a genuine JavaScript exception while locking the diff dialog body is logged.</summary>
[Fact]
public void DiffDialog_BodyLock_GenuineJsException_IsLogged()
{
@@ -74,6 +96,7 @@ public class JsInteropLoggingTests : BunitContext
});
}
/// <summary>Verifies that a circuit disconnect while locking the diff dialog body is not logged.</summary>
[Fact]
public void DiffDialog_BodyLock_Disconnect_IsNotLogged()
{
@@ -93,6 +116,8 @@ public class JsInteropLoggingTests : BunitContext
Assert.DoesNotContain(provider.Entries, e => e.Level >= LogLevel.Warning);
}
/// <summary>Verifies that a genuine JavaScript exception while copying parked messages is logged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ParkedMessages_Copy_GenuineJsException_IsLogged()
{
@@ -135,7 +160,10 @@ public class JsInteropLoggingTests : 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);
}
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class KeysetPagerTests : BunitContext
{
/// <summary>Verifies that the Prev button is disabled when the pager cannot go back.</summary>
[Fact]
public void PrevDisabled_WhenCannotGoBack()
{
@@ -22,6 +23,7 @@ public class KeysetPagerTests : BunitContext
Assert.NotNull(prev.GetAttribute("disabled"));
}
/// <summary>Verifies that the Next button is disabled when there is no next page.</summary>
[Fact]
public void NextDisabled_WhenNoNextPage()
{
@@ -34,6 +36,7 @@ public class KeysetPagerTests : BunitContext
Assert.NotNull(next.GetAttribute("disabled"));
}
/// <summary>Verifies that clicking Prev invokes the OnPrevious callback.</summary>
[Fact]
public void Prev_Click_InvokesOnPrevious()
{
@@ -49,6 +52,7 @@ public class KeysetPagerTests : BunitContext
Assert.True(fired);
}
/// <summary>Verifies that clicking Next invokes the OnNext callback.</summary>
[Fact]
public void Next_Click_InvokesOnNext()
{
@@ -64,6 +68,7 @@ public class KeysetPagerTests : BunitContext
Assert.True(fired);
}
/// <summary>Verifies that the summary renders the page number and row count.</summary>
[Fact]
public void Summary_RendersPageAndRows()
{
@@ -77,6 +82,7 @@ public class KeysetPagerTests : BunitContext
Assert.Contains("42 rows", summary.TextContent);
}
/// <summary>Verifies that both buttons are disabled when the pager itself is disabled.</summary>
[Fact]
public void BothDisabled_WhenDisabled()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-018. <c>MonacoEditor</c> wrapped every JS
/// Regression tests for <c>MonacoEditor</c>, which wrapped every JS
/// interop call in a bare <c>try { ... } catch { }</c> with no logging — a
/// genuine Monaco init failure became invisible. The fix narrows the catch to
/// the expected prerender / disconnect cases and logs any real
@@ -18,25 +18,47 @@ public class MonacoEditorLoggingTests : BunitContext
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
/// <summary>Gets the log entries captured so far.</summary>
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
/// <summary>Creates a logger that appends to the shared entry list.</summary>
/// <param name="categoryName">The logger category name (ignored).</param>
/// <returns>A capturing logger.</returns>
public ILogger CreateLogger(string categoryName) => new CapturingLogger(Entries);
/// <summary>Does nothing; the provider holds no resources.</summary>
public void Dispose() { }
private sealed class CapturingLogger : ILogger
{
private readonly List<(LogLevel, string, Exception?)> _entries;
/// <summary>Initializes the logger over the shared entry list.</summary>
/// <param name="entries">The list that receives each captured log entry.</param>
public CapturingLogger(List<(LogLevel, string, Exception?)> entries) => _entries = entries;
/// <summary>Returns no scope; scopes are not captured.</summary>
/// <typeparam name="TState">The scope state type.</typeparam>
/// <param name="state">The scope state (ignored).</param>
/// <returns>Always null.</returns>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <summary>Reports every log level as enabled.</summary>
/// <param name="logLevel">The log level being queried.</param>
/// <returns>Always true.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Records the formatted log entry for later assertions.</summary>
/// <typeparam name="TState">The log state type.</typeparam>
/// <param name="logLevel">The entry's log level.</param>
/// <param name="eventId">The entry's event id.</param>
/// <param name="state">The entry's state.</param>
/// <param name="exception">The exception attached to the entry, if any.</param>
/// <param name="formatter">Formats the state and exception into the message.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state,
Exception? exception, Func<TState, Exception?, string> formatter)
=> _entries.Add((logLevel, formatter(state, exception), exception));
}
}
/// <summary>Verifies that a genuine JavaScript exception during editor creation is logged rather than swallowed.</summary>
[Fact]
public void CreateEditor_GenuineJsException_IsLogged_NotSwallowed()
{
@@ -58,6 +80,7 @@ public class MonacoEditorLoggingTests : BunitContext
Assert.Contains(errors, e => e.Exception is JSException);
}
/// <summary>Verifies that editor creation during prerender does not log anything.</summary>
[Fact]
public void CreateEditor_Prerender_DoesNotLog()
{
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// bUnit tests for the <see cref="MoveDataConnectionDialog"/> body component
/// (M9-T24b, migrated to the DialogService.ShowAsync host in T33b). The dialog is
/// (migrated to the DialogService.ShowAsync host). The dialog is
/// now opened through <see cref="IDialogService.ShowAsync{TResult}"/>: a single
/// <c>DialogHost</c> owns the backdrop/header/focus-trap, and the body renders only
/// the target-site picker (excluding the connection's current site) plus its own
@@ -33,6 +33,7 @@ public class MoveDataConnectionDialogTests : BunitContext
private readonly IDataConnectionMoveService _service = Substitute.For<IDataConnectionMoveService>();
private readonly DialogService _dialog = new();
/// <summary>Initializes a new instance of the <c>MoveDataConnectionDialogTests</c> class.</summary>
public MoveDataConnectionDialogTests()
{
Services.AddSingleton(_service);
@@ -74,6 +75,7 @@ public class MoveDataConnectionDialogTests : BunitContext
return (host, pending);
}
/// <summary>Verifies that the dialog shown through the host renders the target site picker with the supplied options.</summary>
[Fact]
public void ShownViaHost_RendersTargetSitePicker_WithSuppliedOptions()
{
@@ -89,6 +91,7 @@ public class MoveDataConnectionDialogTests : BunitContext
Assert.Contains("PLC-1", cut.Markup);
}
/// <summary>Verifies that confirming dispatches the move command with the connection and target site ids.</summary>
[Fact]
public void Confirm_DispatchesMoveCommand_WithConnectionAndTargetSiteIds()
{
@@ -107,6 +110,7 @@ public class MoveDataConnectionDialogTests : BunitContext
_service.Received(1).MoveAsync(100, 3, Arg.Any<CancellationToken>());
}
/// <summary>Verifies that a guard error is shown inline and the dialog stays open.</summary>
[Fact]
public void GuardError_IsShownInline_AndDialogStaysOpen()
{
@@ -129,6 +133,8 @@ public class MoveDataConnectionDialogTests : BunitContext
Assert.NotNull(cut.Find("[data-test='move-connection-error']"));
}
/// <summary>Verifies that a successful move closes the dialog and resolves true.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Success_ClosesDialog_AndResolvesTrue()
{
@@ -13,6 +13,7 @@ public class OffsetPagerTests : BunitContext
{
// ── PrevDisabled_OnFirstPage ──────────────────────────────────────────────
/// <summary>Verifies that the previous button is disabled on the first page.</summary>
[Fact]
public void PrevDisabled_OnFirstPage()
{
@@ -27,6 +28,7 @@ public class OffsetPagerTests : BunitContext
// ── NextDisabled_WhenNoNextPage ───────────────────────────────────────────
/// <summary>Verifies that the next button is disabled when there is no next page.</summary>
[Fact]
public void NextDisabled_WhenNoNextPage()
{
@@ -41,6 +43,7 @@ public class OffsetPagerTests : BunitContext
// ── Next_Click_EmitsPagePlusOne ───────────────────────────────────────────
/// <summary>Verifies that clicking next emits the current page plus one.</summary>
[Fact]
public void Next_Click_EmitsPagePlusOne()
{
@@ -58,6 +61,7 @@ public class OffsetPagerTests : BunitContext
// ── Prev_Click_EmitsPageMinusOne ─────────────────────────────────────────
/// <summary>Verifies that clicking previous emits the current page minus one.</summary>
[Fact]
public void Prev_Click_EmitsPageMinusOne()
{
@@ -75,6 +79,7 @@ public class OffsetPagerTests : BunitContext
// ── Summary_RendersPageOfTotal ────────────────────────────────────────────
/// <summary>Verifies that the summary renders the current page out of the total.</summary>
[Fact]
public void Summary_RendersPageOfTotal()
{
@@ -92,6 +97,7 @@ public class OffsetPagerTests : BunitContext
// ── HideWhenSinglePage_HidesBar_OnSinglePage ──────────────────────────────
/// <summary>Verifies that hide-when-single-page hides the bar when there is only one page.</summary>
[Fact]
public void HideWhenSinglePage_HidesBar_OnSinglePage()
{
@@ -109,6 +115,7 @@ public class OffsetPagerTests : BunitContext
// ── HideWhenSinglePage_HidesBar_OnEmptyResult ─────────────────────────────
/// <summary>Verifies that hide-when-single-page hides the bar when the result is empty.</summary>
[Fact]
public void HideWhenSinglePage_HidesBar_OnEmptyResult()
{
@@ -124,6 +131,7 @@ public class OffsetPagerTests : BunitContext
// ── HideWhenSinglePage_ShowsBar_WhenMultiplePages ─────────────────────────
/// <summary>Verifies that hide-when-single-page shows the bar when there are multiple pages.</summary>
[Fact]
public void HideWhenSinglePage_ShowsBar_WhenMultiplePages()
{
@@ -141,6 +149,7 @@ public class OffsetPagerTests : BunitContext
// TotalCount is null (the host cannot count), so single-page-ness is not
// provable — the bar must stay so the user can still reach a further page.
/// <summary>Verifies that hide-when-single-page shows the bar when the total count is unknown.</summary>
[Fact]
public void HideWhenSinglePage_ShowsBar_WhenTotalCountUnknown()
{
@@ -157,6 +166,7 @@ public class OffsetPagerTests : BunitContext
// Default (opt-out) behaviour: consumers that use the summary as their only
// "N total" readout keep the bar on a single page.
/// <summary>Verifies that with hide-when-single-page off the bar stays visible on a single page.</summary>
[Fact]
public void HideWhenSinglePage_Off_KeepsBarVisible_OnSinglePage()
{
@@ -171,6 +181,7 @@ public class OffsetPagerTests : BunitContext
// ── BothDisabled_WhenDisabled ─────────────────────────────────────────────
/// <summary>Verifies that both navigation buttons are disabled when the pager is disabled.</summary>
[Fact]
public void BothDisabled_WhenDisabled()
{
@@ -4,11 +4,12 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Unit tests for the <see cref="PagerWindow"/> helper introduced for
/// CentralUI-016 — windowed pagination that keeps the rendered button count
/// windowed pagination that keeps the rendered button count
/// bounded regardless of total page count.
/// </summary>
public class PagerWindowTests
{
/// <summary>Verifies that a small page count returns every page with no ellipsis.</summary>
[Fact]
public void Build_SmallPageCount_ReturnsEveryPage_NoEllipsis()
{
@@ -17,6 +18,7 @@ public class PagerWindowTests
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, pages);
}
/// <summary>Verifies that a large page count yields a bounded window that includes the first and last pages.</summary>
[Fact]
public void Build_LargePageCount_IsBounded_AndIncludesFirstAndLast()
{
@@ -29,6 +31,7 @@ public class PagerWindowTests
Assert.True(pages.Count <= 12, $"Expected a bounded window but got {pages.Count} entries.");
}
/// <summary>Verifies that a large page count inserts an ellipsis for each gap in the window.</summary>
[Fact]
public void Build_LargePageCount_InsertsEllipsisForGaps()
{
@@ -38,6 +41,7 @@ public class PagerWindowTests
Assert.Contains(0, pages);
}
/// <summary>Verifies that a current page near the start produces no leading ellipsis.</summary>
[Fact]
public void Build_CurrentNearStart_NoLeadingEllipsis()
{
@@ -48,6 +52,7 @@ public class PagerWindowTests
Assert.NotEqual(0, pages[1]);
}
/// <summary>Verifies that an out-of-range current page is clamped into the valid range.</summary>
[Fact]
public void Build_ClampsOutOfRangeCurrentPage()
{
@@ -57,6 +62,8 @@ public class PagerWindowTests
Assert.True(pages.Count <= 12);
}
/// <summary>Verifies that a non-positive total page count returns an empty window.</summary>
/// <param name="totalPages">The non-positive total page count under test.</param>
[Theory]
[InlineData(0)]
[InlineData(-3)]
@@ -6,6 +6,7 @@ public class SchemaBuilderModelTests
{
// ── Parse ─────────────────────────────────────────────────────────────────
/// <summary>Verifies that parsing an empty schema returns the fallback model.</summary>
[Fact]
public void Parse_Empty_ReturnsFallback()
{
@@ -15,6 +16,7 @@ public class SchemaBuilderModelTests
Assert.Same(fallback, SchemaBuilderModel.Parse(" ", fallback));
}
/// <summary>Verifies that parsing a malformed schema returns the fallback model.</summary>
[Fact]
public void Parse_Malformed_ReturnsFallback()
{
@@ -23,6 +25,7 @@ public class SchemaBuilderModelTests
Assert.Same(fallback, SchemaBuilderModel.Parse("42", fallback));
}
/// <summary>Verifies that parsing an object schema extracts its properties and required names.</summary>
[Fact]
public void Parse_ObjectSchema_ExtractsPropertiesAndRequired()
{
@@ -42,6 +45,7 @@ public class SchemaBuilderModelTests
p => { Assert.Equal("active", p.Name); Assert.Equal("boolean", p.Schema.Type); Assert.True(p.Required); });
}
/// <summary>Verifies that parsing an array of a primitive preserves the item type.</summary>
[Fact]
public void Parse_ArrayOfPrimitive_PreservesItemType()
{
@@ -54,6 +58,7 @@ public class SchemaBuilderModelTests
Assert.Equal("integer", node.Items!.Type);
}
/// <summary>Verifies that a legacy flat array is translated to an object schema.</summary>
[Fact]
public void Parse_LegacyFlatArray_TranslatedToObjectSchema()
{
@@ -66,6 +71,7 @@ public class SchemaBuilderModelTests
p => { Assert.Equal("y", p.Name); Assert.Equal("string", p.Schema.Type); Assert.False(p.Required); });
}
/// <summary>Verifies that parsing nested objects recurses into the children.</summary>
[Fact]
public void Parse_NestedObjects_Recurses()
{
@@ -89,6 +95,7 @@ public class SchemaBuilderModelTests
// ── Serialize ─────────────────────────────────────────────────────────────
/// <summary>Verifies that serializing an empty object omits the required list.</summary>
[Fact]
public void Serialize_EmptyObject_OmitsRequired()
{
@@ -97,6 +104,7 @@ public class SchemaBuilderModelTests
Assert.Equal("""{"type":"object","properties":{}}""", json);
}
/// <summary>Verifies that serializing an object with mixed required properties emits only the required names.</summary>
[Fact]
public void Serialize_ObjectWithMixedRequired_EmitsOnlyRequiredNames()
{
@@ -110,6 +118,7 @@ public class SchemaBuilderModelTests
json);
}
/// <summary>Verifies that serializing an array includes its items.</summary>
[Fact]
public void Serialize_Array_IncludesItems()
{
@@ -117,6 +126,7 @@ public class SchemaBuilderModelTests
Assert.Equal("""{"type":"array","items":{"type":"string"}}""", SchemaBuilderModel.Serialize(node));
}
/// <summary>Verifies that properties with a blank name are skipped when serializing.</summary>
[Fact]
public void Serialize_PropertiesWithBlankName_Skipped()
{
@@ -130,6 +140,7 @@ public class SchemaBuilderModelTests
// ── Round-trip ────────────────────────────────────────────────────────────
/// <summary>Verifies that parsing and then serializing a schema round-trips to a stable result.</summary>
[Fact]
public void RoundTrip_Parse_Then_Serialize_Stable()
{
@@ -10,6 +10,7 @@ public class ScriptTriggerConfigCodecTests
{
// ── Parse: mode field ──────────────────────────────────────────────────
/// <summary>Verifies that parsing a conditional trigger without a mode defaults to on-true.</summary>
[Fact]
public void Parse_Conditional_WithoutMode_DefaultsToOnTrue()
{
@@ -20,6 +21,7 @@ public class ScriptTriggerConfigCodecTests
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
/// <summary>Verifies that parsing a conditional trigger reads the while-true mode.</summary>
[Fact]
public void Parse_Conditional_WhileTrue_IsRead()
{
@@ -31,6 +33,7 @@ public class ScriptTriggerConfigCodecTests
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
}
/// <summary>Verifies that parsing an expression trigger without a mode defaults to on-true.</summary>
[Fact]
public void Parse_Expression_WithoutMode_DefaultsToOnTrue()
{
@@ -41,6 +44,7 @@ public class ScriptTriggerConfigCodecTests
Assert.Equal(ScriptTriggerMode.OnTrue, model.Mode);
}
/// <summary>Verifies that parsing an expression trigger reads the while-true mode.</summary>
[Fact]
public void Parse_Expression_WhileTrue_IsRead()
{
@@ -52,6 +56,7 @@ public class ScriptTriggerConfigCodecTests
Assert.Equal(ScriptTriggerMode.WhileTrue, model.Mode);
}
/// <summary>Verifies that parsing an unrecognized mode defaults to on-true.</summary>
[Fact]
public void Parse_UnrecognizedMode_DefaultsToOnTrue()
{
@@ -65,6 +70,7 @@ public class ScriptTriggerConfigCodecTests
// ── Serialize: mode field ──────────────────────────────────────────────
/// <summary>Verifies that serializing a while-true conditional trigger writes the mode.</summary>
[Fact]
public void Serialize_Conditional_WhileTrue_WritesMode()
{
@@ -81,6 +87,7 @@ public class ScriptTriggerConfigCodecTests
Assert.Contains("\"mode\":\"WhileTrue\"", json);
}
/// <summary>Verifies that serializing a while-true expression trigger writes the mode.</summary>
[Fact]
public void Serialize_Expression_WhileTrue_WritesMode()
{
@@ -97,6 +104,8 @@ public class ScriptTriggerConfigCodecTests
// ── Round-trip ─────────────────────────────────────────────────────────
/// <summary>Verifies that a conditional trigger's mode survives a serialize and parse round trip.</summary>
/// <param name="whileTrue">Whether the trigger uses the while-true mode.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -119,6 +128,8 @@ public class ScriptTriggerConfigCodecTests
// ── SupportsMinTimeBetweenRuns ─────────────────────────────────────────
/// <summary>Verifies that automatic trigger types that throttle support a minimum time between runs.</summary>
/// <param name="triggerType">The trigger type under test.</param>
[Theory]
[InlineData("ValueChange")]
[InlineData("Conditional")]
@@ -128,6 +139,8 @@ public class ScriptTriggerConfigCodecTests
Assert.True(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
}
/// <summary>Verifies that interval, call, none and unknown trigger types do not support a minimum time between runs.</summary>
/// <param name="triggerType">The trigger type under test, or null.</param>
[Theory]
[InlineData("Interval")] // has its own period control
[InlineData("Call")] // invoked explicitly — no throttle applies
@@ -138,6 +151,8 @@ public class ScriptTriggerConfigCodecTests
Assert.False(ScriptTriggerConfigCodec.SupportsMinTimeBetweenRuns(triggerType));
}
/// <summary>Verifies that an expression trigger's mode survives a serialize and parse round trip.</summary>
/// <param name="whileTrue">Whether the trigger uses the while-true mode.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -17,6 +17,7 @@ public class ScriptTriggerEditorTests : BunitContext
private const string ConditionalWhileTrueConfig =
@"{""attributeName"":""Temp"",""operator"":"">"",""threshold"":50,""mode"":""WhileTrue""}";
/// <summary>Verifies that selecting while-true emits a trigger config with the while-true mode.</summary>
[Fact]
public void SelectingWhileTrue_EmitsConfigWithWhileTrueMode()
{
@@ -33,6 +34,7 @@ public class ScriptTriggerEditorTests : BunitContext
Assert.Contains("\"mode\":\"WhileTrue\"", captured!.Config);
}
/// <summary>Verifies that the mode selector defaults to on-true when the config has no mode.</summary>
[Fact]
public void ModeSelector_DefaultsToOnTrue_WhenConfigHasNoMode()
{
@@ -50,6 +52,7 @@ public class ScriptTriggerEditorTests : BunitContext
Assert.Contains("\"mode\":\"OnTrue\"", captured!.Config);
}
/// <summary>Verifies that a loaded while-true mode is retained across an unrelated edit.</summary>
[Fact]
public void LoadedWhileTrueMode_IsRetainedAcrossAnUnrelatedEdit()
{
@@ -10,6 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class TemplateFolderTreeRevealKeysTests : BunitContext
{
/// <summary>Initializes a new instance of the <c>TemplateFolderTreeRevealKeysTests</c> class.</summary>
public TemplateFolderTreeRevealKeysTests()
{
JSInterop.Mode = JSRuntimeMode.Loose;
@@ -25,6 +26,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext
return (new() { a, b, other }, new() { t7, t8 });
}
/// <summary>Verifies that a change to extra node metadata rebuilds the forest and survives filtering.</summary>
/// <returns>The result.</returns>
[Fact]
public void ExtraNodeMetadataChange_RebuildsAndSurvivesFiltering()
{
@@ -45,6 +48,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext
Assert.Equal("inherited:2", node.Key);
}
/// <summary>Verifies that repeated parameters preserve the forest while an in-place rename rebuilds it.</summary>
/// <returns>The result.</returns>
[Fact]
public void RepeatedParameters_PreserveForest_ButInPlaceRenameRebuilds()
{
@@ -60,6 +65,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext
Assert.Contains("Renamed", cut.Markup);
}
/// <summary>Verifies that all folders start collapsed when no reveal keys are given.</summary>
/// <returns>The result.</returns>
[Fact]
public void WithoutRevealKeys_AllFoldersStartCollapsed()
{
@@ -69,6 +76,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext
Assert.DoesNotContain("Elsewhere", cut.Markup);
}
/// <summary>Verifies that reveal keys expand only the ancestors of the listed template.</summary>
/// <returns>The result.</returns>
[Fact]
public void RevealKeys_ExpandsOnlyTheAncestorsOfTheListedTemplate()
{
@@ -80,6 +89,8 @@ public class TemplateFolderTreeRevealKeysTests : BunitContext
Assert.DoesNotContain("Elsewhere", cut.Markup); // Other untouched
}
/// <summary>Verifies that an active filter wins over reveal keys.</summary>
/// <returns>The result.</returns>
[Fact]
public void ActiveFilter_WinsOverRevealKeys()
{
@@ -15,6 +15,7 @@ public class TemplatePickerDialogTests : BunitContext
{
private readonly DialogService _service = new();
/// <summary>Registers the dialog service and loose JS interop the picker dialog needs.</summary>
public TemplatePickerDialogTests()
{
Services.AddSingleton<IDialogService>(_service);
@@ -65,6 +66,8 @@ public class TemplatePickerDialogTests : BunitContext
private static AngleSharp.Dom.IElement Label(IRenderedComponent<DialogHost> cut, string text) =>
cut.FindAll(".tv-label").First(l => l.TextContent.Trim() == text);
/// <summary>Verifies that the Select button is disabled until a template is clicked.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Select_IsDisabled_UntilATemplateIsClicked()
{
@@ -73,6 +76,8 @@ public class TemplatePickerDialogTests : BunitContext
Assert.Contains("Select a template", cut.Find("[data-test='picker-status']").TextContent);
}
/// <summary>Verifies that clicking a template and then Select resolves the dialog with that template's id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ClickingTemplate_ThenSelect_ResolvesItsId()
{
@@ -88,6 +93,8 @@ public class TemplatePickerDialogTests : BunitContext
Assert.Equal(7, (await pending)!.TemplateId);
}
/// <summary>Verifies that double-clicking a template resolves the dialog with that template.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DoubleClickingTemplate_Resolves()
{
@@ -98,6 +105,8 @@ public class TemplatePickerDialogTests : BunitContext
Assert.Equal(7, (await pending)!.TemplateId);
}
/// <summary>Verifies that clicking a folder selects nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ClickingFolder_SelectsNothing()
{
@@ -106,6 +115,8 @@ public class TemplatePickerDialogTests : BunitContext
Assert.True(cut.Find("button[data-test='picker-select']").HasAttribute("disabled"));
}
/// <summary>Verifies that Cancel resolves the dialog with null.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Cancel_ResolvesNull()
{
@@ -114,6 +125,8 @@ public class TemplatePickerDialogTests : BunitContext
Assert.Null(await pending);
}
/// <summary>Verifies that the initial template id is revealed in the tree and highlighted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task InitialTemplateId_IsRevealedAndHighlighted()
{
@@ -125,6 +138,8 @@ public class TemplatePickerDialogTests : BunitContext
Assert.Contains("text-primary", Label(cut, "Deep").ClassName);
}
/// <summary>Verifies that typing in the search box filters the tree.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Search_FiltersTree()
{
@@ -4,16 +4,17 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for HealthMonitoring-015. A heartbeat-only registered site has
/// Regression tests for rendering a null timestamp. A heartbeat-only registered site has
/// a <c>null</c> <c>LastReportReceivedAt</c> ("no full report yet"). The health
/// dashboard passes that value straight into <see cref="TimestampDisplay"/>, so the
/// component's <c>Value</c> must accept <c>DateTimeOffset?</c> and render a
/// <c>null</c> as a human-readable placeholder ("never") instead of the
/// <c>DateTimeOffset.MinValue</c> year-0001 sentinel. Non-null callers must keep
/// <c>DateTimeOffset.MinValue</c> year 0001 sentinel. Non-null callers must keep
/// rendering the formatted timestamp exactly as before.
/// </summary>
public class TimestampDisplayTests : BunitContext
{
/// <summary>Verifies that a non-null value renders as a formatted timestamp.</summary>
[Fact]
public void Render_NonNullValue_ShowsFormattedTimestamp()
{
@@ -28,6 +29,7 @@ public class TimestampDisplayTests : BunitContext
Assert.Contains("2026-05-17 14:30:45 UTC", span.GetAttribute("title")!);
}
/// <summary>Verifies that a null value renders the "never" placeholder.</summary>
[Fact]
public void Render_NullValue_ShowsNeverPlaceholder()
{
@@ -38,13 +40,14 @@ public class TimestampDisplayTests : BunitContext
Assert.Contains("never", cut.Markup, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that a null value does not render the year 0001 minimum-value sentinel.</summary>
[Fact]
public void Render_NullValue_DoesNotRenderYear0001Sentinel()
{
var cut = Render<TimestampDisplay>(parameters => parameters
.Add(p => p.Value, (DateTimeOffset?)null));
// The year-0001 DateTimeOffset.MinValue sentinel must never reach the UI.
// The year 0001 DateTimeOffset.MinValue sentinel must never reach the UI.
Assert.DoesNotContain("0001", cut.Markup);
}
}
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-010. <c>ToastNotification.AddToast</c>
/// Regression tests for toast disposal safety. <c>ToastNotification.AddToast</c>
/// scheduled <c>Task.Delay(dismissMs).ContinueWith(...)</c> with the result
/// discarded; the continuation called <c>InvokeAsync(StateHasChanged)</c>. When
/// the host page is disposed before the delay elapses, the continuation ran
@@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class ToastNotificationTests : BunitContext
{
/// <summary>Verifies that show toast, when after disposal, is no op and schedules nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ShowToast_AfterDisposal_IsNoOp_AndSchedulesNothing()
{
@@ -29,6 +31,8 @@ public class ToastNotificationTests : BunitContext
Assert.Equal(0, cut.Instance.ToastCount);
}
/// <summary>Verifies that auto dismiss, when after disposal, does not throw unobserved exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AutoDismiss_AfterDisposal_DoesNotThrowUnobservedException()
{
@@ -63,6 +67,8 @@ public class ToastNotificationTests : BunitContext
Assert.Empty(unobserved);
}
/// <summary>Verifies that auto dismiss, when before disposal, still removes toast.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AutoDismiss_BeforeDisposal_StillRemovesToast()
{
@@ -22,9 +22,9 @@ public class TreeViewKeyboardNavigationTests : BunitContext
private record TestNode(string Key, string Label, List<TestNode> Children);
// Alpha (branch)
// ├── Alpha-1 (leaf)
// └── Alpha-2 (branch)
// └── Alpha-2-X (leaf)
// ├── Alpha.1 (leaf)
// └── Alpha.2 (branch)
// └── Alpha.2.X (leaf)
// Beta (leaf)
private static List<TestNode> SimpleRoots() => new()
{
@@ -122,6 +122,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
// ── Roving tabindex ────────────────────────────────────────────────────────
/// <summary>Verifies that on initial render exactly one node is tabbable and it is the first node.</summary>
[Fact]
public void InitialRender_ExactlyOneNodeIsTabbable_AndItIsTheFirst()
{
@@ -132,6 +133,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(0, TabbableIndex(cut));
}
/// <summary>Verifies that on initial render the selected node owns the tab stop.</summary>
[Fact]
public void InitialRender_SelectedNode_OwnsTheTabStop()
{
@@ -141,16 +143,17 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(1, TabbableIndex(cut));
}
/// <summary>Verifies that the tab stop falls back to the first node when the focused node is collapsed out of view.</summary>
[Fact]
public void TabStop_FallsBackToFirstNode_WhenFocusedNodeIsCollapsedAway()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Visible: Alpha, Alpha-1, Alpha-2, Beta. Move onto Alpha-1.
// Visible: Alpha, Alpha.1, Alpha.2, Beta. Move onto Alpha.1.
PressKey(cut, 0, "ArrowDown");
Assert.Equal(1, TabbableIndex(cut));
// Collapsing Alpha removes Alpha-1 from the visible set; the tab stop must
// Collapsing Alpha removes Alpha.1 from the visible set; the tab stop must
// not vanish with it.
cut.InvokeAsync(() => cut.Instance.CollapseAll());
Assert.Equal(0, TabbableIndex(cut));
@@ -158,12 +161,13 @@ public class TreeViewKeyboardNavigationTests : BunitContext
// ── ArrowDown / ArrowUp ────────────────────────────────────────────────────
/// <summary>Verifies that ArrowDown moves focus to the next visible node.</summary>
[Fact]
public void ArrowDown_MovesToNextVisibleNode()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Visible order: Alpha(0), Alpha-1(1), Alpha-2(2), Beta(3).
// Visible order: Alpha(0), Alpha.1(1), Alpha.2(2), Beta(3).
Assert.Equal(4, TreeItems(cut).Count);
PressKey(cut, 0, "ArrowDown");
@@ -173,18 +177,20 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(2, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowDown skips the children of a collapsed branch.</summary>
[Fact]
public void ArrowDown_SkipsChildrenOfCollapsedBranch()
{
var cut = RenderTreeView();
// Alpha is collapsed, so the next visible node is Beta — not Alpha-1.
// Alpha is collapsed, so the next visible node is Beta — not Alpha.1.
PressKey(cut, 0, "ArrowDown");
Assert.Equal(2, TreeItems(cut).Count);
Assert.Equal(1, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowDown on the last visible node does nothing.</summary>
[Fact]
public void ArrowDown_AtLastVisibleNode_IsNoOp()
{
@@ -197,6 +203,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(1, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowUp moves focus to the previous visible node.</summary>
[Fact]
public void ArrowUp_MovesToPreviousVisibleNode()
{
@@ -209,6 +216,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(1, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowUp on the first visible node does nothing.</summary>
[Fact]
public void ArrowUp_AtFirstVisibleNode_IsNoOp()
{
@@ -221,6 +229,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
// ── ArrowRight ─────────────────────────────────────────────────────────────
/// <summary>Verifies that ArrowRight on a collapsed branch expands it without moving focus.</summary>
[Fact]
public void ArrowRight_OnCollapsedBranch_Expands_WithoutMovingFocus()
{
@@ -230,21 +239,23 @@ public class TreeViewKeyboardNavigationTests : BunitContext
var items = TreeItems(cut);
Assert.Equal("true", items[0].GetAttribute("aria-expanded"));
Assert.Equal(4, items.Count); // Alpha, Alpha-1, Alpha-2, Beta
Assert.Equal(4, items.Count); // Alpha, Alpha.1, Alpha.2, Beta
Assert.Equal(0, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowRight on an expanded branch moves focus to its first child.</summary>
[Fact]
public void ArrowRight_OnExpandedBranch_MovesToFirstChild()
{
var cut = RenderTreeView();
PressKey(cut, 0, "ArrowRight"); // expand
PressKey(cut, 0, "ArrowRight"); // move onto Alpha-1
PressKey(cut, 0, "ArrowRight"); // move onto Alpha.1
Assert.Equal(1, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowRight on a leaf node does nothing.</summary>
[Fact]
public void ArrowRight_OnLeaf_IsNoOp()
{
@@ -262,6 +273,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
// ── ArrowLeft ──────────────────────────────────────────────────────────────
/// <summary>Verifies that ArrowLeft on an expanded branch collapses it without moving focus.</summary>
[Fact]
public void ArrowLeft_OnExpandedBranch_Collapses_WithoutMovingFocus()
{
@@ -275,12 +287,13 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(0, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowLeft on a child node moves focus to its parent.</summary>
[Fact]
public void ArrowLeft_OnChildNode_MovesToParent()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Alpha-1 (index 1) is a collapsed leaf → ArrowLeft goes up to Alpha.
// Alpha.1 (index 1) is a collapsed leaf → ArrowLeft goes up to Alpha.
PressKey(cut, 1, "ArrowLeft");
Assert.Equal(0, TabbableIndex(cut));
@@ -288,17 +301,19 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded"));
}
/// <summary>Verifies that ArrowLeft on a collapsed branch that is a child moves focus to its parent.</summary>
[Fact]
public void ArrowLeft_OnCollapsedBranchChild_MovesToParent()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Alpha-2 (index 2) is a branch but collapsed → move to its parent Alpha.
// Alpha.2 (index 2) is a branch but collapsed → move to its parent Alpha.
PressKey(cut, 2, "ArrowLeft");
Assert.Equal(0, TabbableIndex(cut));
}
/// <summary>Verifies that ArrowLeft on a root-level leaf does nothing.</summary>
[Fact]
public void ArrowLeft_OnRootLeaf_IsNoOp()
{
@@ -313,6 +328,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
// ── Home / End ─────────────────────────────────────────────────────────────
/// <summary>Verifies that Home moves focus to the first visible node.</summary>
[Fact]
public void Home_MovesToFirstVisibleNode()
{
@@ -323,12 +339,13 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(0, TabbableIndex(cut));
}
/// <summary>Verifies that End moves focus to the last visible node.</summary>
[Fact]
public void End_MovesToLastVisibleNode()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
// Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4).
// Visible: Alpha(0), Alpha.1(1), Alpha.2(2), Alpha.2.X(3), Beta(4).
Assert.Equal(5, TreeItems(cut).Count);
PressKey(cut, 0, "End");
@@ -338,6 +355,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
// ── Enter / Space activation ───────────────────────────────────────────────
/// <summary>Verifies that Enter selects the focused node in single-selection mode.</summary>
[Fact]
public void Enter_SelectsNode_InSingleMode()
{
@@ -347,11 +365,12 @@ public class TreeViewKeyboardNavigationTests : BunitContext
selectable: true,
onSelectedKeyChanged: k => selected = k);
PressKey(cut, 1, "Enter"); // Alpha-1
PressKey(cut, 1, "Enter"); // Alpha.1
Assert.Equal("a1", selected);
}
/// <summary>Verifies that Space selects the focused node in single-selection mode.</summary>
[Fact]
public void Space_SelectsNode_InSingleMode()
{
@@ -365,6 +384,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal("b", selected);
}
/// <summary>Verifies that Enter raises no selection callback when the tree is not selectable.</summary>
[Fact]
public void Enter_WhenNotSelectable_RaisesNoCallback()
{
@@ -376,6 +396,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Null(selected);
}
/// <summary>Verifies that Space toggles the focused node in checkbox mode.</summary>
[Fact]
public void Space_InCheckboxMode_TogglesTheNode()
{
@@ -386,14 +407,15 @@ public class TreeViewKeyboardNavigationTests : BunitContext
selectedKeys: new HashSet<object>(),
onSelectedKeysChanged: keys => captured = keys);
// Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4).
// Space on Alpha-1 selects that one leaf.
// Visible: Alpha(0), Alpha.1(1), Alpha.2(2), Alpha.2.X(3), Beta(4).
// Space on Alpha.1 selects that one leaf.
PressKey(cut, 1, " ");
Assert.NotNull(captured);
Assert.Equal(new HashSet<object> { "a1" }, captured);
}
/// <summary>Verifies that Enter on a folder in checkbox mode cascades the selection to its descendant leaves.</summary>
[Fact]
public void Enter_InCheckboxMode_OnFolder_CascadesToDescendantLeaves()
{
@@ -412,6 +434,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Contains((object)"a2x", captured);
}
/// <summary>Verifies that a keydown on a nested node does not also activate its ancestor.</summary>
[Fact]
public void Keydown_OnNestedNode_DoesNotAlsoActivateItsAncestor()
{
@@ -423,13 +446,14 @@ public class TreeViewKeyboardNavigationTests : BunitContext
selectable: true,
onSelectedKeyChanged: k => selected = k);
PressKey(cut, 1, "Enter"); // Alpha-1, nested inside Alpha
PressKey(cut, 1, "Enter"); // Alpha.1, nested inside Alpha
Assert.Equal("a1", selected);
}
// ── Existing chevron behaviour (regression) ────────────────────────────────
/// <summary>Verifies that pressing Enter on the chevron toggle still expands the node and does not select it.</summary>
[Fact]
public void ChevronToggle_EnterKey_StillExpands_AndDoesNotSelect()
{
@@ -443,6 +467,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Null(selected);
}
/// <summary>Verifies that pressing Space on the chevron toggle still expands the node.</summary>
[Fact]
public void ChevronToggle_SpaceKey_StillExpands()
{
@@ -455,13 +480,14 @@ public class TreeViewKeyboardNavigationTests : BunitContext
// ── ARIA structure ─────────────────────────────────────────────────────────
/// <summary>Verifies that tree items carry a one-based aria-level.</summary>
[Fact]
public void TreeItems_CarryOneBasedAriaLevel()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
var items = TreeItems(cut);
// Alpha, Alpha-1, Alpha-2, Alpha-2-X, Beta
// Alpha, Alpha.1, Alpha.2, Alpha.2.X, Beta
Assert.Equal("1", items[0].GetAttribute("aria-level"));
Assert.Equal("2", items[1].GetAttribute("aria-level"));
Assert.Equal("2", items[2].GetAttribute("aria-level"));
@@ -469,6 +495,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal("1", items[4].GetAttribute("aria-level"));
}
/// <summary>Verifies that tree items carry aria-posinset and aria-setsize values.</summary>
[Fact]
public void TreeItems_CarryAriaPosInSetAndSetSize()
{
@@ -491,6 +518,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal("2", items[3].GetAttribute("aria-setsize"));
}
/// <summary>Verifies that the tree structure uses the tree, group and treeitem roles.</summary>
[Fact]
public void TreeStructure_UsesRoleTree_RoleGroup_AndRoleTreeitem()
{
@@ -501,6 +529,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal(4, TreeItems(cut).Count);
}
/// <summary>Verifies that aria-selected is present on selectable nodes and tracks the selection.</summary>
[Fact]
public void AriaSelected_IsPresentOnSelectableNodes_AndTracksSelection()
{
@@ -511,6 +540,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
Assert.Equal("false", items[1].GetAttribute("aria-selected"));
}
/// <summary>Verifies that the root tree suppresses the default browser scrolling for navigation keys.</summary>
[Fact]
public void RootTree_SuppressesBrowserScrollDefaults_ForNavigationKeys()
{
@@ -532,6 +562,7 @@ public class TreeViewKeyboardNavigationTests : BunitContext
}
}
/// <summary>Verifies that aria-selected is absent when the tree is not selectable.</summary>
[Fact]
public void AriaSelected_IsAbsent_WhenTreeIsNotSelectable()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// Regression tests for CentralUI-018. <c>TreeView</c>'s storage-restore path
/// Regression tests for <c>TreeView</c>'s storage-restore path
/// called <c>JsonSerializer.Deserialize</c> on the raw <c>treeviewStorage</c>
/// payload outside any try block — a corrupt payload threw an uncaught
/// <c>JsonException</c> during <c>OnAfterRenderAsync</c>, breaking the
@@ -31,6 +31,7 @@ public class TreeViewStorageResilienceTests : BunitContext
b.AddMarkupContent(0, $"<span>{node.Label}</span>")))
.Add(p => p.StorageKey, "corrupt-tree"));
/// <summary>Verifies that restoring a corrupt JSON payload from storage does not throw and the tree still renders.</summary>
[Fact]
public void StorageRestore_CorruptJsonPayload_DoesNotThrow_AndStillRenders()
{
@@ -47,6 +48,7 @@ public class TreeViewStorageResilienceTests : BunitContext
Assert.Contains("Beta", cut.Markup);
}
/// <summary>Verifies that restoring JSON of the wrong shape from storage does not throw.</summary>
[Fact]
public void StorageRestore_WrongShapeJson_DoesNotThrow()
{
@@ -8,10 +8,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// M9-T28b: the <c>AlarmTriggerEditor</c> and <c>ScriptTriggerEditor</c>
/// The <c>AlarmTriggerEditor</c> and <c>ScriptTriggerEditor</c>
/// components must surface an Advisory|Strict selector for Expression triggers
/// and round-trip the <c>"analysisKind"</c> key into the trigger config JSON
/// that the T28a ValidationService backend reads.
/// that the ValidationService backend reads.
/// </summary>
// ── AlarmTriggerConfigCodec — analysisKind parse / serialize ────────────────
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
public class AlarmTriggerAnalysisKindCodecTests
{
// Codec: parse — absent key defaults to Advisory (false)
/// <summary>Verifies that parsing an Expression trigger with no analysis kind defaults to Advisory.</summary>
[Fact]
public void Parse_Expression_NoAnalysisKind_DefaultsToAdvisory()
{
@@ -28,6 +29,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: parse — "Advisory" explicit → false
/// <summary>Verifies that parsing an Expression trigger with the Advisory key returns false.</summary>
[Fact]
public void Parse_Expression_AdvisoryKey_ReturnsFalse()
{
@@ -37,6 +39,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: parse — "Strict" → true
/// <summary>Verifies that parsing an Expression trigger with the Strict key returns true.</summary>
[Fact]
public void Parse_Expression_StrictKey_ReturnsTrue()
{
@@ -46,6 +49,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: parse — case-insensitive
/// <summary>Verifies that the Strict key is parsed case-insensitively.</summary>
[Fact]
public void Parse_Expression_StrictKeyCaseInsensitive()
{
@@ -55,6 +59,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: serialize — Advisory (false) → "analysisKind" key omitted
/// <summary>Verifies that serializing an Advisory Expression trigger omits the analysisKind key.</summary>
[Fact]
public void Serialize_Expression_Advisory_OmitsAnalysisKindKey()
{
@@ -66,6 +71,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: serialize — Strict (true) → "analysisKind":"Strict"
/// <summary>Verifies that serializing a Strict Expression trigger writes analysisKind as Strict.</summary>
[Fact]
public void Serialize_Expression_Strict_WritesAnalysisKindStrict()
{
@@ -77,6 +83,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: serialize — Strict not emitted for non-Expression trigger types
/// <summary>Verifies that serializing a non-Expression trigger does not emit analysisKind.</summary>
[Fact]
public void Serialize_NonExpression_DoesNotEmitAnalysisKind()
{
@@ -94,6 +101,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: round-trip Strict
/// <summary>Verifies that a Strict Expression trigger is preserved through a round trip.</summary>
[Fact]
public void RoundTrip_Expression_Strict_Preserved()
{
@@ -106,6 +114,7 @@ public class AlarmTriggerAnalysisKindCodecTests
}
// Codec: round-trip Advisory (omit + re-parse → still false)
/// <summary>Verifies that an Advisory Expression trigger is preserved through a round trip.</summary>
[Fact]
public void RoundTrip_Expression_Advisory_Preserved()
{
@@ -122,6 +131,7 @@ public class AlarmTriggerAnalysisKindCodecTests
public class ScriptTriggerAnalysisKindCodecTests
{
// Codec: parse — absent key defaults to Advisory (false)
/// <summary>Verifies that parsing an Expression trigger with no analysis kind defaults to Advisory.</summary>
[Fact]
public void Parse_Expression_NoAnalysisKind_DefaultsToAdvisory()
{
@@ -131,6 +141,7 @@ public class ScriptTriggerAnalysisKindCodecTests
}
// Codec: parse — "Strict" → true
/// <summary>Verifies that parsing an Expression trigger with the Strict key returns true.</summary>
[Fact]
public void Parse_Expression_StrictKey_ReturnsTrue()
{
@@ -140,6 +151,7 @@ public class ScriptTriggerAnalysisKindCodecTests
}
// Codec: serialize — Advisory → key omitted
/// <summary>Verifies that serializing an Advisory Expression trigger omits the analysisKind key.</summary>
[Fact]
public void Serialize_Expression_Advisory_OmitsAnalysisKindKey()
{
@@ -151,6 +163,7 @@ public class ScriptTriggerAnalysisKindCodecTests
}
// Codec: serialize — Strict → "analysisKind":"Strict"
/// <summary>Verifies that serializing a Strict Expression trigger writes analysisKind as Strict.</summary>
[Fact]
public void Serialize_Expression_Strict_WritesAnalysisKindStrict()
{
@@ -162,6 +175,7 @@ public class ScriptTriggerAnalysisKindCodecTests
}
// Codec: serialize — Strict not emitted for non-Expression triggers
/// <summary>Verifies that serializing a non-Expression trigger does not emit analysisKind.</summary>
[Fact]
public void Serialize_NonExpression_DoesNotEmitAnalysisKind()
{
@@ -179,6 +193,7 @@ public class ScriptTriggerAnalysisKindCodecTests
}
// Codec: round-trip Strict
/// <summary>Verifies that a Strict Expression trigger is preserved through a round trip.</summary>
[Fact]
public void RoundTrip_Expression_Strict_Preserved()
{
@@ -200,6 +215,7 @@ public class ScriptTriggerAnalysisKindCodecTests
public class AlarmTriggerEditorAnalysisKindTests : BunitContext
{
/// <summary>Initializes the test context for the alarm trigger editor.</summary>
public AlarmTriggerEditorAnalysisKindTests()
{
// MonacoEditor calls MonacoBlazor.createEditor via JS interop. Use Loose
@@ -208,6 +224,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext
}
// Selector is visible for Expression trigger type
/// <summary>Verifies that the alarm trigger editor shows the analysis kind selector for an Expression trigger.</summary>
[Fact]
public void AlarmTriggerEditor_Expression_ShowsAnalysisKindSelector()
{
@@ -221,6 +238,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext
}
// Selector is NOT visible for non-Expression trigger types
/// <summary>Verifies that the alarm trigger editor does not show the analysis kind selector for a non-Expression trigger.</summary>
[Fact]
public void AlarmTriggerEditor_NonExpression_DoesNotShowAnalysisKindSelector()
{
@@ -232,6 +250,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext
}
// Selector defaults to Advisory
/// <summary>Verifies that the alarm trigger editor selector defaults to Advisory for an Expression trigger.</summary>
[Fact]
public void AlarmTriggerEditor_Expression_NoAnalysisKindInConfig_SelectorDefaultsAdvisory()
{
@@ -245,6 +264,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext
}
// Selector defaults to Advisory when config has no analysisKind (ScriptTriggerEditor)
/// <summary>Verifies that the trigger editor selector defaults to Advisory when the config has no analysis kind.</summary>
[Fact]
public void ScriptTriggerEditor_Expression_NoAnalysisKindInConfig_SelectorDefaultsAdvisory()
{
@@ -258,6 +278,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext
}
// Choosing Strict writes analysisKind:"Strict" into emitted config
/// <summary>Verifies that choosing Strict in the alarm trigger editor emits analysisKind as Strict.</summary>
[Fact]
public void AlarmTriggerEditor_Expression_ChoosingStrict_EmitsAnalysisKindStrict()
{
@@ -276,6 +297,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext
}
// Loaded Strict config reflects in selector, emits Strict on next change
/// <summary>Verifies that a loaded Strict config is retained when the alarm trigger is edited.</summary>
[Fact]
public void AlarmTriggerEditor_Expression_LoadedStrictConfig_RetainedOnEdit()
{
@@ -299,6 +321,7 @@ public class AlarmTriggerEditorAnalysisKindTests : BunitContext
public class ScriptTriggerEditorAnalysisKindTests : BunitContext
{
/// <summary>Initializes the test context for the script trigger editor.</summary>
public ScriptTriggerEditorAnalysisKindTests()
{
// MonacoEditor calls MonacoBlazor.createEditor via JS interop. Use Loose
@@ -307,6 +330,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext
}
// Selector is visible for Expression trigger type
/// <summary>Verifies that the script trigger editor shows the analysis kind selector for an Expression trigger.</summary>
[Fact]
public void ScriptTriggerEditor_Expression_ShowsAnalysisKindSelector()
{
@@ -319,6 +343,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext
}
// Selector is NOT visible for non-Expression trigger types
/// <summary>Verifies that the script trigger editor does not show the analysis kind selector for a Conditional trigger.</summary>
[Fact]
public void ScriptTriggerEditor_Conditional_DoesNotShowAnalysisKindSelector()
{
@@ -330,6 +355,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext
}
// Choosing Strict writes analysisKind:"Strict" into emitted config
/// <summary>Verifies that choosing Strict in the script trigger editor emits analysisKind as Strict.</summary>
[Fact]
public void ScriptTriggerEditor_Expression_ChoosingStrict_EmitsAnalysisKindStrict()
{
@@ -348,6 +374,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext
}
// Choosing Advisory emits config without analysisKind key
/// <summary>Verifies that choosing Advisory in the script trigger editor omits the analysisKind key.</summary>
[Fact]
public void ScriptTriggerEditor_Expression_ChoosingAdvisory_OmitsAnalysisKindKey()
{
@@ -366,6 +393,7 @@ public class ScriptTriggerEditorAnalysisKindTests : BunitContext
}
// Loaded Strict is retained on unrelated edit (fire mode change)
/// <summary>Verifies that a loaded Strict setting is retained when the trigger mode changes.</summary>
[Fact]
public void ScriptTriggerEditor_Expression_LoadedStrict_RetainedOnModeChange()
{