fix(plan): legacy room drafts are healed, not a locked door

A plan that still carried a `room_drafts` key while already on the
current wall model could not be edited at all. The card mirrors the same
migration, so a structural edit was refused before the request ever left
the browser; the toast sent the user to "Optimize plans", which reports
that everything is already optimal because it looks at something else
entirely; and the export path calls the same migration, so the one way
out — take a backup, fix the file by hand — was shut too. An empty
`room_drafts: []`, carrying no data at all, was enough to do it.

The carrier is now removed the way the first migration removes it: an
empty key silently, drafts converted one for one into partitions. The
#478 protection against a stale client re-adding the carrier moves to
the layer that can actually tell the two apart —
`validate_wall_model_transition` sees both the submission and the stored
plan, and refuses when the drafts appear over a plan that does not have
them. It no longer keys on the submitted model number: a stale card
echoes back the number it was given, which is exactly how the outdated
client slipped past this guard and met "conflicting wall identifiers"
instead of "update the card and reload the page". The schema invariant
keeps refusing a non-empty carrier as the last line.

Both mirrors change together and stay identical; the parity fixture is
untouched.

Issue: #529
User-Visible: yes
This commit is contained in:
Codex
2026-09-11 11:19:04 +03:00
parent b736878c0b
commit 49912ac989
52 changed files with 596 additions and 358 deletions
+47
View File
@@ -3611,6 +3611,53 @@ const MUTANT_DEFINITIONS = [
replace: " if (ledger.caught[mutant.id] === fingerprint || mutant.id in ledger.caught) skipped.push(mutant);",
}],
},
{
id: 'room-drafts-refuse-instead-of-heal',
guard: 'node scripts/backend-test-guard.mjs '
+ 'room_drafts_on_current_model_convert_exactly_like_the_first_migration '
+ 'tests_backend/test_wall_segment_model.py',
because: '#529: наследие обязано сниматься миграцией, а не запирать план — отказ здесь '
+ 'отбивал структурную правку ещё до отправки на сервер и заодно ломал экспорт, '
+ 'то есть единственный способ вытащить бэкап и починить конфиг руками',
patches: [{
file: 'custom_components/houseplan/wall_segment_model.py',
find: ' if not drafts:\n'
+ ' space.pop("room_drafts", None)\n'
+ ' return 0, 0',
replace: ' if True:\n'
+ ' raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")',
}],
},
{
id: 'outdated-client-detected-by-model-number',
guard: 'node scripts/backend-test-guard.mjs '
+ 'outdated_client_is_recognised_by_the_carrier_not_by_the_model_number '
+ 'tests_backend/test_wall_segment_model.py',
because: '#529: устаревшая карточка возвращает эхом полученный номер модели, поэтому '
+ 'признаком служит сам носитель поверх чистого сохранённого конфига; сверка по '
+ 'номеру пропускала ровно тот случай, ради которого сторож и написан (#478 AC2)',
patches: [{
file: 'custom_components/houseplan/validation.py',
find: ' if old_model >= 10 and not stored_drafts and any(',
replace: ' if old_model >= 10 and new_model < 10 and any(',
}],
},
{
id: 'room-drafts-mirror-still-throws',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#529" test/wall-segment-model.test.mjs',
because: '#529: зеркало в карточке лечит хранилище — именно оно снимает ключ в кандидате, '
+ 'который уходит в config/set; если оно снова бросает, план остаётся запертым '
+ 'независимо от того, что умеет бэкенд',
patches: [{
file: 'src/wall-segment-model.ts',
find: ' if (!drafts.length) {\n'
+ ' delete space.room_drafts;\n'
+ ' return { drafts: 0, segments: 0 };\n'
+ ' }',
replace: " if (true) throw new WallSegmentModelError('duplicate-id', 'model v10 must not contain room_drafts');",
}],
},
{
id: 'css-minifier-skips-typescript-output',
guard: 'node --test --test-name-pattern="#526 AC4|#526 AC2" test/bundle-assets.test.mjs',