Files
houseplan-card/test/live-viewport.test.mjs
Claude 600330e187 fix(daycycle): stage-sized plan layers without a frozen raster; revert #685 hatch
With the day/night background the plan went blurry after zooming from 100 %
(sharp when the page was opened at 800 %) and navigating a strongly zoomed
plan flashed the page white. Both came from #582's composition, not from the
wall hatch that #685 replaced:

- `.stage.daycycle.hp-safe-daycycle-outline .plan-svg` promoted the scene
  with `will-change: transform`; Chromium freezes the raster scale of such a
  layer, so the 100 % raster was shown stretched. The explicit layer #582
  needs is now `will-change: opacity` (re-rasters at the current scale).
- The filtered outline had `overflow: visible` and a gesture exposed every
  scene (#544) without bound, so the promoted layers grew with zoom squared
  (CDP LayerTree, ~460 %: plan-svg 15.9x, outline 13.2x the stage; 39x after
  navigating at 800 %). The full card clips its outline to its box and marks
  it data-hp-live-overflow="clip" (never exposed); the live viewport bounds
  every other exposure with an inline clip-path: inset(-25%) that leaves
  with it, so idle DOM stays byte-identical (#531).

Owner-verified in Chrome 152 (built-in browser, DPR 2): sharp after 100 ->
800 %, no white flashes after reloading at 800 %.

Owner decision: #685's analytic gradient is reverted (13af1d5e), the single
<pattern> is back at every zoom; its close-up golden scenes stay and check
the pattern, its terminal-frame smoke checks the pattern.

Witnesses: demo/smoke_daycycle_zoom_layers.mjs (800 % x DPR 2: layers vs
stage, the hint, reload path, button/wheel/pinch); #582/#532 smokes now pin
the opacity hint; test/live-viewport.test.mjs (bounded exposure, clipped
scene); test/daycycle-layers.test.mjs (cascade). Four Node-guarded mutants.

Issue: #689
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 17:51:06 +03:00

277 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
LIVE_SCENE_EXPOSURE_CLIP,
isIdentityLiveLayerProjection,
liveLayerProjection,
liveViewBoxText,
paintLiveViewport,
} from '../test-build/live-viewport.js';
test('live viewport projects pan and zoom from the last complete frame', () => {
assert.deepEqual(
liveLayerProjection(
{ x: 0, y: 0, w: 1000, h: 500 },
{ x: 100, y: 50, w: 500, h: 250 },
),
{ translateXPercent: -20, translateYPercent: -20, scaleX: 2, scaleY: 2 },
);
});
test('live viewport serializes one exact SVG camera box', () => {
assert.equal(liveViewBoxText({ x: -12.5, y: 4, w: 800, h: 450 }), '-12.5 4 800 450');
});
test('a settled live viewport removes its compositor transform', () => {
const settled = { x: 17.25, y: -4, w: 800, h: 450 };
assert.equal(isIdentityLiveLayerProjection(liveLayerProjection(settled, settled)), true);
assert.equal(isIdentityLiveLayerProjection(liveLayerProjection(
settled,
{ ...settled, x: settled.x + 0.001 },
)), false);
});
// #531. Перезапись `viewBox` — это инвалидация растеризации всего плана: слой
// нельзя сдвинуть, его надо нарисовать заново. Профиль владельца на панораме
// (Firefox 155, 144 Гц): краска закончилась, а до композиции 94 мс медианы при
// незагруженном GPU, и драйвер пропускает 124–144 тика в секунду с пометкой
// «ждём краску». Поэтому кадр жеста двигает сцену трансформом, а содержимое
// подтягивается по бюджету — иначе на набегающем крае осталась бы пустая полоса.
/** Крошечный DOM: только то, что читает `paintLiveViewport`. */
const fakeRoot = () => {
const node = (attrs) => {
const el = {
attrs: { ...attrs }, writes: 0, styleWrites: 0, styleRemovals: [],
getAttribute: (name) => (name in el.attrs ? el.attrs[name] : null),
setAttribute: (name, value) => { el.attrs[name] = value; el.writes += 1; },
style: new Proxy({ _v: {} }, {
get: (target, key) => {
if (key === 'removeProperty') return (name) => {
const camel = name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
delete target._v[camel];
el.styleRemovals.push(name);
el.styleWrites += 1;
};
if (key === '_v') return target._v;
return target._v[key];
},
set: (target, key, value) => { target._v[key] = value; el.styleWrites += 1; return true; },
}),
};
return el;
};
const camera = node({ viewBox: '0 0 1000 500' });
const cameraPeer = node({ viewBox: '0 0 1000 500' });
const floor = node({ viewBox: '0 0 1000 500' });
const layer = node({});
return {
camera, cameraPeer, floor, layer,
querySelectorAll: (selector) => {
if (selector.includes('viewbox="camera"')) return [camera, cameraPeer];
if (selector.includes('viewbox="floor"')) return [floor];
if (selector.includes('live-layer')) return [layer];
return [];
},
querySelector: () => null,
};
};
const frame = (x, y, w = 1000, h = 500) => ({
view: { x, y, w, h }, floor: { x, y, w, h }, zoom: 1,
});
test('#531 AC1: кадр жеста двигает сцену трансформом и не трогает viewBox', () => {
const root = fakeRoot();
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, painted, null, { now: 1000 });
const writesAfterAnchor = root.camera.writes;
for (let step = 1; step <= 5; step += 1) {
anchor = paintLiveViewport(root, painted, frame(step * 10, 0), anchor, { now: 1000 + step * 10 });
}
assert.equal(root.camera.writes, writesAfterAnchor, 'viewBox переписывать не за что');
assert.equal(root.camera.attrs.viewBox, '0 0 1000 500');
assert.match(root.camera.style.transform, /^translate\(-5%,0%\) scale\(1,1\)$/);
assert.equal(root.camera.style.overflow, 'visible', 'scene exposes the incoming edge');
assert.equal(root.cameraPeer.style.overflow, 'visible', 'every camera scene exposes the edge');
assert.equal(root.floor.style.overflow, 'visible', 'the floor scene exposes the edge');
assert.equal(root.layer.style.transform, root.camera.style.transform, 'слой и сцена едут одинаково');
assert.equal(root.layer.style.overflow, undefined, 'HTML layers do not inherit SVG overflow handling');
});
test('#531 AC2: по истечении бюджета viewBox пишется один раз, трансформ снимается', () => {
const root = fakeRoot();
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 50 });
const before = root.camera.writes;
anchor = paintLiveViewport(root, painted, frame(20, 0), anchor, { now: 100 });
assert.equal(root.camera.writes, before + 1, 'ровно одна перезапись');
assert.equal(root.camera.attrs.viewBox, '20 0 1000 500');
assert.equal(root.camera.style.transform, undefined, 'сцена вернулась в тождество');
assert.equal(root.camera.style.overflow, undefined, 'refresh removes temporary scene overflow');
assert.equal(root.cameraPeer.style.overflow, undefined, 'refresh cleans every camera scene');
assert.equal(root.floor.style.overflow, undefined, 'refresh cleans the floor scene');
assert.ok(root.layer.style.transform, 'слой по-прежнему спроецирован от осевшего кадра');
});
test('#579 a live session keeps one promoted scene layer across budget refreshes', () => {
const root = fakeRoot();
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, painted, null, {
now: 0, keepSceneLayer: true,
});
const scenes = [root.camera, root.cameraPeer, root.floor];
for (const scene of scenes) {
assert.equal(scene.style.transform, 'translate(0%,0%) scale(1,1)');
assert.equal(scene.style.transformOrigin, '0 0');
assert.equal(scene.style.willChange, 'transform');
assert.equal(scene.style.overflow, 'visible');
}
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, {
now: 50, keepSceneLayer: true,
});
const removalsBeforeRefresh = scenes.map((scene) => scene.styleRemovals.length);
const viewBoxWritesBeforeRefresh = root.camera.writes;
anchor = paintLiveViewport(root, painted, frame(20, 0), anchor, {
now: 100, keepSceneLayer: true,
});
assert.equal(root.camera.writes, viewBoxWritesBeforeRefresh + 1, 'budget refresh still lands');
for (const [index, scene] of scenes.entries()) {
assert.equal(scene.style.transform, 'translate(0%,0%) scale(1,1)');
assert.equal(scene.style.transformOrigin, '0 0');
assert.equal(scene.style.willChange, 'transform');
assert.equal(scene.style.overflow, 'visible');
assert.equal(scene.styleRemovals.length, removalsBeforeRefresh[index],
'refresh may not demote and re-promote the SVG');
}
const styleWrites = scenes.reduce((sum, scene) => sum + scene.styleWrites, 0);
anchor = paintLiveViewport(root, painted, frame(20, 0), anchor, {
now: 101, keepSceneLayer: true,
});
assert.equal(scenes.reduce((sum, scene) => sum + scene.styleWrites, 0), styleWrites,
'an unchanged promoted frame writes no style');
const settled = frame(20, 0);
paintLiveViewport(root, settled, settled, anchor, { now: 110, force: true });
for (const scene of scenes) {
assert.equal(scene.style.transform, undefined);
assert.equal(scene.style.transformOrigin, undefined);
assert.equal(scene.style.willChange, undefined);
assert.equal(scene.style.overflow, undefined);
}
});
test('#531 AC2а: рывок переписывает viewBox сразу, не дожидаясь бюджета времени', () => {
const root = fakeRoot();
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
const before = root.camera.writes;
// 14 % видимой ширины — меньше порога: едем трансформом.
anchor = paintLiveViewport(root, painted, frame(140, 0), anchor, { now: 5 });
assert.equal(root.camera.writes, before, 'ниже порога сдвига — без перезаписи');
// 15 % — порог достигнут, время не истекло.
anchor = paintLiveViewport(root, painted, frame(150, 0), anchor, { now: 10 });
assert.equal(root.camera.writes, before + 1);
assert.equal(root.camera.attrs.viewBox, '150 0 1000 500');
});
test('#531 AC3: повторная покраска тем же кадром не пишет в DOM ничего', () => {
const root = fakeRoot();
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, frame(10, 0), null, { now: 0 });
const writes = root.camera.writes + root.floor.writes;
const styles = root.camera.styleWrites + root.floor.styleWrites + root.layer.styleWrites;
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 1 });
assert.equal(root.camera.writes + root.floor.writes, writes, 'атрибуты не переписаны');
assert.equal(
root.camera.styleWrites + root.floor.styleWrites + root.layer.styleWrites, styles,
'стили не переписаны',
);
});
test('#531 AC4: терминальное примирение пишет окончательный viewBox и снимает трансформы', () => {
const root = fakeRoot();
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, frame(30, 0), null, { now: 0 });
anchor = paintLiveViewport(root, painted, frame(60, 0), anchor, { now: 10 });
const settled = frame(60, 0);
anchor = paintLiveViewport(root, settled, settled, anchor, { now: 20, force: true });
assert.equal(root.camera.attrs.viewBox, '60 0 1000 500');
assert.equal(root.camera.style.transform, undefined);
assert.equal(root.camera.style.overflow, undefined);
assert.equal(root.cameraPeer.style.overflow, undefined);
assert.equal(root.floor.style.overflow, undefined);
assert.equal(root.layer.style.transform, undefined);
const writes = root.camera.writes;
paintLiveViewport(root, settled, settled, anchor, { now: 21, force: true });
assert.equal(root.camera.writes, writes, 'повторное примирение ничего не пишет');
});
// Наблюдение ревьюера r1: во всех кадрах выше `floor === view`, то есть
// изометрия, ради которой проекции и считаются раздельно, ни одним свидетелем
// не тронута. Здесь виды камеры и пола расходятся и по сдвигу, и по масштабу.
const isoFrame = (x, y, floorX, floorW) => ({
view: { x, y, w: 1000, h: 500 },
floor: { x: floorX, y: y / 2, w: floorW, h: 400 },
zoom: 1,
});
test('#531 изометрия: камера и пол проецируются раздельно и оседают вместе', () => {
const root = fakeRoot();
const painted = isoFrame(0, 0, 0, 800);
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
anchor = paintLiveViewport(root, painted, isoFrame(100, 40, 60, 800), anchor, { now: 10 });
assert.equal(root.camera.style.transform, 'translate(-10%,-8%) scale(1,1)');
assert.equal(root.floor.style.transform, 'translate(-7.5%,-5%) scale(1,1)');
assert.notEqual(root.camera.style.transform, root.floor.style.transform,
'у пола свой вид, и подменять его видом камеры нельзя');
anchor = paintLiveViewport(root, painted, isoFrame(200, 80, 120, 800), anchor, { now: 200 });
assert.equal(root.camera.attrs.viewBox, '200 80 1000 500');
assert.equal(root.floor.attrs.viewBox, '120 40 800 400');
assert.equal(root.camera.style.transform, undefined);
assert.equal(root.floor.style.transform, undefined);
});
// #689. Unbounded `overflow: visible` let a projected scene layer grow with
// zoom² (CDP LayerTree, ~460 %: plan-svg 15.9× and the day-cycle outline
// 13.2× the stage); at 800 % × DPR 2 that is hundreds of MB of GPU memory
// and the page flashed white. The exposure is bounded and leaves with it.
test('#689 AC3: exposure is bounded by a clip that leaves together with it', () => {
const root = fakeRoot();
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 10 });
assert.equal(LIVE_SCENE_EXPOSURE_CLIP, 'inset(-25%)');
for (const scene of [root.camera, root.cameraPeer, root.floor]) {
assert.equal(scene.style.overflow, 'visible', 'the incoming edge is still exposed (#544)');
assert.equal(scene.style.clipPath, LIVE_SCENE_EXPOSURE_CLIP, 'but only within the bound');
}
assert.equal(root.layer.style.clipPath, undefined, 'HTML layers get no scene clip');
paintLiveViewport(root, painted, painted, anchor, { now: 20, force: true });
for (const scene of [root.camera, root.cameraPeer, root.floor]) {
assert.equal(scene.style.overflow, undefined, 'idle DOM keeps no exposure (#531)');
assert.equal(scene.style.clipPath, undefined, 'idle DOM keeps no clip (#531)');
assert.equal(scene.style.transform, undefined);
assert.ok(scene.styleRemovals.includes('clip-path'));
}
});
test('#689 AC3: a scene marked clip is projected but never exposed', () => {
const root = fakeRoot();
root.floor.attrs['data-hp-live-overflow'] = 'clip';
const painted = frame(0, 0);
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0, keepSceneLayer: true });
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 10, keepSceneLayer: true });
assert.match(root.floor.style.transform, /^translate\(-1%,0%\) scale\(1,1\)$/, 'it still follows the gesture');
assert.equal(root.floor.style.overflow, undefined, 'the filtered outline is never opened');
assert.equal(root.floor.style.clipPath, undefined);
assert.equal(root.camera.style.clipPath, LIVE_SCENE_EXPOSURE_CLIP, 'unmarked scenes are still exposed');
paintLiveViewport(root, painted, painted, anchor, { now: 20, force: true });
assert.equal(root.floor.style.transform, undefined);
assert.equal(root.floor.styleRemovals.includes('overflow'), false, 'nothing to remove, nothing written');
});