@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
@@ -0,0 +1,359 @@
1
+ /**
2
+ * OCEL 2.0 adapter — RM-066.
3
+ *
4
+ * The Object-Centric Event Log standard (OCEL 2.0, ocel-standard.org) drops the single
5
+ * case notion: an event references any number of OBJECTS of any number of object TYPES
6
+ * (`order`, `item`, `package`, …). This adapter reads the standard's JSON serialization
7
+ * and FLATTENS it once per object type — the projection every object-centric
8
+ * directly-follows graph starts from: for object type `T`, every object of type `T` is a
9
+ * case, and every event that references that object is one row of that case.
10
+ *
11
+ * ## JSON only, on purpose
12
+ *
13
+ * OCEL 2.0 also ships as XML and SQLite. Only JSON is read here: it needs nothing beyond
14
+ * `JSON.parse`, which runs identically in Node, a worker and a browser main thread, and
15
+ * `/core` takes no parser dependency (the same format-choice precedent `fromXes` set in
16
+ * RM-063). SQLite would need a WASM engine; XML would need a second hand-rolled tokenizer
17
+ * for a format whose producers all also emit JSON. Convert an XML/SQLite log to JSON with
18
+ * the producing tool first.
19
+ *
20
+ * ## What survives the flattening
21
+ *
22
+ * Each emitted {@link EventRow} carries two reserved attributes so the object-centric
23
+ * discovery step can re-merge the projections:
24
+ *
25
+ * - `__ocelEventId` — the OCEL event's own id, so a shared activity is counted once per
26
+ * EVENT rather than once per referenced object;
27
+ * - `__objectRefs` — the event's full object map (`{ order: ["o1"], item: ["i1", "i2"] }`),
28
+ * JSON-encoded because `EventRow.attributes` values are scalars by contract (RM-049's
29
+ * frozen types). Read it back with {@link readObjectRefs}.
30
+ */
31
+ import type { EventLog, EventRow } from "../types";
32
+
33
+ /** One `{ name, … }` type declaration (`objectTypes` / `eventTypes`). */
34
+ export interface OcelTypeDeclaration {
35
+ name: string;
36
+ attributes?: { name: string; type?: string }[];
37
+ }
38
+
39
+ /** An event or object attribute value. Object attributes carry a `time` (they can change). */
40
+ export interface OcelAttribute {
41
+ name: string;
42
+ value: string | number | boolean | null;
43
+ time?: string;
44
+ }
45
+
46
+ /** A qualified reference from an event (or object) to an object. */
47
+ export interface OcelRelationship {
48
+ objectId: string;
49
+ qualifier?: string;
50
+ }
51
+
52
+ /** One OCEL 2.0 event. `type` is the activity; `time` is an ISO-8601 timestamp. */
53
+ export interface OcelEvent {
54
+ id: string;
55
+ type: string;
56
+ time: string;
57
+ attributes?: OcelAttribute[];
58
+ relationships?: OcelRelationship[];
59
+ }
60
+
61
+ /** One OCEL 2.0 object. */
62
+ export interface OcelObject {
63
+ id: string;
64
+ type: string;
65
+ attributes?: OcelAttribute[];
66
+ relationships?: OcelRelationship[];
67
+ }
68
+
69
+ /** An OCEL 2.0 JSON document (OCEL 2.0 specification, JSON serialization). */
70
+ export interface OcelJson {
71
+ objectTypes: OcelTypeDeclaration[];
72
+ eventTypes?: OcelTypeDeclaration[];
73
+ objects: OcelObject[];
74
+ events: OcelEvent[];
75
+ }
76
+
77
+ /** Options for {@link fromOcel}. */
78
+ export interface OcelParseOptions {
79
+ /**
80
+ * Which object types to project a log for, in output order. Defaults to every declared
81
+ * object type, in declaration order. Naming an undeclared type is an error.
82
+ */
83
+ objectTypes?: string[];
84
+ }
85
+
86
+ /** One problem found while reading an OCEL 2.0 document. */
87
+ export interface OcelParseError {
88
+ type:
89
+ | "malformed_json"
90
+ | "invalid_document"
91
+ | "missing_event_id"
92
+ | "missing_event_type"
93
+ | "missing_timestamp"
94
+ | "unknown_object"
95
+ | "unknown_object_type";
96
+ message: string;
97
+ eventIndex?: number;
98
+ objectIndex?: number;
99
+ }
100
+
101
+ /**
102
+ * `fromOcel`'s result — the same `{ ok: true, … } | { ok: false, errors }` shape as
103
+ * `fromXes`. `logs` is keyed by object type; `activities` lists every event type that
104
+ * occurs, in first-appearance order; `objectTypes` is the projected types, in order.
105
+ */
106
+ export type OcelParseResult =
107
+ | { ok: true; logs: Record<string, EventLog>; activities: string[]; objectTypes: string[] }
108
+ | { ok: false; errors: OcelParseError[] };
109
+
110
+ /** The reserved attribute holding an emitted row's OCEL event id. */
111
+ export const OCEL_EVENT_ID_ATTRIBUTE = "__ocelEventId";
112
+ /** The reserved attribute holding an emitted row's JSON-encoded object map. */
113
+ export const OCEL_OBJECT_REFS_ATTRIBUTE = "__objectRefs";
114
+
115
+ function isRecord(value: unknown): value is Record<string, unknown> {
116
+ return typeof value === "object" && value !== null && !Array.isArray(value);
117
+ }
118
+
119
+ function isScalar(value: unknown): value is string | number | boolean | null {
120
+ return (
121
+ value === null ||
122
+ typeof value === "string" ||
123
+ typeof value === "number" ||
124
+ typeof value === "boolean"
125
+ );
126
+ }
127
+
128
+ function nonEmptyString(value: unknown): value is string {
129
+ return typeof value === "string" && value !== "";
130
+ }
131
+
132
+ /**
133
+ * An object's attributes as a flat record: for a time-varying attribute the value with the
134
+ * LATEST `time` wins (document order breaks ties), which is the object's final state.
135
+ */
136
+ function objectAttributes(object: Record<string, unknown>): Record<string, unknown> | undefined {
137
+ if (!Array.isArray(object.attributes)) return undefined;
138
+ const latest = new Map<string, { time: number; value: unknown }>();
139
+ for (const raw of object.attributes) {
140
+ if (!isRecord(raw) || !nonEmptyString(raw.name) || !isScalar(raw.value)) continue;
141
+ const parsed = typeof raw.time === "string" ? Date.parse(raw.time) : Number.NEGATIVE_INFINITY;
142
+ const time = Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
143
+ const previous = latest.get(raw.name);
144
+ if (previous === undefined || time >= previous.time) {
145
+ latest.set(raw.name, { time, value: raw.value });
146
+ }
147
+ }
148
+ if (latest.size === 0) return undefined;
149
+ const out: Record<string, unknown> = {};
150
+ for (const [name, entry] of latest) out[name] = entry.value;
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Read an OCEL 2.0 JSON document (a string, or an already-parsed object) into one
156
+ * {@link EventLog} per object type.
157
+ *
158
+ * Collects every per-event problem (a missing id, type or parsable `time`; a relationship
159
+ * to an object the document never declares) rather than stopping at the first; only a
160
+ * document that is not JSON, or not shaped like OCEL at all, short-circuits. Any error
161
+ * fails the whole read — a silently-dropped event would change every count downstream.
162
+ */
163
+ export function fromOcel(input: string | OcelJson, options?: OcelParseOptions): OcelParseResult {
164
+ let document: unknown = input;
165
+ if (typeof input === "string") {
166
+ try {
167
+ document = JSON.parse(input);
168
+ } catch (error) {
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ return { ok: false, errors: [{ type: "malformed_json", message }] };
171
+ }
172
+ }
173
+
174
+ if (
175
+ !isRecord(document) ||
176
+ !Array.isArray(document.objectTypes) ||
177
+ !Array.isArray(document.objects) ||
178
+ !Array.isArray(document.events)
179
+ ) {
180
+ return {
181
+ ok: false,
182
+ errors: [
183
+ {
184
+ type: "invalid_document",
185
+ message: "expected an object with objectTypes, objects and events arrays",
186
+ },
187
+ ],
188
+ };
189
+ }
190
+
191
+ const errors: OcelParseError[] = [];
192
+
193
+ const declaredTypes: string[] = [];
194
+ for (const declaration of document.objectTypes) {
195
+ if (isRecord(declaration) && nonEmptyString(declaration.name)) {
196
+ if (!declaredTypes.includes(declaration.name)) declaredTypes.push(declaration.name);
197
+ }
198
+ }
199
+
200
+ const projected = options?.objectTypes ?? declaredTypes;
201
+ for (const type of projected) {
202
+ if (!declaredTypes.includes(type)) {
203
+ errors.push({
204
+ type: "unknown_object_type",
205
+ message: `object type "${type}" is not declared in objectTypes`,
206
+ });
207
+ }
208
+ }
209
+ const projectedSet = new Set(projected);
210
+
211
+ const typeOfObject = new Map<string, string>();
212
+ const attributesOfObject = new Map<string, Record<string, unknown>>();
213
+ document.objects.forEach((object, objectIndex) => {
214
+ if (!isRecord(object) || !nonEmptyString(object.id) || !nonEmptyString(object.type)) {
215
+ errors.push({
216
+ type: "invalid_document",
217
+ message: "object is missing its id or type",
218
+ objectIndex,
219
+ });
220
+ return;
221
+ }
222
+ if (!declaredTypes.includes(object.type)) {
223
+ errors.push({
224
+ type: "unknown_object_type",
225
+ message: `object "${object.id}" has undeclared type "${object.type}"`,
226
+ objectIndex,
227
+ });
228
+ return;
229
+ }
230
+ typeOfObject.set(object.id, object.type);
231
+ const attributes = objectAttributes(object);
232
+ if (attributes !== undefined) attributesOfObject.set(object.id, attributes);
233
+ });
234
+
235
+ const rowsByType = new Map<string, EventRow[]>(projected.map((type) => [type, []]));
236
+ const activities: string[] = [];
237
+ const seenActivities = new Set<string>();
238
+
239
+ document.events.forEach((event, eventIndex) => {
240
+ if (!isRecord(event)) {
241
+ errors.push({ type: "invalid_document", message: "event is not an object", eventIndex });
242
+ return;
243
+ }
244
+ if (!nonEmptyString(event.id)) {
245
+ errors.push({ type: "missing_event_id", message: "event is missing its id", eventIndex });
246
+ return;
247
+ }
248
+ if (!nonEmptyString(event.type)) {
249
+ errors.push({
250
+ type: "missing_event_type",
251
+ message: `event "${event.id}" is missing its type`,
252
+ eventIndex,
253
+ });
254
+ return;
255
+ }
256
+ if (!nonEmptyString(event.time) || Number.isNaN(Date.parse(event.time))) {
257
+ errors.push({
258
+ type: "missing_timestamp",
259
+ message: `event "${event.id}" has no parsable time`,
260
+ eventIndex,
261
+ });
262
+ return;
263
+ }
264
+
265
+ // Group this event's references by object type, deduplicated (the same object can be
266
+ // referenced twice under two qualifiers) and in document order.
267
+ const refs: Record<string, string[]> = {};
268
+ let broken = false;
269
+ const relationships = Array.isArray(event.relationships) ? event.relationships : [];
270
+ for (const relationship of relationships) {
271
+ if (!isRecord(relationship) || !nonEmptyString(relationship.objectId)) continue;
272
+ const type = typeOfObject.get(relationship.objectId);
273
+ if (type === undefined) {
274
+ errors.push({
275
+ type: "unknown_object",
276
+ message: `event "${event.id}" references undeclared object "${relationship.objectId}"`,
277
+ eventIndex,
278
+ });
279
+ broken = true;
280
+ continue;
281
+ }
282
+ const ids = (refs[type] ??= []);
283
+ if (!ids.includes(relationship.objectId)) ids.push(relationship.objectId);
284
+ }
285
+ if (broken) return;
286
+
287
+ if (!seenActivities.has(event.type)) {
288
+ seenActivities.add(event.type);
289
+ activities.push(event.type);
290
+ }
291
+
292
+ let attributes: Record<string, string | number | boolean | null> | undefined;
293
+ if (Array.isArray(event.attributes)) {
294
+ for (const attribute of event.attributes) {
295
+ if (!isRecord(attribute) || !nonEmptyString(attribute.name)) continue;
296
+ if (!isScalar(attribute.value)) continue;
297
+ attributes ??= {};
298
+ attributes[attribute.name] = attribute.value;
299
+ }
300
+ }
301
+ const encodedRefs = JSON.stringify(refs);
302
+
303
+ for (const [type, objectIds] of Object.entries(refs)) {
304
+ if (!projectedSet.has(type)) continue;
305
+ const rows = rowsByType.get(type) as EventRow[];
306
+ for (const objectId of objectIds) {
307
+ rows.push({
308
+ caseId: objectId,
309
+ activity: event.type,
310
+ timestamp: event.time,
311
+ attributes: {
312
+ ...attributes,
313
+ [OCEL_EVENT_ID_ATTRIBUTE]: event.id,
314
+ [OCEL_OBJECT_REFS_ATTRIBUTE]: encodedRefs,
315
+ },
316
+ });
317
+ }
318
+ }
319
+ });
320
+
321
+ if (errors.length > 0) return { ok: false, errors };
322
+
323
+ const logs: Record<string, EventLog> = {};
324
+ for (const type of projected) {
325
+ const events = rowsByType.get(type) as EventRow[];
326
+ const log: EventLog = { events };
327
+ let caseAttributes: Record<string, Record<string, unknown>> | undefined;
328
+ for (const row of events) {
329
+ const attributes = attributesOfObject.get(row.caseId);
330
+ if (attributes === undefined || caseAttributes?.[row.caseId] !== undefined) continue;
331
+ caseAttributes ??= {};
332
+ caseAttributes[row.caseId] = attributes;
333
+ }
334
+ if (caseAttributes !== undefined) log.caseAttributes = caseAttributes;
335
+ logs[type] = log;
336
+ }
337
+
338
+ return { ok: true, logs, activities, objectTypes: [...projected] };
339
+ }
340
+
341
+ /**
342
+ * The object map an emitted row was flattened from (`{ order: ["o1"], item: ["i1"] }`), or
343
+ * `undefined` for a row that did not come from {@link fromOcel}.
344
+ */
345
+ export function readObjectRefs(row: EventRow): Record<string, string[]> | undefined {
346
+ const raw = row.attributes?.[OCEL_OBJECT_REFS_ATTRIBUTE];
347
+ if (typeof raw !== "string") return undefined;
348
+ try {
349
+ const parsed: unknown = JSON.parse(raw);
350
+ if (!isRecord(parsed)) return undefined;
351
+ const out: Record<string, string[]> = {};
352
+ for (const [type, ids] of Object.entries(parsed)) {
353
+ if (Array.isArray(ids)) out[type] = ids.filter((id): id is string => typeof id === "string");
354
+ }
355
+ return out;
356
+ } catch {
357
+ return undefined;
358
+ }
359
+ }
@@ -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 &amp; 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
+ });