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
@@ -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()
{