Files
houseplan-card/test/optional-space-model-contract.test.mjs
T
Codex 00b6f41233 fix: the danger confirmation lives outside render()'s branches (#402)
hp-confirm sat at the end of a chain of early returns, so in onboarding
(«no spaces yet»), in the fixed-floor states and without a space it did
not exist at all: the trash button next to a saved plan was dead and the
promise hung forever, because the decision event had no source in the
DOM. An already open dialog vanished the moment the card slipped into
one of those branches, leaving the caller waiting for a resolution that
could never come. Before #32 a browser confirm() worked there.

render() is now a wrapper: it takes the body — the old chain, unchanged,
as _renderBody — and renders the confirmation beside it. That fixes the
class rather than the instance: a branch added later cannot lose the
dialog again. noChange and nothing are passed through untouched, since
neither may be wrapped in a template; in those states _confirmDanger
refuses the request outright instead of leaving it pending, which is the
honest answer while the card is not on screen and the user has pressed
nothing.

_tapConfirm and _vacCalConfirm deliberately stay where they are. They
share the same final branch, but they have no promise (a synchronous
exec, a dialog closed by hp-close), so the defect cannot occur there,
and their entry points require a drawn plan.

Proven by a separate smoke rather than an addition to
smoke_danger_confirmation: that file keeps deliberately incomplete
dialog fixtures open, and the extra re-renders this change needs make
them throw. The new smoke runs under touch emulation, because
TOUCH-SUPPORT § Safety floor forbids bypassing a destructive
confirmation and the broken branch pierced that floor on finger as
surely as on mouse. Reverting the wrapper reddens it.

User-Visible: yes
Issue: #402
2026-08-31 16:47:05 +03:00

86 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
const source = readHouseplanProductionSource();
const methodBody = (name) => {
const start = source.search(new RegExp(`(?:private|protected)\\s+(?:get\\s+|async\\s+)?${name}(?:\\s*=)?(?:\\(|:)`));
assert.notEqual(start, -1, `${name} exists`);
const tail = source.slice(start + 1);
const next = tail.search(/\n (?:private|protected|public)\s/);
return source.slice(start, next < 0 ? source.length : start + 1 + next);
};
test('_spaceModel exposes absence and no call site bypasses it', () => {
assert.match(source, /private _spaceModel\(\): SpaceModel \| undefined/);
assert.match(source, /private _spaceModelById\([^)]*\): SpaceModel \| undefined/);
assert.doesNotMatch(source, /this\._spaceModel\(\s*[^)]/,
'explicit ids belong to the exact selector');
assert.doesNotMatch(source, /this\._spaceModel\(\)\s*!/,
'absence must not be hidden behind a non-null assertion');
assert.doesNotMatch(source, /this\._spaceModel\(\)\s*\./,
'every dereference is guarded or uses an intentional empty fallback');
});
test('authoritative empty-space cleanup aborts every space-bound transaction', () => {
const cleanup = methodBody('_syncEmptySpaceState');
for (const fragment of [
'releasePointerCapture', '_pointers.clear()', '_cancelModeTransition(false)',
"_mode = 'view'", '_clearGeometryGesture()', '_geometryHistory.clear()',
'_resumeDraftBySpace = {}', '_drag = null', '_vacFit = null',
'_markerDialog = null', '_saveConfigDebounced.cancel()', "_commitSpace('', true)",
]) assert.ok(cleanup.includes(fragment), fragment);
const willUpdate = methodBody('willUpdate');
assert.ok(willUpdate.indexOf('_syncEmptySpaceState()')
< willUpdate.indexOf('_captureRenderDeviceSnapshot()'),
'cleanup happens before a new render snapshot is captured');
});
test('stable space ids use exact lookup and abort before side effects', () => {
for (const name of [
'_livePos', '_vacPlanRoomAnchors', '_vacStartFit', '_labelMove', '_rlResizeMove',
]) assert.match(methodBody(name), /_spaceModelById\(/, name);
const saveMarker = methodBody('_saveMarker');
const exactAt = saveMarker.indexOf('_spaceModelById(explicitSpaceId)');
const guardAt = saveMarker.indexOf('if (!targetSpaceModel) return;');
const busyAt = saveMarker.indexOf('busy: true');
const migrateAt = saveMarker.indexOf("type: 'houseplan/files/migrate'");
const configMutationAt = saveMarker.indexOf('cfg.markers = markers');
assert.ok(exactAt >= 0 && exactAt < guardAt && guardAt < busyAt
&& busyAt < migrateAt && migrateAt < configMutationAt);
const savePos = methodBody('_savePos');
const positionExactAt = savePos.indexOf('_spaceModelById(d.space)');
const layoutMutationAt = savePos.indexOf('this._layout =');
const dirtyAt = savePos.indexOf('_dirtyPos.add');
const persistAt = savePos.indexOf('_persistLayout()');
assert.ok(positionExactAt >= 0 && positionExactAt < layoutMutationAt
&& layoutMutationAt < dirtyAt && dirtyAt < persistAt,
'stale position writes abort before layout, dirty and persistence side effects');
});
test('empty render keeps create/import affordances without spatial layers', () => {
// #402: цепочка веток переехала из `render` в `_renderBody`, а `render`
// стал обёрткой — подтверждение опасного действия обязано жить снаружи
// веток, иначе в онбординге его не существует вовсе.
const render = methodBody('_renderBody');
const emptyAt = render.indexOf('if (!model.length)');
const addAt = render.indexOf("_openSpaceDialog('create')");
const spatialAt = render.indexOf('const space = this._spaceModel()');
assert.ok(emptyAt >= 0 && emptyAt < addAt && addAt < spatialAt);
assert.match(render, /if \(!space\) return nothing;/);
// Обёртка: тело + подтверждение, причём «ничего не рисуем» пробрасывается
// как есть — `noChange` нельзя оборачивать в шаблон.
const wrapper = methodBody('render');
assert.match(wrapper, /const body = this\._renderBody\(\);/);
assert.match(wrapper, /if \(body === noChange \|\| body === nothing\) return body;/);
assert.match(wrapper, /return html`\$\{body\}\$\{this\._renderDangerConfirm\(\)\}`;/);
assert.equal(render.includes('_renderDangerConfirm'), false,
'подтверждение не должно возвращаться внутрь ветки — это и есть дефект #402');
});