@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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@elabs-ai/components-process/test` — jsdom-safe test doubles for the process package's
|
|
3
|
+
* view components (issue #228 / RM-053).
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `@elabs-ai/components-charts/src/test/index.ts` (issue #364): a `vi.mock`-swappable
|
|
6
|
+
* subpath so a consumer's test can render process views without the real
|
|
7
|
+
* flow/charts-composed engine underneath them. See `contract.ts`'s header for why the doubles
|
|
8
|
+
* exported here carry an explicit `Double` suffix rather than the real component names.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately excluded from the agent-facing manifest — `readSubpathBarrels` denies any
|
|
11
|
+
* `/test` subpath (see `@elabs-ai/components-charts`'s identical exclusion), because these are
|
|
12
|
+
* test infrastructure, not a build-with surface.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Contract engine
|
|
16
|
+
export {
|
|
17
|
+
assertProcessContract,
|
|
18
|
+
buildProcessDoublePayload,
|
|
19
|
+
ProcessContractError,
|
|
20
|
+
readProcessDoubleProps,
|
|
21
|
+
} from "./contract";
|
|
22
|
+
export type { ProcessContractSpec, ProcessDoublePayload, ProcessSelection } from "./contract";
|
|
23
|
+
|
|
24
|
+
// Doubles
|
|
25
|
+
export { ProcessKpiStripDouble, ProcessMapDouble, VariantExplorerDouble } from "./doubles";
|
|
26
|
+
export type {
|
|
27
|
+
ProcessKpiStripDoubleProps,
|
|
28
|
+
ProcessMapDoubleProps,
|
|
29
|
+
VariantExplorerDoubleProps,
|
|
30
|
+
} from "./doubles";
|
|
31
|
+
|
|
32
|
+
// Fixture helper
|
|
33
|
+
export { withProcessFixture } from "./primitives";
|
|
34
|
+
export type { ProcessFixture } from "./primitives";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composition-primitive stand-ins + fixture helper for `@elabs-ai/components-process/test` —
|
|
3
|
+
* RM-053.
|
|
4
|
+
*
|
|
5
|
+
* `@elabs-ai/components-charts/src/test/primitives.tsx` exists because Vitest's `vi.mock`
|
|
6
|
+
* factory proxy throws on ANY omitted export the moment consumer code reads the binding — so
|
|
7
|
+
* every composition primitive a real container renders declaratively (`<Line>`, `<XAxis>`, …)
|
|
8
|
+
* needs an inert stand-in here too, not only the containers themselves. The process package's
|
|
9
|
+
* public barrel (`src/index.ts`) ships no components yet (RM-051/052/054 land the first ones),
|
|
10
|
+
* so there is currently nothing to namespace-complete — this file is the documented,
|
|
11
|
+
* intentionally-near-empty placeholder that keeps the module shape identical to the charts
|
|
12
|
+
* package's, ready for RM-051 onward to append inert stand-ins here as real composition
|
|
13
|
+
* primitives land.
|
|
14
|
+
*
|
|
15
|
+
* What it does ship today is {@link withProcessFixture} — a small assembly helper, not a
|
|
16
|
+
* render stand-in (Testing Library's own `render()` already renders JSX; nothing here needs to
|
|
17
|
+
* wrap it). It takes a raw `EventLog` — typically {@link generateSyntheticLog} or the
|
|
18
|
+
* BPI-2012-shaped fixture generator (`core/fixtures/generate-bpi-2012-subset.ts`) — and derives
|
|
19
|
+
* the graph and variants a story or test usually wants alongside it, once.
|
|
20
|
+
*/
|
|
21
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
22
|
+
import { extractVariants } from "../core/extract-variants";
|
|
23
|
+
import type { EventLog, ProcessGraph, Variant } from "../core/types";
|
|
24
|
+
|
|
25
|
+
/** The derived triple most process stories/tests want from one raw log. */
|
|
26
|
+
export interface ProcessFixture {
|
|
27
|
+
log: EventLog;
|
|
28
|
+
graph: ProcessGraph;
|
|
29
|
+
variants: Variant[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Derive {@link ProcessFixture} from a raw event log. Pure — no caching, no I/O. */
|
|
33
|
+
export function withProcessFixture(log: EventLog): ProcessFixture {
|
|
34
|
+
return { log, graph: discoverGraph(log), variants: extractVariants(log) };
|
|
35
|
+
}
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { asNormalizedLog } from "../core/event-log";
|
|
4
|
+
import { detectRework } from "../core/detect-rework";
|
|
5
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
6
|
+
import { durationStats } from "../core/duration-stats";
|
|
7
|
+
import { extractVariants } from "../core/extract-variants";
|
|
8
|
+
import { handleProcessRequest, type ProcessWorkerRequest } from "../core/worker/process-worker";
|
|
9
|
+
import type { ProcessWorkerLike } from "../core/worker/create-process-worker";
|
|
10
|
+
import type { EventLog } from "../core/types";
|
|
11
|
+
import fixture from "../core/fixtures/order-to-cash-small.json";
|
|
12
|
+
import { useProcessExplorer } from "./use-process-explorer";
|
|
13
|
+
|
|
14
|
+
// Spies on the REAL `discoverGraph`, so every existing `discoverGraph(orderToCash)` call
|
|
15
|
+
// used elsewhere in this file to compute an "expected" value keeps its real behaviour —
|
|
16
|
+
// this only makes the call COUNTABLE for the G1 lock below (#227).
|
|
17
|
+
vi.mock("../core/discover-graph", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("../core/discover-graph")>();
|
|
19
|
+
return { ...actual, discoverGraph: vi.fn(actual.discoverGraph) };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Same shape, for `extractVariants` — every existing `extractVariants(orderToCash)` call
|
|
23
|
+
// used elsewhere in this file to compute an "expected" value keeps its real behaviour; this
|
|
24
|
+
// only makes the call COUNTABLE for the P1 lock below (PR #413 review).
|
|
25
|
+
vi.mock("../core/extract-variants", async (importOriginal) => {
|
|
26
|
+
const actual = await importOriginal<typeof import("../core/extract-variants")>();
|
|
27
|
+
return { ...actual, extractVariants: vi.fn(actual.extractVariants) };
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(cleanup);
|
|
31
|
+
|
|
32
|
+
const orderToCash = fixture as EventLog;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Minimal `ProcessWorkerLike` — answers on a LATER microtask (never synchronously), so a
|
|
36
|
+
* test that races two requests exercises real interleaving rather than an accident of
|
|
37
|
+
* call order. Mirrors `create-process-worker.test.ts`'s own `FakeWorker`, kept local and
|
|
38
|
+
* smaller: this suite only needs the happy path, not its degrade branches (those are
|
|
39
|
+
* RM-050's to prove).
|
|
40
|
+
*/
|
|
41
|
+
class FakeWorker implements ProcessWorkerLike {
|
|
42
|
+
private readonly listeners: ((event: unknown) => void)[] = [];
|
|
43
|
+
|
|
44
|
+
postMessage(message: unknown): void {
|
|
45
|
+
const response = handleProcessRequest(message as ProcessWorkerRequest);
|
|
46
|
+
void Promise.resolve().then(() => {
|
|
47
|
+
for (const listener of this.listeners) listener({ data: response });
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
terminate(): void {}
|
|
52
|
+
|
|
53
|
+
addEventListener(
|
|
54
|
+
type: "message" | "error" | "messageerror",
|
|
55
|
+
listener: (event: unknown) => void,
|
|
56
|
+
): void {
|
|
57
|
+
if (type === "message") this.listeners.push(listener);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Like `FakeWorker`, but answers only when explicitly told to. The RM-052 round 4 H1 lock
|
|
63
|
+
* below needs to observe the hook's state WHILE a worker request is genuinely in flight —
|
|
64
|
+
* `FakeWorker`'s always-resolve-on-a-microtask behaviour settles too fast to inspect that
|
|
65
|
+
* window at all.
|
|
66
|
+
*/
|
|
67
|
+
class ManualWorker implements ProcessWorkerLike {
|
|
68
|
+
private readonly listeners: ((event: unknown) => void)[] = [];
|
|
69
|
+
private readonly pendingRequests: ProcessWorkerRequest[] = [];
|
|
70
|
+
|
|
71
|
+
postMessage(message: unknown): void {
|
|
72
|
+
this.pendingRequests.push(message as ProcessWorkerRequest);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Answer every request queued so far — a single discovery round-trip posts TWO
|
|
77
|
+
* (`discover` and `variants`), and both must resolve together for `Promise.all` to
|
|
78
|
+
* settle — then clear the queue.
|
|
79
|
+
*/
|
|
80
|
+
resolvePending(): void {
|
|
81
|
+
const requests = this.pendingRequests.splice(0, this.pendingRequests.length);
|
|
82
|
+
for (const request of requests) {
|
|
83
|
+
const response = handleProcessRequest(request);
|
|
84
|
+
for (const listener of this.listeners) listener({ data: response });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
terminate(): void {}
|
|
89
|
+
|
|
90
|
+
addEventListener(
|
|
91
|
+
type: "message" | "error" | "messageerror",
|
|
92
|
+
listener: (event: unknown) => void,
|
|
93
|
+
): void {
|
|
94
|
+
if (type === "message") this.listeners.push(listener);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("useProcessExplorer — defaults", () => {
|
|
99
|
+
it("starts at identity abstraction, the default metric, no selection and no intents", () => {
|
|
100
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
101
|
+
expect(result.current.abstraction).toEqual({
|
|
102
|
+
activities: 1,
|
|
103
|
+
paths: 1,
|
|
104
|
+
invert: false,
|
|
105
|
+
keepConnected: true,
|
|
106
|
+
});
|
|
107
|
+
expect(result.current.metric).toEqual({ node: "absolute", edge: "absolute" });
|
|
108
|
+
expect(result.current.selection).toBeNull();
|
|
109
|
+
expect(result.current.intents).toEqual([]);
|
|
110
|
+
expect(result.current.loading).toBe(false);
|
|
111
|
+
// Identity abstraction hides nothing.
|
|
112
|
+
expect(result.current.hiddenCounts).toEqual({ activities: 0, paths: 0 });
|
|
113
|
+
expect(result.current.graph.activities).toHaveLength(
|
|
114
|
+
discoverGraph(orderToCash).activities.length,
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("useProcessExplorer — exactly one discovery when no intent is active (RM-052 round 3, #227, G1)", () => {
|
|
120
|
+
it("discovers the log exactly once at mount — filteredLog === log must reuse the full discovery, not recompute it", () => {
|
|
121
|
+
vi.mocked(discoverGraph).mockClear();
|
|
122
|
+
renderHook(() => useProcessExplorer(orderToCash));
|
|
123
|
+
// Decision §1.4 step 3 / §4: "with no intents active `filteredLog === log`, so the
|
|
124
|
+
// pipeline reuses `fullRaw` for both roles and runs exactly one discovery — identical
|
|
125
|
+
// to today." Before the G1 fix this read 2 (see RM-052-result-round3.md).
|
|
126
|
+
expect(discoverGraph).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("discovers the filtered log exactly once more after applyIntent, without re-discovering the unchanged full log", () => {
|
|
130
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
131
|
+
vi.mocked(discoverGraph).mockClear();
|
|
132
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Reject Order" }));
|
|
133
|
+
// `log` did not change, so the full derivation's memo must not re-fire; only the new
|
|
134
|
+
// `filteredLog` reference is genuinely discovered.
|
|
135
|
+
expect(discoverGraph).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("useProcessExplorer — abstraction and metric setters merge, not replace", () => {
|
|
140
|
+
it("setAbstraction patches only the given keys", () => {
|
|
141
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
142
|
+
act(() => result.current.setAbstraction({ activities: 0.5 }));
|
|
143
|
+
expect(result.current.abstraction).toEqual({
|
|
144
|
+
activities: 0.5,
|
|
145
|
+
paths: 1,
|
|
146
|
+
invert: false,
|
|
147
|
+
keepConnected: true,
|
|
148
|
+
});
|
|
149
|
+
act(() => result.current.setAbstraction({ invert: true }));
|
|
150
|
+
expect(result.current.abstraction).toEqual({
|
|
151
|
+
activities: 0.5,
|
|
152
|
+
paths: 1,
|
|
153
|
+
invert: true,
|
|
154
|
+
keepConnected: true,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("setMetric patches only node or edge, not both", () => {
|
|
159
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
160
|
+
act(() => result.current.setMetric({ node: "median" }));
|
|
161
|
+
expect(result.current.metric).toEqual({ node: "median", edge: "absolute" });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("honours initial abstraction/metric options", () => {
|
|
165
|
+
const { result } = renderHook(() =>
|
|
166
|
+
useProcessExplorer(orderToCash, {
|
|
167
|
+
abstraction: { activities: 0.25 },
|
|
168
|
+
metric: { edge: "median" },
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
expect(result.current.abstraction.activities).toBe(0.25);
|
|
172
|
+
expect(result.current.metric).toEqual({ node: "absolute", edge: "median" });
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("useProcessExplorer — selection", () => {
|
|
177
|
+
it("onSelect round-trips into `selection`, including clearing it", () => {
|
|
178
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
179
|
+
act(() => result.current.onSelect({ kind: "activity", id: "Check Credit" }));
|
|
180
|
+
expect(result.current.selection).toEqual({ kind: "activity", id: "Check Credit" });
|
|
181
|
+
act(() => result.current.onSelect(null));
|
|
182
|
+
expect(result.current.selection).toBeNull();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("useProcessExplorer — filter intents (the RM-052 acceptance criterion)", () => {
|
|
187
|
+
const ALL_ACTIVITIES = [
|
|
188
|
+
"Amend Order",
|
|
189
|
+
"Approve Order",
|
|
190
|
+
"Check Credit",
|
|
191
|
+
"Create Order",
|
|
192
|
+
"Receive Payment",
|
|
193
|
+
"Reject Order",
|
|
194
|
+
"Send Invoice",
|
|
195
|
+
"Ship Order",
|
|
196
|
+
];
|
|
197
|
+
const SURVIVING = ["Check Credit", "Create Order", "Reject Order"];
|
|
198
|
+
const EXCLUDED = ALL_ACTIVITIES.filter((id) => !SURVIVING.includes(id));
|
|
199
|
+
|
|
200
|
+
it("applying an intent RE-INKS the excluded activities rather than removing them (Invariant F, #227)", () => {
|
|
201
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
202
|
+
const before = result.current.graph;
|
|
203
|
+
expect(before.activities).toHaveLength(8);
|
|
204
|
+
|
|
205
|
+
// "Reject Order" only occurs in case-3 of the fixture — a real, non-trivial filter.
|
|
206
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Reject Order" }));
|
|
207
|
+
expect(result.current.intents).toEqual([{ kind: "with", activity: "Reject Order" }]);
|
|
208
|
+
// Sanity: the intent actually changed the graph — this test would be vacuous otherwise.
|
|
209
|
+
expect(result.current.graph).not.toEqual(before);
|
|
210
|
+
|
|
211
|
+
// Invariant F: every one of the eight activities is STILL in the rendered graph — the
|
|
212
|
+
// filter never shrinks the node set, it only re-inks the five it dropped.
|
|
213
|
+
expect(result.current.graph.activities.map((a) => a.id).sort()).toEqual(
|
|
214
|
+
[...ALL_ACTIVITIES].sort(),
|
|
215
|
+
);
|
|
216
|
+
expect(Object.keys(result.current.selectionStates.activities ?? {}).sort()).toEqual(
|
|
217
|
+
[...EXCLUDED].sort(),
|
|
218
|
+
);
|
|
219
|
+
for (const id of EXCLUDED) {
|
|
220
|
+
expect(result.current.selectionStates.activities?.[id]).toBe("excluded");
|
|
221
|
+
const ghost = result.current.graph.activities.find((a) => a.id === id)!;
|
|
222
|
+
expect(ghost.instances).toBe(0);
|
|
223
|
+
expect(ghost.cases).toBe(0);
|
|
224
|
+
}
|
|
225
|
+
for (const id of SURVIVING) {
|
|
226
|
+
expect(result.current.selectionStates.activities?.[id]).toBeUndefined();
|
|
227
|
+
const survivor = result.current.graph.activities.find((a) => a.id === id)!;
|
|
228
|
+
expect(survivor.instances).toBeGreaterThan(0);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// `excludedCounts` — disjoint from `hiddenCounts` (abstraction is untouched, at identity).
|
|
232
|
+
expect(result.current.excludedCounts).toEqual({
|
|
233
|
+
activities: EXCLUDED.length,
|
|
234
|
+
paths: expect.any(Number),
|
|
235
|
+
});
|
|
236
|
+
expect(result.current.excludedCounts.paths).toBeGreaterThan(0);
|
|
237
|
+
expect(result.current.hiddenCounts).toEqual({ activities: 0, paths: 0 });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("clearIntent(0) restores the unfiltered graph exactly (deep equality) and clears selectionStates", () => {
|
|
241
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
242
|
+
const before = result.current.graph;
|
|
243
|
+
|
|
244
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Reject Order" }));
|
|
245
|
+
expect(result.current.graph).not.toEqual(before);
|
|
246
|
+
|
|
247
|
+
act(() => result.current.clearIntent(0));
|
|
248
|
+
expect(result.current.intents).toEqual([]);
|
|
249
|
+
expect(result.current.graph).toEqual(before);
|
|
250
|
+
expect(result.current.selectionStates.activities).toEqual({});
|
|
251
|
+
expect(result.current.selectionStates.transitions).toEqual({});
|
|
252
|
+
expect(result.current.excludedCounts).toEqual({ activities: 0, paths: 0 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("clearIntent removes by index, leaving the others in place", () => {
|
|
256
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
257
|
+
act(() => {
|
|
258
|
+
result.current.applyIntent({ kind: "with", activity: "Create Order" });
|
|
259
|
+
result.current.applyIntent({ kind: "without", activity: "Amend Order" });
|
|
260
|
+
});
|
|
261
|
+
expect(result.current.intents).toHaveLength(2);
|
|
262
|
+
act(() => result.current.clearIntent(0));
|
|
263
|
+
expect(result.current.intents).toEqual([{ kind: "without", activity: "Amend Order" }]);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("useProcessExplorer — selectionStates.variants (RM-052 round 3, #227, G2)", () => {
|
|
268
|
+
it('marks each id of an active "variant" intent "selected"', () => {
|
|
269
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
270
|
+
act(() => result.current.applyIntent({ kind: "variant", ids: ["variant-a", "variant-b"] }));
|
|
271
|
+
// Decision §1.4 step 5. Before this fix `selectionStates.variants` was `undefined` —
|
|
272
|
+
// the namespace was declared on `ProcessSelectionStates` and never populated.
|
|
273
|
+
expect(result.current.selectionStates.variants).toEqual({
|
|
274
|
+
"variant-a": "selected",
|
|
275
|
+
"variant-b": "selected",
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("leaves variants EMPTY (not absent) when the active intent is unrelated to variants", () => {
|
|
280
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
281
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Reject Order" }));
|
|
282
|
+
expect(result.current.selectionStates.variants).toEqual({});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("useProcessExplorer — hiddenCounts sourced from abstractGraph's own `hidden` field", () => {
|
|
287
|
+
it("reflects the current abstraction, not the intents", () => {
|
|
288
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
289
|
+
act(() => result.current.setAbstraction({ activities: 0.5 }));
|
|
290
|
+
const total = discoverGraph(orderToCash).activities.length;
|
|
291
|
+
const naiveKept = Math.round(total * 0.5) || 1;
|
|
292
|
+
expect(result.current.hiddenCounts).toEqual(result.current.graph.hidden);
|
|
293
|
+
// `abstractGraph`'s connectivity repair (`reconnect`) can ADD activities back beyond
|
|
294
|
+
// the naive rank-truncated count to keep every kept node reachable — so hidden can
|
|
295
|
+
// only be <= the naive count, never more, on a real (non-empty-edge) graph.
|
|
296
|
+
expect(result.current.hiddenCounts.activities).toBeGreaterThanOrEqual(0);
|
|
297
|
+
expect(result.current.hiddenCounts.activities).toBeLessThanOrEqual(total - naiveKept);
|
|
298
|
+
// Sanity: some abstraction is actually happening at 50%, not a total no-op.
|
|
299
|
+
expect(result.current.hiddenCounts.activities).toBeGreaterThan(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("stays UNMOVED by a filter intent — abstraction and filtering are disjoint (#227)", () => {
|
|
303
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
304
|
+
act(() => result.current.setAbstraction({ activities: 0.5 }));
|
|
305
|
+
const hiddenBeforeFilter = result.current.hiddenCounts;
|
|
306
|
+
|
|
307
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Reject Order" }));
|
|
308
|
+
// The filter changed what is EXCLUDED (re-inked), never what abstraction HID.
|
|
309
|
+
expect(result.current.hiddenCounts).toEqual(hiddenBeforeFilter);
|
|
310
|
+
expect(result.current.excludedCounts.activities).toBeGreaterThan(0);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("useProcessExplorer — layer (RM-052 round 2, #227, F6)", () => {
|
|
315
|
+
it("defaults to frequency, honours an initial option, and setLayer is a plain setter", () => {
|
|
316
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
317
|
+
expect(result.current.layer).toBe("frequency");
|
|
318
|
+
act(() => result.current.setLayer("performance"));
|
|
319
|
+
expect(result.current.layer).toBe("performance");
|
|
320
|
+
// A plain setter — it does not touch `metric` on its own; `MetricLayerSwitch` owns the
|
|
321
|
+
// frequency/performance coercion.
|
|
322
|
+
expect(result.current.metric).toEqual({ node: "absolute", edge: "absolute" });
|
|
323
|
+
|
|
324
|
+
const initial = renderHook(() => useProcessExplorer(orderToCash, { layer: "rework" }));
|
|
325
|
+
expect(initial.result.current.layer).toBe("rework");
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("useProcessExplorer — kpis and rework (derived from the FILTERED log, not the abstracted graph)", () => {
|
|
330
|
+
it("matches the /core primitives directly", () => {
|
|
331
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
332
|
+
const expectedGraph = discoverGraph(orderToCash);
|
|
333
|
+
const expectedVariants = extractVariants(orderToCash);
|
|
334
|
+
const expectedRework = detectRework(orderToCash);
|
|
335
|
+
const expectedMedian = durationStats(
|
|
336
|
+
asNormalizedLog(orderToCash).cases.map((kase) => kase.duration),
|
|
337
|
+
).median;
|
|
338
|
+
|
|
339
|
+
expect(result.current.kpis).toEqual({
|
|
340
|
+
cases: expectedGraph.totals.cases,
|
|
341
|
+
events: expectedGraph.totals.events,
|
|
342
|
+
variants: expectedVariants.length,
|
|
343
|
+
medianThroughput: expectedMedian,
|
|
344
|
+
reworkRate: expectedRework.caseReworkRate,
|
|
345
|
+
});
|
|
346
|
+
expect(result.current.rework).toEqual(expectedRework);
|
|
347
|
+
// Fixture-specific: case-4 repeats "Check Credit" non-adjacently (a loop, not a
|
|
348
|
+
// self-loop) — exactly one of the five cases carries rework.
|
|
349
|
+
expect(result.current.kpis.reworkRate).toBe(0.2);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("kpis stay based on the FILTERED log even while abstraction hides most of the graph", () => {
|
|
353
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
354
|
+
act(() => result.current.setAbstraction({ activities: 0.1 }));
|
|
355
|
+
expect(result.current.graph.activities.length).toBeLessThan(
|
|
356
|
+
discoverGraph(orderToCash).activities.length,
|
|
357
|
+
);
|
|
358
|
+
// Totals are untouched — RM-050's "sliders never change statistics" property.
|
|
359
|
+
expect(result.current.kpis.cases).toBe(5);
|
|
360
|
+
expect(result.current.kpis.events).toBe(discoverGraph(orderToCash).totals.events);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe("useProcessExplorer — the async gap (`loading`)", () => {
|
|
365
|
+
it("stays false for a log at or under the worker threshold", () => {
|
|
366
|
+
const { result } = renderHook(() => useProcessExplorer(orderToCash));
|
|
367
|
+
expect(result.current.loading).toBe(false);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("is true while a worker request is in flight, then false once it resolves", async () => {
|
|
371
|
+
const worker = new FakeWorker();
|
|
372
|
+
const { result } = renderHook(() =>
|
|
373
|
+
useProcessExplorer(orderToCash, {
|
|
374
|
+
workerThreshold: 0,
|
|
375
|
+
worker: { createWorker: () => worker },
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
expect(result.current.loading).toBe(true);
|
|
379
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
380
|
+
expect(result.current.graph.totals).toEqual(discoverGraph(orderToCash).totals);
|
|
381
|
+
expect(result.current.variants).toEqual(extractVariants(orderToCash));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("a later request supersedes an earlier one still in flight", async () => {
|
|
385
|
+
const worker = new FakeWorker();
|
|
386
|
+
const other: EventLog = { events: orderToCash.events.slice(0, 6) }; // case-1 and case-2 only
|
|
387
|
+
const { result, rerender } = renderHook(
|
|
388
|
+
({ log }: { log: EventLog }) =>
|
|
389
|
+
useProcessExplorer(log, { workerThreshold: 0, worker: { createWorker: () => worker } }),
|
|
390
|
+
{ initialProps: { log: orderToCash } },
|
|
391
|
+
);
|
|
392
|
+
expect(result.current.loading).toBe(true);
|
|
393
|
+
// Change the log before the first request's microtask has a chance to settle.
|
|
394
|
+
rerender({ log: other });
|
|
395
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
396
|
+
// The settled graph reflects the LATEST log, never the superseded first request.
|
|
397
|
+
expect(result.current.graph.totals).toEqual(discoverGraph(other).totals);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("useProcessExplorer — the first filter on a worker-path log (RM-052 round 4, #227, H1)", () => {
|
|
402
|
+
it("keeps showing the full discovery's graph/counts until the filtered instance settles its own result", async () => {
|
|
403
|
+
const worker = new ManualWorker();
|
|
404
|
+
const { result } = renderHook(() =>
|
|
405
|
+
useProcessExplorer(orderToCash, {
|
|
406
|
+
workerThreshold: 0,
|
|
407
|
+
worker: { createWorker: () => worker },
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
// Mount's own full-log discovery is also worker-path at `workerThreshold: 0` — settle
|
|
411
|
+
// it first so this test isolates the FILTER transition, not the mount gap RM-050 owns.
|
|
412
|
+
worker.resolvePending();
|
|
413
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
414
|
+
const fullTotals = result.current.graph.totals;
|
|
415
|
+
expect(fullTotals).toEqual(discoverGraph(orderToCash).totals);
|
|
416
|
+
|
|
417
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Reject Order" }));
|
|
418
|
+
// The filtered instance is now running (loading flips back to true) but has not
|
|
419
|
+
// settled — its worker request sits unresolved in `worker`'s queue, deliberately.
|
|
420
|
+
expect(result.current.loading).toBe(true);
|
|
421
|
+
|
|
422
|
+
// H1: before the fix, the caller read the filtered instance's still-`EMPTY_GRAPH`
|
|
423
|
+
// result the moment `filteredLog !== log`, painting every activity/path excluded and
|
|
424
|
+
// zeroing every total for the whole worker round-trip.
|
|
425
|
+
expect(result.current.graph.totals).not.toEqual({ cases: 0, events: 0, variants: 0 });
|
|
426
|
+
expect(result.current.graph.totals).toEqual(fullTotals);
|
|
427
|
+
expect(result.current.excludedCounts).not.toEqual({ activities: 8, paths: 11 });
|
|
428
|
+
expect(result.current.excludedCounts).toEqual({ activities: 0, paths: 0 });
|
|
429
|
+
|
|
430
|
+
worker.resolvePending();
|
|
431
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
432
|
+
// Once the filtered instance settles, the real filter effect is visible again.
|
|
433
|
+
expect(result.current.excludedCounts.activities).toBeGreaterThan(0);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("useProcessExplorer — settled means settled for the CURRENT target, not 'ever answered' (#347)", () => {
|
|
438
|
+
it("never surfaces filter A's stale ghosting during filter B's round-trip, even once A's late response lands", async () => {
|
|
439
|
+
const worker = new ManualWorker();
|
|
440
|
+
const { result } = renderHook(() =>
|
|
441
|
+
useProcessExplorer(orderToCash, {
|
|
442
|
+
workerThreshold: 0,
|
|
443
|
+
worker: { createWorker: () => worker },
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
// Settle the mount's own full-log discovery first, isolating the race below.
|
|
447
|
+
worker.resolvePending();
|
|
448
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
449
|
+
const fullTotals = result.current.graph.totals;
|
|
450
|
+
|
|
451
|
+
// Step 1: apply filter A — its worker request is now queued, unresolved.
|
|
452
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Reject Order" }));
|
|
453
|
+
expect(result.current.loading).toBe(true);
|
|
454
|
+
|
|
455
|
+
// Step 2: clear filter A BEFORE its answer arrives — `filteredLog` is back to `log`
|
|
456
|
+
// itself, so this instance transitions into skip.
|
|
457
|
+
act(() => result.current.clearIntent(0));
|
|
458
|
+
expect(result.current.intents).toEqual([]);
|
|
459
|
+
expect(result.current.loading).toBe(false);
|
|
460
|
+
|
|
461
|
+
// Step 3: A's now-stale response lands anyway, while the instance sits skipped. The
|
|
462
|
+
// fix must supersede it on the skip transition (never store it); before the fix, it
|
|
463
|
+
// landed straight into `asyncState` because skip never bumped `requestIdRef`. Flush
|
|
464
|
+
// past every microtask hop between `resolvePending` and the state update it may cause
|
|
465
|
+
// (two chained async functions plus `Promise.all`) with a real macrotask tick, so the
|
|
466
|
+
// next step observes whatever A's response actually did, not a not-yet-landed promise.
|
|
467
|
+
await act(async () => {
|
|
468
|
+
worker.resolvePending();
|
|
469
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
470
|
+
});
|
|
471
|
+
// Skipped means `filteredLog === log` again — the graph must read exactly like the
|
|
472
|
+
// never-filtered full discovery, not like A's ghosted one.
|
|
473
|
+
expect(result.current.graph.totals).toEqual(fullTotals);
|
|
474
|
+
expect(result.current.excludedCounts).toEqual({ activities: 0, paths: 0 });
|
|
475
|
+
|
|
476
|
+
// Step 4: apply a DIFFERENT filter, B — its own worker request is now in flight.
|
|
477
|
+
act(() => result.current.applyIntent({ kind: "with", activity: "Ship Order" }));
|
|
478
|
+
expect(result.current.loading).toBe(true);
|
|
479
|
+
|
|
480
|
+
// The real race: while B is still unsettled, nothing here may show A's shape. Before
|
|
481
|
+
// the fix, A's stale result (wrongly stored in step 3) was still sitting in state and
|
|
482
|
+
// read back out as if it were a settled (if outdated) answer for B's round-trip —
|
|
483
|
+
// `settled` read `true` from A's leftover result, never re-checked against B's target.
|
|
484
|
+
expect(result.current.graph.totals).toEqual(fullTotals);
|
|
485
|
+
expect(result.current.excludedCounts).toEqual({ activities: 0, paths: 0 });
|
|
486
|
+
// The three KPIs must also agree with each other throughout — `variants` is a
|
|
487
|
+
// synchronous derivation of `filteredLog`, so it can never lag `cases`/`events` into
|
|
488
|
+
// reporting A's (or B's not-yet-settled) shape.
|
|
489
|
+
expect(result.current.kpis.variants).toBe(extractVariants(result.current.filteredLog).length);
|
|
490
|
+
|
|
491
|
+
// Step 5: B settles — its real filter effect becomes visible, and it must be B's
|
|
492
|
+
// shape, not A's (both filters exclude a disjoint, non-empty set of activities here).
|
|
493
|
+
worker.resolvePending();
|
|
494
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
495
|
+
expect(result.current.excludedCounts.activities).toBeGreaterThan(0);
|
|
496
|
+
// "Ship Order" is what B itself filters on — it must SURVIVE under B, even though it
|
|
497
|
+
// was one of the five activities A's filter excluded (a real A-vs-B shape check).
|
|
498
|
+
expect(result.current.selectionStates.activities?.["Ship Order"]).toBeUndefined();
|
|
499
|
+
expect(result.current.kpis.variants).toBe(extractVariants(result.current.filteredLog).length);
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe("useProcessExplorer — kpis.variants stays off the render path above workerThreshold (PR #413 review, P1)", () => {
|
|
504
|
+
it("never runs the full extractVariants pipeline synchronously in render for a worker-path log", () => {
|
|
505
|
+
vi.mocked(extractVariants).mockClear();
|
|
506
|
+
const worker = new ManualWorker();
|
|
507
|
+
const { result } = renderHook(() =>
|
|
508
|
+
useProcessExplorer(orderToCash, {
|
|
509
|
+
workerThreshold: 0,
|
|
510
|
+
worker: { createWorker: () => worker },
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
// Above `workerThreshold`, the ONLY place `extractVariants` may run is `handle.variants()`
|
|
514
|
+
// — queued on `worker`, deliberately not yet answered (`resolvePending` was never
|
|
515
|
+
// called). Before the fix, `kpis.variants` called `extractVariants(filteredLog)` directly
|
|
516
|
+
// during render, running the exact same full extraction (normalize, group, sort, hash,
|
|
517
|
+
// per-variant `durationStats`) the worker path exists to avoid.
|
|
518
|
+
expect(extractVariants).not.toHaveBeenCalled();
|
|
519
|
+
// The count itself must still be correct once the render has run.
|
|
520
|
+
expect(result.current.kpis.variants).toBe(extractVariants(orderToCash).length);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe("useProcessExplorer — a log switch never flashes a settled-looking empty result (PR #413 review, P2)", () => {
|
|
525
|
+
it("reports loading on the very render the target moves to a new above-threshold log", async () => {
|
|
526
|
+
const worker = new ManualWorker();
|
|
527
|
+
const renders: { loading: boolean; cases: number; events: number }[] = [];
|
|
528
|
+
const { rerender } = renderHook(
|
|
529
|
+
({ log }: { log: EventLog }) => {
|
|
530
|
+
const value = useProcessExplorer(log, {
|
|
531
|
+
workerThreshold: 0,
|
|
532
|
+
worker: { createWorker: () => worker },
|
|
533
|
+
});
|
|
534
|
+
renders.push({
|
|
535
|
+
loading: value.loading,
|
|
536
|
+
cases: value.graph.totals.cases,
|
|
537
|
+
events: value.graph.totals.events,
|
|
538
|
+
});
|
|
539
|
+
return value;
|
|
540
|
+
},
|
|
541
|
+
{ initialProps: { log: orderToCash } },
|
|
542
|
+
);
|
|
543
|
+
worker.resolvePending();
|
|
544
|
+
await waitFor(() => expect(renders[renders.length - 1]?.loading).toBe(false));
|
|
545
|
+
|
|
546
|
+
const other: EventLog = { events: orderToCash.events.slice(0, 6) }; // case-1 and case-2 only
|
|
547
|
+
renders.length = 0;
|
|
548
|
+
rerender({ log: other });
|
|
549
|
+
|
|
550
|
+
// Every render produced for the NEW target — including the very first one, before its
|
|
551
|
+
// own worker request can resolve or `loading`'s passive effect can even run — must never
|
|
552
|
+
// claim settled-empty: `loading: false` paired with `EMPTY_GRAPH`'s zero totals. Before
|
|
553
|
+
// the fix, the FIRST such render did exactly that, because `asyncState` still held the
|
|
554
|
+
// OLD target's stale settled result and the `loading` STATE variable had not yet been
|
|
555
|
+
// flipped by the effect for the new target.
|
|
556
|
+
const flashed = renders.some(
|
|
557
|
+
(render) => !render.loading && render.cases === 0 && render.events === 0,
|
|
558
|
+
);
|
|
559
|
+
expect(flashed).toBe(false);
|
|
560
|
+
|
|
561
|
+
worker.resolvePending();
|
|
562
|
+
await waitFor(() => expect(renders[renders.length - 1]?.loading).toBe(false));
|
|
563
|
+
});
|
|
564
|
+
});
|