mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
R2-1 (high): plan replacement committed filesystem state before the config CAS.
The upload wrote the final name and unlinked the other extension, so a rejected
config write left the live plan already replaced — or the stored config
pointing at a deleted file. Uploads now go to <space>.<token>.<ext> and delete
nothing; houseplan/plan/cleanup runs only after the config write is accepted.
The '.' separator is load-bearing: a space id cannot contain one, so cleaning
'f1' can never reach the files of 'f1-attic'.
R2-2: the backend signs at most MAX_SIGN_PATHS (200) per request and ignores
the rest silently, while the card sent its whole cache in one call and trusted
any cached entry forever — past 200 attachments the later ones stopped being
refreshed and expired for good. Requests are chunked to the shared constant,
entries carry their issue time (aging urls keep rendering while a replacement
is fetched, expired ones are dropped), and the cache is pruned to urls the live
config still references.
R2-3: areaClimate() rescanned the whole registry per room and per measurement.
areaClimateMap() classifies once and returns Map<area,{temp,hum}>, memoized on
hass identity so fresh states are always observed. Smoke measurement: 133
registry scans per update with 44 rooms before, 2 after, flat in room count.
Also: smoke_ux_fixes wrote its screenshot to a hard-coded /tmp path and could
not run on Windows.
Tests: smoke_plan_upload_reject, smoke_sign_cap, smoke_climate_once (all fail
on v1.44.8), three backend tests for versioned plan names and cleanup scoping,
unit tests for chunk/referencedContentUrls and areaClimateMap.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
70 lines
3.0 KiB
JavaScript
70 lines
3.0 KiB
JavaScript
// Граница транзакции загрузки подложки (ревью R2-1).
|
|
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
|
|
// сохранение не имеет права трогать сохранённый план. Проверяем контракт со
|
|
// стороны карточки: удаление старых файлов (houseplan/plan/cleanup) уходит
|
|
// ТОЛЬКО после принятого config/set — и никогда после отказа.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
const { page, browser } = await launch();
|
|
const res = await page.evaluate(async () => {
|
|
const out = {};
|
|
const c = window.__card;
|
|
const base = c.hass.callWS;
|
|
let uploads = 0;
|
|
const cleanups = [];
|
|
let rejectSave = true;
|
|
|
|
c.hass = { ...c.hass, callWS: async (m) => {
|
|
if (m.type === 'houseplan/plan/set') {
|
|
uploads++;
|
|
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
|
|
}
|
|
if (m.type === 'houseplan/plan/cleanup') { cleanups.push(m); return { ok: true, removed: 1 }; }
|
|
if (m.type === 'houseplan/config/set') {
|
|
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
|
|
c.__sent = m.config; return { ok: true, rev: 77 };
|
|
}
|
|
if (m.type === 'houseplan/config/get') {
|
|
const r = await base(m);
|
|
return { ...r, config: JSON.parse(JSON.stringify(r.config)) };
|
|
}
|
|
return base(m);
|
|
} };
|
|
|
|
const attach = async () => {
|
|
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
|
c._spaceDialog = { ...c._spaceDialog, title: 'Ground', source: 'file',
|
|
planFile: { ext: 'png', b64: 'AAAA', aspect: 1.6 } };
|
|
await c._saveSpaceDialog(); await c.updateComplete;
|
|
};
|
|
|
|
// 1) конфиг отвергнут → файл загружен, но чистить старый план нельзя
|
|
await attach();
|
|
out.uploadedOnReject = uploads === 1;
|
|
out.cleanupsAfterReject = cleanups.length;
|
|
out.dialogStaysOpenOnReject = c._spaceDialog !== null;
|
|
|
|
// 2) конфиг принят → чистка уходит, и ровно на тот файл, что записан в конфиг
|
|
rejectSave = false;
|
|
c._spaceDialog = null; await c.updateComplete;
|
|
await attach();
|
|
out.cleanupsAfterAccept = cleanups.length;
|
|
out.cleanupSpace = cleanups[0]?.space_id;
|
|
out.cleanupKeep = cleanups[0]?.keep;
|
|
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
|
|
out.savedPlanUrl = f1?.plan_url;
|
|
out.keepMatchesSavedUrl = !!f1 && f1.plan_url.endsWith('/' + cleanups[0]?.keep);
|
|
return out;
|
|
});
|
|
// зафиксировано прогоном на v1.45.0 и сверено с кодом
|
|
checkAll(res, {
|
|
uploadedOnReject: true,
|
|
cleanupsAfterReject: 0,
|
|
dialogStaysOpenOnReject: true,
|
|
cleanupsAfterAccept: 1,
|
|
cleanupSpace: 'f1',
|
|
cleanupKeep: 'f1.tok2.png',
|
|
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
|
|
keepMatchesSavedUrl: true,
|
|
});
|
|
await finish(browser);
|