Files
houseplan-card/test/optional-space-model-contract.test.mjs
T
2026-09-06 17:38:47 +03:00

91 lines
4.9 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()',
'_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('candidate.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;/);
// Обёртка: настоящий `nothing` пробрасывается как есть. `noChange` вложен в
// стабильный shell: так тело остаётся, а соседний confirm можно убрать.
const wrapper = methodBody('render');
assert.match(wrapper, /const body = this\._renderBody\(\);/);
assert.match(wrapper, /if \(body === nothing\) return body;/);
assert.match(wrapper, /return this\._renderRoot\(body\);/);
const shell = methodBody('_renderRoot');
assert.match(shell,
/return html`\$\{body\}\$\{this\._renderVersionBanner\(\)\}\$\{this\._renderDangerConfirm\(\)\}`;/);
assert.equal(render.includes('_renderVersionBanner'), false,
'version recovery must remain available beside every non-empty body branch');
assert.equal(render.includes('_renderDangerConfirm'), false,
'подтверждение не должно возвращаться внутрь ветки — это и есть дефект #402');
});