mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Обновление на новую модель стен больше не замораживает редактирование:
|
||||
сервер распознаёт первую запись более новой карточки поверх старого
|
||||
документа (например, с осиротевшей легаси-границей) вместо требования
|
||||
обновить уже обновлённую карточку
|
||||
([#319](https://github.com/Matysh/houseplan-card/issues/319)).
|
||||
|
||||
## v1.68.0-beta.3 — 2026-08-26
|
||||
|
||||
- Теперь у стен одна модель при любой толщине. Отдельные инструмент
|
||||
|
||||
+505
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user