From 3ab6fa9f8a1ef1c35730c3fb03870f69c3e50be6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 26 Aug 2026 18:16:29 +0300 Subject: [PATCH] fix: the first write of a newer wall model is not an outdated client (#319) 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 --- custom_components/houseplan/validation.py | 8 +- docs/CHANGELOG.md | 6 + docs/CHANGELOG.ru.md | 6 + test/fixtures/319-orphan-span-migration.json | 505 +++++++++++++++++++ test/wall-segment-model.test.mjs | 17 + tests_backend/test_wall_segment_model.py | 39 ++ 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/319-orphan-span-migration.json diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index 7ee2d68f..b80b9ce4 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -190,13 +190,19 @@ def validate_wall_model_transition(config: dict, previous: dict | None) -> None: raise WallModelClientOutdatedError( f"stored model={old_model}; submitted model={new_model}" ) - if old_model >= 8 and new_model >= 8 and contour_geometry_changed: + if old_model >= 8 and 8 <= new_model <= old_model and contour_geometry_changed: # The realistic stale-client case echoes model_version and the unknown # catalogue verbatim while changing room/contour geometry. Let the # frontend show the dedicated reload guidance instead of a generic # schema error. Independent drafts, partitions, columns and explicitly # hosted openings are validated by CONFIG_SCHEMA without requiring a # contour catalogue change (#314). + # + # A submitted model ABOVE the stored one is excluded on purpose (#319): + # a stale client can only echo the stored version, never raise it. The + # first write of a newer client legitimately drops legacy projection + # fields (an orphan open_span/open_to) without touching the catalogue; + # treating that as "outdated" wedged every structural write forever. if _wall_catalog_projection(config) == _wall_catalog_projection(previous): raise WallModelClientOutdatedError( f"stored model={old_model}; unchanged wall catalogue" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 849d99cf..73a82c58 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Updating to the new wall model no longer freezes editing: the server now + recognises the first write of a newer card over an older document (for + example one that still carried an orphan legacy border) instead of demanding + a card update that was already installed + ([#319](https://github.com/Matysh/houseplan-card/issues/319)). + ## v1.68.0-beta.3 — 2026-08-26 - Walls now use one model at every thickness. The separate **Boundary** tool diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index e6f55117..f2f6da67 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,12 @@ ## Не выпущено +- Обновление на новую модель стен больше не замораживает редактирование: + сервер распознаёт первую запись более новой карточки поверх старого + документа (например, с осиротевшей легаси-границей) вместо требования + обновить уже обновлённую карточку + ([#319](https://github.com/Matysh/houseplan-card/issues/319)). + ## v1.68.0-beta.3 — 2026-08-26 - Теперь у стен одна модель при любой толщине. Отдельные инструмент diff --git a/test/fixtures/319-orphan-span-migration.json b/test/fixtures/319-orphan-span-migration.json new file mode 100644 index 00000000..339c608b --- /dev/null +++ b/test/fixtures/319-orphan-span-migration.json @@ -0,0 +1,505 @@ +{ + "comment": "stored: produced by the v1.68.0-beta.2 (model v8) writer from the 282 parity input plus one orphan open_span; sent: the same document after the current initial migration. The catalogue is byte-identical, the orphan span is dropped (#319).", + "stored": { + "spaces": [ + { + "id": "floor", + "title": "Floor", + "rooms": [ + { + "id": "large", + "poly": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 1, + 0.25 + ], + [ + 1, + 0.75 + ], + [ + 1, + 1 + ], + [ + 0, + 1 + ] + ], + "wall_ids": [ + "wall-7cu6gpb7e44ucopgzjm2", + "wall-vgpy3fkbbprvy2cofyq3", + "wall-g33xjvyd5marxwkowoqm", + "wall-tqy5sdtydjrx7syybupl", + "wall-j2ti4ktyymgw4e2r5ns5", + "wall-oq2aqjdsktorqgxatdwh" + ] + }, + { + "id": "small", + "poly": [ + [ + 1, + 0.25 + ], + [ + 1.5, + 0.25 + ], + [ + 1.5, + 0.75 + ], + [ + 1, + 0.75 + ] + ], + "wall_ids": [ + "wall-d2hfqa5fvueqwrwo2da7", + "wall-ybr4j2bztfx2wkbwvbwd", + "wall-qancsrbiwkhhy5hos7io", + "wall-g33xjvyd5marxwkowoqm" + ] + } + ], + "walls": [ + { + "key": "0.500000,0.000000@0.0000", + "cm": 15, + "a": [ + 0, + 0 + ], + "b": [ + 1, + 0 + ] + } + ], + "openings": [ + { + "id": "door", + "type": "door", + "x": 0.5, + "y": 0, + "angle": 0, + "length": 0.2, + "host": { + "kind": "wall", + "id": "wall-7cu6gpb7e44ucopgzjm2", + "t": 0.5 + } + } + ], + "room_drafts": [ + { + "id": "draft", + "points": [ + [ + 2, + 0 + ], + [ + 2.5, + 0 + ], + [ + 3, + 0 + ] + ], + "segments": [ + { + "cm": 10, + "id": "wall-ulziozdfsjzlriamibvg" + }, + { + "cm": 20, + "id": "wall-bvlpyhrsy6xjgox3g6e6" + } + ] + } + ], + "open_spans": [ + { + "a": [ + 0.83, + 0.91 + ], + "b": [ + 0.93, + 0.91 + ] + } + ], + "wall_segments": [ + { + "id": "wall-oq2aqjdsktorqgxatdwh", + "a": [ + 0, + 0 + ], + "b": [ + 0, + 1 + ], + "cm": 0 + }, + { + "id": "wall-7cu6gpb7e44ucopgzjm2", + "a": [ + 0, + 0 + ], + "b": [ + 1, + 0 + ], + "cm": 15 + }, + { + "id": "wall-j2ti4ktyymgw4e2r5ns5", + "a": [ + 0, + 1 + ], + "b": [ + 1, + 1 + ], + "cm": 0 + }, + { + "id": "wall-vgpy3fkbbprvy2cofyq3", + "a": [ + 1, + 0 + ], + "b": [ + 1, + 0.25 + ], + "cm": 0 + }, + { + "id": "wall-g33xjvyd5marxwkowoqm", + "a": [ + 1, + 0.25 + ], + "b": [ + 1, + 0.75 + ], + "cm": 0 + }, + { + "id": "wall-d2hfqa5fvueqwrwo2da7", + "a": [ + 1, + 0.25 + ], + "b": [ + 1.5, + 0.25 + ], + "cm": 0 + }, + { + "id": "wall-tqy5sdtydjrx7syybupl", + "a": [ + 1, + 0.75 + ], + "b": [ + 1, + 1 + ], + "cm": 0 + }, + { + "id": "wall-qancsrbiwkhhy5hos7io", + "a": [ + 1, + 0.75 + ], + "b": [ + 1.5, + 0.75 + ], + "cm": 0 + }, + { + "id": "wall-ybr4j2bztfx2wkbwvbwd", + "a": [ + 1.5, + 0.25 + ], + "b": [ + 1.5, + 0.75 + ], + "cm": 0 + } + ] + } + ], + "markers": [], + "settings": {}, + "model_version": 8 + }, + "sent": { + "spaces": [ + { + "id": "floor", + "title": "Floor", + "rooms": [ + { + "id": "large", + "poly": [ + [ + 0, + 0 + ], + [ + 1, + 0 + ], + [ + 1, + 0.25 + ], + [ + 1, + 0.75 + ], + [ + 1, + 1 + ], + [ + 0, + 1 + ] + ], + "wall_ids": [ + "wall-7cu6gpb7e44ucopgzjm2", + "wall-vgpy3fkbbprvy2cofyq3", + "wall-g33xjvyd5marxwkowoqm", + "wall-tqy5sdtydjrx7syybupl", + "wall-j2ti4ktyymgw4e2r5ns5", + "wall-oq2aqjdsktorqgxatdwh" + ] + }, + { + "id": "small", + "poly": [ + [ + 1, + 0.25 + ], + [ + 1.5, + 0.25 + ], + [ + 1.5, + 0.75 + ], + [ + 1, + 0.75 + ] + ], + "wall_ids": [ + "wall-d2hfqa5fvueqwrwo2da7", + "wall-ybr4j2bztfx2wkbwvbwd", + "wall-qancsrbiwkhhy5hos7io", + "wall-g33xjvyd5marxwkowoqm" + ] + } + ], + "walls": [ + { + "key": "0.500000,0.000000@0.0000", + "cm": 15, + "a": [ + 0, + 0 + ], + "b": [ + 1, + 0 + ] + } + ], + "openings": [ + { + "id": "door", + "type": "door", + "x": 0.5, + "y": 0, + "angle": 0, + "length": 0.2, + "host": { + "kind": "wall", + "id": "wall-7cu6gpb7e44ucopgzjm2", + "t": 0.5 + } + } + ], + "room_drafts": [ + { + "id": "draft", + "points": [ + [ + 2, + 0 + ], + [ + 2.5, + 0 + ], + [ + 3, + 0 + ] + ], + "segments": [ + { + "cm": 10, + "id": "wall-ulziozdfsjzlriamibvg" + }, + { + "cm": 20, + "id": "wall-bvlpyhrsy6xjgox3g6e6" + } + ] + } + ], + "wall_segments": [ + { + "id": "wall-oq2aqjdsktorqgxatdwh", + "a": [ + 0, + 0 + ], + "b": [ + 0, + 1 + ], + "cm": 0 + }, + { + "id": "wall-7cu6gpb7e44ucopgzjm2", + "a": [ + 0, + 0 + ], + "b": [ + 1, + 0 + ], + "cm": 15 + }, + { + "id": "wall-j2ti4ktyymgw4e2r5ns5", + "a": [ + 0, + 1 + ], + "b": [ + 1, + 1 + ], + "cm": 0 + }, + { + "id": "wall-vgpy3fkbbprvy2cofyq3", + "a": [ + 1, + 0 + ], + "b": [ + 1, + 0.25 + ], + "cm": 0 + }, + { + "id": "wall-g33xjvyd5marxwkowoqm", + "a": [ + 1, + 0.25 + ], + "b": [ + 1, + 0.75 + ], + "cm": 0 + }, + { + "id": "wall-d2hfqa5fvueqwrwo2da7", + "a": [ + 1, + 0.25 + ], + "b": [ + 1.5, + 0.25 + ], + "cm": 0 + }, + { + "id": "wall-tqy5sdtydjrx7syybupl", + "a": [ + 1, + 0.75 + ], + "b": [ + 1, + 1 + ], + "cm": 0 + }, + { + "id": "wall-qancsrbiwkhhy5hos7io", + "a": [ + 1, + 0.75 + ], + "b": [ + 1.5, + 0.75 + ], + "cm": 0 + }, + { + "id": "wall-ybr4j2bztfx2wkbwvbwd", + "a": [ + 1.5, + 0.25 + ], + "b": [ + 1.5, + 0.75 + ], + "cm": 0 + } + ] + } + ], + "markers": [], + "settings": {}, + "model_version": 9 + } +} diff --git a/test/wall-segment-model.test.mjs b/test/wall-segment-model.test.mjs index 34c537b8..04cc2fe1 100644 --- a/test/wall-segment-model.test.mjs +++ b/test/wall-segment-model.test.mjs @@ -26,6 +26,23 @@ 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', diff --git a/tests_backend/test_wall_segment_model.py b/tests_backend/test_wall_segment_model.py index 8dfe9aee..109be07c 100644 --- a/tests_backend/test_wall_segment_model.py +++ b/tests_backend/test_wall_segment_model.py @@ -223,6 +223,45 @@ def test_stale_client_echoing_v8_catalog_gets_the_named_error() -> None: validate_wall_model_transition(non_structural, previous) +def test_first_write_of_a_newer_model_is_not_outdated_even_without_catalog_change() -> None: + """#319: a client can only echo the stored model_version, never raise it. + + The pair fixture is generated by real writers: `stored` by the + v1.68.0-beta.2 (model v8) writer with one orphan open_span, `sent` by the + current initial migration — the catalogue is byte-identical, the orphan + span is dropped. Before the fix this exact pair raised + WallModelClientOutdatedError and wedged every structural write forever. + """ + fixture = json.loads(( + Path(__file__).parents[1] / "test" / "fixtures" + / "319-orphan-span-migration.json" + ).read_text(encoding="utf-8")) + stored, sent = fixture["stored"], fixture["sent"] + assert stored["model_version"] == 8 and sent["model_version"] == 9 + assert stored["spaces"][0]["wall_segments"] == sent["spaces"][0]["wall_segments"] + assert "open_spans" in stored["spaces"][0] + assert "open_spans" not in sent["spaces"][0] + + # AC1: the pure migration write passes. + validate_wall_model_transition(copy.deepcopy(sent), stored) + + # AC2: the same write with the user's first draft (the first click of the + # Walls tool, independent geometry per #314) passes too. + with_draft = copy.deepcopy(sent) + with_draft["spaces"][0].setdefault("room_drafts", []).append({ + "id": "d-319", "points": [[0.7, 0.7], [0.8, 0.7]], + "segments": [{"id": "seg-d-319", "cm": 20}], + }) + validate_wall_model_transition(with_draft, stored) + + # AC3: the same-version echo (9 → 9) with changed contours and an + # unchanged catalogue keeps the named refusal. + echoed = copy.deepcopy(sent) + echoed["spaces"][0]["rooms"][0]["poly"][0][0] += 0.01 + with pytest.raises(WallModelClientOutdatedError, match="unchanged wall catalogue"): + validate_wall_model_transition(echoed, copy.deepcopy(sent)) + + def test_current_wall_model_independent_geometry_does_not_require_contour_catalog_change() -> None: """Drafts, partitions, columns and hosted openings own their identity (#314).""" previous, _ = commit_wall_segment_model(_config({