diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs index a5a5aef..c415d48 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs @@ -44,7 +44,11 @@ public partial class ExecutionTree /// Recursive — children are themselves values. /// /// The execution this subtree is rooted at. - /// Child subtrees, ordered by first-occurrence time. + /// + /// Child subtrees, ordered by (FirstOccurredAtUtc ?? DateTime.MaxValue, + /// ExecutionId) — earliest first-occurrence time first, stub nodes + /// (null timestamp) last, with ExecutionId breaking ties. + /// public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList Children); /// @@ -78,6 +82,11 @@ public partial class ExecutionTree // or taken straight from PreBuiltRoots on a nested instance. private IReadOnlyList _rootsToRender = Array.Empty(); + // The Nodes reference the current _rootsToRender was assembled from. Used + // to skip a redundant re-assembly when OnParametersSet fires for an + // unrelated parameter change (the flat list itself is unchanged). + private IReadOnlyList? _assembledFrom; + // Per-execution expand/collapse state. Absent => expanded (the default): // the whole chain is shown on arrival so the user sees the full picture. private readonly HashSet _collapsed = new(); @@ -91,15 +100,25 @@ public partial class ExecutionTree return; } - // Root instance: assemble the flat list into a tree. - _rootsToRender = BuildForest(Nodes ?? Array.Empty()); + // Root instance: assemble the flat list into a tree. Re-assemble only + // when the Nodes reference itself changes — OnParametersSet also fires + // for unrelated parameter changes (e.g. ArrivedFromExecutionId), and + // re-running assembly then would needlessly rebuild an identical tree. + if (!ReferenceEquals(Nodes, _assembledFrom)) + { + _assembledFrom = Nodes; + _rootsToRender = BuildForest(Nodes ?? Array.Empty()); + } } /// /// Assembles the flat list into a forest of /// values. There is normally exactly one root (the /// chain's topmost ancestor); the method returns a list to stay total if - /// the input ever contains disjoint fragments. + /// the input ever contains disjoint fragments. A fully-cyclic feed has no + /// real root, so each remaining cyclic component is seeded with a fallback + /// root after the main pass — every execution in + /// is therefore placed in the forest exactly once. /// private static IReadOnlyList BuildForest(IReadOnlyList nodes) { @@ -137,20 +156,29 @@ public partial class ExecutionTree } } - // Cycle guard: if the input is fully cyclic (A→B, B→A) every node has a - // present parent, so `roots` is empty even though there is data to - // show. Fall back to treating the lowest-ordered id as the root so the - // chain still renders — the visited-set below then breaks the cycle. - if (roots.Count == 0) - { - roots.Add(byId.Values.OrderBy(n => n.ExecutionId).First()); - } - var visited = new HashSet(); - return roots + var forest = roots .OrderBy(SortKey) .Select(root => BuildSubtree(root, childrenByParent, visited)) .ToList(); + + // Cycle guard: if the input is fully cyclic every node has a present + // parent, so a cyclic component contributes no entry to `roots`. Any + // execution still missing from `visited` after the pass above belongs + // to such a component (a corrupt feed may contain several independent + // cycles, e.g. A↔B and C↔D). Seed the lowest-ordered unvisited id of + // each remaining component as an extra root and assemble it, looping + // until every node has been placed — so every execution renders. + while (visited.Count < byId.Count) + { + var fallbackRoot = byId.Values + .Where(n => !visited.Contains(n.ExecutionId)) + .OrderBy(SortKey) + .First(); + forest.Add(BuildSubtree(fallbackRoot, childrenByParent, visited)); + } + + return forest; } /// @@ -231,11 +259,8 @@ public partial class ExecutionTree return $"{firstText} → {Iso(last)}"; } + // Audit timestamps are UTC by system convention, so the value is formatted + // with a literal 'Z' suffix without re-tagging its DateTimeKind. private static string Iso(DateTime utc) - { - var kind = utc.Kind == DateTimeKind.Unspecified - ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) - : utc; - return kind.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); - } + => utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); } diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs index 5da931e..43b5825 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs @@ -184,6 +184,49 @@ public class ExecutionTreeTests : BunitContext 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")); + } + private static int CountOccurrences(string haystack, string needle) { int count = 0, idx = 0;