test: complete isometric stage 1 gates

Issue: #89
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-13 15:28:19 +03:00
parent 0ef900a3ae
commit 6ea3ebff17
23 changed files with 1331 additions and 564 deletions
+3
View File
@@ -148,6 +148,8 @@ jobs:
run: |
npm run benchmark:large-house -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/baseline.json
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
@@ -161,6 +163,7 @@ jobs:
working-directory: candidate
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
+16
View File
@@ -272,6 +272,22 @@ Chromium — a `1e-6`-tolerance magnet-snap on the opening-*placement* path. It
reproduces against the pristine committed bundle, so treat it as
pre-existing/pixel-precision, not a regression you introduced.
## Labs flags
`src/labs.ts` is the single registry and resolver for hidden presentation
experiments. Activate a live flag through `?hp-labs=<id>` or the shared hash
grammar, remove it with `-<id>`, and use `off` to clear the set. Do not add a
YAML/config switch for a Labs-only experiment. A new entry needs a unique
lowercase id, issue, numeric-core `since`, numeric-core `expires`, summary and
unit/browser coverage. Invalid or duplicate registry entries fail closed.
Expiry is exclusive and ignores prerelease suffixes: an entry expiring at
`1.65.0` is unavailable in `1.65.0-beta.1`. Before that cycle, either remove the
experiment or graduate it through its own reviewed issue; never extend expiry as
an incidental change. Labs may alter presentation only and must not gate data,
migrations, stores, HA actions or network calls. Current renderer details are in
`docs/ISOMETRIC.md`.
Demo harness render quirk: the fake `hass` in `demo.html` is set once, so opening the
page directly in a browser renders the floor plan but **device icons only appear
after a re-render** (an F5 refresh, or nudging `card.hass = {...card.hass}`). The
File diff suppressed because one or more lines are too long
+40 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
import { mkdirSync, writeFileSync } from 'node:fs';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { launch } from './serve.mjs';
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
@@ -13,6 +13,11 @@ const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const fixture = makeLargeHouseFixture();
const viewport = { width: 1440, height: 1000 };
@@ -43,7 +48,7 @@ const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, sample, cardContract }) => {
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
@@ -98,10 +103,18 @@ try {
wallUnion: card._wallUnionCache ? 1 : 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
});
window.__card?.remove?.();
localStorage.clear();
if (isometric) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(['iso']));
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
fixture.config.spaces.map((space) => [space.id, 'iso']),
)));
history.replaceState(null, '', '?hp-labs=iso');
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
card.setConfig({
@@ -144,6 +157,10 @@ try {
host.replaceChildren(card);
card.hass = hassFor(fixture.states);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._setProjection !== 'function'
|| !(card._isoGeometryCache instanceof Map))) {
throw new Error('large-house-isometric-v1 candidate has no renderer contract');
}
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
await card.updateComplete;
await frame();
@@ -152,6 +169,18 @@ try {
await frame();
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const loadLongTaskResult = await loadLongTasks.stop();
const viewToggle = isometric ? await duration(async () => {
if (typeof card._setProjection === 'function') {
card._setProjection('flat');
await card.updateComplete;
card._setProjection('iso');
await card.updateComplete;
} else {
// Comparison SHAs before #89 intentionally ignore the Labs operation.
card.requestUpdate();
await card.updateComplete;
}
}) : null;
const spaceSwitch = await duration(async () => {
card._pickSpace('perf-floor-2');
await card.updateComplete;
@@ -249,6 +278,7 @@ try {
sample,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -257,6 +287,7 @@ try {
switchCycleMs: switchCycle.ms,
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -273,7 +304,10 @@ try {
card.remove();
await frame();
return result;
}, { fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT });
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric,
});
if (measuredSample >= 0) rows.push(row);
}
} finally {
@@ -284,9 +318,10 @@ const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
const report = {
schema: 2,
profile: 'large-house-v1',
profile,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
@@ -304,7 +339,7 @@ const report = {
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
rows,
note: 'Compare with a base-SHA report captured by the same runner and evaluate demo/performance/budgets.json.',
note: `Compare with a base-SHA report captured by the same runner and evaluate the ${profile} budget.`,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
+18 -1
View File
@@ -62,13 +62,14 @@ export function prepareGoldenFixture(scenario) {
fixture.devices[scenario.deviceId].name = scenario.deviceName;
}
if (scenario.fillMode || typeof scenario.glowEnabled === 'boolean'
|| typeof scenario.sunRays === 'boolean') {
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
const space = requireSpace();
space.settings = {
...(space.settings || {}),
...(scenario.fillMode ? { fill_mode: scenario.fillMode } : {}),
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
};
}
@@ -204,10 +205,21 @@ export async function prepareGoldenScenario(page, scenario) {
window.__goldenCard?.remove?.();
window.__card?.remove?.();
localStorage.clear();
history.replaceState(null, '', scenario.labs?.length
? `?hp-labs=${encodeURIComponent(scenario.labs.join(','))}` : location.pathname);
if (scenario.labs?.length) {
localStorage.setItem('houseplan_card_labs_v1', JSON.stringify(scenario.labs));
}
if (scenario.projection && scenario.space) {
localStorage.setItem('houseplan_card_view_v1', JSON.stringify({
[scenario.space]: scenario.projection,
}));
}
const host = document.getElementById('host');
const cardConfig = {
type: 'custom:houseplan-card', title: `Golden ${scenario.id}`, icon_size: 3.4,
language: scenario.language || 'en',
...(scenario.kiosk ? { kiosk: true } : {}),
};
const hassFor = () => ({
language: scenario.language || 'en', locale: { language: scenario.language || 'en' },
@@ -268,6 +280,11 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await settleMode(card);
}
if (scenario.projection === 'iso' && typeof card._setProjection === 'function') {
card._setProjection('iso');
await card.updateComplete;
await frame();
}
if (Number.isFinite(scenario.zoom)) {
card._applyView(scenario.zoom, 500, 500);
card.requestUpdate();
+17 -1
View File
@@ -1,13 +1,29 @@
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 = 16;
export const GOLDEN_MATRIX_VERSION = 17;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-live-layers-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-no-borders-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
labs: ['iso'], projection: 'iso', showBorders: false, fillMode: 'custom',
customFill: { c: '#486a8f', a: 0.42 }, glowEnabled: true,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-touch-kiosk-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', kiosk: true,
theme: 'dark', viewport: { width: 390, height: 760 }, ...stage },
{ id: 'isometric-large-warm-remount-dark', fixture: 'large', space: 'perf-floor-2', mode: 'view',
labs: ['iso'], projection: 'iso', warmRemount: true,
theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
{ id: 'geometry-view-dark-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-view-light-fit', fixture: 'visual', space: 'golden-geometry', mode: 'view',
+8
View File
@@ -4,6 +4,14 @@
fixture. The fixture has 60 rooms, 200 devices, 100 openings, 60 partitions,
40 columns and 500 decor objects on three floors.
Issue #89 adds `large-house-isometric-v1` through the same runner. It enables
`hp-labs=iso`, selects iso per fixture space, measures the View toggle and
records the capped `isoGeometry` cache. A comparison SHA predating #89 remains
flat while reporting the same profile. The dedicated
`budgets-large-house-isometric.json` applies the reviewed 20% relative allowance
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
evidence; a local report is diagnostic.
The runner records seven measured samples after one discarded warm-up. With
this intentionally small CI sample, the nearest-rank `p95` is the observed
maximum; reports keep the conventional field name but should be read as a
@@ -0,0 +1,50 @@
{
"schema": 1,
"profile": "large-house-isometric-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 },
"firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 },
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
"settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 },
"switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 }
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.2,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.2,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.2,
"noiseAllowanceMs": 150
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.2,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 160,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1,
"isoGeometry": 8
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0,
"isoGeometry": 0
},
"renderedDevices": 200
}
+5 -1
View File
@@ -37,7 +37,9 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_settingsDialog',
'_tool',
]),
optionalFields: Object.freeze([]),
// A comparison SHA before #89 is intentionally flat; the isometric runner
// checks these two members only when the target source tree supports Stage 1.
optionalFields: Object.freeze(['_isoGeometryCache', '_setProjection']),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
@@ -46,6 +48,8 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
_gridPitch: 'number',
_loadOk: 'boolean',
_model: 'array',
_isoGeometryCache: 'map',
_setProjection: 'function',
_tool: 'string',
}),
});
+116
View File
@@ -0,0 +1,116 @@
// #89 Stage 1: Labs lifecycle, toggle, projection parity and flat editor boundary.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
const out = await page.evaluate(async () => {
const card = window.__card;
const root = () => card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const center = (element) => {
const rect = element.getBoundingClientRect();
return [rect.left + rect.width / 2, rect.top + rect.height / 2];
};
const enable = async () => {
history.replaceState(null, '', '#space=f1&hp-labs=iso');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await frame();
};
const result = {};
const configSpace = card._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
...(configSpace.settings || {}), show_borders: true, show_names: true,
};
configSpace.partitions = [{
id: 'iso-smoke-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
}];
card._cfgEpoch++;
card.requestUpdate();
await card.updateComplete;
await enable();
const toggle = root().querySelector('[data-hp="projection-toggle"]');
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
&& JSON.stringify(window.__hpLabs) === '["iso"]';
result.toggleShown = !!toggle;
result.toggleMinHitTarget = toggle && toggle.getBoundingClientRect().width >= 44
&& toggle.getBoundingClientRect().height >= 44;
result.flatDefault = toggle?.getAttribute('aria-pressed') === 'false'
&& !root().querySelector('[data-hp="iso-walls"]');
toggle?.click();
await card.updateComplete;
await frame();
const isoToggle = root().querySelector('[data-hp="projection-toggle"]');
const device = root().querySelector('[data-hp="device"]');
const roomLabel = root().querySelector('[data-hp="room-label"]');
result.isoRendered = isoToggle?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-walls"] .iso-wall-top');
result.preferenceStored = JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
result.anchorsFinite = [device, roomLabel].every((node) => node
&& center(node).every((value) => Number.isFinite(value)));
const deviceBefore = device ? center(device) : null;
card.hass = { ...card.hass, states: { ...card.hass.states } };
await card.updateComplete;
const deviceAfter = root().querySelector('[data-hp="device"]');
result.haUpdateKeepsGeometry = root().querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint
&& card._isoGeometryCache.size === 1;
result.haUpdateKeepsAnchor = !!deviceBefore && !!deviceAfter
&& Math.hypot(...center(deviceAfter).map((value, index) => value - deviceBefore[index])) <= 1;
card._setMode('plan');
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
result.editorIsFlat = card._mode === 'plan'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]');
card._setMode('view');
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
result.viewRestoresIso = root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true'
&& !!root().querySelector('[data-hp="iso-walls"]');
const source = card._isoSource;
let attempts = 0;
let shouldFail = true;
const fallbackKey = 'f1|injected-render-failure';
card._isoGeometryCache.clear();
card._isoSource = () => ({
key: fallbackKey,
build: () => {
attempts++;
if (shouldFail) throw new Error('injected isometric failure');
return [];
},
});
card.requestUpdate();
await card.updateComplete;
card.hass = { ...card.hass, states: { ...card.hass.states } };
await card.updateComplete;
result.fallbackLatched = attempts === 1
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'false'
&& !root().querySelector('[data-hp="iso-walls"]')
&& JSON.parse(localStorage.getItem('houseplan_card_view_v1') || '{}').f1 === 'iso';
shouldFail = false;
card._setProjection('iso');
await card.updateComplete;
result.explicitRetryRestoresIso = attempts === 2
&& !!root().querySelector('[data-hp="iso-walls"]')
&& root().querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
card._isoSource = source;
card._isoGeometryCache.delete(fallbackKey);
card._isoFallback.delete(fallbackKey);
card.requestUpdate();
await card.updateComplete;
history.replaceState(null, '', '#space=f1&hp-labs=-iso');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await frame();
result.removalIsImmediateFlat = JSON.stringify(window.__hpLabs) === '[]'
&& !root().querySelector('[data-hp="projection-toggle"]')
&& !root().querySelector('[data-hp="iso-walls"]');
return result;
});
checkAll(out);
await finish(browser, out);
+283
View File
@@ -0,0 +1,283 @@
// #89 Stage 1: Q6 live-floor preservation plus touch, kiosk and remount paths.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(
{ width: 430, height: 820 }, 1, [], { hasTouch: true, isMobile: true },
);
const out = await page.evaluate(async () => {
const original = window.__card;
const root = (card) => card.renderRoot;
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
history.replaceState(null, '', '?hp-labs=iso#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
await original.updateComplete;
const configSpace = original._serverCfg.spaces.find((space) => space.id === 'f1');
configSpace.settings = {
...(configSpace.settings || {}), fill_mode: 'custom',
custom_fill: { c: '#31465f', a: 0.42 }, glow_enabled: true,
show_borders: true, show_names: true, north_deg: 0, sun_rays: true,
};
configSpace.rooms[0].settings = {
...(configSpace.rooms[0].settings || {}), fill_mode: 'none', glow: true,
};
configSpace.openings = [
{ id: 'iso-window', type: 'window', x: 0.28, y: 0.14, angle: 0, length: 0.12,
contact: 'binary_sensor.window' },
{ id: 'iso-door', type: 'door', x: 0.55, y: 0.36, angle: 90, length: 0.12,
contact: 'binary_sensor.window', lock: 'lock.front_door' },
];
configSpace.partitions = [{
id: 'iso-live-wall', a: [0.15, 0.12], b: [0.85, 0.12], cm: 15,
}];
configSpace.decor = [
{ id: 'iso-line', kind: 'line', x1: 0.12, y1: 0.7, x2: 0.45, y2: 0.7,
color: '#667788', opacity: 0.7, width_cm: 1 },
{ id: 'iso-sofa', kind: 'furniture', symbol: 'sofa', x: 0.62, y: 0.55,
w: 0.2, h: 0.12, color: '#667788', opacity: 0.7, width_cm: 1 },
];
original._serverCfg.markers = original._serverCfg.markers || [];
original._serverCfg.markers.push({
id: 'iso-vacuum', binding: 'device:d_mower', space: 'f1', area: 'living_room',
vacuum: {
source: 'camera.static_robot_map', trail_mode: 'always',
calibration: { m1: [0.5, 0, 0, 0, 0.5, 0] },
},
});
original._layout['iso-vacuum'] = { s: 'f1', x: 0.18, y: 0.78 };
original._hoverRoom = { space: 'f1', room: original._spaceModel('f1').rooms[0] };
original.hass = {
...original.hass,
states: {
...original.hass.states,
'light.floor_lamp': {
...original.hass.states['light.floor_lamp'], state: 'on',
},
'vacuum.mower': {
...original.hass.states['vacuum.mower'], state: 'cleaning',
},
'camera.static_robot_map': {
entity_id: 'camera.static_robot_map', state: 'idle',
attributes: {
map_name: 'm1', vacuum_position: { x: 650, y: 700, a: 0 },
path: [[200, 200], [420, 440], [650, 700]],
},
},
'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 180, elevation: 24 },
},
},
};
original._cfgEpoch++;
original._regSignature = '';
original.requestUpdate();
await original.updateComplete;
await frame();
const count = (selector) => root(original).querySelectorAll(selector).length;
const layers = () => ({
rooms: count('[data-hp="room"]'),
roomFills: count('[data-hp="room"].filled'),
decor: count('.decorlayer [data-hp="decor"]'),
furniture: count('.decorlayer .dfurn'),
backdrop: count('.hp-backdrop'),
glowBase: count('.glow-base-layer .glow-base'),
glowSources: count('.glowlayer [data-glow-source]'),
sun: count('.sunlayer'),
openings: count('[data-hp="opening"]'),
devices: count('[data-hp="device"]'),
hover: count('.room-hover-fill'),
vacuumPucks: count('.vacpuck'),
vacuumTrails: count('.vactrail .case'),
});
const flat = layers();
const flatSpillParts = [...root(original).querySelectorAll('.glow-pool')]
.map((node) => node.getAttribute('data-lit-parts'));
root(original).querySelector('[data-hp="projection-toggle"]')?.click();
await original.updateComplete;
await frame();
const before = layers();
const beforeSpillParts = [...root(original).querySelectorAll('.glow-pool')]
.map((node) => node.getAttribute('data-lit-parts'));
const wallFingerprint = root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint;
const cachedGeometry = original._isoGeometryCache.get(wallFingerprint)?.geometry;
const light = original.hass.states['light.ceiling'];
original.hass = {
...original.hass,
states: {
...original.hass.states,
'light.ceiling': {
...light, attributes: { ...light.attributes, rgb_color: [255, 64, 32] },
},
},
};
await original.updateComplete;
const after = layers();
const afterSpillParts = [...root(original).querySelectorAll('.glow-pool')]
.map((node) => node.getAttribute('data-lit-parts'));
const ordered = [
root(original).querySelector('.hp-backdrop'),
root(original).querySelector('.decorlayer'),
root(original).querySelector('[data-hp="room"]'),
root(original).querySelector('.glow-base-layer'),
root(original).querySelector('.glowlayer'),
root(original).querySelector('.sunlayer'),
root(original).querySelector('.iso-walls-svg'),
root(original).querySelector('.vacpuck'),
];
const result = {
isoOnTouch: !!root(original).querySelector('[data-hp="iso-walls"]'),
flatIsoLayerParity: JSON.stringify(flat) === JSON.stringify(before)
&& JSON.stringify(flatSpillParts) === JSON.stringify(beforeSpillParts),
liveLayersPresent: before.rooms > 0 && before.roomFills > 0 && before.decor >= 2
&& before.furniture > 0 && before.backdrop > 0 && before.glowBase > 0
&& before.glowSources >= 2 && before.sun > 0 && before.openings >= 2
&& before.devices > 0 && before.hover > 0 && before.vacuumPucks > 0
&& before.vacuumTrails >= 2,
liveLayersStable: JSON.stringify(after) === JSON.stringify(before),
spillBarrierStable: JSON.stringify(afterSpillParts) === JSON.stringify(beforeSpillParts)
&& beforeSpillParts.length >= 2 && beforeSpillParts.every((parts) => Number(parts) > 0),
floorToOverlayOrderPreserved: ordered.every(Boolean)
&& ordered.slice(1).every((node, index) => Boolean(
ordered[index].compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING
)),
haColorUpdatePainted: [...root(original).querySelectorAll('radialGradient stop')]
.some((stop) => stop.getAttribute('stop-color') === '#ff4020'),
sameWallFingerprint: root(original).querySelector('[data-hp="iso-walls"]')?.dataset.fingerprint === wallFingerprint,
haUpdateReusesGeometry: original._isoGeometryCache.get(wallFingerprint)?.geometry === cachedGeometry,
noStage1VerticalOpenings: !root(original).querySelector('.iso-window,.window-light,.vertical-door'),
};
const calls = [];
original.hass = {
...original.hass,
callService: async (domain, service, data) => calls.push({ domain, service, data }),
};
await original.updateComplete;
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
await frame();
original._setProjection('flat');
await original.updateComplete;
root(original).querySelector('[data-entity="light.ceiling"]')?.click();
await frame();
result.flatIsoActionParity = calls.length === 2
&& JSON.stringify(calls[0]) === JSON.stringify(calls[1]);
original._setProjection('iso');
await original.updateComplete;
const stage = root(original).querySelector('.stage');
const stageRect = stage.getBoundingClientRect();
const zoomBefore = original._zoom;
const pointer = (type, id, x, y, buttons) => new PointerEvent(type, {
bubbles: true, composed: true, pointerId: id, pointerType: 'touch',
clientX: x, clientY: y, button: type === 'pointerdown' ? 0 : -1, buttons,
});
const cy = stageRect.top + stageRect.height / 2;
stage.dispatchEvent(pointer('pointerdown', 8902, stageRect.left + 150, cy, 1));
stage.dispatchEvent(pointer('pointerdown', 8903, stageRect.left + 270, cy, 1));
stage.dispatchEvent(pointer('pointermove', 8902, stageRect.left + 115, cy, 1));
stage.dispatchEvent(pointer('pointermove', 8903, stageRect.left + 305, cy, 1));
stage.dispatchEvent(pointer('pointerup', 8902, stageRect.left + 115, cy, 0));
stage.dispatchEvent(pointer('pointerup', 8903, stageRect.left + 305, cy, 0));
await original.updateComplete;
result.touchPinchKeepsIso = original._zoom > zoomBefore
&& !!root(original).querySelector('[data-hp="iso-walls"]');
const opening = root(original).querySelector('[data-hp="opening"] .op-hit');
opening?.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true }));
result.touchOpeningIsSafe = !original._openingDialog && !original._openingInfo
&& original._mode === 'view' && calls.length === 2;
const device = root(original).querySelector('[data-hp="device"]');
if (device) {
const rect = device.getBoundingClientRect();
device.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
button: 0, buttons: 1,
}));
await wait(700);
result.touchLongPressHitsDevice = !!original._infoCard;
device.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, composed: true, pointerId: 8901, pointerType: 'touch',
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
button: 0, buttons: 0,
}));
original._closeInfoCard();
} else result.touchLongPressHitsDevice = false;
original._pickSpace('garden');
await wait(230);
original._setProjection('iso');
await original.updateComplete;
const gardenIso = original._space === 'garden'
&& root(original).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
original._pickSpace('f1');
await wait(230);
result.touchSpaceSwitchKeepsPerSpaceIso = gardenIso && original._space === 'f1'
&& !!root(original).querySelector('[data-hp="iso-walls"]');
const ownVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
await original.updateComplete;
await frame();
const recoveryStarted = performance.now();
while (root(original).querySelector('.recoveryoverlay')
&& performance.now() - recoveryStarted < 2000) await wait(20);
result.backgroundForegroundKeepsIso = !!root(original).querySelector('[data-hp="iso-walls"]')
&& !root(original).querySelector('.recoveryoverlay');
if (ownVisibilityState) Object.defineProperty(document, 'visibilityState', ownVisibilityState);
else delete document.visibilityState;
const host = document.getElementById('host');
const mount = async (kiosk = false) => {
const card = document.createElement('houseplan-card');
card.setConfig({ type: 'custom:houseplan-card', kiosk });
host.replaceChildren(card);
card.hass = window.__mkHass();
const started = performance.now();
while ((!card._loadOk || card._booting) && performance.now() - started < 9000) await wait(30);
await card.updateComplete;
await frame();
return card;
};
original.remove();
const warm = await mount(false);
result.warmRemountIso = !warm._booting
&& !!root(warm).querySelector('[data-hp="iso-walls"]')
&& root(warm).querySelector('[data-hp="projection-toggle"]')?.getAttribute('aria-pressed') === 'true';
warm.remove();
const kiosk = await mount(true);
result.kioskReadsPreference = !!root(kiosk).querySelector('[data-hp="iso-walls"]');
result.kioskHasNoToggle = !root(kiosk).querySelector('[data-hp="projection-toggle"]');
return result;
});
await page.setViewportSize({ width: 820, height: 430 });
out.orientationResizeKeepsIso = await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
window.dispatchEvent(new Event('resize'));
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const anchor = card.renderRoot.querySelector('[data-hp="device"]')?.getBoundingClientRect();
return !!card.renderRoot.querySelector('[data-hp="iso-walls"]')
&& !!anchor && [anchor.left, anchor.top, anchor.width, anchor.height].every(Number.isFinite);
});
out.kioskEmergencyOffIsFlat = await page.evaluate(async () => {
const card = document.querySelector('houseplan-card');
history.replaceState(null, '', '?hp-labs=-iso#space=f1');
dispatchEvent(new HashChangeEvent('hashchange'));
await card.updateComplete;
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
return !card.renderRoot.querySelector('[data-hp="iso-walls"]')
&& !card.renderRoot.querySelector('[data-hp="projection-toggle"]');
});
checkAll(out);
await finish(browser, out);
File diff suppressed because one or more lines are too long
+46 -46
View File
File diff suppressed because one or more lines are too long
+20
View File
@@ -105,6 +105,23 @@ git config core.untrackedCache true
## Maintenance diagnostics
### Labs presentation flags
Labs is an internal, presentation-only runtime in `src/labs.ts`; it must never
gate config/schema migrations, persistence writes, HA services or network
requests. Enable the current experiment with `?hp-labs=iso` (or
`#hp-labs=iso&space=<id>`), remove it with `-iso`, and clear all flags with
`hp-labs=off`. Known URL operations persist to
`houseplan_card_labs_v1` without rewriting the URL. Active flags are exposed for
diagnostics as the frozen sorted `window.__hpLabs` array.
To add a flag, add one unique lowercase id plus issue, `since`, `expires` and a
non-empty summary to `LABS_FLAGS`, then cover URL/storage ordering, runtime
subscription and expiry. Versions compare the numeric `major.minor.patch` core:
at `expires` (including its first beta) the flag is dead and every activation
path fails closed. Remove or graduate a flag before its expiry cycle; do not
extend an expiry silently. See `docs/ISOMETRIC.md` for the current Stage 1 use.
These commands are read-only diagnostics, not release gates:
```bash
@@ -115,6 +132,9 @@ npm run audit:config -- path/to/houseplan-config.json
# Reproducible synthetic large-house report (seven measured samples + warm-up).
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
# Hidden isometric profile; diagnostic only outside exact-SHA Linux CI.
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
# Golden candidates never overwrite reviewed references.
npm run golden:capture
npm run golden:verify
+92
View File
@@ -0,0 +1,92 @@
# Isometric Stage 1 internals
Issue [#89](https://github.com/Matysh/houseplan-card/issues/89) implements a
hidden, presentation-only volumetric View experiment. The normative contract is
`docs/specs/089-isometric-view-stage1.md`; the fixed rendering decisions are in
`docs/adr/089-isometric-stage1-renderer.md`.
## Activation and lifetime
The feature exists only while the `iso` entry in `src/labs.ts` is live. For the
v1.62 cycle it can be enabled with either `?hp-labs=iso` or
`#hp-labs=iso&space=<id>`. Query operations are applied first and hash operations
second. `-iso` removes this flag and `off` clears every Labs flag. A known URL
operation is persisted in `houseplan_card_labs_v1`; the URL itself is not
rewritten.
The selected presentation is stored per space in `houseplan_card_view_v1`. Flat
is always the initial default. Kiosk has no toggle but reads the saved preference.
Editors and `houseplan-space-card` are always flat.
The current registry entry is live from numeric core `1.62.0` and expires at
`1.65.0`. Thus `1.65.0-beta.1` is already expired. Malformed versions, malformed
entries and duplicate ids fail closed. Labs never gate schemas, migrations,
stores, service calls or network requests.
## Coordinate systems
- Plan points are the existing 1000-unit logical floor coordinates.
- Scene points are coordinates in the effective SVG viewBox.
- HTML devices, vacuum pucks, room labels/cards and lock markers stay
screen-facing. Their anchors use the same `projectPlanPoint()` snapshot as the
SVG floor.
- `clientToScenePoint()` maps a client point to the current scene; floor hit
testing then uses `unprojectFloorPoint()`.
Stage 1 uses a fixed orthographic affine camera:
```text
rotDeg=0, tiltDeg=20, xyScale=1, zScale=1, origin=[500,500]
wallHeight=64 plan units
```
There is no perspective, free rotation or user tilt. Switching projections
preserves scalar zoom and converts the view centre through logical floor space.
`projectedFrame()` includes both floor corners and wall-top corners so fit/home
cannot clip the volume.
## Geometry and composition
`src/iso-projection.ts` owns pure projection math. `src/iso-walls.ts` consumes
the canonical `wallBodiesGeometry()` MultiPolygon after openings and extra
physical bodies have been resolved. It normalizes outer/hole winding, builds one
evenodd top path and at most one visible side per ring edge, then uses a stable
depth/order tie-break. Complexity is O(E) in canonical ring edges.
The content fingerprint includes rooms, wall geometry/thickness, open cuts,
openings, partitions, drafts, columns, scale/grid inputs, camera, wall height and
algorithm version. It deliberately excludes `_cfgEpoch`, HA state, hover and
`show_borders`. The per-card LRU is capped at eight scenes. Pan, zoom and HA-only
updates reuse it.
Composition remains SVG-first:
1. the existing floor SVG and all its current live layers;
2. explicit visible wall sides and wall top;
3. screen-facing HTML overlays.
The floor keeps the same nodes and order for paper/backdrop, room fills/hover,
Glow/spill, sun, decor/furniture, opening symbols and vacuum path/outline. Stage
1 does not add a second light source/layer. Markers and room cards intentionally
remain above walls without geometric occlusion.
## Failure boundary
Projection/topology failures latch flat fallback for the current
`space|fingerprint`. One diagnostic is emitted without config, entity ids or URL
data. The saved iso preference is retained; an explicit iso request retries the
fingerprint, and changed geometry receives a new fingerprint. Flat rendering is
the rollback path and does not depend on the iso cache.
## Deliberate Stage 1 limits
- door, window and gate keep their current floor-plane symbol and live state;
- no vertical leaf/window panels, sill model or new window light;
- no floor-edge extrusion, shadows, photorealistic materials or marker
occlusion;
- no volumetric editor and no volumetric `houseplan-space-card`;
- no YAML/config option or public settings surface.
Golden references are accepted only from the complete reviewed Linux artifact.
The full `large-house-isometric-v1` performance comparison is also canonical on
the exact Linux CI SHA.
+3 -2
View File
@@ -17,12 +17,13 @@ change must pass through a published beta/RC before stable. Stable release
commits are promotion-only (versions, generated bundles and release/changelog
metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
## Snapshot (2026-08-12)
## Snapshot (2026-08-13)
| Item | State |
|---|---|
| Version | **v1.62.0-beta.8** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
| Current local cycle | v1.62.0-beta.8 combines Window / Door / Gate placement under the Opening sub-menu, renders the full 50%-opacity placement preview over physical walls, fixes first-click editor closing and incorporates the agreed beta.7 recursive code-review hardening. Issues #89 and #103 remain specification/backlog-only. |
| Current local cycle | v1.62.0-beta.8 combines Window / Door / Gate placement under the Opening sub-menu, renders the full 50%-opacity placement preview over physical walls, fixes first-click editor closing and incorporates the agreed beta.7 recursive code-review hardening. Issue #103 remains specification/backlog-only. |
| Hidden Labs Stage | #89 Stage 1 implements a fixed near-top orthographic volumetric View behind the expiring `iso` Labs flag. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a released/public feature. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | Prerelease publication requires a green exact-SHA Validate: frontend/backend, smoke (including the #73 rAF frame sampler), golden, HACS/Hassfest and a short absolute-ceiling performance smoke. Obsolete same-ref Validate runs are cancelled. Full seven-sample base/candidate performance moved to `performance.yml` (`main` push, weekly, manual); stable release assets fail closed unless Validate and Full Performance are green for the exact tagged SHA and the stable-only CDP compositor screencast finds no empty/black presented frame. |
+3 -3
View File
@@ -2,9 +2,9 @@
- Issue: https://github.com/Matysh/houseplan-card/issues/89
- Приоритет: **P1**, feature
- Статус ТЗ: **на независимом ревью** (ревизия 3, решения владельца 2026-08-13).
Ревизия 2 предшествует действующему процессу и новым визуальным референсам;
эта редакция обязана получить независимое ревью до `S5-ready`.
- Статус ТЗ: **принято к реализации** (ревизия 3, решения владельца и зелёное
независимое ревью ТЗ 2026-08-13).
Ревизия 2 предшествует действующему процессу и новым визуальным референсам.
- Исследование и продуктовое обоснование: [`089-isometric-view.md`](089-isometric-view.md)
(контекст; нормативен настоящий файл)
- Историческое ревью ревизии 1 не является доступным артефактом репозитория и
+1 -1
View File
@@ -43,7 +43,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#50](https://github.com/Matysh/houseplan-card/issues/50) Экспорт и импорт конфигурации | [050-config-export-import.md](050-config-export-import.md) | реализовано локально; проверки на следующем пре-релизе |
| [#58](https://github.com/Matysh/houseplan-card/issues/58) Vacuum integration coverage — Stage 1 | [058-vacuum-stage1.md](058-vacuum-stage1.md) | в реализации |
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Опциональный объёмный 2.5D/изометрический вид | [089-isometric-view.md](089-isometric-view.md) | исследование завершено; продуктовый контекст |
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) | ТЗ на ревью, ревизия 3 |
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) | ТЗ принято, ревизия 3 |
| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) | реализовано локально; ожидает prerelease/CI gate |
## P2
+1
View File
@@ -12,6 +12,7 @@
"inventory": "node scripts/inventory.mjs",
"audit:config": "node scripts/config-audit.mjs",
"benchmark:large-house": "node demo/benchmark_large_house.mjs",
"benchmark:large-house-isometric": "node demo/benchmark_large_house.mjs --profile=large-house-isometric-v1",
"benchmark:glow": "node demo/benchmark_glow.mjs",
"benchmark:compare": "node demo/performance/compare.mjs",
"golden:capture": "node demo/golden/run.mjs --mode=capture",
+2 -1
View File
@@ -4469,11 +4469,12 @@ class HouseplanCard extends LitElement {
private _effectiveProjection(): 'flat' | 'iso' {
if (this._desiredProjection !== 'iso' || !this._model.length) return 'flat';
const key = this._isoSceneKey() || `${this._space}|invalid`;
if (this._isoFallback.has(key)) return 'flat';
try {
const scene = this._isoScene();
return isoEffectiveView('iso', scene.key, this._isoFallback);
} catch (error) {
const key = this._isoSceneKey() || `${this._space}|invalid`;
if (!this._isoFallback.has(key)) {
this._isoFallback.add(key);
const parts = key.split('|');
+12 -5
View File
@@ -59,16 +59,23 @@ export function validLabsFlag(flag: LabsFlag): boolean {
return !!since && !!expires && compareVersion(since, expires) < 0;
}
/** A malformed or ambiguous registry disables the whole mechanism. */
export function validLabsRegistry(registry: readonly LabsFlag[]): boolean {
const ids = new Set<string>();
for (const flag of registry) {
if (!validLabsFlag(flag) || ids.has(flag.id)) return false;
ids.add(flag.id);
}
return true;
}
export function liveLabsFlags(
version: string, registry: readonly LabsFlag[] = LABS_FLAGS,
): ReadonlyMap<string, LabsFlag> {
const current = parseVersionCore(version);
const live = new Map<string, LabsFlag>();
if (!current) return live;
const ids = new Set<string>();
if (!current || !validLabsRegistry(registry)) return live;
for (const flag of registry) {
if (!validLabsFlag(flag) || ids.has(flag.id)) continue;
ids.add(flag.id);
const since = parseVersionCore(flag.since)!;
const expires = parseVersionCore(flag.expires)!;
if (compareVersion(current, since) >= 0 && compareVersion(current, expires) < 0) {
@@ -110,7 +117,7 @@ export function resolveLabs(
registry: readonly LabsFlag[] = LABS_FLAGS,
): LabsResolution {
const live = liveLabsFlags(version, registry);
const known = new Set(registry.filter(validLabsFlag).map((flag) => flag.id));
const known = new Set(validLabsRegistry(registry) ? registry.map((flag) => flag.id) : []);
const active = new Set(storedFlags(storageValue).filter((id) => live.has(id)));
let knownUrlOperation = false;
const apply = (token: string) => {
+8 -1
View File
@@ -21,6 +21,11 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(Number.isInteger(region.minPixels) && region.minPixels > 0, true, scenario.id);
assert.equal(region.minRedBlueDelta > 0, true, scenario.id);
}
if (scenario.labs) {
assert.deepEqual(scenario.labs, ['iso'], scenario.id);
assert.equal(scenario.projection, 'iso', scenario.id);
assert.equal(scenario.mode, 'view', scenario.id);
}
if (scenario.sunRayPixels) {
assert.equal(Number.isInteger(scenario.sunRayPixels.minPixels)
&& scenario.sunRayPixels.minPixels > 0, true, scenario.id);
@@ -64,7 +69,9 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'fill-light', 'fill-temp', 'fill-lqi', 'lighting', 'hover', 'zoom-040', 'zoom-250',
'warm-remount', 'dialog-mobile', 'color-popover', 'tray-wide', 'tray-medium', 'sun-window',
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
'backup-full', 'backup-space', 'value-badge-positions'])
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount'])
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);
+7 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
LABS_FLAGS, hashSpace, liveLabsFlags, parseVersionCore, resolveLabs, validLabsFlag,
validLabsRegistry,
} from '../test-build/labs.js';
const resolve = (search = '', hash = '', storage = null, version = '1.62.0') =>
@@ -15,6 +16,12 @@ test('registry metadata and numeric-core lifetime fail closed', () => {
assert.equal(liveLabsFlags('1.65.0').has('iso'), false);
assert.equal(liveLabsFlags('not-a-version').size, 0);
assert.equal(validLabsFlag({ ...LABS_FLAGS[0], since: '1.65.0' }), false);
assert.equal(validLabsRegistry([LABS_FLAGS[0], { ...LABS_FLAGS[0] }]), false);
assert.equal(liveLabsFlags('1.62.0', [LABS_FLAGS[0], { ...LABS_FLAGS[0] }]).size, 0);
assert.deepEqual(resolveLabs(
{ search: '?hp-labs=iso', hash: '' }, '["iso"]', '1.62.0',
[LABS_FLAGS[0], { ...LABS_FLAGS[0] }],
).active, []);
});
test('URL operations are ordered query then hash with off and removal semantics', () => {
@@ -48,4 +55,3 @@ test('malformed and unavailable storage are safe', () => {
assert.deepEqual(resolve('', '', '{bad').active, []);
assert.deepEqual(resolve('?hp-labs=iso', '', undefined).active, ['iso']);
});