@elabs-ai/components-process 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/core/index.d.ts +1029 -0
- package/dist/core/index.js +1553 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/process-worker.js +462 -0
- package/dist/core/process-worker.js.map +1 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +3146 -0
- package/dist/index.js.map +1 -0
- package/dist/test/index.d.ts +196 -0
- package/dist/test/index.js +527 -0
- package/dist/test/index.js.map +1 -0
- package/package.json +80 -0
- package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
- package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
- package/src/abstraction-controls/abstraction-controls.tsx +288 -0
- package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
- package/src/abstraction-controls/auto-abstraction.ts +128 -0
- package/src/abstraction-controls/index.ts +4 -0
- package/src/core/abstract-graph.test.ts +209 -0
- package/src/core/abstract-graph.ts +407 -0
- package/src/core/adapters/csv.test.ts +131 -0
- package/src/core/adapters/csv.ts +146 -0
- package/src/core/adapters/flat.test.ts +149 -0
- package/src/core/adapters/flat.ts +168 -0
- package/src/core/aggregate-performance.test.ts +208 -0
- package/src/core/aggregate-performance.ts +200 -0
- package/src/core/detect-rework.test.ts +134 -0
- package/src/core/detect-rework.ts +100 -0
- package/src/core/discover-graph.test.ts +378 -0
- package/src/core/discover-graph.ts +202 -0
- package/src/core/duration-stats.test.ts +116 -0
- package/src/core/duration-stats.ts +162 -0
- package/src/core/event-log.test.ts +224 -0
- package/src/core/event-log.ts +244 -0
- package/src/core/extract-variants.test.ts +126 -0
- package/src/core/extract-variants.ts +140 -0
- package/src/core/filter-log.test.ts +193 -0
- package/src/core/filter-log.ts +215 -0
- package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
- package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
- package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
- package/src/core/fixtures/order-to-cash-small.json +200 -0
- package/src/core/fixtures/synthetic-log.test.ts +109 -0
- package/src/core/fixtures/synthetic-log.ts +167 -0
- package/src/core/index.ts +118 -0
- package/src/core/reconcile-graph.test.ts +175 -0
- package/src/core/reconcile-graph.ts +107 -0
- package/src/core/scale.test.ts +80 -0
- package/src/core/scale.ts +100 -0
- package/src/core/types.ts +151 -0
- package/src/core/worker/create-process-worker.test.ts +255 -0
- package/src/core/worker/create-process-worker.ts +211 -0
- package/src/core/worker/process-worker.ts +80 -0
- package/src/index.ts +29 -0
- package/src/metric-layer-switch/index.ts +6 -0
- package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
- package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
- package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
- package/src/process-explorer.stories.tsx +392 -0
- package/src/process-kpi-strip/index.ts +6 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
- package/src/process-map/index.ts +13 -0
- package/src/process-map/map-model.test.ts +326 -0
- package/src/process-map/map-model.ts +873 -0
- package/src/process-map/process-activity-node.tsx +200 -0
- package/src/process-map/process-map-context.ts +71 -0
- package/src/process-map/process-map.stories.tsx +673 -0
- package/src/process-map/process-map.test.tsx +523 -0
- package/src/process-map/process-map.tsx +979 -0
- package/src/process-map/process-transition-edge.test.tsx +160 -0
- package/src/process-map/process-transition-edge.tsx +151 -0
- package/src/process-map/use-process-layout.test.tsx +265 -0
- package/src/process-map/use-process-layout.ts +315 -0
- package/src/test/contract.test.ts +99 -0
- package/src/test/contract.ts +118 -0
- package/src/test/doubles.test.tsx +51 -0
- package/src/test/doubles.tsx +82 -0
- package/src/test/index.ts +34 -0
- package/src/test/primitives.tsx +35 -0
- package/src/use-process-explorer/index.ts +8 -0
- package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
- package/src/use-process-explorer/use-process-explorer.ts +540 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { normalizeLog } from "../event-log";
|
|
4
|
+
import { fromCsv, parseDelimited } from "./csv";
|
|
5
|
+
|
|
6
|
+
const mapping = { caseId: "case", activity: "activity", timestamp: "timestamp" };
|
|
7
|
+
|
|
8
|
+
describe("parseDelimited", () => {
|
|
9
|
+
it("parses a plain comma-separated table", () => {
|
|
10
|
+
expect(parseDelimited("a,b,c\n1,2,3")).toEqual([
|
|
11
|
+
["a", "b", "c"],
|
|
12
|
+
["1", "2", "3"],
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("does not emit a phantom record for a trailing newline", () => {
|
|
17
|
+
expect(parseDelimited("a,b\n1,2\n")).toHaveLength(2);
|
|
18
|
+
expect(parseDelimited("a,b\r\n1,2\r\n")).toHaveLength(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps a delimiter, a newline and a CRLF inside a quoted field", () => {
|
|
22
|
+
expect(parseDelimited('a,b\n"x,y","line1\nline2"')[1]).toEqual(["x,y", "line1\nline2"]);
|
|
23
|
+
expect(parseDelimited('a\n"line1\r\nline2"')[1]).toEqual(["line1\r\nline2"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("reads a doubled quote inside a quoted field as one literal quote", () => {
|
|
27
|
+
expect(parseDelimited('a\n"say ""hi"""')[1]).toEqual(['say "hi"']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("strips a UTF-8 BOM from the first field", () => {
|
|
31
|
+
expect(parseDelimited("case,activity\n1,Ship")[0]).toEqual(["case", "activity"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("preserves empty fields, including a trailing one", () => {
|
|
35
|
+
expect(parseDelimited("a,b,c\n1,,3")[1]).toEqual(["1", "", "3"]);
|
|
36
|
+
expect(parseDelimited("a,b\n1,")[1]).toEqual(["1", ""]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not trim field values — trimming is the mapping's decision", () => {
|
|
40
|
+
expect(parseDelimited("a\n spaced ")[1]).toEqual([" spaced "]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("tolerates an unterminated quote by running the field to the end", () => {
|
|
44
|
+
expect(parseDelimited('a\n"never closed')[1]).toEqual(["never closed"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("honours a non-comma delimiter", () => {
|
|
48
|
+
expect(parseDelimited("a\tb\n1\t2", "\t")[1]).toEqual(["1", "2"]);
|
|
49
|
+
expect(parseDelimited("a;b\n1;2", ";")[1]).toEqual(["1", "2"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("answers empty text with no records", () => {
|
|
53
|
+
expect(parseDelimited("")).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("fromCsv", () => {
|
|
58
|
+
const text = [
|
|
59
|
+
"case,activity,timestamp,resource",
|
|
60
|
+
"1,Create Order,2026-01-05T09:00:00Z,A. Novak",
|
|
61
|
+
"1,Check Credit,2026-01-05T10:00:00Z,Credit Service",
|
|
62
|
+
"2,Create Order,2026-01-06T09:00:00Z,A. Novak",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n");
|
|
65
|
+
|
|
66
|
+
it("reads the header row and maps the columns", () => {
|
|
67
|
+
const log = fromCsv(text, { ...mapping, resource: "resource" });
|
|
68
|
+
expect(log.events).toHaveLength(3);
|
|
69
|
+
expect(log.events[0]).toEqual({
|
|
70
|
+
caseId: "1",
|
|
71
|
+
activity: "Create Order",
|
|
72
|
+
timestamp: "2026-01-05T09:00:00Z",
|
|
73
|
+
resource: "A. Novak",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("trims header names so a hand-edited export still matches", () => {
|
|
78
|
+
const log = fromCsv("case , activity ,timestamp\n1,Ship,2026-01-05T09:00:00Z", mapping);
|
|
79
|
+
expect(log.events).toHaveLength(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("accepts a headerless source when the caller names the columns", () => {
|
|
83
|
+
const log = fromCsv("1,Ship,2026-01-05T09:00:00Z", {
|
|
84
|
+
...mapping,
|
|
85
|
+
header: ["case", "activity", "timestamp"],
|
|
86
|
+
});
|
|
87
|
+
expect(log.events[0]?.activity).toBe("Ship");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("handles a quoted activity name containing the delimiter", () => {
|
|
91
|
+
const log = fromCsv(
|
|
92
|
+
'case,activity,timestamp\n1,"Approve, then ship",2026-01-05T09:00:00Z',
|
|
93
|
+
mapping,
|
|
94
|
+
);
|
|
95
|
+
expect(log.events[0]?.activity).toBe("Approve, then ship");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("reads a semicolon-delimited European export", () => {
|
|
99
|
+
const log = fromCsv("case;activity;timestamp\n1;Ship;2026-01-05T09:00:00Z", {
|
|
100
|
+
...mapping,
|
|
101
|
+
delimiter: ";",
|
|
102
|
+
});
|
|
103
|
+
expect(log.events[0]?.caseId).toBe("1");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("answers empty text with an empty log rather than throwing", () => {
|
|
107
|
+
expect(fromCsv("", mapping)).toEqual({ events: [] });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("delegates skipping to fromFlatRows — a short row does not become an event", () => {
|
|
111
|
+
const log = fromCsv("case,activity,timestamp\n1,Ship\n2,Pay,2026-01-05T09:00:00Z", mapping);
|
|
112
|
+
expect(log.events).toHaveLength(1);
|
|
113
|
+
expect(log.events[0]?.caseId).toBe("2");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("feeds normalizeLog end to end, lifecycle pairing included", () => {
|
|
117
|
+
const csv = [
|
|
118
|
+
"case,activity,timestamp,phase",
|
|
119
|
+
"1,Pick,2026-01-05T09:00:00Z,start",
|
|
120
|
+
"1,Pack,2026-01-05T10:00:00Z,start",
|
|
121
|
+
"1,Pick,2026-01-05T11:00:00Z,complete",
|
|
122
|
+
"1,Pack,2026-01-05T12:00:00Z,complete",
|
|
123
|
+
].join("\n");
|
|
124
|
+
const normalized = normalizeLog(fromCsv(csv, { ...mapping, lifecycle: "phase" }));
|
|
125
|
+
expect(normalized.totals).toEqual({ cases: 1, events: 2 });
|
|
126
|
+
expect(normalized.cases[0]?.events.map((e) => [e.activity, e.duration])).toEqual([
|
|
127
|
+
["Pick", 7_200_000],
|
|
128
|
+
["Pack", 7_200_000],
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV adapter — RM-049.
|
|
3
|
+
*
|
|
4
|
+
* A minimal RFC 4180 reader plus a thin front end onto {@link fromFlatRows}. It is written
|
|
5
|
+
* here rather than pulled from npm on purpose: `/core` is the framework-free,
|
|
6
|
+
* worker-safe leaf, and a parser this small (quoted fields, escaped quotes, embedded
|
|
7
|
+
* newlines, CRLF, a BOM) does not justify a runtime dependency in a package a consumer
|
|
8
|
+
* imports into a web worker.
|
|
9
|
+
*
|
|
10
|
+
* What it deliberately does NOT do: type inference, header de-duplication, streaming, or
|
|
11
|
+
* dialect sniffing. A source that needs any of those parses itself and calls
|
|
12
|
+
* {@link fromFlatRows} with the rows.
|
|
13
|
+
*/
|
|
14
|
+
import type { EventLog } from "../types";
|
|
15
|
+
import { fromFlatRows, type FlatRow, type FlatRowMapping } from "./flat";
|
|
16
|
+
|
|
17
|
+
/** Options for {@link parseDelimited} and {@link fromCsv}. */
|
|
18
|
+
export interface CsvOptions {
|
|
19
|
+
/** Field separator. Defaults to `","`; pass `"\t"` for TSV, `";"` for a European export. */
|
|
20
|
+
delimiter?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Column names, when the text has NO header row. Omit for the usual case, where the
|
|
23
|
+
* first record is the header.
|
|
24
|
+
*/
|
|
25
|
+
header?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Everything `fromCsv` needs: the column mapping plus the dialect. */
|
|
29
|
+
export type CsvMapping = FlatRowMapping & CsvOptions;
|
|
30
|
+
|
|
31
|
+
const QUOTE = '"';
|
|
32
|
+
const CR = "\r";
|
|
33
|
+
const LF = "\n";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse RFC 4180 delimited text into records of raw string fields.
|
|
37
|
+
*
|
|
38
|
+
* Handles: quoted fields; a doubled `""` inside a quoted field as one literal quote;
|
|
39
|
+
* delimiters, `CR`, `LF` and `CRLF` inside a quoted field; `CRLF` or bare `LF` record
|
|
40
|
+
* separators; a leading UTF-8 BOM; and a trailing newline (which does NOT produce a
|
|
41
|
+
* phantom empty record).
|
|
42
|
+
*
|
|
43
|
+
* A field is returned exactly as written, without trimming — trimming is a MAPPING
|
|
44
|
+
* decision, and `fromFlatRows` makes it. Unterminated quotes are tolerated: the field runs
|
|
45
|
+
* to the end of the text rather than throwing, so a truncated download yields the rows it
|
|
46
|
+
* did contain.
|
|
47
|
+
*/
|
|
48
|
+
export function parseDelimited(text: string, delimiter = ","): string[][] {
|
|
49
|
+
const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
50
|
+
const records: string[][] = [];
|
|
51
|
+
let record: string[] = [];
|
|
52
|
+
let field = "";
|
|
53
|
+
let quoted = false;
|
|
54
|
+
let dirty = false; // has anything been read into the current record at all?
|
|
55
|
+
const delimiterChar = delimiter.length > 0 ? delimiter[0] : ",";
|
|
56
|
+
|
|
57
|
+
const endField = (): void => {
|
|
58
|
+
record.push(field);
|
|
59
|
+
field = "";
|
|
60
|
+
dirty = true;
|
|
61
|
+
};
|
|
62
|
+
const endRecord = (): void => {
|
|
63
|
+
endField();
|
|
64
|
+
records.push(record);
|
|
65
|
+
record = [];
|
|
66
|
+
dirty = false;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
70
|
+
const char = source[i] as string;
|
|
71
|
+
|
|
72
|
+
if (quoted) {
|
|
73
|
+
if (char === QUOTE) {
|
|
74
|
+
if (source[i + 1] === QUOTE) {
|
|
75
|
+
field += QUOTE;
|
|
76
|
+
i += 1;
|
|
77
|
+
} else {
|
|
78
|
+
quoted = false;
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
field += char;
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (char === QUOTE && field === "") {
|
|
87
|
+
quoted = true;
|
|
88
|
+
dirty = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (char === delimiterChar) {
|
|
92
|
+
endField();
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (char === CR) {
|
|
96
|
+
if (source[i + 1] === LF) i += 1;
|
|
97
|
+
endRecord();
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (char === LF) {
|
|
101
|
+
endRecord();
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
field += char;
|
|
105
|
+
dirty = true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (field !== "" || dirty) endRecord();
|
|
109
|
+
return records;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Turn parsed records into objects keyed by `header`. Missing trailing cells read as `""`. */
|
|
113
|
+
export function toObjects(records: readonly string[][], header: readonly string[]): FlatRow[] {
|
|
114
|
+
const rows: FlatRow[] = [];
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
const row: FlatRow = {};
|
|
117
|
+
for (let i = 0; i < header.length; i += 1) {
|
|
118
|
+
row[header[i] as string] = record[i] ?? "";
|
|
119
|
+
}
|
|
120
|
+
rows.push(row);
|
|
121
|
+
}
|
|
122
|
+
return rows;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read delimited text into an {@link EventLog}.
|
|
127
|
+
*
|
|
128
|
+
* The first record is the header unless `mapping.header` supplies one. Column names in
|
|
129
|
+
* the header are trimmed (a `", activity"` from a hand-edited export still matches
|
|
130
|
+
* `activity`); field VALUES are not, so `fromFlatRows` stays the only place that decides
|
|
131
|
+
* what an empty cell means.
|
|
132
|
+
*
|
|
133
|
+
* Empty text yields an empty log rather than throwing.
|
|
134
|
+
*/
|
|
135
|
+
export function fromCsv(text: string, mapping: CsvMapping): EventLog {
|
|
136
|
+
const records = parseDelimited(text, mapping.delimiter ?? ",");
|
|
137
|
+
let header = mapping.header;
|
|
138
|
+
let body = records;
|
|
139
|
+
if (header === undefined) {
|
|
140
|
+
const first = records[0];
|
|
141
|
+
if (first === undefined) return { events: [] };
|
|
142
|
+
header = first.map((name) => name.trim());
|
|
143
|
+
body = records.slice(1);
|
|
144
|
+
}
|
|
145
|
+
return fromFlatRows(toObjects(body, header), mapping);
|
|
146
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { normalizeLog } from "../event-log";
|
|
4
|
+
import { DEFAULT_LIFECYCLE_VALUES, fromFlatRows, normalizeLifecycle } from "./flat";
|
|
5
|
+
import type { FlatRow } from "./flat";
|
|
6
|
+
|
|
7
|
+
const rows: FlatRow[] = [
|
|
8
|
+
{
|
|
9
|
+
case: "1",
|
|
10
|
+
step: "Create Order",
|
|
11
|
+
when: "2026-01-05T09:00:00Z",
|
|
12
|
+
who: "A. Novak",
|
|
13
|
+
region: "North",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
case: "1",
|
|
17
|
+
step: "Check Credit",
|
|
18
|
+
when: "2026-01-05T10:00:00Z",
|
|
19
|
+
who: "Credit Service",
|
|
20
|
+
region: "North",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
case: "2",
|
|
24
|
+
step: "Create Order",
|
|
25
|
+
when: "2026-01-06T09:00:00Z",
|
|
26
|
+
who: "A. Novak",
|
|
27
|
+
region: "South",
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const mapping = { caseId: "case", activity: "step", timestamp: "when", resource: "who" };
|
|
32
|
+
|
|
33
|
+
describe("fromFlatRows", () => {
|
|
34
|
+
it("maps the three required columns onto EventRows", () => {
|
|
35
|
+
const log = fromFlatRows(rows, mapping);
|
|
36
|
+
expect(log.events).toHaveLength(3);
|
|
37
|
+
expect(log.events[0]).toEqual({
|
|
38
|
+
caseId: "1",
|
|
39
|
+
activity: "Create Order",
|
|
40
|
+
timestamp: "2026-01-05T09:00:00Z",
|
|
41
|
+
resource: "A. Novak",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("skips rows missing a case id, an activity or a timestamp rather than throwing", () => {
|
|
46
|
+
const log = fromFlatRows(
|
|
47
|
+
[
|
|
48
|
+
...rows,
|
|
49
|
+
{ case: "", step: "Ship", when: "2026-01-05T09:00:00Z" },
|
|
50
|
+
{ case: "3", step: "", when: "2026-01-05T09:00:00Z" },
|
|
51
|
+
{ case: "3", step: "Ship", when: "" },
|
|
52
|
+
{ case: "3", step: "Ship", when: null },
|
|
53
|
+
{},
|
|
54
|
+
],
|
|
55
|
+
mapping,
|
|
56
|
+
);
|
|
57
|
+
expect(log.events).toHaveLength(3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("trims cell values and treats a blank cell as absent", () => {
|
|
61
|
+
const log = fromFlatRows(
|
|
62
|
+
[{ case: " 1 ", step: " Ship ", when: " 2026-01-05T09:00:00Z " }],
|
|
63
|
+
mapping,
|
|
64
|
+
);
|
|
65
|
+
expect(log.events[0]).toMatchObject({ caseId: "1", activity: "Ship" });
|
|
66
|
+
expect(log.events[0]?.resource).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("passes a Date or an epoch number through without pre-converting it", () => {
|
|
70
|
+
const date = new Date("2026-01-05T09:00:00Z");
|
|
71
|
+
const log = fromFlatRows(
|
|
72
|
+
[
|
|
73
|
+
{ case: "1", step: "Ship", when: date },
|
|
74
|
+
{ case: "1", step: "Pay", when: 1_767_600_000_000 },
|
|
75
|
+
],
|
|
76
|
+
mapping,
|
|
77
|
+
);
|
|
78
|
+
expect(log.events[0]?.timestamp).toBe(date);
|
|
79
|
+
expect(log.events[1]?.timestamp).toBe(1_767_600_000_000);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("copies the named columns into event attributes", () => {
|
|
83
|
+
const log = fromFlatRows(rows, { ...mapping, attributes: ["region"] });
|
|
84
|
+
expect(log.events[0]?.attributes).toEqual({ region: "North" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("takes the FIRST non-empty value per case for case attributes", () => {
|
|
88
|
+
const log = fromFlatRows(
|
|
89
|
+
[
|
|
90
|
+
{ case: "1", step: "A", when: "2026-01-05T09:00:00Z", region: "North" },
|
|
91
|
+
{ case: "1", step: "B", when: "2026-01-05T10:00:00Z", region: "Elsewhere" },
|
|
92
|
+
],
|
|
93
|
+
{ ...mapping, caseAttributes: ["region"] },
|
|
94
|
+
);
|
|
95
|
+
expect(log.caseAttributes).toEqual({ "1": { region: "North" } });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("omits caseAttributes entirely when none were requested", () => {
|
|
99
|
+
expect(fromFlatRows(rows, mapping).caseAttributes).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("feeds normalizeLog directly — the adapter's whole reason to exist", () => {
|
|
103
|
+
const normalized = normalizeLog(fromFlatRows(rows, mapping));
|
|
104
|
+
expect(normalized.totals).toEqual({ cases: 2, events: 3 });
|
|
105
|
+
expect(normalized.cases[0]?.events.map((e) => e.activity)).toEqual([
|
|
106
|
+
"Create Order",
|
|
107
|
+
"Check Credit",
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("normalizeLifecycle", () => {
|
|
113
|
+
it("recognizes the default spellings, case-insensitively", () => {
|
|
114
|
+
expect(normalizeLifecycle("START")).toBe("start");
|
|
115
|
+
expect(normalizeLifecycle(" Completed ")).toBe("complete");
|
|
116
|
+
expect(normalizeLifecycle("schedule")).toBe("start");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("answers undefined for an unknown or empty value", () => {
|
|
120
|
+
expect(normalizeLifecycle("suspend")).toBeUndefined();
|
|
121
|
+
expect(normalizeLifecycle("")).toBeUndefined();
|
|
122
|
+
expect(normalizeLifecycle(undefined)).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("honours a source that spells the transitions differently", () => {
|
|
126
|
+
expect(normalizeLifecycle("S", { start: ["S"], complete: ["C"] })).toBe("start");
|
|
127
|
+
expect(normalizeLifecycle("C", { start: ["S"], complete: ["C"] })).toBe("complete");
|
|
128
|
+
// An override of one half falls back to the defaults for the other.
|
|
129
|
+
expect(normalizeLifecycle("complete", { start: ["S"] })).toBe("complete");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("reaches normalizeLog through fromFlatRows and pairs the two halves", () => {
|
|
133
|
+
const log = fromFlatRows(
|
|
134
|
+
[
|
|
135
|
+
{ case: "1", step: "Pick", when: "2026-01-05T09:00:00Z", phase: "S" },
|
|
136
|
+
{ case: "1", step: "Pick", when: "2026-01-05T10:00:00Z", phase: "C" },
|
|
137
|
+
],
|
|
138
|
+
{ ...mapping, lifecycle: "phase", lifecycleValues: { start: ["S"], complete: ["C"] } },
|
|
139
|
+
);
|
|
140
|
+
const events = normalizeLog(log).cases[0]?.events ?? [];
|
|
141
|
+
expect(events).toHaveLength(1);
|
|
142
|
+
expect(events[0]?.duration).toBe(3_600_000);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("exports the default spellings so a caller can extend rather than replace them", () => {
|
|
146
|
+
expect(DEFAULT_LIFECYCLE_VALUES.start).toContain("start");
|
|
147
|
+
expect(DEFAULT_LIFECYCLE_VALUES.complete).toContain("complete");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flat tabular adapter — RM-049.
|
|
3
|
+
*
|
|
4
|
+
* Pre-shaped tabular data is the lingua franca of event logs: whatever the source (a
|
|
5
|
+
* warehouse query, a spreadsheet export, an API page), it arrives as rows of columns. This
|
|
6
|
+
* adapter maps those columns onto `EventRow`, so every other importer — the CSV reader
|
|
7
|
+
* next door, and any future one — can be a thin front end over this single mapping step
|
|
8
|
+
* rather than a second, subtly different interpretation of the same log.
|
|
9
|
+
*
|
|
10
|
+
* Pure and deterministic: no clock, no I/O, no mutation of the caller's rows.
|
|
11
|
+
*/
|
|
12
|
+
import type { EventLog, EventRow } from "../types";
|
|
13
|
+
|
|
14
|
+
/** One row of a flat source. Values are read defensively — a source may hand back anything. */
|
|
15
|
+
export type FlatRow = Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
/** Which raw cell to read a lifecycle transition's `"start"` / `"complete"` from. */
|
|
18
|
+
export interface LifecycleValues {
|
|
19
|
+
start?: readonly string[];
|
|
20
|
+
complete?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Column names that `fromFlatRows` reads. Only `caseId`, `activity` and `timestamp` are required. */
|
|
24
|
+
export interface FlatRowMapping {
|
|
25
|
+
/** Column holding the case (process instance) id. */
|
|
26
|
+
caseId: string;
|
|
27
|
+
/** Column holding the activity name. */
|
|
28
|
+
activity: string;
|
|
29
|
+
/** Column holding the event timestamp. */
|
|
30
|
+
timestamp: string;
|
|
31
|
+
/** Column holding an explicit interval start, when the source models one. */
|
|
32
|
+
startTimestamp?: string;
|
|
33
|
+
/** Column holding the executing resource. */
|
|
34
|
+
resource?: string;
|
|
35
|
+
/** Column holding a lifecycle transition. */
|
|
36
|
+
lifecycle?: string;
|
|
37
|
+
/** Extra columns copied verbatim into `EventRow.attributes`. */
|
|
38
|
+
attributes?: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* Columns copied into `EventLog.caseAttributes`, taking the FIRST non-empty value seen
|
|
41
|
+
* per case. Case attributes are case-level by definition, so a later row disagreeing
|
|
42
|
+
* with an earlier one is a data problem, not something an adapter should silently
|
|
43
|
+
* resolve by overwriting.
|
|
44
|
+
*/
|
|
45
|
+
caseAttributes?: readonly string[];
|
|
46
|
+
/**
|
|
47
|
+
* Raw cell values that mean `"start"` / `"complete"`, compared case-insensitively after
|
|
48
|
+
* trimming. Defaults to {@link DEFAULT_LIFECYCLE_VALUES}; supply your own for a source
|
|
49
|
+
* that spells them differently (`"S"` / `"C"`, `"begin"` / `"done"`).
|
|
50
|
+
*/
|
|
51
|
+
lifecycleValues?: LifecycleValues;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The lifecycle spellings recognized when a mapping does not override them. */
|
|
55
|
+
export const DEFAULT_LIFECYCLE_VALUES: Required<LifecycleValues> = {
|
|
56
|
+
start: ["start", "started", "begin", "assign", "schedule"],
|
|
57
|
+
complete: ["complete", "completed", "end", "finish", "finished", "done"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Read a cell as a trimmed string, treating `null`/`undefined`/`""` as absent. */
|
|
61
|
+
function readString(row: FlatRow, column: string | undefined): string | undefined {
|
|
62
|
+
if (column === undefined) return undefined;
|
|
63
|
+
const value = row[column];
|
|
64
|
+
if (value === undefined || value === null) return undefined;
|
|
65
|
+
const text = typeof value === "string" ? value.trim() : String(value);
|
|
66
|
+
return text === "" ? undefined : text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read a cell as something `toEpochMs` can resolve, without pre-converting it. */
|
|
70
|
+
function readTimestamp(
|
|
71
|
+
row: FlatRow,
|
|
72
|
+
column: string | undefined,
|
|
73
|
+
): string | number | Date | undefined {
|
|
74
|
+
if (column === undefined) return undefined;
|
|
75
|
+
const value = row[column];
|
|
76
|
+
if (value === undefined || value === null) return undefined;
|
|
77
|
+
if (value instanceof Date || typeof value === "number") return value;
|
|
78
|
+
const text = String(value).trim();
|
|
79
|
+
return text === "" ? undefined : text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Coerce a cell into the `attributes` value union; anything else becomes its string form. */
|
|
83
|
+
function readAttribute(value: unknown): string | number | boolean | null {
|
|
84
|
+
if (value === null || value === undefined) return null;
|
|
85
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
if (value instanceof Date) return value.toISOString();
|
|
89
|
+
return String(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Map a raw lifecycle cell onto the canonical pair, or `undefined` when it matches neither. */
|
|
93
|
+
export function normalizeLifecycle(
|
|
94
|
+
raw: string | undefined,
|
|
95
|
+
values: LifecycleValues = DEFAULT_LIFECYCLE_VALUES,
|
|
96
|
+
): "start" | "complete" | undefined {
|
|
97
|
+
if (raw === undefined) return undefined;
|
|
98
|
+
const needle = raw.trim().toLowerCase();
|
|
99
|
+
if (needle === "") return undefined;
|
|
100
|
+
const starts = values.start ?? DEFAULT_LIFECYCLE_VALUES.start;
|
|
101
|
+
const completes = values.complete ?? DEFAULT_LIFECYCLE_VALUES.complete;
|
|
102
|
+
for (const candidate of starts) if (candidate.toLowerCase() === needle) return "start";
|
|
103
|
+
for (const candidate of completes) if (candidate.toLowerCase() === needle) return "complete";
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Map flat rows onto an {@link EventLog}.
|
|
109
|
+
*
|
|
110
|
+
* Rows missing a case id, an activity or a timestamp are SKIPPED rather than throwing: a
|
|
111
|
+
* single blank trailing line or one incomplete record must not fail an entire import, and
|
|
112
|
+
* an event with no activity cannot take part in a directly-follows relation anyway. Count
|
|
113
|
+
* the difference between `rows.length` and `log.events.length` when you need to surface
|
|
114
|
+
* how many were dropped.
|
|
115
|
+
*/
|
|
116
|
+
export function fromFlatRows(rows: readonly FlatRow[], mapping: FlatRowMapping): EventLog {
|
|
117
|
+
const events: EventRow[] = [];
|
|
118
|
+
let caseAttributes: Record<string, Record<string, unknown>> | undefined;
|
|
119
|
+
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
if (row === null || typeof row !== "object") continue;
|
|
122
|
+
const caseId = readString(row, mapping.caseId);
|
|
123
|
+
const activity = readString(row, mapping.activity);
|
|
124
|
+
const timestamp = readTimestamp(row, mapping.timestamp);
|
|
125
|
+
if (caseId === undefined || activity === undefined || timestamp === undefined) continue;
|
|
126
|
+
|
|
127
|
+
const event: EventRow = { caseId, activity, timestamp };
|
|
128
|
+
|
|
129
|
+
const startTimestamp = readTimestamp(row, mapping.startTimestamp);
|
|
130
|
+
if (startTimestamp !== undefined) event.startTimestamp = startTimestamp;
|
|
131
|
+
|
|
132
|
+
const resource = readString(row, mapping.resource);
|
|
133
|
+
if (resource !== undefined) event.resource = resource;
|
|
134
|
+
|
|
135
|
+
const lifecycle = normalizeLifecycle(
|
|
136
|
+
readString(row, mapping.lifecycle),
|
|
137
|
+
mapping.lifecycleValues,
|
|
138
|
+
);
|
|
139
|
+
if (lifecycle !== undefined) event.lifecycle = lifecycle;
|
|
140
|
+
|
|
141
|
+
if (mapping.attributes !== undefined && mapping.attributes.length > 0) {
|
|
142
|
+
const attributes: Record<string, string | number | boolean | null> = {};
|
|
143
|
+
let any = false;
|
|
144
|
+
for (const column of mapping.attributes) {
|
|
145
|
+
if (!(column in row)) continue;
|
|
146
|
+
attributes[column] = readAttribute(row[column]);
|
|
147
|
+
any = true;
|
|
148
|
+
}
|
|
149
|
+
if (any) event.attributes = attributes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (mapping.caseAttributes !== undefined && mapping.caseAttributes.length > 0) {
|
|
153
|
+
caseAttributes ??= {};
|
|
154
|
+
const bucket = (caseAttributes[caseId] ??= {});
|
|
155
|
+
for (const column of mapping.caseAttributes) {
|
|
156
|
+
if (bucket[column] !== undefined) continue;
|
|
157
|
+
const value = readString(row, column);
|
|
158
|
+
if (value !== undefined) bucket[column] = value;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
events.push(event);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const log: EventLog = { events };
|
|
166
|
+
if (caseAttributes !== undefined) log.caseAttributes = caseAttributes;
|
|
167
|
+
return log;
|
|
168
|
+
}
|