@bldrs-ai/conway 1.543.1513 → 1.548.1514

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.
@@ -30,7 +30,7 @@ var import_node_process = require("node:process");
30
30
  var readline = __toESM(require("node:readline"), 1);
31
31
 
32
32
  // compiled/src/version/version.js
33
- var versionString = "Conway v1.543.1513";
33
+ var versionString = "Conway v1.548.1514";
34
34
 
35
35
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
36
36
  var wasmType = "";
@@ -14965,7 +14965,7 @@ ${t5.join("\n")}` : "";
14965
14965
  var import_process = require("process");
14966
14966
 
14967
14967
  // compiled/src/version/version.js
14968
- var versionString = "Conway v1.543.1513";
14968
+ var versionString = "Conway v1.548.1514";
14969
14969
 
14970
14970
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
14971
14971
  function pThreadsAllowed() {
@@ -15943,7 +15943,7 @@ var ParsingBuffer = class {
15943
15943
  };
15944
15944
 
15945
15945
  // compiled/src/version/version.js
15946
- var versionString = "Conway v1.543.1513";
15946
+ var versionString = "Conway v1.548.1514";
15947
15947
 
15948
15948
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
15949
15949
  function pThreadsAllowed() {
@@ -944,7 +944,7 @@ var EntityTypesIfcCount = 909;
944
944
  var entity_types_ifc_gen_default = EntityTypesIfc;
945
945
 
946
946
  // compiled/src/version/version.js
947
- var versionString = "Conway v1.543.1513";
947
+ var versionString = "Conway v1.548.1514";
948
948
 
949
949
  // compiled/dependencies/conway-geom/interface/conway_geometry.js
950
950
  var wasmType = "";
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=bless_perf_snapshot.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bless_perf_snapshot.test.d.ts","sourceRoot":"","sources":["../../../src/scripts/bless_perf_snapshot.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,278 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
5
+ import { createRequire } from 'module';
6
+ /**
7
+ * The rc-regression bless path's perf snapshot (scripts/bless_perf_snapshot.cjs).
8
+ *
9
+ * The `rebless` job measures the full corpus with
10
+ * `ifc_regression_batch_main --perf`, whose 8-column perf.csv is a different
11
+ * file from the 15-column `performance-detail.csv` the committed benchmark
12
+ * snapshots use. This pins the mapping between them — the shape a delta and
13
+ * GitHub's CSV viewer both depend on — and the choice of predecessor to diff
14
+ * against.
15
+ */
16
+ const require_ = createRequire(import.meta.url);
17
+ // Resolved from the repo root: the test runs from compiled/src/scripts, and
18
+ // scripts/ is not part of the tsc build. Jest's rootDir is the repo root.
19
+ const { DETAIL_COLUMNS, findPreviousSnapshot, isChronologicalDelta, removeStaleDeltas, writeDetailCsv, versionCompare, } = require_(path.resolve(process.cwd(), 'scripts/bless_perf_snapshot.cjs'));
20
+ const { parseCsv } = require_(path.resolve(process.cwd(), 'scripts/csv_rfc4180.cjs'));
21
+ let workDir;
22
+ beforeEach(() => {
23
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bless-perf-'));
24
+ });
25
+ afterEach(() => {
26
+ fs.rmSync(workDir, { recursive: true, force: true });
27
+ });
28
+ describe('writeDetailCsv', () => {
29
+ test('maps perf.csv onto the 15-column convention, N/A for unmeasured', () => {
30
+ const out = path.join(workDir, 'performance-detail.csv');
31
+ writeDetailCsv([{
32
+ file: 'Snowdon Towers Sample Architectural_IFC4.ifc',
33
+ status: 'OK',
34
+ parseTimeMs: '1200',
35
+ geometryTimeMs: '5400',
36
+ totalTimeMs: '6600',
37
+ rssMb: '812.50',
38
+ heapUsedMb: '410.25',
39
+ heapTotalMb: '450.00',
40
+ }], out, 'conway1.543.1513-ci', '20260821221710');
41
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
42
+ expect(records[0]).toEqual(DETAIL_COLUMNS);
43
+ expect(records).toHaveLength(2);
44
+ const row = records[1];
45
+ expect(row).toHaveLength(DETAIL_COLUMNS.length);
46
+ /**
47
+ * Read one column of the written row.
48
+ *
49
+ * @param column Column name.
50
+ * @return The cell value.
51
+ */
52
+ const cell = (column) => row[DETAIL_COLUMNS.indexOf(column)];
53
+ expect(cell('engine')).toBe('conway1.543.1513-ci');
54
+ expect(cell('loadStatus')).toBe('OK');
55
+ expect(cell('parseTimeMs')).toBe('1200');
56
+ expect(cell('geometryTimeMs')).toBe('5400');
57
+ expect(cell('totalTimeMs')).toBe('6600');
58
+ expect(cell('rssMb')).toBe('812.50');
59
+ // The committed snapshots URL-encode the filename and the delta joins on
60
+ // it, so an unencoded name would simply fail to match the baseline row.
61
+ expect(cell('filename'))
62
+ .toBe('Snowdon%20Towers%20Sample%20Architectural_IFC4.ifc');
63
+ // perf.csv does not carry these; they stay as placeholders rather than
64
+ // being dropped, so the row keeps its 15-column shape.
65
+ for (const column of ['schemaVersion', 'geometryMemoryMb', 'preprocessorVersion',
66
+ 'originatingSystem']) {
67
+ expect(cell(column)).toBe('N/A');
68
+ }
69
+ });
70
+ test('carries a FAIL row through instead of dropping it', () => {
71
+ const out = path.join(workDir, 'fail.csv');
72
+ writeDetailCsv([{
73
+ file: 'broken.ifc', status: 'FAIL', parseTimeMs: '90',
74
+ geometryTimeMs: '0', totalTimeMs: '90', rssMb: '', heapUsedMb: '',
75
+ heapTotalMb: '',
76
+ }], out, 'conway1.543.1513-ci', '20260821221710');
77
+ const row = parseCsv(fs.readFileSync(out, 'utf8'))[1];
78
+ expect(row[DETAIL_COLUMNS.indexOf('loadStatus')]).toBe('FAIL');
79
+ // An empty measurement becomes N/A, which gen_delta_csv.cjs reads as 0
80
+ // rather than NaN.
81
+ expect(row[DETAIL_COLUMNS.indexOf('rssMb')]).toBe('N/A');
82
+ });
83
+ });
84
+ describe('findPreviousSnapshot', () => {
85
+ /**
86
+ * Create a benchmarks/ directory containing the named snapshot dirs, each
87
+ * with a performance-detail.csv.
88
+ *
89
+ * @param names Snapshot directory names.
90
+ * @param withoutCsv Names to create empty, without a CSV.
91
+ * @return Path to the benchmarks directory.
92
+ */
93
+ function makeBenchmarks(names, withoutCsv = []) {
94
+ const benchmarks = path.join(workDir, 'benchmarks');
95
+ for (const name of [...names, ...withoutCsv]) {
96
+ fs.mkdirSync(path.join(benchmarks, name), { recursive: true });
97
+ }
98
+ for (const name of names) {
99
+ fs.writeFileSync(path.join(benchmarks, name, 'performance-detail.csv'), 'header\n');
100
+ }
101
+ return benchmarks;
102
+ }
103
+ test('picks the highest version, comparing numerically not lexically', () => {
104
+ // 0.9.789 sorts after 0.23.940 as a string; the corpus has both.
105
+ const benchmarks = makeBenchmarks([
106
+ 'conway0.9.789_test-models',
107
+ 'conway0.23.940-ci_test-models',
108
+ 'conway1.451.1357-ci_test-models',
109
+ ]);
110
+ const previous = findPreviousSnapshot(benchmarks, 'conway1.543.1513-ci_test-models', '1.543.1513');
111
+ expect(previous).not.toBeNull();
112
+ expect(previous.name).toBe('conway1.451.1357-ci_test-models');
113
+ // engine1 half of the delta filename, suffix included.
114
+ expect(previous.engine).toBe('conway1.451.1357-ci');
115
+ });
116
+ test('excludes the directory this run is writing', () => {
117
+ // Re-running the same rc must not diff a snapshot against itself.
118
+ const benchmarks = makeBenchmarks([
119
+ 'conway1.451.1357-ci_test-models',
120
+ 'conway1.543.1513-ci_test-models',
121
+ ]);
122
+ const previous = findPreviousSnapshot(benchmarks, 'conway1.543.1513-ci_test-models', '1.543.1513');
123
+ expect(previous.name).toBe('conway1.451.1357-ci_test-models');
124
+ });
125
+ test('ignores webifc dirs and dirs with no performance-detail.csv', () => {
126
+ const benchmarks = makeBenchmarks(['conway0.23.940_test-models'], ['webifc0.0.67_test-models', 'conway9.9.9-ci_test-models']);
127
+ const previous = findPreviousSnapshot(benchmarks, 'conway1.543.1513-ci_test-models', '1.543.1513');
128
+ expect(previous.name).toBe('conway0.23.940_test-models');
129
+ });
130
+ test('never selects a snapshot NEWER than the version being blessed', () => {
131
+ // Re-running an older rc- tag after a newer release has been blessed. An
132
+ // unbounded maximum picks conway1.600.1600-ci and writes
133
+ // `conway1.600.1600-ci_1.543.1513_delta.csv` into the OLDER release's
134
+ // directory — a delta claiming it changed relative to its own future.
135
+ const benchmarks = makeBenchmarks([
136
+ 'conway1.451.1357-ci_test-models',
137
+ 'conway1.543.1513-ci_test-models',
138
+ 'conway1.600.1600-ci_test-models',
139
+ ]);
140
+ const previous = findPreviousSnapshot(benchmarks, 'conway1.543.1513-ci_test-models', '1.543.1513');
141
+ expect(previous.name).toBe('conway1.451.1357-ci_test-models');
142
+ });
143
+ test('bounds strictly below, so an equal version is not a predecessor', () => {
144
+ // The same release under a different directory name (a different harness,
145
+ // say) is not its own predecessor.
146
+ const benchmarks = makeBenchmarks([
147
+ 'conway1.543.1513_test-models',
148
+ 'conway1.543.1513-ci_test-models',
149
+ ]);
150
+ const previous = findPreviousSnapshot(benchmarks, 'conway1.543.1513-ci_test-models', '1.543.1513');
151
+ expect(previous).toBeNull();
152
+ });
153
+ test('returns null rather than reaching upward when nothing precedes', () => {
154
+ // The first blessed release in a repo. No delta is the correct outcome;
155
+ // silently picking the nearest snapshot in either direction is not.
156
+ const benchmarks = makeBenchmarks(['conway1.600.1600-ci_test-models']);
157
+ expect(findPreviousSnapshot(benchmarks, 'conway1.451.1357-ci_test-models', '1.451.1357')).toBeNull();
158
+ });
159
+ test('bounds numerically, not lexicographically', () => {
160
+ // Both of these directories really exist in test-models/benchmarks, and
161
+ // they are conway#533's trap: 0.23.940 is NEWER than 0.9.789 by number but
162
+ // sorts BELOW it as a string ('2' < '9' at the third character). A string
163
+ // bound therefore fails to exclude it, and since it is the numeric maximum
164
+ // it gets picked — handing the older release a delta against its future,
165
+ // which is the whole bug this bound exists to stop.
166
+ const benchmarks = makeBenchmarks([
167
+ 'conway0.8.782_test-models',
168
+ 'conway0.23.940_test-models',
169
+ ]);
170
+ const previous = findPreviousSnapshot(benchmarks, 'conway0.9.789_test-models', '0.9.789');
171
+ expect(previous.name).toBe('conway0.8.782_test-models');
172
+ });
173
+ test('returns null when there is no prior snapshot', () => {
174
+ expect(findPreviousSnapshot(path.join(workDir, 'nope'), 'x', '1.0.0'))
175
+ .toBeNull();
176
+ expect(findPreviousSnapshot(makeBenchmarks([]), 'x', '1.0.0')).toBeNull();
177
+ });
178
+ });
179
+ describe('versionCompare', () => {
180
+ test('orders by numeric component, not string order', () => {
181
+ expect(versionCompare('0.9.789', '0.23.940')).toBeLessThan(0);
182
+ expect(versionCompare('1.543.1513', '1.451.1357')).toBeGreaterThan(0);
183
+ expect(versionCompare('1.0.0', '1.0.0')).toBe(0);
184
+ });
185
+ });
186
+ describe('removeStaleDeltas', () => {
187
+ /** Everything a real release snapshot directory holds today. */
188
+ const RELEASE_DIR_CONTENTS = [
189
+ '00-command.log.txt',
190
+ '00-rendering-server.log.txt',
191
+ 'README.md',
192
+ 'conway0.22.921_0.23.940_delta.csv',
193
+ 'index.html',
194
+ 'performance-detail.csv',
195
+ 'performance.csv',
196
+ 'performance.err.txt',
197
+ 'webifc0.0.56_conway0.23.940_delta.csv',
198
+ 'webifc0.0.67_conway0.23.940_delta.csv',
199
+ ];
200
+ /**
201
+ * Populate a snapshot directory with the given entry names.
202
+ *
203
+ * @param names File names to create.
204
+ * @return The directory path.
205
+ */
206
+ function makeReleaseDir(names) {
207
+ const dir = path.join(workDir, 'conway0.23.940_test-models');
208
+ fs.mkdirSync(dir, { recursive: true });
209
+ for (const name of names) {
210
+ fs.writeFileSync(path.join(dir, name), 'x');
211
+ }
212
+ return dir;
213
+ }
214
+ test('removes only this release chronological delta', () => {
215
+ // The exact contents of benchmarks/conway0.23.940_test-models/, which
216
+ // legitimately carries one chronological delta AND two cross-engine ones.
217
+ const dir = makeReleaseDir(RELEASE_DIR_CONTENTS);
218
+ const removed = removeStaleDeltas(dir, '0.23.940');
219
+ expect(removed).toEqual(['conway0.22.921_0.23.940_delta.csv']);
220
+ expect(fs.readdirSync(dir).sort()).toEqual(RELEASE_DIR_CONTENTS
221
+ .filter((n) => n !== 'conway0.22.921_0.23.940_delta.csv').sort());
222
+ });
223
+ test('leaves the cross-engine deltas alone — they are a different comparison', () => {
224
+ const dir = makeReleaseDir(RELEASE_DIR_CONTENTS);
225
+ removeStaleDeltas(dir, '0.23.940');
226
+ expect(fs.existsSync(path.join(dir, 'webifc0.0.56_conway0.23.940_delta.csv')))
227
+ .toBe(true);
228
+ expect(fs.existsSync(path.join(dir, 'webifc0.0.67_conway0.23.940_delta.csv')))
229
+ .toBe(true);
230
+ });
231
+ test('never touches the data files or the README', () => {
232
+ const dir = makeReleaseDir(RELEASE_DIR_CONTENTS);
233
+ removeStaleDeltas(dir, '0.23.940');
234
+ for (const kept of ['performance-detail.csv', 'performance.csv',
235
+ 'performance.err.txt', 'README.md', 'index.html',
236
+ '00-command.log.txt', '00-rendering-server.log.txt']) {
237
+ expect(fs.existsSync(path.join(dir, kept))).toBe(true);
238
+ }
239
+ });
240
+ test('clears a delta whose predecessor changed, so only one survives', () => {
241
+ // The case codex found: an already-blessed rc re-run after an older
242
+ // snapshot was backfilled picks a different predecessor and writes a
243
+ // differently NAMED file, leaving two deltas against different
244
+ // predecessors in one directory with the README naming only one.
245
+ const dir = makeReleaseDir([
246
+ 'performance-detail.csv',
247
+ 'conway0.21.915_0.23.940_delta.csv',
248
+ ]);
249
+ const removed = removeStaleDeltas(dir, '0.23.940');
250
+ expect(removed).toEqual(['conway0.21.915_0.23.940_delta.csv']);
251
+ expect(fs.readdirSync(dir)).toEqual(['performance-detail.csv']);
252
+ });
253
+ test('does not remove another release delta that happens to be present', () => {
254
+ const dir = makeReleaseDir([
255
+ 'performance-detail.csv',
256
+ 'conway0.22.921_0.23.940_delta.csv',
257
+ ]);
258
+ expect(removeStaleDeltas(dir, '1.543.1513')).toEqual([]);
259
+ expect(fs.existsSync(path.join(dir, 'conway0.22.921_0.23.940_delta.csv')))
260
+ .toBe(true);
261
+ });
262
+ });
263
+ describe('isChronologicalDelta', () => {
264
+ test('matches the naming convention and nothing else', () => {
265
+ expect(isChronologicalDelta('conway0.22.921_0.23.940_delta.csv', '0.23.940'))
266
+ .toBe(true);
267
+ expect(isChronologicalDelta('conway1.451.1357-ci_1.543.1513_delta.csv', '1.543.1513')).toBe(true);
268
+ // Cross-engine: starts with webifc, so never matched.
269
+ expect(isChronologicalDelta('webifc0.0.67_conway0.23.940_delta.csv', '0.23.940')).toBe(false);
270
+ // Wrong release.
271
+ expect(isChronologicalDelta('conway0.22.921_0.23.940_delta.csv', '1.543.1513')).toBe(false);
272
+ // Not a delta at all.
273
+ for (const name of ['performance-detail.csv', 'performance.csv',
274
+ 'README.md', 'index.html', '00-command.log.txt']) {
275
+ expect(isChronologicalDelta(name, '0.23.940')).toBe(false);
276
+ }
277
+ });
278
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=perf_csv_quoting.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"perf_csv_quoting.test.d.ts","sourceRoot":"","sources":["../../../src/scripts/perf_csv_quoting.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,300 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { describe, expect, test, beforeAll, afterAll } from '@jest/globals';
5
+ import { createRequire } from 'module';
6
+ /**
7
+ * RFC 4180 quoting in the perf CSVs (`performance-detail.csv` and the delta
8
+ * CSVs derived from it).
9
+ *
10
+ * `preprocessorVersion` and `originatingSystem` are free text lifted straight
11
+ * out of an IFC/STEP FILE_NAME header. Emitted unquoted, a value like
12
+ * `Trimble Nova (Build = 16.2.0.15, Compile = Sep 23 2021)` splits the record
13
+ * into 16 columns against a 15-column header — which is exactly what five
14
+ * committed rows in test-models / test-models-private did, and why GitHub's
15
+ * CSV viewer refused to render them.
16
+ *
17
+ * Both halves are pinned here: the writer must quote, and the reader
18
+ * gen_delta_csv.cjs uses must honour the quotes, or the join silently reads
19
+ * the wrong columns.
20
+ */
21
+ const require_ = createRequire(import.meta.url);
22
+ // Resolved from the repo root rather than relative to this file: the test runs
23
+ // from compiled/src/scripts, where a relative hop would land in
24
+ // compiled/scripts, which does not exist (scripts/ is not part of the tsc
25
+ // build). Jest's rootDir is the repo root.
26
+ const { csvField, csvRow, parseCsv } = require_(path.resolve(process.cwd(), 'scripts/csv_rfc4180.cjs'));
27
+ const { generateDeltaCSV } = require_(path.resolve(process.cwd(), 'scripts/gen_delta_csv.cjs'));
28
+ /**
29
+ * The shape that broke, plus a double quote — the IFC header field can carry
30
+ * one and nothing in the pipeline was escaping it either.
31
+ */
32
+ const NASTY_PREPROCESSOR = 'Trimble Nova (Build = 16.2.0.15, Compile = "Sep 23 2021")';
33
+ /** Columns in the delta CSV, per the committed `*_delta.csv` convention. */
34
+ const DELTA_COLUMN_COUNT = 18;
35
+ const DETAIL_HEADER = [
36
+ 'timestamp', 'loadStatus', 'uname', 'engine', 'filename', 'schemaVersion',
37
+ 'parseTimeMs', 'geometryTimeMs', 'totalTimeMs', 'geometryMemoryMb',
38
+ 'rssMb', 'heapUsedMb', 'heapTotalMb', 'preprocessorVersion',
39
+ 'originatingSystem',
40
+ ];
41
+ /**
42
+ * Build a performance-detail.csv row the way scripts/benchmark.cjs does —
43
+ * through csvRow, every field, in column order.
44
+ *
45
+ * @param filename Model filename (the delta's join key).
46
+ * @param totalTimeMs Total load time for the row.
47
+ * @return The encoded record, without a trailing newline.
48
+ */
49
+ function detailRow(filename, totalTimeMs) {
50
+ return csvRow([
51
+ '20260811153721', 'OK', 'x64', 'conway1.451.1357', filename, 'IFC2X3',
52
+ '71', '245', totalTimeMs, '0.776', '300.859', '137.000', '157.582',
53
+ NASTY_PREPROCESSOR, 'N/A',
54
+ ]);
55
+ }
56
+ let workDir;
57
+ beforeAll(() => {
58
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-csv-quoting-'));
59
+ });
60
+ afterAll(() => {
61
+ fs.rmSync(workDir, { recursive: true, force: true });
62
+ });
63
+ describe('csvField', () => {
64
+ test('leaves a field with no special characters alone', () => {
65
+ expect(csvField('conway1.451.1357')).toBe('conway1.451.1357');
66
+ // A number field is stringified, not quoted.
67
+ expect(csvField(Number('48303'))).toBe('48303');
68
+ });
69
+ test('quotes a comma and doubles an embedded quote', () => {
70
+ // Written out literally rather than compared against a round trip: this
71
+ // pins the on-disk encoding, so a reader that is not parseCsv (GitHub's
72
+ // CSV viewer, python csv, a spreadsheet) reads it back the same way.
73
+ expect(csvField(NASTY_PREPROCESSOR)).toBe('"Trimble Nova (Build = 16.2.0.15, Compile = ""Sep 23 2021"")"');
74
+ });
75
+ test('quotes embedded newlines', () => {
76
+ expect(csvField('line one\nline two')).toBe('"line one\nline two"');
77
+ expect(csvField('line one\r\nline two')).toBe('"line one\r\nline two"');
78
+ });
79
+ test('renders null and undefined as the empty field', () => {
80
+ expect(csvField(null)).toBe('');
81
+ expect(csvField(undefined)).toBe('');
82
+ });
83
+ });
84
+ describe('performance-detail.csv rows', () => {
85
+ test('a preprocessorVersion with a comma and a quote round-trips', () => {
86
+ const text = `${csvRow(DETAIL_HEADER)}\n${detailRow('mep.ifc', '326')}\n`;
87
+ const records = parseCsv(text);
88
+ expect(records).toHaveLength(2);
89
+ // The whole point: 15 columns, not 16.
90
+ expect(records[0]).toHaveLength(DETAIL_HEADER.length);
91
+ expect(records[1]).toHaveLength(DETAIL_HEADER.length);
92
+ const preprocessorIndex = DETAIL_HEADER.indexOf('preprocessorVersion');
93
+ expect(records[1][preprocessorIndex]).toBe(NASTY_PREPROCESSOR);
94
+ expect(records[1][DETAIL_HEADER.indexOf('originatingSystem')]).toBe('N/A');
95
+ // A field after the comma still lands in its own column, which is what a
96
+ // torn row loses.
97
+ expect(records[1][DETAIL_HEADER.indexOf('totalTimeMs')]).toBe('326');
98
+ });
99
+ test('a filename containing a comma stays one column', () => {
100
+ // The FAIL path writes the raw model name, and models in the corpus are
101
+ // named this way ("Wiesenplatz 7, 4057 Basel.ifc").
102
+ const name = 'Wiesenplatz 7, 4057 Basel.ifc';
103
+ const records = parseCsv(`${csvRow(DETAIL_HEADER)}\n${detailRow(name, '12')}\n`);
104
+ expect(records[1]).toHaveLength(DETAIL_HEADER.length);
105
+ expect(records[1][DETAIL_HEADER.indexOf('filename')]).toBe(name);
106
+ });
107
+ });
108
+ describe('gen_delta_csv.cjs', () => {
109
+ /**
110
+ * Write a two-row performance-detail.csv into the work dir.
111
+ *
112
+ * @param name File basename to write.
113
+ * @param rows Encoded records.
114
+ * @return The absolute path written.
115
+ */
116
+ function writeDetail(name, rows) {
117
+ const filePath = path.join(workDir, name);
118
+ fs.writeFileSync(filePath, `${csvRow(DETAIL_HEADER)}\n${rows.join('\n')}\n`);
119
+ return filePath;
120
+ }
121
+ test('joins on filename across quoted fields and emits 18 columns', () => {
122
+ const older = writeDetail('older.csv', [
123
+ detailRow('mep.ifc', '400'),
124
+ detailRow('only-in-older.ifc', '100'),
125
+ ]);
126
+ const newer = writeDetail('newer.csv', [
127
+ detailRow('mep.ifc', '300'),
128
+ detailRow('only-in-newer.ifc', '200'),
129
+ ]);
130
+ const out = path.join(workDir, 'delta.csv');
131
+ generateDeltaCSV(older, newer, out);
132
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
133
+ const header = records[0];
134
+ expect(header).toHaveLength(DELTA_COLUMN_COUNT);
135
+ for (const record of records) {
136
+ expect(record).toHaveLength(DELTA_COLUMN_COUNT);
137
+ }
138
+ const byFile = new Map(records.slice(1).map((r) => [r[header.indexOf('filename')], r]));
139
+ // The join found the shared model despite the comma-bearing field sitting
140
+ // between the columns it reads.
141
+ expect(byFile.get('mep.ifc')[header.indexOf('totalTimeMsDelta')]).toBe('-100');
142
+ expect(byFile.get('mep.ifc')[header.indexOf('totalTimeMsPercentageChange')])
143
+ .toBe('-25.00%');
144
+ // A model present in only one run is reported, not dropped — the corpus
145
+ // changes between releases and those rows are the interesting ones.
146
+ expect(byFile.get('only-in-older.ifc')[header.indexOf('loadStatus2')])
147
+ .toBe('N/A');
148
+ expect(byFile.get('only-in-newer.ifc')[header.indexOf('loadStatus1')])
149
+ .toBe('N/A');
150
+ });
151
+ test('joins on a filename that itself contains a comma', () => {
152
+ // filename sits at column 5, ahead of every measurement column the delta
153
+ // reads, so this is the case where a reader that ignores quoting does not
154
+ // merely drop a trailing cell — it shifts loadStatus, totalTimeMs and the
155
+ // rest one place right and joins on a fragment. The FAIL path writes the
156
+ // raw model name, and the corpus contains names like this one.
157
+ const name = 'Wiesenplatz 7, 4057 Basel.ifc';
158
+ const older = writeDetail('comma-older.csv', [detailRow(name, '400')]);
159
+ const newer = writeDetail('comma-newer.csv', [detailRow(name, '300')]);
160
+ const out = path.join(workDir, 'comma-delta.csv');
161
+ generateDeltaCSV(older, newer, out);
162
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
163
+ const header = records[0];
164
+ expect(records).toHaveLength(2);
165
+ expect(records[1]).toHaveLength(DELTA_COLUMN_COUNT);
166
+ expect(records[1][header.indexOf('filename')]).toBe(name);
167
+ expect(records[1][header.indexOf('totalTimeMsDelta')]).toBe('-100');
168
+ expect(records[1][header.indexOf('loadStatus1')]).toBe('OK');
169
+ });
170
+ test('reports an absent measurement as N/A, not as a delta against zero', () => {
171
+ // The bug this pins: parseValue used to coerce 'N/A' to 0, so a matched row
172
+ // whose newer side has no geometryMemoryMb came out as
173
+ // geometryMemoryMbDelta = -(the whole baseline allocation) — a fabricated
174
+ // 100% memory win. It reported -185.836 for SKYLARK250, which is exactly
175
+ // the model someone reads this delta for.
176
+ const withMemory = csvRow([
177
+ '20260811154725', 'OK', 'x64', 'conway1.451.1357', 'skylark.ifc', 'IFC4',
178
+ '5729', '42572', '48303', '185.836', '5495.645', '3865.072', '3952.602',
179
+ NASTY_PREPROCESSOR, 'N/A',
180
+ ]);
181
+ // The conway-native perf writer does not measure geometryMemoryMb.
182
+ const withoutMemory = csvRow([
183
+ '20260821154725', 'OK', 'x64', 'conway1.543.1513-ci', 'skylark.ifc',
184
+ 'N/A', '8035', '72371', '80406', 'N/A', '5379.12', '3793.69', '3877.96',
185
+ 'N/A', 'N/A',
186
+ ]);
187
+ const older = writeDetail('mem-older.csv', [withMemory]);
188
+ const newer = writeDetail('mem-newer.csv', [withoutMemory]);
189
+ const out = path.join(workDir, 'mem-delta.csv');
190
+ generateDeltaCSV(older, newer, out);
191
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
192
+ const header = records[0];
193
+ const row = records[1];
194
+ expect(row[header.indexOf('geometryMemoryMbDelta')]).toBe('N/A');
195
+ // The columns that ARE measured on both sides still compute.
196
+ expect(row[header.indexOf('totalTimeMsDelta')]).toBe('32103');
197
+ expect(row[header.indexOf('rssMbDelta')]).not.toBe('N/A');
198
+ });
199
+ test('reports a FAIL row as N/A rather than a 100% improvement', () => {
200
+ // Same coercion, and this is where it did the most damage: a model that
201
+ // regressed OK -> FAIL used to read as totalTimeMsDelta = -(its old total)
202
+ // with a -100.00% change, i.e. the biggest "improvement" in the file.
203
+ const okRow = detailRow('flipper.ifc', '48303');
204
+ const failRow = csvRow([
205
+ '20260821153721', 'FAIL', 'x64', 'conway1.543.1513', 'flipper.ifc',
206
+ 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A',
207
+ ]);
208
+ const older = writeDetail('fail-older.csv', [okRow]);
209
+ const newer = writeDetail('fail-newer.csv', [failRow]);
210
+ const out = path.join(workDir, 'fail-delta.csv');
211
+ generateDeltaCSV(older, newer, out);
212
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
213
+ const header = records[0];
214
+ const row = records[1];
215
+ expect(row[header.indexOf('loadStatus1')]).toBe('OK');
216
+ expect(row[header.indexOf('loadStatus2')]).toBe('FAIL');
217
+ expect(row[header.indexOf('totalTimeMsDelta')]).toBe('N/A');
218
+ expect(row[header.indexOf('totalTimeMsPercentageChange')]).toBe('N/A');
219
+ expect(row[header.indexOf('geometryTimeMsDelta')]).toBe('N/A');
220
+ });
221
+ test('a real zero is still a number, not treated as absent', () => {
222
+ // web-ifc rows carry parseTimeMs/geometryTimeMs of literally 0 because that
223
+ // engine does not split the stages, so "0" and "no measurement" must stay
224
+ // distinguishable.
225
+ const zeroed = (engine, total) => csvRow([
226
+ '20260811154725', 'OK', 'x64', engine, 'z.ifc', 'IFC4',
227
+ '0', '0', total, '1.5', '100', '50', '60', 'N/A', 'N/A',
228
+ ]);
229
+ const older = writeDetail('zero-older.csv', [zeroed('webifc0.0.67', '100')]);
230
+ const newer = writeDetail('zero-newer.csv', [zeroed('webifc0.0.67', '150')]);
231
+ const out = path.join(workDir, 'zero-delta.csv');
232
+ generateDeltaCSV(older, newer, out);
233
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
234
+ const header = records[0];
235
+ const row = records[1];
236
+ expect(row[header.indexOf('parseTimeMsDelta')]).toBe('0');
237
+ expect(row[header.indexOf('totalTimeMsDelta')]).toBe('50');
238
+ expect(row[header.indexOf('totalTimeMsPercentageChange')]).toBe('50.00%');
239
+ });
240
+ test('joins a raw filename on one side to its encoded form on the other', () => {
241
+ // benchmark.cjs URL-encoded the filename on its OK path but wrote the raw
242
+ // name on its render-failure path, so a committed baseline can hold both
243
+ // spellings of one model. The writer is fixed, but those files are history:
244
+ // without the canonical fallback the delta emits two one-sided rows and
245
+ // loses the OK -> FAIL transition, which is the row that matters most.
246
+ const rawFail = csvRow([
247
+ '20260811154725', 'FAIL', 'x64', 'conway1.451.1357',
248
+ 'S_Office_Integrated Design Archi.ifc', 'N/A', 'N/A', 'N/A', 'N/A',
249
+ 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A',
250
+ ]);
251
+ const encodedOk = detailRow('S_Office_Integrated%20Design%20Archi.ifc', '4647');
252
+ const older = writeDetail('enc-older.csv', [rawFail]);
253
+ const newer = writeDetail('enc-newer.csv', [encodedOk]);
254
+ const out = path.join(workDir, 'enc-delta.csv');
255
+ generateDeltaCSV(older, newer, out);
256
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
257
+ const header = records[0];
258
+ // One row, not two one-sided ones.
259
+ expect(records).toHaveLength(2);
260
+ expect(records[1][header.indexOf('loadStatus1')]).toBe('FAIL');
261
+ expect(records[1][header.indexOf('loadStatus2')]).toBe('OK');
262
+ });
263
+ test('does not collapse two models whose names differ only by encoding', () => {
264
+ // The fallback must not become a normalizing join: a corpus that really
265
+ // contained both spellings as distinct files has to keep them distinct.
266
+ const older = writeDetail('amb-older.csv', [
267
+ detailRow('a b.ifc', '100'),
268
+ detailRow('a%20b.ifc', '200'),
269
+ ]);
270
+ const newer = writeDetail('amb-newer.csv', [detailRow('a b.ifc', '150')]);
271
+ const out = path.join(workDir, 'amb-delta.csv');
272
+ generateDeltaCSV(older, newer, out);
273
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
274
+ const header = records[0];
275
+ const byFile = new Map(records.slice(1).map((r) => [r[header.indexOf('filename')], r]));
276
+ expect(byFile.size).toBe(2);
277
+ // The exact match wins.
278
+ expect(byFile.get('a b.ifc')[header.indexOf('totalTimeMsDelta')]).toBe('50');
279
+ // The other stays one-sided rather than stealing the same counterpart.
280
+ expect(byFile.get('a%20b.ifc')[header.indexOf('loadStatus2')]).toBe('N/A');
281
+ });
282
+ test('reports a model whose loadStatus changed between runs', () => {
283
+ const okRow = detailRow('flipper.ifc', '500');
284
+ const failRow = csvRow([
285
+ '20260821153721', 'FAIL', 'x64', 'conway1.543.1513', 'flipper.ifc',
286
+ 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A', 'N/A',
287
+ NASTY_PREPROCESSOR, 'N/A',
288
+ ]);
289
+ const older = writeDetail('status-older.csv', [okRow]);
290
+ const newer = writeDetail('status-newer.csv', [failRow]);
291
+ const out = path.join(workDir, 'status-delta.csv');
292
+ generateDeltaCSV(older, newer, out);
293
+ const records = parseCsv(fs.readFileSync(out, 'utf8'));
294
+ const header = records[0];
295
+ const row = records[1];
296
+ expect(records).toHaveLength(2);
297
+ expect(row[header.indexOf('loadStatus1')]).toBe('OK');
298
+ expect(row[header.indexOf('loadStatus2')]).toBe('FAIL');
299
+ });
300
+ });
@@ -5,5 +5,5 @@
5
5
  // only the first segment (major) is meaningful and is the one CI carries forward.
6
6
  // Must stay in `vN.N.N` shape: the CI stamp regex, scripts/updateVersion.mjs, and
7
7
  // statistics.ts all match `v\d+\.\d+\.\d+`.
8
- const versionString = 'Conway v1.543.1513';
8
+ const versionString = 'Conway v1.548.1514';
9
9
  export { versionString };