diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index c139681f..beac0735 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -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&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=`** / **`/notifications/report?correlationId=`** (*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&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=`** / **`/notifications/report?correlationId=`** (*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 ``* 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. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor index 3be9dd4f..cd207e24 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor @@ -17,10 +17,6 @@ - @* Trigger spawn (plan D9): a small bolt in place of the arrowhead — this write fired a value-change script. *@ - - - @* 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). *@ + + Trigger cascade: this write fired the script + + + + } } @* Nodes *@ diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs index 33bfe56f..d3c6e561 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.cs @@ -55,7 +55,7 @@ public sealed record FlowLayoutNode( /// Dashed — an external actor (the tag source responding). /// Light — an edge of the prediction, not of a run. /// Dashed — a fire-and-forget branch (cached call / notification) the source never waited for (plan D4). -/// Bolt marker — the target is the first step of a trigger-spawned child execution (plan D9). +/// Bolt badge on the connector — the target is the first step of a trigger-spawned child execution (plan D9). public sealed record FlowLayoutEdge(string From, string To, string Path, bool CrossesBoundary, bool External, bool PredictedOnly, bool Async = false, bool Spawn = false); /// A computed swimlane layout. @@ -184,8 +184,9 @@ public partial class FlowGraphView : ComponentBase return sb.ToString(); } + /// Every connector ends in an arrowhead — a spawn edge marks itself with a bolt badge on the line instead (). 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)"; /// True for a real outbound step (API call / database / notification) — the kinds that carry a status pill instead of a subtitle line. 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)); } + /// + /// 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. + /// + 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)); + } + + /// + /// Midpoint of the vertical run of the connector / + /// draws from to (the middle of a same-lane straight line). + /// Kept in step with those two path builders. + /// + 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; diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css index 9a4e2f11..5075c9dd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor.css @@ -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); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs index 643db394..af484b55 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs @@ -27,7 +27,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; /// Notify, or a cached operation whose first row is CachedSubmit. Each skipped step instead gets an /// Async (dashed) edge from the chain step immediately before it in time. /// The first step of an execution is linked from its spawner: a RouteCall whose -/// childExecutionId matches, else — a Spawn edge (trigger cascade) — the last non-async, +/// childExecutionId matches, else — a Spawn edge (trigger cascade) — the parent's last +/// AttributeWrite of the attribute the child's triggerAttribute 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-Response parent step that occurred at or before 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); + /// Is this an AttributeWrite step whose write list names (ordinal, case-insensitive)? + 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)); + /// /// 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 (ApiCall / DbWrite), the same rule the diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs index 33e06002..cd4370b0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs @@ -456,20 +456,65 @@ public class FlowGraphViewTests : BunitContext } [Fact] - public void Render_AsyncEdgesAreDashedClass_SpawnEdgeUsesTheBoltMarker() + public void Render_AsyncEdgesAreDashedClass_SpawnEdgeKeepsAnArrowheadAndGetsABoltBadge() { - var cut = Render(p => p.Add(c => c.Graph, OutboundGraph())); + var graph = OutboundGraph(); + var cut = Render(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] diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs index 9cf23c47..4377d42f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Debugging/FlowGraphBuilderTests.cs @@ -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() {