@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,160 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // @xyflow/react requires real layout/measurement (React Flow renders no edges
5
+ // under jsdom at all — confirmed empirically: the full `<ProcessMap>` canvas
6
+ // leaves `.react-flow__edges`/`.react-flow__edgelabel-renderer` both empty even
7
+ // with the DOMMatrixReadOnly polyfill in place, because edge geometry depends on
8
+ // a real `ResizeObserver` measurement pass jsdom never runs). So this file tests
9
+ // `ProcessTransitionEdge` STANDALONE, the pattern its own context module
10
+ // documents as intentional (`process-map-context.ts`: "so `ProcessActivityNode`
11
+ // and `ProcessTransitionEdge` render correctly outside a `ProcessMap`"), mocking
12
+ // only the `@xyflow/react` primitives its `FlowWeightedEdge`/`FlowEdgePath`
13
+ // delegates need — the same shape `flow-weighted-edge.test.tsx` already uses.
14
+ //
15
+ // `vi.mock`'s factory is hoisted above every import — `vi.hoisted` is the escape
16
+ // hatch so the mock fns themselves survive the hoist without a TDZ ReferenceError.
17
+ const { edgesBox } = vi.hoisted(() => ({ edgesBox: { current: [] as unknown[] } }));
18
+
19
+ vi.mock("@xyflow/react", () => {
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- a vi.mock factory is hoisted above imports; a lazy require avoids the TDZ a top-level import would hit
21
+ const React = require("react");
22
+ return {
23
+ BaseEdge: ({
24
+ id,
25
+ path,
26
+ style,
27
+ className,
28
+ markerStart: _markerStart,
29
+ interactionWidth: _interactionWidth,
30
+ ...rest
31
+ }: {
32
+ id: string;
33
+ path: string;
34
+ style?: React.CSSProperties;
35
+ className?: string;
36
+ markerStart?: string;
37
+ interactionWidth?: number;
38
+ [key: string]: unknown;
39
+ }) =>
40
+ React.createElement("svg", { "data-testid": "base-edge" }, [
41
+ React.createElement("path", { key: "p", d: path, id, style, className, ...rest }),
42
+ ]),
43
+ // Real EdgeLabelRenderer portals into a fixed container; a passthrough is
44
+ // enough here since we only assert the rendered pill's own attributes.
45
+ EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children,
46
+ getBezierPath: ({
47
+ sourceX,
48
+ sourceY,
49
+ targetX,
50
+ targetY,
51
+ }: {
52
+ sourceX: number;
53
+ sourceY: number;
54
+ targetX: number;
55
+ targetY: number;
56
+ }) => [
57
+ `M${sourceX},${sourceY} C${targetX},${targetY}`,
58
+ (sourceX + targetX) / 2,
59
+ (sourceY + targetY) / 2,
60
+ ],
61
+ getSmoothStepPath: ({
62
+ sourceX,
63
+ sourceY,
64
+ targetX,
65
+ targetY,
66
+ }: {
67
+ sourceX: number;
68
+ sourceY: number;
69
+ targetX: number;
70
+ targetY: number;
71
+ }) => [
72
+ `M${sourceX},${sourceY} L${targetX},${targetY}`,
73
+ (sourceX + targetX) / 2,
74
+ (sourceY + targetY) / 2,
75
+ ],
76
+ useEdges: () => edgesBox.current,
77
+ // `FlowWeightedEdge` reads the laid-out cards to route a back edge's return leg
78
+ // clear of them; nothing here is a back edge, so an empty canvas is enough.
79
+ useNodes: () => [],
80
+ // `FlowWeightedEdge` reads nodes through `useStore(selector)` so a forward edge
81
+ // does not re-render on every node change; this map has no measured rects.
82
+ useStore: (selector: (state: { nodes: unknown[] }) => unknown) => selector({ nodes: [] }),
83
+ Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
84
+ };
85
+ });
86
+
87
+ import { ProcessTransitionEdge } from "./process-transition-edge";
88
+ import type { ProcessMapEdge, ProcessTransitionEdgeData } from "./map-model";
89
+ import type { EdgeProps } from "@xyflow/react";
90
+
91
+ afterEach(() => {
92
+ cleanup();
93
+ edgesBox.current = [];
94
+ });
95
+
96
+ /** A complete `ProcessTransitionEdgeData` — a plain forward transition, associated. */
97
+ const BASE_EDGE_DATA: ProcessTransitionEdgeData = {
98
+ source: "a",
99
+ target: "b",
100
+ weight: 1,
101
+ value: 1,
102
+ valueDomain: [0, 1],
103
+ label: "12×",
104
+ isSelfLoop: false,
105
+ isBackEdge: false,
106
+ selectionState: "associated",
107
+ };
108
+
109
+ /** Minimal EdgeProps factory for ProcessTransitionEdge — a plain forward transition. */
110
+ function makeEdgeProps(
111
+ overrides: Partial<EdgeProps<ProcessMapEdge>> = {},
112
+ ): EdgeProps<ProcessMapEdge> {
113
+ return {
114
+ id: "a->b",
115
+ type: "process-transition",
116
+ source: "a",
117
+ target: "b",
118
+ sourceX: 0,
119
+ sourceY: 0,
120
+ targetX: 100,
121
+ targetY: 100,
122
+ sourcePosition: "bottom" as EdgeProps["sourcePosition"],
123
+ targetPosition: "top" as EdgeProps["targetPosition"],
124
+ selected: false,
125
+ animated: false,
126
+ data: BASE_EDGE_DATA,
127
+ ...overrides,
128
+ };
129
+ }
130
+
131
+ // #351 — the label pill is portalled out from under this edge's `<g>` by
132
+ // `EdgeLabelRenderer`, so a whole-subtree opacity (the treatment every OTHER
133
+ // ghosted mark gets) never reaches it. `ProcessTransitionEdge` instead reaches
134
+ // the pill explicitly, by DATA (`labelProps`), with a non-opacity treatment —
135
+ // this locks that the data actually lands on the rendered pill, not just that
136
+ // the edge's own `<g>` opacity changes (which `process-map.test.tsx`'s sibling
137
+ // lock already covers for the node).
138
+ describe("ProcessTransitionEdge — reaches an excluded edge's label pill by data (#351)", () => {
139
+ it("stamps the ghost frame (dashed border + data-selection) on the pill when excluded", () => {
140
+ render(
141
+ <ProcessTransitionEdge
142
+ {...makeEdgeProps({ data: { ...BASE_EDGE_DATA, selectionState: "excluded" } })}
143
+ />,
144
+ );
145
+ const pill = screen.getByRole("button", { name: "12×" });
146
+ expect(pill.className).toMatch(/\bborder-dashed\b/);
147
+ expect(pill).toHaveAttribute("data-selection", "excluded");
148
+ // The pill's own text is untouched — no opacity, inline or via class — so it
149
+ // never drops below the 4.5:1 rung the ghosted node's title also keeps.
150
+ expect(pill.style.opacity).toBe("");
151
+ expect(pill.className).not.toMatch(/\bopacity-/);
152
+ });
153
+
154
+ it("does not stamp the ghost frame when the transition is not excluded", () => {
155
+ render(<ProcessTransitionEdge {...makeEdgeProps()} />);
156
+ const pill = screen.getByRole("button", { name: "12×" });
157
+ expect(pill.className).not.toMatch(/\bborder-dashed\b/);
158
+ expect(pill).not.toHaveAttribute("data-selection");
159
+ });
160
+ });
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProcessTransitionEdge — one directly-follows relation on the process map (RM-051).
5
+ *
6
+ * ONE registered edge type that dispatches to the three shapes `@elabs-ai/components-flow`
7
+ * already owns, and authors none of them:
8
+ *
9
+ * | `data` | renders as | non-colour cue |
10
+ * | --------------------- | ---------------------------------------------- | -------------- |
11
+ * | `isSelfLoop: true` | `FlowSelfLoopEdge` | a closed arc above the node |
12
+ * | `isBackEdge: true` | `FlowWeightedEdge` `variant="back"` | dashed, routed clear of the forward edge |
13
+ * | otherwise | `FlowWeightedEdge` `variant="forward"` | the plain bezier |
14
+ *
15
+ * There is no `BaseEdge` here and no authored `<path>`: an edge is a `flow` primitive, and
16
+ * a layer-3 composite that draws its own would be a second component library
17
+ * (`pnpm process:reuse:check`).
18
+ *
19
+ * ## Why the metric is never carried by colour alone
20
+ *
21
+ * The delegate gets `weight` (stroke width, min-maxed to `[1.5, 8]` px against every other
22
+ * edge in the flow) AND `label` (a printed pill with the value in it). `value`/`valueDomain`
23
+ * tint the stroke as a third, redundant channel. Two edges with different values therefore
24
+ * differ in thickness and in printed text before they differ in hue — which matters
25
+ * because two open flow-layer defects (#321, #297) can currently collapse or invert that
26
+ * hue, and this surface is designed not to depend on it.
27
+ *
28
+ * ## Hover and selection
29
+ *
30
+ * Hovering an activity raises the opacity contrast between its incident edges and the
31
+ * rest; the selection tri-state dims an excluded edge. Both are opacity changes on a
32
+ * wrapper `<g>` — never a hue swap — and both leave the delegate's own `selected` ring
33
+ * alone.
34
+ *
35
+ * ## The label pill does not inherit this `<g>`'s opacity (#351)
36
+ *
37
+ * `EdgeLabelRenderer` portals the pill out from under this `<g>` into a sibling HTML layer,
38
+ * so CSS `opacity` on the `<g>` never reaches it — an excluded edge used to fade to
39
+ * {@link GHOST_OPACITY} while its own label pill stayed at full strength, the one part of
40
+ * the ghosting rung a filter could never actually dim. Rather than matching the pill's
41
+ * OWN opacity to the edge's (which would dim the pill's TEXT the same way #352 found
42
+ * failing 4.5:1 on the activity node — the pill's label sits on `bg-flow-node` at the same
43
+ * high-contrast rung as a node's title), the pill is reached explicitly, by DATA, with a
44
+ * non-opacity treatment: `labelProps` (a generic pass-through on
45
+ * `FlowWeightedEdgeData`/`FlowSelfLoopEdgeData`, mirroring `EdgeLabelPill`'s own new
46
+ * `className`/`...props`) carries a dashed border and a `data-selection="excluded"`
47
+ * attribute straight onto the pill's root button — visible, non-text, reachable by a
48
+ * `[data-selection="excluded"]` selector same as the node and the edge, and never lower
49
+ * than 4.5:1 because the label text itself is untouched.
50
+ */
51
+ import { useMemo } from "react";
52
+ import type { EdgeProps } from "@xyflow/react";
53
+ import {
54
+ FlowSelfLoopEdge,
55
+ FlowWeightedEdge,
56
+ type FlowSelfLoopEdgeData,
57
+ type FlowWeightedEdgeData,
58
+ } from "@elabs-ai/components-flow";
59
+ import { useProcessMapEdgeKeys, useProcessMapHover } from "./process-map-context";
60
+ import { GHOST_OPACITY, type ProcessMapEdge } from "./map-model";
61
+
62
+ /**
63
+ * The `scaleGroup` every process-map edge shares, so `computeEdgeWeightScale` min-maxes
64
+ * the whole map against ONE domain — the `Legend variant="scale"` beside it reads that
65
+ * same domain, which is what stops the key and the picture from disagreeing.
66
+ */
67
+ export const PROCESS_MAP_EDGE_SCALE_GROUP = "process-map";
68
+
69
+ /** Opacity of an edge that is neither hovered-incident nor selection-excluded. */
70
+ const RESTING_OPACITY = 1;
71
+ /** Opacity of an edge that is not incident to the hovered activity. */
72
+ const UNRELATED_OPACITY = 0.25;
73
+
74
+ /** The pill's own ghost treatment — a dashed frame + a reachable `data-selection`, never an
75
+ * opacity (see this file's own docblock, and {@link GHOST_OPACITY}'s, for why). */
76
+ const EXCLUDED_LABEL_PROPS = { className: "border-dashed", "data-selection": "excluded" } as const;
77
+
78
+ /**
79
+ * Branded process-map transition edge. Register it in
80
+ * `edgeTypes={{ "process-transition": ProcessTransitionEdge }}`; build edges with
81
+ * `buildProcessMapModel`.
82
+ */
83
+ export function ProcessTransitionEdge(props: EdgeProps<ProcessMapEdge>) {
84
+ const { data } = props;
85
+ const hover = useProcessMapHover();
86
+ const onEdgeKey = useProcessMapEdgeKeys();
87
+ const isExcluded = data?.selectionState === "excluded";
88
+ const labelProps = isExcluded ? EXCLUDED_LABEL_PROPS : undefined;
89
+
90
+ const weightedData = useMemo<FlowWeightedEdgeData>(
91
+ () => ({
92
+ weight: data?.weight,
93
+ scaleGroup: PROCESS_MAP_EDGE_SCALE_GROUP,
94
+ value: data?.value,
95
+ valueDomain: data?.valueDomain,
96
+ label: data?.label,
97
+ secondaryLabel: data?.secondaryLabel,
98
+ variant: data?.isBackEdge ? "back" : "forward",
99
+ labelProps,
100
+ }),
101
+ [
102
+ data?.weight,
103
+ data?.value,
104
+ data?.valueDomain,
105
+ data?.label,
106
+ data?.secondaryLabel,
107
+ data?.isBackEdge,
108
+ labelProps,
109
+ ],
110
+ );
111
+
112
+ const selfLoopData = useMemo<FlowSelfLoopEdgeData>(
113
+ () => ({
114
+ weight: data?.weight,
115
+ scaleGroup: PROCESS_MAP_EDGE_SCALE_GROUP,
116
+ label: data?.label,
117
+ secondaryLabel: data?.secondaryLabel,
118
+ labelProps,
119
+ }),
120
+ [data?.weight, data?.label, data?.secondaryLabel, labelProps],
121
+ );
122
+
123
+ const opacity = isExcluded
124
+ ? GHOST_OPACITY
125
+ : hover.activityId !== null && !hover.incidentEdgeIds.has(props.id)
126
+ ? UNRELATED_OPACITY
127
+ : RESTING_OPACITY;
128
+
129
+ return (
130
+ <g
131
+ data-slot="process-transition-edge"
132
+ data-shape={data?.isSelfLoop ? "self-loop" : data?.isBackEdge ? "back" : "forward"}
133
+ data-selection={data?.selectionState}
134
+ data-incident={hover.incidentEdgeIds.has(props.id) ? "true" : undefined}
135
+ className="transition-opacity duration-fast ease-standard motion-reduce:transition-none"
136
+ style={{ opacity }}
137
+ // The label pill is portalled out of this `<g>` by `EdgeLabelRenderer`, so it has no
138
+ // `[data-id]` ancestor the map's root handler could read — but a portal's events
139
+ // still bubble up the REACT tree, through here, where the edge id is known. This is
140
+ // what keeps `Enter` (select) and `f` (filter menu) working on a transition now that
141
+ // the edge itself is not a tab stop and the pill is the only stop on the arrow.
142
+ onKeyDown={(event) => onEdgeKey(props.id, event)}
143
+ >
144
+ {data?.isSelfLoop ? (
145
+ <FlowSelfLoopEdge {...props} type="self-loop" data={selfLoopData} />
146
+ ) : (
147
+ <FlowWeightedEdge {...props} type="weighted" data={weightedData} />
148
+ )}
149
+ </g>
150
+ );
151
+ }
@@ -0,0 +1,265 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import type { ActivityStats, DurationStats, ProcessGraph, TransitionStats } from "../core/types";
6
+ import { buildProcessMapModel, processGraphStructureKey } from "./map-model";
7
+ import {
8
+ applyLayoutSnapshot,
9
+ PROCESS_MAP_NODE_MOTION_CLASS,
10
+ useProcessLayout,
11
+ } from "./use-process-layout";
12
+
13
+ afterEach(cleanup);
14
+
15
+ const EMPTY_DURATION: DurationStats = {
16
+ min: 0,
17
+ max: 0,
18
+ mean: 0,
19
+ median: 0,
20
+ p90: 0,
21
+ sum: 0,
22
+ trimmedMean: 0,
23
+ };
24
+
25
+ function activity(id: string, index: number, total: number): ActivityStats {
26
+ return {
27
+ id,
28
+ label: id,
29
+ instances: total - index,
30
+ cases: total - index,
31
+ isStart: index === 0,
32
+ isEnd: index === total - 1,
33
+ duration: { ...EMPTY_DURATION },
34
+ };
35
+ }
36
+
37
+ function transition(source: string, target: string, count: number): TransitionStats {
38
+ return {
39
+ source,
40
+ target,
41
+ count,
42
+ caseCount: count,
43
+ duration: { ...EMPTY_DURATION },
44
+ isSelfLoop: source === target,
45
+ isBackEdge: false,
46
+ };
47
+ }
48
+
49
+ /** A synthetic chain of `size` activities — the shape the 60-node budget is stated over. */
50
+ function chainGraph(size: number): ProcessGraph {
51
+ const ids = Array.from({ length: size }, (_, i) => `Activity ${i + 1}`);
52
+ const transitions: TransitionStats[] = [];
53
+ for (let i = 0; i < ids.length - 1; i += 1) {
54
+ transitions.push(transition(ids[i]!, ids[i + 1]!, size - i));
55
+ // A second, skipping path so the layout has real width to solve, not a straight line.
56
+ if (i + 2 < ids.length)
57
+ transitions.push(transition(ids[i]!, ids[i + 2]!, Math.max(1, size - i - 3)));
58
+ }
59
+ return {
60
+ activities: ids.map((id, index) => activity(id, index, size)),
61
+ transitions,
62
+ startActivities: { [ids[0]!]: size },
63
+ endActivities: { [ids[ids.length - 1]!]: size },
64
+ totals: { cases: size, events: size * 2, variants: 2 },
65
+ };
66
+ }
67
+
68
+ const graph = chainGraph(60);
69
+ const structureKey = processGraphStructureKey(graph);
70
+
71
+ function modelFor(metric: "absolute" | "absolute_case") {
72
+ return buildProcessMapModel({ graph, metric: { node: metric, edge: metric } });
73
+ }
74
+
75
+ describe("useProcessLayout", () => {
76
+ it("lays out once on mount, without waiting for the debounce", async () => {
77
+ const model = modelFor("absolute");
78
+ const { result } = renderHook(() =>
79
+ useProcessLayout({ nodes: model.nodes, edges: model.edges, structureKey, direction: "TB" }),
80
+ );
81
+ await waitFor(() => expect(result.current.layoutRuns).toBe(1));
82
+ expect(result.current.nodes).toHaveLength(graph.activities.length);
83
+ });
84
+
85
+ it("positions every node — the layout is applied, not merely computed", async () => {
86
+ const model = modelFor("absolute");
87
+ const { result } = renderHook(() =>
88
+ useProcessLayout({ nodes: model.nodes, edges: model.edges, structureKey, direction: "TB" }),
89
+ );
90
+ await waitFor(() => expect(result.current.layoutRuns).toBe(1));
91
+ const distinct = new Set(result.current.nodes.map((n) => `${n.position.x}:${n.position.y}`));
92
+ expect(distinct.size).toBe(graph.activities.length);
93
+ });
94
+
95
+ it("does NOT re-run layoutFlow for a metric-only change (the cache-hit criterion)", async () => {
96
+ const first = modelFor("absolute");
97
+ // A short debounce, so the wait below is real rather than nominal. With the shipped
98
+ // 80 ms default, `layoutRuns` is read BEFORE an invalidated re-layout could have fired
99
+ // and would still read 1 with the cache key broken — the assertion looks stronger than
100
+ // it is. Shortening the debounce and then waiting past it is what makes BOTH halves of
101
+ // this test load-bearing rather than just the byte-identical positions.
102
+ const debounceMs = 10;
103
+ const { result, rerender } = renderHook(
104
+ ({ nodes, edges }) =>
105
+ useProcessLayout({ nodes, edges, structureKey, direction: "TB", debounceMs }),
106
+ { initialProps: { nodes: first.nodes, edges: first.edges } },
107
+ );
108
+ await waitFor(() => expect(result.current.layoutRuns).toBe(1));
109
+ const positionsBefore = result.current.nodes.map(
110
+ (n) => `${n.id}:${n.position.x},${n.position.y}`,
111
+ );
112
+
113
+ // A metric switch produces entirely NEW node objects with the SAME structure.
114
+ const second = modelFor("absolute_case");
115
+ expect(second.nodes[0]).not.toBe(first.nodes[0]);
116
+ rerender({ nodes: second.nodes, edges: second.edges });
117
+
118
+ await waitFor(() =>
119
+ expect(result.current.nodes[0]!.data.metricLabel).toBe(second.nodes[0]!.data.metricLabel),
120
+ );
121
+ await new Promise((resolve) => setTimeout(resolve, debounceMs * 6));
122
+ expect(result.current.layoutRuns).toBe(1);
123
+ expect(result.current.nodes.map((n) => `${n.id}:${n.position.x},${n.position.y}`)).toEqual(
124
+ positionsBefore,
125
+ );
126
+ });
127
+
128
+ it("re-runs layout when the STRUCTURE changes, after the debounce", async () => {
129
+ const first = modelFor("absolute");
130
+ const smallerGraph = chainGraph(12);
131
+ const smaller = buildProcessMapModel({
132
+ graph: smallerGraph,
133
+ metric: { node: "absolute", edge: "absolute" },
134
+ });
135
+ const { result, rerender } = renderHook(
136
+ ({ nodes, edges, key }) =>
137
+ useProcessLayout({ nodes, edges, structureKey: key, direction: "TB", debounceMs: 10 }),
138
+ { initialProps: { nodes: first.nodes, edges: first.edges, key: structureKey } },
139
+ );
140
+ await waitFor(() => expect(result.current.layoutRuns).toBe(1));
141
+
142
+ rerender({
143
+ nodes: smaller.nodes,
144
+ edges: smaller.edges,
145
+ key: processGraphStructureKey(smallerGraph),
146
+ });
147
+ await waitFor(() => expect(result.current.layoutRuns).toBe(2));
148
+ expect(result.current.nodes).toHaveLength(12);
149
+ });
150
+
151
+ it("re-runs layout for a direction change, and caches each direction separately", async () => {
152
+ const model = modelFor("absolute");
153
+ const { result, rerender } = renderHook(
154
+ ({ direction }: { direction: "TB" | "LR" }) =>
155
+ useProcessLayout({
156
+ nodes: model.nodes,
157
+ edges: model.edges,
158
+ structureKey,
159
+ direction,
160
+ debounceMs: 0,
161
+ }),
162
+ { initialProps: { direction: "TB" as "TB" | "LR" } },
163
+ );
164
+ await waitFor(() => expect(result.current.layoutRuns).toBe(1));
165
+ rerender({ direction: "LR" });
166
+ await waitFor(() => expect(result.current.layoutRuns).toBe(2));
167
+ rerender({ direction: "TB" });
168
+ // Back to a key already in the cache — no third dagre run.
169
+ await waitFor(() => expect(result.current.nodes.length).toBe(graph.activities.length));
170
+ expect(result.current.layoutRuns).toBe(2);
171
+ });
172
+
173
+ it("lays a 60-node graph out in under 200 ms", async () => {
174
+ const model = modelFor("absolute");
175
+ const { result } = renderHook(() =>
176
+ useProcessLayout({ nodes: model.nodes, edges: model.edges, structureKey, direction: "TB" }),
177
+ );
178
+ await waitFor(() => expect(result.current.layoutRuns).toBe(1));
179
+ expect(graph.activities).toHaveLength(60);
180
+ expect(result.current.lastLayoutMs).toBeLessThan(200);
181
+ });
182
+
183
+ it("reports back-edges and self-loops so the edge component can change SHAPE", async () => {
184
+ const withLoop: ProcessGraph = {
185
+ ...graph,
186
+ transitions: [
187
+ ...graph.transitions,
188
+ transition("Activity 3", "Activity 3", 4),
189
+ transition("Activity 5", "Activity 2", 3),
190
+ ],
191
+ };
192
+ const model = buildProcessMapModel({
193
+ graph: withLoop,
194
+ metric: { node: "absolute", edge: "absolute" },
195
+ });
196
+ const { result } = renderHook(() =>
197
+ useProcessLayout({
198
+ nodes: model.nodes,
199
+ edges: model.edges,
200
+ structureKey: processGraphStructureKey(withLoop),
201
+ direction: "TB",
202
+ }),
203
+ );
204
+ await waitFor(() => expect(result.current.layoutRuns).toBe(1));
205
+ expect(result.current.selfLoopIds.size).toBeGreaterThan(0);
206
+ expect(result.current.backEdgeIds.size).toBeGreaterThan(0);
207
+ });
208
+
209
+ it("renders an empty graph without laying anything out", async () => {
210
+ const { result } = renderHook(() =>
211
+ useProcessLayout({ nodes: [], edges: [], structureKey: "", direction: "TB" }),
212
+ );
213
+ await act(async () => {});
214
+ expect(result.current.layoutRuns).toBe(0);
215
+ expect(result.current.pending).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe("applyLayoutSnapshot", () => {
220
+ it("matches positions on id, so fresh node objects keep their place", () => {
221
+ const model = modelFor("absolute");
222
+ const snapshot = {
223
+ positions: { [model.nodes[0]!.id]: { x: 42, y: 99 } },
224
+ sourcePosition: {},
225
+ targetPosition: {},
226
+ backEdges: [],
227
+ selfLoops: [],
228
+ durationMs: 0,
229
+ };
230
+ const applied = applyLayoutSnapshot(model.nodes, snapshot);
231
+ expect(applied[0]!.position).toEqual({ x: 42, y: 99 });
232
+ // A node the snapshot does not know is returned untouched, not dropped.
233
+ expect(applied).toHaveLength(model.nodes.length);
234
+ expect(applied[1]).toBe(model.nodes[1]);
235
+ });
236
+ });
237
+
238
+ describe("PROCESS_MAP_NODE_MOTION_CLASS is scanner-visible", () => {
239
+ // Regression lock (#401 review): the class was once assembled by interpolating a
240
+ // `MOTION_TARGET` constant. Tailwind extracts candidates from source TEXT and never
241
+ // evaluates JavaScript, so no CSS was emitted and the node-slide animation silently
242
+ // stopped — with every test, story and typecheck still green. Asserting the runtime
243
+ // string is not enough; the SOURCE has to carry each full utility.
244
+ // The file's own TEXT, which is what Tailwind's scanner reads. `__dirname` is how the
245
+ // other source-reading tests in this repo reach a sibling file (see the tokens package).
246
+ const SOURCE = readFileSync(join(__dirname, "use-process-layout.ts"), "utf8");
247
+ const SELECTOR = "[&_div[data-id]:not([data-handlepos])]";
248
+
249
+ it.each([
250
+ `${SELECTOR}:transition-transform`,
251
+ `${SELECTOR}:duration-base`,
252
+ `${SELECTOR}:ease-standard`,
253
+ `motion-reduce:${SELECTOR}:transition-none`,
254
+ ])("%s appears verbatim in the source Tailwind scans", (utility) => {
255
+ expect(PROCESS_MAP_NODE_MOTION_CLASS).toContain(utility);
256
+ expect(SOURCE).toContain(utility);
257
+ });
258
+
259
+ it("builds the class from literals only — no interpolation in its definition", () => {
260
+ const definition = SOURCE.slice(
261
+ SOURCE.indexOf("export const PROCESS_MAP_NODE_MOTION_CLASS"),
262
+ ).split(";")[0];
263
+ expect(definition).not.toMatch(/\$\{/);
264
+ });
265
+ });