@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,326 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { computeEdgeWeightScale, DEFAULT_EDGE_WIDTH_RANGE } from "@elabs-ai/components-flow";
3
+ import { discoverGraph } from "../core/discover-graph";
4
+ import { detectRework } from "../core/detect-rework";
5
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
6
+ import type { ProcessGraph } from "../core/types";
7
+ import {
8
+ activityAriaLabel,
9
+ activityRole,
10
+ buildProcessMapModel,
11
+ edgeMetricLabel,
12
+ formatDurationMs,
13
+ formatMetricValue,
14
+ nodeMetricLabel,
15
+ processEdgeId,
16
+ processGraphStructureKey,
17
+ resolveActivityFrequencyMode,
18
+ resolveSelectionState,
19
+ resolveTransitionFrequencyMode,
20
+ selectionNeighbourhood,
21
+ transitionAriaLabel,
22
+ transitionShape,
23
+ } from "./map-model";
24
+
25
+ const log = generateSyntheticLog({ cases: 60, seed: 7 });
26
+ const graph: ProcessGraph = discoverGraph(log);
27
+ const rework = detectRework(log);
28
+
29
+ describe("metric resolution", () => {
30
+ it("maps the three edge-only frequency modes onto readings an activity can answer", () => {
31
+ expect(resolveActivityFrequencyMode("relative_antecedent")).toBe("relative_case");
32
+ expect(resolveActivityFrequencyMode("relative_consequent")).toBe("relative_case");
33
+ expect(resolveActivityFrequencyMode("max_repetitions")).toBe("absolute");
34
+ expect(resolveActivityFrequencyMode("absolute_case")).toBe("absolute_case");
35
+ });
36
+
37
+ it("only resolves max_repetitions for a transition", () => {
38
+ expect(resolveTransitionFrequencyMode("max_repetitions")).toBe("absolute");
39
+ expect(resolveTransitionFrequencyMode("relative_antecedent")).toBe("relative_antecedent");
40
+ });
41
+
42
+ it("labels the RESOLVED mode, so no value is printed under another measure's name", () => {
43
+ // The activity cannot answer "share of the source's traffic", so it must not claim to.
44
+ expect(nodeMetricLabel("relative_antecedent")).toBe("Share of cases");
45
+ expect(edgeMetricLabel("relative_antecedent")).toBe("Share of source traffic");
46
+ expect(nodeMetricLabel("median")).toBe("Median duration");
47
+ });
48
+ });
49
+
50
+ describe("formatting", () => {
51
+ it("prints a duration with one decimal below ten and none above", () => {
52
+ expect(formatDurationMs(3.4 * 24 * 3_600_000)).toBe("3.4 d");
53
+ expect(formatDurationMs(18 * 3_600_000)).toBe("18 h");
54
+ expect(formatDurationMs(0)).toBe("0 s");
55
+ });
56
+
57
+ it("answers an em dash rather than NaN for a hole in the data", () => {
58
+ expect(formatDurationMs(Number.NaN)).toBe("—");
59
+ expect(formatDurationMs(-1)).toBe("—");
60
+ expect(formatMetricValue(Number.POSITIVE_INFINITY, "absolute")).toBe("—");
61
+ });
62
+
63
+ it("prints a share as a percentage and a count grouped", () => {
64
+ expect(formatMetricValue(0.1234, "relative_case")).toBe("12.3%");
65
+ expect(formatMetricValue(1234, "absolute")).toBe((1234).toLocaleString());
66
+ });
67
+ });
68
+
69
+ describe("identity", () => {
70
+ it("keys an edge the way /core keys it, so a selection round-trips", () => {
71
+ const first = graph.transitions[0]!;
72
+ expect(processEdgeId(first.source, first.target)).toContain(first.source);
73
+ expect(processEdgeId(first.source, first.target)).toContain(first.target);
74
+ });
75
+
76
+ it("keys the STRUCTURE only — a metric change must not move the key", () => {
77
+ expect(processGraphStructureKey(graph)).toBe(processGraphStructureKey(graph));
78
+ const reordered: ProcessGraph = {
79
+ ...graph,
80
+ activities: [...graph.activities].reverse(),
81
+ transitions: [...graph.transitions].reverse(),
82
+ };
83
+ expect(processGraphStructureKey(reordered)).toBe(processGraphStructureKey(graph));
84
+ });
85
+ });
86
+
87
+ describe("buildProcessMapModel", () => {
88
+ const model = buildProcessMapModel({
89
+ graph,
90
+ metric: { node: "absolute_case", edge: "absolute" },
91
+ rework,
92
+ });
93
+
94
+ it("emits one node per activity and one edge per transition", () => {
95
+ expect(model.nodes).toHaveLength(graph.activities.length);
96
+ expect(model.edges).toHaveLength(graph.transitions.length);
97
+ });
98
+
99
+ it("registers every node and edge against this package's own types", () => {
100
+ expect(new Set(model.nodes.map((n) => n.type))).toEqual(new Set(["process-activity"]));
101
+ expect(new Set(model.edges.map((e) => e.type))).toEqual(new Set(["process-transition"]));
102
+ });
103
+
104
+ it("gives the table twin exactly the canvas's numbers", () => {
105
+ for (const row of model.activityRows) {
106
+ const node = model.nodes.find((n) => n.id === row.id);
107
+ expect(node?.data.primaryLabel).toBe(row.primaryLabel);
108
+ }
109
+ for (const row of model.transitionRows) {
110
+ const edge = model.edges.find((e) => e.id === row.id);
111
+ expect(edge?.data?.label).toBe(row.primaryLabel);
112
+ }
113
+ });
114
+
115
+ it("resolves through flow's own scale into the [1.5, 8] px band, monotonically", () => {
116
+ // `data.weight` is the RAW metric value; `flow` owns the min-max into
117
+ // DEFAULT_EDGE_WIDTH_RANGE. Asserting through the real scale is what proves the
118
+ // encoding, rather than asserting a band this package does not apply.
119
+ const widths = computeEdgeWeightScale(
120
+ model.edges.map((edge) => ({ id: edge.id, data: edge.data })),
121
+ );
122
+ const [floor, ceiling] = DEFAULT_EDGE_WIDTH_RANGE;
123
+ for (const edge of model.edges) {
124
+ const width = widths.get(edge.id)!;
125
+ expect(width).toBeGreaterThanOrEqual(floor);
126
+ expect(width).toBeLessThanOrEqual(ceiling);
127
+ }
128
+ const sorted = [...model.edges].sort((a, b) => a.data!.weight! - b.data!.weight!);
129
+ for (let i = 1; i < sorted.length; i += 1) {
130
+ expect(widths.get(sorted[i]!.id)!).toBeGreaterThanOrEqual(widths.get(sorted[i - 1]!.id)!);
131
+ }
132
+ });
133
+
134
+ it("prints a label on every edge, so width is never the only channel", () => {
135
+ for (const edge of model.edges) {
136
+ expect(edge.data!.label).toBeTruthy();
137
+ }
138
+ });
139
+
140
+ it("keeps node saturation inside [0, 1] and mid-scale for a flat domain", () => {
141
+ for (const node of model.nodes) {
142
+ expect(node.data.saturation).toBeGreaterThanOrEqual(0);
143
+ expect(node.data.saturation).toBeLessThanOrEqual(1);
144
+ }
145
+ const flat = buildProcessMapModel({
146
+ graph: { ...graph, activities: [graph.activities[0]!] },
147
+ metric: { node: "absolute", edge: "absolute" },
148
+ });
149
+ expect(flat.nodes[0]!.data.saturation).toBe(0.5);
150
+ });
151
+
152
+ it("applies the back-edge ids the layout reports, on the second pass", () => {
153
+ const id = model.edges[0]!.id;
154
+ const second = buildProcessMapModel({
155
+ graph,
156
+ metric: { node: "absolute_case", edge: "absolute" },
157
+ backEdgeIds: new Set([id]),
158
+ });
159
+ expect(second.edges.find((e) => e.id === id)!.data!.isBackEdge).toBe(true);
160
+ });
161
+
162
+ it("carries the rework tally onto the node that repeats", () => {
163
+ const repeating = Object.entries(rework.perActivity).find(
164
+ ([, tally]) => tally.selfLoops + tally.loops > 0,
165
+ );
166
+ if (!repeating) return; // a log with no rework is a valid log; nothing to assert
167
+ const node = model.nodes.find((n) => n.id === repeating[0]);
168
+ expect(node?.data.reworkCount).toBeGreaterThan(0);
169
+ });
170
+ });
171
+
172
+ describe("selection", () => {
173
+ const focus = graph.transitions[0]!.source;
174
+ const neighbourhood = selectionNeighbourhood(graph, { kind: "activity", id: focus })!;
175
+
176
+ it("keeps the activity, its neighbours and its incident edges", () => {
177
+ expect(neighbourhood.activities.has(focus)).toBe(true);
178
+ for (const transition of graph.transitions) {
179
+ if (transition.source !== focus && transition.target !== focus) continue;
180
+ expect(
181
+ neighbourhood.transitions.has(processEdgeId(transition.source, transition.target)),
182
+ ).toBe(true);
183
+ }
184
+ });
185
+
186
+ it("marks everything outside the neighbourhood excluded, and NEVER aria-disabled", () => {
187
+ const model = buildProcessMapModel({
188
+ graph,
189
+ metric: { node: "absolute", edge: "absolute" },
190
+ selection: { kind: "activity", id: focus },
191
+ });
192
+ const selected = model.nodes.find((n) => n.id === focus)!;
193
+ expect(selected.data.selectionState).toBe("selected");
194
+ const excluded = model.nodes.filter((n) => n.data.selectionState === "excluded");
195
+ expect(excluded.length).toBeGreaterThan(0);
196
+ for (const node of excluded) {
197
+ // An excluded node stays fully operable — clicking it is how a reader re-selects
198
+ // it — so it must never be aria-disabled (RM-052 round 2, #227).
199
+ expect(node.domAttributes).not.toHaveProperty("aria-disabled");
200
+ // The exclusion still reaches assistive technology, through real text.
201
+ expect(node.ariaLabel).toContain("excluded");
202
+ }
203
+ });
204
+
205
+ it("answers null when nothing is selected", () => {
206
+ expect(selectionNeighbourhood(graph, null)).toBeNull();
207
+ });
208
+ });
209
+
210
+ // #375: the pure inputs to the map's own live-region summary, locked without a render —
211
+ // derived once in buildProcessMapModel so the canvas branch, the table branch and this
212
+ // test all read one number instead of three independent recomputations.
213
+ describe("excludedCounts (#375) — the derived summary a live region announces", () => {
214
+ it("counts zero excluded, against the real totals, when nothing narrows the graph", () => {
215
+ const model = buildProcessMapModel({
216
+ graph,
217
+ metric: { node: "absolute", edge: "absolute" },
218
+ });
219
+ expect(model.excludedCounts).toEqual({
220
+ activities: 0,
221
+ totalActivities: graph.activities.length,
222
+ transitions: 0,
223
+ totalTransitions: graph.transitions.length,
224
+ });
225
+ });
226
+
227
+ it("counts exactly the nodes/edges the model itself marks excluded", () => {
228
+ const focus = graph.transitions[0]!.source;
229
+ const model = buildProcessMapModel({
230
+ graph,
231
+ metric: { node: "absolute", edge: "absolute" },
232
+ selection: { kind: "activity", id: focus },
233
+ });
234
+ const expectedActivities = model.nodes.filter(
235
+ (n) => n.data.selectionState === "excluded",
236
+ ).length;
237
+ const expectedTransitions = model.edges.filter(
238
+ (e) => (e.data as { selectionState: string }).selectionState === "excluded",
239
+ ).length;
240
+ // Non-vacuity: this selection genuinely excludes something on the shared fixture.
241
+ expect(expectedActivities).toBeGreaterThan(0);
242
+ expect(model.excludedCounts).toEqual({
243
+ activities: expectedActivities,
244
+ totalActivities: graph.activities.length,
245
+ transitions: expectedTransitions,
246
+ totalTransitions: graph.transitions.length,
247
+ });
248
+ });
249
+ });
250
+
251
+ describe("resolveSelectionState — the five-rule precedence (RM-052 round 2, #227)", () => {
252
+ const a = graph.activities[0]!.id;
253
+ const b = graph.activities[1]!.id;
254
+ const neighbourhood = selectionNeighbourhood(graph, { kind: "activity", id: a })!;
255
+
256
+ it("rule 1: the element IS the click target -> selected, even when nothing else applies", () => {
257
+ expect(resolveSelectionState(a, "activity", { kind: "activity", id: a }, null)).toBe(
258
+ "selected",
259
+ );
260
+ });
261
+
262
+ it("rule 1 beats rule 2: clicking an element the filter excluded re-selects it", () => {
263
+ const states = { activities: { [a]: "excluded" as const } };
264
+ expect(
265
+ resolveSelectionState(a, "activity", { kind: "activity", id: a }, neighbourhood, states),
266
+ ).toBe("selected");
267
+ });
268
+
269
+ it("rule 2 beats rule 3: a filter-excluded element stays excluded even inside the clicked neighbourhood", () => {
270
+ // b is inside a's neighbourhood (kept by rule 3's own test), but the filter marks it
271
+ // excluded directly — rule 2 must win.
272
+ expect(neighbourhood.activities.has(b)).toBe(true);
273
+ const states = { activities: { [b]: "excluded" as const } };
274
+ expect(
275
+ resolveSelectionState(b, "activity", { kind: "activity", id: a }, neighbourhood, states),
276
+ ).toBe("excluded");
277
+ });
278
+
279
+ it("rule 3: outside the click target's neighbourhood -> excluded, with no filter states at all", () => {
280
+ const outsider = graph.activities.find(
281
+ (activity) => !neighbourhood.activities.has(activity.id),
282
+ );
283
+ if (!outsider) return; // every activity happens to be reachable on this fixture
284
+ expect(
285
+ resolveSelectionState(outsider.id, "activity", { kind: "activity", id: a }, neighbourhood),
286
+ ).toBe("excluded");
287
+ });
288
+
289
+ it("rule 4: an explicit non-excluded filter state is honoured when nothing is click-selected", () => {
290
+ const states = { activities: { [b]: "associated" as const } };
291
+ expect(resolveSelectionState(b, "activity", null, null, states)).toBe("associated");
292
+ });
293
+
294
+ it("rule 5: the default is associated — no click selection, no filter state", () => {
295
+ expect(resolveSelectionState(a, "activity", null, null)).toBe("associated");
296
+ });
297
+ });
298
+
299
+ describe("non-colour channels reach assistive technology", () => {
300
+ const model = buildProcessMapModel({
301
+ graph,
302
+ metric: { node: "absolute_case", edge: "absolute" },
303
+ rework,
304
+ });
305
+
306
+ it("says a node's role in words, not only with a glyph", () => {
307
+ const start = model.nodes.find((n) => n.data.isStart)!;
308
+ expect(activityRole(start.data)).toMatch(/^Start/);
309
+ expect(activityAriaLabel(start.data)).toContain("start");
310
+ expect(activityAriaLabel(start.data)).toContain(start.data.primaryLabel);
311
+ });
312
+
313
+ it("says an edge's shape in words, not only with a dash pattern", () => {
314
+ const edge = model.edges[0]!;
315
+ expect(["Self-loop", "Back edge", "Forward"]).toContain(transitionShape(edge.data!));
316
+ const back = { ...edge.data!, isBackEdge: true, isSelfLoop: false };
317
+ expect(transitionAriaLabel(back, "Transitions")).toContain("Back edge");
318
+ const loop = { ...edge.data!, isSelfLoop: true };
319
+ expect(transitionAriaLabel(loop, "Transitions")).toContain("Self-loop");
320
+ });
321
+
322
+ it("names every node and edge, and stamps that name for React Flow", () => {
323
+ for (const node of model.nodes) expect(node.ariaLabel).toBe(activityAriaLabel(node.data));
324
+ for (const edge of model.edges) expect(edge.ariaLabel).toBeTruthy();
325
+ });
326
+ });