fix(central-ui): Flow Debugger spawn edges — real arrowhead + bolt badge; spawner picked by trigger attribute

The trigger-cascade (Spawn) edge used a lightning-bolt marker IN PLACE of the arrowhead. On the upward
Tags→Site hop the bolt sat on top of the riser and read as a broken arrow (rig screenshot 2026-09-08).
Every connector now ends in an ordinary arrowhead; the bolt is a badge (circle + bolt) centred on the
connector's vertical run (FlowGraphView.SpawnBadgePoint, kept in step with OrthogonalPath/AsyncPath).

Same screenshot, second defect: the cascade spawned by "RecipeDownloadFlag = false" was drawn from the
LAST write of the burst (RecipeProcessedFlag) because all four writes share a millisecond and the
spawner rule was time-only. FlowGraphBuilder now prefers the parent's last AttributeWrite of the attribute
the child's triggerAttribute names (at or before the child, else the last such write); the time/position
rules remain the fallback when the child names no attribute or the parent never wrote it.

Verified live on the rebuilt rig (DelmiaSim01 / DelmiaReceiverSimulator): both spawn edges show an
arrowhead + badge, the second one originates from the RecipeDownloadFlag write. bUnit + builder tests
updated/added; Playwright FlowDebuggerTests 4/4.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-08 09:04:39 -04:00
parent cb8c6eb813
commit 859922ba17
7 changed files with 174 additions and 18 deletions
@@ -456,20 +456,65 @@ public class FlowGraphViewTests : BunitContext
}
[Fact]
public void Render_AsyncEdgesAreDashedClass_SpawnEdgeUsesTheBoltMarker()
public void Render_AsyncEdgesAreDashedClass_SpawnEdgeKeepsAnArrowheadAndGetsABoltBadge()
{
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, OutboundGraph()));
var graph = OutboundGraph();
var cut = Render<FlowGraphView>(p => p.Add(c => c.Graph, graph));
Assert.NotNull(cut.Find("marker#fg-arrow-spawn"));
// Rig 2026-09-08: the bolt used to replace the arrowhead and read as a broken arrow on the upward
// Tags→Site hop. Every edge now ends in a real arrowhead; the bolt is a badge on the connector.
Assert.Empty(cut.FindAll("marker#fg-arrow-spawn"));
var edges = cut.FindAll("path[data-test='flow-edge']");
var async = edges.Where(e => e.ClassList.Contains("async")).ToList();
Assert.Equal(2, async.Count);
Assert.All(async, e => Assert.Equal("url(#fg-arrow)", e.GetAttribute("marker-end")));
Assert.All(async, e => Assert.DoesNotContain("external", e.ClassList));
var spawn = Assert.Single(edges, e => e.ClassList.Contains("spawn"));
Assert.Equal("url(#fg-arrow-spawn)", spawn.GetAttribute("marker-end"));
Assert.All(edges.Where(e => !e.ClassList.Contains("spawn")), e => Assert.NotEqual("url(#fg-arrow-spawn)", e.GetAttribute("marker-end")));
Assert.Equal("url(#fg-arrow)", spawn.GetAttribute("marker-end"));
Assert.All(edges, e => Assert.DoesNotContain(",", e.GetAttribute("d")));
var badge = Assert.Single(cut.FindAll("g[data-test='flow-edge-spawn']"));
Assert.NotNull(badge.QuerySelector("path.fg-spawn-bolt"));
Assert.NotNull(badge.QuerySelector("circle.fg-spawn-badge-bg"));
// The badge sits on the riser of the edge it belongs to: between the two nodes horizontally and
// between the two lanes vertically, so it never overlaps either box.
var layout = FlowGraphView.ComputeLayout(graph, null);
var a = layout.Nodes.Single(n => n.Key == spawn.GetAttribute("data-from"));
var b = layout.Nodes.Single(n => n.Key == spawn.GetAttribute("data-to"));
var (x, y) = FlowGraphView.SpawnBadgePoint(a, b, isAsync: false);
Assert.Equal($"translate({FlowGraphView.F(x)} {FlowGraphView.F(y)})", badge.GetAttribute("transform"));
Assert.InRange(x, a.X + FlowGraphView.NodeWidth, b.X);
Assert.InRange(y, Math.Min(a.Y, b.Y) + FlowGraphView.NodeHeight / 2, Math.Max(a.Y, b.Y) + FlowGraphView.NodeHeight / 2);
}
[Fact]
public void SpawnBadgePoint_SitsOnTheVerticalRunOfEachConnectorShape()
{
var w = FlowGraphView.NodeWidth; var h = FlowGraphView.NodeHeight;
FlowLayoutNode N(string k, FlowLane lane, int col, double x, double y) => new(k, lane, col, x, y, null, null, false);
var tagsWrite = N("w", FlowLane.Tags, 0, 160, 16 + 2 * 110);
var siteRun = N("r", FlowLane.Site, 1, 330, 16 + 110);
// Upward Tags→Site hop (the rig shape): mid-gap x, halfway between the two lane centres.
var (x, y) = FlowGraphView.SpawnBadgePoint(tagsWrite, siteRun, isAsync: false);
Assert.Equal(160 + w + (330 - (160 + w)) / 2, x);
Assert.Equal(((16 + 220 + h / 2) + (16 + 110 + h / 2)) / 2, y);
Assert.Contains($"H {FlowGraphView.F(x)} V", FlowGraphView.OrthogonalPath(tagsWrite, siteRun));
// Same lane: the middle of the straight line.
var next = N("n", FlowLane.Tags, 1, 330, tagsWrite.Y);
Assert.Equal((160 + w + 330) / 2, FlowGraphView.SpawnBadgePoint(tagsWrite, next, false).X);
Assert.Equal(tagsWrite.Y + h / 2, FlowGraphView.SpawnBadgePoint(tagsWrite, next, false).Y);
// Backwards: on the loop under both nodes.
var back = N("b", FlowLane.Site, 0, 160, siteRun.Y);
Assert.Equal(Math.Max(tagsWrite.Y, back.Y) + h + 8, FlowGraphView.SpawnBadgePoint(next, back, false).Y);
// Async drop to a lower lane: on the first vertical run out of the source's bottom-centre.
var external = N("e", FlowLane.External, 1, 330, 16 + 3 * 110);
var (ax, ay) = FlowGraphView.SpawnBadgePoint(siteRun, external, isAsync: true);
Assert.Equal(siteRun.X + w / 2, ax);
Assert.InRange(ay, siteRun.Y + h, external.Y - FlowGraphView.LanePad / 2);
}
[Theory]
@@ -472,6 +472,47 @@ public class FlowGraphBuilderTests
Assert.Equal(cascade.EventId, spawn.ToEventId);
}
[Fact]
public void SimultaneousWrites_TriggerCascade_SpawnsFromTheWriteOfTheTriggerAttribute_NotTheLastWrite()
{
// Rig 2026-09-08: a run wrote Result, ResultText, Flag=false and Processed inside the same millisecond,
// and the value-change child (trigger attribute Flag) was drawn spawning from Processed — the last write
// at or before the child. The child names its trigger attribute, so that write wins outright.
var run = SiteRunRow(0);
var result = WriteRow(10, "DelmiaReceiver.Result");
var flag = WriteRow(10, "DelmiaReceiver.Flag");
var processed = WriteRow(10, "DelmiaReceiver.Processed");
var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(10), actor: "OnFlag", target: "OnFlag", sourceNode: "node-a",
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "delmiareceiver.flag" }.ToJson());
var graph = FlowGraphBuilder.Build([run, result, flag, processed, cascade]);
var spawn = Assert.Single(graph.Edges, e => e.Spawn);
Assert.Equal(flag.EventId, spawn.FromEventId);
Assert.Equal(cascade.EventId, spawn.ToEventId);
}
[Fact]
public void TriggerCascade_WhoseAttributeTheParentNeverWrote_FallsBackToTheTimeRule()
{
// The device echo of a DCL-sourced change, or a trace from an older site that names an attribute
// the parent chain has no write for: the attribute rule finds nothing and the time rule still applies.
var run = SiteRunRow(0);
var writeA = WriteRow(10, "DelmiaReceiver.A");
var writeB = WriteRow(30, "DelmiaReceiver.B");
var cascade = ScadaBridgeAuditEventFactory.Create(AuditChannel.ScriptTrace, AuditKind.ScriptRun, AuditStatus.Delivered,
occurredAtUtc: T0.AddMilliseconds(15), actor: "OnX", target: "OnX", sourceNode: "node-a",
executionId: Cascade, parentExecutionId: SiteRun, sourceSiteId: "site-a", sourceInstanceId: "M1", durationMs: 3,
extra: new ScriptTraceExtra { TriggerKind = "valuechange", TriggerAttribute = "DelmiaReceiver.X" }.ToJson());
var graph = FlowGraphBuilder.Build([run, writeA, writeB, cascade]);
var spawn = Assert.Single(graph.Edges, e => e.Spawn);
Assert.Equal(writeA.EventId, spawn.FromEventId);
}
[Fact]
public void FoldedNode_TakesTheEmissionIndexOfItsEarliestEmittedRow()
{