@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,160 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// @xyflow/react requires real layout/measurement (React Flow renders no edges
|
|
5
|
+
// under jsdom at all — confirmed empirically: the full `<ProcessMap>` canvas
|
|
6
|
+
// leaves `.react-flow__edges`/`.react-flow__edgelabel-renderer` both empty even
|
|
7
|
+
// with the DOMMatrixReadOnly polyfill in place, because edge geometry depends on
|
|
8
|
+
// a real `ResizeObserver` measurement pass jsdom never runs). So this file tests
|
|
9
|
+
// `ProcessTransitionEdge` STANDALONE, the pattern its own context module
|
|
10
|
+
// documents as intentional (`process-map-context.ts`: "so `ProcessActivityNode`
|
|
11
|
+
// and `ProcessTransitionEdge` render correctly outside a `ProcessMap`"), mocking
|
|
12
|
+
// only the `@xyflow/react` primitives its `FlowWeightedEdge`/`FlowEdgePath`
|
|
13
|
+
// delegates need — the same shape `flow-weighted-edge.test.tsx` already uses.
|
|
14
|
+
//
|
|
15
|
+
// `vi.mock`'s factory is hoisted above every import — `vi.hoisted` is the escape
|
|
16
|
+
// hatch so the mock fns themselves survive the hoist without a TDZ ReferenceError.
|
|
17
|
+
const { edgesBox } = vi.hoisted(() => ({ edgesBox: { current: [] as unknown[] } }));
|
|
18
|
+
|
|
19
|
+
vi.mock("@xyflow/react", () => {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- a vi.mock factory is hoisted above imports; a lazy require avoids the TDZ a top-level import would hit
|
|
21
|
+
const React = require("react");
|
|
22
|
+
return {
|
|
23
|
+
BaseEdge: ({
|
|
24
|
+
id,
|
|
25
|
+
path,
|
|
26
|
+
style,
|
|
27
|
+
className,
|
|
28
|
+
markerStart: _markerStart,
|
|
29
|
+
interactionWidth: _interactionWidth,
|
|
30
|
+
...rest
|
|
31
|
+
}: {
|
|
32
|
+
id: string;
|
|
33
|
+
path: string;
|
|
34
|
+
style?: React.CSSProperties;
|
|
35
|
+
className?: string;
|
|
36
|
+
markerStart?: string;
|
|
37
|
+
interactionWidth?: number;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}) =>
|
|
40
|
+
React.createElement("svg", { "data-testid": "base-edge" }, [
|
|
41
|
+
React.createElement("path", { key: "p", d: path, id, style, className, ...rest }),
|
|
42
|
+
]),
|
|
43
|
+
// Real EdgeLabelRenderer portals into a fixed container; a passthrough is
|
|
44
|
+
// enough here since we only assert the rendered pill's own attributes.
|
|
45
|
+
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => children,
|
|
46
|
+
getBezierPath: ({
|
|
47
|
+
sourceX,
|
|
48
|
+
sourceY,
|
|
49
|
+
targetX,
|
|
50
|
+
targetY,
|
|
51
|
+
}: {
|
|
52
|
+
sourceX: number;
|
|
53
|
+
sourceY: number;
|
|
54
|
+
targetX: number;
|
|
55
|
+
targetY: number;
|
|
56
|
+
}) => [
|
|
57
|
+
`M${sourceX},${sourceY} C${targetX},${targetY}`,
|
|
58
|
+
(sourceX + targetX) / 2,
|
|
59
|
+
(sourceY + targetY) / 2,
|
|
60
|
+
],
|
|
61
|
+
getSmoothStepPath: ({
|
|
62
|
+
sourceX,
|
|
63
|
+
sourceY,
|
|
64
|
+
targetX,
|
|
65
|
+
targetY,
|
|
66
|
+
}: {
|
|
67
|
+
sourceX: number;
|
|
68
|
+
sourceY: number;
|
|
69
|
+
targetX: number;
|
|
70
|
+
targetY: number;
|
|
71
|
+
}) => [
|
|
72
|
+
`M${sourceX},${sourceY} L${targetX},${targetY}`,
|
|
73
|
+
(sourceX + targetX) / 2,
|
|
74
|
+
(sourceY + targetY) / 2,
|
|
75
|
+
],
|
|
76
|
+
useEdges: () => edgesBox.current,
|
|
77
|
+
// `FlowWeightedEdge` reads the laid-out cards to route a back edge's return leg
|
|
78
|
+
// clear of them; nothing here is a back edge, so an empty canvas is enough.
|
|
79
|
+
useNodes: () => [],
|
|
80
|
+
// `FlowWeightedEdge` reads nodes through `useStore(selector)` so a forward edge
|
|
81
|
+
// does not re-render on every node change; this map has no measured rects.
|
|
82
|
+
useStore: (selector: (state: { nodes: unknown[] }) => unknown) => selector({ nodes: [] }),
|
|
83
|
+
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
import { ProcessTransitionEdge } from "./process-transition-edge";
|
|
88
|
+
import type { ProcessMapEdge, ProcessTransitionEdgeData } from "./map-model";
|
|
89
|
+
import type { EdgeProps } from "@xyflow/react";
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
cleanup();
|
|
93
|
+
edgesBox.current = [];
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** A complete `ProcessTransitionEdgeData` — a plain forward transition, associated. */
|
|
97
|
+
const BASE_EDGE_DATA: ProcessTransitionEdgeData = {
|
|
98
|
+
source: "a",
|
|
99
|
+
target: "b",
|
|
100
|
+
weight: 1,
|
|
101
|
+
value: 1,
|
|
102
|
+
valueDomain: [0, 1],
|
|
103
|
+
label: "12×",
|
|
104
|
+
isSelfLoop: false,
|
|
105
|
+
isBackEdge: false,
|
|
106
|
+
selectionState: "associated",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Minimal EdgeProps factory for ProcessTransitionEdge — a plain forward transition. */
|
|
110
|
+
function makeEdgeProps(
|
|
111
|
+
overrides: Partial<EdgeProps<ProcessMapEdge>> = {},
|
|
112
|
+
): EdgeProps<ProcessMapEdge> {
|
|
113
|
+
return {
|
|
114
|
+
id: "a->b",
|
|
115
|
+
type: "process-transition",
|
|
116
|
+
source: "a",
|
|
117
|
+
target: "b",
|
|
118
|
+
sourceX: 0,
|
|
119
|
+
sourceY: 0,
|
|
120
|
+
targetX: 100,
|
|
121
|
+
targetY: 100,
|
|
122
|
+
sourcePosition: "bottom" as EdgeProps["sourcePosition"],
|
|
123
|
+
targetPosition: "top" as EdgeProps["targetPosition"],
|
|
124
|
+
selected: false,
|
|
125
|
+
animated: false,
|
|
126
|
+
data: BASE_EDGE_DATA,
|
|
127
|
+
...overrides,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// #351 — the label pill is portalled out from under this edge's `<g>` by
|
|
132
|
+
// `EdgeLabelRenderer`, so a whole-subtree opacity (the treatment every OTHER
|
|
133
|
+
// ghosted mark gets) never reaches it. `ProcessTransitionEdge` instead reaches
|
|
134
|
+
// the pill explicitly, by DATA (`labelProps`), with a non-opacity treatment —
|
|
135
|
+
// this locks that the data actually lands on the rendered pill, not just that
|
|
136
|
+
// the edge's own `<g>` opacity changes (which `process-map.test.tsx`'s sibling
|
|
137
|
+
// lock already covers for the node).
|
|
138
|
+
describe("ProcessTransitionEdge — reaches an excluded edge's label pill by data (#351)", () => {
|
|
139
|
+
it("stamps the ghost frame (dashed border + data-selection) on the pill when excluded", () => {
|
|
140
|
+
render(
|
|
141
|
+
<ProcessTransitionEdge
|
|
142
|
+
{...makeEdgeProps({ data: { ...BASE_EDGE_DATA, selectionState: "excluded" } })}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
const pill = screen.getByRole("button", { name: "12×" });
|
|
146
|
+
expect(pill.className).toMatch(/\bborder-dashed\b/);
|
|
147
|
+
expect(pill).toHaveAttribute("data-selection", "excluded");
|
|
148
|
+
// The pill's own text is untouched — no opacity, inline or via class — so it
|
|
149
|
+
// never drops below the 4.5:1 rung the ghosted node's title also keeps.
|
|
150
|
+
expect(pill.style.opacity).toBe("");
|
|
151
|
+
expect(pill.className).not.toMatch(/\bopacity-/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("does not stamp the ghost frame when the transition is not excluded", () => {
|
|
155
|
+
render(<ProcessTransitionEdge {...makeEdgeProps()} />);
|
|
156
|
+
const pill = screen.getByRole("button", { name: "12×" });
|
|
157
|
+
expect(pill.className).not.toMatch(/\bborder-dashed\b/);
|
|
158
|
+
expect(pill).not.toHaveAttribute("data-selection");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProcessTransitionEdge — one directly-follows relation on the process map (RM-051).
|
|
5
|
+
*
|
|
6
|
+
* ONE registered edge type that dispatches to the three shapes `@elabs-ai/components-flow`
|
|
7
|
+
* already owns, and authors none of them:
|
|
8
|
+
*
|
|
9
|
+
* | `data` | renders as | non-colour cue |
|
|
10
|
+
* | --------------------- | ---------------------------------------------- | -------------- |
|
|
11
|
+
* | `isSelfLoop: true` | `FlowSelfLoopEdge` | a closed arc above the node |
|
|
12
|
+
* | `isBackEdge: true` | `FlowWeightedEdge` `variant="back"` | dashed, routed clear of the forward edge |
|
|
13
|
+
* | otherwise | `FlowWeightedEdge` `variant="forward"` | the plain bezier |
|
|
14
|
+
*
|
|
15
|
+
* There is no `BaseEdge` here and no authored `<path>`: an edge is a `flow` primitive, and
|
|
16
|
+
* a layer-3 composite that draws its own would be a second component library
|
|
17
|
+
* (`pnpm process:reuse:check`).
|
|
18
|
+
*
|
|
19
|
+
* ## Why the metric is never carried by colour alone
|
|
20
|
+
*
|
|
21
|
+
* The delegate gets `weight` (stroke width, min-maxed to `[1.5, 8]` px against every other
|
|
22
|
+
* edge in the flow) AND `label` (a printed pill with the value in it). `value`/`valueDomain`
|
|
23
|
+
* tint the stroke as a third, redundant channel. Two edges with different values therefore
|
|
24
|
+
* differ in thickness and in printed text before they differ in hue — which matters
|
|
25
|
+
* because two open flow-layer defects (#321, #297) can currently collapse or invert that
|
|
26
|
+
* hue, and this surface is designed not to depend on it.
|
|
27
|
+
*
|
|
28
|
+
* ## Hover and selection
|
|
29
|
+
*
|
|
30
|
+
* Hovering an activity raises the opacity contrast between its incident edges and the
|
|
31
|
+
* rest; the selection tri-state dims an excluded edge. Both are opacity changes on a
|
|
32
|
+
* wrapper `<g>` — never a hue swap — and both leave the delegate's own `selected` ring
|
|
33
|
+
* alone.
|
|
34
|
+
*
|
|
35
|
+
* ## The label pill does not inherit this `<g>`'s opacity (#351)
|
|
36
|
+
*
|
|
37
|
+
* `EdgeLabelRenderer` portals the pill out from under this `<g>` into a sibling HTML layer,
|
|
38
|
+
* so CSS `opacity` on the `<g>` never reaches it — an excluded edge used to fade to
|
|
39
|
+
* {@link GHOST_OPACITY} while its own label pill stayed at full strength, the one part of
|
|
40
|
+
* the ghosting rung a filter could never actually dim. Rather than matching the pill's
|
|
41
|
+
* OWN opacity to the edge's (which would dim the pill's TEXT the same way #352 found
|
|
42
|
+
* failing 4.5:1 on the activity node — the pill's label sits on `bg-flow-node` at the same
|
|
43
|
+
* high-contrast rung as a node's title), the pill is reached explicitly, by DATA, with a
|
|
44
|
+
* non-opacity treatment: `labelProps` (a generic pass-through on
|
|
45
|
+
* `FlowWeightedEdgeData`/`FlowSelfLoopEdgeData`, mirroring `EdgeLabelPill`'s own new
|
|
46
|
+
* `className`/`...props`) carries a dashed border and a `data-selection="excluded"`
|
|
47
|
+
* attribute straight onto the pill's root button — visible, non-text, reachable by a
|
|
48
|
+
* `[data-selection="excluded"]` selector same as the node and the edge, and never lower
|
|
49
|
+
* than 4.5:1 because the label text itself is untouched.
|
|
50
|
+
*/
|
|
51
|
+
import { useMemo } from "react";
|
|
52
|
+
import type { EdgeProps } from "@xyflow/react";
|
|
53
|
+
import {
|
|
54
|
+
FlowSelfLoopEdge,
|
|
55
|
+
FlowWeightedEdge,
|
|
56
|
+
type FlowSelfLoopEdgeData,
|
|
57
|
+
type FlowWeightedEdgeData,
|
|
58
|
+
} from "@elabs-ai/components-flow";
|
|
59
|
+
import { useProcessMapEdgeKeys, useProcessMapHover } from "./process-map-context";
|
|
60
|
+
import { GHOST_OPACITY, type ProcessMapEdge } from "./map-model";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The `scaleGroup` every process-map edge shares, so `computeEdgeWeightScale` min-maxes
|
|
64
|
+
* the whole map against ONE domain — the `Legend variant="scale"` beside it reads that
|
|
65
|
+
* same domain, which is what stops the key and the picture from disagreeing.
|
|
66
|
+
*/
|
|
67
|
+
export const PROCESS_MAP_EDGE_SCALE_GROUP = "process-map";
|
|
68
|
+
|
|
69
|
+
/** Opacity of an edge that is neither hovered-incident nor selection-excluded. */
|
|
70
|
+
const RESTING_OPACITY = 1;
|
|
71
|
+
/** Opacity of an edge that is not incident to the hovered activity. */
|
|
72
|
+
const UNRELATED_OPACITY = 0.25;
|
|
73
|
+
|
|
74
|
+
/** The pill's own ghost treatment — a dashed frame + a reachable `data-selection`, never an
|
|
75
|
+
* opacity (see this file's own docblock, and {@link GHOST_OPACITY}'s, for why). */
|
|
76
|
+
const EXCLUDED_LABEL_PROPS = { className: "border-dashed", "data-selection": "excluded" } as const;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Branded process-map transition edge. Register it in
|
|
80
|
+
* `edgeTypes={{ "process-transition": ProcessTransitionEdge }}`; build edges with
|
|
81
|
+
* `buildProcessMapModel`.
|
|
82
|
+
*/
|
|
83
|
+
export function ProcessTransitionEdge(props: EdgeProps<ProcessMapEdge>) {
|
|
84
|
+
const { data } = props;
|
|
85
|
+
const hover = useProcessMapHover();
|
|
86
|
+
const onEdgeKey = useProcessMapEdgeKeys();
|
|
87
|
+
const isExcluded = data?.selectionState === "excluded";
|
|
88
|
+
const labelProps = isExcluded ? EXCLUDED_LABEL_PROPS : undefined;
|
|
89
|
+
|
|
90
|
+
const weightedData = useMemo<FlowWeightedEdgeData>(
|
|
91
|
+
() => ({
|
|
92
|
+
weight: data?.weight,
|
|
93
|
+
scaleGroup: PROCESS_MAP_EDGE_SCALE_GROUP,
|
|
94
|
+
value: data?.value,
|
|
95
|
+
valueDomain: data?.valueDomain,
|
|
96
|
+
label: data?.label,
|
|
97
|
+
secondaryLabel: data?.secondaryLabel,
|
|
98
|
+
variant: data?.isBackEdge ? "back" : "forward",
|
|
99
|
+
labelProps,
|
|
100
|
+
}),
|
|
101
|
+
[
|
|
102
|
+
data?.weight,
|
|
103
|
+
data?.value,
|
|
104
|
+
data?.valueDomain,
|
|
105
|
+
data?.label,
|
|
106
|
+
data?.secondaryLabel,
|
|
107
|
+
data?.isBackEdge,
|
|
108
|
+
labelProps,
|
|
109
|
+
],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const selfLoopData = useMemo<FlowSelfLoopEdgeData>(
|
|
113
|
+
() => ({
|
|
114
|
+
weight: data?.weight,
|
|
115
|
+
scaleGroup: PROCESS_MAP_EDGE_SCALE_GROUP,
|
|
116
|
+
label: data?.label,
|
|
117
|
+
secondaryLabel: data?.secondaryLabel,
|
|
118
|
+
labelProps,
|
|
119
|
+
}),
|
|
120
|
+
[data?.weight, data?.label, data?.secondaryLabel, labelProps],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const opacity = isExcluded
|
|
124
|
+
? GHOST_OPACITY
|
|
125
|
+
: hover.activityId !== null && !hover.incidentEdgeIds.has(props.id)
|
|
126
|
+
? UNRELATED_OPACITY
|
|
127
|
+
: RESTING_OPACITY;
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<g
|
|
131
|
+
data-slot="process-transition-edge"
|
|
132
|
+
data-shape={data?.isSelfLoop ? "self-loop" : data?.isBackEdge ? "back" : "forward"}
|
|
133
|
+
data-selection={data?.selectionState}
|
|
134
|
+
data-incident={hover.incidentEdgeIds.has(props.id) ? "true" : undefined}
|
|
135
|
+
className="transition-opacity duration-fast ease-standard motion-reduce:transition-none"
|
|
136
|
+
style={{ opacity }}
|
|
137
|
+
// The label pill is portalled out of this `<g>` by `EdgeLabelRenderer`, so it has no
|
|
138
|
+
// `[data-id]` ancestor the map's root handler could read — but a portal's events
|
|
139
|
+
// still bubble up the REACT tree, through here, where the edge id is known. This is
|
|
140
|
+
// what keeps `Enter` (select) and `f` (filter menu) working on a transition now that
|
|
141
|
+
// the edge itself is not a tab stop and the pill is the only stop on the arrow.
|
|
142
|
+
onKeyDown={(event) => onEdgeKey(props.id, event)}
|
|
143
|
+
>
|
|
144
|
+
{data?.isSelfLoop ? (
|
|
145
|
+
<FlowSelfLoopEdge {...props} type="self-loop" data={selfLoopData} />
|
|
146
|
+
) : (
|
|
147
|
+
<FlowWeightedEdge {...props} type="weighted" data={weightedData} />
|
|
148
|
+
)}
|
|
149
|
+
</g>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import type { ActivityStats, DurationStats, ProcessGraph, TransitionStats } from "../core/types";
|
|
6
|
+
import { buildProcessMapModel, processGraphStructureKey } from "./map-model";
|
|
7
|
+
import {
|
|
8
|
+
applyLayoutSnapshot,
|
|
9
|
+
PROCESS_MAP_NODE_MOTION_CLASS,
|
|
10
|
+
useProcessLayout,
|
|
11
|
+
} from "./use-process-layout";
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
|
|
15
|
+
const EMPTY_DURATION: DurationStats = {
|
|
16
|
+
min: 0,
|
|
17
|
+
max: 0,
|
|
18
|
+
mean: 0,
|
|
19
|
+
median: 0,
|
|
20
|
+
p90: 0,
|
|
21
|
+
sum: 0,
|
|
22
|
+
trimmedMean: 0,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function activity(id: string, index: number, total: number): ActivityStats {
|
|
26
|
+
return {
|
|
27
|
+
id,
|
|
28
|
+
label: id,
|
|
29
|
+
instances: total - index,
|
|
30
|
+
cases: total - index,
|
|
31
|
+
isStart: index === 0,
|
|
32
|
+
isEnd: index === total - 1,
|
|
33
|
+
duration: { ...EMPTY_DURATION },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function transition(source: string, target: string, count: number): TransitionStats {
|
|
38
|
+
return {
|
|
39
|
+
source,
|
|
40
|
+
target,
|
|
41
|
+
count,
|
|
42
|
+
caseCount: count,
|
|
43
|
+
duration: { ...EMPTY_DURATION },
|
|
44
|
+
isSelfLoop: source === target,
|
|
45
|
+
isBackEdge: false,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A synthetic chain of `size` activities — the shape the 60-node budget is stated over. */
|
|
50
|
+
function chainGraph(size: number): ProcessGraph {
|
|
51
|
+
const ids = Array.from({ length: size }, (_, i) => `Activity ${i + 1}`);
|
|
52
|
+
const transitions: TransitionStats[] = [];
|
|
53
|
+
for (let i = 0; i < ids.length - 1; i += 1) {
|
|
54
|
+
transitions.push(transition(ids[i]!, ids[i + 1]!, size - i));
|
|
55
|
+
// A second, skipping path so the layout has real width to solve, not a straight line.
|
|
56
|
+
if (i + 2 < ids.length)
|
|
57
|
+
transitions.push(transition(ids[i]!, ids[i + 2]!, Math.max(1, size - i - 3)));
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
activities: ids.map((id, index) => activity(id, index, size)),
|
|
61
|
+
transitions,
|
|
62
|
+
startActivities: { [ids[0]!]: size },
|
|
63
|
+
endActivities: { [ids[ids.length - 1]!]: size },
|
|
64
|
+
totals: { cases: size, events: size * 2, variants: 2 },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const graph = chainGraph(60);
|
|
69
|
+
const structureKey = processGraphStructureKey(graph);
|
|
70
|
+
|
|
71
|
+
function modelFor(metric: "absolute" | "absolute_case") {
|
|
72
|
+
return buildProcessMapModel({ graph, metric: { node: metric, edge: metric } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("useProcessLayout", () => {
|
|
76
|
+
it("lays out once on mount, without waiting for the debounce", async () => {
|
|
77
|
+
const model = modelFor("absolute");
|
|
78
|
+
const { result } = renderHook(() =>
|
|
79
|
+
useProcessLayout({ nodes: model.nodes, edges: model.edges, structureKey, direction: "TB" }),
|
|
80
|
+
);
|
|
81
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(1));
|
|
82
|
+
expect(result.current.nodes).toHaveLength(graph.activities.length);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("positions every node — the layout is applied, not merely computed", async () => {
|
|
86
|
+
const model = modelFor("absolute");
|
|
87
|
+
const { result } = renderHook(() =>
|
|
88
|
+
useProcessLayout({ nodes: model.nodes, edges: model.edges, structureKey, direction: "TB" }),
|
|
89
|
+
);
|
|
90
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(1));
|
|
91
|
+
const distinct = new Set(result.current.nodes.map((n) => `${n.position.x}:${n.position.y}`));
|
|
92
|
+
expect(distinct.size).toBe(graph.activities.length);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does NOT re-run layoutFlow for a metric-only change (the cache-hit criterion)", async () => {
|
|
96
|
+
const first = modelFor("absolute");
|
|
97
|
+
// A short debounce, so the wait below is real rather than nominal. With the shipped
|
|
98
|
+
// 80 ms default, `layoutRuns` is read BEFORE an invalidated re-layout could have fired
|
|
99
|
+
// and would still read 1 with the cache key broken — the assertion looks stronger than
|
|
100
|
+
// it is. Shortening the debounce and then waiting past it is what makes BOTH halves of
|
|
101
|
+
// this test load-bearing rather than just the byte-identical positions.
|
|
102
|
+
const debounceMs = 10;
|
|
103
|
+
const { result, rerender } = renderHook(
|
|
104
|
+
({ nodes, edges }) =>
|
|
105
|
+
useProcessLayout({ nodes, edges, structureKey, direction: "TB", debounceMs }),
|
|
106
|
+
{ initialProps: { nodes: first.nodes, edges: first.edges } },
|
|
107
|
+
);
|
|
108
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(1));
|
|
109
|
+
const positionsBefore = result.current.nodes.map(
|
|
110
|
+
(n) => `${n.id}:${n.position.x},${n.position.y}`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// A metric switch produces entirely NEW node objects with the SAME structure.
|
|
114
|
+
const second = modelFor("absolute_case");
|
|
115
|
+
expect(second.nodes[0]).not.toBe(first.nodes[0]);
|
|
116
|
+
rerender({ nodes: second.nodes, edges: second.edges });
|
|
117
|
+
|
|
118
|
+
await waitFor(() =>
|
|
119
|
+
expect(result.current.nodes[0]!.data.metricLabel).toBe(second.nodes[0]!.data.metricLabel),
|
|
120
|
+
);
|
|
121
|
+
await new Promise((resolve) => setTimeout(resolve, debounceMs * 6));
|
|
122
|
+
expect(result.current.layoutRuns).toBe(1);
|
|
123
|
+
expect(result.current.nodes.map((n) => `${n.id}:${n.position.x},${n.position.y}`)).toEqual(
|
|
124
|
+
positionsBefore,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("re-runs layout when the STRUCTURE changes, after the debounce", async () => {
|
|
129
|
+
const first = modelFor("absolute");
|
|
130
|
+
const smallerGraph = chainGraph(12);
|
|
131
|
+
const smaller = buildProcessMapModel({
|
|
132
|
+
graph: smallerGraph,
|
|
133
|
+
metric: { node: "absolute", edge: "absolute" },
|
|
134
|
+
});
|
|
135
|
+
const { result, rerender } = renderHook(
|
|
136
|
+
({ nodes, edges, key }) =>
|
|
137
|
+
useProcessLayout({ nodes, edges, structureKey: key, direction: "TB", debounceMs: 10 }),
|
|
138
|
+
{ initialProps: { nodes: first.nodes, edges: first.edges, key: structureKey } },
|
|
139
|
+
);
|
|
140
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(1));
|
|
141
|
+
|
|
142
|
+
rerender({
|
|
143
|
+
nodes: smaller.nodes,
|
|
144
|
+
edges: smaller.edges,
|
|
145
|
+
key: processGraphStructureKey(smallerGraph),
|
|
146
|
+
});
|
|
147
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(2));
|
|
148
|
+
expect(result.current.nodes).toHaveLength(12);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("re-runs layout for a direction change, and caches each direction separately", async () => {
|
|
152
|
+
const model = modelFor("absolute");
|
|
153
|
+
const { result, rerender } = renderHook(
|
|
154
|
+
({ direction }: { direction: "TB" | "LR" }) =>
|
|
155
|
+
useProcessLayout({
|
|
156
|
+
nodes: model.nodes,
|
|
157
|
+
edges: model.edges,
|
|
158
|
+
structureKey,
|
|
159
|
+
direction,
|
|
160
|
+
debounceMs: 0,
|
|
161
|
+
}),
|
|
162
|
+
{ initialProps: { direction: "TB" as "TB" | "LR" } },
|
|
163
|
+
);
|
|
164
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(1));
|
|
165
|
+
rerender({ direction: "LR" });
|
|
166
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(2));
|
|
167
|
+
rerender({ direction: "TB" });
|
|
168
|
+
// Back to a key already in the cache — no third dagre run.
|
|
169
|
+
await waitFor(() => expect(result.current.nodes.length).toBe(graph.activities.length));
|
|
170
|
+
expect(result.current.layoutRuns).toBe(2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("lays a 60-node graph out in under 200 ms", async () => {
|
|
174
|
+
const model = modelFor("absolute");
|
|
175
|
+
const { result } = renderHook(() =>
|
|
176
|
+
useProcessLayout({ nodes: model.nodes, edges: model.edges, structureKey, direction: "TB" }),
|
|
177
|
+
);
|
|
178
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(1));
|
|
179
|
+
expect(graph.activities).toHaveLength(60);
|
|
180
|
+
expect(result.current.lastLayoutMs).toBeLessThan(200);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("reports back-edges and self-loops so the edge component can change SHAPE", async () => {
|
|
184
|
+
const withLoop: ProcessGraph = {
|
|
185
|
+
...graph,
|
|
186
|
+
transitions: [
|
|
187
|
+
...graph.transitions,
|
|
188
|
+
transition("Activity 3", "Activity 3", 4),
|
|
189
|
+
transition("Activity 5", "Activity 2", 3),
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
const model = buildProcessMapModel({
|
|
193
|
+
graph: withLoop,
|
|
194
|
+
metric: { node: "absolute", edge: "absolute" },
|
|
195
|
+
});
|
|
196
|
+
const { result } = renderHook(() =>
|
|
197
|
+
useProcessLayout({
|
|
198
|
+
nodes: model.nodes,
|
|
199
|
+
edges: model.edges,
|
|
200
|
+
structureKey: processGraphStructureKey(withLoop),
|
|
201
|
+
direction: "TB",
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
await waitFor(() => expect(result.current.layoutRuns).toBe(1));
|
|
205
|
+
expect(result.current.selfLoopIds.size).toBeGreaterThan(0);
|
|
206
|
+
expect(result.current.backEdgeIds.size).toBeGreaterThan(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("renders an empty graph without laying anything out", async () => {
|
|
210
|
+
const { result } = renderHook(() =>
|
|
211
|
+
useProcessLayout({ nodes: [], edges: [], structureKey: "", direction: "TB" }),
|
|
212
|
+
);
|
|
213
|
+
await act(async () => {});
|
|
214
|
+
expect(result.current.layoutRuns).toBe(0);
|
|
215
|
+
expect(result.current.pending).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("applyLayoutSnapshot", () => {
|
|
220
|
+
it("matches positions on id, so fresh node objects keep their place", () => {
|
|
221
|
+
const model = modelFor("absolute");
|
|
222
|
+
const snapshot = {
|
|
223
|
+
positions: { [model.nodes[0]!.id]: { x: 42, y: 99 } },
|
|
224
|
+
sourcePosition: {},
|
|
225
|
+
targetPosition: {},
|
|
226
|
+
backEdges: [],
|
|
227
|
+
selfLoops: [],
|
|
228
|
+
durationMs: 0,
|
|
229
|
+
};
|
|
230
|
+
const applied = applyLayoutSnapshot(model.nodes, snapshot);
|
|
231
|
+
expect(applied[0]!.position).toEqual({ x: 42, y: 99 });
|
|
232
|
+
// A node the snapshot does not know is returned untouched, not dropped.
|
|
233
|
+
expect(applied).toHaveLength(model.nodes.length);
|
|
234
|
+
expect(applied[1]).toBe(model.nodes[1]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("PROCESS_MAP_NODE_MOTION_CLASS is scanner-visible", () => {
|
|
239
|
+
// Regression lock (#401 review): the class was once assembled by interpolating a
|
|
240
|
+
// `MOTION_TARGET` constant. Tailwind extracts candidates from source TEXT and never
|
|
241
|
+
// evaluates JavaScript, so no CSS was emitted and the node-slide animation silently
|
|
242
|
+
// stopped — with every test, story and typecheck still green. Asserting the runtime
|
|
243
|
+
// string is not enough; the SOURCE has to carry each full utility.
|
|
244
|
+
// The file's own TEXT, which is what Tailwind's scanner reads. `__dirname` is how the
|
|
245
|
+
// other source-reading tests in this repo reach a sibling file (see the tokens package).
|
|
246
|
+
const SOURCE = readFileSync(join(__dirname, "use-process-layout.ts"), "utf8");
|
|
247
|
+
const SELECTOR = "[&_div[data-id]:not([data-handlepos])]";
|
|
248
|
+
|
|
249
|
+
it.each([
|
|
250
|
+
`${SELECTOR}:transition-transform`,
|
|
251
|
+
`${SELECTOR}:duration-base`,
|
|
252
|
+
`${SELECTOR}:ease-standard`,
|
|
253
|
+
`motion-reduce:${SELECTOR}:transition-none`,
|
|
254
|
+
])("%s appears verbatim in the source Tailwind scans", (utility) => {
|
|
255
|
+
expect(PROCESS_MAP_NODE_MOTION_CLASS).toContain(utility);
|
|
256
|
+
expect(SOURCE).toContain(utility);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("builds the class from literals only — no interpolation in its definition", () => {
|
|
260
|
+
const definition = SOURCE.slice(
|
|
261
|
+
SOURCE.indexOf("export const PROCESS_MAP_NODE_MOTION_CLASS"),
|
|
262
|
+
).split(";")[0];
|
|
263
|
+
expect(definition).not.toMatch(/\$\{/);
|
|
264
|
+
});
|
|
265
|
+
});
|