using Bunit; using ScadaLink.CentralUI.Components.Audit; using ScadaLink.Commons.Types.Audit; namespace ScadaLink.CentralUI.Tests.Components.Audit; /// /// bUnit tests for (Audit Log ParentExecutionId /// feature, Task 10). The component takes the FLAT /// list the repository returns, assembles it /// into a tree by joining to a /// parent node's , and renders it /// recursively. Tests pin: single-node tree, multi-level assembly, stub-node /// presentation, the arrived-from highlight, node-click navigation, and /// cycle-safety (a corrupt flat list must not infinite-loop). /// 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() : new[] { "ApiOutbound" }, rowCount == 0 ? Array.Empty() : 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 { Node(id, null) }; var cut = Render(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 { Node(grandchild, child), Node(root, null), Node(child, root), }; var cut = Render(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 { Node(stubParent, null, rowCount: 0), Node(child, stubParent), }; var cut = Render(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 { Node(root, null), Node(child, root), }; var cut = Render(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 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 { Node(root, null), Node(child, root), }; var cut = Render(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(p => p .Add(c => c.Nodes, (IReadOnlyList)Array.Empty()) .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 { Node(a, b), Node(b, a), }; var cut = Render(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 { Node(root, null), Node(child, root), Node(grandchild, child), }; var cut = Render(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 { Node(root, null), Node(child, root), }; Guid? activated = null; var cut = Render(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 { Node(root, null), Node(child, root), Node(grandchild, child), }; Guid? activated = null; var cut = Render(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; } }