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
+28 -1
View File
@@ -12,7 +12,7 @@ import {
swipeTarget, clampScale,
migratePdfUrls,
roomFillModeOf,
contentUrl,
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
@@ -881,3 +881,30 @@ test('segKey: one wall, one key at any precision (audit G3)', () => {
// разные стены — разные ключи
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
});
test('chunk / referencedContentUrls: signing batches and cache pruning (review R2-2)', () => {
assert.deepEqual(chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]);
assert.deepEqual(chunk([], 200), []);
assert.equal(chunk(new Array(201).fill(0), MAX_SIGN_PATHS).length, 2);
assert.deepEqual(chunk([1, 2, 3], 0), [[1], [2], [3]]); // never an infinite loop
const cfg = {
spaces: [
{ id: 'f1', plan_url: '/houseplan_files/plans/f1.svg' }, // legacy, rewritten on read
{ id: 'f2', plan_url: '/api/houseplan/content/plans/_/f2.abc.png' },
{ id: 'f3', plan_url: null },
{ id: 'f4', plan_url: '/local/not-ours.png' }, // not our endpoint
],
markers: [
{ id: 'm1', pdfs: [{ url: '/houseplan_files/files/m1/manual.pdf' }, { url: '' }] },
{ id: 'm2' },
],
};
assert.deepEqual([...referencedContentUrls(cfg)].sort(), [
'/api/houseplan/content/files/m1/manual.pdf',
'/api/houseplan/content/plans/_/f1.svg',
'/api/houseplan/content/plans/_/f2.abc.png',
]);
assert.equal(referencedContentUrls(null).size, 0);
assert.equal(referencedContentUrls({}).size, 0);
});