@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,293 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { normalizeLog } from "../event-log";
|
|
8
|
+
import { fromXes } from "./xes";
|
|
9
|
+
|
|
10
|
+
const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), "../fixtures");
|
|
11
|
+
const sampleXes = readFileSync(join(fixturesDir, "sample.xes"), "utf8");
|
|
12
|
+
|
|
13
|
+
describe("fromXes", () => {
|
|
14
|
+
it("parses the 3-trace fixture to the hand-computed EventLog", () => {
|
|
15
|
+
const result = fromXes(sampleXes);
|
|
16
|
+
expect(result.ok).toBe(true);
|
|
17
|
+
if (!result.ok) throw new Error("expected ok: true");
|
|
18
|
+
|
|
19
|
+
expect(result.log.events).toEqual([
|
|
20
|
+
{
|
|
21
|
+
caseId: "case-1",
|
|
22
|
+
activity: "Create Order",
|
|
23
|
+
timestamp: "2026-01-05T09:00:00.000Z",
|
|
24
|
+
resource: "A. Novak",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
caseId: "case-1",
|
|
28
|
+
activity: "Check Credit",
|
|
29
|
+
timestamp: "2026-01-05T09:15:00.000Z",
|
|
30
|
+
resource: "Credit Service",
|
|
31
|
+
attributes: { "cost:total": 120.5 },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
caseId: "case-2",
|
|
35
|
+
activity: "Register",
|
|
36
|
+
timestamp: "2026-01-06T09:00:00.000Z",
|
|
37
|
+
resource: "System",
|
|
38
|
+
lifecycle: "start",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
caseId: "case-2",
|
|
42
|
+
activity: "Register",
|
|
43
|
+
timestamp: "2026-01-06T09:10:00.000Z",
|
|
44
|
+
resource: "System",
|
|
45
|
+
lifecycle: "complete",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
caseId: "case-2",
|
|
49
|
+
activity: "Approve",
|
|
50
|
+
timestamp: "2026-01-06T09:10:00.000Z",
|
|
51
|
+
resource: "B. Kowalski",
|
|
52
|
+
lifecycle: "start",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
caseId: "case-2",
|
|
56
|
+
activity: "Approve",
|
|
57
|
+
timestamp: "2026-01-06T09:30:00.000Z",
|
|
58
|
+
resource: "B. Kowalski",
|
|
59
|
+
lifecycle: "complete",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
caseId: "case-3",
|
|
63
|
+
activity: "Create Order",
|
|
64
|
+
timestamp: "2026-01-07T09:00:00.000Z",
|
|
65
|
+
resource: "Unassigned",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
caseId: "case-3",
|
|
69
|
+
activity: "Ship Order",
|
|
70
|
+
timestamp: "2026-01-07T09:20:00.000Z",
|
|
71
|
+
resource: "Courier",
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
expect(result.log.caseAttributes).toEqual({
|
|
76
|
+
"case-1": { channel: "web" },
|
|
77
|
+
"case-2": { channel: "mobile" },
|
|
78
|
+
"case-3": { channel: "phone" },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("feeds normalizeLog end to end, lifecycle pairing included", () => {
|
|
83
|
+
const result = fromXes(sampleXes);
|
|
84
|
+
if (!result.ok) throw new Error("expected ok: true");
|
|
85
|
+
|
|
86
|
+
const normalized = normalizeLog(result.log);
|
|
87
|
+
expect(normalized.totals).toEqual({ cases: 3, events: 6 });
|
|
88
|
+
|
|
89
|
+
const register = normalized.cases
|
|
90
|
+
.find((c) => c.caseId === "case-2")
|
|
91
|
+
?.events.find((e) => e.activity === "Register");
|
|
92
|
+
expect(register).toMatchObject({ duration: 10 * 60 * 1000, isOpen: false });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('defaults org:resource for an event from a <global scope="event"> element', () => {
|
|
96
|
+
const result = fromXes(sampleXes);
|
|
97
|
+
if (!result.ok) throw new Error("expected ok: true");
|
|
98
|
+
|
|
99
|
+
const defaulted = result.log.events.find(
|
|
100
|
+
(event) => event.caseId === "case-3" && event.activity === "Create Order",
|
|
101
|
+
);
|
|
102
|
+
expect(defaulted?.resource).toBe("Unassigned");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns { ok: false, errors } for a trace with no events, rather than throwing", () => {
|
|
106
|
+
const xml = [
|
|
107
|
+
'<?xml version="1.0"?>',
|
|
108
|
+
"<log>",
|
|
109
|
+
"<trace>",
|
|
110
|
+
'<string key="concept:name" value="empty-case"/>',
|
|
111
|
+
"</trace>",
|
|
112
|
+
"</log>",
|
|
113
|
+
].join("");
|
|
114
|
+
|
|
115
|
+
const result = fromXes(xml);
|
|
116
|
+
expect(result).toEqual({
|
|
117
|
+
ok: false,
|
|
118
|
+
errors: [{ type: "missing_trace", message: "trace has no events", traceIndex: 0 }],
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns { ok: false, errors } for an event missing time:timestamp, rather than throwing", () => {
|
|
123
|
+
const xml = [
|
|
124
|
+
'<?xml version="1.0"?>',
|
|
125
|
+
"<log>",
|
|
126
|
+
"<trace>",
|
|
127
|
+
'<string key="concept:name" value="case-1"/>',
|
|
128
|
+
"<event>",
|
|
129
|
+
'<string key="concept:name" value="Create Order"/>',
|
|
130
|
+
"</event>",
|
|
131
|
+
"</trace>",
|
|
132
|
+
"</log>",
|
|
133
|
+
].join("");
|
|
134
|
+
|
|
135
|
+
const result = fromXes(xml);
|
|
136
|
+
expect(result).toEqual({
|
|
137
|
+
ok: false,
|
|
138
|
+
errors: [
|
|
139
|
+
{
|
|
140
|
+
type: "missing_timestamp",
|
|
141
|
+
message: "event is missing time:timestamp",
|
|
142
|
+
traceIndex: 0,
|
|
143
|
+
eventIndex: 0,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns { ok: false, errors } for an event missing its classifier key, rather than throwing", () => {
|
|
150
|
+
const xml = [
|
|
151
|
+
'<?xml version="1.0"?>',
|
|
152
|
+
"<log>",
|
|
153
|
+
"<trace>",
|
|
154
|
+
'<string key="concept:name" value="case-1"/>',
|
|
155
|
+
"<event>",
|
|
156
|
+
'<date key="time:timestamp" value="2026-01-05T09:00:00.000Z"/>',
|
|
157
|
+
"</event>",
|
|
158
|
+
"</trace>",
|
|
159
|
+
"</log>",
|
|
160
|
+
].join("");
|
|
161
|
+
|
|
162
|
+
const result = fromXes(xml);
|
|
163
|
+
expect(result).toEqual({
|
|
164
|
+
ok: false,
|
|
165
|
+
errors: [
|
|
166
|
+
{
|
|
167
|
+
type: "missing_concept_name",
|
|
168
|
+
message: "event is missing its classifier key(s)",
|
|
169
|
+
traceIndex: 0,
|
|
170
|
+
eventIndex: 0,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns a single malformed_xml error for unparsable XML, rather than throwing", () => {
|
|
177
|
+
const result = fromXes("<log><trace><event></trace></log>");
|
|
178
|
+
expect(result.ok).toBe(false);
|
|
179
|
+
if (result.ok) throw new Error("expected ok: false");
|
|
180
|
+
expect(result.errors).toHaveLength(1);
|
|
181
|
+
expect(result.errors[0]?.type).toBe("malformed_xml");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("collects errors across more than one trace instead of stopping at the first", () => {
|
|
185
|
+
const xml = [
|
|
186
|
+
'<?xml version="1.0"?>',
|
|
187
|
+
"<log>",
|
|
188
|
+
"<trace>",
|
|
189
|
+
'<string key="concept:name" value="case-1"/>',
|
|
190
|
+
"</trace>",
|
|
191
|
+
"<trace>",
|
|
192
|
+
'<string key="concept:name" value="case-2"/>',
|
|
193
|
+
"<event>",
|
|
194
|
+
'<string key="concept:name" value="Ship"/>',
|
|
195
|
+
"</event>",
|
|
196
|
+
"</trace>",
|
|
197
|
+
"</log>",
|
|
198
|
+
].join("");
|
|
199
|
+
|
|
200
|
+
const result = fromXes(xml);
|
|
201
|
+
expect(result).toEqual({
|
|
202
|
+
ok: false,
|
|
203
|
+
errors: [
|
|
204
|
+
{ type: "missing_trace", message: "trace has no events", traceIndex: 0 },
|
|
205
|
+
{
|
|
206
|
+
type: "missing_timestamp",
|
|
207
|
+
message: "event is missing time:timestamp",
|
|
208
|
+
traceIndex: 1,
|
|
209
|
+
eventIndex: 0,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("decodes XML entities in attribute values", () => {
|
|
216
|
+
const xml = [
|
|
217
|
+
'<?xml version="1.0"?>',
|
|
218
|
+
"<log>",
|
|
219
|
+
"<trace>",
|
|
220
|
+
'<string key="concept:name" value="case-1"/>',
|
|
221
|
+
"<event>",
|
|
222
|
+
'<string key="concept:name" value="Ship & Invoice"/>',
|
|
223
|
+
'<date key="time:timestamp" value="2026-01-05T09:00:00.000Z"/>',
|
|
224
|
+
"</event>",
|
|
225
|
+
"</trace>",
|
|
226
|
+
"</log>",
|
|
227
|
+
].join("");
|
|
228
|
+
|
|
229
|
+
const result = fromXes(xml);
|
|
230
|
+
if (!result.ok) throw new Error("expected ok: true");
|
|
231
|
+
expect(result.log.events[0]?.activity).toBe("Ship & Invoice");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('maps every non-"start" lifecycle value to "complete" under the default model', () => {
|
|
235
|
+
const xml = [
|
|
236
|
+
'<?xml version="1.0"?>',
|
|
237
|
+
"<log>",
|
|
238
|
+
"<trace>",
|
|
239
|
+
'<string key="concept:name" value="case-1"/>',
|
|
240
|
+
"<event>",
|
|
241
|
+
'<string key="concept:name" value="Review"/>',
|
|
242
|
+
'<date key="time:timestamp" value="2026-01-05T09:00:00.000Z"/>',
|
|
243
|
+
'<string key="lifecycle:transition" value="schedule"/>',
|
|
244
|
+
"</event>",
|
|
245
|
+
"</trace>",
|
|
246
|
+
"</log>",
|
|
247
|
+
].join("");
|
|
248
|
+
|
|
249
|
+
const result = fromXes(xml);
|
|
250
|
+
if (!result.ok) throw new Error("expected ok: true");
|
|
251
|
+
expect(result.log.events[0]?.lifecycle).toBe("complete");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('drops lifecycle to undefined under lifecycleModel: "none"', () => {
|
|
255
|
+
const xml = [
|
|
256
|
+
'<?xml version="1.0"?>',
|
|
257
|
+
"<log>",
|
|
258
|
+
"<trace>",
|
|
259
|
+
'<string key="concept:name" value="case-1"/>',
|
|
260
|
+
"<event>",
|
|
261
|
+
'<string key="concept:name" value="Review"/>',
|
|
262
|
+
'<date key="time:timestamp" value="2026-01-05T09:00:00.000Z"/>',
|
|
263
|
+
'<string key="lifecycle:transition" value="start"/>',
|
|
264
|
+
"</event>",
|
|
265
|
+
"</trace>",
|
|
266
|
+
"</log>",
|
|
267
|
+
].join("");
|
|
268
|
+
|
|
269
|
+
const result = fromXes(xml, { lifecycleModel: "none" });
|
|
270
|
+
if (!result.ok) throw new Error("expected ok: true");
|
|
271
|
+
expect(result.log.events[0]?.lifecycle).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("honours an explicit options.classifiers over concept:name", () => {
|
|
275
|
+
const xml = [
|
|
276
|
+
'<?xml version="1.0"?>',
|
|
277
|
+
"<log>",
|
|
278
|
+
"<trace>",
|
|
279
|
+
'<string key="concept:name" value="case-1"/>',
|
|
280
|
+
"<event>",
|
|
281
|
+
'<string key="concept:name" value="Ship"/>',
|
|
282
|
+
'<string key="activity:name" value="Ship Order"/>',
|
|
283
|
+
'<date key="time:timestamp" value="2026-01-05T09:00:00.000Z"/>',
|
|
284
|
+
"</event>",
|
|
285
|
+
"</trace>",
|
|
286
|
+
"</log>",
|
|
287
|
+
].join("");
|
|
288
|
+
|
|
289
|
+
const result = fromXes(xml, { classifiers: ["activity:name"] });
|
|
290
|
+
if (!result.ok) throw new Error("expected ok: true");
|
|
291
|
+
expect(result.log.events[0]?.activity).toBe("Ship Order");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XES adapter — RM-063.
|
|
3
|
+
*
|
|
4
|
+
* IEEE 1849-2016's interchange format for event logs: a `<log>` of `<trace>`s of
|
|
5
|
+
* `<event>`s, each carrying `key`/`value` attributes typed by their own element name
|
|
6
|
+
* (`string`, `date`, `int`, `float`, `boolean`). It is the format most public benchmark
|
|
7
|
+
* logs — including the BPI Challenge series RM-049's synthetic fixture is modelled on —
|
|
8
|
+
* actually ship in.
|
|
9
|
+
*
|
|
10
|
+
* Parsing is hand-rolled here on purpose, and NOT behind a `DOMParser` fast path:
|
|
11
|
+
* `/core` must run identically in Node, in a worker and in a browser main thread, and a
|
|
12
|
+
* parser this narrow — six element names, attribute-only values, the five standard XML
|
|
13
|
+
* entities, self-closing tags — does not need a general XML engine. `DOMParser` is
|
|
14
|
+
* unavailable in Node/workers anyway, and branching on its presence would leave one of
|
|
15
|
+
* the two code paths permanently untested. Do not reach for an XML dependency or
|
|
16
|
+
* `DOMParser` here; extend the tokenizer below instead.
|
|
17
|
+
*/
|
|
18
|
+
import type { EventLog, EventRow } from "../types";
|
|
19
|
+
|
|
20
|
+
/** Options for {@link fromXes}. */
|
|
21
|
+
export interface XesParseOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Event attribute keys that jointly identify an activity, applied in order and joined
|
|
24
|
+
* with `"+"` when there is more than one — the same semantics as an XES `<classifier>`
|
|
25
|
+
* element's `keys` attribute. Defaults to the log's own first `<classifier>`, or
|
|
26
|
+
* `["concept:name"]` when the log declares none.
|
|
27
|
+
*/
|
|
28
|
+
classifiers?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* How `lifecycle:transition` maps onto {@link EventRow.lifecycle}. `"standard"`
|
|
31
|
+
* (default) maps the literal value `"start"` to `"start"` and every other XES
|
|
32
|
+
* lifecycle value (`"complete"`, `"schedule"`, `"suspend"`, …) to `"complete"`;
|
|
33
|
+
* `"none"` ignores the attribute entirely, so every event stays atomic.
|
|
34
|
+
*/
|
|
35
|
+
lifecycleModel?: "standard" | "none";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One recoverable problem found while reading a XES document. */
|
|
39
|
+
export interface XesParseError {
|
|
40
|
+
type: "malformed_xml" | "missing_trace" | "missing_concept_name" | "missing_timestamp";
|
|
41
|
+
message: string;
|
|
42
|
+
traceIndex?: number;
|
|
43
|
+
eventIndex?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `fromXes`'s result — mirrors the worker's `{ ok: true, … } | { ok: false, … }` result
|
|
48
|
+
* convention (`worker/process-worker.ts`), because unlike `fromCsv`/`fromFlatRows` (which
|
|
49
|
+
* silently skip an incomplete row) a XES document can be genuinely malformed XML, which
|
|
50
|
+
* has nothing sound left to skip to.
|
|
51
|
+
*/
|
|
52
|
+
export type XesParseResult = { ok: true; log: EventLog } | { ok: false; errors: XesParseError[] };
|
|
53
|
+
|
|
54
|
+
type XesAttributeValue = string | number | boolean | null;
|
|
55
|
+
|
|
56
|
+
/** A parsed XML element, scoped to the tags XES actually uses (see the file header). */
|
|
57
|
+
interface XmlElement {
|
|
58
|
+
tag: string;
|
|
59
|
+
attrs: Record<string, string>;
|
|
60
|
+
children: XmlElement[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Thrown only inside {@link parseXmlDocument}; always caught and turned into `malformed_xml`. */
|
|
64
|
+
class XmlSyntaxError extends Error {}
|
|
65
|
+
|
|
66
|
+
const NAME_STOP = /[\s/>=]/;
|
|
67
|
+
const ENTITY_NAMES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
68
|
+
|
|
69
|
+
/** Resolve `&`, `'`, `'` and friends. Anything unrecognized is left as written. */
|
|
70
|
+
function decodeXmlEntities(text: string): string {
|
|
71
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, body: string) => {
|
|
72
|
+
if (body[0] === "#") {
|
|
73
|
+
const isHex = body[1] === "x" || body[1] === "X";
|
|
74
|
+
const codePoint = Number.parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
|
|
75
|
+
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
76
|
+
}
|
|
77
|
+
return ENTITY_NAMES[body] ?? match;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse well-formed XML into a tree of {@link XmlElement}, decoding entities as it goes.
|
|
83
|
+
* Deliberately narrow: no namespaces, no DTDs, no processing-instruction content, no
|
|
84
|
+
* nested-attribute (XES 2.0) support — the trace/event/string/date/int/float/boolean/
|
|
85
|
+
* classifier/global vocabulary this adapter reads needs none of that.
|
|
86
|
+
*/
|
|
87
|
+
function parseXmlDocument(text: string): XmlElement {
|
|
88
|
+
let i = 0;
|
|
89
|
+
const n = text.length;
|
|
90
|
+
|
|
91
|
+
const skipWhitespace = (): void => {
|
|
92
|
+
while (i < n && /\s/.test(text[i] as string)) i += 1;
|
|
93
|
+
};
|
|
94
|
+
const skipUntil = (marker: string, what: string): void => {
|
|
95
|
+
const at = text.indexOf(marker, i);
|
|
96
|
+
if (at === -1) throw new XmlSyntaxError(`unterminated ${what}`);
|
|
97
|
+
i = at + marker.length;
|
|
98
|
+
};
|
|
99
|
+
const readName = (): string => {
|
|
100
|
+
const start = i;
|
|
101
|
+
while (i < n && !NAME_STOP.test(text[i] as string)) i += 1;
|
|
102
|
+
if (i === start) throw new XmlSyntaxError(`expected a name at offset ${start}`);
|
|
103
|
+
return text.slice(start, i);
|
|
104
|
+
};
|
|
105
|
+
const readAttrs = (): Record<string, string> => {
|
|
106
|
+
const attrs: Record<string, string> = {};
|
|
107
|
+
for (;;) {
|
|
108
|
+
skipWhitespace();
|
|
109
|
+
const c = text[i];
|
|
110
|
+
if (c === undefined) throw new XmlSyntaxError("unexpected end of input in a start tag");
|
|
111
|
+
if (c === "/" || c === ">") return attrs;
|
|
112
|
+
const name = readName();
|
|
113
|
+
skipWhitespace();
|
|
114
|
+
if (text[i] !== "=") throw new XmlSyntaxError(`expected "=" after attribute "${name}"`);
|
|
115
|
+
i += 1;
|
|
116
|
+
skipWhitespace();
|
|
117
|
+
const quote = text[i];
|
|
118
|
+
if (quote !== '"' && quote !== "'") {
|
|
119
|
+
throw new XmlSyntaxError(`expected a quoted value for attribute "${name}"`);
|
|
120
|
+
}
|
|
121
|
+
i += 1;
|
|
122
|
+
const start = i;
|
|
123
|
+
while (i < n && text[i] !== quote) i += 1;
|
|
124
|
+
if (i >= n) throw new XmlSyntaxError(`unterminated value for attribute "${name}"`);
|
|
125
|
+
attrs[name] = decodeXmlEntities(text.slice(start, i));
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const readElement = (): XmlElement => {
|
|
130
|
+
i += 1; // consume '<'
|
|
131
|
+
const tag = readName();
|
|
132
|
+
const attrs = readAttrs();
|
|
133
|
+
skipWhitespace();
|
|
134
|
+
if (text[i] === "/" && text[i + 1] === ">") {
|
|
135
|
+
i += 2;
|
|
136
|
+
return { tag, attrs, children: [] };
|
|
137
|
+
}
|
|
138
|
+
if (text[i] !== ">") throw new XmlSyntaxError(`expected ">" closing "<${tag}>"`);
|
|
139
|
+
i += 1;
|
|
140
|
+
const children: XmlElement[] = [];
|
|
141
|
+
for (;;) {
|
|
142
|
+
const lt = text.indexOf("<", i);
|
|
143
|
+
if (lt === -1) throw new XmlSyntaxError(`unterminated element "<${tag}>"`);
|
|
144
|
+
i = lt;
|
|
145
|
+
if (text.startsWith("<!--", i)) {
|
|
146
|
+
skipUntil("-->", "comment");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (text.startsWith("<![CDATA[", i)) {
|
|
150
|
+
skipUntil("]]>", "CDATA section");
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (text.startsWith("</", i)) {
|
|
154
|
+
i += 2;
|
|
155
|
+
const closeName = readName();
|
|
156
|
+
skipWhitespace();
|
|
157
|
+
if (text[i] !== ">") throw new XmlSyntaxError(`expected ">" closing "</${closeName}>"`);
|
|
158
|
+
i += 1;
|
|
159
|
+
if (closeName !== tag) {
|
|
160
|
+
throw new XmlSyntaxError(
|
|
161
|
+
`mismatched closing tag: expected "</${tag}>", found "</${closeName}>"`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return { tag, attrs, children };
|
|
165
|
+
}
|
|
166
|
+
children.push(readElement());
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (;;) {
|
|
171
|
+
skipWhitespace();
|
|
172
|
+
if (i >= n) throw new XmlSyntaxError("empty document");
|
|
173
|
+
if (text.startsWith("<?", i)) {
|
|
174
|
+
skipUntil("?>", "processing instruction");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (text.startsWith("<!--", i)) {
|
|
178
|
+
skipUntil("-->", "comment");
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (text.startsWith("<!", i)) {
|
|
182
|
+
skipUntil(">", "declaration");
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (text[i] !== "<") throw new XmlSyntaxError("expected a root element");
|
|
188
|
+
return readElement();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const XES_VALUE_TAGS = new Set(["string", "date", "int", "float", "boolean"]);
|
|
192
|
+
|
|
193
|
+
/** Coerce one `<string|date|int|float|boolean key="…" value="…">` into a typed value. */
|
|
194
|
+
function readXesValue(element: XmlElement): XesAttributeValue {
|
|
195
|
+
const raw = element.attrs.value;
|
|
196
|
+
if (raw === undefined) return null;
|
|
197
|
+
if (element.tag === "int") {
|
|
198
|
+
const value = Number.parseInt(raw, 10);
|
|
199
|
+
return Number.isFinite(value) ? value : raw;
|
|
200
|
+
}
|
|
201
|
+
if (element.tag === "float") {
|
|
202
|
+
const value = Number.parseFloat(raw);
|
|
203
|
+
return Number.isFinite(value) ? value : raw;
|
|
204
|
+
}
|
|
205
|
+
if (element.tag === "boolean") return raw.trim().toLowerCase() === "true";
|
|
206
|
+
return raw;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Read every direct `key`/`value` attribute child (`string`/`date`/`int`/`float`/`boolean`). */
|
|
210
|
+
function readAttributeChildren(children: readonly XmlElement[]): Record<string, XesAttributeValue> {
|
|
211
|
+
const result: Record<string, XesAttributeValue> = {};
|
|
212
|
+
for (const child of children) {
|
|
213
|
+
if (!XES_VALUE_TAGS.has(child.tag)) continue;
|
|
214
|
+
const key = child.attrs.key;
|
|
215
|
+
if (key === undefined) continue;
|
|
216
|
+
result[key] = readXesValue(child);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `own` values win; a `<global>` default only fills a key `own` never set. */
|
|
222
|
+
function withDefaults(
|
|
223
|
+
own: Record<string, XesAttributeValue>,
|
|
224
|
+
defaults: Record<string, XesAttributeValue> | undefined,
|
|
225
|
+
): Record<string, XesAttributeValue> {
|
|
226
|
+
return defaults === undefined ? own : { ...defaults, ...own };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Merge every `<global scope="…">` of the given scope, in document order. */
|
|
230
|
+
function collectGlobalDefaults(
|
|
231
|
+
root: XmlElement,
|
|
232
|
+
scope: "event" | "trace",
|
|
233
|
+
): Record<string, XesAttributeValue> | undefined {
|
|
234
|
+
let defaults: Record<string, XesAttributeValue> | undefined;
|
|
235
|
+
for (const child of root.children) {
|
|
236
|
+
if (child.tag !== "global" || child.attrs.scope !== scope) continue;
|
|
237
|
+
defaults = withDefaults(readAttributeChildren(child.children), defaults);
|
|
238
|
+
}
|
|
239
|
+
return defaults;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The keys that jointly name an activity: `options.classifiers` when given, else the
|
|
244
|
+
* log's own first `<classifier>`, else the XES default of `concept:name` alone.
|
|
245
|
+
*/
|
|
246
|
+
function resolveClassifierKeys(root: XmlElement, options: XesParseOptions | undefined): string[] {
|
|
247
|
+
if (options?.classifiers !== undefined && options.classifiers.length > 0) {
|
|
248
|
+
return options.classifiers;
|
|
249
|
+
}
|
|
250
|
+
const classifier = root.children.find((child) => child.tag === "classifier");
|
|
251
|
+
const keys = classifier?.attrs.keys?.trim();
|
|
252
|
+
if (keys !== undefined && keys !== "") return keys.split(/\s+/);
|
|
253
|
+
return ["concept:name"];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The literal value `"start"` maps to `"start"`; every other value collapses to `"complete"`. */
|
|
257
|
+
function mapLifecycle(
|
|
258
|
+
raw: XesAttributeValue | undefined,
|
|
259
|
+
model: "standard" | "none",
|
|
260
|
+
): "start" | "complete" | undefined {
|
|
261
|
+
if (typeof raw !== "string" || model === "none") return undefined;
|
|
262
|
+
const value = raw.trim().toLowerCase();
|
|
263
|
+
if (value === "") return undefined;
|
|
264
|
+
return value === "start" ? "start" : "complete";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Read a IEEE 1849 XES document into an {@link EventLog}.
|
|
269
|
+
*
|
|
270
|
+
* Collects every recoverable problem (a trace with no events, an event missing its
|
|
271
|
+
* activity classifier key(s) or `time:timestamp`) rather than stopping at the first one;
|
|
272
|
+
* only malformed XML itself — which leaves nothing sound to keep reading — short-circuits
|
|
273
|
+
* with a single `malformed_xml` error. Output feeds directly into `normalizeLog`, exactly
|
|
274
|
+
* like `fromCsv`/`fromFlatRows` — no XES-specific branching downstream.
|
|
275
|
+
*/
|
|
276
|
+
export function fromXes(source: string, options?: XesParseOptions): XesParseResult {
|
|
277
|
+
let root: XmlElement;
|
|
278
|
+
try {
|
|
279
|
+
root = parseXmlDocument(source);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
282
|
+
return { ok: false, errors: [{ type: "malformed_xml", message }] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const lifecycleModel = options?.lifecycleModel ?? "standard";
|
|
286
|
+
const classifierKeys = resolveClassifierKeys(root, options);
|
|
287
|
+
const consumedKeys = new Set([
|
|
288
|
+
...classifierKeys,
|
|
289
|
+
"time:timestamp",
|
|
290
|
+
"org:resource",
|
|
291
|
+
"lifecycle:transition",
|
|
292
|
+
]);
|
|
293
|
+
const eventDefaults = collectGlobalDefaults(root, "event");
|
|
294
|
+
const traceDefaults = collectGlobalDefaults(root, "trace");
|
|
295
|
+
|
|
296
|
+
const errors: XesParseError[] = [];
|
|
297
|
+
const events: EventRow[] = [];
|
|
298
|
+
let caseAttributes: Record<string, Record<string, unknown>> | undefined;
|
|
299
|
+
|
|
300
|
+
const traces = root.children.filter((child) => child.tag === "trace");
|
|
301
|
+
traces.forEach((trace, traceIndex) => {
|
|
302
|
+
const traceEvents = trace.children.filter((child) => child.tag === "event");
|
|
303
|
+
if (traceEvents.length === 0) {
|
|
304
|
+
errors.push({ type: "missing_trace", message: "trace has no events", traceIndex });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { "concept:name": rawCaseId, ...traceValues } = withDefaults(
|
|
309
|
+
readAttributeChildren(trace.children),
|
|
310
|
+
traceDefaults,
|
|
311
|
+
);
|
|
312
|
+
const caseId =
|
|
313
|
+
typeof rawCaseId === "string" && rawCaseId !== "" ? rawCaseId : `trace-${traceIndex}`;
|
|
314
|
+
|
|
315
|
+
if (Object.keys(traceValues).length > 0) {
|
|
316
|
+
caseAttributes ??= {};
|
|
317
|
+
caseAttributes[caseId] = traceValues;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
traceEvents.forEach((eventElement, eventIndex) => {
|
|
321
|
+
const values = withDefaults(readAttributeChildren(eventElement.children), eventDefaults);
|
|
322
|
+
|
|
323
|
+
const activityParts: string[] = [];
|
|
324
|
+
let missingActivityKey = false;
|
|
325
|
+
for (const key of classifierKeys) {
|
|
326
|
+
const value = values[key];
|
|
327
|
+
if (value === undefined || value === null) {
|
|
328
|
+
missingActivityKey = true;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
activityParts.push(String(value));
|
|
332
|
+
}
|
|
333
|
+
if (missingActivityKey) {
|
|
334
|
+
errors.push({
|
|
335
|
+
type: "missing_concept_name",
|
|
336
|
+
message: "event is missing its classifier key(s)",
|
|
337
|
+
traceIndex,
|
|
338
|
+
eventIndex,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const rawTimestamp = values["time:timestamp"];
|
|
344
|
+
if (rawTimestamp === undefined || rawTimestamp === null) {
|
|
345
|
+
errors.push({
|
|
346
|
+
type: "missing_timestamp",
|
|
347
|
+
message: "event is missing time:timestamp",
|
|
348
|
+
traceIndex,
|
|
349
|
+
eventIndex,
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const row: EventRow = {
|
|
355
|
+
caseId,
|
|
356
|
+
activity: activityParts.join("+"),
|
|
357
|
+
timestamp: String(rawTimestamp),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const resource = values["org:resource"];
|
|
361
|
+
if (typeof resource === "string" && resource !== "") row.resource = resource;
|
|
362
|
+
|
|
363
|
+
const lifecycle = mapLifecycle(values["lifecycle:transition"], lifecycleModel);
|
|
364
|
+
if (lifecycle !== undefined) row.lifecycle = lifecycle;
|
|
365
|
+
|
|
366
|
+
const attributes: Record<string, string | number | boolean | null> = {};
|
|
367
|
+
let anyAttribute = false;
|
|
368
|
+
for (const [key, value] of Object.entries(values)) {
|
|
369
|
+
if (consumedKeys.has(key)) continue;
|
|
370
|
+
attributes[key] = value;
|
|
371
|
+
anyAttribute = true;
|
|
372
|
+
}
|
|
373
|
+
if (anyAttribute) row.attributes = attributes;
|
|
374
|
+
|
|
375
|
+
events.push(row);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
380
|
+
|
|
381
|
+
const log: EventLog = { events };
|
|
382
|
+
if (caseAttributes !== undefined) log.caseAttributes = caseAttributes;
|
|
383
|
+
return { ok: true, log };
|
|
384
|
+
}
|