@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
package/package.json ADDED
@@ -0,0 +1,80 @@
1
+ {
2
+ "name": "@elabs-ai/components-process",
3
+ "version": "4.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/mreimitz/elabs-components.git",
8
+ "directory": "packages/process"
9
+ },
10
+ "type": "module",
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./core": {
22
+ "types": "./dist/core/index.d.ts",
23
+ "default": "./dist/core/index.js"
24
+ },
25
+ "./test": {
26
+ "types": "./dist/test/index.d.ts",
27
+ "default": "./dist/test/index.js"
28
+ }
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "dependencies": {
35
+ "class-variance-authority": "^0.7.1",
36
+ "lucide-react": "^0.577.0"
37
+ },
38
+ "peerDependencies": {
39
+ "@xyflow/react": "^12.11.1",
40
+ "react": "^18.2.0 || ^19.0.0",
41
+ "react-dom": "^18.2.0 || ^19.0.0",
42
+ "@elabs-ai/components-charts": "4.1.0",
43
+ "@elabs-ai/components-flow": "4.1.0",
44
+ "@elabs-ai/components-data": "4.1.0",
45
+ "@elabs-ai/components-tokens": "4.1.0",
46
+ "@elabs-ai/components-ui": "4.1.0"
47
+ },
48
+ "devDependencies": {
49
+ "@testing-library/jest-dom": "^6.6.3",
50
+ "@testing-library/react": "^16.1.0",
51
+ "@types/react": "^19.0.2",
52
+ "@types/react-dom": "^19.0.2",
53
+ "@vitejs/plugin-react": "^4.3.4",
54
+ "@xyflow/react": "^12.11.1",
55
+ "eslint": "^9.17.0",
56
+ "jsdom": "^25.0.1",
57
+ "react": "^19.0.0",
58
+ "react-dom": "^19.0.0",
59
+ "tsup": "^8.3.5",
60
+ "typescript": "^5.7.3",
61
+ "vitest": "^3.0.2",
62
+ "@elabs-ai/components-charts": "4.1.0",
63
+ "@elabs-ai/components-data": "4.1.0",
64
+ "@elabs-ai/components-eslint-config": "0.1.0",
65
+ "@elabs-ai/components-flow": "4.1.0",
66
+ "@elabs-ai/components-icons": "4.1.0",
67
+ "@elabs-ai/components-typescript-config": "0.1.0",
68
+ "@elabs-ai/components-ui": "4.1.0",
69
+ "@elabs-ai/components-tokens": "4.1.0"
70
+ },
71
+ "scripts": {
72
+ "build": "rm -rf dist && tsup",
73
+ "typecheck": "tsc --noEmit",
74
+ "lint": "eslint .",
75
+ "test": "vitest run --passWithNoTests",
76
+ "test:watch": "vitest",
77
+ "generate:fixtures": "vitest run --config vitest.fixtures.config.ts",
78
+ "clean": "rm -rf dist .turbo coverage"
79
+ }
80
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Shared, framework-free fixtures for `AbstractionControls`' stories AND its co-located
3
+ * test (#376). Lifted into its own module — rather than exported straight from
4
+ * `abstraction-controls.stories.tsx` — because `tsconfig.json` excludes `**\/*.stories.tsx`
5
+ * from the package's typecheck project; importing one from a `.test.tsx` would pull the
6
+ * whole story file (and its pre-existing, Storybook-only `StoryObj` typing) into that
7
+ * project transitively. This module has no such baggage.
8
+ */
9
+ import type { ActivityStats, DurationStats, ProcessGraph, TransitionStats } from "../core/types";
10
+
11
+ const zero: DurationStats = {
12
+ min: 0,
13
+ max: 0,
14
+ mean: 0,
15
+ median: 0,
16
+ p90: 0,
17
+ sum: 0,
18
+ trimmedMean: 0,
19
+ };
20
+
21
+ /**
22
+ * A 60-activity graph shaped like a real process: a 20-step high-frequency BACKBONE plus
23
+ * 40 rare side activities, each hanging off it as `Step[i] -> Detour -> Step[i+1]`.
24
+ *
25
+ * The topology is the point, not the size (#376). `abstractGraph`'s `keepConnected` repair
26
+ * restores reachability to a start/end activity, so on a strict unbranched CHAIN — the
27
+ * shape the `Interaction` story's fixture used to be — dropping any suffix forces the whole
28
+ * remainder back in and no slider position hides anything (measured; see
29
+ * `auto-abstraction.test.ts`'s "known limitation" block and #343, which owns that runtime
30
+ * behaviour). Dropping a rare detour here leaves the backbone connected, so a narrowing is a
31
+ * real narrowing: at the "Auto" fraction for a 25-activity budget this fixture keeps exactly
32
+ * the 20-step backbone plus 5 detours (25 total), locked in
33
+ * `abstraction-controls.test.tsx`.
34
+ */
35
+ export function backboneGraph(backbone = 20, sides = 40): ProcessGraph {
36
+ const spine = Array.from(
37
+ { length: backbone },
38
+ (_, i) => `Step ${String(i + 1).padStart(2, "0")}`,
39
+ );
40
+ const side = Array.from({ length: sides }, (_, i) => `Detour ${String(i + 1).padStart(2, "0")}`);
41
+ const activities: ActivityStats[] = [
42
+ ...spine.map((id, i) => ({
43
+ id,
44
+ label: id,
45
+ instances: 1000 - i,
46
+ cases: 1000 - i,
47
+ isStart: i === 0,
48
+ isEnd: i === backbone - 1,
49
+ duration: { ...zero, median: (i + 1) * 900_000 },
50
+ })),
51
+ ...side.map((id, i) => ({
52
+ id,
53
+ label: id,
54
+ instances: 40 - i,
55
+ cases: 40 - i,
56
+ isStart: false,
57
+ isEnd: false,
58
+ duration: { ...zero, median: (i + 1) * 300_000 },
59
+ })),
60
+ ];
61
+ const transitions: TransitionStats[] = [];
62
+ const push = (source: string, target: string, count: number) => {
63
+ transitions.push({
64
+ source,
65
+ target,
66
+ count,
67
+ caseCount: Math.max(1, Math.round(count * 0.8)),
68
+ duration: { ...zero, median: count * 60_000 },
69
+ isSelfLoop: source === target,
70
+ isBackEdge: false,
71
+ });
72
+ };
73
+ for (let i = 0; i < backbone - 1; i += 1) push(spine[i]!, spine[i + 1]!, 900 - i * 5);
74
+ side.forEach((id, i) => {
75
+ const anchor = i % (backbone - 1);
76
+ push(spine[anchor]!, id, 40 - i);
77
+ push(id, spine[anchor + 1]!, 40 - i);
78
+ });
79
+ return {
80
+ activities,
81
+ transitions,
82
+ startActivities: { [spine[0]!]: 1000 },
83
+ endActivities: { [spine[backbone - 1]!]: 1000 },
84
+ totals: { cases: 1000, events: 6000, variants: 41 },
85
+ };
86
+ }
@@ -0,0 +1,188 @@
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 { abstractGraph, type AbstractionOptions } from "../core/abstract-graph";
5
+ import { discoverGraph } from "../core/discover-graph";
6
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
7
+ import type { ProcessGraph } from "../core/types";
8
+ import { AbstractionControls } from "./abstraction-controls";
9
+ import { backboneGraph } from "./abstraction-controls-fixtures";
10
+
11
+ const log = generateSyntheticLog({ cases: 240, seed: 42 });
12
+ const fullGraph = discoverGraph(log);
13
+
14
+ /**
15
+ * `busyGraph` is what makes the `Interaction` story below a REAL narrowing rather than a
16
+ * round trip back to 100% — see {@link backboneGraph}'s own docblock for why its topology
17
+ * (not merely its size) is what makes that true (#376).
18
+ */
19
+ const busyGraph = backboneGraph();
20
+
21
+ function ControlledAbstractionControls({
22
+ graph = fullGraph,
23
+ initial,
24
+ autoMaxActivities,
25
+ }: {
26
+ graph?: ProcessGraph;
27
+ initial: AbstractionOptions;
28
+ autoMaxActivities?: number;
29
+ }) {
30
+ const [abstraction, setAbstraction] = useState<AbstractionOptions>(initial);
31
+ const abstracted = abstractGraph(graph, abstraction);
32
+ return (
33
+ <div className="max-w-sm">
34
+ <AbstractionControls
35
+ abstraction={abstraction}
36
+ onAbstractionChange={(next) => setAbstraction((prev) => ({ ...prev, ...next }))}
37
+ graph={abstracted}
38
+ hiddenCounts={abstracted.hidden}
39
+ autoMaxActivities={autoMaxActivities}
40
+ />
41
+ </div>
42
+ );
43
+ }
44
+
45
+ const meta = {
46
+ title: "Process/AbstractionControls",
47
+ component: AbstractionControls,
48
+ tags: ["autodocs"],
49
+ parameters: {
50
+ docs: {
51
+ description: {
52
+ component:
53
+ "The two sliders every process-mining explorer opens with. Activities/paths " +
54
+ "abstraction is a VIEW filter over `ProcessMap`, never a re-analysis — dragging a " +
55
+ "slider hides nodes/edges, it never recomputes a statistic (`abstractGraph`'s own " +
56
+ "contract). The 'Auto' button runs a bounded, deterministic search " +
57
+ "(`computeAutoAbstraction`) for the largest activities fraction that still fits a " +
58
+ "node budget, so a reader can open a busy graph and get a readable one in one click.",
59
+ },
60
+ },
61
+ },
62
+ } satisfies Meta<typeof AbstractionControls>;
63
+ export default meta;
64
+ type Story = StoryObj<typeof meta>;
65
+
66
+ /**
67
+ * Identity abstraction — nothing hidden yet. Also the geometry lock for #355: each INTERIOR
68
+ * tick's measured centre (`getBoundingClientRect`, a real layout, not a JSDOM stand-in) must
69
+ * sit at its own value's fraction of the slider track — a `justify-between` flex row would
70
+ * instead land the four ticks at 0/33.3/66.7/100, never at 25/50/75/100 — on BOTH sliders. The
71
+ * `100%` tick is deliberately right-aligned instead of centred (so it is never clipped by the
72
+ * rail's own right edge, per the issue's own "translate-x only for interior ticks" option), so
73
+ * its RIGHT edge — not its centre — is what is asserted against the track's right edge.
74
+ * Clicking the `50%` tick must move the thumb to that tick's own measured centre.
75
+ */
76
+ export const Default: Story = {
77
+ render: () => (
78
+ <ControlledAbstractionControls
79
+ initial={{ activities: 1, paths: 1, invert: false, keepConnected: true }}
80
+ />
81
+ ),
82
+ play: async ({ canvasElement }) => {
83
+ const canvas = within(canvasElement);
84
+ const tolerancePx = 2;
85
+
86
+ for (const group of ["Activities", "Paths"]) {
87
+ const slider = canvas.getByRole("slider", { name: group });
88
+ const trackRect = (slider.closest(".relative") as HTMLElement).getBoundingClientRect();
89
+ const ticks = within(canvas.getByRole("group", { name: group })).getAllByRole("button", {
90
+ name: /^\d+%$/,
91
+ });
92
+ expect(ticks).toHaveLength(4);
93
+ for (const tick of ticks) {
94
+ const percent = Number(tick.textContent?.replace("%", ""));
95
+ const tickRect = tick.getBoundingClientRect();
96
+ const expectedX = trackRect.left + (percent / 100) * trackRect.width;
97
+ if (percent >= 100) {
98
+ // Right-aligned, not centred — its right edge (not its centre) marks the value.
99
+ expect(Math.abs(tickRect.right - expectedX)).toBeLessThanOrEqual(tolerancePx);
100
+ expect(tickRect.right).toBeLessThanOrEqual(trackRect.right + tolerancePx);
101
+ } else {
102
+ const tickCentre = tickRect.left + tickRect.width / 2;
103
+ expect(Math.abs(tickCentre - expectedX)).toBeLessThanOrEqual(tolerancePx);
104
+ }
105
+ }
106
+ }
107
+
108
+ // Clicking the 50% tick moves the thumb to that tick's own measured centre.
109
+ const activitiesSlider = canvas.getByRole("slider", { name: "Activities" });
110
+ const activitiesTick50 = within(canvas.getByRole("group", { name: "Activities" })).getByRole(
111
+ "button",
112
+ { name: "50%" },
113
+ );
114
+ const tick50Centre = (() => {
115
+ const r = activitiesTick50.getBoundingClientRect();
116
+ return r.left + r.width / 2;
117
+ })();
118
+ await userEvent.click(activitiesTick50);
119
+ await waitFor(() => expect(activitiesSlider).toHaveAttribute("aria-valuenow", "50"));
120
+ // The attribute and the thumb's own layout both come from the same `value` prop update,
121
+ // but give layout one more macrotask to settle before measuring it (real-browser paint,
122
+ // not a JSDOM stand-in).
123
+ await waitFor(() => {
124
+ const thumbRect = activitiesSlider.getBoundingClientRect();
125
+ const thumbCentre = thumbRect.left + thumbRect.width / 2;
126
+ expect(Math.abs(thumbCentre - tick50Centre)).toBeLessThanOrEqual(tolerancePx);
127
+ });
128
+ },
129
+ };
130
+
131
+ /** Half the activities kept — the hidden-count status line reflects the real hidden count. */
132
+ export const PartiallyAbstracted: Story = {
133
+ render: () => (
134
+ <ControlledAbstractionControls
135
+ initial={{ activities: 0.5, paths: 0.5, invert: false, keepConnected: true }}
136
+ />
137
+ ),
138
+ };
139
+
140
+ /** The "what is rare here" reading — `invert` hides the MOST frequent instead of the least. */
141
+ export const Inverted: Story = {
142
+ render: () => (
143
+ <ControlledAbstractionControls
144
+ initial={{ activities: 0.5, paths: 1, invert: true, keepConnected: true }}
145
+ />
146
+ ),
147
+ };
148
+
149
+ /**
150
+ * Dragging a percentage tick sets the corresponding slider directly, and clicking "Auto"
151
+ * runs the bounded heuristic and narrows `busyGraph` (its backbone-plus-detours topology,
152
+ * see {@link backboneGraph}) down to fit its default 25-activity budget — the RM-052
153
+ * acceptance criterion for the control, exercised against a real, oversized graph rather
154
+ * than a stub. This holds for THIS fixture's topology; it is not a general guarantee of
155
+ * "Auto" — on a strict activity chain the same heuristic's prediction and the abstracted
156
+ * result can diverge (`keepConnected`'s reachability repair re-adds the whole chain; see
157
+ * #343, which owns that runtime behaviour and is out of scope here).
158
+ */
159
+ export const Interaction: Story = {
160
+ render: () => (
161
+ <ControlledAbstractionControls
162
+ graph={busyGraph}
163
+ initial={{ activities: 1, paths: 1, invert: false, keepConnected: true }}
164
+ autoMaxActivities={25}
165
+ />
166
+ ),
167
+ play: async ({ canvasElement }) => {
168
+ const canvas = within(canvasElement);
169
+ const activitiesSlider = canvas.getByRole("slider", { name: "Activities" });
170
+ const status = canvasElement.querySelector('[role="status"]');
171
+ expect(activitiesSlider).toHaveAttribute("aria-valuenow", "100");
172
+ expect(status).toHaveTextContent("0 activities hidden");
173
+
174
+ // A percentage tick sets its slider directly, without requiring a drag.
175
+ const tick50 = canvas.getAllByRole("button", { name: "50%" })[0]!;
176
+ await userEvent.click(tick50);
177
+ await waitFor(() => expect(activitiesSlider).toHaveAttribute("aria-valuenow", "50"));
178
+
179
+ // "Auto" narrows toward the 25-activity budget. The slider value is a secondary check —
180
+ // the assertion that actually catches a non-narrowing run is the live region's own text,
181
+ // since that is what a reader of the control sees (#376).
182
+ const autoButton = canvas.getByRole("button", { name: "Auto" });
183
+ await userEvent.click(autoButton);
184
+ await waitFor(() => expect(status).toHaveTextContent(/[1-9]\d* activit(?:y|ies) hidden/));
185
+ const afterAuto = Number(activitiesSlider.getAttribute("aria-valuenow"));
186
+ expect(Math.round(60 * (afterAuto / 100))).toBeLessThanOrEqual(25);
187
+ },
188
+ };
@@ -0,0 +1,226 @@
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 { abstractGraph, type AbstractionOptions } from "../core/abstract-graph";
5
+ import type { ActivityStats } from "../core/types";
6
+ import { AbstractionControls } from "./abstraction-controls";
7
+ import { backboneGraph } from "./abstraction-controls-fixtures";
8
+ import { computeAutoAbstraction } from "./auto-abstraction";
9
+
10
+ afterEach(cleanup);
11
+
12
+ const identity: AbstractionOptions = {
13
+ activities: 1,
14
+ paths: 1,
15
+ invert: false,
16
+ keepConnected: true,
17
+ };
18
+
19
+ /** Only `.length` matters to `AbstractionControls` — minimal-but-valid `ActivityStats` stubs. */
20
+ function fakeActivities(count: number): ActivityStats[] {
21
+ return Array.from({ length: count }, (_, i) => ({
22
+ id: `A${i}`,
23
+ label: `A${i}`,
24
+ instances: 1,
25
+ cases: 1,
26
+ isStart: i === 0,
27
+ isEnd: i === count - 1,
28
+ duration: { min: 0, max: 0, mean: 0, median: 0, p90: 0, sum: 0, trimmedMean: 0 },
29
+ }));
30
+ }
31
+
32
+ function renderControls(overrides: Partial<Parameters<typeof AbstractionControls>[0]> = {}) {
33
+ const onAbstractionChange = vi.fn();
34
+ const utils = render(
35
+ <AbstractionControls
36
+ abstraction={identity}
37
+ onAbstractionChange={onAbstractionChange}
38
+ graph={{ activities: fakeActivities(25) }}
39
+ hiddenCounts={{ activities: 0, paths: 0 }}
40
+ {...overrides}
41
+ />,
42
+ );
43
+ return { onAbstractionChange, ...utils };
44
+ }
45
+
46
+ describe("AbstractionControls — rendering", () => {
47
+ it("renders both sliders, the invert switch and the Auto button", () => {
48
+ renderControls();
49
+ expect(screen.getByRole("slider", { name: "Activities" })).toBeInTheDocument();
50
+ expect(screen.getByRole("slider", { name: "Paths" })).toBeInTheDocument();
51
+ expect(screen.getByRole("switch")).toBeInTheDocument();
52
+ expect(screen.getByRole("button", { name: "Auto" })).toBeInTheDocument();
53
+ });
54
+
55
+ it("shows the current abstraction percentages", () => {
56
+ renderControls({ abstraction: { ...identity, activities: 0.5, paths: 0.25 } });
57
+ expect(screen.getByRole("slider", { name: "Activities" })).toHaveAttribute(
58
+ "aria-valuenow",
59
+ "50",
60
+ );
61
+ expect(screen.getByRole("slider", { name: "Paths" })).toHaveAttribute("aria-valuenow", "25");
62
+ });
63
+
64
+ it("announces the hidden counts in a single live region", () => {
65
+ renderControls({ hiddenCounts: { activities: 3, paths: 7 } });
66
+ const status = screen.getByRole("status");
67
+ expect(status).toHaveTextContent("3 activities hidden");
68
+ expect(status).toHaveTextContent("7 paths hidden");
69
+ });
70
+ });
71
+
72
+ describe("AbstractionControls — a percentage tick", () => {
73
+ it("sets the activities slider directly, without a drag", async () => {
74
+ const user = userEvent.setup();
75
+ const { onAbstractionChange, container } = renderControls();
76
+ // Both sliders render a "75%" tick — scope the query to the activities
77
+ // section (its own `data-slot`) rather than assuming DOM order.
78
+ const activitiesSection = container.querySelector(
79
+ '[data-slot="abstraction-controls-activities"]',
80
+ ) as HTMLElement;
81
+ await user.click(within(activitiesSection).getByRole("button", { name: "75%" }));
82
+ expect(onAbstractionChange).toHaveBeenCalledWith({ activities: 0.75 });
83
+ });
84
+ });
85
+
86
+ describe("AbstractionControls — tick position (#355)", () => {
87
+ it("positions every tick at its own percent, derived from the slider's min/max — not spread evenly by flexbox", () => {
88
+ renderControls();
89
+ const ticks = screen.getAllByRole("button", { name: /^\d+%$/ });
90
+ expect(ticks).toHaveLength(8); // 4 ticks x 2 sliders
91
+ for (const tick of ticks) {
92
+ const percent = Number(tick.textContent?.replace("%", ""));
93
+ // The slider's own range is 0..100, so a tick's rail offset must equal its percent
94
+ // exactly — a `justify-between` flex row would instead land at 0/33.3/66.7/100 for
95
+ // this 4-tick set, never at 25/50/75/100.
96
+ expect(tick.style.left).toBe(`${percent}%`);
97
+ }
98
+ });
99
+
100
+ it("keeps the 100% tick anchored to the rail's right edge rather than centred past it", () => {
101
+ renderControls();
102
+ const tick100 = screen.getAllByRole("button", { name: "100%" })[0]!;
103
+ expect(tick100.style.left).toBe("100%");
104
+ expect(tick100.className).toContain("-translate-x-full");
105
+ expect(tick100.className).not.toContain("-translate-x-1/2");
106
+ });
107
+ });
108
+
109
+ describe("AbstractionControls — tick + group accessible names (#356)", () => {
110
+ it("names three groups — the outer control plus one per slider — reusing the slider's own localized label", () => {
111
+ renderControls();
112
+ const groups = screen.getAllByRole("group");
113
+ expect(groups).toHaveLength(3);
114
+ expect(screen.getByRole("group", { name: "Abstraction" })).toBeInTheDocument();
115
+ expect(screen.getByRole("group", { name: "Activities" })).toBeInTheDocument();
116
+ expect(screen.getByRole("group", { name: "Paths" })).toBeInTheDocument();
117
+ });
118
+
119
+ it("gives every tick button its own accessible name (toHaveAccessibleName, exact string, no regex)", () => {
120
+ renderControls();
121
+ for (const percent of [25, 50, 75, 100]) {
122
+ const [activitiesTick, pathsTick] = screen.getAllByRole("button", {
123
+ name: `${percent}%`,
124
+ });
125
+ expect(activitiesTick).toHaveAccessibleName(`${percent}%`);
126
+ expect(pathsTick).toHaveAccessibleName(`${percent}%`);
127
+ }
128
+ });
129
+
130
+ it("disambiguates all eight ticks via group name + own name — 8 distinct qualified entries", () => {
131
+ renderControls();
132
+ const activitiesGroup = screen.getByRole("group", { name: "Activities" });
133
+ const pathsGroup = screen.getByRole("group", { name: "Paths" });
134
+ const qualifiedNames = new Set<string>();
135
+ for (const [groupName, group] of [
136
+ ["Activities", activitiesGroup],
137
+ ["Paths", pathsGroup],
138
+ ] as const) {
139
+ const ticks = within(group).getAllByRole("button", { name: /^\d+%$/ });
140
+ expect(ticks).toHaveLength(4);
141
+ for (const tick of ticks) {
142
+ qualifiedNames.add(`${groupName} ${tick.textContent}`);
143
+ }
144
+ }
145
+ expect(qualifiedNames.size).toBe(8);
146
+ });
147
+ });
148
+
149
+ describe("AbstractionControls — invert", () => {
150
+ it("toggles the invert switch", async () => {
151
+ const user = userEvent.setup();
152
+ const { onAbstractionChange } = renderControls();
153
+ await user.click(screen.getByRole("switch"));
154
+ expect(onAbstractionChange).toHaveBeenCalledWith({ invert: true });
155
+ });
156
+ });
157
+
158
+ describe("AbstractionControls — Auto (the RM-052 acceptance criterion)", () => {
159
+ it("derives the pre-abstraction total from graph.activities.length + hiddenCounts.activities and requests a fraction that fits the budget", async () => {
160
+ const user = userEvent.setup();
161
+ // 20 kept + 40 hidden = 60 activities pre-abstraction; a 25-activity budget cannot be
162
+ // met at 100%, so Auto must request something less than the identity fraction.
163
+ const { onAbstractionChange } = renderControls({
164
+ graph: { activities: fakeActivities(20) },
165
+ hiddenCounts: { activities: 40, paths: 0 },
166
+ autoMaxActivities: 25,
167
+ });
168
+ await user.click(screen.getByRole("button", { name: "Auto" }));
169
+ expect(onAbstractionChange).toHaveBeenCalledTimes(1);
170
+ const [call] = onAbstractionChange.mock.calls;
171
+ const requested = call![0] as { activities: number };
172
+ expect(requested.activities).toBeLessThan(1);
173
+ expect(Math.round(60 * requested.activities)).toBeLessThanOrEqual(25);
174
+ });
175
+
176
+ it("is a no-op fraction (1) when the graph is already within budget — and paths caps at 1 too", async () => {
177
+ const user = userEvent.setup();
178
+ const { onAbstractionChange } = renderControls({
179
+ graph: { activities: fakeActivities(10) },
180
+ hiddenCounts: { activities: 0, paths: 0 },
181
+ autoMaxActivities: 25,
182
+ });
183
+ await user.click(screen.getByRole("button", { name: "Auto" }));
184
+ // activities: 1 would put the offset ("0.2 above") at 1.2 — proves the cap actually
185
+ // clamps rather than merely never being exercised.
186
+ expect(onAbstractionChange).toHaveBeenCalledWith({ activities: 1, paths: 1 });
187
+ });
188
+
189
+ it("sets paths 0.2 above the requested activities fraction — the roadmap's spec (RM-052 round 2, #227, F2 third piece)", async () => {
190
+ const user = userEvent.setup();
191
+ // Same over-budget shape as the first Auto test: 20 kept + 40 hidden, budget 25, so the
192
+ // heuristic requests an activities fraction well under 0.8 — the offset stays additive
193
+ // here rather than immediately hitting the cap tested above.
194
+ const { onAbstractionChange } = renderControls({
195
+ graph: { activities: fakeActivities(20) },
196
+ hiddenCounts: { activities: 40, paths: 0 },
197
+ autoMaxActivities: 25,
198
+ });
199
+ await user.click(screen.getByRole("button", { name: "Auto" }));
200
+ const [call] = onAbstractionChange.mock.calls;
201
+ const requested = call![0] as { activities: number; paths: number };
202
+ expect(requested.activities).toBeLessThan(0.8);
203
+ expect(requested.paths).toBeCloseTo(requested.activities + 0.2, 10);
204
+ });
205
+ });
206
+
207
+ describe("AbstractionControls — Interaction story fixture suitability (#376)", () => {
208
+ it("actually narrows under Auto's fraction, unlike the strict-chain topology auto-abstraction.test.ts locks as a known limitation", () => {
209
+ const graph = backboneGraph();
210
+ const totalActivities = graph.activities.length;
211
+ const { activities: autoFraction } = computeAutoAbstraction(totalActivities, {
212
+ maxActivities: 25,
213
+ });
214
+ const abstracted = abstractGraph(graph, {
215
+ activities: autoFraction,
216
+ paths: Math.min(1, autoFraction + 0.2),
217
+ keepConnected: true,
218
+ });
219
+ // The mirror image of auto-abstraction.test.ts:154-173's "known limitation" block: that
220
+ // test locks that a strict chain CANNOT narrow under keepConnected repair; this locks
221
+ // that this story's backbone-plus-detours fixture CAN — so a future fixture swap cannot
222
+ // silently reintroduce a non-narrowing graph without this test going red.
223
+ expect(abstracted.hidden.activities).toBeGreaterThan(0);
224
+ expect(abstracted.activities.length).toBeLessThanOrEqual(25);
225
+ });
226
+ });