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, 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'; import { readHouseplanProductionSource } from './houseplan-source.mjs'; 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.partitions.map((segment) => segment.id), fixture.expected.draft_ids); assert.equal('room_drafts' in space, false); }); test('#478: v9 persisted drafts migrate one-for-one to ordinary partitions', () => { const input = { model_version: 9, markers: [], settings: {}, spaces: [{ id: 'floor', title: 'Floor', rooms: [], wall_segments: [], room_drafts: [{ id: 'draft-a', points: [[0, 0], [0.25, 0], [0.25, 0.5]], segments: [{ id: 'draft-wall-a', cm: 0 }, { cm: 22 }], }], partitions: [{ id: 'partition-old', a: [1, 0], b: [1, 1], cm: 15 }], }], }; const first = commitWallSegmentModel(input); const space = first.config.spaces[0]; assert.equal(first.config.model_version, 10); assert.equal(first.migratedDrafts, 1); assert.equal(first.migratedDraftSegments, 2); assert.equal('room_drafts' in space, false); assert.deepEqual(space.partitions[0], input.spaces[0].partitions[0]); assert.deepEqual(space.partitions[1], { id: 'draft-wall-a', a: [0, 0], b: [0.25, 0], cm: 0, }); assert.match(space.partitions[2].id, /^wall-/); assert.deepEqual( { a: space.partitions[2].a, b: space.partitions[2].b, cm: space.partitions[2].cm }, { a: [0.25, 0], b: [0.25, 0.5], cm: 22 }, ); const second = commitWallSegmentModel(first.config); assert.equal(second.changed, false); assert.equal(second.migratedDrafts, 0); assert.equal(second.migratedDraftSegments, 0); assert.deepEqual(second.config, first.config); }); test('#478: malformed legacy draft vectors have deterministic migration semantics', () => { const fixture = JSON.parse(readFileSync( new URL('./fixtures/478-room-draft-migration-vectors.json', import.meta.url), 'utf8', )); const accepted = commitWallSegmentModel(fixture.accepted.input); assert.deepEqual(accepted.config.spaces[0].partitions, fixture.accepted.expected_partitions); assert.equal(accepted.migratedDrafts, 3); assert.equal(accepted.migratedDraftSegments, 3); assert.equal('room_drafts' in accepted.config.spaces[0], false); for (const vector of fixture.rejected) { const input = { model_version: 9, markers: [], settings: {}, spaces: [{ id: 'floor', title: 'Floor', rooms: [], wall_segments: [], room_drafts: [vector.draft], }], }; assert.throws(() => commitWallSegmentModel(input), (error) => ( error instanceof WallSegmentModelError && error.reason === vector.expected_reason )); } }); // #529. Прежде здесь стоял отказ: конфиг текущей модели с ключом `room_drafts` // роняли даже когда список был пуст. Отказ стоил пользователю всего плана — // карточка считает эту же миграцию своим зеркалом, поэтому структурная правка // отбивалась ещё до отправки, а экспорт звал ту же функцию. Защита #478 от // устаревшего клиента переехала на слой валидации, где видно и заявку, и // сохранённый конфиг; здесь наследие снимается. test('#529: пустой room_drafts снимается молча, план не запирается', () => { const { config } = commitWallSegmentModel({ model_version: 10, markers: [], settings: {}, spaces: [{ id: 'floor', title: 'Floor', rooms: [], wall_segments: [], room_drafts: [], }], }); assert.equal('room_drafts' in config.spaces[0], false); assert.deepEqual(config.spaces[0].partitions ?? [], []); }); test('#529: черновики в конфиге текущей модели конвертируются один к одному', () => { const draft = { id: 'draft-1', points: [[0.5, 0.5], [0.7, 0.5]], segments: [{ id: 'draft-seg-1', cm: 10 }], }; const legacy = () => ({ markers: [], settings: {}, spaces: [{ id: 'floor', title: 'Floor', rooms: [], wall_segments: [], room_drafts: [structuredClone(draft)], }], }); const current = commitWallSegmentModel({ ...legacy(), model_version: 10 }).config; const initial = commitWallSegmentModel({ ...legacy(), model_version: 9 }).config; assert.equal('room_drafts' in current.spaces[0], false); assert.deepEqual(current.spaces[0].partitions.map((item) => item.id), ['draft-seg-1']); // Тот же результат, что у первой миграции того же конфига: лечение не // выдумывает собственную семантику. assert.deepEqual(current.spaces[0].partitions, initial.spaces[0].partitions); }); 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('a corner opening on bodyless walls migrates unhosted instead of blocking (#316 §3.3/§3.4)', () => { // Until #316 the initial migration refused this candidate outright; the // reviewed contract degrades it instead and never blocks the write. 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); const out = commitWallSegmentModel(input).config; assert.deepEqual(input, before, 'the input candidate is never mutated'); // Since #306 an unconfigured contour is explicitly bodyless (cm: 0), and a // zero wall is never an opening carrier — the corner door therefore // migrates UNHOSTED (§3.3) instead of blocking the whole candidate. assert.equal('host' in out.spaces[0].openings[0], false); const again = commitWallSegmentModel(structuredClone(out)).config; assert.equal(JSON.stringify(again), JSON.stringify(out), 'the outcome is stable'); }); 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 = readHouseplanProductionSource(); 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'); }); // --- #316: the initial migration auto-resolves opening-host conflicts --- const spanDoorConfig = () => ({ model_version: 8, spaces: [{ id: 'B', title: 'B', cell_cm: 5, view_box: [0, 0, 1, 0.7], rooms: [{ id: 'rB', poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.5], [0.2, 0.5]] }], walls: [{ key: wallKey([0.2, 0.2], [0.6, 0.2], GRID_STEP_N), a: [0.2, 0.2], b: [0.6, 0.2], cm: 15 }], open_spans: [{ a: [0.2, 0.2], b: [0.6, 0.2] }], openings: [{ id: 'op1', type: 'door', x: 0.4, y: 0.2, angle: 0, length: 0.09, cm: 90 }], }], markers: [], settings: {}, }); test('an opening keeps its wall through a legacy span cut (#316 §3.1) and the run stays idempotent', () => { const out = commitWallSegmentModel(spanDoorConfig()).config; const space = out.spaces[0]; const top = space.wall_segments .filter((s) => Math.abs(s.a[1] - 0.2) < 1e-9 && Math.abs(s.b[1] - 0.2) < 1e-9) .sort((x, y) => x.a[0] - y.a[0]); assert.equal(top.length, 3, 'the opening edges split the span run into three atoms'); assert.equal(top[0].cm, 0, 'left of the door the border stays zero'); assert.ok(top[1].cm > 0, 'the atom under the door keeps its real thickness'); assert.equal(top[2].cm, 0, 'right of the door the border stays zero'); assert.deepEqual(space.openings[0].host, { kind: 'wall', id: top[1].id, t: 0.5 }); assert.equal('open_spans' in space, false); const again = commitWallSegmentModel(structuredClone(out)).config; assert.equal(JSON.stringify(again), JSON.stringify(out), 'commit(commit(x)) == commit(x)'); }); test('an ambiguous carrier is resolved deterministically at a thickness boundary (#316 §3.2)', () => { // Two contour atoms of one wall meet at x=0.4 (thickness change); a short // opening centred exactly on the junction fits inside either atom. const config = { model_version: 8, spaces: [{ id: 'S', title: 'S', cell_cm: 5, view_box: [0, 0, 1, 0.7], rooms: [{ id: 'r', poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.5], [0.2, 0.5]] }], walls: [ { key: wallKey([0.2, 0.2], [0.4, 0.2], GRID_STEP_N), a: [0.2, 0.2], b: [0.4, 0.2], cm: 10 }, { key: wallKey([0.4, 0.2], [0.6, 0.2], GRID_STEP_N), a: [0.4, 0.2], b: [0.6, 0.2], cm: 20 }, ], openings: [{ id: 'op', type: 'door', x: 0.4, y: 0.2, angle: 0, length: 0, cm: 0 }], }], markers: [], settings: {}, }; const out = commitWallSegmentModel(structuredClone(config)).config; const space = out.spaces[0]; const host = space.openings[0].host; assert.equal(host?.kind, 'wall'); const carrier = space.wall_segments.find((s) => s.id === host.id); assert.ok(carrier, 'the picked carrier exists in the catalogue'); // §3.2: equal distance → the thicker atom wins. assert.equal(carrier.cm, 20); const again = commitWallSegmentModel(structuredClone(out)).config; assert.equal(JSON.stringify(again), JSON.stringify(out), 'the pick is stable'); }); test('an opening with no usable carrier migrates unhosted and survives later writes (#316 §3.3/§3.4)', () => { const config = spanDoorConfig(); // The opening is far from every wall and across the grain — no carrier. config.spaces[0].openings[0] = { id: 'orphan', type: 'door', x: 0.9, y: 0.65, angle: 90, length: 0.05, cm: 90, }; const out = commitWallSegmentModel(structuredClone(config)).config; const opening = out.spaces[0].openings[0]; assert.equal('host' in opening, false, 'the orphan persists unhosted'); // A later structural write keeps it and does not throw (§3.3). const again = commitWallSegmentModel(structuredClone(out)).config; assert.equal('host' in again.spaces[0].openings[0], false); assert.equal(JSON.stringify(again), JSON.stringify(out)); }); test('a far same-angle wall is NOT a degraded carrier — the opening migrates unhosted (#316 H1)', () => { // CODE-REVIEW-316-r1 H1: a host away from the opening's own x/y would break // the backend geometry-match invariant and wedge the write on the schema // layer. The opening must therefore stay unhosted, not adopt a distant wall. const config = spanDoorConfig(); config.spaces[0].openings[0] = { id: 'far', type: 'door', x: 0.4, y: 0.45, angle: 0, length: 0.09, cm: 90, }; delete config.spaces[0].open_spans; const out = commitWallSegmentModel(structuredClone(config)).config; const opening = out.spaces[0].openings[0]; assert.equal('host' in opening, false, 'no distant fallback host'); const again = commitWallSegmentModel(structuredClone(out)).config; assert.equal(JSON.stringify(again), JSON.stringify(out)); }); test('a post-v9 write that LOST its carrier keeps the fail-closed refusal (#316 AC5)', () => { const migrated = commitWallSegmentModel(spanDoorConfig()).config; const broken = structuredClone(migrated); // Point the hosted opening at a segment id that no longer exists. broken.spaces[0].openings[0].host = { kind: 'wall', id: 'wall-gone', t: 0.5 }; broken.spaces[0].openings[0].x = 0.9; broken.spaces[0].openings[0].y = 0.65; broken.spaces[0].openings[0].angle = 90; assert.throws( () => commitWallSegmentModel(broken), (error) => error instanceof WallSegmentModelError && error.reason === 'opening-host', ); }); test('the #316 golden fixture matches the current initial migration byte for byte', () => { const fixture = JSON.parse(readFileSync( new URL('./fixtures/316-span-over-door-migrated.json', import.meta.url), 'utf8', )); const migrated = commitWallSegmentModel(structuredClone(fixture.stored)).config; assert.deepEqual(migrated, fixture.migrated); });