mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
86 lines
4.6 KiB
JavaScript
86 lines
4.6 KiB
JavaScript
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');
|
||
});
|