Skip to content

Commit 82dc2f9

Browse files
fahchenclaude
andcommitted
fix(dashboard): two-phase JS-driven firing animation + always-pass enabledTransitions in replay
JS-driven RAF replaces the previous CSS keyframe per-edge fire. Edges animate as a two-phase progress bar: input arcs fill source → transition first, output arcs fill transition → destination second. At the half- duration boundary the post-fire derived marking + enabled-transition set are applied so the operator never sees post-fire tokens land before the input arc visually drains. Same refactor closes the "turn_red_* highlighted at every replay version" bug. NetDiagram: - Props refactored: drop firingEdgeIds + firingDurationMs + enabledEdgeIds. Add firingTransition?: string | null, firingProgress?: { input: number, output: number }, enabledTransitions?: ReadonlySet<string>. Export FiringProgress. - buildGraph derives transition-node isEnabled via isTransitionEnabled(name) helper — the replay-supplied enabledTransitions override drives BOTH the input-arc accent AND the transition-node glow. Previously the per-edge override left the node still glowing from live enabled_count, surfacing the "turn_red looks enabled at v0" bug. - OrthogonalEdgeImpl renders a raw <path pathLength={1}> with inline strokeDasharray=1 + strokeDashoffset=1-progress when firing. EnactmentDetailPage: - firingEdgeIds state + setTimeout replaced with firingPhase ({transition, startedAt, halfMs, fullMs}) + firingProgress + RAF loop. RAF applies stashed derivedMarkings + replayEnabledTransitions at halfMs boundary. - onScrub onReply stashes reply data into refs + triggers startFiringPhase only when single-step advance + occurrence at target + visible-arc match; otherwise applies immediately. - Version-bump effect skipped in replay mode (now driven by onReply); kept for live-mode operator-completion firing. - enabledTransitions={replayState === null ? undefined : replayEnabledTransitions} — concrete set in replay, never undefined. NetDiagram never falls back to live counts during replay. CSS: - @Keyframes cf-edge-fill + .cf-edge-firing rule removed. Tests: - NetDiagram: 3 new firingProgress wire-up tests (input/output split + clamping), 2 enabledTransitions override tests (wins over live count for arc + node glow). - EnactmentDetailPage: mock NetDiagram exposes firing-transition / firing-input / firing-output / enabled-transitions data attrs. Two new RAF-driven tests (live progress curve hits 0.5 → 0.5 → cleared; replay step keeps enabledTransitions=[] until elapsed > halfMs, then swaps). - enactment_detail_store_test: TrafficLightFlow v=0 returns ["turn_green_ew"] only — refutes turn_red_* / turn_yellow_*. mix precommit 177/177, dialyzer 0; pnpm typecheck + 172/172 vitest + build clean; lib/coloured_flow byte-identical. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b143546 commit 82dc2f9

6 files changed

Lines changed: 536 additions & 189 deletions

File tree

dashboard/test/coloured_flow_dashboard_web/stores/enactment_detail_store_test.exs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,50 @@ defmodule ColouredFlowDashboardWeb.Stores.EnactmentDetailStoreTest do
11021102
end
11031103
end
11041104

1105+
describe ":replay_to_version against TrafficLightFlow (initial-marking regression)" do
1106+
# Regression cover for the bug where `turn_red_ew` / `turn_red_ns`
1107+
# appeared as enabled on the diagram for every replay version. The
1108+
# backend's engine output for v=0 must list ONLY transitions whose input
1109+
# arcs are satisfied by the initial marking — anything else was the
1110+
# diagram conflating live `enabled_count` with replay-derived state.
1111+
require ColouredFlow.Runner.Storage.InMemory, as: TLInMemory
1112+
alias ColouredFlowDashboard.Seeds.TrafficLightFlow
1113+
1114+
test "v=0 returns only `turn_green_ew` (the only initially firable transition)",
1115+
%{topic_prefix: topic_prefix} do
1116+
flow = TLInMemory.insert_flow!(TrafficLightFlow.cpnet())
1117+
flow_id = TLInMemory.flow(flow, :id)
1118+
{:ok, enactment} = TrafficLightFlow.insert_enactment(flow_id)
1119+
enactment_id = TLInMemory.enactment(enactment, :id)
1120+
{:ok, _pid} = TrafficLightFlow.start_enactment(enactment_id, lifecycle_hooks: nil)
1121+
1122+
flow_cache =
1123+
unique_cache_atom(
1124+
"enactment_detail_store_test_tl_cache_#{System.unique_integer([:positive])}"
1125+
)
1126+
1127+
:ets.new(flow_cache, [:set, :public, :named_table])
1128+
:ets.insert(flow_cache, {enactment_id, "tl-flow", TrafficLightFlow.cpnet()})
1129+
1130+
page = mount_store(enactment_id, topic_prefix, flow_cache)
1131+
1132+
assert {:ok, %{code: :ok} = reply} =
1133+
Musubi.Testing.dispatch_command(page, :replay_to_version, %{version: 0})
1134+
1135+
enabled = Enum.sort(reply.enabled_transitions)
1136+
# Initial marking = red_ew + red_ns + safe_ew. `turn_green_ew` needs
1137+
# `red_ew + safe_ew` → enabled. `turn_green_ns` needs `red_ns + safe_ns`
1138+
# but safe_ns has no initial token → NOT enabled. Every `turn_red_*` /
1139+
# `turn_yellow_*` needs `yellow_*` / `green_*` which are empty at v=0
1140+
# → NOT enabled.
1141+
assert enabled == ["turn_green_ew"]
1142+
refute "turn_red_ew" in enabled
1143+
refute "turn_red_ns" in enabled
1144+
refute "turn_yellow_ew" in enabled
1145+
refute "turn_yellow_ns" in enabled
1146+
end
1147+
end
1148+
11051149
describe "live marking refresh on firing events" do
11061150
require ColouredFlow.Runner.Storage.InMemory, as: InMemory
11071151
alias ColouredFlow.Runner.Enactment.WorkitemTransition

dashboard/ui/src/components/NetDiagram.test.tsx

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -143,28 +143,51 @@ describe("NetDiagram", () => {
143143
expect(onSelectTransition).not.toHaveBeenCalled()
144144
})
145145

146-
it("tags edges in firingEdgeIds with `cf-edge-firing` and the duration var", () => {
146+
it("tags input arcs of firingTransition with firingProgress.input and output arcs with .output", () => {
147147
const diagram = baseDiagram()
148148
const inputId = "arc-p_to_t-pending-approve-0"
149149
const outputId = "arc-t_to_p-decided-approve-1"
150-
const firing = new Set<string>([inputId, outputId])
151-
const { edges } = buildGraph(diagram, "running", firing, 1200)
150+
const { edges } = buildGraph(diagram, "running", "approve", { input: 0.3, output: 0.7 })
152151

153152
const input = edges.find((e) => e.id === inputId)
154153
const output = edges.find((e) => e.id === outputId)
155-
expect(input?.className).toBe("cf-edge-firing")
156-
expect(output?.className).toBe("cf-edge-firing")
157-
expect((input?.style as Record<string, unknown>)["--cf-edge-duration"]).toBe("1200ms")
158-
expect((output?.style as Record<string, unknown>)["--cf-edge-duration"]).toBe("1200ms")
154+
expect((input?.data as Record<string, unknown>).firingProgress).toBe(0.3)
155+
expect((output?.data as Record<string, unknown>).firingProgress).toBe(0.7)
156+
// No className for firing in the new design — the inline `pathLength="1"`
157+
// + dasharray combo replaces the CSS keyframe.
158+
expect(input?.className).toBeUndefined()
159+
expect(output?.className).toBeUndefined()
160+
})
161+
162+
it("omits firingProgress on edges not touching the firingTransition", () => {
163+
const diagram = baseDiagram()
164+
diagram.transitions = [
165+
...diagram.transitions,
166+
{
167+
name: "reject",
168+
enabled_count: 0,
169+
rejected_by_guard_count: 0,
170+
rejected_by_arc_eval_count: 0,
171+
rejected_by_marking_count: 0,
172+
last_fired_at: null
173+
}
174+
]
175+
diagram.arcs = [
176+
...diagram.arcs,
177+
{ place: "pending", transition: "reject", orientation: "p_to_t" }
178+
]
179+
const { edges } = buildGraph(diagram, "running", "approve", { input: 0.5, output: 0 })
180+
const rejectEdge = edges.find((e) => e.id === "arc-p_to_t-pending-reject-2")
181+
expect((rejectEdge?.data as Record<string, unknown> | undefined)?.firingProgress).toBeUndefined()
159182
})
160183

161-
it("leaves non-firing edges with no `cf-edge-firing` class", () => {
184+
it("clamps firingProgress to [0,1]", () => {
162185
const diagram = baseDiagram()
163-
const firing = new Set<string>(["arc-p_to_t-pending-approve-0"])
164-
const { edges } = buildGraph(diagram, "running", firing, 600)
165-
const other = edges.find((e) => e.id === "arc-t_to_p-decided-approve-1")
166-
expect(other?.className).toBeUndefined()
167-
expect((other?.style as Record<string, unknown>)["--cf-edge-duration"]).toBeUndefined()
186+
const { edges } = buildGraph(diagram, "running", "approve", { input: -0.5, output: 1.5 })
187+
const input = edges.find((e) => e.id === "arc-p_to_t-pending-approve-0")
188+
const output = edges.find((e) => e.id === "arc-t_to_p-decided-approve-1")
189+
expect((input?.data as Record<string, unknown>).firingProgress).toBe(0)
190+
expect((output?.data as Record<string, unknown>).firingProgress).toBe(1)
168191
})
169192

170193
it("tags p_to_t arcs of enabled transitions with cf-edge-enabled + accent style", () => {
@@ -197,13 +220,44 @@ describe("NetDiagram", () => {
197220
)
198221
})
199222

200-
it("firing edge wins over enabled — firing class applied, enabled skipped", () => {
223+
it("enabledTransitions override wins over live enabled_count for arc accent + transition glow", () => {
201224
const diagram = baseDiagram()
202-
diagram.transitions[0].enabled_count = 1
203-
const firing = new Set<string>(["arc-p_to_t-pending-approve-0"])
204-
const { edges } = buildGraph(diagram, "running", firing, 600)
225+
// Live counts disagree with replay-derived set: live says enabled, override says not.
226+
diagram.transitions[0].enabled_count = 5
227+
const override: ReadonlySet<string> = new Set()
228+
const { edges, nodes } = buildGraph(
229+
diagram,
230+
"running",
231+
null,
232+
{ input: 0, output: 0 },
233+
undefined,
234+
override
235+
)
205236
const input = edges.find((e) => e.id === "arc-p_to_t-pending-approve-0")
206-
expect(input?.className).toBe("cf-edge-firing")
237+
expect(input?.className).toBeUndefined()
238+
expect((input?.style as Record<string, unknown>).stroke).toBe(
239+
"var(--color-cf-border-strong)"
240+
)
241+
const approveNode = nodes.find((n) => n.id === "t:approve")
242+
expect((approveNode?.data as Record<string, unknown>).isEnabled).toBe(false)
243+
})
244+
245+
it("enabledTransitions override drives transition glow when the live count says zero", () => {
246+
const diagram = baseDiagram()
247+
diagram.transitions[0].enabled_count = 0
248+
const override: ReadonlySet<string> = new Set(["approve"])
249+
const { edges, nodes } = buildGraph(
250+
diagram,
251+
"running",
252+
null,
253+
{ input: 0, output: 0 },
254+
undefined,
255+
override
256+
)
257+
const input = edges.find((e) => e.id === "arc-p_to_t-pending-approve-0")
258+
expect(input?.className).toBe("cf-edge-enabled")
259+
const approveNode = nodes.find((n) => n.id === "t:approve")
260+
expect((approveNode?.data as Record<string, unknown>).isEnabled).toBe(true)
207261
})
208262

209263
it("pulses the transition node when last_fired_at changes", async () => {

0 commit comments

Comments
 (0)