Files
houseplan-card/demo/smoke_v8_draft_write.mjs
T
2026-08-26 09:56:35 +03:00

300 lines
12 KiB
JavaScript

/** #314: model-v8 draft identities survive writes/Undo; rejected writes fail closed. */
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeV8DraftRegressionFixture } from './fixtures/v8-draft-regression.mjs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 760 });
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const denseFixture = makeV8DraftRegressionFixture();
const result = await page.evaluate(async (denseFixture) => {
const out = {};
const card = window.__card;
const clone = (value) => JSON.parse(JSON.stringify(value));
const sleep = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const emptyConfig = () => ({
model_version: 8,
spaces: [{
id: 'v8-draft', title: 'v8 draft', cell_cm: 5,
view_box: [0, 0, 1, 0.7], rooms: [], wall_segments: [],
}],
markers: [], settings: {},
});
let server;
let rev;
let writes;
let writeBehavior;
const installWs = () => {
const fallback = card.hass.callWS.bind(card.hass);
card.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/set') {
const candidate = clone(message.config);
writes.push(candidate);
if (writeBehavior) await writeBehavior(candidate, message);
const missingDraftId = candidate.spaces.some((space) =>
(space.room_drafts || []).some((draft) =>
(draft.segments || []).some((segment) =>
typeof segment.id !== 'string' || !segment.id)));
if (missingDraftId) {
const error = new Error('v8 draft wall segments require ids');
error.code = 'invalid_format';
throw error;
}
server = candidate;
rev += 1;
return { ok: true, rev };
}
if (message.type === 'houseplan/config/get') {
return { config: clone(server), rev };
}
return fallback(message);
} };
};
const reset = async (initialConfig = emptyConfig()) => {
card._saveConfigDebounced.cancel();
await card._writeChain.catch(() => undefined);
server = clone(initialConfig);
rev = 1;
writes = [];
writeBehavior = null;
card._serverCfg = clone(server);
card._cfgRev = rev;
card._cfgContentFingerprint = '';
card._pendingPhysicalWrites.clear();
card._geometryHistory.clear();
card._clearGeometryGesture();
card._toast = '';
card._space = server.spaces[0].id;
card._layout = {};
card._modelCache = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._frame = null;
card._cfgEpoch += 1;
card._drawWallField = '15';
card._setMode('plan');
card._tool = 'draw';
await card.updateComplete;
};
const persistPath = async (points) => {
card._path = points.map((point) => [...point]);
card._draftSegmentCms = points.slice(1).map(() => 15);
card._persistActiveDraftSegment();
card._saveConfigDebounced.flush();
await sleep(0);
};
const settleWrites = async () => {
await card._writeChain.catch(() => undefined);
await sleep(40);
await card._writeChain.catch(() => undefined);
};
const clickPoint = async (x, y, extra = {}) => {
const stage = (card.shadowRoot || card.renderRoot).querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
card._markupClick(new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true, ...extra,
}));
await card.updateComplete;
};
installWs();
// Write-time sanitation must keep the segment corresponding to each
// surviving edge, rather than positional-copying the skipped zero edge.
await reset();
card._serverCfg.spaces[0].room_drafts = [{
id: 'draft-sanitize',
points: [[0.1, 0.1], [0.1, 0.1], [0.3, 0.1], [0.3, 0.1], [0.3, 0.3]],
segments: [
{ id: 'zero-a', cm: 11 }, { id: 'edge-a', cm: 21 },
{ id: 'zero-b', cm: 12 }, { id: 'edge-b', cm: 22 },
],
}, {
id: 'draft-intentional', points: [[0.6, 0.1], [0.7, 0.1]],
segments: [{ id: 'draft-intentional-edge', cm: 18 }],
}];
card._serverCfg.spaces[0].partitions = [{
id: 'partition-intentional', a: [0.6, 0.3], b: [0.7, 0.3], cm: 20,
}];
card._dropLegacySegments();
const sanitized = card._serverCfg.spaces[0].room_drafts[0];
out.sanitationKeepsCarrierIds = JSON.stringify(
sanitized.segments.map((segment) => [segment.id, segment.cm]),
) === JSON.stringify([['edge-a', 21], ['edge-b', 22]]);
out.sanitationPreservesExistingIndependentObjects =
card._serverCfg.spaces[0].partitions?.[0]?.id === 'partition-intentional'
&& card._serverCfg.spaces[0].room_drafts?.[1]?.id === 'draft-intentional'
&& card._serverCfg.spaces[0].room_drafts[1].segments[0].id === 'draft-intentional-edge';
// Successful physical writes carry model-v8 IDs all the way to the fake
// backend, and Undo keeps the identity of every surviving edge.
await reset();
await persistPath([[100, 100], [300, 100], [300, 300]]);
await settleWrites();
const initialIds = server.spaces[0].room_drafts[0].segments.map((segment) => segment.id);
card._undoPoint();
card._saveConfigDebounced.flush();
await settleWrites();
const undone = server.spaces[0].room_drafts[0];
out.successfulWriteHasStableIds = initialIds.length === 2
&& initialIds.every((id) => typeof id === 'string' && !!id)
&& undone.segments.length === 1 && undone.segments[0].id === initialIds[0];
// Exercise the real Walls click/close/promote path, not only its persistence
// primitive. Every completed edge crosses fake config/set independently.
await reset();
await clickPoint(100, 100);
for (const point of [[300, 100], [300, 300], [100, 300], [100, 100]]) {
await clickPoint(point[0], point[1]);
card._saveConfigDebounced.flush();
await settleWrites();
}
const openedRoomDialog = !!card._roomDialog;
card._nameSel = 'Persisted room';
card._saveRoom();
card._saveConfigDebounced.flush();
await settleWrites();
await card._reloadConfigOnly(true);
const savedSpace = card._serverCfg.spaces[0];
out.roomCloseOpensDialog = openedRoomDialog;
out.roomSurvivesCloseAndReload = savedSpace.rooms.some((room) => room.name === 'Persisted room');
out.roomCloseLeavesNoDebris = !savedSpace.room_drafts?.length && !savedSpace.partitions?.length;
out.roomCloseHasOnlySuccessToast = card._toast
=== card._t('toast.wall_rooms_saved', { n: 1 });
// Two edits made while F1 is in flight are serialized. If F1 succeeds, F2
// retains both accepted edges and their identities.
await reset();
let releaseFirst;
const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
let writeNumber = 0;
writeBehavior = async () => {
writeNumber += 1;
if (writeNumber === 1) await firstGate;
};
await persistPath([[100, 100], [300, 100]]);
await sleep(20);
await persistPath([[100, 100], [300, 100], [300, 300]]);
await sleep(20);
out.successQueueIsSerialized = writes.length === 1;
releaseFirst();
await settleWrites();
const queuedDraft = server.spaces[0].room_drafts[0];
out.successQueueRetainsF2 = writes.length === 2
&& queuedDraft.segments.length === 2
&& queuedDraft.segments.every((segment) => typeof segment.id === 'string' && !!segment.id);
// A rejected F1 invalidates the whole optimistic transaction, including F2
// authored on the same unaccepted baseline. The plan is safe before the
// best-effort config/get finishes and cannot later create a ghost partition.
await reset();
let rejectFirst;
const rejectGate = new Promise((resolve) => { rejectFirst = resolve; });
writeNumber = 0;
writeBehavior = async () => {
writeNumber += 1;
if (writeNumber === 1) {
await rejectGate;
const error = new Error('server rejected draft');
error.code = 'invalid_format';
throw error;
}
};
await persistPath([[100, 100], [300, 100]]);
await sleep(20);
await persistPath([[100, 100], [300, 100], [300, 300]]);
await sleep(20);
rejectFirst();
await sleep(20);
out.rejectionRollsBackSynchronously = !card._serverCfg.spaces[0].room_drafts?.length
&& card._path.length === 0
&& card._pendingPhysicalWrites.size === 0
&& !card._geometryHistory.undoName && !card._geometryHistory.redoName;
await settleWrites();
card._finishWallChain();
out.rejectionCannotCreateGhostPartition = !card._serverCfg.spaces[0].room_drafts?.length
&& !card._serverCfg.spaces[0].partitions?.length
&& !server.spaces[0].room_drafts?.length
&& !server.spaces[0].partitions?.length;
// AC8: repeat the real click/close/reload path on an anonymised fixture with
// the same population shape as the owner's report. Existing independent
// geometry and the one known unusable draft must survive byte-for-byte.
await reset(denseFixture);
const invariantBefore = clone(server);
const originalPartitionIds = server.spaces[0].partitions.map((item) => item.id);
const originalDraft = JSON.stringify(server.spaces[0].room_drafts[0]);
for (const point of [[800, 100], [900, 100], [900, 200], [800, 200], [800, 100]]) {
await clickPoint(point[0], point[1]);
card._saveConfigDebounced.flush();
await settleWrites();
}
card._nameSel = 'Invariant room';
card._saveRoom();
card._saveConfigDebounced.flush();
await settleWrites();
await card._reloadConfigOnly(true);
const denseAfter = card._serverCfg.spaces[0];
out.denseFixtureHasOwnerReportShape = invariantBefore.spaces[0].rooms.length === 13
&& invariantBefore.spaces[0].wall_segments.length === 44
&& originalPartitionIds.length === 24
&& invariantBefore.spaces[0].room_drafts.length === 1;
out.denseFixtureRoomSurvivesReload = denseAfter.rooms.length === 14
&& denseAfter.rooms.some((room) => room.name === 'Invariant room');
out.denseFixturePreservesIndependentGeometry = JSON.stringify(
denseAfter.partitions.map((item) => item.id),
) === JSON.stringify(originalPartitionIds)
&& denseAfter.room_drafts.length === 1
&& JSON.stringify(denseAfter.room_drafts[0]) === originalDraft;
return { checks: out, invariantBefore, invariantAfter: clone(card._serverCfg) };
}, denseFixture);
const runInvariants = (config, label) => {
const dir = mkdtempSync(join(tmpdir(), `houseplan-314-${label}-`));
const path = join(dir, 'config.json');
try {
writeFileSync(path, JSON.stringify(config));
const run = spawnSync('npm', [
'run', 'invariants', '--', '--config', path, '--json',
], { cwd: repoRoot, encoding: 'utf8', shell: process.platform === 'win32' });
const stdout = run.stdout || '';
const jsonStart = stdout.indexOf('{');
if (jsonStart < 0) {
throw new Error(`invariant CLI produced no JSON (status ${run.status}):\n`
+ `${stdout}\n${run.stderr || run.error || ''}`);
}
const parsed = JSON.parse(stdout.slice(jsonStart));
return { status: run.status, violations: parsed.violations, notes: parsed.notes };
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const before = runInvariants(result.invariantBefore, 'before');
const after = runInvariants(result.invariantAfter, 'after');
const signature = (run) => run.violations.map((item) => [
item.invariant, item.kind, item.owner,
]);
result.checks.denseFixtureKnownDebtIsExplicit = before.status === 1
&& before.violations.length === 1
&& before.violations[0].kind === 'unusable_draft'
&& before.violations[0].owner.endsWith(':draft-314-known-debt');
result.checks.denseFixtureAddsNoInvariantViolations = after.status === 1
&& JSON.stringify(signature(after)) === JSON.stringify(signature(before));
await finish(browser, checkAll(result.checks));