mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 19:28:46 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb181ba5ee | ||
|
|
6d31553a74 | ||
|
|
d46abe51fc | ||
|
|
b74731698f |
File diff suppressed because one or more lines are too long
@@ -215,6 +215,18 @@ export function prepareGoldenFixture(scenario) {
|
||||
}
|
||||
space.openings = [...(space.openings || []), ...structuredClone(scenario.extraOpenings)];
|
||||
}
|
||||
if (scenario.decorOverride) {
|
||||
const space = requireSpace();
|
||||
const known = new Set();
|
||||
for (const shape of scenario.decorOverride) {
|
||||
if (!shape?.id || known.has(shape.id))
|
||||
throw new Error(`golden decorOverride has missing/duplicate id: ${shape?.id || '<empty>'}`);
|
||||
if (!['line', 'rect', 'ellipse', 'text', 'furniture'].includes(shape.kind))
|
||||
throw new Error(`golden decorOverride has unknown kind: ${shape.kind}`);
|
||||
known.add(shape.id);
|
||||
}
|
||||
space.decor = structuredClone(scenario.decorOverride);
|
||||
}
|
||||
if (scenario.openingGeometry) {
|
||||
const space = requireSpace();
|
||||
const opening = (space.openings || []).find(
|
||||
|
||||
+35
-1
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 34;
|
||||
export const GOLDEN_MATRIX_VERSION = 35;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -11,6 +11,29 @@ const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, max
|
||||
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
|
||||
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
|
||||
|
||||
const decorLayerFixture = [
|
||||
{ id: 'golden-decor-axis', kind: 'line', x1: 0.10, y1: 0.54, x2: 0.90, y2: 0.54,
|
||||
color: '#ff0033', opacity: 1, width_cm: 8 },
|
||||
{ id: 'golden-decor-rect', kind: 'rect', x: 0.15, y: 0.28, w: 0.16, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'golden-decor-ellipse', kind: 'ellipse', x: 0.69, y: 0.28, w: 0.16, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'golden-decor-text', kind: 'text', x: 0.24, y: 0.72, text: 'DECOR',
|
||||
color: '#ff0033', opacity: 1, size_cm: 14 },
|
||||
{ id: 'golden-decor-sofa', kind: 'furniture', symbol: 'sofa',
|
||||
x: 0.68, y: 0.66, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
|
||||
];
|
||||
const decorLayerProbes = {
|
||||
color: '#ff0033', radius: 2, minMatchingFraction: 0.6,
|
||||
points: [
|
||||
{ id: 'left-room', x: 0.25, y: 0.54 },
|
||||
{ id: 'opening-tunnel', x: 0.50, y: 0.54 },
|
||||
{ id: 'right-room', x: 0.75, y: 0.54 },
|
||||
],
|
||||
};
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
@@ -167,6 +190,17 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
}],
|
||||
tunnelContinuity: { openingId: 'light-door', insetPx: 2, maxChannelJump: 3, dpr2: true },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'decor-over-opaque-hover-light', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'custom', customFill: { c: '#2255cc', a: 1 }, glowEnabled: false,
|
||||
sunRays: false, showBorders: false, showNames: false, hideOpenings: true,
|
||||
hoverRoom: 'light-left', decorOverride: decorLayerFixture,
|
||||
decorPixelProbes: decorLayerProbes,
|
||||
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'decor-over-glow-base-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'glow', glowEnabled: true, allLightsOff: true,
|
||||
sunRays: false, showBorders: false, showNames: false, hideOpenings: true,
|
||||
decorOverride: decorLayerFixture, decorPixelProbes: decorLayerProbes,
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'openings-hidden-view-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
fillMode: 'none', glowEnabled: false, hideOpenings: true, theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'lighting-glow-sun-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
|
||||
@@ -302,6 +302,61 @@ async function countWarmPixels(page, png, region) {
|
||||
}, { png64: png.toString('base64'), region });
|
||||
}
|
||||
|
||||
/** Semantic guard for #231. Sample named plan points from the actual browser
|
||||
* capture; the decor node merely existing in DOM is insufficient when an
|
||||
* opaque floor layer is painted after it. */
|
||||
async function inspectDecorPixels(page, png, clip, spec) {
|
||||
return page.evaluate(async ({ png64, clip, spec }) => {
|
||||
const card = window.__goldenCard;
|
||||
const svg = card?.renderRoot?.querySelector('.stage .zoomwrap > svg');
|
||||
const decor = card?.renderRoot?.querySelector('.decorlayer');
|
||||
const matrix = svg?.getScreenCTM?.();
|
||||
if (!svg || !decor || !matrix) throw new Error('semantic golden decor layer is missing');
|
||||
const bytes = Uint8Array.from(atob(png64), (char) => char.charCodeAt(0));
|
||||
const image = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width; canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, image.width, image.height).data;
|
||||
const cssWidth = clip?.width || document.documentElement.clientWidth;
|
||||
const cssHeight = clip?.height || document.documentElement.clientHeight;
|
||||
const scaleX = image.width / Math.max(1, cssWidth);
|
||||
const scaleY = image.height / Math.max(1, cssHeight);
|
||||
const originX = clip?.x || 0, originY = clip?.y || 0;
|
||||
const expected = String(spec.color || '').match(/^#([0-9a-f]{6})$/i)?.[1];
|
||||
if (!expected) throw new Error(`semantic golden decor color is invalid: ${spec.color}`);
|
||||
const rgb = [0, 2, 4].map((offset) => parseInt(expected.slice(offset, offset + 2), 16));
|
||||
const radius = Math.max(0, Math.round(spec.radius || 0));
|
||||
return spec.points.map((probe) => {
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = Number(probe.x) * 1000; point.y = Number(probe.y) * 1000;
|
||||
const screen = point.matrixTransform(matrix);
|
||||
const cx = Math.round((screen.x - originX) * scaleX);
|
||||
const cy = Math.round((screen.y - originY) * scaleY);
|
||||
let matching = 0, sampled = 0;
|
||||
for (let y = cy - radius; y <= cy + radius; y++) {
|
||||
for (let x = cx - radius; x <= cx + radius; x++) {
|
||||
if (x < 0 || y < 0 || x >= image.width || y >= image.height) continue;
|
||||
const offset = (y * image.width + x) * 4;
|
||||
const distance = Math.max(
|
||||
Math.abs(pixels[offset] - rgb[0]),
|
||||
Math.abs(pixels[offset + 1] - rgb[1]),
|
||||
Math.abs(pixels[offset + 2] - rgb[2]),
|
||||
);
|
||||
if (pixels[offset + 3] > 240 && distance <= 48) matching++;
|
||||
sampled++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: probe.id, matching, sampled,
|
||||
fraction: sampled ? matching / sampled : 0,
|
||||
pixel: [cx, cy],
|
||||
};
|
||||
});
|
||||
}, { png64: png.toString('base64'), clip, spec });
|
||||
}
|
||||
|
||||
/** Semantic guard for issue #68: the reviewed bubble must contain rendered
|
||||
* glyph pixels, not just an empty surface or a stale open-state flag. */
|
||||
async function countHelpTextPixels(page, png, clip, spec) {
|
||||
@@ -565,6 +620,18 @@ try {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.decorPixelProbes) {
|
||||
const samples = await inspectDecorPixels(page, actual, clip, scenario.decorPixelProbes);
|
||||
result.decorPixelProbes = samples;
|
||||
const failed = samples.filter((sample) =>
|
||||
sample.fraction < scenario.decorPixelProbes.minMatchingFraction);
|
||||
if (failed.length) {
|
||||
throw new Error(
|
||||
`semantic golden assertion failed: decor is hidden/tinted at `
|
||||
+ failed.map((sample) => `${sample.id} (${sample.matching}/${sample.sampled})`).join(', '),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (scenario.helpTextRegion) {
|
||||
const sample = await countHelpTextPixels(page, actual, clip, scenario.helpTextRegion);
|
||||
result.helpTextPixels = sample.textPixels;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* #231: decor is one composition layer above every floor treatment and below
|
||||
* live light, sun, physical geometry, opening symbols and HTML overlays.
|
||||
*
|
||||
* The raster probes are intentional. A DOM-only assertion used to pass while
|
||||
* an opaque room fill still erased decor from the rendered plan.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
const stage = page.locator('houseplan-card').locator('.stage').first();
|
||||
|
||||
const fixture = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = () => c.shadowRoot || c.renderRoot;
|
||||
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
const pitch = 1 / 240;
|
||||
const wallKey = (a, b) => {
|
||||
const q = (value) => Math.round(value / pitch) * pitch;
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
angle = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
|
||||
};
|
||||
const wall = (a, b) => ({ key: wallKey(a, b), a, b, cm: 30 });
|
||||
const update = async () => {
|
||||
c._cfgEpoch++;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const roomPoly = [[0.1, 0.15], [0.9, 0.15], [0.9, 0.85], [0.1, 0.85]];
|
||||
sp.plan_url = null;
|
||||
sp.view_box = [0, 0, 1, 1];
|
||||
sp.cell_cm = 5;
|
||||
sp.rooms = [{
|
||||
id: 'decor-order-room', name: 'Decor room', area: 'decor_order_area', poly: roomPoly,
|
||||
settings: {},
|
||||
}];
|
||||
sp.walls = roomPoly.map((a, index) => wall(a, roomPoly[(index + 1) % roomPoly.length]));
|
||||
sp.openings = [
|
||||
{ id: 'decor-order-window', type: 'window', x: 0.5, y: 0.15, angle: 0, length: 0.24 },
|
||||
];
|
||||
sp.decor = [
|
||||
{ id: 'decor-order-line', kind: 'line', x1: 0.5, y1: 0.04, x2: 0.5, y2: 0.96,
|
||||
color: '#ff0033', opacity: 1, width_cm: 8 },
|
||||
{ id: 'decor-order-rect', kind: 'rect', x: 0.18, y: 0.27, w: 0.18, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'decor-order-ellipse', kind: 'ellipse', x: 0.64, y: 0.27, w: 0.18, h: 0.12,
|
||||
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
||||
fill_color: '#ff6680', fill_opacity: 1 },
|
||||
{ id: 'decor-order-text', kind: 'text', x: 0.27, y: 0.68, text: 'DECOR',
|
||||
color: '#ff0033', opacity: 1, size_cm: 14 },
|
||||
{ id: 'decor-order-furniture', kind: 'furniture', symbol: 'sofa',
|
||||
x: 0.64, y: 0.61, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
|
||||
];
|
||||
sp.settings = {
|
||||
...(sp.settings || {}), fill_mode: 'custom', custom_fill: { c: '#2255cc', a: 1 },
|
||||
show_borders: false, show_names: false, hide_openings: true,
|
||||
hide_decor: false, glow_enabled: false, sun_rays: false,
|
||||
};
|
||||
c._setMode('view');
|
||||
await update();
|
||||
|
||||
const svg = root().querySelector('.stage .zoomwrap > svg');
|
||||
const stageRect = root().querySelector('.stage').getBoundingClientRect();
|
||||
const ctm = svg.getScreenCTM();
|
||||
const toStage = (x, y) => {
|
||||
const point = svg.createSVGPoint(); point.x = x; point.y = y;
|
||||
const screen = point.matrixTransform(ctm);
|
||||
return [screen.x - stageRect.left, screen.y - stageRect.top];
|
||||
};
|
||||
const decor = root().querySelector('.decorlayer');
|
||||
const room = root().querySelector('[data-hp="room"][data-id="decor-order-room"]');
|
||||
const dataTunnel = root().querySelector(
|
||||
'.opening-tunnels[data-layer="data"] [data-id="decor-order-window"]',
|
||||
);
|
||||
const follows = (lower, upper) => !!lower && !!upper
|
||||
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
const kinds = new Set([...root().querySelectorAll('.decorlayer [data-hp="decor"]')]
|
||||
.map((node) => node.dataset.kind));
|
||||
return {
|
||||
before: {
|
||||
allDecorKindsRender: ['line', 'rect', 'ellipse', 'text', 'furniture']
|
||||
.every((kind) => kinds.has(kind)),
|
||||
oneDecorLayer: root().querySelectorAll('.decorlayer').length === 1,
|
||||
dataFloorBeforeDecor: follows(room, decor) && follows(dataTunnel, decor),
|
||||
dataTunnelExists: !!dataTunnel,
|
||||
},
|
||||
probes: { room: toStage(500, 500), tunnel: toStage(500, 150) },
|
||||
stored: JSON.stringify(sp.decor),
|
||||
};
|
||||
});
|
||||
|
||||
const customShot = await stage.screenshot({ animations: 'disabled' });
|
||||
|
||||
const hover = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const room = root.querySelector('[data-hp="room"][data-id="decor-order-room"]');
|
||||
room?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
|
||||
await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const hoverFill = root.querySelector('.room-hover-fill-layer');
|
||||
const decor = root.querySelector('.decorlayer');
|
||||
return {
|
||||
hoverFillExists: !!hoverFill,
|
||||
hoverBeforeDecor: !!hoverFill && !!decor
|
||||
&& !!(hoverFill.compareDocumentPosition(decor) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
};
|
||||
});
|
||||
const hoverShot = await stage.screenshot({ animations: 'disabled' });
|
||||
|
||||
const glow = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
root.querySelector('[data-hp="room"][data-id="decor-order-room"]')?.dispatchEvent(
|
||||
new PointerEvent('pointerleave', { pointerType: 'mouse' }),
|
||||
);
|
||||
sp.rooms[0].settings = {};
|
||||
sp.settings = {
|
||||
...sp.settings, fill_mode: 'glow', glow_enabled: true,
|
||||
show_borders: false, hide_openings: true, sun_rays: false,
|
||||
};
|
||||
c._serverCfg.settings = {
|
||||
...(c._serverCfg.settings || {}),
|
||||
fill_colors: {
|
||||
...(c._serverCfg.settings?.fill_colors || {}),
|
||||
glow_base: { c: '#2255cc', a: 1 },
|
||||
},
|
||||
};
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const base = root.querySelector('.glow-base-layer');
|
||||
const tunnel = root.querySelector(
|
||||
'.opening-tunnels[data-layer="glow-base"] [data-id="decor-order-window"]',
|
||||
);
|
||||
const decor = root.querySelector('.decorlayer');
|
||||
const liveGlow = root.querySelector('.glow-pools-frame');
|
||||
const follows = (lower, upper) => !!lower && !!upper
|
||||
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
return {
|
||||
glowBaseExists: !!base,
|
||||
glowTunnelExists: !!tunnel,
|
||||
glowBaseBeforeDecor: follows(base, decor) && follows(tunnel, decor),
|
||||
decorBeforeLiveGlow: follows(decor, liveGlow),
|
||||
};
|
||||
});
|
||||
const glowShot = await stage.screenshot({ animations: 'disabled' });
|
||||
|
||||
const decodePixels = await page.evaluate(async ({ shots, probes }) => {
|
||||
const decode = async (base64) => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/png;base64,${base64}`;
|
||||
await image.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width; canvas.height = image.height;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, 0, 0);
|
||||
return { width: image.width, height: image.height,
|
||||
pixels: context.getImageData(0, 0, image.width, image.height).data };
|
||||
};
|
||||
const images = await Promise.all(shots.map(decode));
|
||||
const isDecorRed = ({ width, height, pixels }, [rawX, rawY]) => {
|
||||
let red = 0; let samples = 0;
|
||||
const cx = Math.round(rawX), cy = Math.round(rawY);
|
||||
for (let y = cy - 2; y <= cy + 2; y++) for (let x = cx - 2; x <= cx + 2; x++) {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const offset = (y * width + x) * 4;
|
||||
const r = pixels[offset], g = pixels[offset + 1], b = pixels[offset + 2];
|
||||
if (r > 180 && r > g * 1.8 && r > b * 1.35) red++;
|
||||
samples++;
|
||||
}
|
||||
return samples > 0 && red / samples >= 0.6;
|
||||
};
|
||||
const verdict = (image) => ({
|
||||
room: isDecorRed(image, probes.room),
|
||||
tunnel: isDecorRed(image, probes.tunnel),
|
||||
});
|
||||
return images.map(verdict);
|
||||
}, {
|
||||
shots: [customShot, hoverShot, glowShot].map((shot) => shot.toString('base64')),
|
||||
probes: fixture.probes,
|
||||
});
|
||||
|
||||
const parity = await page.evaluate(async (stored) => {
|
||||
const c = window.__card;
|
||||
const root = c.shadowRoot || c.renderRoot;
|
||||
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
||||
const update = async () => {
|
||||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
sp.settings = {
|
||||
...sp.settings, show_borders: true, hide_openings: false,
|
||||
sun_rays: true, north_deg: 0,
|
||||
};
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 0, elevation: 25, rising: true },
|
||||
} } };
|
||||
await update();
|
||||
const decor = root.querySelector('.decorlayer');
|
||||
const follows = (lower, upper) => !!lower && !!upper
|
||||
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
const liveGlow = root.querySelector('.glow-pools-frame');
|
||||
const sun = root.querySelector('.sunlayer');
|
||||
const walls = root.querySelector('.wallbodies');
|
||||
const opening = root.querySelector('[data-hp="opening"][data-id="decor-order-window"]');
|
||||
const devLayer = root.querySelector('.devlayer');
|
||||
const paper = root.querySelector('.hp-paperg');
|
||||
const out = {
|
||||
backdropBeforeDecor: follows(paper, decor),
|
||||
liveGlowAfterDecor: follows(decor, liveGlow),
|
||||
sunAfterDecor: follows(decor, sun),
|
||||
wallsAfterDecor: follows(decor, walls),
|
||||
openingSymbolsAfterDecor: follows(decor, opening),
|
||||
htmlDevicesAndLabelsAfterDecor: follows(decor, devLayer),
|
||||
renderDoesNotRewriteDecor: JSON.stringify(sp.decor) === stored,
|
||||
};
|
||||
|
||||
sp.settings.hide_decor = true;
|
||||
c._setMode('view'); await update();
|
||||
out.hideDecorStillHidesView = !root.querySelector('.decorlayer');
|
||||
c._setMode('decor'); await update();
|
||||
out.ownEditorOverridesHide = !!root.querySelector('.decorlayer')
|
||||
&& root.querySelectorAll('.decorlayer [data-hp="decor"]').length >= 5;
|
||||
return out;
|
||||
}, fixture.stored);
|
||||
|
||||
const result = {
|
||||
...fixture.before,
|
||||
...hover,
|
||||
...glow,
|
||||
customFillKeepsDecorPixels: decodePixels[0].room,
|
||||
customTunnelKeepsDecorPixels: decodePixels[0].tunnel,
|
||||
hoverDoesNotTintDecorPixels: decodePixels[1].room,
|
||||
hoverTunnelKeepsDecorPixels: decodePixels[1].tunnel,
|
||||
glowBaseKeepsDecorPixels: decodePixels[2].room,
|
||||
glowTunnelKeepsDecorPixels: decodePixels[2].tunnel,
|
||||
...parity,
|
||||
};
|
||||
|
||||
checkAll(result);
|
||||
await finish(browser, result);
|
||||
+4
-2
@@ -246,6 +246,7 @@ const res = await page.evaluate(async () => {
|
||||
const hoverHalo = sr().querySelector('.room-hover-halo');
|
||||
const hoverOutline = sr().querySelector('.room-hover-outline');
|
||||
const hoverOutlineLayer = sr().querySelector('.room-hover-outline-layer');
|
||||
const decorLayer = sr().querySelector('.decorlayer');
|
||||
const glowLayer = sr().querySelector('.glow-pools-frame');
|
||||
const wallLayer = sr().querySelector('.wallbodies');
|
||||
out.hoverKeepsGlowDom = poolBeforeHover === sr().querySelector('.glow-pool')
|
||||
@@ -257,8 +258,9 @@ const res = await page.evaluate(async () => {
|
||||
const fillStyle = hoverFill ? getComputedStyle(hoverFill) : null;
|
||||
out.hoverUsesNeutralDarkening = fillStyle?.fill === 'rgb(0, 0, 0)'
|
||||
&& Math.abs(Number(fillStyle.fillOpacity) - 0.22) < 0.001;
|
||||
out.hoverLayerOrder = !!hoverFillLayer && !!glowLayer && !!hoverOutlineLayer
|
||||
&& !!(hoverFillLayer.compareDocumentPosition(glowLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
out.hoverLayerOrder = !!hoverFillLayer && !!decorLayer && !!glowLayer && !!hoverOutlineLayer
|
||||
&& !!(hoverFillLayer.compareDocumentPosition(decorLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
&& !!(decorLayer.compareDocumentPosition(glowLayer) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
// The default smoke fixture has no thick wall body. When one exists, the
|
||||
// late outline must follow it; absence is not a layer-order failure.
|
||||
&& (!wallLayer
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+5
-1
@@ -51,7 +51,11 @@ Layer order, top to bottom:
|
||||
|
||||
```text
|
||||
devices and room labels
|
||||
sun / walls / openings / rooms / decor
|
||||
opening symbols / physical and virtual walls / late room-hover outline
|
||||
sun rays
|
||||
live Glow pools
|
||||
decor
|
||||
room hover fill / Glow-base rooms and tunnels / data room fills and tunnels
|
||||
plan image
|
||||
room-shaped paper
|
||||
scene background
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Decorative lines, shapes, text and furniture are now drawn above opaque room
|
||||
fills, room hover, doorway fill and the dark Glow base, so the floor no
|
||||
longer hides them. Live light and sun, walls, opening symbols, devices and
|
||||
room labels keep their existing position above decor
|
||||
([#231](https://github.com/Matysh/houseplan-card/issues/231)).
|
||||
|
||||
## v1.67.0-beta.1 — 2026-08-22
|
||||
|
||||
- “Optimize plans” now verifies the exact preview candidate with the same wall,
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Декоративные линии, фигуры, текст и мебель теперь рисуются поверх непрозрачной
|
||||
заливки комнат, hover-подсветки, заливки проёмов и тёмной основы Glow, поэтому
|
||||
пол больше не скрывает декор. Живой свет и солнечные лучи, стены, символы
|
||||
проёмов, устройства и названия комнат по-прежнему остаются над декором
|
||||
([#231](https://github.com/Matysh/houseplan-card/issues/231)).
|
||||
|
||||
## v1.67.0-beta.1 — 2026-08-22
|
||||
|
||||
- «Оптимизировать планы» теперь проверяет точный кандидат из предпросмотра теми
|
||||
|
||||
@@ -21,6 +21,7 @@ device state or Home Assistant actions.
|
||||
| Rotation | 5° steps by default; `Shift` gives free rotation. Lines use endpoint handles instead of a rotation handle. |
|
||||
| Magnet targets | Only other decor objects and room contours: corners, edge centres, centres and edges. The image, devices and openings are excluded. |
|
||||
| Context emphasis | Decor and its editing chrome stay fully opaque. Rooms, labels, devices, openings, solid/thick walls and dashed virtual walls are contextual only and render at 35% opacity. |
|
||||
| View composition | All decor kinds form one layer above room/data fills, room hover fill, opening-tunnel fills and Glow base. Live Glow, sun, physical walls, opening symbols, devices and room labels remain above decor. The plan image remains below it. |
|
||||
| Compatibility | Legacy `width`, text `size/scale` and `plan_scale` remain readable. New writes use `width_cm`, `size_cm` and `plan_scale_x/y`. |
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -32,6 +32,29 @@
|
||||
полный прогон — workflow `mutation-gate.yml`, перед стабильным релизом и по
|
||||
понедельникам. Дешёвая половина идёт с юнитами: `test/mutation-gate.test.mjs`.
|
||||
|
||||
## Decor composition order (#231)
|
||||
|
||||
- [ ] All five decor kinds render in one `.decorlayer` after opaque room/data
|
||||
fill, active room-hover fill, opening tunnels and Glow-base rooms/tunnels,
|
||||
but before live Glow, sun, physical walls, opening symbols and the HTML
|
||||
device/room-label layer [auto: `smoke_decor_layer_order.mjs`,
|
||||
`smoke_glow.mjs`].
|
||||
- [ ] Pixel probes through an opaque room and a filled opening tunnel stay the
|
||||
decor colour before/after hover and over Glow base. Restoring the old DOM
|
||||
order makes those probes red [auto: `smoke_decor_layer_order.mjs`;
|
||||
mutation: `decor-restored-below-room-fills`].
|
||||
- [ ] The complete #231 golden impact set is reviewed before baseline
|
||||
acceptance. The two dedicated Light/opaque-hover and Dark/Glow-base
|
||||
scenes contain all five decor types and semantic probes in both rooms and
|
||||
the shared doorway. The three existing large-house scenes also change
|
||||
because their dense decor grid now renders above Glow-base room fills.
|
||||
Reviewed baselines are accepted only from the Linux release artifact
|
||||
[golden: `decor-over-opaque-hover-light`,
|
||||
`decor-over-glow-base-dark`, `isometric-large-warm-remount-dark`,
|
||||
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`].
|
||||
- [ ] `hide_decor`, the Background editor override and stored config remain
|
||||
unchanged; no per-object under-plan compatibility flag is introduced.
|
||||
|
||||
## Device icon design package (#179)
|
||||
|
||||
- [ ] Pure presentation tests cover lock/unlock, exact marker-only LQI bands
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 342 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 329 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"sourceFingerprint": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceFingerprint": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -46,8 +46,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"imageSha256": "3d22e1a2a96823e443833e40ea8b93747099b0fae1e9e19315d8789ae4604997"
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "f8cba93960e94371a22c6c1c8451cbaac0742fd0f50ae2050e5cd99bcf95adc4"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -57,8 +57,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "d5f67be890d6ad6bd810e202c26d922154476bc962fc017f0f95fa4455ef7fe2"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e41b7f694e9515cf7662ebd1af7de311495756ead07640a46348e9e966cb2ff6",
|
||||
"sourceSha256": "790ba6ffd3797e29faac3271b169153baeaf6a19b7ae3eb8e9bdd269eea36097",
|
||||
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# CODE-REVIEW-231-r1
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/231
|
||||
- **ТЗ:** `docs/specs/231-decor-layer-order.md` (зелёный SPEC-REVIEW r2)
|
||||
- **Диапазон:** `git log --oneline origin/dev..HEAD` → один коммит
|
||||
`b747316` «fix: render decor above room fills» (`Issue: #231`,
|
||||
`User-Visible: yes`)
|
||||
- **Заход:** r1 · блокирующих циклов израсходовано 0 из 4
|
||||
|
||||
## Скоуп
|
||||
|
||||
Единственное продуктовое изменение: перенос вызова `_renderDecorLayer()` в
|
||||
`src/houseplan-card.ts` из позиции перед рендером комнат в позицию после
|
||||
активной hover-заливки комнаты, обоих проходов тоннелей проёмов и Glow-base
|
||||
комнат — то есть непосредственно перед `_renderGlowLayer()`, как того требует
|
||||
§8.1 ТЗ. Сопутствующие изменения: новый browser-smoke
|
||||
`demo/smoke_decor_layer_order.mjs`, расширение `demo/smoke_glow.mjs`
|
||||
(`decorLayer` теперь участвует в `hoverLayerOrder`), новый documented mutant
|
||||
`decor-restored-below-room-fills` в `scripts/mutation-gate.mjs`, две новые
|
||||
golden-сцены (`decor-over-opaque-hover-light`, `decor-over-glow-base-dark`) с
|
||||
семантическими пиксельными пробами в `demo/golden/{matrix,harness,run}.mjs` и
|
||||
юнит-контракт на них в `test/golden-matrix.test.mjs`, документация
|
||||
(`docs/BACKDROP.md`, `docs/DECOR-EDITOR.md`, `docs/TESTING.md`, оба
|
||||
changelog), три синхронные копии бандла и пересчитанный отпечаток
|
||||
`docs/images/screenshots.json`.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Свежий чекаут ветки `issue/231-decor-layer-order` на `b747316`, `origin/dev`
|
||||
на момент ревью — `6683e52`.
|
||||
|
||||
### Гейты — обязательная часть (прогнаны все)
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| Unit | `npm test` | зелёный, 1070/1070 pass (0 skip — на CI runner’е автора один тест `process-gate.test.mjs` пропускался, окружение без `gh`; на поведение #231 не влияет) |
|
||||
| Build + bundle parity | `npm run build`, затем `cmp` трёх копий и `sha256sum` | зелёный, все три `houseplan-card.js` (`dist`, `custom_components/houseplan/frontend`, `demo/srv/assets`) байт-в-байт идентичны (`baf60c9a…`) |
|
||||
| Docs fingerprint | `node scripts/check-docs.mjs` | зелёный: «Documentation checks passed (7 files, 10 external links)» — diff трогает `src/**`, поэтому гейт обязателен, не пропущен |
|
||||
|
||||
### Гейты — по необходимости (diff и AC)
|
||||
|
||||
| Гейт | Команда | Результат | Почему выбран |
|
||||
|---|---|---|---|
|
||||
| Mutation registry (статика) | `node scripts/mutation-gate.mjs --check` | зелёный, все id, включая новый | якоря патчей мутанта существуют ровно один раз |
|
||||
| Mutation run (AC5, доказательство «тест умеет падать») | `node scripts/mutation-gate.mjs --id=decor-restored-below-room-fills` | зелёный: «чистый прогон: `node demo/smoke_decor_layer_order.mjs`» → ok, «decor-restored-below-room-fills: тест покраснел, как обязан» | AC5 явно требует это доказательство, а не только регистрацию мутанта |
|
||||
| Целевой smoke (AC1–AC4) | `node demo/smoke_decor_layer_order.mjs` | зелёный, все 25 полей `true`, включая DOM-order и пиксельные пробы hover/tunnel/glow-base | назван в АС1–АС4 и в плане тестов §15.1 |
|
||||
| Glow layer order (AC2/AC3, hover×decor×glow) | `node demo/smoke_glow.mjs` | зелёный, `hoverLayerOrder: true` и весь остальной Glow-контракт | назван в плане тестов §15.1 и напрямую правится в этом diff |
|
||||
| Opening tunnel fill (AC2, тоннель как продолжение пола) | `node demo/smoke_opening_tunnel_fill.mjs` | зелёный | тоннели — предмет нормативной границы §8.1, смок их проверяет отдельно от decor |
|
||||
| Hide/editor parity (AC4) | `node demo/smoke_hide_layers.mjs` | зелёный | явно назван в AC4 |
|
||||
| Decor editor поведение (AC4/AC6, не regresses) | `node demo/smoke_decor.mjs` | зелёный, включая `decorRestored`, `visibleInView`, `inertInView` | самый прямой существующий смок по объекту правки |
|
||||
| Backdrop parity (AC4, backdrop ниже decor) | `node demo/smoke_backdrop.mjs` | зелёный | явно назван в AC4 и §9 |
|
||||
| Golden (AC1/AC8, диф может менять видимый результат) | `npm run golden:verify` | см. раздел «Находки», M1 | обязателен по правилам гейтов — diff меняет геометрию/порядок слоёв |
|
||||
| Mutation unit-контракт | `node --test test/mutation-gate.test.mjs` | зелёный, 6/6 | регистр мутантов и test-build резолвятся |
|
||||
|
||||
### Не прогонялось и почему
|
||||
|
||||
- Полный `demo/smoke_*.mjs` (167 файлов) — не прогонялся. `scripts/smoke-select.mjs`,
|
||||
на который ссылается инструкция ревью, в репозитории отсутствует
|
||||
(`Cannot find module .../scripts/smoke-select.mjs`), поэтому автоматической
|
||||
выборки нет; список выше подобран вручную по AC и по diff (Glow, тоннели,
|
||||
hide/editor/backdrop — единственные существующие smokes, которые diff и ТЗ
|
||||
называют напрямую). Остальные 160+ смоков не касаются композиции SVG-слоёв.
|
||||
- `python -m pytest tests_backend -q` — не прогонялся, diff не трогает
|
||||
`custom_components/**/*.py`.
|
||||
- Performance-профили — не прогонялись, AC7 требует только «DOM count, source
|
||||
review и targeted performance sanity»; смок `smoke_decor_layer_order.mjs`
|
||||
уже проверяет `oneDecorLayer: true` (нет второго `.decorlayer`), число
|
||||
decor-групп не растёт, что и требуется. Отдельного профиля AC не называет.
|
||||
- Ручного тестирования в браузере не было — не предусмотрено циклом (см.
|
||||
«Гейты», PROCESS §2.7); AC доказаны автотестами и разбором по коду.
|
||||
|
||||
## Находки
|
||||
|
||||
### M1 (Medium, в скоупе) — golden regression на трёх существующих large-house сценах не определена и не учтена
|
||||
|
||||
Issue #231 (п.4) прямо требует: «Любая сцена с декором изменится. Определить
|
||||
список и переснять отдельным шагом с доказательством ревью». ТЗ AC8 требует
|
||||
актуальное «golden evidence». Handoff-комментарий разработчика утверждает:
|
||||
«Полный `npm run golden:verify` → ожидаемо красный: две новые сцены имеют
|
||||
`missing-baseline`, а текущий `dev` уже содержит множество ранее накопленных
|
||||
непринятых diff».
|
||||
|
||||
Я прогнал `npm run golden:verify` на `HEAD` (`b747316`) и получил, помимо двух
|
||||
ожидаемых `missing-baseline`, ещё три сценария с вердиктом `different`:
|
||||
`isometric-large-warm-remount-dark`, `large-house-zoom-250-dark`,
|
||||
`large-house-warm-remount-dark`.
|
||||
|
||||
Проверил заявление о «уже накопленном dev-дрейфе» напрямую: собрал `origin/dev`
|
||||
(`6683e52`) в отдельном `git worktree`, собрал бандл и прогнал те же три
|
||||
сценария через `node demo/golden/run.mjs --mode=capture --scenario=<id>`
|
||||
(diagnostic-режим, `--mode=verify` требует полный матрикс и отказывает на
|
||||
`--scenario`) — **все три `passed` против того же самого принятого baseline**.
|
||||
То есть регрессия появляется именно между `origin/dev` и этим коммитом, а не
|
||||
существовала на `dev` заранее — причина утверждения в handoff неверна для этих
|
||||
трёх сценариев.
|
||||
|
||||
Причина найдена по коду: фикстура `demo/fixtures/large-house.mjs` кладёт 500
|
||||
decor-объектов сеткой (`makeDecor`, x:0.02–0.98, y:0.018–0.97) поверх
|
||||
20-комнатной сетки (`roomGrid`, x:0.04–0.96, y:0.04–0.96) с `fill_mode: 'glow'`
|
||||
(`large-house.mjs:214`) — то есть decor обширно перекрывает Glow-base заливку
|
||||
комнат. Это ровно тот же эффект, что и цель #231 (decor становится видимым
|
||||
поверх Glow-base), только на perf-фикстуре, а не на новой синтетической. При
|
||||
zoom 0.4 (`large-house-zoom-040-dark`) разница остаётся под порогом
|
||||
`maxDiffRatio` и сценарий проходит; при zoom 2.5 и в warm-remount сценариях
|
||||
рендер крупнее и разница превышает порог — отсюда `different`.
|
||||
|
||||
Сам визуальный эффект — ожидаемое и корректное следствие фикса (Приложение
|
||||
пруф выше: изменения соответствуют цели задачи, а не побочный дефект
|
||||
рендера). Дефект не в поведении, а в полноте DoD: список затронутых golden
|
||||
сцен (issue п.4, ТЗ AC8, `docs/TESTING.md`) называет только две новые сцены;
|
||||
три существующие accepted-сцены, тоже реально изменившиеся из-за этого же
|
||||
коммита, не определены, не занесены ни в `docs/TESTING.md`, ни в комментарий с
|
||||
доказательством. Риск: на предрелизном `golden:accept -- --reviewed` эти три
|
||||
скриншота будет легко спутать с «уже накопленным неразобранным дрейфом» (как и
|
||||
произошло в handoff-комментарии) и принять непроверенными либо, наоборот,
|
||||
несправедливо связать с #231 задним числом без объяснения причины.
|
||||
|
||||
Не High: поведенческий AC1–AC7 не нарушен, ничего не ломается для
|
||||
пользователя в этом коммите — golden baseline в этом issue не принимается
|
||||
вообще (по правилу принятия эталонов), сама заливка не тронута. Это пробел в
|
||||
трассируемости релизного артефакта и неточное заявление в handoff, а не
|
||||
неверное продуктовое решение.
|
||||
|
||||
**Как закрыть в этой же задаче:** дополнить список сцен, изменившихся из-за
|
||||
переноса decor (минимум `isometric-large-warm-remount-dark`,
|
||||
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`) в
|
||||
`docs/TESTING.md`/комментарий к задаче, и заменить формулировку «уже
|
||||
накопленный dev-дрейф» на подтверждённую причину — пересечение
|
||||
`large-house`-decor с Glow-base после переноса слоя, которое требует того же
|
||||
reviewed-baseline-подтверждения перед бетой, что и две новые сцены.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- **AC1 (decor поверх room fill).** `demo/smoke_decor_layer_order.mjs`:
|
||||
`customFillKeepsDecorPixels`, `customTunnelKeepsDecorPixels` — `true`;
|
||||
прогон против кода до фикса (мутант) даёт красный, значит проверка не
|
||||
тривиальна.
|
||||
- **AC2 (decor поверх hover и пола проёма).** DOM-order
|
||||
`hoverBeforeDecor`/`glowBaseBeforeDecor` и пиксельные `hoverDoesNotTintDecorPixels`,
|
||||
`hoverTunnelKeepsDecorPixels`, `glowBaseKeepsDecorPixels`,
|
||||
`glowTunnelKeepsDecorPixels` — все `true`; `smoke_glow.mjs`
|
||||
`hoverLayerOrder: true` подтверждает то же на независимой fixture. M1 из
|
||||
SPEC-REVIEW r1 закрыта именно здесь — код `src/houseplan-card.ts:16720-16729`
|
||||
ставит decor строго после `_renderRoomHoverFill` и обоих
|
||||
`_renderOpeningTunnelFills`.
|
||||
- **AC3 (верхние слои сохранены).** `parity`-блок smoke:
|
||||
`liveGlowAfterDecor`, `sunAfterDecor`, `wallsAfterDecor`,
|
||||
`openingSymbolsAfterDecor`, `htmlDevicesAndLabelsAfterDecor` — все `true`;
|
||||
подтверждено чтением `src/houseplan-card.ts:16729-16731` (decor → Glow →
|
||||
sun rays, стены/символы/устройства дальше по файлу не переставлялись).
|
||||
- **AC4 (hide/editor/backdrop parity).** `hideDecorStillHidesView`,
|
||||
`ownEditorOverridesHide`, `backdropBeforeDecor` — `true`;
|
||||
`smoke_hide_layers.mjs`, `smoke_decor.mjs`, `smoke_backdrop.mjs` — все
|
||||
зелёные без изменений в их собственной логике (diff их не трогает).
|
||||
- **AC5 (mutant доказывает тест).** Реально прогнан
|
||||
`node scripts/mutation-gate.mjs --id=decor-restored-below-room-fills`:
|
||||
чистый прогон зелёный, с мутантом — целевой smoke красный. Не «зарегистрирован»,
|
||||
а исполнен.
|
||||
- **AC6 (compatibility и surfaces).** `renderDoesNotRewriteDecor: true`
|
||||
(`JSON.stringify(sp.decor) === stored` после полного цикла рендера);
|
||||
`oneDecorLayer: true` — второй `.decorlayer` не создан; статическая карточка
|
||||
и скрытая изометрия в diff не затронуты (изменений в соответствующих
|
||||
renderer'ах нет).
|
||||
- **AC7 (performance/security).** Diff — это перемещение одного вызова и
|
||||
одного komментария, новых groups/observers/listeners не добавлено; `oneDecorLayer: true`
|
||||
подтверждает отсутствие дублирования слоя.
|
||||
- **Q1 (владелец).** Единый порядок без нового флага — подтверждено кодом:
|
||||
`disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()`
|
||||
не получил новых веток/условий по объекту.
|
||||
- **Release-артефакты.** Оба changelog обновлены в том же коммите (`Issue:
|
||||
#231`, `User-Visible: yes`); `docs/BACKDROP.md` и `docs/DECOR-EDITOR.md`
|
||||
описывают новый порядок текстом, совпадающим с фактическим кодом;
|
||||
`docs/images/screenshots.json` — отпечаток пересчитан, `check-docs.mjs`
|
||||
зелёный; три копии бандла синхронны.
|
||||
- **Golden — новые сцены.** `decor-over-opaque-hover-light` и
|
||||
`decor-over-glow-base-dark` дают `missing-baseline`, что ожидаемо (baseline
|
||||
не принимается в этом ревью); `test/golden-matrix.test.mjs` покрывает их
|
||||
структуру юнитом (fill_mode, hoverRoom/glowEnabled, набор decor kinds,
|
||||
наличие `opening-tunnel` пробы) — прогнан в составе `npm test`.
|
||||
- **Существующие golden-сцены с decor (кроме large-house).** `golden-geometry`
|
||||
использует `fill_mode: 'none'` по умолчанию — decor-линия `geo-axis-h`
|
||||
визуально не перекрывалась заливкой ни до, ни после переноса, поэтому 30+
|
||||
сцен на этом пространстве (`geometry-view-*`, `day-cycle-*`,
|
||||
`isometric-geometry-view-*`, `hover-nested-room-dark` и др.) остались
|
||||
`passed` — подтверждено прогоном `golden:verify`, не только чтением.
|
||||
`hover-nested-room-dark` дополнительно проверен геометрически: полигон
|
||||
`geo-nested` (y: 0.14–0.38) не пересекает decor-линию на y=0.5, поэтому
|
||||
hover-fill и decor не перекрываются пространственно в этом сценарии.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный browser-smoke набор (167 файлов) — не прогонялся целиком, только
|
||||
выбранные вручную из-за отсутствия `scripts/smoke-select.mjs` в репозитории
|
||||
(см. «Не прогонялось и почему»).
|
||||
- `pytest tests_backend` — не прогонялся, diff не касается Python.
|
||||
- Performance-профили (`benchmark:large-house` и т.п.) — не прогонялись;
|
||||
AC7 не называет отдельный профиль, а `oneDecorLayer`/DOM count проверены
|
||||
smoke'ом.
|
||||
- Полный Linux golden-артефакт и его reviewed-принятие — вне цикла код-ревью
|
||||
по правилу принятия эталонов (§13 PROCESS.md); это предрелизный шаг, не
|
||||
код-ревью.
|
||||
- Ручной просмотр в браузере на реальном HA — не проводился (не предусмотрено
|
||||
циклом).
|
||||
@@ -0,0 +1,136 @@
|
||||
# CODE-REVIEW-231-r2
|
||||
|
||||
- **Issue:** https://github.com/Matysh/houseplan-card/issues/231
|
||||
- **ТЗ:** `docs/specs/231-decor-layer-order.md` (зелёный SPEC-REVIEW r2)
|
||||
- **Диапазон полной задачи:** `git log --oneline origin/dev..HEAD` →
|
||||
`e023adb`, `9410be6`, `d2e7626`, `b747316` (продуктовый фикс, `Issue: #231`,
|
||||
`User-Visible: yes`), `e943e2f`, `d46abe5`, `6d31553`
|
||||
- **Диапазон этого раунда (дельта r1→r2):** `b747316..6d31553` — SHA
|
||||
предыдущего раунда взят из тела документа r1 (`docs/reviews/CODE-REVIEW-231-r1.md`,
|
||||
«Диапазон» и «Как проверялось») и из вердикта r1 в issue (комментарий
|
||||
цитирует «на HEAD (`b747316`)»); формальная первая строка вердикта r1 сам
|
||||
SHA не называет — это соответствует ожидаемому формату §7.2, находкой не
|
||||
является, так как SHA восстанавливается однозначно из тела того же
|
||||
комментария и документа
|
||||
- **Заход:** r2 · блокирующих циклов израсходовано 1 из 4
|
||||
|
||||
## Скоуп раунда (по дельте)
|
||||
|
||||
`git diff b747316..6d31553 --stat`:
|
||||
|
||||
```
|
||||
docs/TESTING.md | 14 ++-
|
||||
docs/reviews/CODE-REVIEW-231-r1.md | 204 +++++++++++++++++++++++++++++++++++
|
||||
```
|
||||
|
||||
Продуктовый код (`src/houseplan-card.ts`, три копии бандла, тесты, smoke,
|
||||
golden matrix/harness) в дельте не тронут вообще — коммит `d46abe5`
|
||||
архивирует документ r1 в `docs/reviews/` (публикующий шаг, не авторская
|
||||
правка), а `6d31553` — единственная содержательная правка автора: расширение
|
||||
чек-листа `docs/TESTING.md` до полного списка golden-сцен, затронутых #231.
|
||||
Это ровно и только предмет находки M1 из r1. Дельта локальна: новая
|
||||
подсистема не затронута, контракт поведения не менялся, бандл не
|
||||
пересобирался (SHA-256 идентичен r1).
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| Находка | Чем закрыта | Где это видно |
|
||||
|---|---|---|
|
||||
| **M1** (Medium, в скоупе) — список golden-сцен, затронутых переносом decor, называл только 2 новые сцены; 3 существующие large-house сцены (`isometric-large-warm-remount-dark`, `large-house-zoom-250-dark`, `large-house-warm-remount-dark`) реально меняются тем же коммитом, но не были задокументированы, а причина в handoff-комментарии («уже накопленный dev-дрейф») была ошибочной | `docs/TESTING.md` теперь перечисляет все 5 сцен в одном пункте чек-листа и формулирует подтверждённую причину: «The three existing large-house scenes also change because their dense decor grid now renders above Glow-base room fills» — вместо прежней ссылки только на 2 новые сцены без объяснения. Ошибочное объяснение «дрейф dev» не повторено нигде в новом тексте. Требование «reviewed baselines только из Linux release artifact» сохранено для всех пяти сцен | `docs/TESTING.md:46-54` (см. diff выше); текстовая правка причины дополнительно зафиксирована в новом комментарии автора в issue #231 (`IC_kwDOTOcLQM8AAAABQL-q0w`, 2026-08-22 15:53), который прямо отменяет прежнюю формулировку |
|
||||
|
||||
Проверил построчно: три id сцен (`isometric-large-warm-remount-dark`,
|
||||
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`) реальны и
|
||||
совпадают с определениями в `demo/golden/matrix.mjs:63,364,366` — не
|
||||
опечатка и не выдуманные имена.
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Всё, до чего дельта `b747316..6d31553` не дотягивается, принято без
|
||||
повторной проверки из `docs/reviews/CODE-REVIEW-231-r1.md` @ `b747316`
|
||||
(`b74731698f664a9b0dc4c187b5b1b05ccf333b61`):
|
||||
|
||||
- AC1–AC7 (порядок decor относительно room fill, hover, обоих тоннелей,
|
||||
Glow-base, live Glow/солнца/стен/символов/устройств), доказанные
|
||||
`demo/smoke_decor_layer_order.mjs`, `demo/smoke_glow.mjs`,
|
||||
`demo/smoke_opening_tunnel_fill.mjs`, `demo/smoke_hide_layers.mjs`,
|
||||
`demo/smoke_decor.mjs`, `demo/smoke_backdrop.mjs` и мутантом
|
||||
`decor-restored-below-room-fills` — код `src/houseplan-card.ts` в дельте не
|
||||
менялся, бандл байт-в-байт идентичен (`sha256: baf60c9a89a8…`), поэтому
|
||||
повторный прогон этих smoke не может дать новый результат;
|
||||
- Q1 (владелец) — единый порядок decor без нового persisted-флага;
|
||||
- AC8 в части структуры двух новых golden-сцен (`decor-over-opaque-hover-light`,
|
||||
`decor-over-glow-base-dark`) и их `missing-baseline` статуса — сцены не
|
||||
менялись;
|
||||
- release-артефакты предыдущего коммита: оба changelog, `docs/BACKDROP.md`,
|
||||
`docs/DECOR-EDITOR.md`, отпечаток `docs/images/screenshots.json`, три копии
|
||||
бандла — не затронуты этой дельтой;
|
||||
- вывод «AC7/perf» (`oneDecorLayer: true`, отсутствие новых
|
||||
groups/observers/listeners) — код не менялся.
|
||||
|
||||
## Как проверялось в этом раунде
|
||||
|
||||
Дельта не трогает `src/**` и не меняет бандл, поэтому полный целевой набор
|
||||
smoke/golden из r1 не переисполнялся — прогнаны только дешёвые обязательные
|
||||
гейты, чтобы подтвердить, что дельта не сломала ничего наследуемого.
|
||||
|
||||
### Гейты — обязательная часть (прогнаны все)
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
|
||||
| Unit | `npm test` | зелёный, 1070/1070 pass, 0 skip (на этом раннере `gh` доступен, `process-gate.test.mjs` не пропускается) |
|
||||
| Build + bundle parity | `npm run build`, затем `sha256sum` трёх копий | зелёный, все три `houseplan-card.js` идентичны и совпадают с зафиксированным в r1 хэшем `baf60c9a89a8…` — подтверждает, что дельта не изменила продуктовый вывод |
|
||||
| Docs fingerprint | `node scripts/check-docs.mjs` | зелёный: «Documentation checks passed (7 files, 10 external links)» — формально не обязателен (дельта не трогает `src/**`), прогнан всё равно как дешёвый, т.к. дельта меняет `docs/TESTING.md` |
|
||||
|
||||
### Гейты — по необходимости
|
||||
|
||||
Не прогонялись повторно: `demo/smoke_decor_layer_order.mjs`, `smoke_glow.mjs`,
|
||||
`smoke_opening_tunnel_fill.mjs`, `smoke_hide_layers.mjs`, `smoke_decor.mjs`,
|
||||
`smoke_backdrop.mjs`, `npm run golden:verify`, `node scripts/mutation-gate.mjs`.
|
||||
Причина: дельта этого раунда не касается `src/houseplan-card.ts`, тестовых
|
||||
фикстур, golden-матрицы или mutation-gate — правка ограничена одним
|
||||
Markdown-чек-листом. Бандл после `npm run build` в этом раунде byte-for-byte
|
||||
идентичен бандлу r1 (тот же SHA-256), то есть материал, который эти гейты
|
||||
проверяют, не изменился с r1 ни на бит. Повторный прогон дал бы тот же
|
||||
результат, что уже запротоколирован в `docs/reviews/CODE-REVIEW-231-r1.md`, и
|
||||
был бы разбором заново, а не по дельте (PROCESS §2.9).
|
||||
|
||||
`python -m pytest tests_backend -q` — не прогонялся, дельта не касается
|
||||
Python. Performance-профили — не прогонялись, дельта не касается
|
||||
производительности. Ручного тестирования в браузере не было — не
|
||||
предусмотрено циклом.
|
||||
|
||||
## Находки
|
||||
|
||||
Новых находок нет. M1 закрыта полностью (см. «Закрытие раунда r1»).
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Список из пяти golden-сцен в `docs/TESTING.md` полный и точный:
|
||||
2 новые + 3 существующие large-house сцены, все id проверены по существующей
|
||||
golden-матрице (`demo/golden/matrix.mjs:63,364,366`).
|
||||
- Формулировка причины изменения существующих сцен верна и не противоречит
|
||||
разбору r1 (плотная decor-сетка `large-house` теперь рендерится поверх
|
||||
Glow-base заливки — прямое следствие переноса слоя, не побочный дефект).
|
||||
- Правило «reviewed baseline только из Linux release artifact» сохранено для
|
||||
всех пяти сцен без исключений.
|
||||
- Продуктовый код, тесты, smoke и golden-матрица не изменены этой дельтой —
|
||||
подтверждено идентичностью SHA-256 бандла и отсутствием diff вне
|
||||
`docs/TESTING.md` и архивного `docs/reviews/CODE-REVIEW-231-r1.md`.
|
||||
- Трейлеры коммитов дельты (`d46abe5`, `6d31553`) — `Issue: #231`,
|
||||
`User-Visible: no`; верно, так как правка касается только внутреннего
|
||||
тест-плана, не пользовательского поведения — changelog не требуется.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный browser-smoke набор и целевые smoke из r1 (`smoke_decor_layer_order.mjs`,
|
||||
`smoke_glow.mjs`, `smoke_opening_tunnel_fill.mjs`, `smoke_hide_layers.mjs`,
|
||||
`smoke_decor.mjs`, `smoke_backdrop.mjs`) — не перепрогонялись в этом раунде,
|
||||
наследуются из r1 (см. «Унаследовано из r1»); дельта их не касается, бандл
|
||||
идентичен.
|
||||
- `npm run golden:verify` и `mutation-gate` — не перепрогонялись, дельта не
|
||||
меняет рендер, геометрию, тестовые фикстуры или мутантные патчи.
|
||||
- `pytest tests_backend` — не прогонялся, дельта не касается Python.
|
||||
- Полный Linux golden-артефакт и его reviewed-принятие — вне цикла код-ревью
|
||||
(предрелизный шаг, §13 PROCESS.md), в этом раунде сам список сцен для
|
||||
будущего принятия проверен и признан полным.
|
||||
@@ -440,6 +440,30 @@ export const MUTANTS = [
|
||||
replace: 'this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, [],',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'decor-restored-below-room-fills',
|
||||
guard: 'node demo/smoke_decor_layer_order.mjs',
|
||||
because: 'moving decor back before room fills reproduces #231: opaque custom floors, room '
|
||||
+ 'hover and Glow base erase stored decor even though the decor group still exists in the DOM; '
|
||||
+ 'the raster probes must catch the visual regression, including the filled opening tunnel',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " ${''/* Decor is one composition layer above every floor treatment\n"
|
||||
+ ' (room fill/hover, opening tunnels and Glow base) and below\n'
|
||||
+ ' live lighting, physical plan geometry and devices. Keep\n'
|
||||
+ ' hide_decor visual-only: the decor editor must always paint\n'
|
||||
+ ' stored shapes so they remain editable. */}\n'
|
||||
+ " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}\n",
|
||||
replace: '',
|
||||
}, {
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' ${(() => {\n'
|
||||
+ ' // audit L1: hoisted out of the per-room map — these depend on the',
|
||||
replace: " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}\n"
|
||||
+ ' ${(() => {\n'
|
||||
+ ' // audit L1: hoisted out of the per-room map — these depend on the',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'feather-20px',
|
||||
guard: 'node demo/smoke_glow.mjs',
|
||||
@@ -758,10 +782,8 @@ export const MUTANTS = [
|
||||
+ 'the smoke must reject the legacy centred SVG substitute independently of compact and iso',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " ${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')}\n"
|
||||
+ ' ${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}',
|
||||
replace: " ${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')}\n"
|
||||
+ ' ${!space.bg && !disp.showNames && !this._markup ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
|
||||
find: ' ${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}',
|
||||
replace: ' ${!space.bg && !disp.showNames && !this._markup ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
|
||||
+ ' const center = this._roomCenter(room);\n'
|
||||
+ ' return svg`<text class="rlabel" data-hp="room-label" data-id=${room.id || nothing}\n'
|
||||
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
|
||||
|
||||
@@ -16576,12 +16576,6 @@ class HouseplanCard extends LitElement {
|
||||
@dblclick=${(e: Event) => this._openBackdropDialog(e)}
|
||||
preserveAspectRatio="none" />`
|
||||
: nothing}
|
||||
${''/* «Скрыть декоративный слой» (space.hide_decor). The layer is
|
||||
still THERE — the shapes are in the config and the decor
|
||||
editor draws them as always, because a layer you cannot see
|
||||
is a layer you cannot edit. Every other mode simply stops
|
||||
painting it. */}
|
||||
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}
|
||||
${(() => {
|
||||
// audit L1: hoisted out of the per-room map — these depend on the
|
||||
// config, not on entity state, and were recomputed per room.
|
||||
@@ -16727,6 +16721,12 @@ class HouseplanCard extends LitElement {
|
||||
${this._renderOpeningTunnelFills(space, roomFills)}
|
||||
${this._renderGlowBaseRooms(space, glowBase)}
|
||||
${this._renderOpeningTunnelFills(space, glowBase, 'glow-base')}
|
||||
${''/* Decor is one composition layer above every floor treatment
|
||||
(room fill/hover, opening tunnels and Glow base) and below
|
||||
live lighting, physical plan geometry and devices. Keep
|
||||
hide_decor visual-only: the decor editor must always paint
|
||||
stored shapes so they remain editable. */}
|
||||
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer()}
|
||||
${glowLayerVisible ? this._renderGlowLayer(space, disp) : nothing}
|
||||
${this._renderSunRays(space)}
|
||||
${this._editing ? svg`<g class="hp-editor-only-layer"
|
||||
|
||||
@@ -62,6 +62,25 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
|
||||
&& scenario.tunnelContinuity.maxChannelJump <= 32, true, scenario.id);
|
||||
assert.equal(typeof scenario.tunnelContinuity.dpr2, 'boolean', scenario.id);
|
||||
}
|
||||
if (scenario.decorPixelProbes) {
|
||||
assert.match(scenario.decorPixelProbes.color, /^#[0-9a-f]{6}$/i, scenario.id);
|
||||
assert.equal(Number.isInteger(scenario.decorPixelProbes.radius)
|
||||
&& scenario.decorPixelProbes.radius >= 0, true, scenario.id);
|
||||
assert.equal(scenario.decorPixelProbes.minMatchingFraction > 0
|
||||
&& scenario.decorPixelProbes.minMatchingFraction <= 1, true, scenario.id);
|
||||
assert.equal(scenario.decorPixelProbes.points.length >= 1, true, scenario.id);
|
||||
for (const point of scenario.decorPixelProbes.points) {
|
||||
assert.match(point.id, /^[a-z0-9-]+$/, scenario.id);
|
||||
assert.equal(point.x >= 0 && point.x <= 1 && point.y >= 0 && point.y <= 1,
|
||||
true, scenario.id);
|
||||
}
|
||||
}
|
||||
if (scenario.decorOverride) {
|
||||
assert.equal(new Set(scenario.decorOverride.map((shape) => shape.id)).size,
|
||||
scenario.decorOverride.length, scenario.id);
|
||||
assert.deepEqual(new Set(scenario.decorOverride.map((shape) => shape.kind)),
|
||||
new Set(['line', 'rect', 'ellipse', 'text', 'furniture']), scenario.id);
|
||||
}
|
||||
if (scenario.helpTextRegion) {
|
||||
assert.match(scenario.helpTextRegion.key, /^[a-z0-9_.-]+\.help$/, scenario.id);
|
||||
assert.equal(Number.isInteger(scenario.helpTextRegion.minPixels)
|
||||
@@ -98,7 +117,8 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
|
||||
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
|
||||
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
|
||||
'washer-active-cycle', 'washer-idle-cycle'])
|
||||
'washer-active-cycle', 'washer-idle-cycle', 'decor-over-opaque-hover',
|
||||
'decor-over-glow-base'])
|
||||
assert.equal(ids.includes(token), true, token);
|
||||
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
|
||||
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
|
||||
@@ -212,6 +232,34 @@ test('filled opening golden has a pixel-level seam detector', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('decor layer goldens pair opaque hover and Glow base with semantic pixels', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.decorPixelProbes);
|
||||
assert.deepEqual(scenarios.map((item) => item.id), [
|
||||
'decor-over-opaque-hover-light',
|
||||
'decor-over-glow-base-dark',
|
||||
]);
|
||||
assert.deepEqual(scenarios.map((item) => item.theme), ['light', 'dark']);
|
||||
for (const scenario of scenarios) {
|
||||
assert.equal(scenario.showBorders, false);
|
||||
assert.equal(scenario.showNames, false);
|
||||
assert.equal(scenario.hideOpenings, true);
|
||||
assert.equal(scenario.sunRays, false);
|
||||
assert.equal(scenario.decorPixelProbes.points.some((point) => point.id === 'opening-tunnel'), true);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.deepEqual(new Set(space.decor.map((shape) => shape.kind)),
|
||||
new Set(['line', 'rect', 'ellipse', 'text', 'furniture']));
|
||||
assert.equal(space.settings.show_borders, false);
|
||||
assert.equal(space.settings.hide_openings, true);
|
||||
}
|
||||
assert.equal(scenarios[0].fillMode, 'custom');
|
||||
assert.equal(scenarios[0].customFill.a, 1);
|
||||
assert.equal(scenarios[0].hoverRoom, 'light-left');
|
||||
assert.equal(scenarios[1].fillMode, 'glow');
|
||||
assert.equal(scenarios[1].glowEnabled, true);
|
||||
assert.equal(scenarios[1].allLightsOff, true);
|
||||
});
|
||||
|
||||
test('doorway spill golden exposes the opaque-fill failure mode from issue 71', () => {
|
||||
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
|
||||
assert.ok(scenario);
|
||||
@@ -234,7 +282,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 34);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 35);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -424,6 +472,9 @@ test('golden overrides fail closed on misspelled fixture references', () => {
|
||||
assert.throws(() => prepareGoldenFixture({
|
||||
...base, roomGlow: { 'light-rightt': true },
|
||||
}), /missing room/);
|
||||
assert.throws(() => prepareGoldenFixture({
|
||||
...base, decorOverride: [{ id: 'bad', kind: 'triangle' }],
|
||||
}), /unknown kind/);
|
||||
});
|
||||
|
||||
test('a light source paints exactly one region: the floor it can see', () => {
|
||||
|
||||
Reference in New Issue
Block a user