@elabs-ai/components-process 4.2.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.
- package/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- 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
|
-
|
|
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
|
|
242
|
+
const layoutOptions = {
|
|
235
243
|
direction,
|
|
236
244
|
nodeSpacing: NODE_SPACING,
|
|
237
245
|
rankSpacing: RANK_SPACING[direction],
|
|
238
|
-
}
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
});
|