fix: preserve resize undo through write repair

Issue: #293
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 17:46:20 +03:00
parent 08dce8603d
commit e2c1dbec0d
8 changed files with 61 additions and 17 deletions
File diff suppressed because one or more lines are too long
+19 -2
View File
@@ -137,6 +137,15 @@ if (target) {
window.__card._geometryHistory.size), 1);
await page.waitForTimeout(650);
check('resize_pointer.one_atomic_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 1);
check('resize_pointer.undo_ready_after_write_ack', await page.evaluate(() => {
const card = window.__card;
return {
history: card._geometryHistory.size,
mode: card._mode,
tool: card._tool,
canCommit: card._canCommitSpace('real-second-floor'),
};
}), { history: 1, mode: 'plan', tool: 'resize', canCommit: true });
check('resize_pointer.wall_metadata_preserved', await page.evaluate(() => {
const walls = window.__card._serverCfg.spaces[0].walls || [];
return { count: walls.length, cms: walls.map((wall) => wall.cm).sort((a, b) => a - b) };
@@ -144,7 +153,15 @@ if (target) {
await page.keyboard.press('Control+z');
await settle();
check('resize_pointer.undo_keyboard_consumed_history', await page.evaluate(() => ({
history: window.__card._geometryHistory.size,
canRedo: window.__card._geometryHistory.canRedo,
})), { history: 0, canRedo: true });
check('resize_pointer.undo_byte_exact', await persistedGeometry(), before);
await page.waitForTimeout(650);
check('resize_pointer.undo_one_atomic_write', await page.evaluate(() =>
window.__resizeWrites.length), writesBefore + 2);
const stableAfterUndo = await persistedGeometry();
// The second gesture leaves the circle by much more than its hit radius.
// Pointer capture must keep the real browser stream alive; Esc then cancels
@@ -158,7 +175,7 @@ if (target) {
await page.keyboard.press('Escape');
await page.mouse.up();
await settle();
check('resize_pointer.escape_restores_config', await persistedGeometry(), before);
check('resize_pointer.escape_restores_config', await persistedGeometry(), stableAfterUndo);
await page.waitForTimeout(650);
check('resize_pointer.escape_zero_extra_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 2);
@@ -193,7 +210,7 @@ if (target) {
await page.mouse.up();
await settle();
check('resize_pointer.capture_loss_restores_dom', await domHasSharedX(400), true);
check('resize_pointer.capture_loss_restores_config', await persistedGeometry(), before);
check('resize_pointer.capture_loss_restores_config', await persistedGeometry(), stableAfterUndo);
await page.waitForTimeout(650);
check('resize_pointer.capture_loss_zero_extra_write', await page.evaluate(() => window.__resizeWrites.length), writesBefore + 2);
}
+3 -3
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -7,7 +7,9 @@
from the press point, preserves one continuous side-wall thickness record,
keeps working outside the handle through pointer capture, and stops at the
last renderable preview with an explicit message instead of silently doing
nothing ([#293](https://github.com/Matysh/houseplan-card/issues/293)).
nothing. Undo remains available after the write acknowledgement, including
for an older plan that the storage boundary repairs while saving
([#293](https://github.com/Matysh/houseplan-card/issues/293)).
- Every saved plan coordinate now converges to the exact shared `1/240` grid
value when it differs only by an invisible floating-point tail. Authored
off-grid/diagonal geometry is preserved. “Optimize plans” reports the number
+3 -1
View File
@@ -13,7 +13,9 @@
смещение от точки нажатия, сохраняет одну непрерывную запись толщины боковой
стены, продолжает работать за пределами ручки благодаря pointer capture и
останавливается на последнем отображаемом preview с явным сообщением вместо
молчаливого бездействия
молчаливого бездействия. Undo остаётся доступным после подтверждения записи,
в том числе для старого плана, который storage-барьер ремонтирует при
сохранении
([#293](https://github.com/Matysh/houseplan-card/issues/293)).
- Все сохраняемые координаты плана теперь приводятся к точному общему узлу
сетки `1/240`, если отличаются от него только невидимым хвостом вычислений.
+11
View File
@@ -298,6 +298,17 @@ export const MUTANTS = [
replace: ' safe = true;',
}],
},
{
id: 'resize-history-boundary-repair-removed',
guard: 'node demo/smoke_resize_pointer_real_plan.mjs',
because: 'write-time wall degradation must not erase the one Resize Undo command; '
+ 'the strict outbound barrier remains authoritative after the local restore (#293)',
patches: [{
file: 'src/houseplan-card.ts',
find: " && check?.reason === 'wall-degraded-extra');",
replace: ' && false);',
}],
},
{
id: 'resize-pointer-delta-zeroed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+18 -6
View File
@@ -2727,7 +2727,7 @@ class HouseplanCard extends LitElement {
this._undoPoint();
return;
}
if (!this._applyGeometryState(command.before)) {
if (!this._applyGeometryState(command.before, true)) {
this._geometryHistory.clear();
return;
}
@@ -7289,7 +7289,9 @@ class HouseplanCard extends LitElement {
if (viewportGestureEnded && this._pointers.size === 0) this.requestUpdate();
}
private _applyGeometryState(state: SpaceGeometryState): boolean {
private _applyGeometryState(
state: SpaceGeometryState, allowHistoryBoundaryRepair = false,
): boolean {
if (!this._canCommitSpace(state.spaceId)) return false;
const before = this._geometrySnapshot(state.spaceId);
if (!before || !this._restoreGeometryStateLocal(state)) return false;
@@ -7297,8 +7299,18 @@ class HouseplanCard extends LitElement {
!== spacePhysicalGeometryFingerprint(state);
if (physicalChanged) {
let safe = false;
try { safe = !!this._serverCfg
&& this._checkSpacePhysicalGeometry(this._serverCfg, state.spaceId).ok; } catch { safe = false; }
try {
const check = this._serverCfg
? this._checkSpacePhysicalGeometry(this._serverCfg, state.spaceId)
: null;
// A history snapshot can predate the write-time wall degradation that
// canonicalized its command. Restore that one repairable baseline so
// Undo remains byte-exact immediately; _writeConfig still degrades and
// strictly validates the outbound candidate before it can leave the
// card. Every other preflight failure stays fail-closed.
safe = !!check?.ok || !!(allowHistoryBoundaryRepair
&& check?.reason === 'wall-degraded-extra');
} catch { safe = false; }
if (!safe) {
this._restoreGeometryStateLocal(before);
this._showToast(this._t('toast.geometry_unsafe'));
@@ -7342,7 +7354,7 @@ class HouseplanCard extends LitElement {
if (this._rszDrag) { this._rszCancelDrag(); return; }
const command = this._geometryHistory.undo();
if (!command) return;
if (!this._applyGeometryState(command.before)) {
if (!this._applyGeometryState(command.before, true)) {
this._geometryHistory.clear();
return;
}
@@ -7365,7 +7377,7 @@ class HouseplanCard extends LitElement {
if (this._rszDrag) { this._rszCancelDrag(); return; }
const command = this._geometryHistory.redo();
if (!command) return;
if (!this._applyGeometryState(command.after)) {
if (!this._applyGeometryState(command.after, true)) {
this._geometryHistory.clear();
return;
}
+1 -1
View File
@@ -112,7 +112,7 @@ test('#278 production source routes physical writers through one barrier and dec
assert.doesNotMatch(source, /_commitPhysicalGeometry\(this\._t\('history\.decor_/);
assert.match(source, /this\._rszSpaceCandidateRenderable\(preview\.space, preview\.sp\)/);
assert.match(source, /this\._checkSpacePhysicalGeometry\(candidate, spaceId\)\.ok/);
assert.match(source, /if \(physicalChanged\)[\s\S]{0,900}_pendingPhysicalWrites\.set\(state\.spaceId/,
assert.match(source, /if \(physicalChanged\)[\s\S]{0,1500}_pendingPhysicalWrites\.set\(state\.spaceId/,
'physical Undo/Redo must retain the deferred-write barrier');
assert.match(source, /if \(configChanged\)[\s\S]{0,300}_pendingPhysicalWrites\.clear\(\)/,
'an external baseline must invalidate pending local approvals');