v1.45.0: external review of v1.44.8 — R2-1, R2-2, R2-3

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.
This commit is contained in:
Matysh
2026-07-27 21:08:34 +03:00
parent 14cc4df4bd
commit 5d2dbb1009
22 changed files with 814 additions and 110 deletions
+76
View File
@@ -0,0 +1,76 @@
// Ревью R2-3: климат комнат считался отдельным обходом реестра на каждую
// комнату и каждую величину — 60 комнат × 2000 сущностей съедали кадр на
// перечитывании метаданных, которые не менялись. Карта строится один раз на
// снимок hass; при этом новые состояния датчиков обязаны попадать в неё сразу.
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 sr = () => c.shadowRoot || c.renderRoot;
// считаем обходы реестра через ownKeys — именно его дёргает Object.entries
let scans = 0;
const wrap = (h) => {
const ents = h.entities;
const traced = new Proxy(ents, { ownKeys(t) { scans++; return Reflect.ownKeys(t); } });
return { ...h, entities: traced };
};
const fresh = () => wrap(window.__mkHass());
// включаем и заливку по температуре, и подписи — два потребителя климата
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : {
...s, settings: { ...(s.settings || {}), show_names: true, fill_mode: 'temp', label_temp: true, label_hum: true },
})};
c._cfgEpoch++;
c.hass = fresh(); await c.updateComplete;
scans = 0;
c.hass = fresh(); await c.updateComplete;
const fewRooms = scans;
// повторные рендеры на том же снимке hass реестр не трогают
scans = 0;
c.requestUpdate(); await c.updateComplete;
c.requestUpdate(); await c.updateComplete;
out.scansOnRerender = scans;
// главный инвариант: обходов НЕ становится больше от числа комнат
const f1 = c._serverCfg.spaces.find((s) => s.id === 'f1');
const extra = [];
for (let i = 0; i < 40; i++) {
extra.push({ id: 'gen' + i, name: 'R' + i, area: 'living_room',
poly: [[0.01, 0.01], [0.02, 0.01], [0.02, 0.02], [0.01, 0.02]] });
}
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) =>
s.id !== 'f1' ? s : { ...s, rooms: [...s.rooms, ...extra] }) };
c._cfgEpoch++;
c.hass = fresh(); await c.updateComplete;
scans = 0;
c.hass = fresh(); await c.updateComplete;
out.roomCount = c._spaceModel('f1').rooms.length;
out.scansSameWith44Rooms = scans === fewRooms;
out.scansPerUpdate = scans;
// при этом новое состояние датчика обязано быть видно, а не взято из кэша
out.tempBefore = c._climate().get('living_room')?.temp;
const h = fresh();
h.states = { ...h.states, 'sensor.living_temp': { ...h.states['sensor.living_temp'], state: '33.3' } };
c.hass = h; await c.updateComplete;
out.tempAfter = c._climate().get('living_room')?.temp;
out.climateIsMap = c._climate() instanceof Map;
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом.
// scansPerUpdate = 2: один обход у areaClimateMap, один у buildDevices. Важно
// не само число, а что оно не растёт вместе с числом комнат.
checkAll(res, {
scansOnRerender: 0,
roomCount: 44,
scansSameWith44Rooms: true,
scansPerUpdate: 2,
tempBefore: 22.4,
tempAfter: 33.3,
climateIsMap: true,
});
await finish(browser);
+69
View File
@@ -0,0 +1,69 @@
// Граница транзакции загрузки подложки (ревью 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);
+76
View File
@@ -0,0 +1,76 @@
// Ревью R2-2: бэкенд подписывает не более MAX_SIGN_PATHS путей за вызов и
// молча отбрасывает остальные. Карточка обязана бить запрос на батчи, помнить
// возраст подписи и чистить кэш от ссылок, которых в конфиге больше нет —
// иначе на настенном планшете «лишние» записи протухают навсегда.
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;
const batchSizes = [];
let round = 0;
c.hass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/content/sign') {
batchSizes.push(m.paths.length);
const urls = {};
// как настоящий бэкенд: не больше 200 за раз, про остальные — молчание
for (const p of m.paths.slice(0, 200)) urls[p] = p.split('?')[0] + '?authSig=R' + round;
return { urls };
}
return base(m);
} };
// 201 вложение, разложенное по маркерам: столько же подписанных ссылок
const pdfs = [];
for (let i = 0; i < 201; i++) pdfs.push({ name: 'm' + i, url: '/api/houseplan/content/files/m/doc' + i + '.pdf' });
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs }] };
c._cfgEpoch++;
round = 1;
for (const p of pdfs) c._display(p.url);
await new Promise((r) => setTimeout(r, 120));
out.firstBatches = [...batchSizes];
out.signedAfterFirst = Object.keys(c._signed).length;
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
batchSizes.length = 0;
round = 2;
c._resign();
await new Promise((r) => setTimeout(r, 120));
out.resignBatches = [...batchSizes];
const vals = Object.values(c._signed).map((v) => v.url);
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
c._serverCfg = { ...c._serverCfg, markers: [{ id: 'mk1', pdfs: pdfs.slice(0, 5) }] };
c._cfgEpoch++;
batchSizes.length = 0;
round = 3;
c._resign();
await new Promise((r) => setTimeout(r, 120));
out.prunedTo = Object.keys(c._signed).length;
out.pruneBatches = [...batchSizes];
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
const one = pdfs[0].url;
c._signed = { ...c._signed, [one]: { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 } };
out.expiredNotServed = c._display(one) === '';
// а стареющая, но ещё живая — отдаётся, пока едет замена
c._signed = { ...c._signed, [one]: { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 } };
out.agingStillServed = c._display(one) === one + '?authSig=AGING';
return out;
});
// зафиксировано прогоном на v1.45.0 и сверено с кодом
checkAll(res, {
firstBatches: [200, 1],
signedAfterFirst: 201,
resignBatches: [200, 1],
allRefreshed: true,
prunedTo: 5,
pruneBatches: [5],
expiredNotServed: true,
agingStillServed: true,
});
await finish(browser);
+6 -1
View File
@@ -1,3 +1,5 @@
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 640, height: 980 }, 2);
const res = await page.evaluate(async () => {
@@ -33,7 +35,10 @@ const res = await page.evaluate(async () => {
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
return out;
});
await page.screenshot({ path: '/tmp/ux_dialog.png' });
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
// '/tmp/...' указывает в несуществующий C:\tmp и смоук падал, не дойдя до
// ассертов (портируемость, ревью 2026-07-27)
await page.screenshot({ path: join(tmpdir(), 'houseplan_ux_dialog.png') }).catch(() => {});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
"filledClass": 1,
File diff suppressed because one or more lines are too long