Files
houseplan-card/demo/smoke_post_write_adoption.mjs
Codex be6d57e96f test(adoption): the post-write smoke resets its own pending writes
Review r2 M1. The smoke's reset() left the previous scenario's debounced
config/layout write pending; _deleteSpace flushes whatever is pending
before it writes, the fake socket answers without a rev, and the
documented rev+1 fallback then moved the revision on a body from another
scenario. The refused branch looked as if it had adopted:
onboardingDeleteRefusedAdoptsNothing was red on every run. The scenarios
are supposed to be independent, so reset() now cancels both debounced
writers, as smoke_danger_confirmation already does.

37/37 green, three runs in a row; with the cancel removed the same
single check goes red again.

The refused-tail fix from r1 had no witness in CI at all: no mutant
named this smoke as its guard, so the review gate never ran it and a red
witness survived a whole round. A witness that never runs is not a
witness, so the early return in _undoPlanOptimization now has a mutant
that names the smoke.

Issue: #500
User-Visible: no
2026-09-10 15:16:12 +03:00

194 lines
9.1 KiB
JavaScript

// #500 AC4: the four post-write adoptions — space/delete from both runtimes,
// Optimize Undo and Import apply — pass the same backdrop readiness gate as a
// reload before the structure is replaced, and take config/layout revisions
// from the re-read `config/get`/`layout/get`, never from the write reply.
// A second client changes `plan_url` between our write and the re-read; the
// card must prepare THAT backdrop first and end on the re-read revision.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const out = await page.evaluate(async () => {
const card = window.__card;
await card._ensureOnboardingRuntime();
const editor = card._editorRuntime;
const onboarding = card._onboardingRuntime;
const adoption = card._adoption;
const result = {};
const space = (id, title, planUrl) => ({
id, title, plan_url: planUrl, view_box: [0, 0, 1, 1], rooms: [], wall_segments: [], partitions: [],
});
const freshConfig = () => ({
model_version: 9,
spaces: [space('alpha', 'Alpha', '/local/alpha.png'), space('beta', 'Beta', '/local/beta.png')],
markers: [],
settings: {},
});
const order = [];
const toasts = [];
let retries = 0;
let assetReady = true;
const originalPrepareImage = card._signer.prepareImage.bind(card._signer);
card._signer.prepareImage = async (_hass, href) => { order.push(`prepare:${href}`); return assetReady; };
card._scheduleLoadRetry = () => { retries += 1; };
const originalAdoptResponses = adoption.adoptResponses.bind(adoption);
adoption.adoptResponses = (...args) => { order.push('adopt'); return originalAdoptResponses(...args); };
card._confirmDanger = async () => true;
card._showToast = (message) => { toasts.push(message); };
// Fake server: our write bumps both revisions; a concurrent client then
// swaps the backdrop and bumps the config revision again before the re-read.
let server;
let concurrentHref = '';
const concurrentWrite = () => {
for (const s of server.config.spaces) s.plan_url = concurrentHref;
server.cfgRev += 1;
};
const originalHass = card.hass;
card.hass = {
...originalHass,
callWS: async (message) => {
switch (message.type) {
case 'houseplan/space/delete': {
server.config.spaces = server.config.spaces.filter((s) => s.id !== message.space_id);
server.cfgRev += 1; server.layRev += 1;
const reply = { config_rev: server.cfgRev, layout_rev: server.layRev };
concurrentWrite();
return reply;
}
case 'houseplan/plan/optimize_undo': {
server.cfgRev += 1; server.layRev += 1;
const reply = { config_rev: server.cfgRev, layout_rev: server.layRev };
concurrentWrite();
return reply;
}
case 'houseplan/import/apply': {
server.cfgRev += 1; server.layRev += 1;
const reply = { kind: 'full', config_rev: server.cfgRev, layout_rev: server.layRev, counts: { spaces: 2, rooms: 0, markers: 0 } };
concurrentWrite();
return reply;
}
case 'houseplan/config/get':
return { config: structuredClone(server.config), rev: server.cfgRev, can_write: true };
case 'houseplan/layout/get':
return { layout: structuredClone(server.layout), rev: server.layRev };
default:
return {};
}
},
};
const reset = async (scenario) => {
// Ревью r2 M1: отложенная запись предыдущего сценария иначе доживает до
// следующего и флашится безусловным `if (pending) flush()` внутри
// `_deleteSpace`; мок отвечает `{}` без `rev`, запасной путь «rev + 1»
// двигает ревизию по ЧУЖОМУ телу, и отказная ветка выглядит принявшей.
// Сценарии обязаны быть независимы — так же чистится smoke_danger_confirmation.
card._saveConfigDebounced.cancel();
card._persistLayout.cancel();
server = { config: freshConfig(), layout: {}, cfgRev: 10, layRev: 20 };
concurrentHref = `media-source://image/${scenario}-500`;
adoption.restoreCached({ config: structuredClone(server.config), rev: 10, layout: {}, layout_rev: 20 });
card._space = 'beta';
card._spaceDialog = null;
card._backupImportDialog = null;
order.length = 0;
toasts.length = 0;
retries = 0;
await card.updateComplete;
};
// Review r1 M1: a refused backdrop gate adopts nothing and skips the caller's
// tail (no toast, no space switch, dialog released), exactly like every
// reload path; the scheduled retry owns the rest.
const refusedVerdict = (scenario, extra = {}) => {
result[`${scenario}RefusedAdoptsNothing`] = card._cfgRev === 10 && card._layoutRev === 20
&& card._serverCfg.spaces.length === 2 && card._serverCfg.spaces.every((s) => s.plan_url !== concurrentHref);
result[`${scenario}RefusedSchedulesRetry`] = retries === 1 && !order.includes('adopt');
result[`${scenario}RefusedSkipsTail`] = toasts.length === 0 && card._space === 'beta';
Object.assign(result, extra);
};
const verdict = (scenario, extra = {}) => {
const prepared = order.indexOf(`prepare:${concurrentHref}`);
const adopted = order.indexOf('adopt');
result[`${scenario}PreparesConcurrentBackdropBeforeAdoption`] = prepared >= 0 && adopted > prepared;
result[`${scenario}RevisionsFromReRead`] = card._cfgRev === server.cfgRev && card._layoutRev === server.layRev;
result[`${scenario}AdoptsConcurrentBackdrop`] = card._serverCfg.spaces.every((s) => s.plan_url === concurrentHref);
Object.assign(result, extra);
};
const spaceDialog = (spaceId) => ({
mode: 'edit', spaceId, title: 'Beta', planUrl: null, planFile: null, source: 'draw',
showBorders: true, showNames: true, zeroWallStyle: 'dashed', displayTouched: true,
hideDecor: false, hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
busy: false, saved: [],
});
for (const [name, runtime] of [['onboardingDelete', onboarding], ['editorDelete', editor]]) {
await reset(name);
card._spaceDialog = spaceDialog('beta');
await runtime._deleteSpace();
// the write reply carried cfgRev 11; the concurrent client moved it to 12
verdict(name, {
[`${name}RevisionNotFromDeleteReply`]: card._cfgRev === 12 && server.cfgRev === 12,
[`${name}LeavesDeletedSpace`]: card._space === 'alpha' && card._serverCfg.spaces.length === 1,
[`${name}ClosesDialog`]: card._spaceDialog === null,
});
}
await reset('optimizeUndo');
card._canOptimizeUndo = true;
card._undoKind = 'optimize';
await editor._undoPlanOptimization();
verdict('optimizeUndo', { optimizeUndoClearsUndo: card._canOptimizeUndo === false && card._undoKind === null });
await reset('importApply');
card._backupImportDialog = {
filename: 'plan.json', size: 1, token: 'token-500', preview: { confirmation_required: false, counts: {} },
expectedConfigRev: card._cfgRev, expectedLayoutRev: card._layoutRev,
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
};
const fixedFloor = !!card._hasFixedFloor;
await editor._applyBackupImport();
verdict('importApply', {
importApplyClosesDialog: card._backupImportDialog === null,
// today's rule, not its absence: a fixed-floor card re-selects through
// _adoptInitialSpace, otherwise the previous space is kept
importApplyKeepsSpaceRule: fixedFloor ? typeof card._space === 'string' && card._space.length > 0 : card._space === 'beta',
});
assetReady = false;
for (const [name, runtime] of [['onboardingDelete', onboarding], ['editorDelete', editor]]) {
await reset(`${name}-refused`);
card._spaceDialog = spaceDialog('beta');
await runtime._deleteSpace();
refusedVerdict(name, { [`${name}RefusedReleasesDialog`]: card._spaceDialog?.busy === false });
}
await reset('optimizeUndo-refused');
card._canOptimizeUndo = true;
card._undoKind = 'optimize';
await editor._undoPlanOptimization();
refusedVerdict('optimizeUndo', { optimizeUndoRefusedKeepsUndoFlag: card._canOptimizeUndo === true && card._optimizeUndoBusy === false });
await reset('importApply-refused');
card._backupImportDialog = {
filename: 'plan.json', size: 1, token: 'token-500', preview: { confirmation_required: false, counts: {} },
expectedConfigRev: card._cfgRev, expectedLayoutRev: card._layoutRev,
duplicatePolicy: 'skip', confirmMissing: false, busy: false, error: '',
};
await editor._applyBackupImport();
refusedVerdict('importApply', { importApplyRefusedReleasesDialog: card._backupImportDialog?.busy === false });
assetReady = true;
card.hass = originalHass;
card._signer.prepareImage = originalPrepareImage;
delete card._scheduleLoadRetry;
delete adoption.adoptResponses;
await card.updateComplete;
return result;
});
checkAll(out);
await finish(browser, out);