Skip to content

Commit 6e51744

Browse files
committed
fix(dashboard): firing animation gates to single-step jumps and uses pre-fire enabled set
Live firing animation now skips when activeVersion jumps by more than one — multi-step batches snap to post-fire instead of animating only the last occurrence over the already-combined state. Replay-mode path already had this check. Intermediate enabled-transition set during the dwell phase previously came from a naive "every input place has ≥1 token" client check, which ignored arc multiplicity, transition guards, and binding expressions, and treated zero-input transitions as disabled. Drop that helper and keep the pre-fire enabled set across all firing phases (pre, intermediate, output) so the highlighted transitions match a real, server-authoritative enablement state the whole time. The post-fire set takes over at fullMs as before.
1 parent bbea43f commit 6e51744

2 files changed

Lines changed: 227 additions & 46 deletions

File tree

dashboard/ui/src/routes/EnactmentDetailPage.test.tsx

Lines changed: 180 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ describe("EnactmentDetailPage", () => {
10491049
}
10501050
})
10511051

1052-
it("replay step-forward progresses enabled set pre → intermediate (client-computed) → post across the three-interval firing window", async () => {
1052+
it("replay step-forward holds the PRE-fire enabled set across the entire firing window, then snaps to post-fire at fullMs", async () => {
10531053
const restore = mutateReplay(
10541054
{ version: 0, derived_at: "2026-05-29T01:00:00Z" },
10551055
{ min: 0, max: 3 }
@@ -1121,23 +1121,24 @@ describe("EnactmentDetailPage", () => {
11211121
expect(stub().dataset.firingTransition).toBe("approve")
11221122
expect(stub().dataset.enabledTransitions).toBe("[]")
11231123

1124-
// First RAF tick at elapsed = 0. Still pre-inputEndMs → pre-fire.
1124+
// First RAF tick at elapsed = 0. Pre-fire.
11251125
await flushRaf()
11261126
expect(stub().dataset.enabledTransitions).toBe("[]")
11271127

1128-
// Cross inputEndMs (=800). In intermediate window now. The client
1129-
// re-derives the enabled set from the intermediate diagram tokens
1130-
// (the test snapshot keeps pending=1, decided=0 so `approve` remains
1131-
// enabled at the intermediate marking).
1128+
// Cross inputEndMs (=800) into the intermediate (dwell) window. The
1129+
// engine-correct intermediate enabled set can't be computed
1130+
// client-side (arc multiplicity / guards / binding expressions are
1131+
// not on the wire), so we keep the pre-fire enabled set instead of
1132+
// lying with a tokens-only approximation.
11321133
nowRef.current = 5_900
11331134
await flushRaf()
1134-
expect(stub().dataset.enabledTransitions).toBe(JSON.stringify(["approve"]))
1135+
expect(stub().dataset.enabledTransitions).toBe("[]")
11351136

11361137
// Still in Phase B (elapsed = 1600 → between outputStartMs=1200 and
1137-
// fullMs=2000). Intermediate enabled persists until fullMs apply.
1138+
// fullMs=2000). Override still pre-fire.
11381139
nowRef.current = 6_600
11391140
await flushRaf()
1140-
expect(stub().dataset.enabledTransitions).toBe(JSON.stringify(["approve"]))
1141+
expect(stub().dataset.enabledTransitions).toBe("[]")
11411142

11421143
// Past fullMs → applyFinalPendings flips replayEnabledTransitions to
11431144
// the post-fire set carried by the reply.
@@ -1154,6 +1155,176 @@ describe("EnactmentDetailPage", () => {
11541155
}
11551156
})
11561157

1158+
it("skips the live firing animation when activeVersion jumps by more than 1 (multi-occurrence batch)", async () => {
1159+
const mut = sampleSnapshot as unknown as {
1160+
summary: { version: number }
1161+
occurrences: Array<{
1162+
id: string
1163+
step_number: number
1164+
transition: string
1165+
binding_summary: string
1166+
occurred_at: string
1167+
outputs_summary: string
1168+
}>
1169+
}
1170+
const origVersion = mut.summary.version
1171+
const origOccurrences = mut.occurrences
1172+
// Park at v=3 with no occurrence so mount seeds lastVersionRef without
1173+
// animating.
1174+
mut.summary.version = 3
1175+
mut.occurrences = []
1176+
1177+
try {
1178+
const { rerender } = renderRoute(<EnactmentDetailPage />)
1179+
const stub = () => screen.getByTestId("net-diagram-stub")
1180+
expect(stub().dataset.firingTransition).toBe("")
1181+
1182+
// Jump from v=3 to v=5 (TWO occurrences). The runner batched both;
1183+
// animating only the LAST would misrepresent the timeline, so the
1184+
// page must snap to post-fire without running the firing animation.
1185+
mut.summary.version = 5
1186+
mut.occurrences = [
1187+
{
1188+
id: "occ-4",
1189+
step_number: 4,
1190+
transition: "approve",
1191+
binding_summary: "",
1192+
occurred_at: "2026-05-29T01:00:00Z",
1193+
outputs_summary: ""
1194+
},
1195+
{
1196+
id: "occ-5",
1197+
step_number: 5,
1198+
transition: "approve",
1199+
binding_summary: "",
1200+
occurred_at: "2026-05-29T01:00:01Z",
1201+
outputs_summary: ""
1202+
}
1203+
]
1204+
await act(async () => {
1205+
rerender(
1206+
<Toasty>
1207+
<MemoryRouter initialEntries={["/enactments/en-aaaa"]}>
1208+
<Routes>
1209+
<Route path="/enactments/:id" element={<EnactmentDetailPage />} />
1210+
</Routes>
1211+
</MemoryRouter>
1212+
</Toasty>
1213+
)
1214+
})
1215+
1216+
expect(stub().dataset.firingTransition).toBe("")
1217+
expect(stub().dataset.firingInput).toBe("0")
1218+
expect(stub().dataset.firingOutput).toBe("0")
1219+
} finally {
1220+
mut.summary.version = origVersion
1221+
mut.occurrences = origOccurrences
1222+
}
1223+
})
1224+
1225+
it("live firing animation holds the PRE-fire enabled set across all phases via the override", async () => {
1226+
const mut = sampleSnapshot as unknown as {
1227+
summary: { version: number }
1228+
occurrences: Array<{
1229+
id: string
1230+
step_number: number
1231+
transition: string
1232+
binding_summary: string
1233+
occurred_at: string
1234+
outputs_summary: string
1235+
}>
1236+
}
1237+
const origVersion = mut.summary.version
1238+
const origOccurrences = mut.occurrences
1239+
// Sample diagram has `approve` with enabled_count=1, so the pre-fire
1240+
// enabled set derived in live mode is ["approve"]. NetDiagram normally
1241+
// derives that itself from displayedDiagram.transitions[].enabled_count,
1242+
// so the stub reports "live". With the firing override active during
1243+
// the animation it should switch to a concrete set instead.
1244+
mut.summary.version = 3
1245+
mut.occurrences = []
1246+
1247+
const rafCallbacks: FrameRequestCallback[] = []
1248+
const nowRef = { current: 1_000 }
1249+
const performanceSpy = vi.spyOn(performance, "now").mockImplementation(() => nowRef.current)
1250+
const rafSpy = vi
1251+
.spyOn(window, "requestAnimationFrame")
1252+
.mockImplementation((cb: FrameRequestCallback): number => {
1253+
rafCallbacks.push(cb)
1254+
return rafCallbacks.length
1255+
})
1256+
const cafSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {})
1257+
const flushRaf = async () => {
1258+
const due = rafCallbacks.splice(0)
1259+
await act(async () => {
1260+
due.forEach((cb) => cb(nowRef.current))
1261+
})
1262+
}
1263+
1264+
try {
1265+
const { rerender } = renderRoute(<EnactmentDetailPage />)
1266+
const stub = () => screen.getByTestId("net-diagram-stub")
1267+
// No firing yet → NetDiagram derives enabled set itself.
1268+
expect(stub().dataset.enabledTransitions).toBe("live")
1269+
1270+
mut.summary.version = 4
1271+
mut.occurrences = [
1272+
{
1273+
id: "occ-4",
1274+
step_number: 4,
1275+
transition: "approve",
1276+
binding_summary: "",
1277+
occurred_at: "2026-05-29T01:00:00Z",
1278+
outputs_summary: ""
1279+
}
1280+
]
1281+
await act(async () => {
1282+
rerender(
1283+
<Toasty>
1284+
<MemoryRouter initialEntries={["/enactments/en-aaaa"]}>
1285+
<Routes>
1286+
<Route path="/enactments/:id" element={<EnactmentDetailPage />} />
1287+
</Routes>
1288+
</MemoryRouter>
1289+
</Toasty>
1290+
)
1291+
})
1292+
1293+
expect(stub().dataset.firingTransition).toBe("approve")
1294+
// Override active across pre + intermediate + Phase B (until fullMs).
1295+
const preFireSet = JSON.stringify(["approve"])
1296+
expect(stub().dataset.enabledTransitions).toBe(preFireSet)
1297+
1298+
// Phase A midpoint.
1299+
nowRef.current = 1_400
1300+
await flushRaf()
1301+
expect(stub().dataset.enabledTransitions).toBe(preFireSet)
1302+
1303+
// Intermediate (dwell).
1304+
nowRef.current = 2_000
1305+
await flushRaf()
1306+
expect(stub().dataset.enabledTransitions).toBe(preFireSet)
1307+
1308+
// Phase B midpoint.
1309+
nowRef.current = 2_600
1310+
await flushRaf()
1311+
expect(stub().dataset.enabledTransitions).toBe(preFireSet)
1312+
1313+
// Past fullMs → firing clears, override goes away, NetDiagram derives
1314+
// from the (post-fire) displayed diagram again.
1315+
nowRef.current = 3_100
1316+
await flushRaf()
1317+
expect(stub().dataset.firingTransition).toBe("")
1318+
expect(stub().dataset.enabledTransitions).toBe("live")
1319+
} finally {
1320+
mut.summary.version = origVersion
1321+
mut.occurrences = origOccurrences
1322+
performanceSpy.mockRestore()
1323+
rafSpy.mockRestore()
1324+
cafSpy.mockRestore()
1325+
}
1326+
})
1327+
11571328
it("Jump-to-live button dispatches :exit_replay while in replay", async () => {
11581329
const restore = mutateReplay(
11591330
{ version: 1, derived_at: "2026-05-29T01:00:00Z" },

dashboard/ui/src/routes/EnactmentDetailPage.tsx

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,12 @@ type TabId = "markings" | "workitems" | "occurrences" | "telemetry" | "debug"
6363
//
6464
// Marking + enabled-transition state commits at TWO boundaries:
6565
// * inputEndMs (drain visible): displayed diagram flips to intermediate
66-
// (input places drained, output places still pre-fire). Intermediate
67-
// enabled set computed client-side (T enabled iff every input place of T
68-
// still has ≥1 token).
66+
// (input places drained, output places still pre-fire). The displayed
67+
// ENABLED SET stays pinned to pre-fire — computing engine-correct
68+
// enablement client-side would require arc-multiplicity, guards, and
69+
// binding-expression evaluation, which the wire does not carry. Pre-fire
70+
// is a known-good approximation that never lies about specific
71+
// transitions during the brief dwell.
6972
// * fullMs (arrival visible): displayed diagram flips to post-fire
7073
// (server-pushed). Post-fire enabled set from `:replay_to_version` reply
7174
// in replay mode, or from the post-fire diagram's `enabled_count` in
@@ -112,24 +115,16 @@ function buildIntermediateDiagram(
112115
return { ...post, places }
113116
}
114117

115-
// Client-side intermediate enabled set: T is enabled iff every p_to_t arc's
116-
// place has ≥1 token in the supplied diagram. Matches plan option (b) — no
117-
// wire change; the brief intermediate-phase glow approximates engine truth.
118-
function computeEnabledFromDiagramTokens(diagram: DiagramPayload): ReadonlySet<string> {
119-
const tokens = new Map<string, number>()
120-
for (const p of diagram.places) tokens.set(p.name, p.tokens_count)
121-
const inputs = new Map<string, string[]>()
122-
for (const arc of diagram.arcs) {
123-
if (arc.orientation !== "p_to_t") continue
124-
const list = inputs.get(arc.transition) ?? []
125-
list.push(arc.place)
126-
inputs.set(arc.transition, list)
127-
}
118+
// Pre-fire enabled set in LIVE mode is derived directly from the pre-fire
119+
// diagram's per-transition `enabled_count`. Used as the override during the
120+
// firing animation so the highlight doesn't flip to the post-fire set the
121+
// instant the server pushes the new diagram. Replay mode does NOT need this
122+
// helper — `replayEnabledTransitions` already holds the pre-fire value
123+
// through Phases A+B and snaps to post-fire at applyFinalPendings().
124+
function preFireEnabledFromDiagram(diagram: DiagramPayload): ReadonlySet<string> {
128125
const enabled = new Set<string>()
129126
for (const t of diagram.transitions) {
130-
const inputList = inputs.get(t.name)
131-
if (!inputList || inputList.length === 0) continue
132-
if (inputList.every((p) => (tokens.get(p) ?? 0) >= 1)) enabled.add(t.name)
127+
if (t.enabled_count > 0) enabled.add(t.name)
133128
}
134129
return enabled
135130
}
@@ -492,7 +487,8 @@ function DetailContent({
492487

493488
// Three-phase intermediate state. Computed once per (firingPhase,
494489
// firingPreDiagram, diagram) tuple. `firingPhase === null` short-circuits so
495-
// off-firing renders pay nothing.
490+
// off-firing renders pay nothing. Enabled-transition set during firing is
491+
// NOT computed from this diagram — see `enabledTransitionsOverride` below.
496492
const intermediateState = useMemo(() => {
497493
if (firingPhase === null || firingPreDiagram === null || diagram === null) {
498494
return null
@@ -504,11 +500,20 @@ function DetailContent({
504500
)
505501
return {
506502
diagram: interDiag,
507-
markings: diagramToMarkingRows(interDiag),
508-
enabled: computeEnabledFromDiagramTokens(interDiag)
503+
markings: diagramToMarkingRows(interDiag)
509504
}
510505
}, [firingPhase, firingPreDiagram, diagram])
511506

507+
// Live-mode pre-fire enabled set. Captured from the pre-fire diagram so the
508+
// override stays stable through the entire firing animation (Phase A +
509+
// intermediate dwell + Phase B), instead of letting NetDiagram derive from
510+
// `displayedDiagram.transitions[].enabled_count` — which flips to post-fire
511+
// values the instant the server pushes the post-fire diagram.
512+
const liveFiringEnabledOverride = useMemo(() => {
513+
if (firingPhase === null || firingPreDiagram === null) return null
514+
return preFireEnabledFromDiagram(firingPreDiagram)
515+
}, [firingPhase, firingPreDiagram])
516+
512517
// Three-phase selectors. RAF ticks update firingProgress so these recompute
513518
// on every frame the phase advances; identity changes only at the boundaries
514519
// (pre→intermediate at inputEndMs, intermediate→post at fullMs). The dwell
@@ -559,6 +564,11 @@ function DetailContent({
559564
if (prev === null) return
560565
if (activeVersion <= prev) return
561566
if (replayState !== null) return
567+
// Multi-step bumps (runner batched N>1 occurrences into one bridge event)
568+
// skip the firing animation. Animating only the LAST occurrence over the
569+
// already-combined post-fire diagram would drop the intermediate firings
570+
// from the visible timeline. Snap straight to post-fire instead.
571+
if (activeVersion !== prev + 1) return
562572

563573
const occurrence = occurrences.find((o) => o.step_number === activeVersion)
564574
if (!occurrence) return
@@ -615,22 +625,22 @@ function DetailContent({
615625
}, [firingPhase, applyFinalPendings])
616626

617627
// Enabled-transition override resolution:
618-
// * Phase B (intermediate): client-computed set against drained inputs.
619-
// Drives BOTH live and replay glow during the brief intermediate window.
620-
// * Replay (non-firing OR Phase A/post): replay reply's enabled set.
621-
// `replayEnabledTransitions` holds the PRE-fire value through Phases A+B
622-
// and snaps to post-fire when applyFinalPendings runs at fullMs.
623-
// * Live (non-firing OR Phase A/post): undefined → NetDiagram derives from
624-
// the displayedDiagram's transitions[].enabled_count. Pre-fire diagram
625-
// carries pre-fire counts (Phase A), post-fire diagram carries post-fire
626-
// counts (post).
628+
// * Firing (any phase before fullMs): pre-fire enabled set. In replay,
629+
// `replayEnabledTransitions` already holds the pre-fire value through
630+
// Phases A+B and snaps to post-fire when applyFinalPendings runs at
631+
// fullMs. In live, `liveFiringEnabledOverride` mirrors the pre-fire
632+
// diagram's `enabled_count`, so the highlight doesn't follow the
633+
// server-pushed post-fire diagram mid-animation. We deliberately do NOT
634+
// re-derive enablement client-side from the intermediate (drained)
635+
// marking — engine-correct enablement needs arc multiplicity, guards,
636+
// and binding-expression evaluation; the wire carries none of that.
637+
// * Replay, non-firing: replay reply's enabled set.
638+
// * Live, non-firing: undefined → NetDiagram derives from the displayed
639+
// diagram's transitions[].enabled_count.
627640
const enabledTransitionsOverride: ReadonlySet<string> | undefined = (() => {
628-
if (
629-
firingPhase !== null &&
630-
firingDisplayPhase === "intermediate" &&
631-
intermediateState
632-
) {
633-
return intermediateState.enabled
641+
if (firingPhase !== null && firingDisplayPhase !== "post") {
642+
if (replayState !== null) return replayEnabledTransitions
643+
if (liveFiringEnabledOverride !== null) return liveFiringEnabledOverride
634644
}
635645
return replayState === null ? undefined : replayEnabledTransitions
636646
})()

0 commit comments

Comments
 (0)