refactor(centralui): complete cycle fallback + polish in ExecutionTree
This commit is contained in:
@@ -44,7 +44,11 @@ public partial class ExecutionTree
|
||||
/// Recursive — children are themselves <see cref="Subtree"/> values.
|
||||
/// </summary>
|
||||
/// <param name="Node">The execution this subtree is rooted at.</param>
|
||||
/// <param name="Children">Child subtrees, ordered by first-occurrence time.</param>
|
||||
/// <param name="Children">
|
||||
/// Child subtrees, ordered by <c>(FirstOccurredAtUtc ?? DateTime.MaxValue,
|
||||
/// ExecutionId)</c> — earliest first-occurrence time first, stub nodes
|
||||
/// (null timestamp) last, with <c>ExecutionId</c> breaking ties.
|
||||
/// </param>
|
||||
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
|
||||
|
||||
/// <summary>
|
||||
@@ -78,6 +82,11 @@ public partial class ExecutionTree
|
||||
// or taken straight from PreBuiltRoots on a nested instance.
|
||||
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>();
|
||||
|
||||
// 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<ExecutionTreeNode>? _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<Guid> _collapsed = new();
|
||||
@@ -91,15 +100,25 @@ public partial class ExecutionTree
|
||||
return;
|
||||
}
|
||||
|
||||
// Root instance: assemble the flat list into a tree.
|
||||
// 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<ExecutionTreeNode>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
|
||||
/// <see cref="Subtree"/> 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 <paramref name="nodes"/>
|
||||
/// is therefore placed in the forest exactly once.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> 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<Guid>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<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"));
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string haystack, string needle)
|
||||
{
|
||||
int count = 0, idx = 0;
|
||||
|
||||
Reference in New Issue
Block a user