@elabs-ai/components-process 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -39,7 +39,7 @@
39
39
  */
40
40
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
41
41
  import type { XYPosition } from "@xyflow/react";
42
- import { layoutFlow, type FlowLayoutDirection } from "@elabs-ai/components-flow";
42
+ import { layoutFlow, layoutFlowElk, type FlowLayoutDirection } from "@elabs-ai/components-flow";
43
43
  import type { ProcessMapEdge, ProcessMapNode } from "./map-model";
44
44
 
45
45
  /**
@@ -145,6 +145,12 @@ export interface UseProcessLayoutOptions {
145
145
  direction: FlowLayoutDirection;
146
146
  /** @default {@link DEFAULT_LAYOUT_DEBOUNCE_MS} */
147
147
  debounceMs?: number;
148
+ // elkjs adapter — RM-067
149
+ /**
150
+ * Which layout engine runs. `"elk"` calls `layoutFlowElk` (async, lazily loaded, falls
151
+ * back to dagre when elkjs is not installed) instead of `layoutFlow`. @default "dagre"
152
+ */
153
+ layoutEngine?: "dagre" | "elk";
148
154
  }
149
155
 
150
156
  /** What {@link useProcessLayout} answers. */
@@ -217,8 +223,10 @@ export function useProcessLayout({
217
223
  structureKey,
218
224
  direction,
219
225
  debounceMs = DEFAULT_LAYOUT_DEBOUNCE_MS,
226
+ layoutEngine = "dagre",
220
227
  }: UseProcessLayoutOptions): UseProcessLayoutResult {
221
- const cacheKey = `${structureKey}::${direction}`;
228
+ // The engine joins the key only when it is not the default, so dagre keys are unchanged.
229
+ const cacheKey = `${structureKey}::${direction}${layoutEngine === "elk" ? "::elk" : ""}`;
222
230
  const cache = useRef(new Map<string, ProcessLayoutSnapshot>());
223
231
  const runs = useRef(0);
224
232
  // The layout the render below reads. Held in state (not a ref) because producing a new
@@ -231,17 +239,30 @@ export function useProcessLayout({
231
239
  const compute = useCallback(
232
240
  (key: string, currentNodes: ProcessMapNode[], currentEdges: ProcessMapEdge[]) => {
233
241
  const started = performance.now();
234
- const result = layoutFlow<ProcessMapNode, ProcessMapEdge>(currentNodes, currentEdges, {
242
+ const layoutOptions = {
235
243
  direction,
236
244
  nodeSpacing: NODE_SPACING,
237
245
  rankSpacing: RANK_SPACING[direction],
238
- });
239
- const snapshot = toSnapshot(result, performance.now() - started);
240
- runs.current += 1;
241
- cache.current.set(key, snapshot);
242
- setApplied({ key, snapshot });
246
+ };
247
+ const settle = (result: ReturnType<typeof layoutFlow<ProcessMapNode, ProcessMapEdge>>) => {
248
+ const snapshot = toSnapshot(result, performance.now() - started);
249
+ runs.current += 1;
250
+ cache.current.set(key, snapshot);
251
+ setApplied({ key, snapshot });
252
+ };
253
+ // elkjs adapter — RM-067: async, lazily loaded; `layoutFlowElk` never rejects (it
254
+ // falls back to dagre itself), and a result for a key no longer current still caches.
255
+ if (layoutEngine === "elk") {
256
+ void layoutFlowElk<ProcessMapNode, ProcessMapEdge>(
257
+ currentNodes,
258
+ currentEdges,
259
+ layoutOptions,
260
+ ).then(settle);
261
+ return;
262
+ }
263
+ settle(layoutFlow<ProcessMapNode, ProcessMapEdge>(currentNodes, currentEdges, layoutOptions));
243
264
  },
244
- [direction],
265
+ [direction, layoutEngine],
245
266
  );
246
267
 
247
268
  // The nodes/edges the (possibly debounced) layout should run against. Held in a ref so
@@ -0,0 +1,107 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CongestionHeat — the replay's congestion, as a ranked text list (RM-065).
5
+ *
6
+ * The map shows congestion without colour — more tokens on a busy edge, each drawn larger —
7
+ * and this list states it in words: the transitions that held the most cases at once, with
8
+ * the peak count and when it happened, plus a relative bar whose LENGTH (not hue) repeats the
9
+ * peak. It is static (the whole timeline, not the playhead), so it is also the complete
10
+ * reading for anyone who never presses play, including under reduced motion.
11
+ */
12
+ import { forwardRef, useId, useMemo, type HTMLAttributes } from "react";
13
+ import { useLocale } from "@elabs-ai/components-ui";
14
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
15
+ import { rankReplayCongestion, type ReplayTimeline } from "../core/replay-timeline";
16
+ import { fillLabel } from "../variant-explorer/variant-explorer-model";
17
+ import {
18
+ PROCESS_REPLAY_DEFAULT_LABELS,
19
+ useReplayTimeFormatter,
20
+ type ProcessReplayLabels,
21
+ } from "./replay-format";
22
+
23
+ /** Props for {@link CongestionHeat}. */
24
+ export interface CongestionHeatProps extends HTMLAttributes<HTMLElement> {
25
+ /** The replay timeline (`replayTimeline`). */
26
+ timeline: ReplayTimeline;
27
+ /** How many transitions to list. @default 5 */
28
+ limit?: number;
29
+ labels?: Partial<ProcessReplayLabels>;
30
+ }
31
+
32
+ /** The most congested transitions of a replay, ranked. */
33
+ export const CongestionHeat = forwardRef<HTMLElement, CongestionHeatProps>(function CongestionHeat(
34
+ { timeline, limit = 5, labels: labelOverrides, className, ...props },
35
+ ref,
36
+ ) {
37
+ const { formatNumber } = useLocale();
38
+ const labels = useMemo<ProcessReplayLabels>(
39
+ () => ({ ...PROCESS_REPLAY_DEFAULT_LABELS, ...labelOverrides }),
40
+ [labelOverrides],
41
+ );
42
+ const formatTime = useReplayTimeFormatter(timeline, labels.relativeTime);
43
+ const ranked = useMemo(() => rankReplayCongestion(timeline, limit), [timeline, limit]);
44
+ const titleId = useId();
45
+ const top = ranked[0];
46
+
47
+ return (
48
+ <section
49
+ ref={ref}
50
+ data-slot="congestion-heat"
51
+ aria-labelledby={titleId}
52
+ className={cn("flex min-w-0 flex-col gap-2", className)}
53
+ {...props}
54
+ >
55
+ <h3 id={titleId} data-slot="congestion-heat-title" className="text-subtitle">
56
+ {labels.congestionTitle}
57
+ </h3>
58
+ {top ? (
59
+ <>
60
+ <p data-slot="congestion-heat-summary" className="text-caption text-muted-foreground">
61
+ {fillLabel(labels.congestionSummary, {
62
+ source: top.source,
63
+ target: top.target,
64
+ peak: formatNumber(top.peak),
65
+ time: formatTime(top.peakAt),
66
+ })}
67
+ </p>
68
+ <ol data-slot="congestion-heat-list" className="flex flex-col gap-2">
69
+ {ranked.map((entry) => (
70
+ <li
71
+ key={entry.edgeId}
72
+ data-slot="congestion-heat-item"
73
+ data-edge-id={entry.edgeId}
74
+ data-peak={entry.peak}
75
+ className="flex min-w-0 flex-col gap-1"
76
+ >
77
+ <span className="text-body truncate">
78
+ {fillLabel(labels.congestionTransition, {
79
+ source: entry.source,
80
+ target: entry.target,
81
+ })}
82
+ </span>
83
+ <span className="text-meta text-muted-foreground tabular-nums">
84
+ {fillLabel(labels.congestionPeak, {
85
+ peak: formatNumber(entry.peak),
86
+ time: formatTime(entry.peakAt),
87
+ })}
88
+ </span>
89
+ <span aria-hidden="true" className="h-1 w-full rounded-full bg-muted">
90
+ <span
91
+ data-slot="congestion-heat-bar"
92
+ className="block h-full rounded-full bg-primary"
93
+ style={{ width: `${(entry.peak / top.peak) * 100}%` }}
94
+ />
95
+ </span>
96
+ </li>
97
+ ))}
98
+ </ol>
99
+ </>
100
+ ) : (
101
+ <p data-slot="congestion-heat-empty" className="text-caption text-muted-foreground">
102
+ {labels.congestionEmpty}
103
+ </p>
104
+ )}
105
+ </section>
106
+ );
107
+ });
@@ -0,0 +1,14 @@
1
+ /** ProcessReplay (RM-065) — public surface. */
2
+ export * from "./process-replay";
3
+ export * from "./replay-controls";
4
+ export * from "./congestion-heat";
5
+ export {
6
+ PROCESS_REPLAY_DEFAULT_LABELS,
7
+ useReplayTimeFormatter,
8
+ type ProcessReplayLabels,
9
+ } from "./replay-format";
10
+ export {
11
+ ProcessReplayTokensContext,
12
+ useProcessReplayEdgeTokens,
13
+ type ProcessReplayTokens,
14
+ } from "./replay-tokens-context";
@@ -0,0 +1,168 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import "@xyflow/react/dist/style.css";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+ import { discoverGraph } from "../core/discover-graph";
5
+ import fixture from "../core/fixtures/order-to-cash-small.json";
6
+ import type { EventLog } from "../core/types";
7
+ import { processEdgeId } from "../process-map/map-model";
8
+ import { ProcessReplay } from "./process-replay";
9
+
10
+ const log = fixture as EventLog;
11
+ const graph = discoverGraph(log);
12
+ const HOUR = 3_600_000;
13
+
14
+ const tokenEls = (root: HTMLElement) => [
15
+ ...root.querySelectorAll<SVGCircleElement>('[data-slot="flow-edge-tokens-token"]'),
16
+ ];
17
+
18
+ const readout = (root: HTMLElement) =>
19
+ root.querySelector<HTMLElement>('[data-slot="replay-controls-readout"]');
20
+
21
+ const meta = {
22
+ title: "Process/ProcessReplay",
23
+ component: ProcessReplay,
24
+ tags: ["autodocs"],
25
+ parameters: {
26
+ layout: "fullscreen",
27
+ docs: {
28
+ description: {
29
+ component:
30
+ "Cases replayed as tokens moving along the process map’s own transitions, with a " +
31
+ "time slider, play/pause, a speed select and a live in-flight case count. " +
32
+ "Congestion is never colour alone: a busy transition carries more tokens, each " +
33
+ "drawn larger, and a ranked list beside the map names the busiest transitions " +
34
+ "and when they peaked. Under reduced motion nothing starts on its own.",
35
+ },
36
+ },
37
+ },
38
+ decorators: [
39
+ (Story) => (
40
+ <div className="h-[40rem] w-full bg-background p-4">
41
+ <Story />
42
+ </div>
43
+ ),
44
+ ],
45
+ args: { graph, log, synchronizedStart: true, defaultTime: 1.5 * HOUR },
46
+ } satisfies Meta<typeof ProcessReplay>;
47
+ export default meta;
48
+ type Story = StoryObj<typeof meta>;
49
+
50
+ /**
51
+ * Paused at 1.5 h after each case’s start: one token per in-flight case, and the busiest
52
+ * transition (two cases on Check Credit → Approve Order) draws the largest tokens.
53
+ */
54
+ export const Paused: Story = {
55
+ play: async ({ canvasElement }) => {
56
+ await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(5), { timeout: 5000 });
57
+ const radii = tokenEls(canvasElement).map((token) => Number(token.getAttribute("r")));
58
+ const busiestId = processEdgeId("Check Credit", "Approve Order");
59
+ const busiest = [...canvasElement.querySelectorAll(".react-flow__edge")].find(
60
+ (edge) => edge.getAttribute("data-id") === busiestId,
61
+ );
62
+ const busiestTokens = [
63
+ ...(busiest?.querySelectorAll('[data-slot="flow-edge-tokens-token"]') ?? []),
64
+ ];
65
+ await expect(busiestTokens).toHaveLength(2);
66
+ const busiestRadius = Math.max(
67
+ ...busiestTokens.map((token) => Number(token.getAttribute("r"))),
68
+ );
69
+ await expect(busiestRadius).toBe(Math.max(...radii));
70
+ await expect(Math.min(...radii)).toBeLessThan(busiestRadius);
71
+ const canvas = within(canvasElement);
72
+ await expect(canvas.getByRole("button", { name: "Play replay" })).toBeVisible();
73
+ await expect(readout(canvasElement)).toHaveTextContent("+1.5 h · 5 in flight");
74
+ },
75
+ };
76
+
77
+ /**
78
+ * Playing from the start: the playhead advances and tokens move along edges. The test browser
79
+ * runs with reduced motion, where `defaultPlaying` is held, so the play function presses play
80
+ * the way a reader would.
81
+ */
82
+ export const Playing: Story = {
83
+ args: { defaultTime: 0, defaultPlaying: true },
84
+ play: async ({ canvasElement }) => {
85
+ const canvas = within(canvasElement);
86
+ const play = canvas.queryByRole("button", { name: "Play replay" });
87
+ if (play) await userEvent.click(play);
88
+ const slider = canvas.getByRole("slider", { name: "Replay time" });
89
+ await waitFor(() => expect(Number(slider.getAttribute("aria-valuenow"))).toBeGreaterThan(0), {
90
+ timeout: 5000,
91
+ });
92
+ await expect(canvas.getByRole("button", { name: "Pause replay" })).toBeVisible();
93
+ await userEvent.click(canvas.getByRole("button", { name: "Pause replay" }));
94
+ await expect(canvas.getByRole("button", { name: "Play replay" })).toBeVisible();
95
+ },
96
+ };
97
+
98
+ /** Synchronized start: every case begins at t = 0, so all five tokens share the first edge. */
99
+ export const SynchronizedStart: Story = {
100
+ args: { defaultTime: 0 },
101
+ play: async ({ canvasElement }) => {
102
+ await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(5), { timeout: 5000 });
103
+ await expect(readout(canvasElement)).toHaveTextContent("+0 s · 5 in flight");
104
+ },
105
+ };
106
+
107
+ /** Wall-clock time: cases start on different days, so the readout shows dates. */
108
+ export const WallClock: Story = {
109
+ args: { synchronizedStart: false, defaultTime: 0 },
110
+ play: async ({ canvasElement }) => {
111
+ await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(1), { timeout: 5000 });
112
+ },
113
+ };
114
+
115
+ /** The speed select and the time slider, driven from the keyboard. */
116
+ export const SpeedControl: Story = {
117
+ play: async ({ canvasElement }) => {
118
+ const canvas = within(canvasElement);
119
+ const speed = canvas.getByRole("combobox", { name: "Playback speed" });
120
+ speed.focus();
121
+ await userEvent.keyboard("{Enter}");
122
+ const option = await within(canvasElement.ownerDocument.body).findByRole("option", {
123
+ name: "4×",
124
+ });
125
+ await userEvent.click(option);
126
+ await waitFor(() => expect(speed).toHaveTextContent("4×"));
127
+
128
+ const slider = canvas.getByRole("slider", { name: "Replay time" });
129
+ slider.focus();
130
+ await userEvent.keyboard("{Home}");
131
+ await expect(slider).toHaveAttribute("aria-valuetext", "+0 s");
132
+ await expect(readout(canvasElement)).toHaveTextContent("+0 s · 5 in flight");
133
+ },
134
+ };
135
+
136
+ /**
137
+ * Reduced motion: `defaultPlaying` is ignored — nothing moves until the reader presses play —
138
+ * and the ranked congestion list carries the whole reading in text.
139
+ */
140
+ export const ReducedMotion: Story = {
141
+ args: { defaultPlaying: true },
142
+ globals: { motionPref: "reduced" },
143
+ play: async ({ canvasElement }) => {
144
+ const canvas = within(canvasElement);
145
+ await expect(canvas.getByRole("button", { name: "Play replay" })).toBeVisible();
146
+ await expect(canvasElement.querySelectorAll("animateMotion")).toHaveLength(0);
147
+ const heat = canvas.getByRole("region", { name: "Most congested transitions" });
148
+ await expect(heat).toHaveTextContent(/Busiest: Create Order → Check Credit, 5 cases/);
149
+ await expect(within(heat).getAllByRole("listitem").length).toBeGreaterThan(0);
150
+ },
151
+ };
152
+
153
+ /** A log with no cases. */
154
+ export const Empty: Story = {
155
+ args: { log: { events: [] } },
156
+ play: async ({ canvasElement }) => {
157
+ await expect(canvasElement.querySelector('[data-slot="process-replay"]')).toHaveAttribute(
158
+ "data-state",
159
+ "empty",
160
+ );
161
+ await expect(within(canvasElement).getByText("No cases to replay")).toBeInTheDocument();
162
+ },
163
+ };
164
+
165
+ /** No log yet. */
166
+ export const Loading: Story = {
167
+ args: { loading: true },
168
+ };
@@ -0,0 +1,170 @@
1
+ import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { discoverGraph } from "../core/discover-graph";
4
+ import { EDGE_KEY_SEPARATOR } from "../core/discover-graph";
5
+ import fixture from "../core/fixtures/order-to-cash-small.json";
6
+ import type { EventLog } from "../core/types";
7
+ import { CongestionHeat } from "./congestion-heat";
8
+ import { ProcessReplay } from "./process-replay";
9
+ import { replayTimeline } from "../core/replay-timeline";
10
+ import {
11
+ ProcessReplayTokensContext,
12
+ useProcessReplayEdgeTokens,
13
+ type ProcessReplayTokens,
14
+ } from "./replay-tokens-context";
15
+
16
+ // jsdom has no `DOMMatrixReadOnly`, which React Flow's transform math needs to mount the
17
+ // canvas at all — the same missing-browser-API fill `process-map.test.tsx` documents.
18
+ if (typeof globalThis.DOMMatrixReadOnly === "undefined") {
19
+ class DOMMatrixReadOnlyPolyfill {
20
+ m22 = 1;
21
+ constructor(_init?: unknown) {}
22
+ }
23
+ globalThis.DOMMatrixReadOnly = DOMMatrixReadOnlyPolyfill as unknown as typeof DOMMatrixReadOnly;
24
+ }
25
+
26
+ const log = fixture as EventLog;
27
+ const graph = discoverGraph(log);
28
+ const HOUR = 3_600_000;
29
+ const status = () => document.querySelector('[data-slot="process-replay-status"]');
30
+
31
+ let reduced = false;
32
+ let frames: FrameRequestCallback[] = [];
33
+
34
+ function flushFrame(now: number) {
35
+ const queued = frames;
36
+ frames = [];
37
+ act(() => queued.forEach((callback) => callback(now)));
38
+ }
39
+
40
+ beforeEach(() => {
41
+ reduced = false;
42
+ frames = [];
43
+ vi.stubGlobal("matchMedia", (query: string) => ({
44
+ matches: query.includes("reduce") ? reduced : false,
45
+ media: query,
46
+ addEventListener: () => {},
47
+ removeEventListener: () => {},
48
+ addListener: () => {},
49
+ removeListener: () => {},
50
+ }));
51
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
52
+ frames.push(callback);
53
+ return frames.length;
54
+ });
55
+ vi.stubGlobal("cancelAnimationFrame", () => {
56
+ frames = [];
57
+ });
58
+ });
59
+
60
+ afterEach(() => {
61
+ cleanup();
62
+ vi.unstubAllGlobals();
63
+ });
64
+
65
+ describe("ProcessReplay", () => {
66
+ it("renders the controls, the paused announcement and the ranked congestion list", () => {
67
+ render(<ProcessReplay graph={graph} log={log} synchronizedStart bucketMs={HOUR} />);
68
+ expect(screen.getByRole("button", { name: "Play replay" })).toBeInTheDocument();
69
+ expect(screen.getByRole("slider", { name: "Replay time" })).toHaveAttribute(
70
+ "aria-valuetext",
71
+ "+0 s",
72
+ );
73
+ expect(screen.getByRole("combobox", { name: "Playback speed" })).toBeInTheDocument();
74
+ expect(status()).toHaveTextContent("+0 s · 5 in flight");
75
+
76
+ const heat = screen.getByRole("region", { name: "Most congested transitions" });
77
+ const items = within(heat).getAllByRole("listitem");
78
+ expect(items[0]).toHaveTextContent("Create Order → Check Credit");
79
+ expect(items[0]).toHaveAttribute("data-peak", "5");
80
+ expect(heat).toHaveTextContent("Busiest: Create Order → Check Credit, 5 cases at +0 s");
81
+ });
82
+
83
+ it("scrubs with the keyboard, one bucket per arrow press", () => {
84
+ const onTimeChange = vi.fn();
85
+ render(
86
+ <ProcessReplay
87
+ graph={graph}
88
+ log={log}
89
+ synchronizedStart
90
+ bucketMs={HOUR}
91
+ onTimeChange={onTimeChange}
92
+ />,
93
+ );
94
+ fireEvent.keyDown(screen.getByRole("slider", { name: "Replay time" }), { key: "ArrowRight" });
95
+ expect(onTimeChange).toHaveBeenLastCalledWith(HOUR);
96
+ expect(status()).toHaveTextContent("+1.0 h · 5 in flight");
97
+ });
98
+
99
+ it("advances the playhead on animation frames and cancels the loop on pause", () => {
100
+ const onTimeChange = vi.fn();
101
+ render(
102
+ <ProcessReplay
103
+ graph={graph}
104
+ log={log}
105
+ synchronizedStart
106
+ bucketMs={HOUR}
107
+ onTimeChange={onTimeChange}
108
+ />,
109
+ );
110
+ fireEvent.click(screen.getByRole("button", { name: "Play replay" }));
111
+ expect(status()).toHaveTextContent("Replay playing");
112
+ flushFrame(0);
113
+ flushFrame(3000); // 10% of the 30 s playback at 1×.
114
+ expect(onTimeChange).toHaveBeenLastCalledWith(HOUR);
115
+
116
+ fireEvent.click(screen.getByRole("button", { name: "Pause replay" }));
117
+ expect(frames).toHaveLength(0);
118
+ expect(status()).toHaveTextContent("+1.0 h · 5 in flight");
119
+ });
120
+
121
+ it("never autoplays under reduced motion, but plays when the reader presses play", () => {
122
+ reduced = true;
123
+ render(<ProcessReplay graph={graph} log={log} bucketMs={HOUR} defaultPlaying />);
124
+ expect(screen.getByRole("button", { name: "Play replay" })).toBeInTheDocument();
125
+ expect(frames).toHaveLength(0);
126
+ fireEvent.click(screen.getByRole("button", { name: "Play replay" }));
127
+ expect(frames).toHaveLength(1);
128
+ });
129
+
130
+ it("renders the loading and empty panels", () => {
131
+ const { rerender } = render(<ProcessReplay graph={graph} log={log} loading />);
132
+ expect(document.querySelector('[data-slot="process-replay"]')).toHaveAttribute(
133
+ "data-state",
134
+ "loading",
135
+ );
136
+ rerender(<ProcessReplay graph={graph} log={{ events: [] }} />);
137
+ expect(screen.getByText("No cases to replay")).toBeInTheDocument();
138
+ });
139
+ });
140
+
141
+ describe("CongestionHeat", () => {
142
+ it("says so when no case moves", () => {
143
+ render(<CongestionHeat timeline={replayTimeline({ events: [] })} />);
144
+ expect(screen.getByText("No case moves along a transition.")).toBeInTheDocument();
145
+ });
146
+ });
147
+
148
+ describe("replay tokens context", () => {
149
+ function Probe({ edgeId }: { edgeId: string }) {
150
+ const tokens = useProcessReplayEdgeTokens(edgeId);
151
+ return <output>{tokens === undefined ? "none" : tokens.length}</output>;
152
+ }
153
+ const edgeId = `Create Order${EDGE_KEY_SEPARATOR}Check Credit`;
154
+
155
+ it("answers undefined without a provider, so the edge data stays unchanged", () => {
156
+ render(<Probe edgeId={edgeId} />);
157
+ expect(screen.getByRole("status")).toHaveTextContent("none");
158
+ });
159
+
160
+ it("answers the edge's tokens, or an empty list, inside a replay", () => {
161
+ const tokens: ProcessReplayTokens = new Map([[edgeId, [{ id: "case-1", progress: 0.5 }]]]);
162
+ render(
163
+ <ProcessReplayTokensContext value={tokens}>
164
+ <Probe edgeId={edgeId} />
165
+ <Probe edgeId="elsewhere" />
166
+ </ProcessReplayTokensContext>,
167
+ );
168
+ expect(screen.getAllByRole("status").map((el) => el.textContent)).toEqual(["1", "0"]);
169
+ });
170
+ });