fix v1.43.0: external audit P0 — data loss, split geometry, auth, dialog zombies

L2 (silent data loss): debounce gains flush()/pending(); _reloadConfigOnly
flushes a pending write and defers while one is in flight; conflict path
forces; failed reload now toasts instead of an empty catch; teardown flushes.

G1 (split corruption): same-edge cuts carve the niche properly instead of
walking the outline twice; partition invariant (parts sum to the original)
rejects anything else; +1 unit test covering 5 niche shapes and both legacy
cut shapes.

B1 (unauthenticated content): plans and marker files move to
HouseplanContentView (/api/houseplan/content/..., requires_auth); only the
card bundle stays static; contentUrl() rewrites legacy URLs on read (no
storage migration); repairs.py accepts both prefixes; +1 unit test.

L3 (dialog zombies): all four save catch-blocks guard against a closed
dialog; the card no longer blanks when a save fails after Esc.

smokes: smoke_save_race, smoke_dialog_zombie; docs (TESTING/CHANGELOG/
ARCHITECTURE incl. the optimistic-UI note) same-commit
This commit is contained in:
Matysh
2026-07-27 10:44:58 +03:00
parent 5c7d1ca8bb
commit 0fd0ba408d
21 changed files with 464 additions and 98 deletions
+35
View File
@@ -12,6 +12,7 @@ import {
swipeTarget, clampScale,
migratePdfUrls,
roomFillModeOf,
contentUrl,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js';
@@ -809,3 +810,37 @@ test('roomFillModeOf: tier-3 override beats the space, junk inherits', () => {
assert.equal(roomFillModeOf('temp', null), 'temp');
assert.equal(roomFillModeOf('temp', { settings: { fill_mode: 'glow' } }), 'temp'); // glow нельзя выбрать per-room
});
test('splitRoomPath: both ends on the SAME edge carve a niche (audit G1)', () => {
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
const A = (p) => Math.round(polygonArea(p) * 1000) / 1000;
// ИНВАРИАНТ разреза: части в сумме дают исходную площадь
const invariant = (pts, label) => {
const r = splitRoomPath(sq, pts);
assert.ok(r, label + ': разрез должен приниматься');
assert.ok(Math.abs(A(r[0]) + A(r[1]) - A(sq)) < 1e-6,
label + ': сумма частей ' + (A(r[0]) + A(r[1])) + ' != ' + A(sq));
return r;
};
const niche = invariant([[2, 0], [2, 3], [8, 3], [8, 0]], 'ниша снизу');
assert.deepEqual([A(niche[0]), A(niche[1])].sort((x, y) => x - y), [18, 82]);
invariant([[2, 10], [2, 7], [8, 7], [8, 10]], 'ниша сверху');
invariant([[0, 2], [3, 2], [3, 8], [0, 8]], 'ниша слева');
invariant([[8, 0], [8, 3], [2, 3], [2, 0]], 'обратный порядок точек');
invariant([[1, 0], [3, 4], [5, 1], [7, 4], [9, 0]], 'зигзаг');
// вырожденная ниша нулевой площади — отказ
assert.equal(splitRoomPath(sq, [[2, 0], [2, 1e-9], [8, 0]]), null);
// старые формы разрезов сохраняют инвариант
invariant([[5, 0], [5, 10]], 'прямая хорда');
invariant([[4, 0], [4, 6], [10, 6]], 'Г-образный');
});
test('contentUrl: legacy static paths become authenticated ones (audit B1)', () => {
assert.equal(contentUrl('/houseplan_files/plans/f1.svg?v=1'), '/api/houseplan/content/plans/_/f1.svg?v=1');
assert.equal(contentUrl('/houseplan_files/files/dev1/a.pdf?v=2'), '/api/houseplan/content/files/dev1/a.pdf?v=2');
// новые и внешние адреса не трогаем
assert.equal(contentUrl('/api/houseplan/content/files/x/y.pdf'), '/api/houseplan/content/files/x/y.pdf');
assert.equal(contentUrl('https://example.com/a.pdf'), 'https://example.com/a.pdf');
assert.equal(contentUrl(''), '');
assert.equal(contentUrl(null), '');
});