@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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/core/index.d.ts +1029 -0
  4. package/dist/core/index.js +1553 -0
  5. package/dist/core/index.js.map +1 -0
  6. package/dist/core/process-worker.js +462 -0
  7. package/dist/core/process-worker.js.map +1 -0
  8. package/dist/index.d.ts +1153 -0
  9. package/dist/index.js +3146 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/test/index.d.ts +196 -0
  12. package/dist/test/index.js +527 -0
  13. package/dist/test/index.js.map +1 -0
  14. package/package.json +80 -0
  15. package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
  16. package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
  17. package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
  18. package/src/abstraction-controls/abstraction-controls.tsx +288 -0
  19. package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
  20. package/src/abstraction-controls/auto-abstraction.ts +128 -0
  21. package/src/abstraction-controls/index.ts +4 -0
  22. package/src/core/abstract-graph.test.ts +209 -0
  23. package/src/core/abstract-graph.ts +407 -0
  24. package/src/core/adapters/csv.test.ts +131 -0
  25. package/src/core/adapters/csv.ts +146 -0
  26. package/src/core/adapters/flat.test.ts +149 -0
  27. package/src/core/adapters/flat.ts +168 -0
  28. package/src/core/aggregate-performance.test.ts +208 -0
  29. package/src/core/aggregate-performance.ts +200 -0
  30. package/src/core/detect-rework.test.ts +134 -0
  31. package/src/core/detect-rework.ts +100 -0
  32. package/src/core/discover-graph.test.ts +378 -0
  33. package/src/core/discover-graph.ts +202 -0
  34. package/src/core/duration-stats.test.ts +116 -0
  35. package/src/core/duration-stats.ts +162 -0
  36. package/src/core/event-log.test.ts +224 -0
  37. package/src/core/event-log.ts +244 -0
  38. package/src/core/extract-variants.test.ts +126 -0
  39. package/src/core/extract-variants.ts +140 -0
  40. package/src/core/filter-log.test.ts +193 -0
  41. package/src/core/filter-log.ts +215 -0
  42. package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
  43. package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
  44. package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
  45. package/src/core/fixtures/order-to-cash-small.json +200 -0
  46. package/src/core/fixtures/synthetic-log.test.ts +109 -0
  47. package/src/core/fixtures/synthetic-log.ts +167 -0
  48. package/src/core/index.ts +118 -0
  49. package/src/core/reconcile-graph.test.ts +175 -0
  50. package/src/core/reconcile-graph.ts +107 -0
  51. package/src/core/scale.test.ts +80 -0
  52. package/src/core/scale.ts +100 -0
  53. package/src/core/types.ts +151 -0
  54. package/src/core/worker/create-process-worker.test.ts +255 -0
  55. package/src/core/worker/create-process-worker.ts +211 -0
  56. package/src/core/worker/process-worker.ts +80 -0
  57. package/src/index.ts +29 -0
  58. package/src/metric-layer-switch/index.ts +6 -0
  59. package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
  60. package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
  61. package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
  62. package/src/process-explorer.stories.tsx +392 -0
  63. package/src/process-kpi-strip/index.ts +6 -0
  64. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
  65. package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
  66. package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
  67. package/src/process-map/index.ts +13 -0
  68. package/src/process-map/map-model.test.ts +326 -0
  69. package/src/process-map/map-model.ts +873 -0
  70. package/src/process-map/process-activity-node.tsx +200 -0
  71. package/src/process-map/process-map-context.ts +71 -0
  72. package/src/process-map/process-map.stories.tsx +673 -0
  73. package/src/process-map/process-map.test.tsx +523 -0
  74. package/src/process-map/process-map.tsx +979 -0
  75. package/src/process-map/process-transition-edge.test.tsx +160 -0
  76. package/src/process-map/process-transition-edge.tsx +151 -0
  77. package/src/process-map/use-process-layout.test.tsx +265 -0
  78. package/src/process-map/use-process-layout.ts +315 -0
  79. package/src/test/contract.test.ts +99 -0
  80. package/src/test/contract.ts +118 -0
  81. package/src/test/doubles.test.tsx +51 -0
  82. package/src/test/doubles.tsx +82 -0
  83. package/src/test/index.ts +34 -0
  84. package/src/test/primitives.tsx +35 -0
  85. package/src/use-process-explorer/index.ts +8 -0
  86. package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
  87. package/src/use-process-explorer/use-process-explorer.ts +540 -0
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Unit coverage for the parts of `ProcessMap` that render with no engine behind them —
3
+ * the loading and empty panels, the accessible `tableView` twin, and the filter-intent
4
+ * menu. The keyboard path and the axe pass are still NOT asserted here: React Flow needs
5
+ * real interaction/measurement for those, and a mocked stand-in would prove nothing about
6
+ * the surface that ships — that coverage stays in `process-map.stories.tsx`, which runs in
7
+ * a real browser. The one exception is the canvas NODE-ORDERING invariant at the bottom of
8
+ * this file (#365): it renders the real canvas (see the `DOMMatrixReadOnly` polyfill below)
9
+ * because that invariant is reproducible under jsdom with no interaction/measurement at all
10
+ * — see that describe block's own comment for why.
11
+ */
12
+ import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
13
+ import userEvent from "@testing-library/user-event";
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+ import { LocaleProvider } from "@elabs-ai/components-ui";
16
+ import { discoverGraph } from "../core/discover-graph";
17
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
18
+ import { buildProcessMapModel, GHOST_OPACITY } from "./map-model";
19
+ import { ProcessMap } from "./process-map";
20
+
21
+ // jsdom ships no `DOMMatrixReadOnly`/`DOMMatrix` at all, which `@xyflow/react`'s internal
22
+ // transform math calls unconditionally — with neither stubbed, mounting `ProcessMap`'s
23
+ // canvas throws `window.DOMMatrixReadOnly is not a constructor` before a single node
24
+ // renders (confirmed against this exact fixture while building the #365 lock below). This
25
+ // is a MISSING BROWSER API, not React Flow's rendering logic — filling it in lets the real
26
+ // engine run for real under jsdom, which is a different thing from `canvas-shell.test.tsx`
27
+ // mocking `@xyflow/react` itself out. Scoped to this file (not the package's shared
28
+ // `vitest.setup.ts`) since only the one describe block below needs it.
29
+ if (typeof globalThis.DOMMatrixReadOnly === "undefined") {
30
+ class DOMMatrixReadOnlyPolyfill {
31
+ // The only field React Flow's viewport/position math reads off this type for a canvas
32
+ // with no zoom/pan applied (this suite never zooms or pans).
33
+ m22 = 1;
34
+ constructor(_init?: unknown) {}
35
+ }
36
+ globalThis.DOMMatrixReadOnly = DOMMatrixReadOnlyPolyfill as unknown as typeof DOMMatrixReadOnly;
37
+ }
38
+ if (typeof globalThis.DOMMatrix === "undefined") {
39
+ globalThis.DOMMatrix = globalThis.DOMMatrixReadOnly as unknown as typeof DOMMatrix;
40
+ }
41
+
42
+ afterEach(cleanup);
43
+
44
+ const log = generateSyntheticLog({ cases: 40, seed: 11 });
45
+ const graph = discoverGraph(log);
46
+ const metric = { node: "absolute_case", edge: "absolute" } as const;
47
+ const model = buildProcessMapModel({ graph, metric });
48
+
49
+ describe("ProcessMap — not-ready states", () => {
50
+ it("shows a loading panel rather than an empty canvas", () => {
51
+ render(<ProcessMap graph={graph} metric={metric} loading />);
52
+ expect(screen.getByRole("status")).toBeInTheDocument();
53
+ expect(document.querySelector('[data-slot="process-map"]')).toHaveAttribute(
54
+ "data-state",
55
+ "loading",
56
+ );
57
+ });
58
+
59
+ it("shows a real empty state for a graph with no activities", () => {
60
+ render(
61
+ <ProcessMap
62
+ graph={{
63
+ activities: [],
64
+ transitions: [],
65
+ startActivities: {},
66
+ endActivities: {},
67
+ totals: { cases: 0, events: 0, variants: 0 },
68
+ }}
69
+ metric={metric}
70
+ />,
71
+ );
72
+ expect(screen.getByText("No activities to map")).toBeInTheDocument();
73
+ });
74
+
75
+ it("shows the empty state when neither a graph nor a log is given", () => {
76
+ render(<ProcessMap metric={metric} />);
77
+ expect(screen.getByText("No activities to map")).toBeInTheDocument();
78
+ });
79
+ });
80
+
81
+ describe("ProcessMap — the accessible table twin", () => {
82
+ it("prints one row per activity and one per transition", () => {
83
+ render(<ProcessMap graph={graph} metric={metric} tableView />);
84
+ const activities = screen.getByRole("table", { name: /Activities/ });
85
+ const transitions = screen.getByRole("table", { name: /Transitions/ });
86
+ expect(within(activities).getAllByRole("row")).toHaveLength(graph.activities.length + 1);
87
+ expect(within(transitions).getAllByRole("row")).toHaveLength(graph.transitions.length + 1);
88
+ });
89
+
90
+ it("prints the SAME numbers the canvas model carries — the twin cannot drift", () => {
91
+ render(<ProcessMap graph={graph} metric={metric} tableView />);
92
+ const table = screen.getByRole("table", { name: /Activities/ });
93
+ for (const row of model.activityRows) {
94
+ const cells = within(table).getByRole("row", { name: new RegExp(escapeRe(row.title)) });
95
+ expect(cells).toHaveTextContent(row.primaryLabel);
96
+ }
97
+ });
98
+
99
+ it("names the metric in the column header, so a number is never unlabelled", () => {
100
+ render(<ProcessMap graph={graph} metric={metric} tableView />);
101
+ expect(screen.getByRole("columnheader", { name: model.nodeMetricLabel })).toBeInTheDocument();
102
+ expect(screen.getByRole("columnheader", { name: model.edgeMetricLabel })).toBeInTheDocument();
103
+ });
104
+
105
+ it("carries the selection tri-state on the row, not only as a colour", () => {
106
+ const target = graph.activities[0]!.id;
107
+ render(
108
+ <ProcessMap
109
+ graph={graph}
110
+ metric={metric}
111
+ tableView
112
+ selection={{ kind: "activity", id: target }}
113
+ />,
114
+ );
115
+ const table = screen.getByRole("table", { name: /Activities/ });
116
+ const row = within(table).getByRole("row", { name: new RegExp(escapeRe(target)) });
117
+ // #373: the property this test is NAMED for is the row's real accessible name — the
118
+ // channel that reaches a screen reader — not merely the styling-hook attribute below,
119
+ // which every prior version of this test asserted while claiming otherwise.
120
+ expect(row).toHaveAccessibleName(/Selected/);
121
+ expect(row).toHaveAttribute("data-selection", "selected");
122
+ });
123
+
124
+ // #373: the negative case — an ordinary row's name carries neither state word, so the
125
+ // State column reads as a marker for the two states that matter rather than as noise
126
+ // repeated on every row.
127
+ it("names an ordinary (associated) row with neither 'Selected' nor 'Excluded'", () => {
128
+ const target = graph.activities[0]!.id;
129
+ render(
130
+ <ProcessMap
131
+ graph={graph}
132
+ metric={metric}
133
+ tableView
134
+ selection={{ kind: "activity", id: target }}
135
+ />,
136
+ );
137
+ const table = screen.getByRole("table", { name: /Activities/ });
138
+ const associatedRows = within(table)
139
+ .getAllByRole("row")
140
+ .filter((row) => row.getAttribute("data-selection") === "associated");
141
+ expect(associatedRows.length).toBeGreaterThan(0);
142
+ for (const row of associatedRows) {
143
+ expect(row).not.toHaveAccessibleName(/Selected|Excluded/);
144
+ }
145
+ });
146
+
147
+ // #413 review (PRRT_kwDOT6D7ts6gJX2C): the State column's HEADER went through the locale
148
+ // seam (#373's "process.map.columnState"), but every CELL still printed the literal
149
+ // English word — and that cell is also the row's real accessible name (the assertion
150
+ // above), so a non-English `LocaleProvider` left both the visible AND the accessible
151
+ // content untranslated. Locks that a translated `stateSelected`/`stateExcluded` message
152
+ // reaches the cell text and the row's accessible name, in both states.
153
+ it("routes the State cell's text through the locale provider (#413 review)", () => {
154
+ const target = graph.activities[0]!.id;
155
+ const excludedId = graph.activities[graph.activities.length - 1]!.id;
156
+ render(
157
+ <LocaleProvider
158
+ messages={{
159
+ "process.map.stateSelected": "Ausgewählt",
160
+ "process.map.stateExcluded": "Ausgeschlossen",
161
+ }}
162
+ >
163
+ <ProcessMap
164
+ graph={graph}
165
+ metric={metric}
166
+ tableView
167
+ selection={{ kind: "activity", id: target }}
168
+ selectionStates={{ activities: { [excludedId]: "excluded" } }}
169
+ />
170
+ </LocaleProvider>,
171
+ );
172
+ const table = screen.getByRole("table", { name: /Activities/ });
173
+
174
+ const selectedRow = within(table).getByRole("row", { name: new RegExp(escapeRe(target)) });
175
+ expect(selectedRow).toHaveTextContent("Ausgewählt");
176
+ expect(selectedRow).not.toHaveTextContent("Selected");
177
+ expect(selectedRow).toHaveAccessibleName(/Ausgewählt/);
178
+
179
+ const excludedRow = within(table).getByRole("row", {
180
+ name: new RegExp(escapeRe(excludedId)),
181
+ });
182
+ expect(excludedRow).toHaveTextContent("Ausgeschlossen");
183
+ expect(excludedRow).not.toHaveTextContent("Excluded");
184
+ expect(excludedRow).toHaveAccessibleName(/Ausgeschlossen/);
185
+ });
186
+ });
187
+
188
+ describe("ProcessMap — filter exclusion re-inks, never removes (RM-052 round 2, #227, Invariant F)", () => {
189
+ it("keeps an excluded activity's row, marked excluded, rather than dropping it", () => {
190
+ const excludedId = graph.activities[graph.activities.length - 1]!.id;
191
+ render(
192
+ <ProcessMap
193
+ graph={graph}
194
+ metric={metric}
195
+ tableView
196
+ selectionStates={{ activities: { [excludedId]: "excluded" } }}
197
+ />,
198
+ );
199
+ const table = screen.getByRole("table", { name: /Activities/ });
200
+ // Invariant F: the row count is unchanged — nothing was removed from the render.
201
+ expect(within(table).getAllByRole("row")).toHaveLength(graph.activities.length + 1);
202
+ const row = within(table).getByRole("row", { name: new RegExp(escapeRe(excludedId)) });
203
+ // #373: same gap as the "selected" case above — the row's real accessible name, not
204
+ // only the styling-hook attribute.
205
+ expect(row).toHaveAccessibleName(/Excluded/);
206
+ expect(row).toHaveAttribute("data-selection", "excluded");
207
+ // An excluded row stays fully operable — never aria-disabled.
208
+ expect(row).not.toHaveAttribute("aria-disabled");
209
+ });
210
+
211
+ it("omitting selectionStates reproduces today's model exactly (regression lock)", () => {
212
+ render(<ProcessMap graph={graph} metric={metric} tableView />);
213
+ const table = screen.getByRole("table", { name: /Activities/ });
214
+ for (const row of model.activityRows) {
215
+ const cell = within(table).getByRole("row", { name: new RegExp(escapeRe(row.title)) });
216
+ expect(cell).toHaveAttribute("data-selection", row.selectionState);
217
+ }
218
+ });
219
+ });
220
+
221
+ describe("ProcessMap — the filter-intent menu", () => {
222
+ it("offers the four intents and emits the one that was chosen", async () => {
223
+ const user = userEvent.setup();
224
+ const onFilterIntent = vi.fn();
225
+ const target = graph.activities[0]!.id;
226
+ render(
227
+ <ProcessMap
228
+ graph={graph}
229
+ metric={metric}
230
+ tableView
231
+ selection={{ kind: "activity", id: target }}
232
+ onFilterIntent={onFilterIntent}
233
+ />,
234
+ );
235
+
236
+ await user.click(screen.getByRole("button", { name: /Filter/ }));
237
+ const menu = await screen.findByRole("menu");
238
+ expect(within(menu).getAllByRole("menuitem")).toHaveLength(4);
239
+
240
+ await user.click(
241
+ within(menu).getByRole("menuitem", { name: `Keep cases containing ${target}` }),
242
+ );
243
+ expect(onFilterIntent).toHaveBeenCalledWith({ kind: "with", activity: target });
244
+ });
245
+
246
+ it("advertises its keyboard shortcut on the trigger", () => {
247
+ render(<ProcessMap graph={graph} metric={metric} tableView />);
248
+ expect(screen.getByRole("button", { name: /Filter/ })).toHaveAttribute(
249
+ "aria-keyshortcuts",
250
+ "f",
251
+ );
252
+ });
253
+
254
+ // #346: a transition's menu offers the same four intents once per endpoint — eight
255
+ // items, four visible-text duplicates. Locks that every item's real ACCESSIBLE name
256
+ // (not merely its textContent) is distinct, and that each is uniquely resolvable by
257
+ // name — the property the issue's own reproduction found broken.
258
+ it("gives a transition's eight menu items eight distinct accessible names", async () => {
259
+ const user = userEvent.setup();
260
+ const edge = model.edges.find((e) => e.source !== e.target)!;
261
+ render(
262
+ <ProcessMap
263
+ graph={graph}
264
+ metric={metric}
265
+ tableView
266
+ selection={{ kind: "transition", id: edge.id }}
267
+ />,
268
+ );
269
+
270
+ await user.click(screen.getByRole("button", { name: /Filter/ }));
271
+ const menu = await screen.findByRole("menu");
272
+ const items = within(menu).getAllByRole("menuitem");
273
+ expect(items).toHaveLength(8);
274
+
275
+ const names = items.map((item) => item.getAttribute("aria-label"));
276
+ expect(new Set(names).size).toBe(8);
277
+
278
+ // Asserting only `textContent` differs would pass on the pre-fix markup (the visible
279
+ // text is unsuffixed on purpose) — the lock is on the computed accessible name.
280
+ expect(
281
+ within(menu).getByRole("menuitem", { name: `Keep cases without ${edge.source}` }),
282
+ ).toBeInTheDocument();
283
+ expect(
284
+ within(menu).getByRole("menuitem", { name: `Keep cases without ${edge.target}` }),
285
+ ).toBeInTheDocument();
286
+ });
287
+ });
288
+
289
+ // #375: applying a filter/selection re-inks the whole map, and nothing announced it. Locks
290
+ // that exactly one `role="status"` region exists, names the real counts, and CHANGES text
291
+ // between two different exclusion states — a live region whose content never changes
292
+ // announces nothing after mount.
293
+ describe("ProcessMap — the selection/filter summary live region (#375)", () => {
294
+ it("renders exactly one status region, on the table branch", () => {
295
+ render(<ProcessMap graph={graph} metric={metric} tableView />);
296
+ expect(screen.getAllByRole("status")).toHaveLength(1);
297
+ });
298
+
299
+ it("renders exactly one status region, on the canvas branch", () => {
300
+ render(<ProcessMap graph={graph} metric={metric} />);
301
+ expect(screen.getAllByRole("status")).toHaveLength(1);
302
+ });
303
+
304
+ it("names the real excluded/total counts, and changes when the exclusion set changes", () => {
305
+ const target = graph.activities[0]!.id;
306
+ const { rerender } = render(<ProcessMap graph={graph} metric={metric} tableView />);
307
+ const before = screen.getByRole("status").textContent;
308
+ expect(before).toContain(`0 of ${graph.activities.length} activities excluded`);
309
+ expect(before).toContain(`0 of ${graph.transitions.length} transitions excluded`);
310
+
311
+ rerender(
312
+ <ProcessMap
313
+ graph={graph}
314
+ metric={metric}
315
+ tableView
316
+ selection={{ kind: "activity", id: target }}
317
+ />,
318
+ );
319
+ const after = screen.getByRole("status").textContent;
320
+ expect(after).not.toBe(before);
321
+ const selectedModel = buildProcessMapModel({
322
+ graph,
323
+ metric,
324
+ selection: { kind: "activity", id: target },
325
+ });
326
+ // Non-vacuity: this selection genuinely excludes something on the shared fixture.
327
+ expect(selectedModel.excludedCounts.activities).toBeGreaterThan(0);
328
+ expect(after).toContain(
329
+ `${selectedModel.excludedCounts.activities} of ${graph.activities.length} activities excluded`,
330
+ );
331
+ });
332
+
333
+ // #413 review (PRRT_kwDOT6D7ts6gJX2I): `selectionSummary` was built from counts alone, so
334
+ // moving between two states that exclude DIFFERENT elements but land on the SAME counts
335
+ // left the string byte-for-byte identical — a polite region announces content CHANGES, so
336
+ // that interaction announced nothing even though the map re-inked. Two single-activity
337
+ // exclusions on the same fixture: both exclude exactly one of N activities and zero
338
+ // transitions (equal counts), but a different activity each time.
339
+ it("still changes when two exclusion sets differ but land on the same counts", () => {
340
+ const first = graph.activities[0]!.id;
341
+ const second = graph.activities[1]!.id;
342
+ expect(first).not.toBe(second);
343
+
344
+ const { rerender } = render(
345
+ <ProcessMap
346
+ graph={graph}
347
+ metric={metric}
348
+ tableView
349
+ selectionStates={{ activities: { [first]: "excluded" } }}
350
+ />,
351
+ );
352
+ const before = screen.getByRole("status").textContent;
353
+ // Non-vacuity: both states really do land on the same excluded/total counts.
354
+ expect(before).toContain(`1 of ${graph.activities.length} activity excluded`);
355
+ expect(before).toContain(`0 of ${graph.transitions.length} transitions excluded`);
356
+
357
+ rerender(
358
+ <ProcessMap
359
+ graph={graph}
360
+ metric={metric}
361
+ tableView
362
+ selectionStates={{ activities: { [second]: "excluded" } }}
363
+ />,
364
+ );
365
+ const after = screen.getByRole("status").textContent;
366
+ expect(after).toContain(`1 of ${graph.activities.length} activity excluded`);
367
+ expect(after).toContain(`0 of ${graph.transitions.length} transitions excluded`);
368
+
369
+ // The lock: same counts, different affected element — the announced text must differ.
370
+ expect(after).not.toBe(before);
371
+ });
372
+ });
373
+
374
+ // ── The cheap jsdom lock (#365, "Test to add" item 3) ────────────────────────────────────
375
+ //
376
+ // `process-map.stories.tsx`'s `Selection` story asserts this SAME invariant against a real
377
+ // browser, but that story runs in the Storybook `addon-vitest` project — the NON-BLOCKING
378
+ // CI job. This test asserts it here too, in the package's own jsdom suite, which IS the
379
+ // blocking job, so a regression in `applyPositions`' sort (`process-map.tsx`) actually fails
380
+ // a check that gates a merge. It is deliberately independent of the story: it renders the
381
+ // real `ProcessMap` canvas itself, off the same fixture used above, rather than importing
382
+ // anything from the story file — a change to the story can never mask a regression here, and
383
+ // a regression here is never masked by the story either.
384
+ //
385
+ // Dagre computes every node's `position` synchronously off the graph's STRUCTURE (see
386
+ // `useProcessLayout`) using FIXED default node dimensions — it never measures the DOM — so
387
+ // the sort order is reproducible under jsdom exactly as it is in a real browser, with no
388
+ // `ResizeObserver`/`getBoundingClientRect` dependency to fake. What jsdom canNOT reproduce is
389
+ // the map's 260ms CSS entry transition (`PROCESS_MAP_NODE_MOTION_CLASS`) actually PLAYING —
390
+ // jsdom has no compositor — so this lock does not need the story's "read the transform
391
+ // instead of a measured rectangle" workaround for ITS OWN sake. It reads `style.transform`
392
+ // anyway, for two independent reasons: (1) it is the exact mechanism `applyPositions` and
393
+ // this lock both care about, so a regression there is caught the same way in both places, and
394
+ // (2) `getBoundingClientRect` is always `{0,0,0,0}` under jsdom regardless of animation, so it
395
+ // could never distinguish laid-out nodes from un-laid-out ones anyway.
396
+ describe("ProcessMap — canvas node ordering is DOM order (blocking-CI lock, #365)", () => {
397
+ it("orders .react-flow__node wrappers by their laid-out position, not model order", async () => {
398
+ render(<ProcessMap graph={graph} metric={metric} />);
399
+
400
+ // The first layout run is not debounced (`useProcessLayout`), but it still lands after
401
+ // mount via an effect — wait for it rather than asserting on the pre-layout frame.
402
+ const wrappers = await waitFor(() => {
403
+ const nodes = [...document.querySelectorAll<HTMLElement>(".react-flow__node")];
404
+ expect(nodes.length).toBe(graph.activities.length);
405
+ return nodes;
406
+ });
407
+
408
+ const positions = wrappers.map((wrapper) => {
409
+ const match = /translate\(\s*(-?[\d.]+)px\s*,\s*(-?[\d.]+)px\s*\)/.exec(
410
+ wrapper.style.transform,
411
+ );
412
+ expect(
413
+ match,
414
+ `expected a translate() transform on node "${wrapper.dataset.id}"`,
415
+ ).toBeTruthy();
416
+ return { x: Number(match![1]), y: Number(match![2]) };
417
+ });
418
+
419
+ for (let i = 1; i < positions.length; i += 1) {
420
+ const previous = positions[i - 1]!;
421
+ const current = positions[i]!;
422
+ // dagre gives an entire rank the SAME y — an exact tie, not a 1px tolerance, because
423
+ // this is the laid-out coordinate rather than a painted, sub-pixel rectangle.
424
+ const sameRow = previous.y === current.y;
425
+ expect({
426
+ index: i,
427
+ y: [previous.y, current.y],
428
+ x: [previous.x, current.x],
429
+ ordered: sameRow ? previous.x <= current.x : previous.y < current.y,
430
+ }).toMatchObject({ ordered: true });
431
+ }
432
+ });
433
+ });
434
+
435
+ // ── The ghosting-rung locks (#351, #352) ─────────────────────────────────────────────────
436
+ //
437
+ // Both render the real canvas (same `DOMMatrixReadOnly` polyfill as the #365 block above)
438
+ // rather than asserting on class-string presence, because the bug both issues describe is
439
+ // a COMPOSITING one: a class string can look right while the rendered opacity chain still
440
+ // dims text (opacity on an ancestor flattens its whole subtree — `getComputedStyle` on a
441
+ // descendant never reflects that). The primary lock below asserts the specific structural
442
+ // property that regressed: the activity node's border-carrying element (`FlowNode`'s own
443
+ // root) and its text stay untouched by ghosting, while the frame's retargeted custom
444
+ // properties and the meter's opacity are the only things that move.
445
+ describe("ProcessMap — ghosted marks keep their border/text, ghosting reaches only non-text channels (#351, #352)", () => {
446
+ it("retints an excluded node's frame and dims its meter, without touching FlowNode's own card or text", async () => {
447
+ const excludedId = graph.activities[graph.activities.length - 1]!.id;
448
+ const includedId = graph.activities[0]!.id;
449
+ render(
450
+ <ProcessMap
451
+ graph={graph}
452
+ metric={metric}
453
+ selectionStates={{ activities: { [excludedId]: "excluded" } }}
454
+ />,
455
+ );
456
+
457
+ const excludedWrapper = await waitFor(() => {
458
+ const node = document.querySelector<HTMLElement>(
459
+ `.react-flow__node[data-id="${excludedId}"]`,
460
+ );
461
+ expect(node).toBeTruthy();
462
+ return node!;
463
+ });
464
+ const includedWrapper = document.querySelector<HTMLElement>(
465
+ `.react-flow__node[data-id="${includedId}"]`,
466
+ )!;
467
+
468
+ // Only the excluded node's frame carries the two ghost-rung custom-property overrides.
469
+ const excludedFrame = excludedWrapper.querySelector<HTMLElement>(
470
+ '[data-slot="process-activity-node-frame"]',
471
+ )!;
472
+ const includedFrame = includedWrapper.querySelector<HTMLElement>(
473
+ '[data-slot="process-activity-node-frame"]',
474
+ )!;
475
+ expect(excludedFrame.style.getPropertyValue("--border")).toBe("var(--border-strong)");
476
+ expect(excludedFrame.style.getPropertyValue("--flow-node")).toContain("color-mix");
477
+ expect(includedFrame.style.getPropertyValue("--border")).toBe("");
478
+ expect(includedFrame.style.getPropertyValue("--flow-node")).toBe("");
479
+
480
+ // `FlowNode`'s own root — the border-carrying card — is untouched by this component on
481
+ // EITHER node: no inline opacity, no `opacity-*` utility class. This is the primary lock:
482
+ // a ghosted node's boundary must survive ghosting, where a single `opacity-35` on the
483
+ // whole subtree used to fade the border along with everything else.
484
+ const excludedCard = excludedFrame.firstElementChild as HTMLElement;
485
+ const includedCard = includedFrame.firstElementChild as HTMLElement;
486
+ for (const card of [excludedCard, includedCard]) {
487
+ expect(card.style.opacity).toBe("");
488
+ expect(card.className).not.toMatch(/\bopacity-/);
489
+ }
490
+
491
+ // The card's own text (eyebrow/title) is likewise never dimmed — same reasoning, and the
492
+ // same element a screen reader/contrast checker actually reads.
493
+ const excludedTitle = excludedCard.querySelector(
494
+ ".truncate.text-sm.font-medium",
495
+ ) as HTMLElement;
496
+ expect(excludedTitle.style.opacity).toBe("");
497
+ expect(excludedTitle.className).not.toMatch(/\bopacity-/);
498
+
499
+ // The meter fill — a redundant, `aria-hidden` mark, never text — IS the shared ghost
500
+ // rung: dimmed on the excluded node, full-strength on the included one.
501
+ const excludedMeter = excludedWrapper.querySelector<HTMLElement>(
502
+ '[data-slot="process-activity-node-meter"]',
503
+ )!;
504
+ const includedMeter = includedWrapper.querySelector<HTMLElement>(
505
+ '[data-slot="process-activity-node-meter"]',
506
+ )!;
507
+ expect(excludedMeter.style.opacity).toBe(String(GHOST_OPACITY));
508
+ expect(includedMeter.style.opacity).toBe("");
509
+ });
510
+
511
+ // The label-pill-reaches-ghosting lock for #351 lives in
512
+ // `process-transition-edge.test.tsx` instead of here: React Flow renders no
513
+ // edges at all under jsdom (`.react-flow__edges`/`.react-flow__edgelabel-renderer`
514
+ // stay empty even with the DOMMatrixReadOnly polyfill this file's node-ordering
515
+ // lock relies on), so asserting the pill through a full `<ProcessMap>` mount
516
+ // cannot pass here — only node POSITIONS are computed synchronously by dagre.
517
+ // `ProcessTransitionEdge`'s own context module documents standalone rendering
518
+ // as the intended test strategy for exactly this component.
519
+ });
520
+
521
+ function escapeRe(value: string): string {
522
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
523
+ }