@elabs-ai/components-process 4.1.0 → 5.0.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/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- package/src/violation-list/violation-list.tsx +259 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, fireEvent, fn, userEvent, waitFor } from "storybook/test";
|
|
3
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
4
|
+
import { extractVariants } from "../core/extract-variants";
|
|
5
|
+
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
6
|
+
import { segmentKey } from "../core/segments";
|
|
7
|
+
import { PerformanceSpectrum } from "./performance-spectrum";
|
|
8
|
+
|
|
9
|
+
const log = generateSyntheticLog({ cases: 400, seed: 42 });
|
|
10
|
+
const graph = discoverGraph(log);
|
|
11
|
+
const [topVariant] = extractVariants(log);
|
|
12
|
+
const busiest = graph.transitions[0]!;
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: "Process/PerformanceSpectrum",
|
|
16
|
+
component: PerformanceSpectrum,
|
|
17
|
+
tags: ["autodocs"],
|
|
18
|
+
parameters: {
|
|
19
|
+
layout: "padded",
|
|
20
|
+
docs: {
|
|
21
|
+
description: {
|
|
22
|
+
component:
|
|
23
|
+
"ProM’s performance spectrum (§4 R15): one row per segment, one line per case from " +
|
|
24
|
+
"the moment it left `from` (row top) to the moment it reached `to` (row bottom), on a " +
|
|
25
|
+
"shared time axis. Steep lines are fast, parallel lines are FIFO, crossings overtake, " +
|
|
26
|
+
"and bundles converging on an instant are batches. Lines paint on the charts " +
|
|
27
|
+
"`CanvasLayer`; colour is the per-segment duration quartile and is redundant with " +
|
|
28
|
+
"the line’s own slope. Drag across the time axis to emit a `cases` filter intent.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
args: { log },
|
|
33
|
+
decorators: [
|
|
34
|
+
(Story) => (
|
|
35
|
+
<div className="w-full max-w-5xl">
|
|
36
|
+
<Story />
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
],
|
|
40
|
+
} satisfies Meta<typeof PerformanceSpectrum>;
|
|
41
|
+
export default meta;
|
|
42
|
+
type Story = StoryObj<typeof meta>;
|
|
43
|
+
|
|
44
|
+
/** The busiest segments of the log, one line per occurrence. Brush the axis to filter. */
|
|
45
|
+
export const TopSegments: Story = {
|
|
46
|
+
args: { order: "frequency", segmentLimit: 6, onFilterIntent: fn() },
|
|
47
|
+
play: async ({ args, canvas }) => {
|
|
48
|
+
const axis = canvas.getByRole("group", { name: "Time range" });
|
|
49
|
+
const rect = axis.getBoundingClientRect();
|
|
50
|
+
const y = rect.top + rect.height / 2;
|
|
51
|
+
fireEvent.pointerDown(axis, {
|
|
52
|
+
pointerId: 1,
|
|
53
|
+
clientX: rect.left + rect.width * 0.2,
|
|
54
|
+
clientY: y,
|
|
55
|
+
});
|
|
56
|
+
fireEvent.pointerMove(axis, {
|
|
57
|
+
pointerId: 1,
|
|
58
|
+
clientX: rect.left + rect.width * 0.4,
|
|
59
|
+
clientY: y,
|
|
60
|
+
});
|
|
61
|
+
fireEvent.pointerUp(axis, { pointerId: 1, clientX: rect.left + rect.width * 0.5, clientY: y });
|
|
62
|
+
await waitFor(() => expect(args.onFilterIntent).toHaveBeenCalledTimes(1));
|
|
63
|
+
const [intent] = (args.onFilterIntent as ReturnType<typeof fn>).mock.calls[0] as [
|
|
64
|
+
{ kind: string; ids: string[] },
|
|
65
|
+
];
|
|
66
|
+
await expect(intent.kind).toBe("cases");
|
|
67
|
+
await expect(intent.ids.length).toBeGreaterThan(0);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The rows follow the most frequent variant's path, in order. Keyboard: Tab row by row. */
|
|
72
|
+
export const VariantPath: Story = {
|
|
73
|
+
args: { order: { variantId: topVariant!.id }, onCaseSelect: fn() },
|
|
74
|
+
play: async ({ args, canvas }) => {
|
|
75
|
+
const rows = canvas.getAllByRole("button", { name: /→/ });
|
|
76
|
+
await expect(rows.length).toBeGreaterThan(1);
|
|
77
|
+
rows[0]!.focus();
|
|
78
|
+
await userEvent.keyboard("{ArrowRight}");
|
|
79
|
+
await userEvent.keyboard("{Enter}");
|
|
80
|
+
await waitFor(() => expect(args.onCaseSelect).toHaveBeenCalledTimes(1));
|
|
81
|
+
await userEvent.tab();
|
|
82
|
+
await expect(rows[1]).toHaveFocus();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** One bar per day per segment — height is cases entering, fill the median quartile. */
|
|
87
|
+
export const Aggregated: Story = {
|
|
88
|
+
args: { mode: "aggregated", segmentLimit: 6, binSize: 86_400_000 },
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** The selected transition's row carries a rail and says “selected” to assistive tech. */
|
|
92
|
+
export const WithSelection: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
segmentLimit: 6,
|
|
95
|
+
selection: { kind: "transition", id: segmentKey(busiest.source, busiest.target) },
|
|
96
|
+
onFilterIntent: fn(),
|
|
97
|
+
},
|
|
98
|
+
play: async ({ args, canvas }) => {
|
|
99
|
+
await expect(
|
|
100
|
+
canvas.getByRole("group", { name: `${busiest.source} → ${busiest.target}, selected` }),
|
|
101
|
+
).toBeInTheDocument();
|
|
102
|
+
// Keyboard brush: jump to the end, extend back with Shift+PageDown, commit with Enter.
|
|
103
|
+
const axis = canvas.getByRole("group", { name: "Time range" });
|
|
104
|
+
axis.focus();
|
|
105
|
+
await userEvent.keyboard("{End}{Shift>}{PageDown}{PageDown}{/Shift}{Enter}");
|
|
106
|
+
await waitFor(() => expect(args.onFilterIntent).toHaveBeenCalledTimes(1));
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The accessible table twin: cases, median and p90 duration per segment. */
|
|
111
|
+
export const TableView: Story = {
|
|
112
|
+
args: { segmentLimit: 6, tableView: true },
|
|
113
|
+
play: async ({ canvas }) => {
|
|
114
|
+
const table = canvas.getByRole("table");
|
|
115
|
+
await expect(table).toBeInTheDocument();
|
|
116
|
+
await expect(canvas.getAllByRole("row")).toHaveLength(7);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** Data not ready yet. */
|
|
121
|
+
export const Loading: Story = {
|
|
122
|
+
args: { loading: true },
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The chosen segments never occur in the log. */
|
|
126
|
+
export const Empty: Story = {
|
|
127
|
+
args: { order: [{ from: "Nowhere", to: "Never" }] },
|
|
128
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
5
|
+
import { extractVariants } from "../core/extract-variants";
|
|
6
|
+
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
7
|
+
import { segmentKey } from "../core/segments";
|
|
8
|
+
import type { EventLog, EventRow } from "../core/types";
|
|
9
|
+
import { PerformanceSpectrum } from "./performance-spectrum";
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup);
|
|
12
|
+
|
|
13
|
+
// jsdom has no PointerEvent, so `fireEvent.pointer*` would build a bare Event with no
|
|
14
|
+
// `clientX`/`pointerId`. A MouseEvent subclass carrying `pointerId` is enough here.
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
if (typeof window.PointerEvent === "undefined") {
|
|
17
|
+
class PointerEventPolyfill extends MouseEvent {
|
|
18
|
+
pointerId: number;
|
|
19
|
+
constructor(type: string, init: PointerEventInit = {}) {
|
|
20
|
+
super(type, init);
|
|
21
|
+
this.pointerId = init.pointerId ?? 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
window.PointerEvent = PointerEventPolyfill as unknown as typeof PointerEvent;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const H = 3_600_000;
|
|
29
|
+
|
|
30
|
+
function kase(caseId: string, offset: number, waits: [number, number]): EventRow[] {
|
|
31
|
+
const t0 = offset * H;
|
|
32
|
+
return [
|
|
33
|
+
{ caseId, activity: "A", timestamp: t0 },
|
|
34
|
+
{ caseId, activity: "B", timestamp: t0 + waits[0] * H },
|
|
35
|
+
{ caseId, activity: "C", timestamp: t0 + (waits[0] + waits[1]) * H },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Four cases A → B → C, starting an hour apart; domain is 0 h … 10 h. */
|
|
40
|
+
const LOG: EventLog = {
|
|
41
|
+
events: [
|
|
42
|
+
...kase("c1", 0, [1, 1]),
|
|
43
|
+
...kase("c2", 1, [1, 2]),
|
|
44
|
+
...kase("c3", 2, [2, 2]),
|
|
45
|
+
...kase("c4", 6, [2, 2]),
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ORDER = [
|
|
50
|
+
{ from: "A", to: "B" },
|
|
51
|
+
{ from: "B", to: "C" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
describe("PerformanceSpectrum — rendering", () => {
|
|
55
|
+
it("renders one named canvas row per segment, with a parallel summary", () => {
|
|
56
|
+
render(<PerformanceSpectrum log={LOG} order={ORDER} />);
|
|
57
|
+
const view = screen.getByRole("group", { name: "Performance spectrum" });
|
|
58
|
+
expect(within(view).getByRole("group", { name: "A → B" })).toBeInTheDocument();
|
|
59
|
+
expect(within(view).getByRole("group", { name: "B → C" })).toHaveAccessibleDescription(
|
|
60
|
+
/4 occurrences across 4 cases/,
|
|
61
|
+
);
|
|
62
|
+
// One keyboard cursor per row: Tab moves row by row.
|
|
63
|
+
expect(view.querySelectorAll('[data-slot="canvas-layer-cursor"]')).toHaveLength(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("defaults to the busiest transitions and honours segmentLimit", () => {
|
|
67
|
+
const log = generateSyntheticLog({ cases: 60, seed: 5 });
|
|
68
|
+
const graph = discoverGraph(log);
|
|
69
|
+
const { container } = render(<PerformanceSpectrum log={log} segmentLimit={3} />);
|
|
70
|
+
const rows = container.querySelectorAll('[data-slot="performance-spectrum-row"]');
|
|
71
|
+
expect(rows).toHaveLength(3);
|
|
72
|
+
const first = graph.transitions[0]!;
|
|
73
|
+
expect(
|
|
74
|
+
screen.getByRole("group", { name: `${first.source} → ${first.target}` }),
|
|
75
|
+
).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("derives rows from one variant's path", () => {
|
|
79
|
+
const [variant] = extractVariants(LOG);
|
|
80
|
+
render(<PerformanceSpectrum log={LOG} order={{ variantId: variant!.id }} />);
|
|
81
|
+
expect(screen.getByRole("group", { name: "A → B" })).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByRole("group", { name: "B → C" })).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("marks a row touched by the selection in its accessible name", () => {
|
|
86
|
+
render(
|
|
87
|
+
<PerformanceSpectrum
|
|
88
|
+
log={LOG}
|
|
89
|
+
order={ORDER}
|
|
90
|
+
selection={{ kind: "transition", id: segmentKey("B", "C") }}
|
|
91
|
+
/>,
|
|
92
|
+
);
|
|
93
|
+
expect(screen.getByRole("group", { name: "B → C, selected" })).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByRole("group", { name: "A → B" })).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders the loading and empty panels", () => {
|
|
98
|
+
const { rerender, container } = render(<PerformanceSpectrum log={LOG} loading />);
|
|
99
|
+
expect(container.querySelector('[data-state="loading"]')).not.toBeNull();
|
|
100
|
+
rerender(<PerformanceSpectrum log={LOG} order={[{ from: "C", to: "A" }]} />);
|
|
101
|
+
expect(screen.getByText("No segment occurrences")).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders the table twin with cases, median and p90 per segment", () => {
|
|
105
|
+
render(<PerformanceSpectrum log={LOG} order={ORDER} tableView />);
|
|
106
|
+
const table = screen.getByRole("table");
|
|
107
|
+
const rows = within(table).getAllByRole("row");
|
|
108
|
+
expect(rows).toHaveLength(3);
|
|
109
|
+
expect(rows[1]).toHaveTextContent("A → B");
|
|
110
|
+
expect(rows[1]).toHaveTextContent("4");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("renders aggregated mode as one cursor stop per row over bins", () => {
|
|
114
|
+
const { container } = render(
|
|
115
|
+
<PerformanceSpectrum log={LOG} order={ORDER} mode="aggregated" binSize={2 * H} />,
|
|
116
|
+
);
|
|
117
|
+
expect(container.querySelector('[data-mode="aggregated"]')).not.toBeNull();
|
|
118
|
+
expect(container.querySelectorAll('[data-slot="canvas-layer-cursor"]')).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("PerformanceSpectrum — interaction", () => {
|
|
123
|
+
function mockTrack(el: Element) {
|
|
124
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
125
|
+
left: 0,
|
|
126
|
+
top: 0,
|
|
127
|
+
width: 1000,
|
|
128
|
+
height: 32,
|
|
129
|
+
right: 1000,
|
|
130
|
+
bottom: 32,
|
|
131
|
+
x: 0,
|
|
132
|
+
y: 0,
|
|
133
|
+
toJSON: () => ({}),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
it("brushing the time axis emits one cases intent with every overlapping case", () => {
|
|
138
|
+
const onFilterIntent = vi.fn();
|
|
139
|
+
render(<PerformanceSpectrum log={LOG} order={ORDER} onFilterIntent={onFilterIntent} />);
|
|
140
|
+
const axis = screen.getByRole("group", { name: "Time range" });
|
|
141
|
+
mockTrack(axis);
|
|
142
|
+
|
|
143
|
+
// Domain 0 h … 10 h over 1000 px: drag 0 → 150 px covers 0 h … 1.5 h.
|
|
144
|
+
fireEvent.pointerDown(axis, { pointerId: 1, clientX: 0 });
|
|
145
|
+
fireEvent.pointerMove(axis, { pointerId: 1, clientX: 100 });
|
|
146
|
+
fireEvent.pointerUp(axis, { pointerId: 1, clientX: 150 });
|
|
147
|
+
|
|
148
|
+
expect(onFilterIntent).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(onFilterIntent).toHaveBeenCalledWith({ kind: "cases", ids: ["c1", "c2"] });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("a click without travel clears instead of filtering", () => {
|
|
153
|
+
const onFilterIntent = vi.fn();
|
|
154
|
+
render(<PerformanceSpectrum log={LOG} order={ORDER} onFilterIntent={onFilterIntent} />);
|
|
155
|
+
const axis = screen.getByRole("group", { name: "Time range" });
|
|
156
|
+
mockTrack(axis);
|
|
157
|
+
fireEvent.pointerDown(axis, { pointerId: 1, clientX: 400 });
|
|
158
|
+
fireEvent.pointerUp(axis, { pointerId: 1, clientX: 401 });
|
|
159
|
+
expect(onFilterIntent).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("brushes from the keyboard with Shift+Arrow and Enter", () => {
|
|
163
|
+
const onFilterIntent = vi.fn();
|
|
164
|
+
render(<PerformanceSpectrum log={LOG} order={ORDER} onFilterIntent={onFilterIntent} />);
|
|
165
|
+
const axis = screen.getByRole("group", { name: "Time range" });
|
|
166
|
+
act(() => axis.focus());
|
|
167
|
+
// End jumps to 10 h; Shift+PageDown ×2 extends back 2.5 h to 7.5 h.
|
|
168
|
+
fireEvent.keyDown(axis, { key: "End" });
|
|
169
|
+
fireEvent.keyDown(axis, { key: "PageDown", shiftKey: true });
|
|
170
|
+
fireEvent.keyDown(axis, { key: "PageDown", shiftKey: true });
|
|
171
|
+
const status = document.querySelector('[data-slot="performance-spectrum-brush-status"]');
|
|
172
|
+
expect(status?.textContent).toMatch(/1 cases$/);
|
|
173
|
+
fireEvent.keyDown(axis, { key: "Enter" });
|
|
174
|
+
expect(onFilterIntent).toHaveBeenCalledWith({ kind: "cases", ids: ["c4"] });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("activating a row's cursor selects the focused occurrence's case", () => {
|
|
178
|
+
const onCaseSelect = vi.fn();
|
|
179
|
+
const { container } = render(
|
|
180
|
+
<PerformanceSpectrum log={LOG} order={ORDER} onCaseSelect={onCaseSelect} />,
|
|
181
|
+
);
|
|
182
|
+
const cursor = container.querySelector(
|
|
183
|
+
'[data-slot="canvas-layer-cursor"]',
|
|
184
|
+
) as HTMLButtonElement;
|
|
185
|
+
act(() => cursor.focus());
|
|
186
|
+
fireEvent.keyDown(cursor, { key: "ArrowRight" });
|
|
187
|
+
fireEvent.click(cursor);
|
|
188
|
+
expect(onCaseSelect).toHaveBeenCalledWith("c2", expect.objectContaining({ caseId: "c2" }));
|
|
189
|
+
});
|
|
190
|
+
});
|