feat(centralui): execution-chain tree view on the Audit Log page
This commit is contained in:
@@ -303,6 +303,48 @@ public class AuditDrilldownDrawerTests : BunitContext
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
using Bunit;
|
||||
using ScadaLink.CentralUI.Components.Audit;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.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, 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}\""));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user