From 88bdd513ec53f1abc82f1f3f63765ba4207373ae Mon Sep 17 00:00:00 2001 From: Matysh Date: Tue, 6 Oct 2026 16:04:07 +0300 Subject: [PATCH] test(golden): cover battery and Zigbee caption overlap Address the in-scope Medium from CODE-REVIEW-792-r1 with a deterministic golden scenario that uses actual topology rendering and overlapping battery ink. Keep the existing battery boards and their reviewed images unchanged. Align the English guide's setting name with the shipped locale (Low). Issue: #792 User-Visible: no --- demo/golden/device-battery.mjs | 182 +++++++++++++++++++++++++++++++++ demo/golden/harness.mjs | 7 +- demo/golden/matrix.mjs | 7 +- docs/USER-GUIDE.md | 2 +- test/golden-battery.test.mjs | 92 ++++++++++++++++- test/golden-matrix.test.mjs | 6 +- 6 files changed, 289 insertions(+), 7 deletions(-) diff --git a/demo/golden/device-battery.mjs b/demo/golden/device-battery.mjs index 0b676aa3..29a816d4 100644 --- a/demo/golden/device-battery.mjs +++ b/demo/golden/device-battery.mjs @@ -206,3 +206,185 @@ export async function inspectBatteryBoardPixels(page, png, clip, board) { if (failures.length) throw new Error(`semantic battery golden ink missing: ${JSON.stringify(failures)}`); return samples; } + +export const BATTERY_ZIGBEE_MARKER = 'battery-56-normal'; + +/** Same natural unplaced-parent arrangement as smoke_device_battery_zigbee. */ +export function makeBatteryZigbeeFixture() { + const fixture = makeBatteryBoardFixture('desktop'); + fixture.config.markers = fixture.config.markers.filter((marker) => marker.id === BATTERY_ZIGBEE_MARKER); + fixture.config.markers[0].size = 1.4; + fixture.config.settings = { ...fixture.config.settings, volumetric_view: false, + show_device_battery: true, moon_enabled: false, zigbee_topology: { enabled: true }, + marker_area_snapshot: { [BATTERY_ZIGBEE_MARKER]: fixture.config.settings.marker_area_snapshot[BATTERY_ZIGBEE_MARKER] }, + }; + fixture.devices = { [BATTERY_ZIGBEE_MARKER]: fixture.devices[BATTERY_ZIGBEE_MARKER] }; + fixture.entities = Object.fromEntries(Object.entries(fixture.entities) + .filter(([, entity]) => entity.device_id === BATTERY_ZIGBEE_MARKER)); + fixture.states = Object.fromEntries(Object.entries(fixture.states).filter(([id]) => fixture.entities[id])); + fixture.layout = { [BATTERY_ZIGBEE_MARKER]: { s: SPACE, x: 0.34, y: 0.34 } }; + fixture.zhaDevices = [ + { ieee: '00124b0000000001', nwk: 1, device_reg_id: BATTERY_ZIGBEE_MARKER, device_type: 'EndDevice', + neighbors: [{ ieee: '00124b0000000002', relationship: 'Parent', lqi: 50 }] }, + { ieee: '00124b0000000002', nwk: 2, device_reg_id: 'not_on_plan', device_type: 'Router', + name: 'Upstairs parent relay', + neighbors: [{ ieee: '00124b0000000001', relationship: 'Child', lqi: 50 }] }, + ]; + return fixture; +} + +export async function batteryZigbeeProbe(page) { + return page.evaluate((id) => { + const root = window.__goldenCard.renderRoot; + const marker = root.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`); + const overlay = root.querySelector('hp-zigbee-topology-overlay')?.shadowRoot; + const rect = (node) => { + if (!node) throw new Error('battery Zigbee golden missing rendered geometry'); + const r = node.getBoundingClientRect(); + return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; + }; + const battery = rect(marker?.querySelector('.device-battery')); + const caption = rect(overlay?.querySelector('[data-hp="zigbee-topology-parent-bubble"]')); + const core = rect(marker?.querySelector('.device-core')); + return { battery, caption, core, overlap: { + x: Math.max(battery.x, caption.x), y: Math.max(battery.y, caption.y), + right: Math.min(battery.right, caption.right), bottom: Math.min(battery.bottom, caption.bottom), + } }; + }, BATTERY_ZIGBEE_MARKER); +} + +/** Fixed, core-anchored focus: tiny layering changes cannot hide in a full page. */ +export function batteryZigbeeClip(probe, viewport) { + const clip = { x: Math.floor(probe.core.x - 16), + y: Math.floor(probe.core.y + probe.core.height / 2 - 70), width: 420, height: 140 }; + if (clip.x < 0 || clip.y < 0 || clip.x + clip.width > viewport.width + || clip.y + clip.height > viewport.height || [probe.core, probe.battery, probe.caption].some((rect) => + rect.x < clip.x || rect.y < clip.y || rect.right > clip.x + clip.width || rect.bottom > clip.y + clip.height)) + throw new Error(`battery Zigbee golden focused clip truncates the witness: ${JSON.stringify({ clip, probe, viewport })}`); + return clip; +} + +/** Executable pixel oracle shared with unit negative cases, not a z-index check. */ +export function inspectBatteryZigbeePixels(images, probe, clip) { + if (probe.overlap.right - probe.overlap.x < 4 || probe.overlap.bottom - probe.overlap.y < 6) + throw new Error('battery Zigbee golden caption does not overlap battery'); + const at = (image, x, y) => { + const ix = Math.floor(x - clip.x), iy = Math.floor(y - clip.y); + if (ix < 0 || iy < 0 || ix >= image.width || iy >= image.height) + throw new Error('battery Zigbee golden pixel outside capture'); + const index = (iy * image.width + ix) * 4; + return image.data.slice(index, index + 3); + }; + const changed = (a, b) => a.some((value, index) => Math.abs(value - b[index]) > 12); + const green = (pixel) => COLORS.normal.every((value, index) => Math.abs(value - pixel[index]) < 8); + let ink = 0, covered = 0, coreChanged = 0, exposedRoute = 0; + const centreY = probe.caption.y + probe.caption.height / 2; + for (let y = Math.ceil(Math.max(probe.overlap.y + 2, centreY - 5)); + y < Math.min(probe.overlap.bottom - 2, centreY + 5); y++) { + for (let x = Math.ceil(probe.overlap.x + 2); x < probe.overlap.right - 2; x++) { + const baseline = at(images.captionHidden, x, y), painted = at(images.active, x, y); + if (!green(baseline)) continue; + ink++; + if (changed(baseline, painted) && painted[1] - Math.max(painted[0], painted[2]) < 50) covered++; + } + } + const cx = probe.core.x + probe.core.width / 2, cy = probe.core.y + probe.core.height / 2; + for (let x = Math.ceil(cx); x < probe.caption.x - 3; x++) { + for (let dy = -2; dy <= 2; dy++) { + if (!changed(at(images.active, x, cy + dy), at(images.routesHidden, x, cy + dy))) continue; + if (x < probe.core.right - 5) coreChanged++; + else if (x > probe.core.right + 3) exposedRoute++; + } + } + const evidence = { ink, covered, coreChanged, exposedRoute }; + // Keep the smoke's differential thresholds; the focused zero-diff golden + // additionally catches a one-pixel edge leak that a ratio alone could miss. + if (ink < 3 || covered / ink < 0.9) + throw new Error(`battery Zigbee golden caption does not cover real battery ink: ${JSON.stringify(evidence)}`); + if (coreChanged > 2 || exposedRoute < 3) + throw new Error(`battery Zigbee golden route/core paint order is missing: ${JSON.stringify(evidence)}`); + return evidence; +} + +export async function prepareBatteryZigbeeOverlap(page) { + // Open/refresh/close the real General Settings UI; __hpTest still points at + // the old demo card after golden remounts and must not seed this scene. + await page.evaluate(() => window.__goldenCard.renderRoot.querySelector('[data-hp="settings"]').click()); + await page.waitForFunction(() => [...(window.__goldenCard.renderRoot + .querySelector('hp-zigbee-topology-settings')?.shadowRoot?.querySelectorAll('button') || [])] + .some((button) => button.querySelector('ha-icon[icon="mdi:access-point-network"]') && !button.disabled)); + await page.evaluate(() => [...window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-settings') + .shadowRoot.querySelectorAll('button')] + .find((button) => button.querySelector('ha-icon[icon="mdi:access-point-network"]')).click()); + await page.waitForFunction(() => /Received/i.test(window.__goldenCard.renderRoot + .querySelector('hp-zigbee-topology-settings')?.shadowRoot?.textContent || '')); + await page.evaluate(() => window.__goldenCard.renderRoot.querySelector('hp-dialog [data-hp="dialog-cancel"]').click()); + await page.waitForFunction(() => !window.__goldenCard.renderRoot.querySelector('hp-dialog')); + const face = await page.evaluate(async (id) => { + const marker = window.__goldenCard.renderRoot.querySelector(`[data-hp="device"][data-id="${CSS.escape(id)}"]`); + const battery = marker?.querySelector('.device-battery'); + const icon = battery?.querySelector('ha-icon.device-battery-icon'); + await icon?.updateComplete; + const svg = icon?.shadowRoot?.querySelector('svg'), path = svg?.querySelector('path'); + const rect = battery?.getBoundingClientRect(), core = marker?.querySelector('.device-core')?.getBoundingClientRect(); + if (battery?.dataset.state !== 'normal' || icon?.icon !== 'mdi:battery' + || !path || path.getAttribute('d') !== window.__ICONS?.['mdi:battery'] + || !(path.getBBox().width > 0) || !(path.getBBox().height > 0) + || svg.getAttribute('viewBox') !== '0 0 24 24' + || !(rect.width > 0) || Math.abs(svg.getBoundingClientRect().width - rect.width) > 0.5 + || Math.abs(svg.getBoundingClientRect().height - rect.height) > 0.5) + throw new Error('battery Zigbee golden lacks a full-size official MDI battery'); + return { x: core.x + core.width / 2, y: core.y + core.height / 2, + battery: { id, state: 'normal', color: [29, 194, 29], x: rect.x, y: rect.y, width: rect.width, height: rect.height } }; + }, BATTERY_ZIGBEE_MARKER); + // Before hover the very same MDI must paint. Empty glyphs cannot make the + // subsequent caption control vacuously green. + await inspectBatteryBoardPixels(page, await page.screenshot({ scale: 'css', animations: 'disabled' }), + null, { samples: [face.battery] }); + await page.mouse.move(face.x, face.y); + await page.waitForFunction(() => window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-overlay') + ?.shadowRoot?.querySelector('[data-hp="zigbee-topology-parent-bubble"]')?.getBoundingClientRect().width > 0); + await page.evaluate(() => { + const caption = window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-overlay').shadowRoot + .querySelector('[data-hp="zigbee-topology-parent-bubble"]'); + if (!caption.textContent.includes('Upstairs parent relay')) + throw new Error('battery Zigbee golden expected real parent caption text'); + }); + await page.evaluate(async () => { + await window.__goldenCard.updateComplete; + await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))); + }); + const probe = await batteryZigbeeProbe(page), clip = batteryZigbeeClip(probe, page.viewportSize()); + const screenshot = () => page.screenshot({ clip, scale: 'css', animations: 'disabled', caret: 'hide' }); + const controls = async (hidden) => page.evaluate((hidden) => { + const overlay = window.__goldenCard.renderRoot.querySelector('hp-zigbee-topology-overlay').shadowRoot; + for (const [name, selector] of [['caption', 'zigbee-topology-parent-bubble'], ['routes', 'zigbee-topology-lines']]) { + const node = overlay.querySelector(`[data-hp="${selector}"]`); + if (!node) throw new Error(`battery Zigbee golden missing ${name}`); + if (hidden === name) node.style.visibility = 'hidden'; + else node.style.removeProperty('visibility'); + } + }, hidden); + let active, captionHidden, routesHidden; + try { + active = await screenshot(); + await controls('caption'); + captionHidden = await screenshot(); + await controls('routes'); + routesHidden = await screenshot(); + } finally { + await controls(null); + } + const images = await page.evaluate(async (encoded) => Object.fromEntries(await Promise.all( + Object.entries(encoded).map(async ([name, base64]) => { + const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' })); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height), context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0); + const image = context.getImageData(0, 0, bitmap.width, bitmap.height); + bitmap.close(); + return [name, { width: image.width, height: image.height, data: Array.from(image.data) }]; + }))), { active: active.toString('base64'), captionHidden: captionHidden.toString('base64'), + routesHidden: routesHidden.toString('base64') }); + return { ...inspectBatteryZigbeePixels(images, probe, clip), clip, overlap: probe.overlap }; +} diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index 3abf5750..4c234bcd 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -1,7 +1,8 @@ import { makeLargeHouseFixture } from '../fixtures/large-house.mjs'; import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs'; import { readFileSync } from 'node:fs'; -import { makeBatteryBoardFixture, prepareBatteryBoard } from './device-battery.mjs'; +import { makeBatteryBoardFixture, prepareBatteryBoard, makeBatteryZigbeeFixture, + prepareBatteryZigbeeOverlap, batteryZigbeeProbe, batteryZigbeeClip } from './device-battery.mjs'; const junctionArtifactsFixture = JSON.parse(readFileSync( new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8', @@ -43,6 +44,7 @@ const cardVersion = '0.0.0-golden'; const VERSION_RELOAD_ATTEMPT_KEY = 'houseplan-card:version-reload-target:v1'; const fixtureFor = (scenario) => scenario.batteryBoard ? makeBatteryBoardFixture(scenario.batteryBoard) + : scenario.batteryZigbeeOverlap ? makeBatteryZigbeeFixture() : scenario.fixture === 'large' ? makeLargeHouseFixture({ includeStairs: false }) : makeVisualMatrixFixture({ @@ -1013,6 +1015,7 @@ export async function prepareGoldenScenario(page, scenario) { three: { floor_id: 'three', name: 'Three', level: 2 }, }, callWS: async (message) => { + if (message.type === 'zha/devices' && fixture.zhaDevices) return structuredClone(fixture.zhaDevices); if (message.type === 'houseplan/config/get') return { config: structuredClone(fixture.config), rev: 1, can_write: scenario.canWrite !== false, @@ -2494,11 +2497,13 @@ export async function prepareGoldenScenario(page, scenario) { await page.mouse.move(point.x, point.y); } if (scenario.batteryBoard) result.batteryBoard = await prepareBatteryBoard(page, scenario); + if (scenario.batteryZigbeeOverlap) result.batteryZigbeeOverlap = await prepareBatteryZigbeeOverlap(page); return result; } export async function goldenClip(page, capture) { if (capture === 'page') return null; + if (capture === 'battery-zigbee-overlap') return batteryZigbeeClip(await batteryZigbeeProbe(page), page.viewportSize()); return page.evaluate((captureKind) => { const card = window.__goldenCard; const target = card?.renderRoot?.querySelector('.stage'); diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 5866c9a7..16b7bde9 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -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 = 72; +export const GOLDEN_MATRIX_VERSION = 73; const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } }; const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } }; @@ -897,6 +897,11 @@ export const GOLDEN_SCENARIOS = Object.freeze([ })), { id: 'device-battery-mobile-dark', fixture: 'visual', space: 'golden-battery', mode: 'view', batteryBoard: 'mobile', theme: 'dark', viewport: { width: 390, height: 844 }, ...stage }, + // Real ZHA refresh + hover; differential raster controls prove natural + // caption/battery overlap. A focused zero-diff frame protects 1px edges. + { id: 'device-battery-zigbee-overlap-dark', fixture: 'visual', space: 'golden-battery', mode: 'view', + batteryZigbeeOverlap: true, iconSize: 5, theme: 'dark', viewport: { width: 1200, height: 900 }, + capture: 'battery-zigbee-overlap', threshold: { maxChannelDelta: 10, maxDiffRatio: 0 } }, { id: 'device-value-face-cover-source-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view', glowEnabled: false, sunRays: false, showNames: false, diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index f83afc85..59342d02 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1429,7 +1429,7 @@ have no indicator. The normal vacuum dock marker follows the ordinary rules. The indicator does not enlarge the device's click target; Zigbee hover information appears above it. -**General settings → Display → Show device battery charge** is on by default +**General settings → Display → Show device battery status** is on by default and applies installation-wide, including static space cards and the editor preview. Full configuration export/import preserves the setting. diff --git a/test/golden-battery.test.mjs b/test/golden-battery.test.mjs index 574e69a2..966f4ff7 100644 --- a/test/golden-battery.test.mjs +++ b/test/golden-battery.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { BATTERY_BOARD_ICONS, batteryBoardSamples, makeBatteryBoardFixture } from '../demo/golden/device-battery.mjs'; +import { BATTERY_BOARD_ICONS, batteryBoardSamples, makeBatteryBoardFixture, + BATTERY_ZIGBEE_MARKER, makeBatteryZigbeeFixture, batteryZigbeeClip, + inspectBatteryZigbeePixels } from '../demo/golden/device-battery.mjs'; import { prepareGoldenFixture } from '../demo/golden/harness.mjs'; import { GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs'; @@ -61,3 +63,91 @@ test('#792 golden fixtures are fresh independent public config/HA data, without assert.ok(!Object.keys(existing.entities).some((entityId) => entityId.includes('battery_32_'))); assert.throws(() => batteryBoardSamples('unlisted'), /unknown battery board/); }); + +test('#792 fourth battery golden uses a real enabled ZHA parent and a focused zero-diff capture', () => { + const scenes = GOLDEN_SCENARIOS.filter((scenario) => scenario.batteryZigbeeOverlap); + assert.equal(scenes.length, 1); + const [scene] = scenes; + assert.equal(scene.id, 'device-battery-zigbee-overlap-dark'); + assert.equal(scene.mode, 'view'); + assert.equal(scene.theme, 'dark'); + assert.equal(scene.capture, 'battery-zigbee-overlap'); + assert.deepEqual(scene.threshold, { maxChannelDelta: 10, maxDiffRatio: 0 }); + const fixture = prepareGoldenFixture(scene); + assert.equal(fixture.config.settings.zigbee_topology.enabled, true); + assert.equal(fixture.config.settings.show_device_battery, true); + assert.equal(fixture.config.markers.length, 1); + assert.equal(fixture.config.markers[0].binding, `device:${BATTERY_ZIGBEE_MARKER}`); + assert.deepEqual(fixture.layout, { [BATTERY_ZIGBEE_MARKER]: { s: scene.space, x: 0.34, y: 0.34 } }); + assert.equal(Object.keys(fixture.entities).length, 2); // Measurement and its own registry battery only. + const batteryId = Object.keys(fixture.entities).find((id) => fixture.entities[id].original_device_class === 'battery'); + assert.equal(fixture.entities[batteryId].device_id, BATTERY_ZIGBEE_MARKER); + assert.equal(fixture.states[batteryId].state, '80'); + const [child, parent] = fixture.zhaDevices; + assert.equal(child.device_reg_id, BATTERY_ZIGBEE_MARKER); + assert.deepEqual(child.neighbors, [{ ieee: parent.ieee, relationship: 'Parent', lqi: 50 }]); + assert.deepEqual(parent.neighbors, [{ ieee: child.ieee, relationship: 'Child', lqi: 50 }]); + assert.equal(parent.name, 'Upstairs parent relay'); + assert.equal(fixture.devices[parent.device_reg_id], undefined); + assert.equal(fixture.layout[parent.device_reg_id], undefined); + fixture.zhaDevices[0].neighbors[0].lqi = 255; + assert.equal(makeBatteryZigbeeFixture().zhaDevices[0].neighbors[0].lqi, 50); + const oldScene = GOLDEN_SCENARIOS.find((scenario) => scenario.batteryBoard); + assert.equal(prepareGoldenFixture(oldScene).zhaDevices, undefined); +}); + +function pixelWitness() { + const rect = (x, y, width, height) => ({ x, y, width, height, right: x + width, bottom: y + height }); + const probe = { core: rect(4, 16, 20, 16), battery: rect(34, 14, 16, 20), caption: rect(36, 16, 24, 16), + overlap: { x: 36, y: 16, right: 50, bottom: 32 } }; + const image = () => ({ width: 64, height: 48, data: new Array(64 * 48 * 4).fill(32) }); + const images = { active: image(), captionHidden: image(), routesHidden: image() }; + const paint = (image, x, y, width, height, color) => { + for (let py = y; py < y + height; py++) for (let px = x; px < x + width; px++) { + const offset = (py * image.width + px) * 4; + image.data.splice(offset, 4, ...color, 255); + } + }; + paint(images.captionHidden, 34, 14, 16, 20, [29, 194, 29]); + paint(images.active, 28, 23, 5, 3, [220, 200, 50]); + return { images, probe, clip: { x: 0, y: 0 }, paint }; +} + +test('#792 Zigbee pixel oracle proves caption-over-battery and core-over-route from controls', () => { + const { images, probe, clip } = pixelWitness(); + assert.deepEqual(inspectBatteryZigbeePixels(images, probe, clip), + { ink: 100, covered: 100, coreChanged: 0, exposedRoute: 15 }); +}); + +test('#792 Zigbee pixel oracle rejects missing overlap, invisible MDI and reversed caption layer', () => { + const missing = pixelWitness(); + missing.probe.overlap.right = missing.probe.overlap.x; + assert.throws(() => inspectBatteryZigbeePixels(missing.images, missing.probe, missing.clip), /does not overlap/); + const empty = pixelWitness(); + empty.images.captionHidden = structuredClone(empty.images.active); + assert.throws(() => inspectBatteryZigbeePixels(empty.images, empty.probe, empty.clip), /real battery ink/); + const under = pixelWitness(); + under.paint(under.images.active, 34, 14, 16, 20, [29, 194, 29]); + assert.throws(() => inspectBatteryZigbeePixels(under.images, under.probe, under.clip), /real battery ink/); +}); + +test('#792 Zigbee pixel oracle rejects routes above core and an absent route control', () => { + const above = pixelWitness(); + above.paint(above.images.active, 14, 23, 3, 3, [220, 200, 50]); + assert.throws(() => inspectBatteryZigbeePixels(above.images, above.probe, above.clip), /route\/core paint order/); + const absent = pixelWitness(); + absent.images.routesHidden = structuredClone(absent.images.active); + assert.throws(() => inspectBatteryZigbeePixels(absent.images, absent.probe, absent.clip), /route\/core paint order/); +}); + +test('#792 Zigbee focus is fixed size, core-anchored and refuses a clipped witness', () => { + const { probe } = pixelWitness(); + const shifted = Object.fromEntries(Object.entries(probe).map(([key, rect]) => [key, { + ...rect, x: rect.x + 100, right: rect.right + 100, y: rect.y + 100, bottom: rect.bottom + 100, + }])); + assert.deepEqual(batteryZigbeeClip(shifted, { width: 1200, height: 900 }), + { x: 88, y: 54, width: 420, height: 140 }); + assert.throws(() => batteryZigbeeClip(probe, { width: 1200, height: 900 }), /truncates/); + shifted.caption.right = 540; + assert.throws(() => batteryZigbeeClip(shifted, { width: 1200, height: 900 }), /truncates/); +}); diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index e52df589..e5bf5e9c 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -15,7 +15,7 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', () for (const scenario of GOLDEN_SCENARIOS) { assert.match(scenario.id, /^[a-z0-9-]+$/); assert.equal(['visual', 'large'].includes(scenario.fixture), true, scenario.id); - assert.equal(['page', 'stage', 'sun-window'].includes(scenario.capture), true, scenario.id); + assert.equal(['page', 'stage', 'sun-window', 'battery-zigbee-overlap'].includes(scenario.capture), true, scenario.id); assert.equal(scenario.viewport.width > 0 && scenario.viewport.height > 0, true, scenario.id); assert.equal(scenario.threshold.maxChannelDelta >= 0 && scenario.threshold.maxChannelDelta <= 32, true, scenario.id); assert.equal(scenario.threshold.maxDiffRatio >= 0 && scenario.threshold.maxDiffRatio <= 0.01, true, scenario.id); @@ -446,7 +446,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, 72); + assert.equal(GOLDEN_MATRIX_VERSION, 73); assert.equal(space.settings.sun_rays, true); assert.equal(scenario.northDeg, 90, 'the sign-sensitive golden must keep a non-zero north direction'); @@ -940,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => { const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id); - assert.equal(GOLDEN_MATRIX_VERSION, 72); + assert.equal(GOLDEN_MATRIX_VERSION, 73); assert.deepEqual(ids, [ 'isometric-stage6-light-lightfloor', 'isometric-stage6-light-darkfloor',