@elabs-ai/components-process 4.2.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.
Files changed (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -57,6 +57,25 @@ describe("assertProcessContract", () => {
57
57
  ).toThrow(ProcessContractError);
58
58
  });
59
59
 
60
+ // DottedChart — RM-059
61
+ it("passes for a non-empty log with parsable timestamps, and throws otherwise", () => {
62
+ const spec: ProcessContractSpec = { dataProp: "log" };
63
+ const row = { caseId: "c1", activity: "A", timestamp: "2026-01-05T09:00:00Z" };
64
+ expect(() =>
65
+ assertProcessContract("DottedChartDouble", { log: { events: [row] } }, spec),
66
+ ).not.toThrow();
67
+ expect(() => assertProcessContract("DottedChartDouble", { log: { events: [] } }, spec)).toThrow(
68
+ ProcessContractError,
69
+ );
70
+ expect(() =>
71
+ assertProcessContract(
72
+ "DottedChartDouble",
73
+ { log: { events: [{ ...row, timestamp: "nope" }] } },
74
+ spec,
75
+ ),
76
+ ).toThrow(/parsable timestamps/);
77
+ });
78
+
60
79
  it("throws when a required prop is undefined", () => {
61
80
  const spec: ProcessContractSpec = { dataProp: "graph", requiredProps: ["onSelectionChange"] };
62
81
  expect(() => assertProcessContract("ProcessMapDouble", { graph: emptyGraph }, spec)).toThrow(
@@ -65,6 +84,53 @@ describe("assertProcessContract", () => {
65
84
  });
66
85
  });
67
86
 
87
+ // PerformanceSpectrum — RM-060
88
+ describe("assertProcessContract — log + segment order", () => {
89
+ const LOG_SPEC: ProcessContractSpec = { dataProp: "log", segmentOrder: true };
90
+ const log = {
91
+ events: [
92
+ { caseId: "c1", activity: "A", timestamp: 0 },
93
+ { caseId: "c1", activity: "B", timestamp: 10 },
94
+ ],
95
+ };
96
+
97
+ it("throws when the log prop is not an EventLog", () => {
98
+ expect(() => assertProcessContract("PerformanceSpectrumDouble", {}, LOG_SPEC)).toThrow(
99
+ /"log" prop must be an EventLog/,
100
+ );
101
+ });
102
+
103
+ it("passes when the order resolves to a segment present in the log", () => {
104
+ expect(() =>
105
+ assertProcessContract("PerformanceSpectrumDouble", { log }, LOG_SPEC),
106
+ ).not.toThrow();
107
+ expect(() =>
108
+ assertProcessContract(
109
+ "PerformanceSpectrumDouble",
110
+ { log, order: [{ from: "A", to: "B" }] },
111
+ LOG_SPEC,
112
+ ),
113
+ ).not.toThrow();
114
+ });
115
+
116
+ it("throws when the order resolves to nothing in the log", () => {
117
+ expect(() =>
118
+ assertProcessContract(
119
+ "PerformanceSpectrumDouble",
120
+ { log, order: [{ from: "B", to: "A" }] },
121
+ LOG_SPEC,
122
+ ),
123
+ ).toThrow(/resolves to no segment/);
124
+ expect(() =>
125
+ assertProcessContract(
126
+ "PerformanceSpectrumDouble",
127
+ { log, order: { variantId: "x" } },
128
+ LOG_SPEC,
129
+ ),
130
+ ).toThrow(ProcessContractError);
131
+ });
132
+ });
133
+
68
134
  describe("buildProcessDoublePayload / readProcessDoubleProps round-trip", () => {
69
135
  it("carries the graph's activity count and the current selection", () => {
70
136
  const graph: ProcessGraph = { ...emptyGraph, activities: [...emptyGraph.activities] };
@@ -18,17 +18,37 @@
18
18
  * `vi.mock("@elabs-ai/components-process", () => import("@elabs-ai/components-process/test"))`
19
19
  * the way `@elabs-ai/components-charts` consumers do — that rename is out of this item's scope.
20
20
  */
21
- import type { ProcessGraph, Variant } from "../core/types";
21
+ import { toEpochMs } from "../core/event-log";
22
+ import { discoverGraph } from "../core/discover-graph";
23
+ import { extractVariants } from "../core/extract-variants";
24
+ import {
25
+ segmentOrderByFrequency,
26
+ segmentOrderForVariant,
27
+ segmentsFor,
28
+ type SegmentDefinition,
29
+ } from "../core/segments";
30
+ import type { ConformanceResult } from "../core/conformance";
31
+ import type { HappyPath } from "../core/reference-model";
32
+ import type { EventLog, ProcessGraph, Variant } from "../core/types";
22
33
 
23
34
  /** Selection carried by a process view's coordinated-selection contract (RM-068 completes it). */
24
35
  export type ProcessSelection = null | { kind: "node"; id: string } | { kind: "edge"; id: string };
25
36
 
26
37
  /** What {@link assertProcessContract} checks for one double. */
27
38
  export interface ProcessContractSpec {
28
- /** Name of the prop carrying the double's primary data payload. */
29
- dataProp: "graph" | "variants";
39
+ /**
40
+ * Name of the prop carrying the double's primary data payload. `conformance` is a
41
+ * `ConformanceResult` and `value` a `HappyPath` (RM-062).
42
+ */
43
+ dataProp: "graph" | "variants" | "log" | "conformance" | "value";
30
44
  /** Other props the real component requires; the double must not silently accept `undefined`. */
31
45
  requiredProps?: string[];
46
+ /**
47
+ * PerformanceSpectrum — RM-060: the `order` prop (default `"frequency"`) must resolve to
48
+ * at least one segment that actually occurs in `log`, or the real view renders only its
49
+ * empty panel — almost always a test wired to the wrong activity names.
50
+ */
51
+ segmentOrder?: boolean;
32
52
  }
33
53
 
34
54
  /** Thrown by {@link assertProcessContract} when a double is used with an invalid prop shape. */
@@ -48,6 +68,26 @@ function isProcessGraph(value: unknown): value is ProcessGraph {
48
68
  );
49
69
  }
50
70
 
71
+ function isEventLog(value: unknown): value is EventLog {
72
+ return !!value && typeof value === "object" && Array.isArray((value as EventLog).events);
73
+ }
74
+
75
+ /** How many occurrences `order` (as `PerformanceSpectrum` reads it) finds in `log`. */
76
+ function countSpectrumOccurrences(log: EventLog, order: unknown, limit: unknown): number {
77
+ const cap = typeof limit === "number" ? Math.max(0, Math.floor(limit)) : 12;
78
+ let definitions: SegmentDefinition[] = [];
79
+ if (order === undefined || order === "frequency") {
80
+ definitions = segmentOrderByFrequency(discoverGraph(log), cap);
81
+ } else if (Array.isArray(order)) {
82
+ definitions = (order as SegmentDefinition[]).slice(0, cap);
83
+ } else if (order && typeof order === "object" && "variantId" in order) {
84
+ const id = (order as { variantId: unknown }).variantId;
85
+ const variant = extractVariants(log).find((v) => v.id === id);
86
+ definitions = variant ? segmentOrderForVariant(variant).slice(0, cap) : [];
87
+ }
88
+ return segmentsFor(log, definitions).length;
89
+ }
90
+
51
91
  function isVariantArray(value: unknown): value is Variant[] {
52
92
  return (
53
93
  Array.isArray(value) &&
@@ -55,6 +95,32 @@ function isVariantArray(value: unknown): value is Variant[] {
55
95
  );
56
96
  }
57
97
 
98
+ // DottedChart — RM-059
99
+ /** A non-empty event log whose every row carries a parsable timestamp. */
100
+ function isTimedEventLog(value: unknown): value is EventLog {
101
+ const events = (value as EventLog | null | undefined)?.events;
102
+ return (
103
+ Array.isArray(events) &&
104
+ events.length > 0 &&
105
+ events.every(
106
+ (row) => !!row && typeof row === "object" && Number.isFinite(toEpochMs(row.timestamp)),
107
+ )
108
+ );
109
+ }
110
+
111
+ function isConformanceResult(value: unknown): value is ConformanceResult {
112
+ return (
113
+ !!value &&
114
+ typeof value === "object" &&
115
+ Array.isArray((value as ConformanceResult).traces) &&
116
+ typeof (value as ConformanceResult).deviationCounts === "object"
117
+ );
118
+ }
119
+
120
+ function isHappyPath(value: unknown): value is HappyPath {
121
+ return !!value && typeof value === "object" && Array.isArray((value as HappyPath).steps);
122
+ }
123
+
58
124
  /**
59
125
  * Validate a double's props against its contract spec. Throws {@link ProcessContractError} on
60
126
  * a missing/invalid required prop — mirroring what the real component would fail on at
@@ -79,6 +145,35 @@ export function assertProcessContract(
79
145
  `"variants" prop must be a Variant[], got ${typeof data}`,
80
146
  );
81
147
  }
148
+ if (spec.dataProp === "log" && !isTimedEventLog(data)) {
149
+ throw new ProcessContractError(
150
+ componentName,
151
+ `"log" prop must be an EventLog with non-empty events and parsable timestamps`,
152
+ );
153
+ }
154
+ if (
155
+ spec.segmentOrder &&
156
+ isEventLog(data) &&
157
+ data.events.length > 0 &&
158
+ countSpectrumOccurrences(data, props.order, props.segmentLimit) === 0
159
+ ) {
160
+ throw new ProcessContractError(
161
+ componentName,
162
+ `"order" resolves to no segment that occurs in "log"`,
163
+ );
164
+ }
165
+ if (spec.dataProp === "conformance" && !isConformanceResult(data)) {
166
+ throw new ProcessContractError(
167
+ componentName,
168
+ `"conformance" prop must be a ConformanceResult, got ${typeof data}`,
169
+ );
170
+ }
171
+ if (spec.dataProp === "value" && !isHappyPath(data)) {
172
+ throw new ProcessContractError(
173
+ componentName,
174
+ `"value" prop must be a HappyPath, got ${typeof data}`,
175
+ );
176
+ }
82
177
  for (const key of spec.requiredProps ?? []) {
83
178
  if (props[key] === undefined) {
84
179
  throw new ProcessContractError(componentName, `missing required prop "${key}"`);
@@ -103,9 +198,15 @@ export function buildProcessDoublePayload(
103
198
  const dataLength =
104
199
  spec.dataProp === "graph" && isProcessGraph(data)
105
200
  ? data.activities.length
106
- : Array.isArray(data)
107
- ? data.length
108
- : 0;
201
+ : spec.dataProp === "log" && isTimedEventLog(data)
202
+ ? data.events.length
203
+ : spec.dataProp === "conformance" && isConformanceResult(data)
204
+ ? data.traces.length
205
+ : spec.dataProp === "value" && isHappyPath(data)
206
+ ? data.steps.length
207
+ : Array.isArray(data)
208
+ ? data.length
209
+ : 0;
109
210
  const payload: ProcessDoublePayload = { component: componentName, dataLength };
110
211
  if ("selection" in props) payload.selection = props.selection as ProcessSelection;
111
212
  return payload;
@@ -4,7 +4,19 @@ import { describe, expect, it } from "vitest";
4
4
  import { discoverGraph } from "../core/discover-graph";
5
5
  import { extractVariants } from "../core/extract-variants";
6
6
  import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
7
- import { ProcessKpiStripDouble, ProcessMapDouble, VariantExplorerDouble } from "./doubles";
7
+ import { liftHappyPath } from "../core/reference-model";
8
+ import { tokenReplay } from "../core/token-replay";
9
+ import {
10
+ ConformanceOverlayDouble,
11
+ DottedChartDouble,
12
+ HappyPathEditorDouble,
13
+ PerformanceSpectrumDouble,
14
+ ProcessKpiStripDouble,
15
+ ProcessMapDouble,
16
+ ProcessReplayDouble,
17
+ VariantExplorerDouble,
18
+ ViolationListDouble,
19
+ } from "./doubles";
8
20
  import { readProcessDoubleProps } from "./contract";
9
21
 
10
22
  const log = generateSyntheticLog({ cases: 20, seed: 7 });
@@ -44,8 +56,82 @@ describe("process test doubles", () => {
44
56
  expect(node).toBeInstanceOf(HTMLDivElement);
45
57
  });
46
58
 
59
+ // DottedChart — RM-059
60
+ it("DottedChartDouble mounts and records the event count", () => {
61
+ const { container } = render(<DottedChartDouble log={log} />);
62
+ const el = container.querySelector('[data-process-double="DottedChartDouble"]');
63
+ expect(readProcessDoubleProps(el as Element)?.dataLength).toBe(log.events.length);
64
+ });
65
+
66
+ it("DottedChartDouble rejects an empty log or an unparsable timestamp", () => {
67
+ expect(() => render(<DottedChartDouble log={{ events: [] }} />)).toThrow(/DottedChartDouble/);
68
+ expect(() =>
69
+ render(
70
+ <DottedChartDouble log={{ events: [{ caseId: "c", activity: "A", timestamp: "nope" }] }} />,
71
+ ),
72
+ ).toThrow(/parsable timestamps/);
73
+ });
74
+
75
+ // PerformanceSpectrum — RM-060
76
+ it("PerformanceSpectrumDouble records the event count and accepts the default order", () => {
77
+ const { container } = render(<PerformanceSpectrumDouble log={log} />);
78
+ const el = container.querySelector('[data-process-double="PerformanceSpectrumDouble"]');
79
+ expect(readProcessDoubleProps(el as Element)?.dataLength).toBe(log.events.length);
80
+ });
81
+
82
+ it("PerformanceSpectrumDouble accepts a variant path and rejects an order absent from the log", () => {
83
+ expect(() =>
84
+ render(<PerformanceSpectrumDouble log={log} order={{ variantId: variants[0]!.id }} />),
85
+ ).not.toThrow();
86
+ expect(() =>
87
+ render(<PerformanceSpectrumDouble log={log} order={[{ from: "Nope", to: "Never" }]} />),
88
+ ).toThrow(/resolves to no segment/);
89
+ });
90
+
47
91
  it("throws a contract error when required data is missing (a broken test fails loudly)", () => {
48
92
  // @ts-expect-error -- deliberately omitting the required `graph` prop
49
93
  expect(() => render(<ProcessMapDouble />)).toThrow(/ProcessMapDouble/);
50
94
  });
51
95
  });
96
+
97
+ describe("RM-062 doubles", () => {
98
+ const path = { id: "p", label: "Path", steps: [{ activity: "A" }, { activity: "B" }] };
99
+ const conformance = tokenReplay(log, liftHappyPath(path));
100
+
101
+ it("ConformanceOverlayDouble records the replayed case count and needs a graph", () => {
102
+ const { container } = render(
103
+ <ConformanceOverlayDouble graph={graph} conformance={conformance} />,
104
+ );
105
+ const el = container.querySelector('[data-process-double="ConformanceOverlayDouble"]');
106
+ expect(readProcessDoubleProps(el as Element)?.dataLength).toBe(conformance.traces.length);
107
+ // @ts-expect-error -- deliberately omitting the required `graph` prop
108
+ expect(() => render(<ConformanceOverlayDouble conformance={conformance} />)).toThrow(
109
+ /missing required prop "graph"/,
110
+ );
111
+ });
112
+
113
+ it("ViolationListDouble rejects a non-ConformanceResult", () => {
114
+ // @ts-expect-error -- deliberately passing the wrong shape
115
+ expect(() => render(<ViolationListDouble conformance={graph} />)).toThrow(/ConformanceResult/);
116
+ });
117
+
118
+ it("HappyPathEditorDouble records the step count and requires onChange", () => {
119
+ const { container } = render(<HappyPathEditorDouble value={path} onChange={() => {}} />);
120
+ const el = container.querySelector('[data-process-double="HappyPathEditorDouble"]');
121
+ expect(readProcessDoubleProps(el as Element)?.dataLength).toBe(2);
122
+ // @ts-expect-error -- deliberately omitting the required `onChange` prop
123
+ expect(() => render(<HappyPathEditorDouble value={path} />)).toThrow(/onChange/);
124
+ });
125
+ });
126
+
127
+ describe("RM-065 doubles", () => {
128
+ it("ProcessReplayDouble records the event count and needs a graph", () => {
129
+ const { container } = render(<ProcessReplayDouble graph={graph} log={log} />);
130
+ const el = container.querySelector('[data-process-double="ProcessReplayDouble"]');
131
+ expect(readProcessDoubleProps(el as Element)?.dataLength).toBe(log.events.length);
132
+ // @ts-expect-error -- deliberately omitting the required `graph` prop
133
+ expect(() => render(<ProcessReplayDouble log={log} />)).toThrow(
134
+ /missing required prop "graph"/,
135
+ );
136
+ });
137
+ });
@@ -12,10 +12,13 @@
12
12
  import { forwardRef } from "react";
13
13
  import type { HTMLAttributes } from "react";
14
14
 
15
- import type { ProcessGraph, Variant } from "../core/types";
15
+ import type { ConformanceResult } from "../core/conformance";
16
+ import type { HappyPath } from "../core/reference-model";
17
+ import type { EventLog, ProcessGraph, Variant } from "../core/types";
16
18
  import {
17
19
  assertProcessContract,
18
20
  buildProcessDoublePayload,
21
+ ProcessContractError,
19
22
  type ProcessContractSpec,
20
23
  type ProcessSelection,
21
24
  } from "./contract";
@@ -41,7 +44,10 @@ const PROCESS_MAP_SPEC: ProcessContractSpec = { dataProp: "graph" };
41
44
  const VARIANT_EXPLORER_SPEC: ProcessContractSpec = { dataProp: "variants" };
42
45
  const PROCESS_KPI_STRIP_SPEC: ProcessContractSpec = { dataProp: "graph" };
43
46
 
44
- function createProcessDouble<P extends DoubleOwnProps>(name: string, spec: ProcessContractSpec) {
47
+ /** The only props the factory itself reads — a double's own contract is its `P`. */
48
+ type DoubleRenderProps = Pick<HTMLAttributes<HTMLDivElement>, "className" | "style">;
49
+
50
+ function createProcessDouble<P extends DoubleRenderProps>(name: string, spec: ProcessContractSpec) {
45
51
  const Double = forwardRef<HTMLDivElement, P>(function ProcessTestDouble(props, ref) {
46
52
  const record = props as unknown as Record<string, unknown>;
47
53
  assertProcessContract(name, record, spec);
@@ -79,4 +85,169 @@ export const ProcessKpiStripDouble = createProcessDouble<ProcessKpiStripDoublePr
79
85
  PROCESS_KPI_STRIP_SPEC,
80
86
  );
81
87
 
82
- export type { ProcessMapDoubleProps, VariantExplorerDoubleProps, ProcessKpiStripDoubleProps };
88
+ // DottedChart RM-059
89
+ interface DottedChartDoubleProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
90
+ log: EventLog;
91
+ selectedCaseIds?: readonly string[];
92
+ onSelect?: (caseIds: string[]) => void;
93
+ }
94
+
95
+ /** Stand-in for `DottedChart` (RM-059). Asserts a non-empty `log.events` with parsable timestamps. */
96
+ export const DottedChartDouble = createProcessDouble<DottedChartDoubleProps>("DottedChartDouble", {
97
+ dataProp: "log",
98
+ });
99
+
100
+ // PerformanceSpectrum — RM-060
101
+ interface PerformanceSpectrumDoubleProps extends HTMLAttributes<HTMLDivElement> {
102
+ log: EventLog;
103
+ /** Explicit segments, `"frequency"` (the default) or `{ variantId }` — as the real prop. */
104
+ order?: Array<{ from: string; to: string; label?: string }> | "frequency" | { variantId: string };
105
+ segmentLimit?: number;
106
+ mode?: "lines" | "aggregated";
107
+ binSize?: number;
108
+ onFilterIntent?: (intent: { kind: "cases"; ids: string[] }) => void;
109
+ height?: number;
110
+ tableView?: boolean;
111
+ loading?: boolean;
112
+ }
113
+
114
+ const PERFORMANCE_SPECTRUM_SPEC: ProcessContractSpec = { dataProp: "log", segmentOrder: true };
115
+
116
+ /** Stand-in for `PerformanceSpectrum` (RM-060); asserts `order` resolves to a segment present in `log`. */
117
+ export const PerformanceSpectrumDouble = createProcessDouble<PerformanceSpectrumDoubleProps>(
118
+ "PerformanceSpectrumDouble",
119
+ PERFORMANCE_SPECTRUM_SPEC,
120
+ );
121
+
122
+ // ── RM-062 ───────────────────────────────────────────────────────────────────
123
+
124
+ interface ConformanceOverlayDoubleProps extends DoubleOwnProps {
125
+ graph: ProcessGraph;
126
+ conformance: ConformanceResult;
127
+ }
128
+
129
+ interface ViolationListDoubleProps extends HTMLAttributes<HTMLDivElement> {
130
+ conformance: ConformanceResult;
131
+ }
132
+
133
+ interface HappyPathEditorDoubleProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
134
+ value: HappyPath;
135
+ onChange: (path: HappyPath) => void;
136
+ }
137
+
138
+ /** Stand-in for `ConformanceOverlay` (RM-062): requires a graph AND a replay result. */
139
+ export const ConformanceOverlayDouble = createProcessDouble<ConformanceOverlayDoubleProps>(
140
+ "ConformanceOverlayDouble",
141
+ { dataProp: "conformance", requiredProps: ["graph"] },
142
+ );
143
+
144
+ /** Stand-in for `ViolationList` (RM-062). */
145
+ export const ViolationListDouble = createProcessDouble<ViolationListDoubleProps>(
146
+ "ViolationListDouble",
147
+ { dataProp: "conformance" },
148
+ );
149
+
150
+ /** Stand-in for `HappyPathEditor` (RM-062): a controlled editor, so `onChange` is required. */
151
+ export const HappyPathEditorDouble = createProcessDouble<HappyPathEditorDoubleProps>(
152
+ "HappyPathEditorDouble",
153
+ { dataProp: "value", requiredProps: ["onChange"] },
154
+ );
155
+
156
+ // ── RM-065 ───────────────────────────────────────────────────────────────────
157
+
158
+ interface ProcessReplayDoubleProps extends HTMLAttributes<HTMLDivElement> {
159
+ graph: ProcessGraph;
160
+ log: EventLog;
161
+ synchronizedStart?: boolean;
162
+ bucketMs?: number;
163
+ playing?: boolean;
164
+ defaultPlaying?: boolean;
165
+ onPlayingChange?: (playing: boolean) => void;
166
+ time?: number;
167
+ defaultTime?: number;
168
+ onTimeChange?: (time: number) => void;
169
+ speed?: number;
170
+ defaultSpeed?: number;
171
+ onSpeedChange?: (speed: number) => void;
172
+ congestionLimit?: number;
173
+ loading?: boolean;
174
+ }
175
+
176
+ /** Stand-in for `ProcessReplay` (RM-065): requires the log (per-case timing) AND the graph. */
177
+ export const ProcessReplayDouble = createProcessDouble<ProcessReplayDoubleProps>(
178
+ "ProcessReplayDouble",
179
+ { dataProp: "log", requiredProps: ["graph"] },
180
+ );
181
+
182
+ export type {
183
+ ProcessReplayDoubleProps,
184
+ DottedChartDoubleProps,
185
+ PerformanceSpectrumDoubleProps,
186
+ ProcessMapDoubleProps,
187
+ VariantExplorerDoubleProps,
188
+ ProcessKpiStripDoubleProps,
189
+ ConformanceOverlayDoubleProps,
190
+ ViolationListDoubleProps,
191
+ HappyPathEditorDoubleProps,
192
+ };
193
+
194
+ // ── ProcessCompareDouble — RM-064 ────────────────────────────────────────────
195
+ //
196
+ // `ProcessCompare` composes `ProcessMap` twice (or once, superimposed) — a REAL component
197
+ // this package already ships, unlike the pre-launch stand-ins above. Its own contract does
198
+ // not fit `ProcessContractSpec` (`dataProp: "graph" | "variants"` — a single top-level
199
+ // payload), because `ProcessCompare`'s payload is a PAIR (`a`/`b`), so this double validates
200
+ // and serializes its own shape directly rather than stretching the shared engine.
201
+
202
+ interface ProcessCompareSideDoubleProps {
203
+ label: string;
204
+ graph?: ProcessGraph;
205
+ }
206
+
207
+ interface ProcessCompareDoubleProps extends HTMLAttributes<HTMLDivElement> {
208
+ a: ProcessCompareSideDoubleProps;
209
+ b: ProcessCompareSideDoubleProps;
210
+ mode?: "side-by-side" | "superimposed";
211
+ }
212
+
213
+ /** What `ProcessCompareDouble` records to `data-process-props`. */
214
+ interface ProcessCompareDoublePayload {
215
+ component: "ProcessCompareDouble";
216
+ mode: "side-by-side" | "superimposed";
217
+ aLabel: string;
218
+ bLabel: string;
219
+ aActivityCount: number;
220
+ bActivityCount: number;
221
+ }
222
+
223
+ /** Stand-in for `ProcessCompare` (RM-064) — cheap enough to mount without a real canvas. */
224
+ export const ProcessCompareDouble = forwardRef<HTMLDivElement, ProcessCompareDoubleProps>(
225
+ function ProcessCompareDouble({ a, b, mode = "side-by-side", className, style }, ref) {
226
+ if (!a || typeof a.label !== "string") {
227
+ throw new ProcessContractError("ProcessCompareDouble", 'missing required prop "a.label"');
228
+ }
229
+ if (!b || typeof b.label !== "string") {
230
+ throw new ProcessContractError("ProcessCompareDouble", 'missing required prop "b.label"');
231
+ }
232
+ const payload: ProcessCompareDoublePayload = {
233
+ component: "ProcessCompareDouble",
234
+ mode,
235
+ aLabel: a.label,
236
+ bLabel: b.label,
237
+ aActivityCount: a.graph?.activities.length ?? 0,
238
+ bActivityCount: b.graph?.activities.length ?? 0,
239
+ };
240
+ return (
241
+ <div
242
+ ref={ref}
243
+ data-slot="process-test-double"
244
+ data-process-double="ProcessCompareDouble"
245
+ data-process-props={JSON.stringify(payload)}
246
+ className={className}
247
+ style={style}
248
+ />
249
+ );
250
+ },
251
+ );
252
+
253
+ export type { ProcessCompareDoubleProps, ProcessCompareDoublePayload };
package/src/test/index.ts CHANGED
@@ -22,13 +22,37 @@ export {
22
22
  export type { ProcessContractSpec, ProcessDoublePayload, ProcessSelection } from "./contract";
23
23
 
24
24
  // Doubles
25
- export { ProcessKpiStripDouble, ProcessMapDouble, VariantExplorerDouble } from "./doubles";
25
+ export {
26
+ PerformanceSpectrumDouble,
27
+ ProcessCompareDouble,
28
+ ProcessKpiStripDouble,
29
+ ProcessMapDouble,
30
+ VariantExplorerDouble,
31
+ } from "./doubles";
26
32
  export type {
33
+ PerformanceSpectrumDoubleProps,
34
+ ProcessCompareDoubleProps,
27
35
  ProcessKpiStripDoubleProps,
28
36
  ProcessMapDoubleProps,
29
37
  VariantExplorerDoubleProps,
30
38
  } from "./doubles";
31
39
 
40
+ // DottedChart — RM-059
41
+ export { DottedChartDouble } from "./doubles";
42
+ export type { DottedChartDoubleProps } from "./doubles";
43
+
44
+ // Doubles — RM-062
45
+ export { ConformanceOverlayDouble, HappyPathEditorDouble, ViolationListDouble } from "./doubles";
46
+ export type {
47
+ ConformanceOverlayDoubleProps,
48
+ HappyPathEditorDoubleProps,
49
+ ViolationListDoubleProps,
50
+ } from "./doubles";
51
+
52
+ // Doubles — RM-065
53
+ export { ProcessReplayDouble } from "./doubles";
54
+ export type { ProcessReplayDoubleProps } from "./doubles";
55
+
32
56
  // Fixture helper
33
57
  export { withProcessFixture } from "./primitives";
34
58
  export type { ProcessFixture } from "./primitives";
@@ -264,6 +264,50 @@ describe("useProcessExplorer — filter intents (the RM-052 acceptance criterion
264
264
  });
265
265
  });
266
266
 
267
+ describe("useProcessExplorer — excludedByIntent (RM-056, #205)", () => {
268
+ it("is empty with no active intents", () => {
269
+ const { result } = renderHook(() => useProcessExplorer(orderToCash));
270
+ expect(result.current.excludedByIntent).toEqual([]);
271
+ });
272
+
273
+ it("hand-computed against the fixture: a three-intent chain, each entry the MARGINAL exclusion", () => {
274
+ // Fixture cases: case-1/2 (happy path), case-3 (Reject Order), case-4 (Amend Order,
275
+ // then the happy path), case-5 (happy path, Send Invoice before Ship).
276
+ const { result } = renderHook(() => useProcessExplorer(orderToCash));
277
+
278
+ // A: drop case-3 (the only case with "Reject Order") — 5 cases -> 4, excludes 1.
279
+ act(() => result.current.applyIntent({ kind: "without", activity: "Reject Order" }));
280
+ // B: of the remaining 4 (case-1/2/4/5), keep only "Amend Order" — just case-4 — so B
281
+ // alone (on top of A) excludes the other 3 (case-1/2/5).
282
+ act(() => result.current.applyIntent({ kind: "with", activity: "Amend Order" }));
283
+ // C: of the remaining 1 (case-4), "endsWith Receive Payment" still holds — excludes 0.
284
+ act(() => result.current.applyIntent({ kind: "endsWith", activity: "Receive Payment" }));
285
+
286
+ expect(result.current.intents).toEqual([
287
+ { kind: "without", activity: "Reject Order" },
288
+ { kind: "with", activity: "Amend Order" },
289
+ { kind: "endsWith", activity: "Receive Payment" },
290
+ ]);
291
+ expect(result.current.excludedByIntent).toEqual([1, 3, 0]);
292
+ // The chain's own final case count agrees with the KPI strip's own number — no
293
+ // double counting between the per-chip figures and the whole-chain total.
294
+ expect(result.current.kpis.cases).toBe(1);
295
+ });
296
+
297
+ it("stays parallel to `intents` after clearIntent drops an entry from the middle", () => {
298
+ const { result } = renderHook(() => useProcessExplorer(orderToCash));
299
+ act(() => result.current.applyIntent({ kind: "without", activity: "Reject Order" }));
300
+ act(() => result.current.applyIntent({ kind: "with", activity: "Amend Order" }));
301
+ expect(result.current.excludedByIntent).toEqual([1, 3]);
302
+
303
+ act(() => result.current.clearIntent(0));
304
+ expect(result.current.intents).toEqual([{ kind: "with", activity: "Amend Order" }]);
305
+ // Recomputed from scratch against the NEW chain — "with Amend Order" alone, against
306
+ // the full 5-case log, excludes the 4 cases that never touch "Amend Order".
307
+ expect(result.current.excludedByIntent).toEqual([4]);
308
+ });
309
+ });
310
+
267
311
  describe("useProcessExplorer — selectionStates.variants (RM-052 round 3, #227, G2)", () => {
268
312
  it('marks each id of an active "variant" intent "selected"', () => {
269
313
  const { result } = renderHook(() => useProcessExplorer(orderToCash));