@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,315 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useProcessLayout — the process map's cached, debounced dagre layout (RM-051).
5
+ *
6
+ * A process map is re-rendered constantly for reasons that do NOT move a node: switching
7
+ * the metric from frequency to duration, selecting an activity, hovering a path. Laying
8
+ * the graph out again for any of those is both wasteful and visually wrong — the picture
9
+ * would twitch while the reader's eye is on it. So this hook separates two things the
10
+ * naive version conflates:
11
+ *
12
+ * - **STRUCTURE** — which activities and transitions exist, and which way the graph runs.
13
+ * Only a change here can move a node, and only a change here runs dagre.
14
+ * - **EVERYTHING ELSE** — metric, formatting, selection, rework badges. These rebuild the
15
+ * model (cheap, pure) and are re-applied onto the CACHED positions.
16
+ *
17
+ * The cache is keyed on `structureKey` (see `processGraphStructureKey`) plus the layout
18
+ * direction, and it stores POSITIONS BY NODE ID rather than node objects — which is
19
+ * exactly what lets a metric switch reuse a layout: the node objects are all new, their
20
+ * ids are not.
21
+ *
22
+ * `layoutRuns` is returned so the "no second `layoutFlow` call for a metric-only change"
23
+ * acceptance criterion is something a test can ASSERT rather than something a comment
24
+ * claims.
25
+ *
26
+ * ## Debounce
27
+ *
28
+ * A structural change that arrives while another is still settling (dragging an
29
+ * abstraction slider emits one graph per pointer move) is debounced by
30
+ * {@link DEFAULT_LAYOUT_DEBOUNCE_MS}. The FIRST layout for a given hook instance is never
31
+ * debounced — an empty canvas for 80 ms on mount is a worse trade than one extra dagre
32
+ * run — so the debounce only ever delays a RE-layout.
33
+ *
34
+ * ## Motion
35
+ *
36
+ * Position deltas animate via a CSS transform transition on the React Flow node element
37
+ * (see `PROCESS_MAP_NODE_MOTION_CLASS`), so nodes slide rather than re-mount, and the
38
+ * whole thing is neutralized under `prefers-reduced-motion`.
39
+ */
40
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
41
+ import type { XYPosition } from "@xyflow/react";
42
+ import { layoutFlow, type FlowLayoutDirection } from "@elabs-ai/components-flow";
43
+ import type { ProcessMapEdge, ProcessMapNode } from "./map-model";
44
+
45
+ /**
46
+ * Within-rank spacing, wider than `layoutFlow`'s generic 48.
47
+ *
48
+ * Every transition on this canvas carries a label pill, and the pill is portalled OUTSIDE
49
+ * the SVG — it contributes nothing to dagre's routing and nothing to React Flow's fitted
50
+ * bounds, so a gap that reads fine for bare arrows is exactly one pill too narrow, and
51
+ * nothing in the layout maths notices. At 48 the shipped eleven-activity fixture printed
52
+ * three pills on top of activity cards in each direction; at 72, none.
53
+ */
54
+ const NODE_SPACING = 72;
55
+
56
+ /**
57
+ * Between-rank spacing, per direction — and the asymmetry is the point.
58
+ *
59
+ * The rank gap is the one a label pill sits IN: a transition's pill is anchored at its
60
+ * path's midpoint, which falls between two ranks. A pill is a wide, short shape (a number,
61
+ * sometimes a number and a duration), so the gap it needs is not a single number — it is
62
+ * the pill's extent ALONG the rank axis, and that axis turns with the layout:
63
+ *
64
+ * - **Top-to-bottom** ranks stack vertically, so the gap only has to clear the pill's
65
+ * HEIGHT — one line of `text-meta`. `layoutFlow`'s default 72 already does.
66
+ * - **Left-to-right** ranks run horizontally, so the same gap has to clear the pill's
67
+ * WIDTH, several times larger. Measured on the same fixture: at 72 and at 96 two pills
68
+ * still printed over activity cards; at 120 none did.
69
+ *
70
+ * The two reversed directions take the number for their own axis: `BT` is vertical like
71
+ * `TB`, `RL` horizontal like `LR`. Neither is reachable from `ProcessMap`'s own
72
+ * `direction` prop today, and both are spelled out anyway rather than defaulted, so a
73
+ * future map that offers them inherits the reasoning instead of a silent fallback.
74
+ *
75
+ * Using the left-to-right number in both directions would be the tidier-looking constant
76
+ * and the wrong call — a top-to-bottom map is height-constrained, so it would spend 22% of
77
+ * the opening zoom (0.430 → 0.334 on the same fixture) buying clearance for an axis that
78
+ * never needed it.
79
+ */
80
+ const RANK_SPACING: Record<FlowLayoutDirection, number> = { TB: 72, BT: 72, LR: 120, RL: 120 };
81
+
82
+ /** How long a RE-layout waits for the structure to settle. */
83
+ export const DEFAULT_LAYOUT_DEBOUNCE_MS = 80;
84
+
85
+ /**
86
+ * The class that animates node position deltas.
87
+ *
88
+ * React Flow writes `transform: translate(x, y)` onto its own node element, so the
89
+ * transition has to live there — not on anything this package renders. The element is
90
+ * addressed as `div[data-id]` rather than by React Flow's own `.react-flow__node` class
91
+ * on purpose: that class contains underscores, which have to be backslash-escaped inside
92
+ * a Tailwind arbitrary variant and would then be eaten by JavaScript string escaping the
93
+ * moment the class string passes through `cn()`. Nodes are `div`s and edges are `g`s, so
94
+ * the attribute form is both simpler and correctly scoped. Same reasoning as the ancestor
95
+ * selector in `FlowNode`'s focus indicator.
96
+ *
97
+ * ## The `:not([data-handlepos])` is load-bearing
98
+ *
99
+ * React Flow renders a `<Handle>` as a `<div>` carrying BOTH `data-id` and
100
+ * `data-handlepos`, so a bare `div[data-id]` also matched every connector dot — and a dot
101
+ * changes sides when the layout direction flips (bottom/top becomes right/left). The dot
102
+ * then ANIMATED to its new side, React Flow measured `handleBounds` while it was in
103
+ * flight, and never measured again: the stored bounds said `x: 164` where the DOM had
104
+ * settled at `172`, and every edge on the map terminated in mid-air. Measured at 69 px of
105
+ * drift left-to-right, 24 px top-to-bottom.
106
+ *
107
+ * A handle is not a node position; it has no delta to animate, and it must be measurable
108
+ * the instant it is placed. Excluding it is the fix, not a workaround.
109
+ */
110
+ /*
111
+ * WRITTEN OUT IN FULL, deliberately. Tailwind extracts candidates from source TEXT and
112
+ * never evaluates JavaScript, so a class assembled by interpolating a `MOTION_TARGET`
113
+ * constant produces no CSS at all: `new Scanner().scanFiles()` returns only `["const",
114
+ * "export"]` for the interpolated form and all four utilities for this one. The selector
115
+ * therefore repeats instead of being factored into a constant — the duplication is what
116
+ * keeps the animation alive.
117
+ */
118
+ export const PROCESS_MAP_NODE_MOTION_CLASS =
119
+ "[&_div[data-id]:not([data-handlepos])]:transition-transform " +
120
+ "[&_div[data-id]:not([data-handlepos])]:duration-base " +
121
+ "[&_div[data-id]:not([data-handlepos])]:ease-standard " +
122
+ "motion-reduce:[&_div[data-id]:not([data-handlepos])]:transition-none";
123
+
124
+ /** One cached dagre result: where every node sits, plus the structure dagre reported. */
125
+ export interface ProcessLayoutSnapshot {
126
+ positions: Record<string, XYPosition>;
127
+ /** Node id → the handle side edges LEAVE from, as `layoutFlow` set it per direction. */
128
+ sourcePosition: Record<string, ProcessMapNode["sourcePosition"]>;
129
+ /** Node id → the handle side edges ENTER on. */
130
+ targetPosition: Record<string, ProcessMapNode["targetPosition"]>;
131
+ /** Edge ids running against the layout direction (`layoutFlow`'s `backEdges`). */
132
+ backEdges: string[];
133
+ /** Edge ids whose source equals their target (`layoutFlow`'s `selfLoops`). */
134
+ selfLoops: string[];
135
+ /** Wall-clock cost of the `layoutFlow` call that produced this, in milliseconds. */
136
+ durationMs: number;
137
+ }
138
+
139
+ /** Inputs to {@link useProcessLayout}. */
140
+ export interface UseProcessLayoutOptions {
141
+ nodes: ProcessMapNode[];
142
+ edges: ProcessMapEdge[];
143
+ /** Structure-only cache key — see `processGraphStructureKey`. NEVER include a metric. */
144
+ structureKey: string;
145
+ direction: FlowLayoutDirection;
146
+ /** @default {@link DEFAULT_LAYOUT_DEBOUNCE_MS} */
147
+ debounceMs?: number;
148
+ }
149
+
150
+ /** What {@link useProcessLayout} answers. */
151
+ export interface UseProcessLayoutResult {
152
+ /** `nodes`, positioned. Identity changes whenever `nodes` does, so metrics stay live. */
153
+ nodes: ProcessMapNode[];
154
+ /** Edge ids `layoutFlow` reported as running backwards, for the back-edge shape. */
155
+ backEdgeIds: ReadonlySet<string>;
156
+ /** Edge ids `layoutFlow` withheld as self-loops. */
157
+ selfLoopIds: ReadonlySet<string>;
158
+ /** How many times `layoutFlow` has actually run for this hook instance. */
159
+ layoutRuns: number;
160
+ /** Cost of the most recent `layoutFlow` call, in milliseconds. `0` before the first. */
161
+ lastLayoutMs: number;
162
+ /** True between a structural change and the debounced layout that answers it. */
163
+ pending: boolean;
164
+ }
165
+
166
+ const EMPTY_SET: ReadonlySet<string> = new Set<string>();
167
+
168
+ /** Snapshot a `layoutFlow` result into the id-keyed shape the cache stores. */
169
+ function toSnapshot(
170
+ result: ReturnType<typeof layoutFlow<ProcessMapNode, ProcessMapEdge>>,
171
+ durationMs: number,
172
+ ): ProcessLayoutSnapshot {
173
+ const positions: Record<string, XYPosition> = {};
174
+ const sourcePosition: ProcessLayoutSnapshot["sourcePosition"] = {};
175
+ const targetPosition: ProcessLayoutSnapshot["targetPosition"] = {};
176
+ for (const node of result.nodes) {
177
+ positions[node.id] = node.position;
178
+ sourcePosition[node.id] = node.sourcePosition;
179
+ targetPosition[node.id] = node.targetPosition;
180
+ }
181
+ return {
182
+ positions,
183
+ sourcePosition,
184
+ targetPosition,
185
+ backEdges: result.backEdges,
186
+ selfLoops: result.selfLoops,
187
+ durationMs,
188
+ };
189
+ }
190
+
191
+ /** Apply a cached snapshot to a fresh set of nodes, matching on id. */
192
+ export function applyLayoutSnapshot(
193
+ nodes: ProcessMapNode[],
194
+ snapshot: ProcessLayoutSnapshot,
195
+ ): ProcessMapNode[] {
196
+ return nodes.map((node) => {
197
+ const position = snapshot.positions[node.id];
198
+ if (!position) return node;
199
+ return {
200
+ ...node,
201
+ position,
202
+ sourcePosition: snapshot.sourcePosition[node.id],
203
+ targetPosition: snapshot.targetPosition[node.id],
204
+ };
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Lay a process graph out, reusing a cached layout whenever the structure is unchanged.
210
+ *
211
+ * @see {@link UseProcessLayoutResult.layoutRuns} — the observable proof that a metric-only
212
+ * change does not re-run dagre.
213
+ */
214
+ export function useProcessLayout({
215
+ nodes,
216
+ edges,
217
+ structureKey,
218
+ direction,
219
+ debounceMs = DEFAULT_LAYOUT_DEBOUNCE_MS,
220
+ }: UseProcessLayoutOptions): UseProcessLayoutResult {
221
+ const cacheKey = `${structureKey}::${direction}`;
222
+ const cache = useRef(new Map<string, ProcessLayoutSnapshot>());
223
+ const runs = useRef(0);
224
+ // The layout the render below reads. Held in state (not a ref) because producing a new
225
+ // one must re-render; keyed so a cache hit for a DIFFERENT key is never mistaken for a
226
+ // hit for this one.
227
+ const [applied, setApplied] = useState<{ key: string; snapshot: ProcessLayoutSnapshot } | null>(
228
+ null,
229
+ );
230
+
231
+ const compute = useCallback(
232
+ (key: string, currentNodes: ProcessMapNode[], currentEdges: ProcessMapEdge[]) => {
233
+ const started = performance.now();
234
+ const result = layoutFlow<ProcessMapNode, ProcessMapEdge>(currentNodes, currentEdges, {
235
+ direction,
236
+ nodeSpacing: NODE_SPACING,
237
+ rankSpacing: RANK_SPACING[direction],
238
+ });
239
+ const snapshot = toSnapshot(result, performance.now() - started);
240
+ runs.current += 1;
241
+ cache.current.set(key, snapshot);
242
+ setApplied({ key, snapshot });
243
+ },
244
+ [direction],
245
+ );
246
+
247
+ // The nodes/edges the (possibly debounced) layout should run against. Held in a ref so
248
+ // the effect below depends on the STRUCTURE key alone — re-running it whenever a metric
249
+ // produced new node objects is precisely the bug the cache exists to prevent.
250
+ const latest = useRef({ nodes, edges });
251
+ latest.current = { nodes, edges };
252
+
253
+ const cachedForKey = cache.current.get(cacheKey);
254
+ const hasLayout = applied?.key === cacheKey || cachedForKey !== undefined;
255
+
256
+ useEffect(() => {
257
+ const cached = cache.current.get(cacheKey);
258
+ if (cached) {
259
+ setApplied((current) =>
260
+ current?.key === cacheKey ? current : { key: cacheKey, snapshot: cached },
261
+ );
262
+ return;
263
+ }
264
+ if (latest.current.nodes.length === 0) {
265
+ setApplied({
266
+ key: cacheKey,
267
+ snapshot: {
268
+ positions: {},
269
+ sourcePosition: {},
270
+ targetPosition: {},
271
+ backEdges: [],
272
+ selfLoops: [],
273
+ durationMs: 0,
274
+ },
275
+ });
276
+ return;
277
+ }
278
+ // First layout of this hook instance renders immediately; a RE-layout waits for the
279
+ // structure to settle (an abstraction slider emits one graph per pointer move).
280
+ if (runs.current === 0 || debounceMs <= 0) {
281
+ compute(cacheKey, latest.current.nodes, latest.current.edges);
282
+ return;
283
+ }
284
+ const timer = setTimeout(
285
+ () => compute(cacheKey, latest.current.nodes, latest.current.edges),
286
+ debounceMs,
287
+ );
288
+ return () => clearTimeout(timer);
289
+ }, [cacheKey, compute, debounceMs]);
290
+
291
+ const snapshot = applied?.key === cacheKey ? applied.snapshot : cachedForKey;
292
+
293
+ const positionedNodes = useMemo(
294
+ () => (snapshot ? applyLayoutSnapshot(nodes, snapshot) : nodes),
295
+ [nodes, snapshot],
296
+ );
297
+
298
+ const backEdgeIds = useMemo(
299
+ () => (snapshot ? new Set(snapshot.backEdges) : EMPTY_SET),
300
+ [snapshot],
301
+ );
302
+ const selfLoopIds = useMemo(
303
+ () => (snapshot ? new Set(snapshot.selfLoops) : EMPTY_SET),
304
+ [snapshot],
305
+ );
306
+
307
+ return {
308
+ nodes: positionedNodes,
309
+ backEdgeIds,
310
+ selfLoopIds,
311
+ layoutRuns: runs.current,
312
+ lastLayoutMs: snapshot?.durationMs ?? 0,
313
+ pending: !hasLayout && nodes.length > 0,
314
+ };
315
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ assertProcessContract,
5
+ buildProcessDoublePayload,
6
+ ProcessContractError,
7
+ readProcessDoubleProps,
8
+ type ProcessContractSpec,
9
+ } from "./contract";
10
+ import type { ProcessGraph, Variant } from "../core/types";
11
+
12
+ const GRAPH_SPEC: ProcessContractSpec = { dataProp: "graph" };
13
+ const VARIANTS_SPEC: ProcessContractSpec = { dataProp: "variants" };
14
+
15
+ const emptyGraph: ProcessGraph = {
16
+ activities: [],
17
+ transitions: [],
18
+ startActivities: {},
19
+ endActivities: {},
20
+ totals: { cases: 0, events: 0, variants: 0 },
21
+ };
22
+
23
+ const oneVariant: Variant[] = [
24
+ {
25
+ id: "v1",
26
+ sequence: ["A", "B"],
27
+ count: 1,
28
+ share: 1,
29
+ cumulativeShare: 1,
30
+ caseIds: ["c1"],
31
+ duration: { min: 0, max: 0, mean: 0, median: 0, p90: 0, sum: 0, trimmedMean: 0 },
32
+ },
33
+ ];
34
+
35
+ describe("assertProcessContract", () => {
36
+ it("passes for a valid graph payload", () => {
37
+ expect(() =>
38
+ assertProcessContract("ProcessMapDouble", { graph: emptyGraph }, GRAPH_SPEC),
39
+ ).not.toThrow();
40
+ });
41
+
42
+ it("passes for a valid variants payload", () => {
43
+ expect(() =>
44
+ assertProcessContract("VariantExplorerDouble", { variants: oneVariant }, VARIANTS_SPEC),
45
+ ).not.toThrow();
46
+ });
47
+
48
+ it("throws ProcessContractError when the graph prop is missing", () => {
49
+ expect(() => assertProcessContract("ProcessMapDouble", {}, GRAPH_SPEC)).toThrow(
50
+ ProcessContractError,
51
+ );
52
+ });
53
+
54
+ it("throws ProcessContractError when the variants prop is not an array", () => {
55
+ expect(() =>
56
+ assertProcessContract("VariantExplorerDouble", { variants: "nope" }, VARIANTS_SPEC),
57
+ ).toThrow(ProcessContractError);
58
+ });
59
+
60
+ it("throws when a required prop is undefined", () => {
61
+ const spec: ProcessContractSpec = { dataProp: "graph", requiredProps: ["onSelectionChange"] };
62
+ expect(() => assertProcessContract("ProcessMapDouble", { graph: emptyGraph }, spec)).toThrow(
63
+ /missing required prop "onSelectionChange"/,
64
+ );
65
+ });
66
+ });
67
+
68
+ describe("buildProcessDoublePayload / readProcessDoubleProps round-trip", () => {
69
+ it("carries the graph's activity count and the current selection", () => {
70
+ const graph: ProcessGraph = { ...emptyGraph, activities: [...emptyGraph.activities] };
71
+ const payload = buildProcessDoublePayload(
72
+ "ProcessMapDouble",
73
+ { graph, selection: { kind: "node", id: "A" } },
74
+ GRAPH_SPEC,
75
+ );
76
+ expect(payload).toEqual({
77
+ component: "ProcessMapDouble",
78
+ dataLength: 0,
79
+ selection: { kind: "node", id: "A" },
80
+ });
81
+
82
+ const el = document.createElement("div");
83
+ el.setAttribute("data-process-props", JSON.stringify(payload));
84
+ expect(readProcessDoubleProps(el)).toEqual(payload);
85
+ });
86
+
87
+ it("counts variants length for a variants-shaped double", () => {
88
+ const payload = buildProcessDoublePayload(
89
+ "VariantExplorerDouble",
90
+ { variants: oneVariant },
91
+ VARIANTS_SPEC,
92
+ );
93
+ expect(payload.dataLength).toBe(1);
94
+ });
95
+
96
+ it("readProcessDoubleProps returns null when the attribute is absent", () => {
97
+ expect(readProcessDoubleProps(document.createElement("div"))).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The double contract engine — RM-053.
3
+ *
4
+ * Mirrors `@elabs-ai/components-charts/src/test/contract.ts` (issue #364): a real component
5
+ * whose rendering depends on a heavy engine (here, the `flow`-composed process views) gets a
6
+ * `vi.mock`-swappable test double that (a) validates the same required-prop contract the real
7
+ * component would enforce, so a broken test fails loudly instead of silently rendering
8
+ * nothing, (b) never imports the engine, and (c) exposes what it was given as an inspectable
9
+ * `data-process-props` attribute so a test can assert on props without a real graph/variant
10
+ * layout ever running under jsdom.
11
+ *
12
+ * `ProcessMap`, `VariantExplorer` and `ProcessKpiStrip` do not exist yet in this package's
13
+ * public barrel — RM-051/052/054 land them (`src/index.ts` still ships no components on
14
+ * purpose). The doubles built on this contract are therefore named with an explicit `Double`
15
+ * suffix rather than the real component name: there is no real same-named export yet for
16
+ * `vi.mock` to swap in for. A follow-up item that lands the real components should rename the
17
+ * doubles to match (dropping the suffix) so a consumer can
18
+ * `vi.mock("@elabs-ai/components-process", () => import("@elabs-ai/components-process/test"))`
19
+ * the way `@elabs-ai/components-charts` consumers do — that rename is out of this item's scope.
20
+ */
21
+ import type { ProcessGraph, Variant } from "../core/types";
22
+
23
+ /** Selection carried by a process view's coordinated-selection contract (RM-068 completes it). */
24
+ export type ProcessSelection = null | { kind: "node"; id: string } | { kind: "edge"; id: string };
25
+
26
+ /** What {@link assertProcessContract} checks for one double. */
27
+ export interface ProcessContractSpec {
28
+ /** Name of the prop carrying the double's primary data payload. */
29
+ dataProp: "graph" | "variants";
30
+ /** Other props the real component requires; the double must not silently accept `undefined`. */
31
+ requiredProps?: string[];
32
+ }
33
+
34
+ /** Thrown by {@link assertProcessContract} when a double is used with an invalid prop shape. */
35
+ export class ProcessContractError extends Error {
36
+ constructor(componentName: string, message: string) {
37
+ super(`${componentName}: ${message}`);
38
+ this.name = "ProcessContractError";
39
+ }
40
+ }
41
+
42
+ function isProcessGraph(value: unknown): value is ProcessGraph {
43
+ return (
44
+ !!value &&
45
+ typeof value === "object" &&
46
+ Array.isArray((value as ProcessGraph).activities) &&
47
+ Array.isArray((value as ProcessGraph).transitions)
48
+ );
49
+ }
50
+
51
+ function isVariantArray(value: unknown): value is Variant[] {
52
+ return (
53
+ Array.isArray(value) &&
54
+ value.every((entry) => typeof entry === "object" && entry !== null && "sequence" in entry)
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Validate a double's props against its contract spec. Throws {@link ProcessContractError} on
60
+ * a missing/invalid required prop — mirroring what the real component would fail on at
61
+ * runtime, so a test that gets the props wrong fails loudly rather than silently rendering an
62
+ * empty double.
63
+ */
64
+ export function assertProcessContract(
65
+ componentName: string,
66
+ props: Record<string, unknown>,
67
+ spec: ProcessContractSpec,
68
+ ): void {
69
+ const data = props[spec.dataProp];
70
+ if (spec.dataProp === "graph" && !isProcessGraph(data)) {
71
+ throw new ProcessContractError(
72
+ componentName,
73
+ `"graph" prop must be a ProcessGraph, got ${typeof data}`,
74
+ );
75
+ }
76
+ if (spec.dataProp === "variants" && !isVariantArray(data)) {
77
+ throw new ProcessContractError(
78
+ componentName,
79
+ `"variants" prop must be a Variant[], got ${typeof data}`,
80
+ );
81
+ }
82
+ for (const key of spec.requiredProps ?? []) {
83
+ if (props[key] === undefined) {
84
+ throw new ProcessContractError(componentName, `missing required prop "${key}"`);
85
+ }
86
+ }
87
+ }
88
+
89
+ /** What a double records to `data-process-props`, for assertions without a real layout. */
90
+ export interface ProcessDoublePayload {
91
+ component: string;
92
+ dataLength: number;
93
+ selection?: ProcessSelection;
94
+ }
95
+
96
+ /** Build the inspectable payload a double serializes into `data-process-props`. */
97
+ export function buildProcessDoublePayload(
98
+ componentName: string,
99
+ props: Record<string, unknown>,
100
+ spec: ProcessContractSpec,
101
+ ): ProcessDoublePayload {
102
+ const data = props[spec.dataProp];
103
+ const dataLength =
104
+ spec.dataProp === "graph" && isProcessGraph(data)
105
+ ? data.activities.length
106
+ : Array.isArray(data)
107
+ ? data.length
108
+ : 0;
109
+ const payload: ProcessDoublePayload = { component: componentName, dataLength };
110
+ if ("selection" in props) payload.selection = props.selection as ProcessSelection;
111
+ return payload;
112
+ }
113
+
114
+ /** Read a mounted double's payload back out of the DOM (companion to {@link buildProcessDoublePayload}). */
115
+ export function readProcessDoubleProps(el: Element): ProcessDoublePayload | null {
116
+ const raw = el.getAttribute("data-process-props");
117
+ return raw ? (JSON.parse(raw) as ProcessDoublePayload) : null;
118
+ }
@@ -0,0 +1,51 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { discoverGraph } from "../core/discover-graph";
5
+ import { extractVariants } from "../core/extract-variants";
6
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
7
+ import { ProcessKpiStripDouble, ProcessMapDouble, VariantExplorerDouble } from "./doubles";
8
+ import { readProcessDoubleProps } from "./contract";
9
+
10
+ const log = generateSyntheticLog({ cases: 20, seed: 7 });
11
+ const graph = discoverGraph(log);
12
+ const variants = extractVariants(log);
13
+
14
+ describe("process test doubles", () => {
15
+ it("ProcessMapDouble mounts and records the graph's activity count", () => {
16
+ const { container } = render(<ProcessMapDouble graph={graph} />);
17
+ const el = container.querySelector('[data-process-double="ProcessMapDouble"]');
18
+ expect(el).not.toBeNull();
19
+ expect(readProcessDoubleProps(el as Element)?.dataLength).toBe(graph.activities.length);
20
+ });
21
+
22
+ it("VariantExplorerDouble mounts and records the variant count", () => {
23
+ const { container } = render(<VariantExplorerDouble variants={variants} />);
24
+ const el = container.querySelector('[data-process-double="VariantExplorerDouble"]');
25
+ expect(readProcessDoubleProps(el as Element)?.dataLength).toBe(variants.length);
26
+ });
27
+
28
+ it("ProcessKpiStripDouble mounts with the graph payload", () => {
29
+ const { container } = render(<ProcessKpiStripDouble graph={graph} />);
30
+ const el = container.querySelector('[data-process-double="ProcessKpiStripDouble"]');
31
+ expect(el).not.toBeNull();
32
+ });
33
+
34
+ it("forwards a ref to the underlying div", () => {
35
+ let node: HTMLDivElement | null = null;
36
+ render(
37
+ <ProcessMapDouble
38
+ graph={graph}
39
+ ref={(el) => {
40
+ node = el;
41
+ }}
42
+ />,
43
+ );
44
+ expect(node).toBeInstanceOf(HTMLDivElement);
45
+ });
46
+
47
+ it("throws a contract error when required data is missing (a broken test fails loudly)", () => {
48
+ // @ts-expect-error -- deliberately omitting the required `graph` prop
49
+ expect(() => render(<ProcessMapDouble />)).toThrow(/ProcessMapDouble/);
50
+ });
51
+ });
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Test doubles for the process package's not-yet-shipped view components — RM-053.
5
+ *
6
+ * Mirrors `@elabs-ai/components-charts/src/test/doubles.tsx`'s factory shape: each double is a
7
+ * plain `forwardRef<HTMLDivElement, P>` that validates its contract
8
+ * ({@link assertProcessContract}) then renders an inert `<div>` carrying its props as a
9
+ * `data-process-props` JSON attribute — cheap enough to mount by the thousand in a test, with
10
+ * nothing that needs a real graph/variant layout to run under jsdom.
11
+ */
12
+ import { forwardRef } from "react";
13
+ import type { HTMLAttributes } from "react";
14
+
15
+ import type { ProcessGraph, Variant } from "../core/types";
16
+ import {
17
+ assertProcessContract,
18
+ buildProcessDoublePayload,
19
+ type ProcessContractSpec,
20
+ type ProcessSelection,
21
+ } from "./contract";
22
+
23
+ interface DoubleOwnProps extends HTMLAttributes<HTMLDivElement> {
24
+ selection?: ProcessSelection;
25
+ onSelectionChange?: (selection: ProcessSelection) => void;
26
+ }
27
+
28
+ interface ProcessMapDoubleProps extends DoubleOwnProps {
29
+ graph: ProcessGraph;
30
+ }
31
+
32
+ interface VariantExplorerDoubleProps extends DoubleOwnProps {
33
+ variants: Variant[];
34
+ }
35
+
36
+ interface ProcessKpiStripDoubleProps extends HTMLAttributes<HTMLDivElement> {
37
+ graph: ProcessGraph;
38
+ }
39
+
40
+ const PROCESS_MAP_SPEC: ProcessContractSpec = { dataProp: "graph" };
41
+ const VARIANT_EXPLORER_SPEC: ProcessContractSpec = { dataProp: "variants" };
42
+ const PROCESS_KPI_STRIP_SPEC: ProcessContractSpec = { dataProp: "graph" };
43
+
44
+ function createProcessDouble<P extends DoubleOwnProps>(name: string, spec: ProcessContractSpec) {
45
+ const Double = forwardRef<HTMLDivElement, P>(function ProcessTestDouble(props, ref) {
46
+ const record = props as unknown as Record<string, unknown>;
47
+ assertProcessContract(name, record, spec);
48
+ const payload = buildProcessDoublePayload(name, record, spec);
49
+ return (
50
+ <div
51
+ ref={ref}
52
+ data-slot="process-test-double"
53
+ data-process-double={name}
54
+ data-process-props={JSON.stringify(payload)}
55
+ className={props.className}
56
+ style={props.style}
57
+ />
58
+ );
59
+ });
60
+ Double.displayName = name;
61
+ return Double;
62
+ }
63
+
64
+ /** Stand-in for the future `ProcessMap` (RM-051). Named with a `Double` suffix — see contract.ts header. */
65
+ export const ProcessMapDouble = createProcessDouble<ProcessMapDoubleProps>(
66
+ "ProcessMapDouble",
67
+ PROCESS_MAP_SPEC,
68
+ );
69
+
70
+ /** Stand-in for the future `VariantExplorer` (RM-052). */
71
+ export const VariantExplorerDouble = createProcessDouble<VariantExplorerDoubleProps>(
72
+ "VariantExplorerDouble",
73
+ VARIANT_EXPLORER_SPEC,
74
+ );
75
+
76
+ /** Stand-in for the future `ProcessKpiStrip` (RM-054). */
77
+ export const ProcessKpiStripDouble = createProcessDouble<ProcessKpiStripDoubleProps>(
78
+ "ProcessKpiStripDouble",
79
+ PROCESS_KPI_STRIP_SPEC,
80
+ );
81
+
82
+ export type { ProcessMapDoubleProps, VariantExplorerDoubleProps, ProcessKpiStripDoubleProps };