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
+1 -1
View File
@@ -218,7 +218,7 @@ Per-leaf alarm rendering (leaf nodes are individual conditions for native alarms
### Flow Debugger (Flow Debugger policy; Live run = Deployment Role)
- **`/deployment/flow-debugger`** (nav: Deployment → Flow Debugger; design `docs/plans/2026-09-05-flow-debugger-design.md`, plan `docs/plans/2026-09-05-flow-debugger.md`). Runs one entry point with operator-supplied inputs and renders every step the run passes through as a **four-lane swimlane** (Central / Site / Tags / **External**) — request, route call, site script run, attribute writes, attribute waits, **outbound API / database / notification calls**, response — each node carrying its input, output, duration and outcome; a tabbed detail panel (Input · Output · Writes *or* Lifecycle · Timing, plus a *Where this ran* column) opens on the selected node with `Open in Debug View` (script entries, where the instance is known) and `Audit rows (n)` (→ `/audit/log?executionId=`) drill-ins.
- **Two modes.** *Live* is a real run — the method script executes on the active central node with tracing on, or a real attribute write goes through the routed-write path and the site's triggered run is polled back — audited like any other call. *Replay* executes nothing: it rebuilds the flow from an existing execution's `ScriptTrace` audit rows plus its `ApiOutbound` / `DbOutbound` / `Notification` rows (`?executionId=` drill-in from the Audit Log drawer's and the Execution Chain page's **Open in Flow Debugger** actions).
- **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&amp;F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a bolt marker. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=<id>`** / **`/notifications/report?correlationId=<id>`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser).
- **External lane (v1.1, plan `docs/plans/2026-09-06-flow-debugger-outbound-calls.md`).** Every `ExternalSystem.Call/CachedCall`, `Database.*` and `Notify.Send` a traced run makes is a node on the fourth lane — kinds `ApiCall` / `DbCall` / `Notify` (eyebrows `API CALL` / `DATABASE` / `NOTIFY`), title = the system.method / connection / list, a status pill (`Ok` / `Pending` / `Parked` / `Failed`) plus `sync` | `cached` | `notify`. **One node per operation:** a cached call's or notification's later lifecycle rows (Forwarded → Attempted → Delivered / Parked, written minutes later by the S&amp;F forwarder or the Notification Outbox) fold into the node (`FlowStep.Lifecycle`, a Lifecycle tab in the detail panel with *Delivered by*) instead of drawing four boxes. A sync call sits in the execution chain; a cached call or notification hangs off it on a dashed **async** connector, and a trigger cascade's spawn edge carries a **bolt badge on the connector's riser** — the edge itself keeps an ordinary arrowhead (the bolt was the arrowhead until 2026-09-08, and on the upward Tags→Site hop it read as a broken arrow). The spawn edge starts at the parent's **last write of the attribute the child's `triggerAttribute` names** (`FlowGraphBuilder`); only when the child names no attribute, or the parent never wrote it, does the timestamp rule (last non-async parent step at or before the child) decide — a burst of writes landing in one millisecond used to draw the cascade from the last of them rather than the one that fired it. A Live run returns only the rows the run itself emitted, so a graph left with `Pending` nodes **refreshes itself** through the Replay command every 5 s for up to 2 min (page constants), and a *Refresh outcomes* button re-reads on demand in either mode; the state chip stays *Completed* — pending outbound work is not a failed run. A refresh **merges** the re-read rows into the run's known rows by `EventId` (fresh copy wins) and rebuilds the graph from the union, so it only ever adds nodes: the site's outbound rows reach central's audit store by telemetry seconds after the run returned them inline, and a Replay read taken in between would otherwise erase an already-drawn node (observed on the rig 2026-09-06 with a resolved cached DB write). The union is exact because the inline rows are the very `AuditEvent` objects the site writer persists. **v1.2 (follow-ups plan `docs/plans/2026-09-06-flow-debugger-followups.md`, register row 111 closed):** a folded node carries its `CorrelationId` (`FlowStep.CorrelationId` — the `TrackedOperationId` / `NotificationId`), so the detail panel deep-links to **`/site-calls/report?correlationId=<id>`** / **`/notifications/report?correlationId=<id>`** (*Open in Site Calls* / *Open in Notification report*; both pages accept that query, pre-filter to the one row through an additive per-id query filter — `SiteCallQueryRequest.TrackedOperationIdFilter` / `NotificationOutboxQueryRequest.NotificationIdFilter` — show a removable *Operation* / *Notification* pill and auto-open the row's detail modal; note `NotificationId` is minted as a Guid in **`N` form** — no dashes — so the Notify link passes `N` and the report page normalises any Guid-shaped value to `N`, while `TrackedOperationId` is a `varchar(36)` `D` string and the Site Calls filter binds a `varchar` parameter for the PK seek), and a **`Parked` node offers Retry / Discard in place**: the same site-scope re-check, confirm dialog (Discard = danger) and central→site / Notification Outbox relay the report pages use (`IFlowDebugService.RetryOutboundAsync` / `DiscardOutboundAsync` → `CommunicationService`), then the page re-polls Replay (5 s / 2 min) until the node leaves `Parked`. **Prediction is async-safe:** `FlowDebugService.PredictMethodAsync` resolves route targets by a resolve-and-rewalk loop — its original blocking resolver deadlocked the whole Blazor circuit on the renderer sync context the moment a method routing to a site script was chosen (found 2026-09-06; pinned by a single-thread-pump unit test and the Playwright `FlowDebuggerTests`, which log in through the fixture and run a Live run of an ephemeral method in a real browser).
- **Authorization (arch-review F3).** The page is `RequireFlowDebugger` — `AuthorizationPolicies.FlowDebuggerAccessRoles` = Deployer, Administrator, Viewer — so an auditor can open it and Replay a past execution without the ability to run anything, and `FlowDebugReplayCommand` accepts the same set. **Everything that executes stays Deployer-only:** the three run commands (`FlowDebugRunMethodCommand` / `FlowDebugRunScriptCommand` / `FlowDebugRunValueChangeCommand`), the Live entry-point controls, the parked-node Retry/Discard actions and the `?methodId=` / `?instanceId=` / `?script=` drill-ins; for a non-Deployer the page pins itself to Replay mode.
- **Two entry points** (script entry redesigned 2026-09-08, `docs/plans/2026-09-08-flow-debugger-script-entry-design.md`). An inbound API method (inputs form generated from the method's `ParameterDefinitions`, values coerced by declared type) or a **site script** (Site → Instance → Script pickers, site-scope filtered): a script whose trigger is ValueChange or Conditional shows one *New value for `<attribute>`* field, with the attribute derived from the script's own trigger configuration rather than picked; every other script kind (Call, Interval, Expression, or no trigger) shows a `ParameterValueForm` generated from the script's `ParameterDefinitions`, or a "this script takes no parameters" note when there are none. A method-entry Live run is additionally site-scoped **at route time** on the server (`RouteHelper.WithSiteScope` → `SITE_SCOPE_DENIED`, arch-review F2), so an operator cannot reach a site outside their scope by choosing a method that routes there.
- **Predicted graph first.** Choosing an entry point renders the statically predicted shape (`FlowShapePredictor`, Script Analysis REQ-SA-7) before anything runs — nodes whose target depends on inputs render dashed; after a run, predicted nodes the run never produced render greyed.
@@ -17,10 +17,6 @@
<marker id="fg-arrow-accent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" class="fg-arrowhead accent" />
</marker>
@* Trigger spawn (plan D9): a small bolt in place of the arrowhead — this write fired a value-change script. *@
<marker id="fg-arrow-spawn" viewBox="0 0 12 14" refX="10" refY="7" markerWidth="12" markerHeight="14" orient="auto-start-reverse">
<path d="M 5 1 l -4 6 h 5 l -3 6 l 7 -8 h -5 l 3 -4 z" class="fg-arrowhead spawn" />
</marker>
</defs>
@* Lanes *@
@@ -57,6 +53,17 @@
@* Names the branch where it lands: the run carried on without waiting for this. *@
@SvgText(tag.X, tag.Y, "fg-edge-tag", "async", dataTest: "flow-edge-tag")
}
@if (SpawnBadgeFor(e) is { } badge)
{
@* Trigger cascade (plan D9): a bolt badge on the connector itself — this write fired a value-change
script. The edge keeps an ordinary arrowhead; the bolt used to BE the arrowhead, and on the
upward Tags→Site hop it read as a broken arrow (rig, 2026-09-08). *@
<g class="fg-spawn-badge" transform="translate(@badge.X @badge.Y)" data-test="flow-edge-spawn">
<title>Trigger cascade: this write fired the script</title>
<circle r="8" class="fg-spawn-badge-bg" />
<path d="M -1 -6 l -4 6 h 5 l -3 6 l 7 -8 h -5 l 3 -4 z" class="fg-spawn-bolt" />
</g>
}
}
@* Nodes *@
@@ -55,7 +55,7 @@ public sealed record FlowLayoutNode(
/// <param name="External">Dashed — an external actor (the tag source responding).</param>
/// <param name="PredictedOnly">Light — an edge of the prediction, not of a run.</param>
/// <param name="Async">Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4).</param>
/// <param name="Spawn">Bolt marker — the target is the first step of a trigger-spawned child execution (plan D9).</param>
/// <param name="Spawn">Bolt badge on the connector — the target is the first step of a trigger-spawned child execution (plan D9).</param>
public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false);
/// <summary>A computed swimlane layout.</summary>
@@ -184,8 +184,9 @@ public partial class FlowGraphView : ComponentBase
return sb.ToString();
}
/// <summary>Every connector ends in an arrowhead — a spawn edge marks itself with a bolt badge on the line instead (<see cref="SpawnBadgeFor"/>).</summary>
private static string MarkerFor(FlowLayoutEdge e)
=> e.Spawn ? "url(#fg-arrow-spawn)" : e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
=> e.CrossesBoundary ? "url(#fg-arrow-accent)" : "url(#fg-arrow)";
/// <summary>True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line.</summary>
private static bool HasPill(FlowLayoutNode n)
@@ -351,6 +352,47 @@ public partial class FlowGraphView : ComponentBase
return b is null ? null : (F(b.X + 22), F(b.Y - 4));
}
/// <summary>
/// Where a spawn edge's bolt badge sits: centred on the connector's vertical run (the riser between the
/// lanes), so the badge is read as a property of the hop and the arrowhead stays an arrowhead. Null for
/// every non-spawn edge.
/// </summary>
private (string X, string Y)? SpawnBadgeFor(FlowLayoutEdge e)
{
if (!e.Spawn) return null;
var a = _layout.Nodes.FirstOrDefault(n => n.Key == e.From);
var b = _layout.Nodes.FirstOrDefault(n => n.Key == e.To);
if (a is null || b is null) return null;
var (x, y) = SpawnBadgePoint(a, b, e.Async);
return (F(x), F(y));
}
/// <summary>
/// Midpoint of the vertical run of the connector <see cref="OrthogonalPath"/> / <see cref="AsyncPath"/>
/// draws from <paramref name="a"/> to <paramref name="b"/> (the middle of a same-lane straight line).
/// Kept in step with those two path builders.
/// </summary>
public static (double X, double Y) SpawnBadgePoint(FlowLayoutNode a, FlowLayoutNode b, bool isAsync)
{
var x1 = a.X + NodeWidth;
var y1 = a.Y + NodeHeight / 2;
var x2 = b.X;
var y2 = b.Y + NodeHeight / 2;
if (isAsync && b.Y > a.Y)
{
// AsyncPath: first vertical run drops from the source's bottom-centre to the strip above the target.
var strip = b.Y - LanePad / 2;
return (a.X + NodeWidth / 2, (a.Y + NodeHeight + strip) / 2);
}
if (Math.Abs(y1 - y2) < 0.01)
return ((x1 + x2) / 2, y1);
if (x2 >= x1)
return (x1 + (x2 - x1) / 2, (y1 + y2) / 2);
var drop = Math.Max(a.Y, b.Y) + NodeHeight + 8;
return ((x1 + 8 + x2 - 8) / 2, drop);
}
private Task SelectAsync(FlowLayoutNode n)
=> n.Step != null && OnSelect.HasDelegate ? OnSelect.InvokeAsync(n.Step) : Task.CompletedTask;
@@ -121,11 +121,13 @@
.fg-edge.boundary { stroke: var(--accent); }
.fg-edge.external { stroke-dasharray: 5 4; }
/* Async branch (cached call / notification): same dash as the echo edge, its own class — the run
carried on without waiting. Spawn: solid line ending in the bolt marker (a trigger cascade). */
carried on without waiting. Spawn: solid line with an ordinary arrowhead plus a bolt badge sitting
on the riser (a trigger cascade) — the bolt is NOT the arrowhead, that read as a broken arrow. */
.fg-edge.async { stroke-dasharray: 5 4; }
.fg-edge.spawn { stroke: var(--ink-soft); }
.fg-edge.predicted { stroke-opacity: 0.5; stroke-dasharray: 3 3; }
.fg-arrowhead { fill: var(--ink-faint); }
.fg-arrowhead.accent { fill: var(--accent); }
.fg-arrowhead.spawn { fill: var(--ink-soft); }
.fg-spawn-badge-bg { fill: var(--bs-body-bg, #fff); stroke: var(--ink-soft); stroke-width: 1; }
.fg-spawn-bolt { fill: var(--ink-soft); }
.fg-wrap ::deep .fg-edge-tag { fill: var(--ink-faint); font-size: 0.6rem; font-family: var(--mono); }
@@ -27,7 +27,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
/// <c>Notify</c>, or a cached operation whose first row is <c>CachedSubmit</c>. Each skipped step instead gets an
/// <c>Async</c> (dashed) edge from the chain step immediately before it in time.</item>
/// <item>The first step of an execution is linked from its spawner: a <c>RouteCall</c> whose
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the last non-async,
/// <c>childExecutionId</c> matches, else — a <c>Spawn</c> edge (trigger cascade) — the parent's last
/// <c>AttributeWrite</c> of the attribute the child's <c>triggerAttribute</c> names (at or before the child's
/// first step, else the last such write at all), and only when the child names no attribute or the parent
/// never wrote it, the last non-async,
/// non-<c>Response</c> parent step that <b>occurred at or before</b> the child's first step. Parent and
/// child of a cascade share the site clock, so that timestamp rule picks the write that actually fired
/// the trigger; only when it finds nothing (a child clock BEHIND its parent's — a mixed-clock Replay)
@@ -146,11 +149,21 @@ public static class FlowGraphBuilder
// (a run shaped ScriptRun → Write(A) → Wait → Write(B) would spawn from Write(B)).
// Position stays as the fallback for the mixed-clock case F5 was written for — a child
// clock behind its parent's, where no parent step satisfies the time rule.
// Before either rule: when the child names the attribute that fired it, the spawner is the
// parent's last write OF THAT ATTRIBUTE. Timestamps alone cannot separate a burst of writes
// that all land in the same millisecond (a run writing Result, ResultText, Flag=false, Processed
// back-to-back spawned its value-change child from the LAST of them on the rig, not from Flag).
var childTime = first.OccurredAtUtc;
var childPosition = position[first.EventId];
spawner = parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
?? parentChain[0];
var triggerAttribute = first.Extra?.TriggerAttribute;
if (!string.IsNullOrEmpty(triggerAttribute))
{
spawner = parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => WritesAttribute(p, triggerAttribute));
}
spawner ??= parentChain.LastOrDefault(p => Spawnable(p) && p.OccurredAtUtc <= childTime)
?? parentChain.LastOrDefault(p => Spawnable(p) && position[p.EventId] < childPosition)
?? parentChain[0];
}
if (spawner is null) continue;
@@ -228,6 +241,12 @@ public static class FlowGraphBuilder
private static bool Spawnable(FlowStep step)
=> step.Kind != FlowStepKind.Response && !IsAsync(step);
/// <summary>Is this an <c>AttributeWrite</c> step whose write list names <paramref name="attribute"/> (ordinal, case-insensitive)?</summary>
private static bool WritesAttribute(FlowStep step, string attribute)
=> step.Kind == FlowStepKind.AttributeWrite
&& step.Extra?.Writes is { Count: > 0 } writes
&& writes.Any(w => string.Equals(w.Attribute, attribute, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// A step the script did not wait for: a notification, or a cached operation — any API / DB step whose
/// first lifecycle row is not the sync one-shot kind (<c>ApiCall</c> / <c>DbWrite</c>), the same rule the
@@ -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()
{