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