refactor(centralui): complete cycle fallback + polish in ExecutionTree

This commit is contained in:
Joseph Doherty
2026-05-21 18:56:03 -04:00
parent 34a4356625
commit 9b1f78638b
2 changed files with 88 additions and 20 deletions

View File

@@ -44,7 +44,11 @@ public partial class ExecutionTree
/// Recursive — children are themselves <see cref="Subtree"/> values. /// Recursive — children are themselves <see cref="Subtree"/> values.
/// </summary> /// </summary>
/// <param name="Node">The execution this subtree is rooted at.</param> /// <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); public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
/// <summary> /// <summary>
@@ -78,6 +82,11 @@ public partial class ExecutionTree
// or taken straight from PreBuiltRoots on a nested instance. // or taken straight from PreBuiltRoots on a nested instance.
private IReadOnlyList<Subtree> _rootsToRender = Array.Empty<Subtree>(); 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): // Per-execution expand/collapse state. Absent => expanded (the default):
// the whole chain is shown on arrival so the user sees the full picture. // the whole chain is shown on arrival so the user sees the full picture.
private readonly HashSet<Guid> _collapsed = new(); private readonly HashSet<Guid> _collapsed = new();
@@ -91,15 +100,25 @@ public partial class ExecutionTree
return; return;
} }
// Root instance: assemble the flat list into a tree. // Root instance: assemble the flat list into a tree. Re-assemble only
_rootsToRender = BuildForest(Nodes ?? Array.Empty<ExecutionTreeNode>()); // 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> /// <summary>
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of /// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
/// <see cref="Subtree"/> values. There is normally exactly one root (the /// <see cref="Subtree"/> values. There is normally exactly one root (the
/// chain's topmost ancestor); the method returns a list to stay total if /// 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> /// </summary>
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> nodes) 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>(); var visited = new HashSet<Guid>();
return roots var forest = roots
.OrderBy(SortKey) .OrderBy(SortKey)
.Select(root => BuildSubtree(root, childrenByParent, visited)) .Select(root => BuildSubtree(root, childrenByParent, visited))
.ToList(); .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> /// <summary>
@@ -231,11 +259,8 @@ public partial class ExecutionTree
return $"{firstText} → {Iso(last)}"; 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) private static string Iso(DateTime utc)
{ => utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
var kind = utc.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(utc, DateTimeKind.Utc)
: utc;
return kind.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
}
} }

View File

@@ -184,6 +184,49 @@ public class ExecutionTreeTests : BunitContext
Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\"")); 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) private static int CountOccurrences(string haystack, string needle)
{ {
int count = 0, idx = 0; int count = 0, idx = 0;