From 9d5c0357b05a5a7f46b5fe4dd27d8e96e4b26596 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 2 Sep 2026 00:08:02 +0300 Subject: [PATCH] test: keep grid scale static fixture coherent Issue: #411 User-Visible: no --- demo/grid-scale-static-fixture.mjs | 22 ++++++++++++++ demo/smoke_grid_scale_invariance.mjs | 34 +++++++++++++++++++-- test/grid-scale-static-fixture.test.mjs | 39 +++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 demo/grid-scale-static-fixture.mjs create mode 100644 test/grid-scale-static-fixture.test.mjs diff --git a/demo/grid-scale-static-fixture.mjs b/demo/grid-scale-static-fixture.mjs new file mode 100644 index 00000000..358c178c --- /dev/null +++ b/demo/grid-scale-static-fixture.mjs @@ -0,0 +1,22 @@ +/** + * Build the exact snapshot consumed by the static-card half of the grid-scale + * smoke. The main and static cards normally load through different stores; a + * comparative fixture must therefore hand the static card one atomic + * config+layout pair instead of mixing the demo backend config with the main + * card's temporary layout. + */ +export function coherentGridScaleStaticPatch({ config, layout, revision }) { + if (!config || typeof config !== 'object' || !Array.isArray(config.spaces)) { + throw new Error('grid-scale static fixture requires a complete config'); + } + if (!layout || typeof layout !== 'object' || Array.isArray(layout)) { + throw new Error('grid-scale static fixture requires a complete layout'); + } + const token = Number.isFinite(revision) ? revision : 0; + return { + config: structuredClone(config), + configFingerprint: `grid-scale-fixture:${token}:config`, + layout: structuredClone(layout), + layoutFingerprint: `grid-scale-fixture:${token}:layout`, + }; +} diff --git a/demo/smoke_grid_scale_invariance.mjs b/demo/smoke_grid_scale_invariance.mjs index 8f318a87..9bef02e5 100644 --- a/demo/smoke_grid_scale_invariance.mjs +++ b/demo/smoke_grid_scale_invariance.mjs @@ -1,5 +1,6 @@ // Issue #239: a finer coordinate grid changes precision, never the visible plan. import { launch, checkAll, finish } from './serve.mjs'; +import { coherentGridScaleStaticPatch } from './grid-scale-static-fixture.mjs'; const { page, browser } = await launch({ width: 1000, height: 860 }, 1, [], { reducedMotion: 'reduce', @@ -168,8 +169,16 @@ await page.evaluate(async () => { while (!compact.renderRoot?.querySelector('.hp-static-stage') && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 30)); } + while ((compact._loading || !compact._snap) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 30)); + } await compact.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return { + config: card._serverCfg, + layout: sharedLayout, + revision: card._cfgEpoch, + }; }; }); @@ -240,7 +249,21 @@ const capture = async (cellCm, mode, { const pixels = await stableScreenshot(stage); let staticPixels = null; if (staticCard) { - await page.evaluate(() => window.__makeStaticGridCard()); + const source = await page.evaluate(() => window.__makeStaticGridCard()); + const patch = coherentGridScaleStaticPatch(source); + await page.evaluate(async (nextPatch) => { + const compact = document.querySelector('#grid-static-host houseplan-space-card'); + if (!compact) throw new Error('grid-scale static fixture card disappeared'); + if (!compact._snap) throw new Error('grid-scale static fixture did not load its base snapshot'); + // Preserve runtime-only Set/Map values from the in-page snapshot. Only + // config+layout cross the Playwright boundary; both come from one scale. + compact._snap = { ...compact._snap, ...nextPatch }; + compact._loadedOnce = true; + compact._refreshDevices(); + compact.requestUpdate(); + await compact.updateComplete; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }, patch); staticPixels = await stableScreenshot(staticStage()); } return { metrics, pixels, staticPixels }; @@ -262,7 +285,14 @@ const pixelDiff = async (left, right) => page.evaluate(async ([a, b]) => { const decode = async (base64) => createImageBitmap(await (await fetch(`data:image/png;base64,${base64}`)).blob()); const [first, second] = await Promise.all([decode(a), decode(b)]); if (first.width !== second.width || first.height !== second.height) { - return { sameSize: false, changed: Infinity, maxDelta: Infinity, meanDelta: Infinity }; + return { + sameSize: false, + firstSize: [first.width, first.height], + secondSize: [second.width, second.height], + changed: Infinity, + maxDelta: Infinity, + meanDelta: Infinity, + }; } const canvas = document.createElement('canvas'); canvas.width = first.width; canvas.height = first.height; diff --git a/test/grid-scale-static-fixture.test.mjs b/test/grid-scale-static-fixture.test.mjs new file mode 100644 index 00000000..2a9cd4e7 --- /dev/null +++ b/test/grid-scale-static-fixture.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { coherentGridScaleStaticPatch } from '../demo/grid-scale-static-fixture.mjs'; + +test('grid-scale static fixture keeps config and layout in one isolated snapshot', () => { + const config = { spaces: [{ id: 'fixture', cell_cm: 1, view_box: [0, 0, 5, 5] }] }; + const layout = { device: { s: 'fixture', x: 1.2, y: 2.4 } }; + const patch = coherentGridScaleStaticPatch({ config, layout, revision: 42 }); + + assert.deepEqual(patch.config, config); + assert.deepEqual(patch.layout, layout); + assert.notEqual(patch.config, config); + assert.notEqual(patch.layout, layout); + assert.equal(patch.configFingerprint, 'grid-scale-fixture:42:config'); + assert.equal(patch.layoutFingerprint, 'grid-scale-fixture:42:layout'); + + config.spaces[0].cell_cm = 5; + layout.device.x = 99; + assert.equal(patch.config.spaces[0].cell_cm, 1); + assert.equal(patch.layout.device.x, 1.2); +}); + +test('grid-scale smoke uses the coherent static snapshot without weakening raster limits', () => { + const source = readFileSync(new URL('../demo/smoke_grid_scale_invariance.mjs', import.meta.url), 'utf8'); + assert.match(source, /coherentGridScaleStaticPatch\(source\)/); + assert.match(source, /compact\._snap = \{ \.\.\.compact\._snap, \.\.\.nextPatch \}/); + assert.match(source, /firstSize: \[first\.width, first\.height\]/); + assert.match(source, /secondSize: \[second\.width, second\.height\]/); + assert.match(source, /diff\.changed <= 150 && diff\.maxDelta <= 40 && diff\.meanDelta <= 0\.05/); +}); + +test('grid-scale static fixture rejects partial inputs', () => { + assert.throws(() => coherentGridScaleStaticPatch({}), /complete config/); + assert.throws(() => coherentGridScaleStaticPatch({ config: {}, layout: {} }), /complete config/); + assert.throws(() => coherentGridScaleStaticPatch({ + config: { spaces: [] }, layout: [], + }), /complete layout/); +});