mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix: the initial wall-model migration resolves every opening conflict itself (#316)
Implements spec revision 4 (green r4). §3.1 — a legacy open_spans/open_to cut never zeroes the atom that carries an existing contour opening: the opening's edges become atom boundaries, the door keeps its real wall and the zero run continues on both sides. §3.2 — an ambiguous carrier resolves deterministically: current host, then distance, thicker cm, smaller id. §3.3 — an opening with no usable carrier persists unhosted: a valid degraded v9 state, inert in the physics, rendered by its own x/y, kept by later writes and re-placeable in the editor (backend schema accepts it). §3.4 — the initial migration never throws over an opening; a post-v9 write that LOST its carrier keeps the fail-closed opening-host refusal. The Python migration mirror implements the same rules with byte-identical output (verified on the span+door fixture including the segment id). The new smoke replays #316 end to end: a conflicted space no longer blocks drawing on an empty plan. The golden scene span-over-door-migrated-dark renders the migrated fixture pinned byte-for-byte to the real writer; two gate mutants revert §3.1 and §3.4 and are red by execution. Issue: #316 User-Visible: yes
This commit is contained in:
+258
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"comment": "stored: a pre-migration v8 document — a room whose top wall carries a door and is fully covered by a legacy open_span; migrated: the same document after the current initial migration (#316 §3.1) — the door keeps its carrying atom, the border is zero on both sides. The golden scene renders `migrated`; the unit test pins it to the real writer byte for byte.",
|
||||
"stored": {
|
||||
"model_version": 8,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "golden-span-over-door",
|
||||
"title": "Span over door",
|
||||
"cell_cm": 5,
|
||||
"view_box": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0.7
|
||||
],
|
||||
"settings": {
|
||||
"fill_mode": "none",
|
||||
"show_borders": true,
|
||||
"show_names": true
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "sod-room",
|
||||
"name": "Room",
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
[
|
||||
0.2,
|
||||
0.6
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.400000,0.300000@0.0000",
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
"cm": 15
|
||||
}
|
||||
],
|
||||
"open_spans": [
|
||||
{
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.3
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "sod-door",
|
||||
"type": "door",
|
||||
"x": 0.4,
|
||||
"y": 0.3,
|
||||
"angle": 0,
|
||||
"length": 0.09,
|
||||
"cm": 90,
|
||||
"flip_v": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
},
|
||||
"migrated": {
|
||||
"model_version": 9,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "golden-span-over-door",
|
||||
"title": "Span over door",
|
||||
"cell_cm": 5,
|
||||
"view_box": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0.7
|
||||
],
|
||||
"settings": {
|
||||
"fill_mode": "none",
|
||||
"show_borders": true,
|
||||
"show_names": true
|
||||
},
|
||||
"rooms": [
|
||||
{
|
||||
"id": "sod-room",
|
||||
"name": "Room",
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.445,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
[
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
[
|
||||
0.2,
|
||||
0.6
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"wall-s2g2iw3nbbxkym6sodv4",
|
||||
"wall-qmhko777ctlb3hey6c3v",
|
||||
"wall-odwfy5lyxkwsum2jewma",
|
||||
"wall-l3r6mytgo2l4so24dljp",
|
||||
"wall-mzbb6mwcqxj67rhw7mc5",
|
||||
"wall-uyhvjlqcorfj3orzvhhj"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.400000,0.300000@0.0000",
|
||||
"cm": 15,
|
||||
"a": [
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.445,
|
||||
0.3
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "sod-door",
|
||||
"type": "door",
|
||||
"x": 0.4,
|
||||
"y": 0.3,
|
||||
"angle": 0,
|
||||
"length": 0.09,
|
||||
"cm": 90,
|
||||
"flip_v": true,
|
||||
"host": {
|
||||
"kind": "wall",
|
||||
"id": "wall-qmhko777ctlb3hey6c3v",
|
||||
"t": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "wall-uyhvjlqcorfj3orzvhhj",
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.2,
|
||||
0.6
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-s2g2iw3nbbxkym6sodv4",
|
||||
"a": [
|
||||
0.2,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-mzbb6mwcqxj67rhw7mc5",
|
||||
"a": [
|
||||
0.2,
|
||||
0.6
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-qmhko777ctlb3hey6c3v",
|
||||
"a": [
|
||||
0.355,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.445,
|
||||
0.3
|
||||
],
|
||||
"cm": 15
|
||||
},
|
||||
{
|
||||
"id": "wall-odwfy5lyxkwsum2jewma",
|
||||
"a": [
|
||||
0.445,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
"cm": 0
|
||||
},
|
||||
{
|
||||
"id": "wall-l3r6mytgo2l4so24dljp",
|
||||
"a": [
|
||||
0.6,
|
||||
0.3
|
||||
],
|
||||
"b": [
|
||||
0.6,
|
||||
0.6
|
||||
],
|
||||
"cm": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 46);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 47);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
|
||||
@@ -262,18 +262,23 @@ test('room opening host resolver updates t and rejects ambiguous or zero walls',
|
||||
]), null);
|
||||
});
|
||||
|
||||
test('an ambiguous room opening blocks the complete candidate without mutating input', () => {
|
||||
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);
|
||||
assert.throws(
|
||||
() => commitWallSegmentModel(input),
|
||||
(error) => error instanceof WallSegmentModelError && error.reason === 'opening-host',
|
||||
);
|
||||
assert.deepEqual(input, before);
|
||||
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('draft segment ids materialise once and remain stable', () => {
|
||||
@@ -391,3 +396,100 @@ test('every frontend structural transaction crosses the wall identity barrier',
|
||||
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 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user