From 49d48542216fd3d28cc515a6df1416e3e86e489d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 6 Sep 2026 14:32:33 +0300 Subject: [PATCH] =?UTF-8?q?test:=20=D1=81=D0=B2=D0=B8=D0=B4=D0=B5=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80=D1=84-?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D1=83=20#160=20=E2=80=94=20?= =?UTF-8?q?=D0=BA=D1=8D=D1=88=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9,=20=D0=B7=D1=83=D0=BC=20=D0=B2=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=80=D1=8C,=20AABB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Visible: no Issue: #473 --- scripts/mutation-gate.mjs | 57 ++++++++++++++++ test/iso-scene-render.test.mjs | 115 +++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index f7e3a0c5..7a12fe83 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -6462,6 +6462,63 @@ const MUTANT_DEFINITIONS = [ replace: ' if (!this.diagnosticsCache) return;\n if (true) return;\n', }], }, + { + id: 'iso-placement-cache-ignores-selected', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#473 W1" test/iso-scene-render.test.mjs', + because: 'a selected plate must resolve its own placement; a signature without `selected` ' + + 'hands back the unselected cached one — a stale picture with no failure (#473, perf delta of #160)', + patches: [{ + file: 'src/iso-scene-render.ts', + find: " input.layers.shadows ? 1 : 0, selected ? 1 : 0].join('|');", + replace: " input.layers.shadows ? 1 : 0].join('|');", + }], + }, + { + id: 'iso-placement-cache-survives-silhouette-change', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#473 W2" test/iso-scene-render.test.mjs', + because: 'placements are cached per wall-silhouette array identity; keying the cache by a ' + + 'constant serves «near the wall» for a plan whose wall is gone (#473)', + patches: [{ + file: 'src/iso-scene-render.ts', + find: ' let placements = isoOverlayPlacementCache.get(input.wallSilhouettes);', + replace: ' let placements = isoOverlayPlacementCache.get(ISO_PLACEMENT_CACHE_ANY);', + }, { + file: 'src/iso-scene-render.ts', + find: ' isoOverlayPlacementCache.set(input.wallSilhouettes, placements);', + replace: ' isoOverlayPlacementCache.set(ISO_PLACEMENT_CACHE_ANY, placements);', + }, { + file: 'src/iso-scene-render.ts', + find: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;', + replace: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;\nconst ISO_PLACEMENT_CACHE_ANY: readonly IsoWallSilhouette[] = [];', + }], + }, + { + id: 'iso-zoom-in-reuses-near-wall-plate', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#473 W3" test/iso-scene-render.test.mjs', + because: 'zoom-in may reuse a placement only if the plate was never near a wall or was ' + + 'cleared within the cap; dropping the first guard reuses a pinned plate at a scale where it ' + + 'already cuts the wall (#473)', + patches: [{ + file: 'src/iso-scene-render.ts', + find: ' if (!previous.nearWallBefore', + replace: ' if (true', + }], + }, + { + id: 'iso-aabb-rejects-touching-wall', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#473 W4" test/iso-scene-render.test.mjs', + because: 'the AABB pre-check must keep every silhouette within the safety gap; a strict ' + + 'overlap test drops walls the exact test would have caught and lets plates sit flush (#473)', + patches: [{ + file: 'src/iso-overlays.ts', + find: ' return a[0] <= b[2] + gap && a[2] >= b[0] - gap', + replace: ' return a[0] <= b[2] && a[2] >= b[0]', + }], + }, { id: 'stage3-w1-camera-rotation-reset', guard: 'node --test --test-name-pattern="exact fixed" test/iso-projection.test.mjs', diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs index f5824de4..d6643e13 100644 --- a/test/iso-scene-render.test.mjs +++ b/test/iso-scene-render.test.mjs @@ -707,3 +707,118 @@ test('opening geometry policy is both fingerprinted and consumed by the structur const basis = changed.build().openings[0]; assert.equal(basis.leafThickness, basis.wallHeight * policy.leafThicknessRatio); }); + +// #473. Перф-дельта #160 (f1b9bbf3..b3ca0ca2) ввела кэш размещений, кэш по +// идентичности силуэтов, повторное использование при зуме внутрь и +// AABB-отсечение — и ушла в бету по §11.4 без ревью и без единого свидетеля. +// Двенадцать мутантов stage3-w* защищают картинку, ни один — эти механизмы. +// Ошибка любого из них даёт УСТАРЕВШЕЕ размещение без единого падения. + +const perfFixture = () => { + const owner = room('owner', 0, 0, 100, 100); + const space = { + id: 'floor', title: 'Floor', cellCm: 5, vb: [0, 0, 100, 100], bg: null, + rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], + }; + const wallSilhouettes = [{ outer: buildIsoPlatePolygon([0, 50], [4, 60], ISO_WALL_HEIGHT) }]; + const input = { + space, + devices: [{ id: 'device', space: 'floor', marker: { room_id: 'owner' } }], + openings: [], + view: { x: 0, y: 0, w: 100, h: 100 }, + display: { showNames: false, cardFontScale: 1 }, + layers: { structural: true, shadows: true }, + wallSilhouettes, + iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, + kioskIconScale: 1, kioskFontScale: 1, + stageSize: { width: 100, height: 100 }, + positionOf: () => ({ x: 5, y: 50 }), + presentationOf: () => ({ + scale: 1, valueText: null, valueFullText: '', valueBadge: null, + tempText: null, humText: null, lqiText: null, + pulse: { animated: false, diameterScale: 1 }, + }), + labelPositionOf: () => ({ x: 0, y: 0 }), + labelScaleOf: () => 1, + openingEntityAvailable: () => false, + openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }), + }; + return { input, wallSilhouettes }; +}; + +test('#473 W1: выделение входит в подпись кэша размещений', () => { + const { input } = perfFixture(); + const plain = buildIsoOverlayRenderScene(input).devices.get('device'); + const selected = buildIsoOverlayRenderScene({ ...input, selectedDeviceId: 'device' }) + .devices.get('device'); + // Подпись без `selected` вернула бы тот же кэшированный объект — плита + // выделенного устройства осталась бы без своего размещения. + assert.notStrictEqual(selected, plain, 'выделение обязано дать своё размещение, а не кэш'); + // Кэш держит одну запись на (режим, вид, id): снятие выделения пересчитывает + // заново, и это правильно — подпись изменилась. Тождества с `plain` здесь + // быть не должно, проверяется только отсутствие подмены. + const again = buildIsoOverlayRenderScene(input).devices.get('device'); + assert.notStrictEqual(again, selected, 'снятие выделения не отдаёт выделенное размещение'); +}); + +test('#473 W2: кэш размещений привязан к идентичности массива силуэтов', () => { + const { input } = perfFixture(); + const withWall = buildIsoOverlayRenderScene(input).devices.get('device'); + assert.equal(withWall.nearWallBefore, true, 'фикстура: стена рядом с плитой'); + // Новая геометрия — новый массив. Кэш, ключуемый константой, отдал бы + // размещение «у стены» для плана, в котором стены больше нет. + const noWalls = buildIsoOverlayRenderScene({ ...input, wallSilhouettes: [] }).devices.get('device'); + assert.equal(noWalls.nearWallBefore, false, 'без стен плита не у стены'); + assert.notStrictEqual(noWalls, withWall); +}); + +test('#473 W3: при зуме внутрь плита у стены не переиспользуется вслепую', () => { + const { input } = perfFixture(); + // Далёкая плита: зум внутрь переиспользует доказанно безопасное размещение. + const far = { ...input, positionOf: () => ({ x: 60, y: 20 }) }; + const farLive = buildIsoOverlayRenderScene(far).devices.get('device'); + assert.equal(farLive.nearWallBefore, false); + const farZoomed = buildIsoOverlayRenderScene({ ...far, view: { x: 0, y: 0, w: 80, h: 80 } }) + .devices.get('device'); + assert.strictEqual(farZoomed, farLive, 'не у стены — переиспользуется'); + // Плита у стены, которую не удалось очистить: зум внутрь обязан идти в + // точный резолвер. Гард `!nearWallBefore || cleared && …` без первой + // половины переиспользовал бы её на масштабе, где она уже режет стену. + const pinned = { + ...input, + // упор со всех сторон: узкая комната не даёт места для nudge + space: { ...input.space, rooms: [room('owner', 0, 44, 12, 56)] }, + positionOf: () => ({ x: 5, y: 50 }), + }; + const pinnedLive = buildIsoOverlayRenderScene(pinned).devices.get('device'); + assert.equal(pinnedLive.nearWallBefore, true); + if (pinnedLive.cleared) { + // Фикстура не смогла создать неочищенную плиту — тест обязан сказать об + // этом честно, а не пройти молча (правило после #426). + assert.fail('фикстура «у стены, не очищена» не построилась: cleared=true'); + } + const pinnedZoomed = buildIsoOverlayRenderScene({ ...pinned, view: { x: 0, y: 0, w: 80, h: 80 } }) + .devices.get('device'); + assert.notStrictEqual(pinnedZoomed, pinnedLive, 'у стены и не очищена — точный резолвер, не кэш'); +}); + +test('#473 W4: AABB-отсечение учитывает зазор безопасности', () => { + // Плита, чьи границы не пересекают границы стены, но лежат внутри gap, + // обязана считаться «у стены». Отсечение без зазора отбросило бы силуэт + // до точного теста, и плита легла бы вплотную к стене. + const wall = { outer: buildIsoPlatePolygon([0, 50], [4, 60], ISO_WALL_HEIGHT) }; + const owner = { id: 'owner', outer: [[0, 0], [100, 0], [100, 100], [0, 100]], holes: [], safePoint: [50, 50] }; + const placeAt = (x, safetyGapCssPx) => resolveIsoOverlayPlacement({ + kind: 'device', floorAnchor: [x, 50], plateHalfSize: [2, 2], + rooms: [owner], roomsValidated: true, preferredRoomId: 'owner', + ownerAlreadyResolved: true, resolvedOwner: { room: owner, safePoint: [50, 50] }, + showBorders: true, wallSilhouettes: [wall], wallGeometryValidated: true, + unitsPerPixel: 1, safetyGapCssPx, wallHeight: ISO_WALL_HEIGHT, visualOffset: 0, + }); + // без зазора плита на расстоянии 2 единиц от стены — не у стены + const noGap = placeAt(4 + 2 + 2, 0); + assert.equal(noGap.nearWallBefore, false, 'контроль: без зазора не у стены'); + // с зазором 4 — у стены, хотя AABB не пересекаются + const withGap = placeAt(4 + 2 + 2, 4); + assert.equal(withGap.nearWallBefore, true, 'в пределах зазора — у стены'); +});