@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,211 @@
1
+ /**
2
+ * Off-thread discovery — RM-050.
3
+ *
4
+ * Discovering a graph from a raw log is O(events) with a couple of hash lookups per event;
5
+ * on a 200 000-row import that is long enough to drop frames, and a dropped frame during a
6
+ * file import is exactly the moment an app reads as broken. So the derivation moves to a
7
+ * worker — but ONLY as an optimization: the caller never branches on the environment.
8
+ *
9
+ * **Degrade, never fail.** When there is no `Worker` (a server render, a Node test, an
10
+ * embedded webview), when constructing one throws, or when a live one errors, the same
11
+ * functions run inline on the calling thread and the promise still resolves with the same
12
+ * answer. `handleProcessRequest` is literally shared with the worker entry, so "the inline
13
+ * path agrees with the worker path" is a property of the code, not of a test.
14
+ *
15
+ * **Normalize once.** Every method takes an `AnyLog`; hand it an already-normalized log
16
+ * (`asNormalizedLog`) and neither side re-parses. A `NormalizedLog` is plain data and
17
+ * clones across `postMessage` unchanged.
18
+ */
19
+ import type { DiscoverGraphOptions } from "../discover-graph";
20
+ import type { AnyLog } from "../event-log";
21
+ import type { ProcessGraph, Variant } from "../types";
22
+ import {
23
+ handleProcessRequest,
24
+ type ProcessWorkerRequest,
25
+ type ProcessWorkerResponse,
26
+ } from "./process-worker";
27
+
28
+ /**
29
+ * The slice of the `Worker` interface this module uses.
30
+ *
31
+ * Narrow on purpose: it is the seam a test (or a bundler with its own worker construction)
32
+ * substitutes, and demanding the full DOM `Worker` surface for that would be pointless
33
+ * ceremony. A real `Worker` satisfies it.
34
+ */
35
+ export interface ProcessWorkerLike {
36
+ postMessage(message: unknown): void;
37
+ terminate(): void;
38
+ addEventListener(
39
+ type: "message" | "error" | "messageerror",
40
+ listener: (event: unknown) => void,
41
+ ): void;
42
+ }
43
+
44
+ /** Options for {@link createProcessWorker}. */
45
+ export interface CreateProcessWorkerOptions {
46
+ /** Skip the worker entirely and run inline. Useful for benchmarks and for tests. */
47
+ forceInline?: boolean;
48
+ /**
49
+ * Build the worker yourself.
50
+ *
51
+ * The default construction is
52
+ * `new Worker(new URL("./process-worker.ts", import.meta.url), { type: "module" })`.
53
+ * A bundler compiling THIS SOURCE (Vite, webpack) rewrites that literal itself; a
54
+ * consumer of the BUILT package gets `dist/core/process-worker.js`, which
55
+ * `tsup.config.ts` emits as its own pass and points the built bundle at — a bundler
56
+ * rewrites the source it compiles, never a `dist` it merely consumes, so that file has
57
+ * to be real. A host that needs its own URL supplies this instead. Throwing from it is
58
+ * safe: the handle falls back to the inline path.
59
+ */
60
+ createWorker?: () => ProcessWorkerLike;
61
+ }
62
+
63
+ /** What {@link createProcessWorker} hands back. */
64
+ export interface ProcessWorkerHandle {
65
+ /** Discover a directly-follows graph. */
66
+ discover(log: AnyLog, options?: DiscoverGraphOptions): Promise<ProcessGraph>;
67
+ /** Group the log's cases into variants. */
68
+ variants(log: AnyLog): Promise<Variant[]>;
69
+ /** Stop the worker. Pending promises reject, and so does every later call. */
70
+ terminate(): void;
71
+ /**
72
+ * `true` when work is currently running on the CALLING thread — either because the
73
+ * environment has no worker, or because one failed and the handle degraded.
74
+ */
75
+ readonly inline: boolean;
76
+ }
77
+
78
+ interface Pending {
79
+ request: ProcessWorkerRequest;
80
+ settle: (response: ProcessWorkerResponse) => void;
81
+ }
82
+
83
+ function workerConstructible(): boolean {
84
+ return typeof Worker !== "undefined" && typeof URL !== "undefined";
85
+ }
86
+
87
+ /**
88
+ * Create a handle that computes off-thread when it can and inline when it cannot.
89
+ *
90
+ * The worker is created LAZILY, on the first request, so constructing a handle costs
91
+ * nothing in an environment that never uses it.
92
+ */
93
+ export function createProcessWorker(options: CreateProcessWorkerOptions = {}): ProcessWorkerHandle {
94
+ const construct = options.createWorker;
95
+ let inline = options.forceInline === true || (construct === undefined && !workerConstructible());
96
+ let terminated = false;
97
+ let worker: ProcessWorkerLike | undefined;
98
+ let nextId = 0;
99
+ const pending = new Map<number, Pending>();
100
+
101
+ /** Give up on the worker and answer everything outstanding on this thread. */
102
+ function degrade(): void {
103
+ inline = true;
104
+ const stale = [...pending.values()];
105
+ pending.clear();
106
+ if (worker !== undefined) {
107
+ const dying = worker;
108
+ worker = undefined;
109
+ try {
110
+ dying.terminate();
111
+ } catch {
112
+ // A worker that cannot be terminated is already gone; nothing to recover.
113
+ }
114
+ }
115
+ for (const entry of stale) entry.settle(handleProcessRequest(entry.request));
116
+ }
117
+
118
+ function onMessage(event: unknown): void {
119
+ const response = (event as { data?: unknown }).data as ProcessWorkerResponse | undefined;
120
+ if (response === undefined || typeof response.id !== "number") return;
121
+ const entry = pending.get(response.id);
122
+ if (entry === undefined) return;
123
+ pending.delete(response.id);
124
+ entry.settle(response);
125
+ }
126
+
127
+ function ensureWorker(): ProcessWorkerLike | undefined {
128
+ if (inline || terminated) return undefined;
129
+ if (worker !== undefined) return worker;
130
+ try {
131
+ worker =
132
+ construct === undefined
133
+ ? (new Worker(new URL("./process-worker.ts", import.meta.url), {
134
+ type: "module",
135
+ }) as unknown as ProcessWorkerLike)
136
+ : construct();
137
+ } catch {
138
+ degrade();
139
+ return undefined;
140
+ }
141
+ worker.addEventListener("message", onMessage);
142
+ worker.addEventListener("error", degrade);
143
+ worker.addEventListener("messageerror", degrade);
144
+ return worker;
145
+ }
146
+
147
+ function send(build: (id: number) => ProcessWorkerRequest): Promise<ProcessWorkerResponse> {
148
+ if (terminated) return Promise.reject(new Error("process worker terminated"));
149
+ nextId += 1;
150
+ const request = build(nextId);
151
+ const active = ensureWorker();
152
+ if (active === undefined) {
153
+ // Inline, but still a microtask later, so the caller's own frame returns first.
154
+ return Promise.resolve().then(() => handleProcessRequest(request));
155
+ }
156
+ return new Promise<ProcessWorkerResponse>((resolve, reject) => {
157
+ pending.set(request.id, { request, settle: resolve });
158
+ try {
159
+ active.postMessage(request);
160
+ } catch {
161
+ pending.delete(request.id);
162
+ degrade();
163
+ try {
164
+ resolve(handleProcessRequest(request));
165
+ } catch (error) {
166
+ reject(error instanceof Error ? error : new Error(String(error)));
167
+ }
168
+ }
169
+ });
170
+ }
171
+
172
+ function unwrap<T>(response: ProcessWorkerResponse, read: (ok: ProcessWorkerResponse) => T): T {
173
+ if (!response.ok) throw new Error(response.error);
174
+ return read(response);
175
+ }
176
+
177
+ return {
178
+ async discover(log, discoverOptions) {
179
+ const response = await send((id) =>
180
+ discoverOptions === undefined
181
+ ? { id, kind: "discover", log }
182
+ : { id, kind: "discover", log, options: discoverOptions },
183
+ );
184
+ return unwrap(response, (ok) => (ok as { graph: ProcessGraph }).graph);
185
+ },
186
+ async variants(log) {
187
+ const response = await send((id) => ({ id, kind: "variants", log }));
188
+ return unwrap(response, (ok) => (ok as { variants: Variant[] }).variants);
189
+ },
190
+ terminate() {
191
+ terminated = true;
192
+ const stale = [...pending.values()];
193
+ pending.clear();
194
+ for (const entry of stale) {
195
+ entry.settle({ id: entry.request.id, ok: false, error: "process worker terminated" });
196
+ }
197
+ if (worker !== undefined) {
198
+ const dying = worker;
199
+ worker = undefined;
200
+ try {
201
+ dying.terminate();
202
+ } catch {
203
+ // Already gone.
204
+ }
205
+ }
206
+ },
207
+ get inline(): boolean {
208
+ return inline;
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The worker entry point — RM-050.
3
+ *
4
+ * Two things live here, deliberately in one module: the PURE request handler, and the
5
+ * `message` bootstrap that runs it inside a real worker. `createProcessWorker` imports the
6
+ * handler for its inline path, so the code that answers on the main thread and the code
7
+ * that answers off it are the SAME function — parity is structural, not something a test
8
+ * has to keep true.
9
+ *
10
+ * The bootstrap is guarded by a real worker-scope check, so importing this module from the
11
+ * main thread registers nothing.
12
+ */
13
+ import { discoverGraph, type DiscoverGraphOptions } from "../discover-graph";
14
+ import type { AnyLog } from "../event-log";
15
+ import { extractVariants } from "../extract-variants";
16
+ import type { ProcessGraph, Variant } from "../types";
17
+
18
+ /** What the main thread asks the worker to compute. Structured-cloneable, by construction. */
19
+ export type ProcessWorkerRequest =
20
+ | { id: number; kind: "discover"; log: AnyLog; options?: DiscoverGraphOptions }
21
+ | { id: number; kind: "variants"; log: AnyLog };
22
+
23
+ /** What comes back. `ok: false` carries a message, never an `Error` (which clones poorly). */
24
+ export type ProcessWorkerResponse =
25
+ | { id: number; ok: true; kind: "discover"; graph: ProcessGraph }
26
+ | { id: number; ok: true; kind: "variants"; variants: Variant[] }
27
+ | { id: number; ok: false; error: string };
28
+
29
+ /**
30
+ * Answer one request.
31
+ *
32
+ * Pure and synchronous: the whole point of the worker is that this is the expensive part,
33
+ * and moving it off the main thread is the only thing the worker adds. Throwing is
34
+ * converted to an `ok: false` response so a bad log cannot silently kill the worker and
35
+ * leave every later request hanging.
36
+ */
37
+ export function handleProcessRequest(request: ProcessWorkerRequest): ProcessWorkerResponse {
38
+ try {
39
+ if (request.kind === "discover") {
40
+ return {
41
+ id: request.id,
42
+ ok: true,
43
+ kind: "discover",
44
+ graph: discoverGraph(request.log, request.options ?? {}),
45
+ };
46
+ }
47
+ return { id: request.id, ok: true, kind: "variants", variants: extractVariants(request.log) };
48
+ } catch (error) {
49
+ return { id: request.id, ok: false, error: describe(error) };
50
+ }
51
+ }
52
+
53
+ function describe(error: unknown): string {
54
+ if (error instanceof Error) return error.message;
55
+ return String(error);
56
+ }
57
+
58
+ /**
59
+ * Is this module executing INSIDE a worker?
60
+ *
61
+ * `self instanceof WorkerGlobalScope` is the specified test. A duck-type on `postMessage`
62
+ * would be wrong: `window.postMessage` exists on every page, so the main thread would
63
+ * install a listener that answers its own messages.
64
+ */
65
+ function inWorkerScope(): boolean {
66
+ const scope = globalThis as { WorkerGlobalScope?: new () => unknown; self?: unknown };
67
+ const constructor = scope.WorkerGlobalScope;
68
+ if (typeof constructor !== "function") return false;
69
+ return scope.self instanceof constructor;
70
+ }
71
+
72
+ if (inWorkerScope()) {
73
+ const scope = self as unknown as {
74
+ addEventListener: (type: "message", listener: (event: MessageEvent) => void) => void;
75
+ postMessage: (message: unknown) => void;
76
+ };
77
+ scope.addEventListener("message", (event: MessageEvent) => {
78
+ scope.postMessage(handleProcessRequest(event.data as ProcessWorkerRequest));
79
+ });
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `@elabs-ai/components-process` — the React surface of the process-mining package.
3
+ *
4
+ * This is a LAYER-3 composite (ADR 0034): it composes `@elabs-ai/components-flow`,
5
+ * `-charts`, `-data` and `-ui`, and nothing depends on it. The binding rule is
6
+ * "primitives go down, compositions go up" — a generic edge, mark, table, scale or
7
+ * control belongs in the base package that owns it, never here. See
8
+ * `.claude/rules/process-components.md` and `pnpm process:reuse:check`.
9
+ *
10
+ * Wave-1 items APPEND their exports at the end of the block below, each under a
11
+ * `// <Name> — RM-NNN` comment, so concurrent branches merge as appends.
12
+ *
13
+ * The framework-free half (event-log types, directly-follows derivation, variant and
14
+ * conformance math) lives at `@elabs-ai/components-process/core` — see `./core/index.ts`.
15
+ */
16
+
17
+ // ── Views (React) ────────────────────────────────────────────────────────────
18
+ // (empty — the wave-0 scaffold ships no components on purpose; RM-049 onward append here)
19
+
20
+ export {};
21
+
22
+ // ProcessMap — RM-051
23
+ export * from "./process-map";
24
+
25
+ // AbstractionControls/MetricLayerSwitch/ProcessKpiStrip/useProcessExplorer — RM-052
26
+ export * from "./abstraction-controls";
27
+ export * from "./metric-layer-switch";
28
+ export * from "./process-kpi-strip";
29
+ export * from "./use-process-explorer";
@@ -0,0 +1,6 @@
1
+ export { MetricLayerSwitch } from "./metric-layer-switch";
2
+ export type {
3
+ MetricLayer,
4
+ MetricLayerSwitchMetric,
5
+ MetricLayerSwitchProps,
6
+ } from "./metric-layer-switch";
@@ -0,0 +1,131 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+ import {
5
+ MetricLayerSwitch,
6
+ type MetricLayer,
7
+ type MetricLayerSwitchMetric,
8
+ } from "./metric-layer-switch";
9
+
10
+ function ControlledMetricLayerSwitch({
11
+ initialLayer,
12
+ initialMetric,
13
+ defaultLocked,
14
+ }: {
15
+ initialLayer: MetricLayer;
16
+ initialMetric: MetricLayerSwitchMetric;
17
+ defaultLocked?: boolean;
18
+ }) {
19
+ const [layer, setLayer] = useState<MetricLayer>(initialLayer);
20
+ const [metric, setMetric] = useState<MetricLayerSwitchMetric>(initialMetric);
21
+ return (
22
+ <div className="max-w-md">
23
+ <MetricLayerSwitch
24
+ layer={layer}
25
+ onLayerChange={setLayer}
26
+ metric={metric}
27
+ onMetricChange={(next) => setMetric((prev) => ({ ...prev, ...next }))}
28
+ defaultLocked={defaultLocked}
29
+ />
30
+ </div>
31
+ );
32
+ }
33
+
34
+ const meta = {
35
+ title: "Process/MetricLayerSwitch",
36
+ component: MetricLayerSwitch,
37
+ tags: ["autodocs"],
38
+ parameters: {
39
+ docs: {
40
+ description: {
41
+ component:
42
+ "The 'what number is this graph drawing' control. A Frequency/Performance/Rework " +
43
+ "`ToggleGroup` picks a family of readings before two `Select`s narrow to one " +
44
+ "member of it — one for the activity (node) metric, one for the transition (edge) " +
45
+ "metric. The lock keeps the two in sync, restricting the edge `Select` to the " +
46
+ "4-value domain the two sides share while locked. Selecting 'Rework' disables both " +
47
+ "selects: rework has no frequency/performance READING of its own — it is " +
48
+ "`ProcessMap`'s own `rework` prop, driven by `detectRework`.",
49
+ },
50
+ },
51
+ },
52
+ } satisfies Meta<typeof MetricLayerSwitch>;
53
+ export default meta;
54
+ type Story = StoryObj<typeof meta>;
55
+
56
+ /** Frequency layer, locked (the default) — both selects share one 4-value domain. */
57
+ export const Default: Story = {
58
+ render: () => (
59
+ <ControlledMetricLayerSwitch
60
+ initialLayer="frequency"
61
+ initialMetric={{ node: "absolute", edge: "absolute" }}
62
+ />
63
+ ),
64
+ };
65
+
66
+ /** Frequency layer, unlocked — the edge select widens to its own 6-value domain. */
67
+ export const Unlocked: Story = {
68
+ render: () => (
69
+ <ControlledMetricLayerSwitch
70
+ initialLayer="frequency"
71
+ initialMetric={{ node: "absolute", edge: "relative_antecedent" }}
72
+ defaultLocked={false}
73
+ />
74
+ ),
75
+ };
76
+
77
+ /** Performance layer — the same 7-value duration-aggregate domain on both sides. */
78
+ export const Performance: Story = {
79
+ render: () => (
80
+ <ControlledMetricLayerSwitch
81
+ initialLayer="performance"
82
+ initialMetric={{ node: "median", edge: "median" }}
83
+ />
84
+ ),
85
+ };
86
+
87
+ /** Rework layer — both selects and the lock are disabled; rework is not a `ProcessMetric`. */
88
+ export const Rework: Story = {
89
+ render: () => (
90
+ <ControlledMetricLayerSwitch
91
+ initialLayer="rework"
92
+ initialMetric={{ node: "absolute", edge: "absolute" }}
93
+ />
94
+ ),
95
+ };
96
+
97
+ /**
98
+ * Switching the node metric while locked carries the edge metric along with it — the
99
+ * RM-052 acceptance criterion for the lock, exercised as a real click rather than a
100
+ * direct prop assertion.
101
+ */
102
+ export const LockSync: Story = {
103
+ render: () => (
104
+ <ControlledMetricLayerSwitch
105
+ initialLayer="frequency"
106
+ initialMetric={{ node: "absolute", edge: "absolute" }}
107
+ />
108
+ ),
109
+ play: async ({ canvasElement }) => {
110
+ const canvas = within(canvasElement);
111
+ const body = within(canvasElement.ownerDocument.body);
112
+
113
+ const nodeSelect = canvas.getByRole("combobox", { name: "Activity" });
114
+ await userEvent.click(nodeSelect);
115
+ await userEvent.click(await body.findByRole("option", { name: "Share of cases" }));
116
+
117
+ // Locked: the edge select follows the node select to the same value.
118
+ const edgeSelect = canvas.getByRole("combobox", { name: "Transition" });
119
+ await waitFor(() => expect(edgeSelect).toHaveTextContent("Share of cases"));
120
+
121
+ // Unlocking, then changing the node metric again, must NOT drag the edge along.
122
+ const lockToggle = canvas.getByRole("button", {
123
+ name: "Unlock activity and transition metrics",
124
+ });
125
+ await userEvent.click(lockToggle);
126
+ await userEvent.click(nodeSelect);
127
+ await userEvent.click(await body.findByRole("option", { name: "Occurrences" }));
128
+ await waitFor(() => expect(nodeSelect).toHaveTextContent("Occurrences"));
129
+ expect(edgeSelect).toHaveTextContent("Share of cases");
130
+ },
131
+ };
@@ -0,0 +1,102 @@
1
+ import { cleanup, render, screen, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { MetricLayer, MetricLayerSwitchMetric } from "./metric-layer-switch";
5
+ import { MetricLayerSwitch } from "./metric-layer-switch";
6
+
7
+ afterEach(cleanup);
8
+
9
+ function renderSwitch(
10
+ overrides: Partial<{
11
+ layer: MetricLayer;
12
+ metric: MetricLayerSwitchMetric;
13
+ defaultLocked: boolean;
14
+ locked: boolean;
15
+ }> = {},
16
+ ) {
17
+ const onLayerChange = vi.fn();
18
+ const onMetricChange = vi.fn();
19
+ const onLockedChange = vi.fn();
20
+ const utils = render(
21
+ <MetricLayerSwitch
22
+ layer={overrides.layer ?? "frequency"}
23
+ onLayerChange={onLayerChange}
24
+ metric={overrides.metric ?? { node: "absolute", edge: "absolute" }}
25
+ onMetricChange={onMetricChange}
26
+ defaultLocked={overrides.defaultLocked}
27
+ locked={overrides.locked}
28
+ onLockedChange={onLockedChange}
29
+ />,
30
+ );
31
+ return { onLayerChange, onMetricChange, onLockedChange, ...utils };
32
+ }
33
+
34
+ describe("MetricLayerSwitch — rendering", () => {
35
+ it("renders the layer toggle group and both metric selects", () => {
36
+ renderSwitch();
37
+ expect(screen.getByRole("group", { name: "Metric layer" })).toBeInTheDocument();
38
+ expect(screen.getByRole("combobox", { name: "Activity" })).toBeInTheDocument();
39
+ expect(screen.getByRole("combobox", { name: "Transition" })).toBeInTheDocument();
40
+ });
41
+
42
+ it("defaults to locked — the lock toggle announces 'Unlock…'", () => {
43
+ renderSwitch();
44
+ expect(
45
+ screen.getByRole("button", { name: "Unlock activity and transition metrics" }),
46
+ ).toBeInTheDocument();
47
+ });
48
+ });
49
+
50
+ describe("MetricLayerSwitch — Rework layer disables both selects and the lock", () => {
51
+ it("disables node/edge selects and the lock toggle when layer is 'rework'", () => {
52
+ renderSwitch({ layer: "rework" });
53
+ expect(screen.getByRole("combobox", { name: "Activity" })).toBeDisabled();
54
+ expect(screen.getByRole("combobox", { name: "Transition" })).toBeDisabled();
55
+ expect(
56
+ screen.getByRole("button", { name: "Unlock activity and transition metrics" }),
57
+ ).toBeDisabled();
58
+ });
59
+ });
60
+
61
+ describe("MetricLayerSwitch — switching to Performance normalizes the metric", () => {
62
+ it("requests { node: 'median', edge: 'median' } when leaving a frequency-only value", async () => {
63
+ const user = userEvent.setup();
64
+ const { onMetricChange } = renderSwitch({ metric: { node: "absolute", edge: "absolute" } });
65
+ await user.click(screen.getByRole("radio", { name: "Performance" }));
66
+ expect(onMetricChange).toHaveBeenCalledWith({ node: "median", edge: "median" });
67
+ });
68
+ });
69
+
70
+ describe("MetricLayerSwitch — the lock (RM-052 acceptance criterion)", () => {
71
+ it("changing the node metric while locked carries the edge metric along", async () => {
72
+ const user = userEvent.setup();
73
+ const { onMetricChange } = renderSwitch({ defaultLocked: true });
74
+ const nodeSelect = screen.getByRole("combobox", { name: "Activity" });
75
+ await user.click(nodeSelect);
76
+ const body = within(nodeSelect.ownerDocument.body);
77
+ await user.click(await body.findByRole("option", { name: "Share of cases" }));
78
+ expect(onMetricChange).toHaveBeenCalledWith({ node: "relative_case", edge: "relative_case" });
79
+ });
80
+
81
+ it("changing the node metric while unlocked leaves the edge metric alone", async () => {
82
+ const user = userEvent.setup();
83
+ const { onMetricChange } = renderSwitch({ defaultLocked: false });
84
+ const nodeSelect = screen.getByRole("combobox", { name: "Activity" });
85
+ await user.click(nodeSelect);
86
+ const body = within(nodeSelect.ownerDocument.body);
87
+ await user.click(await body.findByRole("option", { name: "Share of cases" }));
88
+ expect(onMetricChange).toHaveBeenCalledWith({ node: "relative_case" });
89
+ });
90
+
91
+ it("turning the lock ON syncs the edge metric to the node's current value", async () => {
92
+ const user = userEvent.setup();
93
+ const { onMetricChange } = renderSwitch({
94
+ defaultLocked: false,
95
+ metric: { node: "absolute", edge: "relative_antecedent" },
96
+ });
97
+ await user.click(
98
+ screen.getByRole("button", { name: "Lock activity and transition metrics together" }),
99
+ );
100
+ expect(onMetricChange).toHaveBeenCalledWith({ edge: "absolute" });
101
+ });
102
+ });