@elabs-ai/components-process 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/core/index.d.ts +1029 -0
- package/dist/core/index.js +1553 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/process-worker.js +462 -0
- package/dist/core/process-worker.js.map +1 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +3146 -0
- package/dist/index.js.map +1 -0
- package/dist/test/index.d.ts +196 -0
- package/dist/test/index.js +527 -0
- package/dist/test/index.js.map +1 -0
- package/package.json +80 -0
- package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
- package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
- package/src/abstraction-controls/abstraction-controls.tsx +288 -0
- package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
- package/src/abstraction-controls/auto-abstraction.ts +128 -0
- package/src/abstraction-controls/index.ts +4 -0
- package/src/core/abstract-graph.test.ts +209 -0
- package/src/core/abstract-graph.ts +407 -0
- package/src/core/adapters/csv.test.ts +131 -0
- package/src/core/adapters/csv.ts +146 -0
- package/src/core/adapters/flat.test.ts +149 -0
- package/src/core/adapters/flat.ts +168 -0
- package/src/core/aggregate-performance.test.ts +208 -0
- package/src/core/aggregate-performance.ts +200 -0
- package/src/core/detect-rework.test.ts +134 -0
- package/src/core/detect-rework.ts +100 -0
- package/src/core/discover-graph.test.ts +378 -0
- package/src/core/discover-graph.ts +202 -0
- package/src/core/duration-stats.test.ts +116 -0
- package/src/core/duration-stats.ts +162 -0
- package/src/core/event-log.test.ts +224 -0
- package/src/core/event-log.ts +244 -0
- package/src/core/extract-variants.test.ts +126 -0
- package/src/core/extract-variants.ts +140 -0
- package/src/core/filter-log.test.ts +193 -0
- package/src/core/filter-log.ts +215 -0
- package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
- package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
- package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
- package/src/core/fixtures/order-to-cash-small.json +200 -0
- package/src/core/fixtures/synthetic-log.test.ts +109 -0
- package/src/core/fixtures/synthetic-log.ts +167 -0
- package/src/core/index.ts +118 -0
- package/src/core/reconcile-graph.test.ts +175 -0
- package/src/core/reconcile-graph.ts +107 -0
- package/src/core/scale.test.ts +80 -0
- package/src/core/scale.ts +100 -0
- package/src/core/types.ts +151 -0
- package/src/core/worker/create-process-worker.test.ts +255 -0
- package/src/core/worker/create-process-worker.ts +211 -0
- package/src/core/worker/process-worker.ts +80 -0
- package/src/index.ts +29 -0
- package/src/metric-layer-switch/index.ts +6 -0
- package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
- package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
- package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
- package/src/process-explorer.stories.tsx +392 -0
- package/src/process-kpi-strip/index.ts +6 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
- package/src/process-map/index.ts +13 -0
- package/src/process-map/map-model.test.ts +326 -0
- package/src/process-map/map-model.ts +873 -0
- package/src/process-map/process-activity-node.tsx +200 -0
- package/src/process-map/process-map-context.ts +71 -0
- package/src/process-map/process-map.stories.tsx +673 -0
- package/src/process-map/process-map.test.tsx +523 -0
- package/src/process-map/process-map.tsx +979 -0
- package/src/process-map/process-transition-edge.test.tsx +160 -0
- package/src/process-map/process-transition-edge.tsx +151 -0
- package/src/process-map/use-process-layout.test.tsx +265 -0
- package/src/process-map/use-process-layout.ts +315 -0
- package/src/test/contract.test.ts +99 -0
- package/src/test/contract.ts +118 -0
- package/src/test/doubles.test.tsx +51 -0
- package/src/test/doubles.tsx +82 -0
- package/src/test/index.ts +34 -0
- package/src/test/primitives.tsx +35 -0
- package/src/use-process-explorer/index.ts +8 -0
- package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
- package/src/use-process-explorer/use-process-explorer.ts +540 -0
|
@@ -0,0 +1,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,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
|
+
});
|