Skip to content

Commit 0e22478

Browse files
authored
test(viewer): a bare apostrophe is its own opening and closing quote (#3149)
* test(viewer): mutation-test raw-step-format's positional-attribute helpers Adds a suite for extractRawStepTokens/serializeStepToken/isInlineEditableToken/ parseRawStepInput, previously untested. Mutation sweep found one genuine gap: parseRawStepInput's quoted-string branch guards trimmed.length >= 2 so a bare "'" isn't misread as an empty quoted string (both startsWith/endsWith match the same character) - now pinned by a regression test. * test(viewer): mutation-test the Space Sketch whole-footprint hull walls Adds coverage for exteriorPerimeter/perimeterWalls (storey-footprint.ts), previously untested. Mutation sweep confirmed the degenerate-edge skip, the hull-wraparound edge, the thickness/2 offset, and the out.length >= 3 loop guard are all load-bearing (each mutant killed by a named test). The hull.length < 3 early-return is an equivalent mutant when weakened: the final out.length >= 3 check independently rejects every hull that early guard would have caught (verified for lengths 0/1/2), so it is a fast-path only, not exercised as a distinct branch. * test(viewer): make the footprint hull test able to fail louistrue's finding holds. Deleting the hull entirely -- -return convexHull(rects.flatMap((r) => r.corners as Pt[])); +return rects.flatMap((r) => r.corners as Pt[]); -- left storey-footprint.test.ts at 9 passed, 0 failed. A test file for a convex-hull helper could not tell whether the convex hull was computed. The cause is that the only assertion on `exteriorPerimeter`'s output is containment: for (const p of [[0, 0], [10, 9], [10, 10], [0, 1]]) assert.ok(hull.some(([x, y]) => x === p[0] && y === p[1]), ...); Every one of those points is a corner of some input rect, so the raw unhulled list contains them all. Containment cannot fail here. Exclusion can, and that is what a hull is for: the useful claim is which corners are LEFT OUT, not which survive. The new test nests one rect wholly inside another, so all four of the inner rect's corners are interior and none may appear, and asserts the exact array rather than membership. Mutation evidence: correct impl + old tests 9 passed, 0 failed hull DELETED + old tests 9 passed, 0 failed <- the finding hull DELETED + new test 9 passed, 1 failed correct impl + new test 10 passed, 0 failed Asserting the exact array also pins the winding and the starting vertex, and I checked that rather than assuming it: a second mutation appending `.reverse()` to the hull -- correct vertex SET, wrong ORDER -- also fails the new test. That property is load-bearing, because `perimeterWalls` consumes the result as a closed loop and a reordered hull emits walls across the diagonal. Worth noting that `perimeterWalls` stayed green under both mutations. It takes a hull as an argument and the tests hand it a literal, so it never sees `exteriorPerimeter`'s output at all. The two halves of this file do not check each other. Implementation untouched -- verified byte-identical to HEAD after each mutation was reverted. Space Sketch suite: 35 passed, 0 failed.
1 parent a5ba0d8 commit 0e22478

2 files changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
import { describe, it } from 'node:test';
6+
import assert from 'node:assert/strict';
7+
8+
import {
9+
extractRawStepTokens,
10+
serializeStepToken,
11+
isInlineEditableToken,
12+
parseRawStepInput,
13+
} from './raw-step-format.js';
14+
15+
function bytesOf(text: string): Uint8Array {
16+
return new TextEncoder().encode(text);
17+
}
18+
19+
describe('extractRawStepTokens', () => {
20+
it('returns null for a non-positive byteLength', () => {
21+
const buf = bytesOf('#42=IFCWALL();');
22+
assert.strictEqual(extractRawStepTokens(buf, 0, 0), null);
23+
assert.strictEqual(extractRawStepTokens(buf, 0, -1), null);
24+
});
25+
26+
it('tokenizes a simple entity body', () => {
27+
const text = "#42=IFCWALL('abc',#1,.T.,(1,2,3));";
28+
const buf = bytesOf(text);
29+
const tokens = extractRawStepTokens(buf, 0, text.length);
30+
assert.deepStrictEqual(tokens, ["'abc'", '#1', '.T.', '(1,2,3)']);
31+
});
32+
33+
it('works without the trailing semicolon', () => {
34+
const text = "#42=IFCWALL('abc',#1)";
35+
const buf = bytesOf(text);
36+
const tokens = extractRawStepTokens(buf, 0, text.length);
37+
assert.deepStrictEqual(tokens, ["'abc'", '#1']);
38+
});
39+
40+
it('returns null when the entity body cannot be parsed (mismatched parens)', () => {
41+
const text = "#42=IFCWALL('abc',#1;";
42+
const buf = bytesOf(text);
43+
assert.strictEqual(extractRawStepTokens(buf, 0, text.length), null);
44+
});
45+
46+
it('does not split on a comma embedded inside a quoted string', () => {
47+
const text = "#1=IFCLABEL('a,b');";
48+
const buf = bytesOf(text);
49+
const tokens = extractRawStepTokens(buf, 0, text.length);
50+
assert.deepStrictEqual(tokens, ["'a,b'"]);
51+
});
52+
53+
it('honours a doubled single-quote as an escaped quote inside a string', () => {
54+
const text = "#1=IFCLABEL('it''s a test');";
55+
const buf = bytesOf(text);
56+
const tokens = extractRawStepTokens(buf, 0, text.length);
57+
assert.deepStrictEqual(tokens, ["'it''s a test'"]);
58+
});
59+
60+
it('reads only the requested byte slice, honouring byteOffset', () => {
61+
const prefix = 'XXXX';
62+
const entity = "#7=IFCWALL('x');";
63+
const text = prefix + entity;
64+
const buf = bytesOf(text);
65+
const tokens = extractRawStepTokens(buf, prefix.length, entity.length);
66+
assert.deepStrictEqual(tokens, ["'x'"]);
67+
});
68+
});
69+
70+
describe('serializeStepToken', () => {
71+
it('serializes null and undefined as $', () => {
72+
assert.strictEqual(serializeStepToken(null), '$');
73+
// `undefined` is outside the static `IfcAttributeValue` type but the
74+
// function guards it explicitly (`value === undefined`) for callers that
75+
// reach it dynamically (e.g. an unset positional attribute lookup).
76+
assert.strictEqual(serializeStepToken(undefined as unknown as import('@ifc-lite/mutations').IfcAttributeValue), '$');
77+
});
78+
79+
it('serializes booleans distinctly', () => {
80+
assert.strictEqual(serializeStepToken(true), '.T.');
81+
assert.strictEqual(serializeStepToken(false), '.F.');
82+
});
83+
84+
it('serializes finite numbers verbatim and non-finite numbers as $', () => {
85+
assert.strictEqual(serializeStepToken(42), '42');
86+
assert.strictEqual(serializeStepToken(1.5), '1.5');
87+
assert.strictEqual(serializeStepToken(0), '0');
88+
assert.strictEqual(serializeStepToken(Number.NaN), '$');
89+
assert.strictEqual(serializeStepToken(Number.POSITIVE_INFINITY), '$');
90+
});
91+
92+
it('passes through $ and * strings unchanged', () => {
93+
assert.strictEqual(serializeStepToken('$'), '$');
94+
assert.strictEqual(serializeStepToken('*'), '*');
95+
});
96+
97+
it('passes through a reference string unchanged', () => {
98+
assert.strictEqual(serializeStepToken('#123'), '#123');
99+
});
100+
101+
it('upper-cases an enum-shaped string', () => {
102+
assert.strictEqual(serializeStepToken('.area.'), '.AREA.');
103+
assert.strictEqual(serializeStepToken('.AREA.'), '.AREA.');
104+
});
105+
106+
it('quotes a plain string and doubles embedded single quotes', () => {
107+
assert.strictEqual(serializeStepToken('My Column'), "'My Column'");
108+
assert.strictEqual(serializeStepToken("it's"), "'it''s'");
109+
});
110+
111+
it('serializes arrays recursively, comma-joined and wrapped in parens', () => {
112+
assert.strictEqual(serializeStepToken([1, 'a', null, true]), "(1,'a',$,.T.)");
113+
});
114+
115+
it('serializes an empty array as ()', () => {
116+
assert.strictEqual(serializeStepToken([]), '()');
117+
});
118+
});
119+
120+
describe('isInlineEditableToken', () => {
121+
it('treats an empty/whitespace token as editable', () => {
122+
assert.strictEqual(isInlineEditableToken(''), true);
123+
assert.strictEqual(isInlineEditableToken(' '), true);
124+
});
125+
126+
it('treats a list token as not editable', () => {
127+
assert.strictEqual(isInlineEditableToken('(1,2,3)'), false);
128+
});
129+
130+
it('treats a typed-value token as not editable, case-insensitively', () => {
131+
assert.strictEqual(isInlineEditableToken("IFCLABEL('x')"), false);
132+
assert.strictEqual(isInlineEditableToken("ifclabel('x')"), false);
133+
});
134+
135+
it('treats a plain scalar token as editable', () => {
136+
assert.strictEqual(isInlineEditableToken('#42'), true);
137+
assert.strictEqual(isInlineEditableToken('.T.'), true);
138+
assert.strictEqual(isInlineEditableToken("'hello'"), true);
139+
});
140+
});
141+
142+
describe('parseRawStepInput', () => {
143+
it('maps empty, $, and null (any case) to null value', () => {
144+
assert.deepStrictEqual(parseRawStepInput(''), { value: null });
145+
assert.deepStrictEqual(parseRawStepInput('$'), { value: null });
146+
assert.deepStrictEqual(parseRawStepInput('null'), { value: null });
147+
assert.deepStrictEqual(parseRawStepInput('NULL'), { value: null });
148+
});
149+
150+
it('maps .T./.t. to true and .F./.f. to false', () => {
151+
assert.deepStrictEqual(parseRawStepInput('.T.'), { value: true });
152+
assert.deepStrictEqual(parseRawStepInput('.t.'), { value: true });
153+
assert.deepStrictEqual(parseRawStepInput('.F.'), { value: false });
154+
assert.deepStrictEqual(parseRawStepInput('.f.'), { value: false });
155+
});
156+
157+
it('keeps a reference as-is', () => {
158+
assert.deepStrictEqual(parseRawStepInput('#77'), { value: '#77' });
159+
});
160+
161+
it('upper-cases an enum value', () => {
162+
assert.deepStrictEqual(parseRawStepInput('.area.'), { value: '.AREA.' });
163+
});
164+
165+
it('parses an integer', () => {
166+
assert.deepStrictEqual(parseRawStepInput('42'), { value: 42 });
167+
assert.deepStrictEqual(parseRawStepInput('-7'), { value: -7 });
168+
});
169+
170+
it('parses real numbers in several notations, including scientific', () => {
171+
assert.deepStrictEqual(parseRawStepInput('1.5'), { value: 1.5 });
172+
assert.deepStrictEqual(parseRawStepInput('.5'), { value: 0.5 });
173+
assert.deepStrictEqual(parseRawStepInput('5.'), { value: 5 });
174+
assert.deepStrictEqual(parseRawStepInput('1e3'), { value: 1000 });
175+
assert.deepStrictEqual(parseRawStepInput('-1.5e-3'), { value: -0.0015 });
176+
});
177+
178+
it('strips wrapping quotes and un-escapes doubled quotes', () => {
179+
assert.deepStrictEqual(parseRawStepInput("'foo'"), { value: 'foo' });
180+
assert.deepStrictEqual(parseRawStepInput("'it''s'"), { value: "it's" });
181+
});
182+
183+
it('rejects list literals with an actionable error, without corrupting the value', () => {
184+
const result = parseRawStepInput('(1,2,3)');
185+
assert.deepStrictEqual(result, { error: 'Lists and typed values must be edited from the script panel' });
186+
});
187+
188+
it('rejects typed-value literals the same way', () => {
189+
const result = parseRawStepInput("IFCLABEL('x')");
190+
assert.deepStrictEqual(result, { error: 'Lists and typed values must be edited from the script panel' });
191+
});
192+
193+
it('falls back to treating an unrecognised token as a plain string', () => {
194+
assert.deepStrictEqual(parseRawStepInput('hello'), { value: 'hello' });
195+
});
196+
197+
it('treats a bare single quote as a literal apostrophe, not an empty quoted string', () => {
198+
// A single "'" both starts and ends with a quote character (it's the same
199+
// character satisfying both checks), so without the `length >= 2` guard
200+
// this would be misread as an empty quoted string ({ value: '' }) instead
201+
// of the literal apostrophe the user typed.
202+
assert.deepStrictEqual(parseRawStepInput("'"), { value: "'" });
203+
});
204+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
import { describe, it } from 'node:test';
6+
import assert from 'node:assert/strict';
7+
8+
import { exteriorPerimeter, perimeterWalls } from './storey-footprint.js';
9+
import type { WallRect } from '@/lib/wall-rects-from-meshes';
10+
11+
type Pt = [number, number];
12+
13+
/** A `size`×`size` square, CCW from the origin. */
14+
const SQUARE: Pt[] = [
15+
[0, 0],
16+
[10, 0],
17+
[10, 10],
18+
[0, 10],
19+
];
20+
21+
/** A rectangle described by its 4 corners, for `exteriorPerimeter`'s input. */
22+
function rect(corners: Pt[]): WallRect {
23+
return { corners, centreline: [corners[0], corners[1]], thickness: 0.2 };
24+
}
25+
26+
describe('exteriorPerimeter', () => {
27+
it('returns the convex hull of every corner across all rects', () => {
28+
const rects = [
29+
rect([
30+
[0, 0],
31+
[1, 0],
32+
[1, 1],
33+
[0, 1],
34+
]),
35+
rect([
36+
[9, 9],
37+
[10, 9],
38+
[10, 10],
39+
[9, 10],
40+
]),
41+
];
42+
const hull = exteriorPerimeter(rects);
43+
// The hull must reach every extreme corner across both rects.
44+
for (const p of [[0, 0], [10, 9], [10, 10], [0, 1]] as Pt[]) {
45+
assert.ok(hull.some(([x, y]) => x === p[0] && y === p[1]), `hull missing ${p}`);
46+
}
47+
});
48+
49+
it('DROPS corners that fall inside the hull, and returns the loop in order', () => {
50+
// The test above asserts only that the result CONTAINS each extreme
51+
// corner, which the raw unhulled corner list also does -- deleting the
52+
// `convexHull` call and returning `rects.flatMap(r => r.corners)` left the
53+
// whole file at 9 passed, 0 failed. Containment cannot fail; exclusion
54+
// can, so that is what this pins.
55+
//
56+
// An inner rect wholly inside an outer one: all four of its corners are
57+
// interior and none may survive. Asserting the exact array also pins the
58+
// winding and the starting vertex, which `perimeterWalls` depends on --
59+
// it walks the result as a closed loop, so a correct SET in a wrong ORDER
60+
// would emit walls across the diagonal.
61+
const outer = rect([
62+
[0, 0],
63+
[10, 0],
64+
[10, 10],
65+
[0, 10],
66+
]);
67+
const inner = rect([
68+
[4, 4],
69+
[6, 4],
70+
[6, 6],
71+
[4, 6],
72+
]);
73+
assert.deepStrictEqual(exteriorPerimeter([outer, inner]), [
74+
[0, 0],
75+
[10, 0],
76+
[10, 10],
77+
[0, 10],
78+
]);
79+
});
80+
81+
it('returns fewer than 3 points when there are not enough distinct corners', () => {
82+
assert.deepStrictEqual(exteriorPerimeter([]), []);
83+
});
84+
});
85+
86+
describe('perimeterWalls', () => {
87+
it('returns null when the hull is degenerate (fewer than 3 points)', () => {
88+
assert.strictEqual(perimeterWalls([]), null);
89+
assert.strictEqual(perimeterWalls([[0, 0]]), null);
90+
assert.strictEqual(perimeterWalls([[0, 0], [1, 1]]), null);
91+
});
92+
93+
it('emits one thin wall per hull edge, defaulting to 0.2 thickness', () => {
94+
const walls = perimeterWalls(SQUARE);
95+
assert.ok(walls);
96+
assert.strictEqual(walls!.length, 4);
97+
for (const w of walls!) assert.strictEqual(w.thickness, 0.2);
98+
});
99+
100+
it('centres each synthetic wall on its hull edge, wrapping the last edge back to the first vertex', () => {
101+
const walls = perimeterWalls(SQUARE)!;
102+
assert.deepStrictEqual(
103+
walls.map((w) => w.centreline),
104+
[
105+
[[0, 0], [10, 0]],
106+
[[10, 0], [10, 10]],
107+
[[10, 10], [0, 10]],
108+
[[0, 10], [0, 0]],
109+
],
110+
);
111+
});
112+
113+
it('offsets the 4 corners of each wall symmetrically across the centreline by half the thickness', () => {
114+
const thickness = 2;
115+
const walls = perimeterWalls(SQUARE, thickness)!;
116+
const bottom = walls[0]; // centreline (0,0) -> (10,0)
117+
// Perpendicular to a horizontal edge is vertical: corners should sit at
118+
// y = +1 and y = -1 (half of thickness 2), x unchanged from the endpoints.
119+
const ys = bottom.corners.map(([, y]) => y).sort((a, b) => a - b);
120+
assert.deepStrictEqual(ys, [-1, -1, 1, 1]);
121+
const xs = bottom.corners.map(([x]) => x).sort((a, b) => a - b);
122+
assert.deepStrictEqual(xs, [0, 0, 10, 10]);
123+
});
124+
125+
it('respects a custom thickness parameter', () => {
126+
const walls = perimeterWalls(SQUARE, 1)!;
127+
for (const w of walls) assert.strictEqual(w.thickness, 1);
128+
});
129+
130+
it('skips a degenerate (near-zero-length) edge and still returns the remaining walls when at least 3 survive', () => {
131+
// A repeated vertex creates a zero-length edge between it and its
132+
// duplicate. With 5 hull points but one degenerate edge, 4 real walls
133+
// should remain (a pentagon minus the one collapsed edge).
134+
const hull: Pt[] = [
135+
[0, 0],
136+
[10, 0],
137+
[10, 0], // duplicate of the previous point -> zero-length edge, skipped
138+
[10, 10],
139+
[0, 10],
140+
];
141+
const walls = perimeterWalls(hull)!;
142+
assert.strictEqual(walls.length, 4);
143+
});
144+
145+
it('returns null when skipping degenerate edges leaves fewer than 3 walls', () => {
146+
// A "triangle" whose first two vertices coincide only has one genuine
147+
// edge once the degenerate one is dropped, and one > 0 length edge is
148+
// not a wall loop — perimeterWalls should refuse rather than emit a
149+
// 2-wall non-loop.
150+
const hull: Pt[] = [
151+
[0, 0],
152+
[0, 0],
153+
[10, 0],
154+
];
155+
assert.strictEqual(perimeterWalls(hull), null);
156+
});
157+
});

0 commit comments

Comments
 (0)