mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
A stale client can only echo the stored model_version, never raise it. The 'unchanged wall catalogue' refusal now applies only when the submitted model is not above the stored one; the first v9 write over a v8 document with an orphan open_span/open_to legitimately drops the legacy projection without touching the catalogue and passes. The regression pair fixture is produced by the real writers (stored: v1.68.0-beta.2, sent: current migration) and is pinned on the frontend byte-for-byte so it cannot drift. Issue: #319 User-Visible: yes
394 lines
17 KiB
JavaScript
394 lines
17 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import {
|
|
adoptWallSegmentModelCandidateInPlace,
|
|
commitWallSegmentModel,
|
|
deterministicWallSegmentId,
|
|
fixedTopologyWallLineageHints,
|
|
resolveRoomOpeningHost,
|
|
sanitizeRoomDraftPath,
|
|
wallModelOffGridValueCount,
|
|
WallSegmentModelError,
|
|
WALL_SEGMENT_MODEL_VERSION,
|
|
} from '../test-build/wall-segment-model.js';
|
|
import { wallKey } from '../test-build/wall-thickness.js';
|
|
import { GRID_STEP_N } from '../test-build/space-geometry.js';
|
|
|
|
const rectangle = (id, x1 = 0, y1 = 0, x2 = 1, y2 = 1) => ({
|
|
id,
|
|
poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]],
|
|
});
|
|
const configOf = (space) => ({ spaces: [space], markers: [], settings: {} });
|
|
const segmentFor = (room, index, catalogue) => (
|
|
catalogue.find((segment) => segment.id === room.wall_ids[index])
|
|
);
|
|
|
|
test('the #319 pair fixture matches the current initial migration byte for byte', () => {
|
|
// Guards the fixture against drift: `sent` must stay exactly what the
|
|
// current writer produces from `stored` (a v1.68.0-beta.2 document with one
|
|
// orphan open_span), and the wall catalogue must stay byte-identical —
|
|
// that combination is the whole point of the backend regression test.
|
|
const fixture = JSON.parse(readFileSync(
|
|
new URL('./fixtures/319-orphan-span-migration.json', import.meta.url), 'utf8',
|
|
));
|
|
const migrated = commitWallSegmentModel(structuredClone(fixture.stored)).config;
|
|
assert.deepEqual(migrated, fixture.sent);
|
|
assert.equal(
|
|
JSON.stringify(fixture.stored.spaces[0].wall_segments),
|
|
JSON.stringify(fixture.sent.spaces[0].wall_segments),
|
|
);
|
|
assert.equal('open_spans' in fixture.sent.spaces[0], false);
|
|
});
|
|
|
|
test('frontend migration matches the shared backend parity fixture', () => {
|
|
const fixture = JSON.parse(readFileSync(
|
|
new URL('./fixtures/282-wall-identity-parity.json', import.meta.url), 'utf8',
|
|
));
|
|
const space = commitWallSegmentModel(fixture.input).config.spaces[0];
|
|
assert.deepEqual(space.rooms[0].wall_ids, fixture.expected.large_wall_ids);
|
|
assert.deepEqual(space.rooms[1].wall_ids, fixture.expected.small_wall_ids);
|
|
assert.deepEqual(space.openings[0].host, fixture.expected.opening_host);
|
|
assert.deepEqual(
|
|
space.room_drafts[0].segments.map((segment) => segment.id), fixture.expected.draft_ids,
|
|
);
|
|
});
|
|
|
|
test('wall ids use the specified SHA-256/base32 seed and ignore endpoint order', () => {
|
|
const a = [0, 0], b = [1, 0], owners = ['room-b', 'room-a'];
|
|
const seed = 'floor|0.000000000000,0.000000000000|1.000000000000,0.000000000000|room-a,room-b';
|
|
const digest = createHash('sha256').update(seed).digest();
|
|
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
let bits = 0, value = 0, encoded = '';
|
|
for (const byte of digest) {
|
|
value = (value << 8) | byte;
|
|
bits += 8;
|
|
while (bits >= 5) {
|
|
encoded += alphabet[(value >>> (bits - 5)) & 31];
|
|
bits -= 5;
|
|
}
|
|
}
|
|
if (bits) encoded += alphabet[(value << (5 - bits)) & 31];
|
|
const expected = `wall-${encoded.slice(0, 20)}`;
|
|
assert.equal(deterministicWallSegmentId('floor', a, b, owners), expected);
|
|
assert.equal(deterministicWallSegmentId('floor', b, a, [...owners].reverse()), expected);
|
|
});
|
|
|
|
test('v7 rectangle migrates atomically to the complete id catalogue and is idempotent', () => {
|
|
const input = configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
|
|
walls: [{
|
|
key: wallKey([0, 0], [1, 0], GRID_STEP_N),
|
|
a: [0, 0], b: [1, 0], cm: 15,
|
|
}],
|
|
});
|
|
const first = commitWallSegmentModel(input);
|
|
assert.equal(first.config.model_version, WALL_SEGMENT_MODEL_VERSION);
|
|
assert.equal(first.config.spaces[0].wall_segments.length, 4);
|
|
assert.equal(first.config.spaces[0].rooms[0].wall_ids.length, 4);
|
|
assert.equal(segmentFor(
|
|
first.config.spaces[0].rooms[0], 0, first.config.spaces[0].wall_segments,
|
|
).cm, 15);
|
|
assert.equal(first.config.spaces[0].wall_segments.filter((segment) => segment.cm === 0).length, 3);
|
|
assert.deepEqual(first.config.spaces[0].walls, [{
|
|
key: wallKey([0, 0], [1, 0], GRID_STEP_N), cm: 15, a: [0, 0], b: [1, 0],
|
|
}]);
|
|
assert.deepEqual(input.spaces[0].rooms[0], rectangle('room'), 'pure barrier cannot mutate v7');
|
|
|
|
const second = commitWallSegmentModel(first.config);
|
|
assert.equal(second.changed, false);
|
|
assert.equal(second.migratedSegments, 0);
|
|
assert.deepEqual(second.config, first.config);
|
|
});
|
|
|
|
test('an explicit canonical zero is not resurrected from the old thickness projection', () => {
|
|
const base = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
|
|
walls: [{
|
|
key: wallKey([0, 0], [1, 0], GRID_STEP_N),
|
|
a: [0, 0], b: [1, 0], cm: 20,
|
|
}],
|
|
})).config;
|
|
const edited = structuredClone(base);
|
|
const topId = edited.spaces[0].rooms[0].wall_ids[0];
|
|
edited.spaces[0].wall_segments.find((segment) => segment.id === topId).cm = 0;
|
|
delete edited.spaces[0].walls;
|
|
const result = commitWallSegmentModel(edited).config.spaces[0];
|
|
assert.equal(result.wall_segments.find((segment) => segment.id === topId).cm, 0);
|
|
assert.equal(result.walls, undefined);
|
|
});
|
|
|
|
test('v8 virtual spans migrate to ordinary zero atoms and legacy fields disappear', () => {
|
|
const base = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor',
|
|
rooms: [rectangle('left', 0, 0, 0.5, 1), rectangle('right', 0.5, 0, 1, 1)],
|
|
})).config;
|
|
base.model_version = 8;
|
|
base.spaces[0].open_spans = [{ a: [0.5, 0.25], b: [0.5, 0.75] }];
|
|
base.spaces[0].rooms[0].open_to = ['right'];
|
|
const result = commitWallSegmentModel(base).config.spaces[0];
|
|
const shared = result.wall_segments.filter((segment) => (
|
|
Math.abs(segment.a[0] - 0.5) < 1e-9 && Math.abs(segment.b[0] - 0.5) < 1e-9
|
|
));
|
|
assert.equal(result.open_spans, undefined);
|
|
assert.equal(result.rooms.some((room) => Object.hasOwn(room, 'open_to')), false);
|
|
assert.ok(shared.some((segment) => segment.cm === 0
|
|
&& segment.a[1] >= 0.25 - 1e-9 && segment.b[1] <= 0.75 + 1e-9));
|
|
});
|
|
|
|
test('partial shared side is atomised once and every atom has one or two owners', () => {
|
|
const result = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor',
|
|
rooms: [rectangle('large', 0, 0, 1, 1), rectangle('small', 1, 0.25, 1.5, 0.75)],
|
|
})).config.spaces[0];
|
|
assert.equal(result.rooms[0].poly.length, 6);
|
|
const counts = new Map(result.wall_segments.map((segment) => [segment.id, 0]));
|
|
for (const room of result.rooms) for (const id of room.wall_ids) counts.set(id, counts.get(id) + 1);
|
|
assert.ok([...counts.values()].every((count) => count === 1 || count === 2));
|
|
assert.equal([...counts.values()].filter((count) => count === 2).length, 1);
|
|
});
|
|
|
|
test('existing ids survive rigid geometry edits and split lineage chooses midpoint then old a', () => {
|
|
const base = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
|
|
})).config;
|
|
const moved = structuredClone(base);
|
|
moved.spaces[0].rooms[0].poly = moved.spaces[0].rooms[0].poly.map(([x, y]) => [x + 1, y]);
|
|
const movedResult = commitWallSegmentModel(moved).config;
|
|
assert.deepEqual(movedResult.spaces[0].rooms[0].wall_ids, base.spaces[0].rooms[0].wall_ids);
|
|
|
|
const split = structuredClone(base);
|
|
split.spaces[0].rooms[0].poly.splice(1, 0, [0.25, 0]);
|
|
// The old top segment midpoint lies in [0.25, 1], so that child inherits it.
|
|
const oldTop = base.spaces[0].rooms[0].wall_ids[0];
|
|
const splitResult = commitWallSegmentModel(split).config.spaces[0];
|
|
assert.notEqual(splitResult.rooms[0].wall_ids[0], oldTop);
|
|
assert.equal(splitResult.rooms[0].wall_ids[1], oldTop);
|
|
});
|
|
|
|
test('fixed-topology Resize hints preserve every shared and side-wall id', () => {
|
|
const legacy = configOf({
|
|
id: 'floor', title: 'Floor',
|
|
rooms: [rectangle('left', 0, 0, 0.5, 1), rectangle('right', 0.5, 0, 1, 1)],
|
|
});
|
|
const baseline = commitWallSegmentModel(legacy).config;
|
|
const candidate = structuredClone(baseline);
|
|
candidate.spaces[0].rooms = [
|
|
rectangle('left', 0, 0, 0.6, 1), rectangle('right', 0.6, 0, 1, 1),
|
|
];
|
|
const hints = fixedTopologyWallLineageHints(
|
|
baseline.spaces[0], legacy.spaces[0].rooms, candidate.spaces[0],
|
|
);
|
|
const result = commitWallSegmentModel(candidate, {
|
|
lineageHints: hints, lineageSpaceId: 'floor',
|
|
}).config.spaces[0];
|
|
assert.deepEqual(
|
|
[...result.wall_segments.map((segment) => segment.id)].sort(),
|
|
[...baseline.spaces[0].wall_segments.map((segment) => segment.id)].sort(),
|
|
);
|
|
assert.equal(result.rooms[0].wall_ids[1], result.rooms[1].wall_ids[3]);
|
|
});
|
|
|
|
test('fixed-topology move carries zero walls without leaving phantom breakpoints', () => {
|
|
const baseline = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room', 0, 0, 1, 1)],
|
|
})).config;
|
|
const candidate = structuredClone(baseline);
|
|
const room = candidate.spaces[0].rooms[0];
|
|
room.poly = [[0, 0], [1, 0], [1, 1.25], [0, 1.25]];
|
|
const hints = fixedTopologyWallLineageHints(
|
|
baseline.spaces[0], baseline.spaces[0].rooms, candidate.spaces[0],
|
|
);
|
|
const result = commitWallSegmentModel(candidate, {
|
|
lineageHints: hints, lineageSpaceId: 'floor',
|
|
}).config.spaces[0];
|
|
assert.equal(result.rooms[0].poly.length, 4);
|
|
assert.deepEqual(result.rooms[0].poly, room.poly);
|
|
assert.equal(result.wall_segments.length, 4);
|
|
assert.ok(result.wall_segments.every((segment) => segment.cm === 0));
|
|
assert.ok(result.wall_segments.some((segment) => (
|
|
segment.a[1] === 1.25 || segment.b[1] === 1.25
|
|
)));
|
|
});
|
|
|
|
test('off-grid contour guard counts values once across compatibility projections', () => {
|
|
const point = 0.0605;
|
|
assert.equal(wallModelOffGridValueCount({
|
|
rooms: [{ id: 'room', poly: [[point, 0], [1, 0], [1, 1], [point, 1]] }],
|
|
wall_segments: [{ id: 'wall', a: [point, 0], b: [point, 1], cm: 20 }],
|
|
walls: [{ key: 'legacy', a: [point, 0], b: [point, 1], cm: 20 }],
|
|
}), 1);
|
|
assert.equal(
|
|
wallModelOffGridValueCount({ rooms: [] }, [[point, 0], [point, 1]]),
|
|
1,
|
|
'a transient authored path contributes its coordinates to the baseline once',
|
|
);
|
|
});
|
|
|
|
test('room openings acquire a stable wall host while partition hosts stay untouched', () => {
|
|
const result = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
|
|
walls: [{ key: wallKey([0, 0], [1, 0], GRID_STEP_N), a: [0, 0], b: [1, 0], cm: 15 }],
|
|
openings: [
|
|
{ id: 'door', type: 'door', x: 0.5, y: 0, angle: 0, length: 0.2 },
|
|
{
|
|
id: 'partition-door', type: 'door', x: 0.5, y: 0.5, angle: 0, length: 0.2,
|
|
host: { kind: 'partition', id: 'partition', t: 0.5 },
|
|
},
|
|
],
|
|
partitions: [{ id: 'partition', a: [0, 0.5], b: [1, 0.5], cm: 15 }],
|
|
})).config.spaces[0];
|
|
assert.equal(result.openings[0].host.kind, 'wall');
|
|
assert.equal(result.openings[0].host.t, 0.5);
|
|
assert.deepEqual(result.openings[1].host, { kind: 'partition', id: 'partition', t: 0.5 });
|
|
});
|
|
|
|
test('room opening host resolver updates t and rejects ambiguous or zero walls', () => {
|
|
const opening = {
|
|
id: 'door', type: 'door', x: 0.75, y: 0, angle: 0, length: 0.2,
|
|
host: { kind: 'wall', id: 'top', t: 0.5 },
|
|
};
|
|
assert.deepEqual(resolveRoomOpeningHost(opening, [
|
|
{ id: 'top', a: [0, 0], b: [1, 0], cm: 15 },
|
|
]), { kind: 'wall', id: 'top', t: 0.75 });
|
|
assert.equal(resolveRoomOpeningHost({ ...opening, host: undefined }, [
|
|
{ id: 'a', a: [0, 0], b: [1, 0], cm: 15 },
|
|
{ id: 'b', a: [0, 0], b: [1, 0], cm: 15 },
|
|
]), null);
|
|
assert.equal(resolveRoomOpeningHost({ ...opening, host: undefined }, [
|
|
{ id: 'top', a: [0, 0], b: [1, 0], cm: 0 },
|
|
]), null);
|
|
});
|
|
|
|
test('an ambiguous room opening blocks the complete candidate without mutating input', () => {
|
|
const input = configOf({
|
|
id: 'floor', title: 'Floor',
|
|
rooms: [rectangle('room')],
|
|
openings: [{ id: 'door', type: 'door', x: 0, y: 0, angle: 0, length: 0.2 }],
|
|
});
|
|
const before = structuredClone(input);
|
|
assert.throws(
|
|
() => commitWallSegmentModel(input),
|
|
(error) => error instanceof WallSegmentModelError && error.reason === 'opening-host',
|
|
);
|
|
assert.deepEqual(input, before);
|
|
});
|
|
|
|
test('draft segment ids materialise once and remain stable', () => {
|
|
const first = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [],
|
|
room_drafts: [{
|
|
id: 'draft', points: [[0, 0], [0.5, 0], [1, 0]],
|
|
segments: [{ cm: 10 }, { cm: 20 }],
|
|
}],
|
|
})).config;
|
|
const ids = first.spaces[0].room_drafts[0].segments.map((segment) => segment.id);
|
|
assert.ok(ids.every(Boolean));
|
|
assert.deepEqual(
|
|
commitWallSegmentModel(first).config.spaces[0].room_drafts[0].segments.map((segment) => segment.id),
|
|
ids,
|
|
);
|
|
});
|
|
|
|
test('draft sanitation drops only the segment carried by a duplicate point', () => {
|
|
const draft = sanitizeRoomDraftPath({
|
|
points: [[0, 0], [0, 0], [1, 0], [1, 0], [1, 1]],
|
|
segments: [
|
|
{ id: 'zero-a', cm: 11 }, { id: 'edge-a', cm: 21 },
|
|
{ id: 'zero-b', cm: 12 }, { id: 'edge-b', cm: 22 },
|
|
],
|
|
});
|
|
assert.deepEqual(draft.points, [[0, 0], [1, 0], [1, 1]]);
|
|
assert.deepEqual(draft.segments, [
|
|
{ id: 'edge-a', cm: 21 }, { id: 'edge-b', cm: 22 },
|
|
]);
|
|
});
|
|
|
|
test('post-v8 atoms use fresh identity while promoted draft carriers keep theirs', () => {
|
|
const base = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
|
|
})).config;
|
|
const changed = structuredClone(base);
|
|
changed.spaces[0].rooms.push({
|
|
id: 'promoted',
|
|
poly: [[2, 0], [3, 0], [3, 1], [2, 1]],
|
|
wall_ids: ['draft-top', '', '', ''],
|
|
});
|
|
const result = commitWallSegmentModel(changed).config.spaces[0];
|
|
const promoted = result.rooms.find((room) => room.id === 'promoted');
|
|
assert.equal(promoted.wall_ids[0], 'draft-top');
|
|
assert.match(promoted.wall_ids[1], /^wall-[0-9a-f-]{36}$/,
|
|
'new v8 identity comes from the UUID factory, not its coordinates');
|
|
assert.notEqual(
|
|
promoted.wall_ids[1],
|
|
deterministicWallSegmentId('floor', [3, 0], [3, 1], ['promoted']),
|
|
);
|
|
});
|
|
|
|
test('promoted divider identity outranks a stale same-index room hint', () => {
|
|
const base = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('parent')],
|
|
})).config;
|
|
const changed = structuredClone(base);
|
|
const oldIds = changed.spaces[0].rooms[0].wall_ids;
|
|
changed.spaces[0].rooms = [{
|
|
id: 'parent', poly: [[0, 0], [0.5, 0], [0.5, 1], [0, 1]], wall_ids: oldIds,
|
|
}, {
|
|
id: 'child', poly: [[0.5, 0], [1, 0], [1, 1], [0.5, 1]],
|
|
wall_ids: ['', '', '', 'draft-divider'],
|
|
}];
|
|
const result = commitWallSegmentModel(changed).config.spaces[0];
|
|
const parent = result.rooms.find((room) => room.id === 'parent');
|
|
const child = result.rooms.find((room) => room.id === 'child');
|
|
assert.equal(parent.wall_ids[1], 'draft-divider');
|
|
assert.equal(child.wall_ids[3], 'draft-divider');
|
|
});
|
|
|
|
test('validated candidate adoption preserves active editor object identity', () => {
|
|
const target = configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
|
|
walls: [{ key: wallKey([0, 0], [1, 0], GRID_STEP_N), a: [0, 0], b: [1, 0], cm: 15 }],
|
|
openings: [{ id: 'door', type: 'door', x: 0.5, y: 0, angle: 0, length: 0.2 }],
|
|
});
|
|
const space = target.spaces[0];
|
|
const opening = space.openings[0];
|
|
const candidate = commitWallSegmentModel(target).config;
|
|
adoptWallSegmentModelCandidateInPlace(target, candidate);
|
|
assert.equal(target.spaces[0], space);
|
|
assert.equal(space.openings[0], opening);
|
|
assert.equal(target.model_version, WALL_SEGMENT_MODEL_VERSION);
|
|
assert.equal(opening.host.kind, 'wall');
|
|
});
|
|
|
|
test('initial migration resolves a reserved deterministic id with the documented suffix', () => {
|
|
const baseId = deterministicWallSegmentId('floor', [0, 0], [1, 0], ['room']);
|
|
const result = commitWallSegmentModel(configOf({
|
|
id: 'floor', title: 'Floor', rooms: [rectangle('room')],
|
|
partitions: [{ id: baseId, a: [2, 0], b: [3, 0], cm: 15 }],
|
|
})).config.spaces[0];
|
|
assert.equal(result.rooms[0].wall_ids[0], `${baseId}-2`);
|
|
});
|
|
|
|
test('every frontend structural transaction crosses the wall identity barrier', () => {
|
|
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
|
const optimizer = readFileSync(new URL('../src/plan-optimizer.ts', import.meta.url), 'utf8');
|
|
const methodSource = (name) => {
|
|
const start = source.indexOf(`private ${name}(`);
|
|
assert.notEqual(start, -1, `${name} must remain a named structural boundary`);
|
|
const end = source.indexOf('\n private ', start + 10);
|
|
return source.slice(start, end < 0 ? source.length : end);
|
|
};
|
|
const commitMethod = methodSource('_commitPhysicalGeometry');
|
|
const restoreMethod = methodSource('_applyGeometryState');
|
|
assert.match(commitMethod, /commitWallSegmentModel\(liveCandidate\)/,
|
|
'ordinary structural commits must cross the identity barrier');
|
|
assert.match(restoreMethod, /commitWallSegmentModel\(restoredCandidate\)/,
|
|
'Undo/Redo restoration must cross the identity barrier');
|
|
assert.match(optimizer, /commitWallSegmentModelInPlace\(config\)/,
|
|
'Optimize must cross the identity barrier after its geometry repairs');
|
|
assert.doesNotMatch(commitMethod, /this\._writeConfig\(/,
|
|
'the common barrier must finish before the existing persistence path runs');
|
|
});
|