refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,381 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditDrilldownDrawer"/> (#23 M7 Bundle C / M7-T4..T8).
///
/// The drawer is a child component opened from the Audit Log page when a grid row
/// is clicked. It renders the offcanvas chrome (header, open/close) and delegates
/// the <see cref="AuditEvent"/> body to the shared <see cref="AuditEventDetail"/>
/// component, which since the recent refactor owns the channel-aware bodies
/// (JSON pretty-print, SQL block for DbOutbound), redaction badges on
/// Request/Response, and conditional action buttons.
///
/// Tests pin the behaviours we cannot lose without breaking the spec:
/// offcanvas open/close, header rendering, and that the event body is handed
/// off to <see cref="AuditEventDetail"/>.
/// </summary>
public class AuditDrilldownDrawerTests : BunitContext
{
public AuditDrilldownDrawerTests()
{
// Default to Loose so the cURL clipboard call does not blow up tests
// that don't exercise it. Tests that need to assert interop calls flip
// to Strict and configure their own setups.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? requestSummary = null,
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
=> new()
{
EventId = Guid.Parse("11111111-2222-3333-4444-555555555555"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 30, 46, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
Actor = "tester",
Target = target,
Status = status,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
DurationMs = 42,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
Extra = extra,
};
[Fact]
public void Drawer_RendersField_OccurredAtUtc()
{
var ev = MakeEvent();
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
// OccurredAtUtc renders ISO-8601 round-trip ("o" format). The
// year+time fragment is sufficient evidence — the full ISO string
// changes shape with locale-dependent formatting in some envs.
Assert.Contains("data-test=\"field-OccurredAtUtc\"", cut.Markup);
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
}
[Fact]
public void Drawer_JsonRequestSummary_PrettyPrinted_Indented()
{
// A single-line JSON body should be re-emitted indented.
var ev = MakeEvent(requestSummary: "{\"a\":1,\"b\":\"two\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
// Pretty-print writes one property per line — the " \"a\":" prefix
// proves indentation. We don't pin the exact bytes; we pin "indented"
// by looking for newline-prefixed property lines.
Assert.Contains("data-test=\"request-body\"", cut.Markup);
Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup);
Assert.Matches(@"\n\s+""b"":\s*""two""", cut.Markup);
}
[Fact]
public void Drawer_NonJsonRequestSummary_RenderedVerbatim()
{
// Non-JSON content (e.g. plain text or invalid JSON) must round-trip
// exactly — the drawer should not attempt to "fix" or rewrite it.
var ev = MakeEvent(requestSummary: "not really json {{}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("not really json {{}", cut.Markup);
}
[Fact]
public void Drawer_DbOutboundChannel_RendersSqlBlock()
{
// DbOutbound payloads carry a {sql, parameters} JSON shape. The drawer
// renders sql inside a code block with language-sql class (CSS-only,
// no JS highlighter) and lists the parameters in a definition list.
const string body = "{\"sql\":\"UPDATE T SET x=@p1 WHERE id=@p2\",\"parameters\":{\"p1\":42,\"p2\":\"abc\"}}";
var ev = MakeEvent(channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, requestSummary: body);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("language-sql", cut.Markup);
Assert.Contains("UPDATE T SET x=@p1 WHERE id=@p2", cut.Markup);
// Parameter dl shows both keys.
Assert.Contains("p1", cut.Markup);
Assert.Contains("p2", cut.Markup);
Assert.Contains("42", cut.Markup);
Assert.Contains("abc", cut.Markup);
}
[Fact]
public void Drawer_ApiOutbound_ShowsCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.ApiOutbound);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void Drawer_NotApiChannel_HidesCopyAsCurlButton()
{
// Notification is neither an API outbound nor inbound — no cURL.
var ev = MakeEvent(channel: AuditChannel.Notification, kind: AuditKind.NotifySend);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void Drawer_NullCorrelationId_HidesShowAllButton()
{
var ev = MakeEvent(correlationId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void Drawer_RedactedBody_ShowsRedactionBadge()
{
// The redaction sentinel is the literal string `<redacted>` (or
// `<redacted: redactor error>`) — the drawer must flag it visibly.
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"<redacted>\"},\"body\":\"hello\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void Drawer_NonRedactedBody_HidesBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"Bearer abc\"},\"body\":\"hello\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
{
var corr = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
var ev = MakeEvent(correlationId: corr);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"show-all-events\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains("/audit/log?correlationId=", nav.Uri);
Assert.Contains(corr.ToString(), nav.Uri);
}
[Fact]
public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
{
var ev = MakeEvent(executionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
{
var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-this-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
}
[Fact]
public void Drawer_NullParentExecutionId_HidesViewParentExecutionButton()
{
var ev = MakeEvent(parentExecutionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-parent-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullParentExecutionId_ShowsViewParentExecutionButton()
{
var ev = MakeEvent(parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
}
[Fact]
public void ViewParentExecution_Navigates_WithExecutionIdQueryString()
{
// A routed (child) row drills in to its spawner: the "View parent
// execution" action navigates to /audit/log?executionId={ParentExecutionId}
// so the user sees the spawner execution's rows.
var parent = Guid.Parse("eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa");
var ev = MakeEvent(parentExecutionId: parent);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-parent-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
}
[Fact]
public void Drawer_NullExecutionId_HidesViewExecutionChainButton()
{
var ev = MakeEvent(executionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
// The "View execution chain" action opens the tree view rooted at the
// chain containing this row's ExecutionId.
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-execution-chain\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
// Set up Strict mode interop so the call must match exactly.
JSInterop.Mode = JSRuntimeMode.Strict;
var clipboardCall = JSInterop.SetupVoid(
"navigator.clipboard.writeText",
invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal));
// Build an event with a {headers, body} RequestSummary so the cURL
// builder has material to fold in.
var ev = MakeEvent(
channel: AuditChannel.ApiOutbound,
target: "https://example.test/api/v1/widgets",
requestSummary: "{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":\"{\\\"x\\\":1}\"}");
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
await cut.InvokeAsync(() => cut.Find("[data-test=\"copy-as-curl\"]").Click());
// Bunit's JSRuntimeInvocationDictionary is keyed by identifier
// (string) — we enumerate it instead of indexing by int.
var calls = clipboardCall.Invocations.ToList();
Assert.NotEmpty(calls);
var argString = (string)calls[0].Arguments[0]!;
Assert.StartsWith("curl ", argString);
Assert.Contains("https://example.test/api/v1/widgets", argString);
Assert.Contains("Content-Type: application/json", argString);
}
}
@@ -0,0 +1,320 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditEventDetail"/> — the reusable single-row
/// detail body extracted from <see cref="AuditDrilldownDrawer"/> (Task 1 of the
/// Execution-Tree Node Detail Modal feature).
///
/// These tests render the detail component directly (not via the drawer) and
/// pin the contract the drawer — and any future modal host — relies on:
/// the read-only field block, the conditional Error/Request/Response/Extra
/// sections, the redaction badge, channel-aware body rendering, and the
/// action buttons. All <c>data-test</c> values must match the originals so the
/// existing <see cref="AuditDrilldownDrawer"/> selectors keep resolving.
/// </summary>
public class AuditEventDetailTests : BunitContext
{
public AuditEventDetailTests()
{
// Loose so the cURL clipboard call does not blow up tests that do not
// exercise it. The clipboard test flips to Strict itself.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? requestSummary = null,
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
=> new()
{
EventId = Guid.Parse("11111111-2222-3333-4444-555555555555"),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
IngestedAtUtc = new DateTime(2026, 5, 20, 12, 30, 46, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
Actor = "tester",
Target = target,
Status = status,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
DurationMs = 42,
ErrorMessage = errorMessage,
ErrorDetail = errorDetail,
RequestSummary = requestSummary,
ResponseSummary = responseSummary,
Extra = extra,
};
[Fact]
public void RendersFieldBlock()
{
var ev = MakeEvent();
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"drawer-fields\"", cut.Markup);
Assert.Contains("data-test=\"field-Channel\"", cut.Markup);
Assert.Contains("data-test=\"field-Status\"", cut.Markup);
Assert.Contains("data-test=\"field-OccurredAtUtc\"", cut.Markup);
Assert.Contains("2026-05-20T12:30:45", cut.Markup);
}
[Fact]
public void RendersSourceNodeField_BetweenSiteAndInstance()
{
// SourceNode is rendered as a sibling row directly under SourceSiteId
// so the popup reads "site → node → instance → script" in provenance
// order. Populated case.
var ev = MakeEvent() with { SourceNode = "node-a" };
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"field-SourceNode\"", cut.Markup);
Assert.Contains("node-a", cut.Markup);
// Ordering: SourceSiteId appears before SourceNode, which appears
// before SourceInstanceId.
var siteIdx = cut.Markup.IndexOf("data-test=\"field-SourceSiteId\"", StringComparison.Ordinal);
var nodeIdx = cut.Markup.IndexOf("data-test=\"field-SourceNode\"", StringComparison.Ordinal);
var instanceIdx = cut.Markup.IndexOf("data-test=\"field-SourceInstanceId\"", StringComparison.Ordinal);
Assert.True(siteIdx > 0 && nodeIdx > siteIdx && instanceIdx > nodeIdx,
$"Expected SourceSiteId < SourceNode < SourceInstanceId; got {siteIdx}, {nodeIdx}, {instanceIdx}");
}
[Fact]
public void RendersSourceNodeField_AsDashWhenNull()
{
// Null SourceNode (e.g. central direct-write row pre-feature, or a
// reconciled row from a retired node) renders as the em-dash, same
// convention as the sibling provenance fields.
var ev = MakeEvent(); // SourceNode left at default null
Assert.Null(ev.SourceNode);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"field-SourceNode\"", cut.Markup);
// The field is present and renders the em-dash placeholder.
Assert.Contains(">—<", cut.Markup);
}
[Fact]
public void ErrorSection_RendersWhenErrorPresent()
{
var ev = MakeEvent(
status: AuditStatus.Parked,
errorMessage: "boom",
errorDetail: "stack trace here");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-error\"", cut.Markup);
Assert.Contains("boom", cut.Markup);
Assert.Contains("stack trace here", cut.Markup);
}
[Fact]
public void ErrorSection_HiddenWhenNoError()
{
var ev = MakeEvent();
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"section-error\"", cut.Markup);
}
[Fact]
public void RequestSection_PrettyPrintsJson()
{
var ev = MakeEvent(requestSummary: "{\"a\":1,\"b\":\"two\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-request\"", cut.Markup);
Assert.Contains("data-test=\"request-body\"", cut.Markup);
Assert.Matches(@"\n\s+""a"":\s*1", cut.Markup);
}
[Fact]
public void ResponseSection_RendersWhenPresent()
{
var ev = MakeEvent(responseSummary: "{\"ok\":true}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-response\"", cut.Markup);
Assert.Contains("data-test=\"response-body\"", cut.Markup);
}
[Fact]
public void ExtraSection_RendersWhenPresent()
{
var ev = MakeEvent(extra: "{\"note\":\"hi\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"section-extra\"", cut.Markup);
}
[Fact]
public void RedactedBody_ShowsRedactionBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"<redacted>\"},\"body\":\"hello\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void NonRedactedBody_HidesRedactionBadge()
{
var ev = MakeEvent(requestSummary: "{\"headers\":{\"Authorization\":\"Bearer abc\"},\"body\":\"hello\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"redaction-badge-request\"", cut.Markup);
}
[Fact]
public void DbOutboundChannel_RendersSqlBlock()
{
const string body = "{\"sql\":\"UPDATE T SET x=@p1 WHERE id=@p2\",\"parameters\":{\"p1\":42,\"p2\":\"abc\"}}";
var ev = MakeEvent(channel: AuditChannel.DbOutbound, kind: AuditKind.DbWrite, requestSummary: body);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("language-sql", cut.Markup);
Assert.Contains("UPDATE T SET x=@p1 WHERE id=@p2", cut.Markup);
Assert.Contains("data-test=\"sql-parameters\"", cut.Markup);
}
[Fact]
public void ApiChannel_ShowsCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.ApiOutbound);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void NonApiChannel_HidesCopyAsCurlButton()
{
var ev = MakeEvent(channel: AuditChannel.Notification, kind: AuditKind.NotifySend);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"copy-as-curl\"", cut.Markup);
}
[Fact]
public void NullCorrelationId_HidesShowAllButton()
{
var ev = MakeEvent(correlationId: null);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.DoesNotContain("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void NonNullCorrelationId_ShowsShowAllButton()
{
var ev = MakeEvent(correlationId: Guid.NewGuid());
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"show-all-events\"", cut.Markup);
}
[Fact]
public void ExecutionButtons_ConditionalOnExecutionIds()
{
var ev = MakeEvent(
executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"),
parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444"));
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup);
Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup);
}
[Fact]
public void ShowAllForOperation_Navigates_WithCorrelationIdQueryString()
{
var corr = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
var ev = MakeEvent(correlationId: corr);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
cut.Find("[data-test=\"show-all-events\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?correlationId={corr}", nav.Uri);
}
[Fact]
public void ViewExecutionChain_Navigates_ToExecutionTreePage()
{
var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
cut.Find("[data-test=\"view-execution-chain\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{
JSInterop.Mode = JSRuntimeMode.Strict;
var clipboardCall = JSInterop.SetupVoid(
"navigator.clipboard.writeText",
invocation => invocation.Arguments.Count == 1
&& invocation.Arguments[0] is string s
&& s.StartsWith("curl ", StringComparison.Ordinal));
var ev = MakeEvent(
channel: AuditChannel.ApiOutbound,
target: "https://example.test/api/v1/widgets",
requestSummary: "{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":\"{\\\"x\\\":1}\"}");
var cut = Render<AuditEventDetail>(p => p.Add(c => c.Event, ev));
await cut.InvokeAsync(() => cut.Find("[data-test=\"copy-as-curl\"]").Click());
var calls = clipboardCall.Invocations.ToList();
Assert.NotEmpty(calls);
var argString = (string)calls[0].Arguments[0]!;
Assert.StartsWith("curl ", argString);
Assert.Contains("https://example.test/api/v1/widgets", argString);
}
}
@@ -0,0 +1,314 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
/// Kind / Status / Site are
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls whose options are checkboxes tagged
/// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin:
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
/// to roughly an hour before "now" — proves the time-window collapse without
/// freezing the clock.
/// </summary>
public class AuditFilterBarTests : BunitContext
{
private readonly ISiteRepository _siteRepo;
private readonly IAuditLogQueryService _auditLogQueryService;
public AuditFilterBarTests()
{
_siteRepo = Substitute.For<ISiteRepository>();
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
{
new("Plant A", "plant-a") { Id = 1 },
new("Plant B", "plant-b") { Id = 2 },
}));
Services.AddSingleton(_siteRepo);
// Task 15: the Node multi-select pulls its options from
// IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub
// returns the two central nodes the cluster uses; individual tests can
// override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...).
_auditLogQueryService = Substitute.For<IAuditLogQueryService>();
_auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
_auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
Services.AddSingleton(_auditLogQueryService);
}
[Fact]
public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present()
{
var cut = Render<AuditFilterBar>();
// Each filter element is tagged with a stable data-test attribute so the test
// doesn't churn on cosmetic label changes.
var markers = new[]
{
"data-test=\"filter-channel\"",
"data-test=\"filter-kind\"",
"data-test=\"filter-status\"",
"data-test=\"filter-site\"",
"data-test=\"filter-node\"",
"data-test=\"filter-time-range\"",
"data-test=\"filter-custom-range\"",
"data-test=\"filter-instance\"",
"data-test=\"filter-script\"",
"data-test=\"filter-target\"",
"data-test=\"filter-actor\"",
"data-test=\"filter-execution-id\"",
"data-test=\"filter-parent-execution-id\"",
"data-test=\"filter-errors-only\"",
};
foreach (var marker in markers)
{
Assert.Contains(marker, cut.Markup);
}
}
[Fact]
public void Apply_RaisesOnFilterChanged_WithSelectedFilters()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Drive UI: pick a Channel, type in the Target search box, click Apply.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
Assert.Equal("Plant-A-OPC", captured.Target);
}
[Fact]
public void ChangingChannel_ReplacesTheSelection_SingleSelect()
{
// Channel is single-select: picking a second channel replaces the first
// rather than adding to it (the page filters one channel at a time).
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);
// Selecting "All channels" clears the channel filter entirely.
cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.Channels);
}
[Fact]
public void Channel_Narrows_Kind_Options_When_Selected()
{
var cut = Render<AuditFilterBar>();
// With no Channel selected, every kind option is in the DOM.
foreach (var kind in Enum.GetValues<AuditKind>())
{
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Select ApiOutbound; Kind options outside the channel-kind map drop out.
cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
foreach (var kind in apiKinds)
{
Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
}
// Sanity: an unrelated kind is gone.
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
}
[Fact]
public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Toggle Errors-only ON, leaving Status chips empty.
cut.Find("[data-test=\"filter-errors-only\"] input").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
// Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
Assert.NotNull(captured!.Statuses);
Assert.Equal(3, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
Assert.Contains(AuditStatus.Parked, captured.Statuses);
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
// Now pin an explicit Status option — Errors-only must yield (explicit wins).
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
}
[Fact]
public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter()
{
// Task 15: the Node filter pulls its option set from
// IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the
// chip selection into AuditLogQueryFilter.SourceNodes.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// The bar marker plus the option checkboxes for the two cluster nodes
// are present after init (the constructor stubs return two nodes).
Assert.Contains("data-test=\"filter-node\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-a\"", cut.Markup);
Assert.Contains("data-test=\"filter-node-ms-opt-central-b\"", cut.Markup);
cut.Find("[data-test=\"filter-node-ms-opt-central-a\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.SourceNodes);
Assert.Equal(new[] { "central-a" }, captured.SourceNodes);
}
[Fact]
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
{
// Task 9: multiple explicit Status chips all reach the filter — and they
// win over the Errors-only default.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.NotNull(captured!.Statuses);
Assert.Equal(2, captured.Statuses!.Count);
Assert.Contains(AuditStatus.Delivered, captured.Statuses);
Assert.Contains(AuditStatus.Failed, captured.Statuses);
}
[Fact]
public void Apply_WithPastedExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Execution ID box; Apply must map it
// straight onto AuditLogQueryFilter.ExecutionId.
var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(executionId, captured!.ExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
{
// Lax parsing: a blank box yields no constraint; garbage text likewise.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Null(captured!.ExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.ExecutionId);
}
[Fact]
public void Apply_WithPastedParentExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Parent execution ID box; Apply must
// map it straight onto AuditLogQueryFilter.ParentExecutionId.
var parentExecutionId = Guid.Parse("11112222-3333-4444-5555-666677778888");
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change(parentExecutionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(parentExecutionId, captured!.ParentExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull()
{
// Lax parsing: a blank box yields no constraint; garbage text likewise.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Null(captured!.ParentExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.ParentExecutionId);
}
[Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// LastHour is the default preset; clicking Apply must collapse it to FromUtc.
var before = DateTime.UtcNow;
cut.Find("[data-test=\"filter-apply\"]").Click();
var after = DateTime.UtcNow;
Assert.NotNull(captured);
Assert.NotNull(captured!.FromUtc);
// FromUtc should be in [now-1h-eps, now-1h+eps] computed against the Apply moment.
var expectedLow = before.AddHours(-1).AddSeconds(-1);
var expectedHigh = after.AddHours(-1).AddSeconds(1);
Assert.InRange(captured.FromUtc!.Value, expectedLow, expectedHigh);
}
}
@@ -0,0 +1,380 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="AuditResultsGrid"/> (#23 M7-T3 / Bundle B). The grid
/// renders 10 columns, paginates via keyset (passing the last row's
/// (OccurredAtUtc, EventId) back to the service), raises a row-click callback
/// that Bundle C wires to the drilldown drawer, and styles non-success status
/// rows with an error-coded badge.
/// </summary>
public class AuditResultsGridTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
=> new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = occurredAtUtc,
Channel = channel,
Kind = kind,
Status = status,
SourceSiteId = site,
Target = "demo-target",
Actor = "tester",
ExecutionId = executionId,
ParentExecutionId = parentExecutionId,
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
};
public AuditResultsGridTests()
{
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// The grid's OnAfterRenderAsync calls into audit-grid.js (init + the
// sessionStorage load). Loose mode lets those unconfigured calls no-op
// — auditGrid.load returns null (no prior state) unless a test sets up
// an explicit JSInterop.Setup to return a stored payload.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private void StubPage(IReadOnlyList<AuditEvent> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
return Task.FromResult(rows);
});
}
[Fact]
public void Render_TenColumns_FromStubService()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// 10 column headers per Component-AuditLog.md §10.
var expectedHeaders = new[]
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
};
foreach (var header in expectedHeaders)
{
Assert.Contains($"data-test=\"col-header-{header}\"", cut.Markup);
}
}
[Fact]
public void Click_NextPage_CallsService_WithCursor_OfLastRow()
{
// First page: two rows, descending by OccurredAtUtc. The grid must pass the
// LAST row (the older one) back as the keyset cursor for the next page.
var first = MakeEvent(new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), AuditStatus.Delivered);
var second = MakeEvent(new DateTime(2026, 5, 20, 11, 30, 0, DateTimeKind.Utc), AuditStatus.Failed);
StubPage(new[] { first, second });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
cut.Find("[data-test=\"grid-next-page\"]").Click();
// Two service calls: initial + next.
Assert.Equal(2, _calls.Count);
var nextCall = _calls[1];
Assert.NotNull(nextCall.Paging);
Assert.Equal(second.OccurredAtUtc, nextCall.Paging!.AfterOccurredAtUtc);
Assert.Equal(second.EventId, nextCall.Paging.AfterEventId);
}
[Fact]
public void Click_Row_RaisesOnRowSelected()
{
var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
StubPage(new[] { target });
AuditEvent? captured = null;
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEvent>(this, e => captured = e)));
cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();
Assert.NotNull(captured);
Assert.Equal(target.EventId, captured!.EventId);
}
[Fact]
public void Render_IncludesNodeColumn_BetweenSiteAndChannel()
{
// Task 15: the grid surfaces SourceNode in a dedicated "Node" column
// positioned between Site and Channel.
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
Assert.Contains("data-test=\"col-header-Node\"", cut.Markup);
// The header order must place Node between Site and Channel.
var siteIdx = cut.Markup.IndexOf("data-test=\"col-header-Site\"", StringComparison.Ordinal);
var nodeIdx = cut.Markup.IndexOf("data-test=\"col-header-Node\"", StringComparison.Ordinal);
var channelIdx = cut.Markup.IndexOf("data-test=\"col-header-Channel\"", StringComparison.Ordinal);
Assert.True(siteIdx < nodeIdx, "Node column must follow Site.");
Assert.True(nodeIdx < channelIdx, "Node column must precede Channel.");
}
[Fact]
public void Render_IncludesExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
}
[Fact]
public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form.
Assert.Equal("abcdef01", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ExecutionId renders the em-dash placeholder, not a value cell.
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
}
[Fact]
public void Render_IncludesParentExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ParentExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup);
}
[Fact]
public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var parentExecutionId = Guid.Parse("fedcba98-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: parentExecutionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"parent-execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form — mirrors ExecutionId.
Assert.Equal("fedcba98", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ParentExecutionId renders the em-dash placeholder, not a value cell.
Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]"));
}
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{
var failed = MakeEvent(DateTime.UtcNow.AddMinutes(-2), AuditStatus.Failed);
var delivered = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered);
StubPage(new[] { delivered, failed });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Failed badge => bg-danger (red). Delivered => bg-success (green).
var failedBadge = cut.Find($"[data-test=\"status-badge-{failed.EventId}\"]");
Assert.Contains("bg-danger", failedBadge.GetAttribute("class") ?? string.Empty);
var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
}
// --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
//
// The drag interaction itself is browser-side (audit-grid.js) and covered
// by the Playwright suite. The bUnit tests below exercise the .NET-side
// load/apply/persist logic that the JS callbacks drive: graceful handling
// of stored orders, the reorder slot-move maths, and the resize minimum.
/// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
private static readonly string[] DefaultOrder =
{
"OccurredAtUtc", "Site", "Channel", "Kind", "Status",
"Target", "Actor", "ExecutionId", "ParentExecutionId",
"DurationMs", "HttpStatus", "ErrorMessage",
};
private static int HeaderIndex(string markup, string key)
=> markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);
[Fact]
public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
foreach (var key in DefaultOrder)
{
// Each <th> carries the stable drag key and a resize handle.
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup);
}
}
[Fact]
public void ColumnOrderParameter_DrivesHeaderOrder()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p
.Add(c => c.Filter, new AuditLogQueryFilter())
.Add(c => c.ColumnOrder, new[] { "Status", "Site" }));
// Status + Site move to the front; the omitted columns still render,
// appended in default order — Status precedes Site precedes Channel.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel"));
// No column is dropped — all ten headers are present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Drag Status onto OccurredAtUtc — Status should land in slot 0.
await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc"));
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc"));
// The new order was persisted to sessionStorage under the order key.
// Loose-mode JSInterop records every InvokeVoidAsync; find the save call.
var save = JSInterop.Invocations
.Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder");
Assert.Contains("Status", (string)save.Arguments[1]!);
}
[Fact]
public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A drag that would shrink the column to 10px must clamp to the 64px floor.
await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10));
// The clamped width is reflected as the --audit-col-width custom property.
Assert.Contains("--audit-col-width: 64px", cut.Markup);
// The width was persisted to sessionStorage under the widths key.
Assert.Contains(JSInterop.Invocations,
i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
}
[Fact]
public void StoredOrder_WithUnknownKey_DegradesGracefully()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
// A stale persisted order naming a removed column ("LegacyCol") plus a
// subset of real columns — the unknown key must be dropped and the
// omitted real columns appended in default order, never throwing.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult((string?)null);
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// Restored order applied: Status then Site at the front.
Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
// The unknown key produced no header and did not break rendering.
Assert.DoesNotContain("LegacyCol", cut.Markup);
// All ten real columns still present.
foreach (var key in DefaultOrder)
{
Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
}
}
[Fact]
public void StoredWidths_ForUnknownColumn_AreIgnored()
{
StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
.SetResult((string?)null);
// A width for a real column and one for a removed column.
JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
.SetResult("{\"Target\":220,\"LegacyCol\":300}");
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The valid column's width was applied; the stale one silently ignored.
Assert.Contains("--audit-col-width: 220px", cut.Markup);
Assert.DoesNotContain("300px", cut.Markup);
}
}
@@ -0,0 +1,299 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionDetailModal"/> (Execution-Tree Node Detail
/// Modal, Task 3). The modal opens on an execution-tree node double-click: given
/// an <c>ExecutionId</c> it loads that execution's audit rows via
/// <see cref="IAuditLogQueryService"/> and shows a list → per-row detail.
///
/// Tests pin the behaviours the spec cannot lose: load-on-open-transition,
/// the four data states (multi-row list, single-row straight-to-detail,
/// zero-row empty, query-failure error), and that closing raises OnClose.
/// </summary>
public class ExecutionDetailModalTests : BunitContext
{
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
public ExecutionDetailModalTests()
{
_service = Substitute.For<IAuditLogQueryService>();
_service.DefaultPageSize.Returns(100);
Services.AddSingleton(_service);
// AuditEventDetail (the per-row detail body) owns a clipboard interop
// call. Loose mode lets that no-op for tests that don't exercise it.
JSInterop.Mode = JSRuntimeMode.Loose;
}
private static AuditEvent MakeEvent(
Guid executionId,
AuditStatus status = AuditStatus.Delivered,
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
string? target = "demo-target")
=> new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = new DateTime(2026, 5, 20, 12, 30, 45, DateTimeKind.Utc),
Channel = channel,
Kind = kind,
Status = status,
ExecutionId = executionId,
SourceSiteId = "plant-a",
Target = target,
Actor = "tester",
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
};
private void StubRows(IReadOnlyList<AuditEvent> rows)
{
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
_calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
return Task.FromResult(rows);
});
}
[Fact]
public void ClosedModal_RendersNothing_AndDoesNotQuery()
{
StubRows(new[] { MakeEvent(Guid.NewGuid()) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, Guid.NewGuid())
.Add(c => c.IsOpen, false));
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]"));
Assert.Empty(_calls);
}
[Fact]
public void OpenTransition_QueriesByExecutionId_WithPageSize100()
{
var executionId = Guid.Parse("11111111-2222-3333-4444-555555555555");
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, false));
// Closed on first render — no query yet.
Assert.Empty(_calls);
// Flip open: the modal loads exactly once for the open transition.
cut.Render(p => p.Add(c => c.IsOpen, true));
Assert.Single(_calls);
Assert.Equal(executionId, _calls[0].Filter.ExecutionId);
Assert.NotNull(_calls[0].Paging);
Assert.Equal(100, _calls[0].Paging!.PageSize);
}
[Fact]
public void StillOpen_NonOpenParameterChange_DoesNotRequery()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
Assert.Single(_calls);
// A parameter set that does NOT flip IsOpen must not re-query.
cut.Render(p => p.Add(c => c.IsOpen, true));
Assert.Single(_calls);
}
[Fact]
public void MultiRow_RendersListView_WithOneButtonPerRow()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
// List view: a row button per audit row, keyed by EventId.
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]"));
// Not in detail view yet — no shared detail body rendered.
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
[Fact]
public void MultiRow_ClickRow_ShowsAuditEventDetail()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
cut.Find($"[data-test=\"execution-detail-row-{rowB.EventId}\"]").Click();
// The shared AuditEventDetail body is now rendered (its field list).
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
// And a Back control to return to the list.
Assert.NotNull(cut.Find("[data-test=\"execution-detail-back\"]"));
}
[Fact]
public void MultiRow_BackControl_ReturnsToList()
{
var executionId = Guid.NewGuid();
var rowA = MakeEvent(executionId, AuditStatus.Delivered);
var rowB = MakeEvent(executionId, AuditStatus.Failed);
StubRows(new[] { rowA, rowB });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]").Click();
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
cut.Find("[data-test=\"execution-detail-back\"]").Click();
// Back in the list view: row buttons present, detail body gone.
Assert.NotNull(cut.Find($"[data-test=\"execution-detail-row-{rowA.EventId}\"]"));
Assert.Empty(cut.FindAll("[data-test=\"drawer-fields\"]"));
}
[Fact]
public void SingleRow_OpensStraightToDetail_NoBackControl()
{
var executionId = Guid.NewGuid();
var only = MakeEvent(executionId);
StubRows(new[] { only });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
// Straight to detail — the shared body is rendered without a click.
Assert.NotNull(cut.Find("[data-test=\"drawer-fields\"]"));
// Nothing to go back to: the Back control is hidden for a single row.
Assert.Empty(cut.FindAll("[data-test=\"execution-detail-back\"]"));
}
[Fact]
public void ZeroRow_ShowsFriendlyEmptyState()
{
var executionId = Guid.NewGuid();
StubRows(Array.Empty<AuditEvent>());
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var empty = cut.Find("[data-test=\"execution-detail-empty\"]");
Assert.Contains("This execution emitted no audit rows.", empty.TextContent);
}
[Fact]
public void QueryThrows_ShowsInlineErrorState_DoesNotRethrow()
{
var executionId = Guid.NewGuid();
_service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<AuditEvent>>>(_ => throw new InvalidOperationException("db is down"));
// Rendering with IsOpen=true must not throw — the modal degrades to an
// inline error banner rather than killing the SignalR circuit.
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var error = cut.Find("[data-test=\"execution-detail-error\"]");
Assert.Contains("db is down", error.TextContent);
}
[Fact]
public void CloseButton_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-close\"]").Click();
Assert.True(closed);
}
[Fact]
public void BackdropClick_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-backdrop\"]").Click();
Assert.True(closed);
}
[Fact]
public void EscapeKey_RaisesOnClose()
{
var executionId = Guid.NewGuid();
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId) });
var closed = false;
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true)
.Add(c => c.OnClose, EventCallback.Factory.Create(this, () => closed = true)));
cut.Find("[data-test=\"execution-detail-modal\"]").KeyDown("Escape");
Assert.True(closed);
}
[Fact]
public void Header_ShowsShortExecutionId_AndRowCount()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
StubRows(new[] { MakeEvent(executionId), MakeEvent(executionId), MakeEvent(executionId) });
var cut = Render<ExecutionDetailModal>(p => p
.Add(c => c.ExecutionId, executionId)
.Add(c => c.IsOpen, true));
var modal = cut.Find("[data-test=\"execution-detail-modal\"]");
// Short id (first 8 hex of the "N" form) appears in the header.
Assert.Contains("abcdef01", modal.TextContent);
// Row count surfaces in the header chrome.
Assert.Contains("3", modal.TextContent);
}
}
@@ -0,0 +1,299 @@
using Bunit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Audit;
/// <summary>
/// bUnit tests for <see cref="ExecutionTree"/> (Audit Log ParentExecutionId
/// feature, Task 10). The component takes the FLAT
/// <see cref="ExecutionTreeNode"/> list the repository returns, assembles it
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, and renders it
/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node
/// presentation, the arrived-from highlight, node-click navigation, node
/// double-click raising/bubbling <see cref="ExecutionTreeNode.ExecutionId"/> via
/// <c>OnNodeActivated</c>, and cycle-safety (a corrupt flat list must not
/// infinite-loop).
/// </summary>
public class ExecutionTreeTests : BunitContext
{
private static ExecutionTreeNode Node(
Guid executionId,
Guid? parentExecutionId,
int rowCount = 2,
string? site = "plant-a",
string? instance = "boiler-3")
=> new(
executionId,
parentExecutionId,
rowCount,
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
rowCount == 0 ? Array.Empty<string>() : new[] { "Delivered" },
rowCount == 0 ? null : site,
rowCount == 0 ? null : instance,
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc));
[Fact]
public void SingleNode_RendersOneTreeNode()
{
var id = Guid.Parse("11111111-1111-1111-1111-111111111111");
var nodes = new List<ExecutionTreeNode> { Node(id, null) };
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, id));
Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup);
}
[Fact]
public void MultiLevel_AssemblesTree_FromFlatList()
{
// root → child → grandchild — a deliberately shuffled flat list so the
// component must reconstruct parent/child links rather than rely on
// input ordering.
var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000");
var child = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000");
var grandchild = Guid.Parse("cccccccc-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(grandchild, child),
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
// All three executions render as nodes.
Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
// The root must appear before the child, and the child before the
// grandchild — recursive depth-first rendering preserves ancestry.
var rootIdx = cut.Markup.IndexOf($"tree-node-{root}", StringComparison.Ordinal);
var childIdx = cut.Markup.IndexOf($"tree-node-{child}", StringComparison.Ordinal);
var grandIdx = cut.Markup.IndexOf($"tree-node-{grandchild}", StringComparison.Ordinal);
Assert.True(rootIdx < childIdx, "root must render before child");
Assert.True(childIdx < grandIdx, "child must render before grandchild");
}
[Fact]
public void StubNode_RendersStubMarker()
{
// A stub parent (RowCount = 0) referenced by a real child must still
// render, visibly marked as "no audited actions".
var stubParent = Guid.Parse("dddddddd-0000-0000-0000-000000000000");
var child = Guid.Parse("eeeeeeee-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(stubParent, null, rowCount: 0),
Node(child, stubParent),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
Assert.Contains($"data-test=\"tree-node-{stubParent}\"", cut.Markup);
Assert.Contains($"data-test=\"stub-node-{stubParent}\"", cut.Markup);
Assert.Contains("no audited actions", cut.Markup);
}
[Fact]
public void ArrivedFromNode_IsVisuallyHighlighted()
{
var root = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111");
var child = Guid.Parse("bbbbbbbb-1111-1111-1111-111111111111");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, child));
// The arrived-from node carries the highlight marker; a non-arrived
// sibling does not.
var arrived = cut.Find($"[data-test=\"tree-node-{child}\"]");
Assert.Contains("execution-tree-node--current", arrived.GetAttribute("class"));
var other = cut.Find($"[data-test=\"tree-node-{root}\"]");
Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void NodeLink_PointsTo_AuditLogFilteredByThatExecution()
{
// Each node's id is a real <a href> deep link — clicking it lands on
// the Audit Log filtered to that execution's rows. A genuine anchor
// (rather than an @onclick navigate) keeps the link middle-click /
// open-in-new-tab friendly, matching the rest of the Audit UI.
var root = Guid.Parse("aaaaaaaa-2222-2222-2222-222222222222");
var child = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root));
var childLink = cut.Find($"[data-test=\"tree-node-link-{child}\"]");
Assert.Equal($"/audit/log?executionId={child}", childLink.GetAttribute("href"));
var rootLink = cut.Find($"[data-test=\"tree-node-link-{root}\"]");
Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href"));
}
[Fact]
public void EmptyNodeList_RendersNothingWithoutThrowing()
{
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, (IReadOnlyList<ExecutionTreeNode>)Array.Empty<ExecutionTreeNode>())
.Add(c => c.ArrivedFromExecutionId, Guid.NewGuid()));
Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup);
}
[Fact]
public void CyclicFlatList_TerminatesWithoutInfiniteLoop()
{
// Defensive: a corrupt flat list where A→B and B→A must not hang the
// renderer. Each execution is rendered at most once.
var a = Guid.Parse("a0000000-0000-0000-0000-000000000000");
var b = Guid.Parse("b0000000-0000-0000-0000-000000000000");
var nodes = new List<ExecutionTreeNode>
{
Node(a, b),
Node(b, a),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, a));
// Both render exactly once — no runaway recursion.
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{a}\""));
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\""));
}
[Fact]
public void ToggleExpand_CollapsesAndReExpandsChildSubtree()
{
// root → child → grandchild. Clicking the root's toggle collapses its
// subtree (the child node disappears); clicking it again re-expands.
var root = Guid.Parse("aaaaaaaa-3333-3333-3333-333333333333");
var child = Guid.Parse("bbbbbbbb-3333-3333-3333-333333333333");
var grandchild = Guid.Parse("cccccccc-3333-3333-3333-333333333333");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
Node(grandchild, child),
};
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root));
// All nodes start expanded — the whole chain is visible on arrival.
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
var toggle = cut.Find($"[data-test=\"tree-toggle-{root}\"]");
Assert.Equal("true", toggle.GetAttribute("aria-expanded"));
// Collapse: the child (and its descendants) must disappear.
toggle.Click();
Assert.DoesNotContain($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.DoesNotContain($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
Assert.Equal(
"false",
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
// Re-expand: the child subtree reappears.
cut.Find($"[data-test=\"tree-toggle-{root}\"]").Click();
Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup);
Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup);
Assert.Equal(
"true",
cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded"));
}
[Fact]
public void DoubleClickingNode_RaisesOnNodeActivated_WithExecutionId()
{
// Double-clicking a node's body raises OnNodeActivated carrying that
// node's ExecutionId — the affordance a later task uses to open the
// node detail modal.
var root = Guid.Parse("aaaaaaaa-4444-4444-4444-444444444444");
var child = Guid.Parse("bbbbbbbb-4444-4444-4444-444444444444");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
};
Guid? activated = null;
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root)
.Add(c => c.OnNodeActivated, (Guid id) => activated = id));
var rootBody = cut.Find($"[data-test=\"tree-node-{root}\"] .execution-tree-body");
rootBody.DoubleClick();
Assert.Equal(root, activated);
}
[Fact]
public void DoubleClickingNestedNode_BubblesOnNodeActivated_ToRoot()
{
// root → child → grandchild. Double-clicking a deeply nested node's
// body invokes the SAME root-supplied callback — the EventCallback is
// threaded unchanged down every recursive ExecutionTree instance.
var root = Guid.Parse("aaaaaaaa-5555-5555-5555-555555555555");
var child = Guid.Parse("bbbbbbbb-5555-5555-5555-555555555555");
var grandchild = Guid.Parse("cccccccc-5555-5555-5555-555555555555");
var nodes = new List<ExecutionTreeNode>
{
Node(root, null),
Node(child, root),
Node(grandchild, child),
};
Guid? activated = null;
var cut = Render<ExecutionTree>(p => p
.Add(c => c.Nodes, nodes)
.Add(c => c.ArrivedFromExecutionId, root)
.Add(c => c.OnNodeActivated, (Guid id) => activated = id));
// Double-click the grandchild (two recursion levels deep).
cut.Find($"[data-test=\"tree-node-{grandchild}\"] .execution-tree-body").DoubleClick();
Assert.Equal(grandchild, activated);
// And the child (one level deep) — both reach the root's callback.
cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick();
Assert.Equal(child, activated);
}
private static int CountOccurrences(string haystack, string needle)
{
int count = 0, idx = 0;
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
{
count++;
idx += needle.Length;
}
return count;
}
}
@@ -0,0 +1,158 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="AuditKpiTiles"/> (#23 M7 Bundle E / M7-T13). The
/// component renders three Bootstrap-card tiles — Volume, Error Rate, Backlog —
/// from a single <see cref="AuditLogKpiSnapshot"/>. The tests pin:
///
/// <list type="bullet">
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
/// <item>Error-rate maths: <c>ErrorEventsLastHour / TotalEventsLastHour</c> with
/// safe zero-events handling (no DivideByZero, displays "0.0%").</item>
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
/// <item>Tile clicks navigate to the correct pre-filtered Audit Log URL.</item>
/// </list>
/// </summary>
public class AuditKpiTilesTests : BunitContext
{
private static AuditLogKpiSnapshot MakeSnapshot(long total, long errors, long backlog) =>
new(total, errors, backlog, new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 120, errors: 3, backlog: 7))
.Add(c => c.IsAvailable, true));
// Three stable data-test selectors — these are the contract for both
// tests and any future Playwright sweep.
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-error-rate\"", cut.Markup);
Assert.Contains("data-test=\"audit-kpi-backlog\"", cut.Markup);
// Tile values render the snapshot's counters.
Assert.Contains("120", cut.Markup); // volume
Assert.Contains("7", cut.Markup); // backlog
}
[Fact]
public void ErrorRate_Computed_From_Total_AndErrors()
{
// 5 errors out of 100 → 5.0%.
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
.Add(c => c.IsAvailable, true));
Assert.Contains("5.0%", cut.Markup);
}
[Fact]
public void ZeroEvents_DoesNotDivideByZero_RendersZeroPercent()
{
// Total = 0 → naïve division would throw or yield NaN. The tile must
// render "0.0%" instead (zero events means zero errors too — a real
// signal, not an unavailability marker).
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 0, errors: 0, backlog: 0))
.Add(c => c.IsAvailable, true));
Assert.Contains("0.0%", cut.Markup);
// And the volume tile shows "0", not an em dash — the snapshot itself
// is available; the system was just quiet for the hour.
Assert.Contains("data-test=\"audit-kpi-volume\"", cut.Markup);
}
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, (AuditLogKpiSnapshot?)null)
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "DB connection refused"));
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
Assert.Contains("—", cut.Markup);
// Inline error message renders below.
Assert.Contains("Audit KPIs unavailable", cut.Markup);
Assert.Contains("DB connection refused", cut.Markup);
}
[Fact]
public void ErrorRateTile_Click_NavigatesToAuditLog_WithFailedStatusFilter()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
.Add(c => c.IsAvailable, true));
// bUnit's BunitNavigationManager records the last URI a Navigation.NavigateTo call hit.
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
tile.Click();
// Spec: error-rate tile drills into ?status=Failed.
Assert.Contains("/audit/log?status=Failed", nav.Uri);
}
[Fact]
public void VolumeTile_Click_NavigatesToUnfilteredAuditLog()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 3, backlog: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"audit-kpi-volume\"]");
tile.Click();
// Unfiltered /audit/log — no query string.
Assert.EndsWith("/audit/log", nav.Uri);
}
[Fact]
public void BacklogTile_Click_NavigatesToAuditLog()
{
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 50, errors: 0, backlog: 12))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"audit-kpi-backlog\"]");
tile.Click();
Assert.EndsWith("/audit/log", nav.Uri);
}
[Fact]
public void NonzeroErrorRate_GetsWarningBorder_NotDangerBelowTenPercent()
{
// 5% is < 10% → warning border, not danger.
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 5, backlog: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void HighErrorRate_GetsDangerBorder()
{
// 25% is > 10% → danger border.
var cut = Render<AuditKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(total: 100, errors: 25, backlog: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"audit-kpi-error-rate\"]");
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
}
@@ -0,0 +1,177 @@
using Bunit;
using Bunit.TestDoubles;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Components.Health;
/// <summary>
/// bUnit tests for <see cref="SiteCallKpiTiles"/> (Site Call Audit #22, Task 7).
/// The component renders three Bootstrap-card tiles — Buffered, Stuck, Parked —
/// from a single <see cref="SiteCallKpiResponse"/> snapshot. The tests pin:
///
/// <list type="bullet">
/// <item>Three-tile render contract (data-test attributes for stable selectors).</item>
/// <item>Tile values render the snapshot's counters.</item>
/// <item>Threshold borders fire correctly — danger on Parked &gt; 0, warning
/// on Stuck &gt; 0, none when those counts are zero, none on Buffered.</item>
/// <item>Unavailable snapshot renders em dashes plus the error message.</item>
/// <item>Tile clicks navigate to the correct pre-filtered Site Calls report URL.</item>
/// </list>
/// </summary>
public class SiteCallKpiTilesTests : BunitContext
{
private static SiteCallKpiResponse MakeSnapshot(int buffered, int parked, int stuck) =>
new(
CorrelationId: "k",
Success: true,
ErrorMessage: null,
BufferedCount: buffered,
ParkedCount: parked,
FailedLastInterval: 0,
DeliveredLastInterval: 0,
OldestPendingAge: null,
StuckCount: stuck);
[Fact]
public void Renders_ThreeTiles_FromSnapshot()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 120, parked: 3, stuck: 7))
.Add(c => c.IsAvailable, true));
// Three stable data-test selectors — the contract for both these tests
// and any future Playwright sweep.
Assert.Contains("data-test=\"site-call-kpi-buffered\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-stuck\"", cut.Markup);
Assert.Contains("data-test=\"site-call-kpi-parked\"", cut.Markup);
// Tile values render the snapshot's counters.
Assert.Contains(">120<", cut.Markup); // buffered
Assert.Contains(">7<", cut.Markup); // stuck
Assert.Contains(">3<", cut.Markup); // parked
}
[Fact]
public void UnavailableSnapshot_RendersEmDashes_AndErrorMessage()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, (SiteCallKpiResponse?)null)
.Add(c => c.IsAvailable, false)
.Add(c => c.ErrorMessage, "site call repository unavailable"));
// All three tiles show em dashes — em dash (U+2014) "—" must appear.
Assert.Contains("—", cut.Markup);
// Inline error message renders below.
Assert.Contains("Site Call KPIs unavailable", cut.Markup);
Assert.Contains("site call repository unavailable", cut.Markup);
}
[Fact]
public void ParkedTile_GetsDangerBorder_WhenParkedAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.Contains("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void ParkedTile_NoDangerBorder_WhenParkedZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_GetsWarningBorder_WhenStuckAboveZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.Contains("border-warning", tile.GetAttribute("class") ?? string.Empty);
// Warning, not danger — Stuck is the softer signal.
Assert.DoesNotContain("border-danger", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void StuckTile_NoWarningBorder_WhenStuckZero()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 9, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
Assert.DoesNotContain("border-warning", tile.GetAttribute("class") ?? string.Empty);
}
[Fact]
public void BufferedTile_HasNoThresholdBorder_EvenWithHighCount()
{
// A non-zero buffer is normal operation — the Buffered tile is a plain
// count tile and never gets a danger/warning border.
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 5000, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
var cls = tile.GetAttribute("class") ?? string.Empty;
Assert.DoesNotContain("border-danger", cls);
Assert.DoesNotContain("border-warning", cls);
}
[Fact]
public void BufferedTile_Click_NavigatesToUnfilteredSiteCallsReport()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 50, parked: 0, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-buffered\"]");
tile.Click();
// Unfiltered /site-calls/report — no query string.
Assert.EndsWith("/site-calls/report", nav.Uri);
}
[Fact]
public void StuckTile_Click_NavigatesToSiteCallsReport_WithStuckFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 0, stuck: 6))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-stuck\"]");
tile.Click();
// Spec: Stuck tile drills into the report's "stuck only" filter.
Assert.Contains("/site-calls/report?stuck=true", nav.Uri);
}
[Fact]
public void ParkedTile_Click_NavigatesToSiteCallsReport_WithParkedStatusFilter()
{
var cut = Render<SiteCallKpiTiles>(p => p
.Add(c => c.Snapshot, MakeSnapshot(buffered: 0, parked: 4, stuck: 0))
.Add(c => c.IsAvailable, true));
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
var tile = cut.Find("[data-test=\"site-call-kpi-parked\"]");
tile.Click();
// Spec: Parked tile drills into ?status=Parked.
Assert.Contains("/site-calls/report?status=Parked", nav.Uri);
}
}