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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user