@elabs-ai/components-process 4.1.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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/core/index.d.ts +1029 -0
- package/dist/core/index.js +1553 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/process-worker.js +462 -0
- package/dist/core/process-worker.js.map +1 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +3146 -0
- package/dist/index.js.map +1 -0
- package/dist/test/index.d.ts +196 -0
- package/dist/test/index.js +527 -0
- package/dist/test/index.js.map +1 -0
- package/package.json +80 -0
- package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
- package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
- package/src/abstraction-controls/abstraction-controls.tsx +288 -0
- package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
- package/src/abstraction-controls/auto-abstraction.ts +128 -0
- package/src/abstraction-controls/index.ts +4 -0
- package/src/core/abstract-graph.test.ts +209 -0
- package/src/core/abstract-graph.ts +407 -0
- package/src/core/adapters/csv.test.ts +131 -0
- package/src/core/adapters/csv.ts +146 -0
- package/src/core/adapters/flat.test.ts +149 -0
- package/src/core/adapters/flat.ts +168 -0
- package/src/core/aggregate-performance.test.ts +208 -0
- package/src/core/aggregate-performance.ts +200 -0
- package/src/core/detect-rework.test.ts +134 -0
- package/src/core/detect-rework.ts +100 -0
- package/src/core/discover-graph.test.ts +378 -0
- package/src/core/discover-graph.ts +202 -0
- package/src/core/duration-stats.test.ts +116 -0
- package/src/core/duration-stats.ts +162 -0
- package/src/core/event-log.test.ts +224 -0
- package/src/core/event-log.ts +244 -0
- package/src/core/extract-variants.test.ts +126 -0
- package/src/core/extract-variants.ts +140 -0
- package/src/core/filter-log.test.ts +193 -0
- package/src/core/filter-log.ts +215 -0
- package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
- package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
- package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
- package/src/core/fixtures/order-to-cash-small.json +200 -0
- package/src/core/fixtures/synthetic-log.test.ts +109 -0
- package/src/core/fixtures/synthetic-log.ts +167 -0
- package/src/core/index.ts +118 -0
- package/src/core/reconcile-graph.test.ts +175 -0
- package/src/core/reconcile-graph.ts +107 -0
- package/src/core/scale.test.ts +80 -0
- package/src/core/scale.ts +100 -0
- package/src/core/types.ts +151 -0
- package/src/core/worker/create-process-worker.test.ts +255 -0
- package/src/core/worker/create-process-worker.ts +211 -0
- package/src/core/worker/process-worker.ts +80 -0
- package/src/index.ts +29 -0
- package/src/metric-layer-switch/index.ts +6 -0
- package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
- package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
- package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
- package/src/process-explorer.stories.tsx +392 -0
- package/src/process-kpi-strip/index.ts +6 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
- package/src/process-map/index.ts +13 -0
- package/src/process-map/map-model.test.ts +326 -0
- package/src/process-map/map-model.ts +873 -0
- package/src/process-map/process-activity-node.tsx +200 -0
- package/src/process-map/process-map-context.ts +71 -0
- package/src/process-map/process-map.stories.tsx +673 -0
- package/src/process-map/process-map.test.tsx +523 -0
- package/src/process-map/process-map.tsx +979 -0
- package/src/process-map/process-transition-edge.test.tsx +160 -0
- package/src/process-map/process-transition-edge.tsx +151 -0
- package/src/process-map/use-process-layout.test.tsx +265 -0
- package/src/process-map/use-process-layout.ts +315 -0
- package/src/test/contract.test.ts +99 -0
- package/src/test/contract.ts +118 -0
- package/src/test/doubles.test.tsx +51 -0
- package/src/test/doubles.tsx +82 -0
- package/src/test/index.ts +34 -0
- package/src/test/primitives.tsx +35 -0
- package/src/use-process-explorer/index.ts +8 -0
- package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
- package/src/use-process-explorer/use-process-explorer.ts +540 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import "@xyflow/react/dist/style.css";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
5
|
+
import {
|
|
6
|
+
endpointsOffHandles,
|
|
7
|
+
framingMisses,
|
|
8
|
+
handlesOffCard,
|
|
9
|
+
labelsOverNodes,
|
|
10
|
+
miniMapNodeCount,
|
|
11
|
+
waitForSettledCanvas,
|
|
12
|
+
} from "@elabs-ai/components-flow/test";
|
|
13
|
+
import { abstractGraph } from "../core/abstract-graph";
|
|
14
|
+
import { detectRework } from "../core/detect-rework";
|
|
15
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
16
|
+
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
17
|
+
import type { ActivityStats, DurationStats, ProcessGraph, TransitionStats } from "../core/types";
|
|
18
|
+
import { useProcessExplorer } from "../use-process-explorer";
|
|
19
|
+
import { PROCESS_MAP_LEGIBLE_ZOOM, ProcessMap } from "./process-map";
|
|
20
|
+
import type { ProcessSelection } from "./map-model";
|
|
21
|
+
|
|
22
|
+
const log = generateSyntheticLog({ cases: 240, seed: 42 });
|
|
23
|
+
const graph = discoverGraph(log);
|
|
24
|
+
const rework = detectRework(log);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A 60-activity graph. The shipped synthetic log has eleven activities by design, so the
|
|
28
|
+
* scale story builds its own — a wide chain with skip paths and a few loops, which is the
|
|
29
|
+
* shape the "under 200 ms" layout budget is stated over.
|
|
30
|
+
*/
|
|
31
|
+
function largeGraph(size = 60): ProcessGraph {
|
|
32
|
+
const zero: DurationStats = {
|
|
33
|
+
min: 0,
|
|
34
|
+
max: 0,
|
|
35
|
+
mean: 0,
|
|
36
|
+
median: 0,
|
|
37
|
+
p90: 0,
|
|
38
|
+
sum: 0,
|
|
39
|
+
trimmedMean: 0,
|
|
40
|
+
};
|
|
41
|
+
const ids = Array.from({ length: size }, (_, i) => `Step ${String(i + 1).padStart(2, "0")}`);
|
|
42
|
+
const activities: ActivityStats[] = ids.map((id, index) => ({
|
|
43
|
+
id,
|
|
44
|
+
label: id,
|
|
45
|
+
instances: size * 4 - index * 3,
|
|
46
|
+
cases: size - index,
|
|
47
|
+
isStart: index === 0,
|
|
48
|
+
isEnd: index === size - 1,
|
|
49
|
+
duration: { ...zero, median: (index + 1) * 900_000 },
|
|
50
|
+
}));
|
|
51
|
+
const transitions: TransitionStats[] = [];
|
|
52
|
+
const push = (source: string, target: string, count: number) => {
|
|
53
|
+
transitions.push({
|
|
54
|
+
source,
|
|
55
|
+
target,
|
|
56
|
+
count,
|
|
57
|
+
caseCount: Math.max(1, Math.round(count * 0.8)),
|
|
58
|
+
duration: { ...zero, median: count * 60_000 },
|
|
59
|
+
isSelfLoop: source === target,
|
|
60
|
+
isBackEdge: false,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
for (let i = 0; i < ids.length - 1; i += 1) {
|
|
64
|
+
push(ids[i]!, ids[i + 1]!, size * 3 - i * 2);
|
|
65
|
+
if (i + 2 < ids.length) push(ids[i]!, ids[i + 2]!, Math.max(2, size - i));
|
|
66
|
+
if (i % 9 === 4) push(ids[i]!, ids[i]!, 6);
|
|
67
|
+
if (i % 13 === 7 && i > 3) push(ids[i]!, ids[i - 3]!, 5);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
activities,
|
|
71
|
+
transitions,
|
|
72
|
+
startActivities: { [ids[0]!]: size },
|
|
73
|
+
endActivities: { [ids[size - 1]!]: size },
|
|
74
|
+
totals: { cases: size, events: size * 6, variants: 9 },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The four things this canvas has to get right about its own picture, asserted in a REAL
|
|
80
|
+
* browser because none of them is visible to a typecheck and none is measurable in jsdom
|
|
81
|
+
* (which reports every box as 0×0, so every one of these assertions would pass vacuously).
|
|
82
|
+
*
|
|
83
|
+
* Each line locks one defect that actually shipped on this map:
|
|
84
|
+
*
|
|
85
|
+
* 1. **Framing** — the map opened at React Flow's `minZoom` floor of `0.5` with five of
|
|
86
|
+
* eleven activities below the fold, because `fitView` fires once and fired before the
|
|
87
|
+
* layout hook had positions.
|
|
88
|
+
* 2. **Connectors** — every handle dot sat 24 px (top-to-bottom) to 69 px (left-to-right)
|
|
89
|
+
* off the card it belonged to: the metric meter was rendered as a SIBLING of the card,
|
|
90
|
+
* and React Flow lays handles out against the nearest positioned ancestor.
|
|
91
|
+
* 3. **Edges** — an edge path that stops short of a dot reads as a broken graph. Both edge
|
|
92
|
+
* types are checked, including the self-loop, which used to draw an unattached arc
|
|
93
|
+
* floating above the node.
|
|
94
|
+
* 4. **Labels** — an edge's metric pill is portalled out of the SVG, so it contributes
|
|
95
|
+
* nothing to the layout maths; three pills per direction printed on top of activity
|
|
96
|
+
* names before the within-rank gap was widened.
|
|
97
|
+
*
|
|
98
|
+
* `waitForSettledCanvas` first, and it is not optional politeness about timing: an
|
|
99
|
+
* unlaid-out canvas has every node stacked on one point, where all four assertions below
|
|
100
|
+
* pass for the wrong reason. Measured — this helper passed in 129 ms against a picture in
|
|
101
|
+
* which eleven activities sat on top of each other.
|
|
102
|
+
*/
|
|
103
|
+
async function expectWellFramedCanvas(canvasElement: HTMLElement) {
|
|
104
|
+
await waitForSettledCanvas(canvasElement);
|
|
105
|
+
|
|
106
|
+
// 1. Everything in frame — or, once the graph outgrows the pane, the opening zoom
|
|
107
|
+
// sitting on the legibility floor with the START of the process in frame. The map
|
|
108
|
+
// is allowed to open partial; it is not allowed to open on the middle of nowhere.
|
|
109
|
+
expect(framingMisses(canvasElement, { legibleZoom: PROCESS_MAP_LEGIBLE_ZOOM })).toEqual([]);
|
|
110
|
+
|
|
111
|
+
// 2. Every connector dot on its own card's border.
|
|
112
|
+
expect(handlesOffCard(canvasElement)).toEqual([]);
|
|
113
|
+
|
|
114
|
+
// 3. Every edge — plain transition AND self-loop — terminating on a dot.
|
|
115
|
+
expect(endpointsOffHandles(canvasElement, "flow-weighted-edge")).toEqual([]);
|
|
116
|
+
expect(endpointsOffHandles(canvasElement, "flow-self-loop-edge")).toEqual([]);
|
|
117
|
+
|
|
118
|
+
// 4. No metric pill printed on top of an activity.
|
|
119
|
+
expect(labelsOverNodes(canvasElement)).toEqual([]);
|
|
120
|
+
|
|
121
|
+
// The overview thumbnail draws a rectangle per activity — it rendered blank while the
|
|
122
|
+
// measurements React Flow takes never reached the node objects the minimap reads.
|
|
123
|
+
expect(miniMapNodeCount(canvasElement)).toBe(
|
|
124
|
+
canvasElement.querySelectorAll(".react-flow__node").length,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Locks #350: `ZoomControls` and `FlowMiniMap` used to both resolve to the React Flow
|
|
130
|
+
* `bottom-right` panel slot, which is absolutely positioned — the pannable/zoomable
|
|
131
|
+
* minimap `<svg>` painted over the zoom buttons and took every pointer event meant for
|
|
132
|
+
* them.
|
|
133
|
+
*
|
|
134
|
+
* The hit test is the user-visible property: whatever `document.elementFromPoint` returns
|
|
135
|
+
* at a button's own centre is what a real click actually lands on, so a synthetic
|
|
136
|
+
* `.click()` (which fires regardless of what is drawn on top) would pass right through
|
|
137
|
+
* this exact bug — the reason the issue explicitly rules that approach out. The rect
|
|
138
|
+
* check is kept alongside it because it explains a hit-test failure with two numbers
|
|
139
|
+
* instead of a screen coordinate, and it also catches the two panels drifting back
|
|
140
|
+
* together at a size where they happen not to share a centre point.
|
|
141
|
+
*
|
|
142
|
+
* Also checked: neither panel overlaps the top rail (legend + filter trigger), which sits
|
|
143
|
+
* in its own `pointer-events-none` wrapper at the top of the canvas.
|
|
144
|
+
*/
|
|
145
|
+
function expectClickableZoomControls(canvasElement: HTMLElement) {
|
|
146
|
+
const rectsIntersect = (a: DOMRect, b: DOMRect) =>
|
|
147
|
+
a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
|
148
|
+
|
|
149
|
+
for (const label of ["Zoom in", "Zoom out", "Fit view"]) {
|
|
150
|
+
const button = within(canvasElement).getByRole("button", { name: label });
|
|
151
|
+
const rect = button.getBoundingClientRect();
|
|
152
|
+
const cx = rect.left + rect.width / 2;
|
|
153
|
+
const cy = rect.top + rect.height / 2;
|
|
154
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
155
|
+
expect(hit, `expected "${label}" to be the topmost element at its own centre`).toBeTruthy();
|
|
156
|
+
expect(
|
|
157
|
+
hit === button || button.contains(hit),
|
|
158
|
+
`"${label}" is covered at its own centre by ${hit?.outerHTML ?? "nothing"}`,
|
|
159
|
+
).toBe(true);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const zoomInButton = within(canvasElement).getByRole("button", { name: "Zoom in" });
|
|
163
|
+
const zoomPanel = zoomInButton.closest<HTMLElement>(".react-flow__panel");
|
|
164
|
+
const miniMap = canvasElement.querySelector<HTMLElement>(".react-flow__minimap");
|
|
165
|
+
const topRail = canvasElement.querySelector<HTMLElement>('[data-slot="process-map-top-rail"]');
|
|
166
|
+
expect(zoomPanel, "no .react-flow__panel around the zoom controls").toBeTruthy();
|
|
167
|
+
|
|
168
|
+
if (miniMap) {
|
|
169
|
+
expect(
|
|
170
|
+
rectsIntersect(zoomPanel!.getBoundingClientRect(), miniMap.getBoundingClientRect()),
|
|
171
|
+
"ZoomControls and FlowMiniMap bounding boxes intersect",
|
|
172
|
+
).toBe(false);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (topRail) {
|
|
176
|
+
const railRect = topRail.getBoundingClientRect();
|
|
177
|
+
expect(
|
|
178
|
+
rectsIntersect(zoomPanel!.getBoundingClientRect(), railRect),
|
|
179
|
+
"ZoomControls overlaps the top rail (legend + filter trigger)",
|
|
180
|
+
).toBe(false);
|
|
181
|
+
if (miniMap) {
|
|
182
|
+
expect(
|
|
183
|
+
rectsIntersect(miniMap.getBoundingClientRect(), railRect),
|
|
184
|
+
"FlowMiniMap overlaps the top rail (legend + filter trigger)",
|
|
185
|
+
).toBe(false);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const meta = {
|
|
191
|
+
title: "Process/ProcessMap",
|
|
192
|
+
component: ProcessMap,
|
|
193
|
+
tags: ["autodocs"],
|
|
194
|
+
parameters: {
|
|
195
|
+
layout: "fullscreen",
|
|
196
|
+
docs: {
|
|
197
|
+
description: {
|
|
198
|
+
component:
|
|
199
|
+
"The directly-follows map a process-mining session opens on: activities as " +
|
|
200
|
+
"nodes, directly-follows relations as edges, both painted from one metric " +
|
|
201
|
+
"choice. It is a composition of `@elabs-ai/components-flow` — `CanvasShell`, " +
|
|
202
|
+
"`layoutFlow`, `ZoomControls`, `FlowMiniMap` and a continuous `Legend` — over " +
|
|
203
|
+
"the framework-free graph from `@elabs-ai/components-process/core`. " +
|
|
204
|
+
"Every reading reaches the viewer through at least two channels: an edge " +
|
|
205
|
+
"carries its metric as stroke WIDTH and as a printed label pill before it " +
|
|
206
|
+
"carries it as hue, and a node prints its value above a meter bar whose LENGTH " +
|
|
207
|
+
"encodes the same number. Self-loops are a closed arc, back-edges are dashed, " +
|
|
208
|
+
"and a filter-excluded element is dimmed — never removed, never " +
|
|
209
|
+
"`aria-disabled` — so nothing on this canvas is legible only in colour, and " +
|
|
210
|
+
"clicking a dimmed element is how a reader filters it back in. `tableView` " +
|
|
211
|
+
"renders the identical numbers as two tables, sharing one formatter with the " +
|
|
212
|
+
"canvas so the two can never disagree.",
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
decorators: [
|
|
217
|
+
(Story) => (
|
|
218
|
+
<div className="h-[36rem] w-full bg-background">
|
|
219
|
+
<Story />
|
|
220
|
+
</div>
|
|
221
|
+
),
|
|
222
|
+
],
|
|
223
|
+
} satisfies Meta<typeof ProcessMap>;
|
|
224
|
+
export default meta;
|
|
225
|
+
type Story = StoryObj<typeof meta>;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The default reading: cases per activity, transitions per edge. Edge width and the
|
|
229
|
+
* printed pill both scale with the count; the node's meter bar and printed value both
|
|
230
|
+
* scale with its case count.
|
|
231
|
+
*/
|
|
232
|
+
export const Frequency: Story = {
|
|
233
|
+
args: {
|
|
234
|
+
graph,
|
|
235
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
236
|
+
},
|
|
237
|
+
play: async ({ canvasElement }) => {
|
|
238
|
+
const canvas = within(canvasElement);
|
|
239
|
+
await expectWellFramedCanvas(canvasElement);
|
|
240
|
+
expectClickableZoomControls(canvasElement);
|
|
241
|
+
|
|
242
|
+
// Click-to-select: a node picks itself, and the rest of the graph reads as excluded.
|
|
243
|
+
const nodes = canvasElement.querySelectorAll<HTMLElement>(
|
|
244
|
+
'[data-slot="process-activity-node"]',
|
|
245
|
+
);
|
|
246
|
+
await userEvent.click(nodes[0]!);
|
|
247
|
+
await waitFor(() =>
|
|
248
|
+
expect(canvasElement.querySelector('[data-selection="selected"]')).toBeTruthy(),
|
|
249
|
+
);
|
|
250
|
+
await waitFor(() =>
|
|
251
|
+
expect(canvasElement.querySelector('[data-selection="excluded"]')).toBeTruthy(),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// The legend names the edge metric, so a width is never an unlabelled width.
|
|
255
|
+
await expect(canvas.getByText("Transitions")).toBeInTheDocument();
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The same graph read as time: median activity duration on the nodes, median flow time on
|
|
261
|
+
* the edges. The value is always printed — a duration ramp is never asked to carry the
|
|
262
|
+
* reading on its own.
|
|
263
|
+
*/
|
|
264
|
+
export const Performance: Story = {
|
|
265
|
+
args: {
|
|
266
|
+
graph,
|
|
267
|
+
metric: { node: "median", edge: "median" },
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Rework tallies from `detectRework`, badged on the activities that repeat. The badge is a
|
|
273
|
+
* count and an `sr-only` sentence, not a colour.
|
|
274
|
+
*/
|
|
275
|
+
export const Rework: Story = {
|
|
276
|
+
args: {
|
|
277
|
+
graph,
|
|
278
|
+
metric: { node: "absolute", edge: "absolute", secondary: "median" },
|
|
279
|
+
rework,
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Half the activities and a third of the paths kept — `/core`'s `abstractGraph`. */
|
|
284
|
+
export const Abstracted: Story = {
|
|
285
|
+
args: {
|
|
286
|
+
graph: abstractGraph(graph, { activities: 0.5, paths: 0.35 }),
|
|
287
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A controlled selection, plus the keyboard path, asserted by a REAL `Tab` walk rather
|
|
293
|
+
* than a synthetic `focus()` call: `Tab` reaches every activity in layout order, `Enter`
|
|
294
|
+
* selects the one it is standing on, `F` opens the filter-intent menu for it, and
|
|
295
|
+
* `Escape` puts focus back on the activity instead of on the Filter button.
|
|
296
|
+
*
|
|
297
|
+
* Wired through `useProcessExplorer` (RM-052 round 2, #227) rather than a bare `useState`,
|
|
298
|
+
* so `onFilterIntent` is the hook's real `applyIntent` — choosing an intent from the menu
|
|
299
|
+
* this story already opens does not shrink the canvas. The tail of the play function
|
|
300
|
+
* proves it: applying a real filter dims the activities it drops but leaves every one of
|
|
301
|
+
* them in the DOM (Invariant F).
|
|
302
|
+
*/
|
|
303
|
+
export const Selection: Story = {
|
|
304
|
+
args: {
|
|
305
|
+
graph,
|
|
306
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
307
|
+
},
|
|
308
|
+
render: function SelectionStory(args) {
|
|
309
|
+
const explorer = useProcessExplorer(log);
|
|
310
|
+
const [selection, setSelection] = useState<ProcessSelection | null>({
|
|
311
|
+
kind: "activity",
|
|
312
|
+
id: graph.activities[0]!.id,
|
|
313
|
+
});
|
|
314
|
+
return (
|
|
315
|
+
<ProcessMap
|
|
316
|
+
{...args}
|
|
317
|
+
graph={explorer.graph}
|
|
318
|
+
selection={selection}
|
|
319
|
+
onSelect={setSelection}
|
|
320
|
+
selectionStates={explorer.selectionStates}
|
|
321
|
+
onFilterIntent={explorer.applyIntent}
|
|
322
|
+
/>
|
|
323
|
+
);
|
|
324
|
+
},
|
|
325
|
+
play: async ({ canvasElement }) => {
|
|
326
|
+
await waitFor(() =>
|
|
327
|
+
expect(canvasElement.querySelector('[data-selection="selected"]')).toBeTruthy(),
|
|
328
|
+
);
|
|
329
|
+
// The selection lands before the layout does, so waiting on it is not enough: this
|
|
330
|
+
// snapshot of DOM order is only meaningful once the map has been laid out, because
|
|
331
|
+
// `applyPositions` re-sorts the nodes into reading order as part of that pass. Taken a
|
|
332
|
+
// frame early it captures the model's own alphabetical order instead, and the tab-order
|
|
333
|
+
// assertion below then compares a real traversal against a picture that never rendered.
|
|
334
|
+
await waitForSettledCanvas(canvasElement);
|
|
335
|
+
|
|
336
|
+
const wrappers = [...canvasElement.querySelectorAll<HTMLElement>(".react-flow__node")];
|
|
337
|
+
expect(wrappers.length).toBeGreaterThan(1);
|
|
338
|
+
|
|
339
|
+
// ── DOM order IS layout order ────────────────────────────────────────────────────
|
|
340
|
+
// Read each node's own `translate(Xpx, Ypx)` out of `wrapper.style.transform` —
|
|
341
|
+
// React Flow's authoritative, un-animated position (written straight from
|
|
342
|
+
// `node.position` — see `applyPositions`, `process-map.tsx`) — rather than calling
|
|
343
|
+
// `getBoundingClientRect()`. A measured rectangle is wrong here for TWO independent
|
|
344
|
+
// reasons, not one: (1) focusing a node pans the viewport (`autoPanOnNodeFocus`),
|
|
345
|
+
// which this still runs before, and (2) every node also carries the map's own 260ms
|
|
346
|
+
// entry transition, sliding it in from the model's mount position `{x:0, y:0}`
|
|
347
|
+
// (`PROCESS_MAP_NODE_MOTION_CLASS`, `use-process-layout.ts`) — so a rectangle sampled
|
|
348
|
+
// this early can be a mid-flight animation frame rather than the laid-out position
|
|
349
|
+
// (#365). The inline `transform` is a value a CSS transition never touches, so it
|
|
350
|
+
// reads the final, laid-out coordinate on the very first commit that has it, whatever
|
|
351
|
+
// frame the animation itself is on. The map is laid out `TB`, so reading order is
|
|
352
|
+
// top-to-bottom and then left-to-right — exactly what `applyPositions` sorts by, and
|
|
353
|
+
// therefore what the tab order below inherits.
|
|
354
|
+
const positions = wrappers.map((wrapper) => {
|
|
355
|
+
const match = /translate\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px\s*\)/.exec(
|
|
356
|
+
wrapper.style.transform,
|
|
357
|
+
);
|
|
358
|
+
expect(
|
|
359
|
+
match,
|
|
360
|
+
`expected a translate() transform on node "${wrapper.dataset.id}"`,
|
|
361
|
+
).toBeTruthy();
|
|
362
|
+
return { x: Number(match![1]), y: Number(match![2]) };
|
|
363
|
+
});
|
|
364
|
+
for (let i = 1; i < positions.length; i += 1) {
|
|
365
|
+
const previous = positions[i - 1]!;
|
|
366
|
+
const current = positions[i]!;
|
|
367
|
+
// dagre gives an entire rank the SAME y — an exact tie, not a 1px tolerance, because
|
|
368
|
+
// this is the laid-out coordinate rather than a painted, sub-pixel rectangle.
|
|
369
|
+
const sameRow = previous.y === current.y;
|
|
370
|
+
expect({
|
|
371
|
+
index: i,
|
|
372
|
+
y: [previous.y, current.y],
|
|
373
|
+
x: [previous.x, current.x],
|
|
374
|
+
ordered: sameRow ? previous.x <= current.x : previous.y < current.y,
|
|
375
|
+
}).toMatchObject({ ordered: true });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ── A real Tab walk ─────────────────────────────────────────────────────────────
|
|
379
|
+
// From the top of the document, so this measures the traversal a keyboard user
|
|
380
|
+
// actually performs. A synthetic `.focus()` would prove the element is focusable and
|
|
381
|
+
// nothing about reachability or order.
|
|
382
|
+
(document.activeElement as HTMLElement | null)?.blur();
|
|
383
|
+
const visited: HTMLElement[] = [];
|
|
384
|
+
const visitedNodes: HTMLElement[] = [];
|
|
385
|
+
for (let i = 0; i < 80 && visitedNodes.length < wrappers.length; i += 1) {
|
|
386
|
+
await userEvent.tab();
|
|
387
|
+
const active = document.activeElement as HTMLElement | null;
|
|
388
|
+
if (!active || active === document.body) break;
|
|
389
|
+
visited.push(active);
|
|
390
|
+
if (active.classList.contains("react-flow__node")) visitedNodes.push(active);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Every activity is reached, and in the same order the DOM (and so the layout) has
|
|
394
|
+
// them — this is the acceptance criterion, asserted against the real traversal.
|
|
395
|
+
expect(visitedNodes.map((node) => node.dataset.id)).toEqual(
|
|
396
|
+
wrappers.map((wrapper) => wrapper.dataset.id),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// An arrow is ONE tab stop (its label pill), never two: the edge `<g>` itself is not
|
|
400
|
+
// focusable, so the activities are not buried behind a second stop per edge.
|
|
401
|
+
expect(visited.filter((element) => element.classList.contains("react-flow__edge"))).toEqual([]);
|
|
402
|
+
const pills = canvasElement.querySelectorAll('[data-slot="edge-label-pill"]');
|
|
403
|
+
expect(visited.indexOf(visitedNodes[0]!)).toBe(pills.length);
|
|
404
|
+
|
|
405
|
+
// ── Enter selects, F opens the menu, Escape comes back ───────────────────────────
|
|
406
|
+
const standingOn = visitedNodes.at(-1)!;
|
|
407
|
+
expect(document.activeElement).toBe(standingOn);
|
|
408
|
+
await userEvent.keyboard("{Enter}");
|
|
409
|
+
await waitFor(() =>
|
|
410
|
+
expect(
|
|
411
|
+
standingOn
|
|
412
|
+
.querySelector('[data-slot="process-activity-node"]')
|
|
413
|
+
?.getAttribute("data-selection"),
|
|
414
|
+
).toBe("selected"),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
await userEvent.keyboard("f");
|
|
418
|
+
const menu = await waitFor(() => {
|
|
419
|
+
const found = document.querySelector<HTMLElement>('[role="menu"]');
|
|
420
|
+
expect(found).toBeTruthy();
|
|
421
|
+
return found!;
|
|
422
|
+
});
|
|
423
|
+
expect(within(menu).getAllByRole("menuitem")).toHaveLength(4);
|
|
424
|
+
|
|
425
|
+
// Escape returns focus to the activity the user was standing on — not to the Filter
|
|
426
|
+
// trigger, which is Radix's default and would drop a keyboard user out of the graph.
|
|
427
|
+
await userEvent.keyboard("{Escape}");
|
|
428
|
+
await waitFor(() => expect(document.activeElement).toBe(standingOn));
|
|
429
|
+
|
|
430
|
+
// ── The same two keys, on an ARROW ───────────────────────────────────────────────
|
|
431
|
+
// An edge's label pill is portalled out of the edge's own `<g>` by
|
|
432
|
+
// `EdgeLabelRenderer`, so it has no `[data-id]` ancestor to read; the map recovers the
|
|
433
|
+
// edge id by catching the key on the edge component itself, where portal events bubble
|
|
434
|
+
// in the React tree. A transition offers BOTH endpoints, so eight menu items rather
|
|
435
|
+
// than a node's four is the observable proof the right target was resolved.
|
|
436
|
+
const shapes = [
|
|
437
|
+
...canvasElement.querySelectorAll<SVGGElement>('[data-slot="process-transition-edge"]'),
|
|
438
|
+
];
|
|
439
|
+
const forward = shapes.findIndex((shape) => shape.dataset.shape !== "self-loop");
|
|
440
|
+
expect(forward).toBeGreaterThanOrEqual(0);
|
|
441
|
+
|
|
442
|
+
(document.activeElement as HTMLElement | null)?.blur();
|
|
443
|
+
for (let i = 0; i <= forward; i += 1) await userEvent.tab();
|
|
444
|
+
const pill = document.activeElement as HTMLElement;
|
|
445
|
+
expect(pill).toHaveAttribute("data-slot", "edge-label-pill");
|
|
446
|
+
|
|
447
|
+
const pillIndex = [
|
|
448
|
+
...canvasElement.querySelectorAll<HTMLElement>('[data-slot="edge-label-pill"]'),
|
|
449
|
+
].indexOf(pill);
|
|
450
|
+
|
|
451
|
+
await userEvent.keyboard("f");
|
|
452
|
+
const edgeMenu = await waitFor(() => {
|
|
453
|
+
const found = document.querySelector<HTMLElement>('[role="menu"]');
|
|
454
|
+
expect(found).toBeTruthy();
|
|
455
|
+
return found!;
|
|
456
|
+
});
|
|
457
|
+
expect(within(edgeMenu).getAllByRole("menuitem")).toHaveLength(8);
|
|
458
|
+
|
|
459
|
+
// Escape comes back to the ARROW too. Compared by position rather than by node
|
|
460
|
+
// identity: React Flow re-creates every portalled label pill on a re-render, so the
|
|
461
|
+
// button the user was standing on is a different DOM node by the time the menu closes
|
|
462
|
+
// — which is exactly what `focusRestorer` exists to survive.
|
|
463
|
+
await userEvent.keyboard("{Escape}");
|
|
464
|
+
await waitFor(() => {
|
|
465
|
+
const active = document.activeElement as HTMLElement;
|
|
466
|
+
expect(active.dataset.slot).toBe("edge-label-pill");
|
|
467
|
+
expect(
|
|
468
|
+
[...canvasElement.querySelectorAll<HTMLElement>('[data-slot="edge-label-pill"]')].indexOf(
|
|
469
|
+
active,
|
|
470
|
+
),
|
|
471
|
+
).toBe(pillIndex);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// ── Filtering re-inks, it never removes (Invariant F, RM-052 round 2, #227) ──────
|
|
475
|
+
// `onFilterIntent` is wired straight into `useProcessExplorer`'s own `applyIntent` —
|
|
476
|
+
// choosing a real intent from the menu this story already knows how to open must dim
|
|
477
|
+
// the activities it drops, never delete them from the canvas.
|
|
478
|
+
//
|
|
479
|
+
// Non-vacuity fix (RM-052 round 3, #227, G6): `standingOn` was selected via `Enter`
|
|
480
|
+
// earlier in this same play function and stays selected here, so
|
|
481
|
+
// `resolveSelectionState`'s neighbourhood rule (an activity outside the selection's
|
|
482
|
+
// one-hop neighbourhood reads "excluded" once something else is "selected") already
|
|
483
|
+
// paints excluded nodes BEFORE the filter click below ever runs — a bare
|
|
484
|
+
// `toBeTruthy()` on "some excluded node exists" would pass even if the filter click
|
|
485
|
+
// changed nothing. Capture the excluded count before the click and require it to
|
|
486
|
+
// strictly grow, so the assertion actually depends on the filter's effect rather than
|
|
487
|
+
// on the pre-existing selection.
|
|
488
|
+
//
|
|
489
|
+
// Menu item [1] ("Keep cases without" <activity>), not [0] ("Keep cases containing"
|
|
490
|
+
// <activity>) — and NOT `standingOn` (RM-052 round 4, #227, H2, correcting round 3's
|
|
491
|
+
// G6 comment). The `f` press right above this block fires while focus sits on the
|
|
492
|
+
// edge's label pill (restored by the Escape at line ~319), so `targetOfEvent` resolves
|
|
493
|
+
// a TRANSITION, and `menuActivities` returns `[edge.source, edge.target]` for one —
|
|
494
|
+
// this is the 8-item EDGE menu, not the 4-item activity menu `standingOn` would open.
|
|
495
|
+
// Menu item [1] belongs to the FIRST group, i.e. `edge.source`: measured directly by
|
|
496
|
+
// dumping the open menu in a real browser, that edge's source is common to (or the
|
|
497
|
+
// only start activity of) effectively every case, so "containing" it is a no-op filter
|
|
498
|
+
// — confirmed by running this fixture with item [0] and observing the excluded count
|
|
499
|
+
// stay flat at 29 before and after the click, i.e. exactly the false pass this lock
|
|
500
|
+
// exists to prevent. "without" instead drops the cases through `edge.source`, so other
|
|
501
|
+
// activities lose their statistics and the excluded count measurably grows (29 → 35).
|
|
502
|
+
const totalNodesBeforeFilter = wrappers.length;
|
|
503
|
+
const excludedCountBeforeFilter = canvasElement.querySelectorAll(
|
|
504
|
+
'[data-selection="excluded"]',
|
|
505
|
+
).length;
|
|
506
|
+
// #375: the live region's text is captured BEFORE the click too, so the assertion
|
|
507
|
+
// below proves the SAME click that re-inks the canvas also moves the announcement —
|
|
508
|
+
// not merely that some non-empty sentence happens to be present at rest.
|
|
509
|
+
const statusBeforeFilter = canvasElement.querySelector('[role="status"]')!.textContent;
|
|
510
|
+
await userEvent.keyboard("f");
|
|
511
|
+
const filterMenu = await waitFor(() => {
|
|
512
|
+
const found = document.querySelector<HTMLElement>('[role="menu"]');
|
|
513
|
+
expect(found).toBeTruthy();
|
|
514
|
+
return found!;
|
|
515
|
+
});
|
|
516
|
+
await userEvent.click(within(filterMenu).getAllByRole("menuitem")[1]!);
|
|
517
|
+
|
|
518
|
+
await waitFor(() =>
|
|
519
|
+
expect(canvasElement.querySelectorAll('[data-selection="excluded"]').length).toBeGreaterThan(
|
|
520
|
+
excludedCountBeforeFilter,
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
expect(canvasElement.querySelectorAll('[data-slot="process-activity-node"]').length).toBe(
|
|
524
|
+
totalNodesBeforeFilter,
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
// ── The live region announces what the click just did (#375) ────────────────────
|
|
528
|
+
// Filtering re-inks the canvas, but nothing narrated it outside the DOM — this is
|
|
529
|
+
// the exact gap #375 closes: the SAME real filter click above must also move the
|
|
530
|
+
// one `role="status"` region's text off its pre-click content.
|
|
531
|
+
const statusAfterFilter = canvasElement.querySelector('[role="status"]')!.textContent;
|
|
532
|
+
expect(statusAfterFilter).not.toBe(statusBeforeFilter);
|
|
533
|
+
expect(statusAfterFilter).not.toMatch(/^0 of \d+ activities? excluded/);
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Left-to-right layout. The same cached model; only the layout key changes.
|
|
539
|
+
*
|
|
540
|
+
* It repeats the whole framing check rather than trusting the top-to-bottom pass, because
|
|
541
|
+
* the two directions put the handles on different sides of the card and every one of the
|
|
542
|
+
* defects {@link expectWellFramedCanvas} locks behaved DIFFERENTLY here: the connector
|
|
543
|
+
* drift measured 69 px against top-to-bottom's 24 px, and it was the direction FLIP that
|
|
544
|
+
* triggered it — the node's motion transition was reaching React Flow's handle elements as
|
|
545
|
+
* well as the node body, so a dot animated to its new side while React Flow measured it.
|
|
546
|
+
*/
|
|
547
|
+
export const LeftToRight: Story = {
|
|
548
|
+
args: {
|
|
549
|
+
graph,
|
|
550
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
551
|
+
direction: "LR",
|
|
552
|
+
},
|
|
553
|
+
play: async ({ canvasElement }) => {
|
|
554
|
+
await expectWellFramedCanvas(canvasElement);
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The accessible twin required for a graph that cannot be read as a picture. It renders
|
|
560
|
+
* from the same model as the canvas, so the numbers are identical by construction.
|
|
561
|
+
*/
|
|
562
|
+
export const TableView: Story = {
|
|
563
|
+
args: {
|
|
564
|
+
graph,
|
|
565
|
+
metric: { node: "absolute_case", edge: "absolute", secondary: "median" },
|
|
566
|
+
tableView: true,
|
|
567
|
+
rework,
|
|
568
|
+
},
|
|
569
|
+
decorators: [
|
|
570
|
+
(Story) => (
|
|
571
|
+
<div className="h-[36rem] w-full overflow-auto bg-background p-6">
|
|
572
|
+
<Story />
|
|
573
|
+
</div>
|
|
574
|
+
),
|
|
575
|
+
],
|
|
576
|
+
play: async ({ canvasElement }) => {
|
|
577
|
+
const canvas = within(canvasElement);
|
|
578
|
+
const activities = await canvas.findByRole("table", { name: /Activities/ });
|
|
579
|
+
await expect(within(activities).getAllByRole("row").length).toBeGreaterThan(1);
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The accessible twin WITH an active selection AND an active filter exclusion (#373) — the
|
|
585
|
+
* cell of the story matrix that was missing before this fix. No shipped story combined
|
|
586
|
+
* `tableView` with a real `selection`/`selectionStates`, so the twin's state gap never
|
|
587
|
+
* reached the blocking interaction + axe job or autodocs. Locks that a selected row and an
|
|
588
|
+
* excluded row are each named for their real, perceivable state.
|
|
589
|
+
*/
|
|
590
|
+
export const TableViewSelection: Story = {
|
|
591
|
+
args: {
|
|
592
|
+
graph,
|
|
593
|
+
metric: { node: "absolute_case", edge: "absolute", secondary: "median" },
|
|
594
|
+
tableView: true,
|
|
595
|
+
rework,
|
|
596
|
+
selection: { kind: "activity", id: graph.activities[0]!.id },
|
|
597
|
+
selectionStates: {
|
|
598
|
+
activities: { [graph.activities[graph.activities.length - 1]!.id]: "excluded" },
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
decorators: [
|
|
602
|
+
(Story) => (
|
|
603
|
+
<div className="h-[36rem] w-full overflow-auto bg-background p-6">
|
|
604
|
+
<Story />
|
|
605
|
+
</div>
|
|
606
|
+
),
|
|
607
|
+
],
|
|
608
|
+
play: async ({ canvasElement }) => {
|
|
609
|
+
const canvas = within(canvasElement);
|
|
610
|
+
const activities = await canvas.findByRole("table", { name: /Activities/ });
|
|
611
|
+
const selectedRow = within(activities).getByRole("row", {
|
|
612
|
+
name: new RegExp(graph.activities[0]!.id),
|
|
613
|
+
});
|
|
614
|
+
const excludedRow = within(activities).getByRole("row", {
|
|
615
|
+
name: new RegExp(graph.activities[graph.activities.length - 1]!.id),
|
|
616
|
+
});
|
|
617
|
+
await expect(selectedRow).toHaveAccessibleName(/Selected/);
|
|
618
|
+
await expect(excludedRow).toHaveAccessibleName(/Excluded/);
|
|
619
|
+
// The two rows' accessible names actually differ — not merely their `data-*`
|
|
620
|
+
// attributes, which the pre-fix markup already carried correctly.
|
|
621
|
+
await expect(selectedRow).not.toHaveAccessibleName(excludedRow.textContent ?? "");
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/** No graph yet — a whole-region loading panel, not an empty canvas. */
|
|
626
|
+
export const Loading: Story = {
|
|
627
|
+
args: {
|
|
628
|
+
graph,
|
|
629
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
630
|
+
loading: true,
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
/** An abstraction that hid everything, or a log with no events. */
|
|
635
|
+
export const Empty: Story = {
|
|
636
|
+
args: {
|
|
637
|
+
graph: {
|
|
638
|
+
activities: [],
|
|
639
|
+
transitions: [],
|
|
640
|
+
startActivities: {},
|
|
641
|
+
endActivities: {},
|
|
642
|
+
totals: { cases: 0, events: 0, variants: 0 },
|
|
643
|
+
},
|
|
644
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Sixty activities with skip paths, self-loops and back-edges — the layout budget case,
|
|
650
|
+
* and the one where the overview thumbnail earns its place: at this size the reader
|
|
651
|
+
* navigates by the minimap, so a blank one is not a cosmetic loss.
|
|
652
|
+
*
|
|
653
|
+
* Only the thumbnail is asserted here. Label crowding at sixty activities is a real
|
|
654
|
+
* property of the graph rather than a layout defect, so the pill/card check belongs on the
|
|
655
|
+
* two eleven-activity stories, where a collision means the spacing is wrong.
|
|
656
|
+
*/
|
|
657
|
+
export const LargeGraph: Story = {
|
|
658
|
+
args: {
|
|
659
|
+
graph: largeGraph(60),
|
|
660
|
+
metric: { node: "absolute_case", edge: "absolute" },
|
|
661
|
+
showMiniMap: true,
|
|
662
|
+
},
|
|
663
|
+
play: async ({ canvasElement }) => {
|
|
664
|
+
await waitFor(
|
|
665
|
+
() =>
|
|
666
|
+
expect(miniMapNodeCount(canvasElement)).toBe(
|
|
667
|
+
canvasElement.querySelectorAll(".react-flow__node").length,
|
|
668
|
+
),
|
|
669
|
+
{ timeout: 15_000 },
|
|
670
|
+
);
|
|
671
|
+
expect(miniMapNodeCount(canvasElement)).toBeGreaterThan(1);
|
|
672
|
+
},
|
|
673
|
+
};
|