Files
houseplan-card/demo/benchmark_large_house.mjs
Claude f5b20e07f6 perf(floor): key the floor-geometry caches by the floor's content (#744)
The physical bodies, the wall union pool, the inner room contours and the
clean floor carried the global config epoch in their keys. Every edit of
any floor bumps it, so after one edit every other floor was cold again:
in large-house the first visit to an untouched floor rebuilt its wall
union and paid ~0.7 s flat / ~0.65 s 2.5D instead of ~40-55 ms.

A floor's geometry reads only its own config record (spaceModels) and
constants, so the key is now a content fingerprint of that record
(src/floor-geometry-key.ts), remembered per epoch and per record object.
The geometry also reads the current floor's config next to the model it
is given; when those records differ the key covers both. The live resize
preview is its own record, so preview frames get their own key; the
editor runtime seeds the pool and re-keys the bodies through the same
reader. The stairs editor no longer clears the clean floors of every
floor: the stairs are part of the floor's record.

The #735 switch-cycle guard now also sees the union pool and the inner
contours (optional members of the large-house card contract, so an older
comparison bundle reads 0). smoke_floor_geometry_cache proves the warm other floor and the
invalidation against an independent card (multi-floor push with shared
walls, a stair, a resize preview and its cancel); two mutants guard it.

Issue: #744
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-02 00:56:04 +03:00

1365 lines
69 KiB
JavaScript

#!/usr/bin/env node
/** Reproducible browser benchmark and report producer for HP-PERF-01. */
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, 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';
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
import { ensureHarnessEditorRuntime } from './editor-runtime-compat.mjs';
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
import {
ISOMETRIC_STAGE3_DENSE_PROFILE,
makeIsometricStage3DenseFixture,
} from './performance/isometric-stage3-dense-fixture.mjs';
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
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', ISOMETRIC_STAGE3_DENSE_PROFILE,
'large-house-plan-snap-v1', 'large-house-interaction-v1', 'large-house-isometric-backdrop-v1',
].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const stage3Dense = profile === ISOMETRIC_STAGE3_DENSE_PROFILE;
const allowStage2Base = process.argv.includes('--allow-stage2-base');
if (allowStage2Base && !stage3Dense)
throw new Error('--allow-stage2-base is valid only for the Stage 3 dense profile');
const requireStage3 = stage3Dense && !allowStage2Base;
const backdrop = profile === 'large-house-isometric-backdrop-v1';
const isometric = profile === 'large-house-isometric-v1' || stage3Dense || backdrop;
const planSnap = profile === 'large-house-plan-snap-v1';
const interaction = profile === 'large-house-interaction-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresIsoStructuralBuildCounter = requiresIsometric
&& readFileSync(resolve(targetRoot, 'src/houseplan-card.ts'), 'utf8')
.includes('private _isoStructuralBuildCount');
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const requiresWallFace = planSnap && existsSync(resolve(targetRoot, 'src/wall-face-graph.ts'));
const requiresInteraction = interaction && existsSync(resolve(targetRoot, 'src/live-viewport.ts'));
const requiresStairs = existsSync(resolve(targetRoot, 'src/stairs.ts'));
const fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
if (backdrop) {
// #743: the 2.5D twin with a backdrop image on every floor, so the imagePlan
// path (#739 K1) is measured at all. One shipped picture (demo/srv/assets
// since v1.14.0, present in every base) under a URL of its own per floor, so
// each floor decodes its own image; plan_aspect 1 lays it over the whole
// plan square and leaves the room geometry alone. The variant lives here, as
// plan-snap's does: demo/fixtures/** and the bundle fingerprint stay as they are.
for (const space of fixture.config.spaces) {
space.plan_url = `/assets/f1.svg?${space.id}`;
space.plan_aspect = 1;
}
fixture.counts = { ...fixture.counts, backdrops: fixture.config.spaces.length };
}
const fixtureCounts = stage3Dense || backdrop ? fixture.counts : LARGE_HOUSE_COUNTS;
const sourceSha = (() => {
try {
return execFileSync('git', ['-C', targetRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {
return null;
}
})();
if (isometric && !sourceSha)
throw new Error(`${profile} requires an exact git source SHA`);
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.partitions ||= [];
for (const chain of [0, 1]) {
const y = 0.985 + chain * 0.025;
space.partitions.push(
{ id: `perf-chain-${floor}-${chain}-0`, a: [0.10, y], b: [0.38, y], cm: 15 },
{ id: `perf-chain-${floor}-${chain}-1`, a: [0.38, y], b: [0.46, y + 0.035], cm: 20 },
);
}
}
fixture.counts = { ...fixture.counts, chainSegments: 12, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
viewport,
1,
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
{},
resolve(targetRoot, 'demo/srv'),
targetRoot,
);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
});
await page.addScriptTag({
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
});
await page.addScriptTag({
content: `window.__hpEnsureHarnessEditorRuntime = ${ensureHarnessEditorRuntime.toString()};`,
});
const chromium = await browser.version();
let buildFingerprint;
try {
buildFingerprint = await assertFreshDemoBundle(page, targetRoot);
} catch (error) {
await browser.close();
throw error;
}
const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
requiresWallFace, interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
while (!predicate()) {
if (performance.now() - started > timeout) throw new Error('large-house benchmark timed out');
await new Promise((done) => setTimeout(done, 10));
}
};
const startLongTaskWindow = () => {
const entries = [];
if (!PerformanceObserver.supportedEntryTypes?.includes('longtask')) {
return { stop: async () => ({ supported: false, count: 0, maxMs: 0, totalMs: 0 }) };
}
const observer = new PerformanceObserver((list) => entries.push(...list.getEntries()));
observer.observe({ type: 'longtask', buffered: false });
return {
stop: async () => {
await new Promise((done) => setTimeout(done, 0));
entries.push(...observer.takeRecords());
observer.disconnect();
const durations = entries.map((entry) => entry.duration);
return {
supported: true,
count: durations.length,
maxMs: Number((durations.length ? Math.max(...durations) : 0).toFixed(2)),
totalMs: Number(durations.reduce((sum, value) => sum + value, 0).toFixed(2)),
};
},
};
};
const duration = async (action) => {
const longTasks = startLongTaskWindow();
const started = performance.now();
await action();
await frame();
return {
ms: Number((performance.now() - started).toFixed(2)),
longTasks: await longTasks.stop(),
};
};
// Current candidates split the lazy 2.5D runtime out of the initial graph. Comparison
// bundles remain valid: their older monolithic renderer has no preload
// hook and is already available synchronously.
const ensureIsoRuntime = async (card) => {
if (typeof card._ensureIsoSceneRuntime !== 'function') return;
if (!(await card._ensureIsoSceneRuntime()))
throw new Error(`${profile} isometric runtime did not load`);
await card.updateComplete;
};
const forceGc = async () => {
if (typeof globalThis.gc !== 'function') return false;
globalThis.gc();
await frame();
globalThis.gc();
await frame();
return true;
};
const cacheSnapshot = (card) => ({
cleanFloor: card._cleanFloorCache?.size ?? 0,
glowClip: card._glowClipCache?.size ?? 0,
wallUnion: card._wallUnionCache ? 1 : 0,
// #744: the union is pooled per floor, and the inner contours are the
// second structural cache of a floor visit; 0/1 above cannot see a
// cold floor whose union is rebuilt while its clean floor stays warm.
wallUnionPool: card._wallUnionPool?.size ?? 0,
innerContour: card._innerContourCache?.size ?? 0,
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
wallFaceGraph: card._wallFaceGraphCache?.length ?? 0,
});
const isoStructuralBuildCount = (card) => Number.isFinite(card._isoStructuralBuildCount)
? card._isoStructuralBuildCount : null;
const stage3Snapshot = (card) => {
const stage = card.renderRoot.querySelector('.stage');
// Count the interactive HTML owners, not the diagnostic SVG
// ground/tether/plate primitives which deliberately repeat kind/id.
const overlayRoots = [...card.renderRoot.querySelectorAll(
'[data-hp-iso-overlay-kind][data-hp-iso-floor][data-hp-iso-visual]',
)];
const overlaySelectors = {
device: '[data-hp="device"]',
'room-label': '[data-hp="room-label"]',
'opening-lock': '.oplock:not(.iso-tile-shadow)',
};
const totalRootsByKind = Object.fromEntries(Object.entries(overlaySelectors)
.map(([kind, selector]) => [kind, card.renderRoot.querySelectorAll(selector).length]));
const stage3RootsByKind = Object.fromEntries(Object.keys(overlaySelectors)
.map((kind) => [kind, overlayRoots.filter((root) =>
root.getAttribute('data-hp-iso-overlay-kind') === kind).length]));
const openingSurfaceCounts = Object.fromEntries(
fixture.stage3Dense.expectedOpeningKinds.map((kind) => [kind,
card.renderRoot.querySelectorAll(`.iso-opening-panel[data-kind="${kind}"]`).length,
]),
);
const vacuumRoots = [...card.renderRoot.querySelectorAll('.vacpuck, .vactrail')];
const materialDefinitions = card.renderRoot.querySelectorAll('[data-hp-iso-material-def]');
const definitionCounts = {
total: materialDefinitions.length,
patterns: card.renderRoot.querySelectorAll('pattern[data-hp-iso-material-def]').length,
filters: card.renderRoot.querySelectorAll('filter[data-hp-iso-material-def]').length,
};
const pulseDeviceId = fixture.stage3Dense?.pulseDeviceIdsBySpace?.[card._space] || '';
const pulseRoot = card.renderRoot.querySelector(
`.dev[data-id="${CSS.escape(pulseDeviceId)}"]`,
);
const facetCounts = {
value: card.renderRoot.querySelectorAll('.dev .value-badge').length,
lqi: card.renderRoot.querySelectorAll('.dev .lqi').length,
new: card.renderRoot.querySelectorAll('.dev .newdot').length,
pulse: pulseRoot?.querySelectorAll('.device-pulse, .activity-dot').length ?? 0,
roomMetrics: card.renderRoot.querySelectorAll('.roomlabel .rlmetrics').length,
};
const rawBuilds = stage?.getAttribute('data-hp-iso-structural-builds');
const structuralBuilds = rawBuilds == null ? null : Number(rawBuilds);
return {
effectiveProjection: typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null,
isoStageRevision: stage?.getAttribute('data-hp-iso-stage') ?? null,
structuralBuilds: Number.isFinite(structuralBuilds) ? structuralBuilds : null,
renderedOverlayCount: overlayRoots.length,
raisedOverlayCount: overlayRoots
.filter((root) => root.getAttribute('data-hp-iso-raised') === 'true').length,
nudgedOverlayCount: overlayRoots
.filter((root) => root.getAttribute('data-hp-iso-nudged') === 'true').length,
raisedVacuumCount: vacuumRoots.filter((root) =>
root.getAttribute('data-hp-iso-raised') === 'true'
|| root.hasAttribute('data-hp-iso-overlay-kind')).length,
overlayKinds: [...new Set(overlayRoots
.map((root) => root.getAttribute('data-hp-iso-overlay-kind'))
.filter(Boolean))].sort(),
totalRootsByKind,
stage3RootsByKind,
openingSurfaceCounts,
materialDefinitionCount: definitionCounts.total,
definitionCounts,
facetCounts,
pulseDiagnostics: pulseRoot ? {
id: pulseRoot.getAttribute('data-id'),
state: pulseRoot.getAttribute('data-state'),
className: pulseRoot.className,
ariaLabel: pulseRoot.getAttribute('aria-label'),
} : null,
};
};
const assertStage3Snapshot = (snapshot, label) => {
if (!requireStage3) return;
const expectedKinds = fixture.stage3Dense.expectedOverlayKinds;
const missingKinds = expectedKinds.filter((kind) => !snapshot.overlayKinds.includes(kind));
const failures = [];
if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');
if (snapshot.isoStageRevision !== '4') failures.push('Stage 4 revision marker is absent');
if (snapshot.structuralBuilds == null) failures.push('structural build counter is absent');
if (snapshot.renderedOverlayCount < 1) failures.push('no raised overlay roots rendered');
if (snapshot.raisedOverlayCount !== snapshot.renderedOverlayCount)
failures.push('one or more Stage 4 overlay roots remained outside the low visual plane');
if (snapshot.nudgedOverlayCount !== 0)
failures.push('raised overlay nudged although #713 lifts every tile by one rise');
if (snapshot.raisedVacuumCount !== 0) failures.push('floor-bound vacuum was marked as raised');
if (missingKinds.length) failures.push(`missing overlay kinds: ${missingKinds.join(', ')}`);
for (const kind of expectedKinds) {
const total = snapshot.totalRootsByKind[kind];
const stage3 = snapshot.stage3RootsByKind[kind];
if (total < 1) failures.push(`no rendered ${kind} roots`);
else if (stage3 !== total)
failures.push(`${kind} Stage 4 roots ${stage3}/${total}`);
}
for (const kind of fixture.stage3Dense.expectedOpeningKinds) {
if (!(snapshot.openingSurfaceCounts[kind] > 0))
failures.push(`no Stage 4 ${kind} opening surfaces`);
}
for (const [facet, count] of Object.entries(snapshot.facetCounts)) {
if (!(count > 0)) failures.push(`no observable ${facet} facet${facet === 'pulse'
? ` (${JSON.stringify(snapshot.pulseDiagnostics)})` : ''}`);
}
if (snapshot.materialDefinitionCount < 1
|| snapshot.materialDefinitionCount > fixture.stage3Dense.materialDefinitionLimit) {
failures.push(`material definitions ${snapshot.materialDefinitionCount}`
+ ` exceed 1..${fixture.stage3Dense.materialDefinitionLimit}`);
}
if (!(snapshot.definitionCounts.patterns > 0)) failures.push('no shared texture pattern');
if (!(snapshot.definitionCounts.filters > 0)) failures.push('no shared shadow filter');
if (failures.length)
throw new Error(`Stage 4 ${label} contract failed: ${failures.join('; ')}`);
};
window.__card?.remove?.();
localStorage.clear();
if (isometric) {
localStorage.setItem('houseplan_card_alpha_v1', '1');
localStorage.setItem('houseplan_card_view_v1', JSON.stringify(Object.fromEntries(
fixture.config.spaces.map((space) => [space.id, 'iso']),
)));
history.replaceState(null, '', '?hp_alpha=1');
// #649: current bundles read the installation setting; the alpha
// storage above stays for comparison bundles before it.
fixture.config.settings = { ...(fixture.config.settings || {}), volumetric_view: true };
} else history.replaceState(null, '', location.pathname);
const host = document.getElementById('host');
const card = document.createElement('houseplan-card');
// #520 diagnostics: where the extra half second of model readiness goes.
// Counts Lit update cycles and model builds; printed, never budgeted.
card.__diag = { updates: 0, updateMs: 0, models: 0, adopts: 0, epochs: [] };
// Every `_cfgEpoch` bump with the frame that made it: the extra epoch is
// what rebuilds the model a second time (#520).
let diagEpoch = 0;
Object.defineProperty(card, '_cfgEpoch', {
configurable: true,
get: () => diagEpoch,
set: (next) => {
if (next !== diagEpoch) {
const frames = (new Error().stack || '').split('\n').slice(1, 4)
.map((line) => line.trim().replace(/^at\s+/, '').replace(/\s*\(.*$/, ''));
card.__diag.epochs.push(`${diagEpoch}->${next}@${frames.join('<')}`);
}
diagEpoch = next;
},
});
const diagPerform = card.performUpdate.bind(card);
card.performUpdate = function () {
const started = performance.now();
const result = diagPerform();
card.__diag.updates += 1;
card.__diag.updateMs += performance.now() - started;
return result;
};
const diagBuild = card._buildModel.bind(card);
card._buildModel = function () { card.__diag.models += 1; return diagBuild(); };
if (typeof card._adoptStructuralResponses === 'function') {
const legacy = card._adoptStructuralResponses.bind(card);
card._adoptStructuralResponses = function (...args) { card.__diag.adopts += 1; return legacy(...args); };
} else if (typeof card._adoptAuthoritative === 'function') {
const gated = card._adoptAuthoritative.bind(card);
card._adoptAuthoritative = function (...args) { card.__diag.adopts += 1; return gated(...args); };
}
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
let wsCalls = 0;
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
};
const hassBase = {
language: 'en', locale: { language: 'en' },
user: { id: 'perf', name: 'Performance fixture', is_admin: true },
devices: fixture.devices, entities: fixture.entities, areas: fixture.areas,
floors: {
one: { floor_id: 'one', name: 'One', level: 0 },
two: { floor_id: 'two', name: 'Two', level: 1 },
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
wsCalls++;
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: structuredClone(fixture.layout), rev: 1 };
if (message.type === 'config/device_registry/list') return Object.values(fixture.devices);
if (message.type === 'config/entity_registry/list') return Object.values(fixture.entities);
if (message.type === 'config_entries/get')
return [{ entry_id: 'perf_entry', domain: 'houseplan_perf', title: 'Synthetic performance fixture' }];
if (message.type === 'manifest/list') return [{ domain: 'houseplan_perf', name: 'House Plan Performance' }];
return { ok: true };
},
callService: async () => undefined,
connection,
localize: () => null,
formatEntityState: (state) => state.state,
config: { unit_system: { length: 'km' } },
};
const hassFor = (states) => ({ ...hassBase, states });
const loadLongTasks = startLongTaskWindow();
const loadStarted = performance.now();
host.replaceChildren(card);
if (requiresIsometric && window.__hpAlpha !== true) {
if (typeof card._onLabsSnapshot !== 'function')
throw new Error('large-house-isometric-v1 candidate has no Labs fixture hook');
// Comparison bundles before #448 do not understand hp_alpha. Preserve
// the cross-version benchmark only for that base; current candidates
// must activate through the real URL/storage contract above.
card._onLabsSnapshot({ alpha: true, active: Object.freeze(['iso']), space: '' });
}
card.hass = hassFor(fixture.states);
// launch() warms its own bootstrap card, but each measured sample creates
// a fresh card. Current lazy bundles therefore need their own controller
// construction; monolithic stable bundles remain immediate (#380).
if (!await window.__hpEnsureHarnessEditorRuntime(card))
throw new Error('large-house editor runtime did not preload');
if (isometric) await ensureIsoRuntime(card);
window.__hpAssertCardContract(card, cardContract);
if (requiresIsometric && (typeof card._effectiveProjection !== '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();
const modelReadyMs = Number((performance.now() - loadStarted).toFixed(2));
await until(() => card._booting === false);
if (interaction && '_bootSoft' in card) await until(() => card._bootSoft === false);
await frame();
if (requiresStairs
&& card.renderRoot.querySelectorAll('[data-hp="stair"]').length !== fixture.counts.stairs) {
throw new Error(`${profile} did not render the bounded maximum stair collection`);
}
// #743 precondition (docs/TESTING.md rule 3): the fixture carries the
// layer the profile guards, so a stable frame without the backdrop fails.
if (backdrop && !card.renderRoot.querySelector('.stage svg image.hp-backdrop'))
throw new Error(`${profile} rendered no backdrop image`);
const firstStableRenderMs = Number((performance.now() - loadStarted).toFixed(2));
const bootDiag = {
updates: card.__diag.updates,
updateMs: Number(card.__diag.updateMs.toFixed(1)),
models: card.__diag.models,
adopts: card.__diag.adopts,
cfgEpoch: card._cfgEpoch,
epochs: card.__diag.epochs.slice(0, 8),
};
const initialProjection = typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null;
if (requiresIsometric && initialProjection !== 'iso')
throw new Error(`${profile} entered Flat fallback instead of effective Iso`);
const initialIsoFingerprint = card.renderRoot
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
if (requiresIsometric && !initialIsoFingerprint)
throw new Error(`${profile} has no rendered Iso geometry fingerprint`);
const initialIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
if (requiresIsoStructuralBuildCounter && initialIsoStructuralBuilds == null)
throw new Error(`${profile} has no structural build counter`);
const initialStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (initialStage3) assertStage3Snapshot(initialStage3, 'initial render');
const loadLongTaskResult = await loadLongTasks.stop();
let fullRenderCount = 0;
let diagnosticsScanCount = 0;
let physicalPreflightCount = 0;
let physicalPreflightMs = 0;
const fullRenderReasons = [];
if (interaction) {
const renderBody = card._renderBody.bind(card);
card._renderBody = (...args) => { fullRenderCount++; return renderBody(...args); };
const bindingStatus = card._bindingStatus.bind(card);
card._bindingStatus = (...args) => { diagnosticsScanCount++; return bindingStatus(...args); };
const checkSpacePhysicalGeometry = card._checkSpacePhysicalGeometry.bind(card);
card._checkSpacePhysicalGeometry = (...args) => {
physicalPreflightCount++;
const started = performance.now();
try { return checkSpacePhysicalGeometry(...args); }
finally { physicalPreflightMs += performance.now() - started; }
};
const willUpdate = card.willUpdate.bind(card);
card.willUpdate = (changed) => {
fullRenderReasons.push([...changed.keys()].map(String));
return willUpdate(changed);
};
}
const viewToggle = isometric ? await duration(async () => {
if (typeof card._setProjection === 'function') {
// Comparison bundles before #649: the per-device alpha preference.
card._setProjection('flat');
await card.updateComplete;
card._setProjection('iso');
await ensureIsoRuntime(card);
await card.updateComplete;
} else if (typeof card._syncVolumetricSetting === 'function') {
// #649: the installation-wide General settings switch, as a save applies it.
const cfg = card._serverCfg;
card._serverCfg = { ...cfg, settings: { ...(cfg.settings || {}), volumetric_view: false } };
await card.updateComplete;
card._serverCfg = { ...cfg, settings: { ...(cfg.settings || {}), volumetric_view: true } };
await ensureIsoRuntime(card);
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;
});
const steadyStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (steadyStage3) assertStage3Snapshot(steadyStage3, 'steady floor');
const steadyIsoFingerprint = card.renderRoot
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
if (requiresIsometric && !steadyIsoFingerprint)
throw new Error(`${profile} lost Iso geometry after the space switch`);
const steadyIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
if (requiresIsoStructuralBuildCounter && steadyIsoStructuralBuilds == null)
throw new Error(`${profile} lost its structural build counter after the space switch`);
const firstEntity = stage3Dense
? fixture.stage3Dense.stateEntityId
: Object.keys(fixture.states)[0];
const nextStates = {
...fixture.states,
[firstEntity]: { ...fixture.states[firstEntity], state: fixture.states[firstEntity].state === 'on' ? 'off' : 'on' },
};
const stateUpdate = await duration(async () => {
card.hass = hassFor(nextStates);
await card.updateComplete;
});
const afterStateStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (afterStateStage3) assertStage3Snapshot(afterStateStage3, 'HA-only update');
const afterStateFingerprint = card.renderRoot
.querySelector('[data-hp="iso-walls"]')?.getAttribute('data-fingerprint') ?? null;
if (requiresIsometric && afterStateFingerprint !== steadyIsoFingerprint)
throw new Error(`${profile} rebuilt Iso geometry for an HA-only state update`);
const afterStateIsoStructuralBuilds = isometric ? isoStructuralBuildCount(card) : null;
if (requiresIsoStructuralBuildCounter
&& afterStateIsoStructuralBuilds !== steadyIsoStructuralBuilds) {
throw new Error(`${profile} performed a structural rebuild for an HA-only state update`);
}
if (requireStage3 && afterStateStage3.structuralBuilds !== steadyStage3.structuralBuilds)
throw new Error(`${profile} rebuilt Stage 4 structure for an HA-only state update`);
let stage3States = nextStates;
const openingUpdate = stage3Dense ? await duration(async () => {
const entityId = fixture.stage3Dense.contactEntityId;
const current = stage3States[entityId];
stage3States = {
...stage3States,
[entityId]: { ...current, state: current.state === 'on' ? 'off' : 'on' },
};
card.hass = hassFor(stage3States);
await card.updateComplete;
}) : null;
const afterOpeningStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (afterOpeningStage3) assertStage3Snapshot(afterOpeningStage3, 'opening update');
if (stage3Dense && requiresIsometric && afterOpeningStage3?.effectiveProjection !== 'iso')
throw new Error(`${profile} entered Flat fallback during an opening update`);
if (requireStage3 && afterOpeningStage3.structuralBuilds !== steadyStage3.structuralBuilds)
throw new Error(`${profile} rebuilt Stage 4 structure for an opening update`);
const overlayInteraction = stage3Dense ? await duration(async () => {
const overlay = card.renderRoot.querySelector(requireStage3
? '[data-hp-iso-overlay-kind="device"][data-hp-iso-floor][data-hp-iso-visual]'
: '[data-hp="device"]');
if (!overlay) throw new Error(`${profile} dense fixture rendered no interactive device`);
overlay.dispatchEvent(new PointerEvent('pointerover', {
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
}));
overlay.focus?.();
await card.updateComplete;
await frame();
overlay.dispatchEvent(new PointerEvent('pointerout', {
bubbles: true, composed: true, pointerId: 885, pointerType: 'mouse', isPrimary: true,
}));
overlay.blur?.();
await card.updateComplete;
}) : null;
const afterInteractionStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (afterInteractionStage3)
assertStage3Snapshot(afterInteractionStage3, 'hover/focus update');
if (stage3Dense && requiresIsometric && afterInteractionStage3?.effectiveProjection !== 'iso')
throw new Error(`${profile} entered Flat fallback during a hover/focus update`);
if (requireStage3
&& afterInteractionStage3.structuralBuilds !== steadyStage3.structuralBuilds) {
throw new Error(`${profile} rebuilt Stage 4 structure for a hover/focus update`);
}
let interactionDiagnostics = null;
let interactionTimings = null;
let interactionLongTasks = null;
const interactionSeries = interaction ? await duration(async () => {
const deltas = {};
const timings = {};
interactionLongTasks = {};
const diagnosticsBefore = diagnosticsScanCount;
const reasonsBefore = fullRenderReasons.length;
const beforeIrrelevant = fullRenderCount;
const intakeBefore = card._hassSequence;
const irrelevantStarted = performance.now();
let currentStates = nextStates;
for (let index = 0; index < 30; index++) {
currentStates = {
...currentStates,
'sensor.performance_unrelated': {
entity_id: 'sensor.performance_unrelated', state: `${sample}:${index}`,
},
};
card.hass = hassFor(currentStates);
}
await card.updateComplete;
await frame();
timings.irrelevantHaTicksMs = performance.now() - irrelevantStarted;
deltas.irrelevantFullRenders = fullRenderCount - beforeIrrelevant;
deltas.irrelevantHassIntakes = card._hassSequence - intakeBefore;
const relevantBefore = fullRenderCount;
const relevantStarted = performance.now();
currentStates = {
...currentStates,
[firstEntity]: {
...currentStates[firstEntity],
state: currentStates[firstEntity].state === 'on' ? 'off' : 'on',
},
};
card.hass = hassFor(currentStates);
await card.updateComplete;
await frame();
timings.relevantHaTickMs = performance.now() - relevantStarted;
deltas.relevantOutsideGestureFullRenders = fullRenderCount - relevantBefore;
const stage = card.renderRoot.querySelector('.stage');
const device = card.renderRoot.querySelector('[data-hp="device"]');
const room = card.renderRoot.querySelector('[data-hp="room"]');
const heavy = card.renderRoot.querySelector('.wallbodies');
if (!stage || !device || !room || !heavy)
throw new Error('interaction profile has no stage/device/room/heavy scene');
const rect = stage.getBoundingClientRect();
const hoverBefore = fullRenderCount;
const hoverLongTasks = startLongTaskWindow();
const hoverStarted = performance.now();
device.dispatchEvent(new PointerEvent('pointerover', {
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
device.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 100 + index, clientY: rect.top + 100 + index / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) {
await card.updateComplete;
await frame();
}
}
const liveTip = card.renderRoot.querySelector('[data-hp-live-tip]');
deltas.tooltipVisible = !!liveTip && !liveTip.hidden && !!liveTip.textContent?.trim();
device.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
room.dispatchEvent(new PointerEvent('pointerenter', {
clientX: rect.left + 300, clientY: rect.top + 300,
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
room.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 300 + index, clientY: rect.top + 300 + index / 2,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
}
room.dispatchEvent(new PointerEvent('pointerleave', {
bubbles: false, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
for (let index = 0; index < 40; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 20 + index, clientY: rect.top + 20,
bubbles: true, composed: true, pointerId: 991, pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 20 === 0) { await card.updateComplete; await frame(); }
}
await card.updateComplete;
timings.hoverSeriesMs = performance.now() - hoverStarted;
deltas.hoverFullRenders = fullRenderCount - hoverBefore;
deltas.hoverTargets = ['device', 'room', 'miss'];
interactionLongTasks.hoverSeries = await hoverLongTasks.stop();
const cameraSvg = card.renderRoot.querySelector('[data-hp-live-viewbox="camera"], [data-hp-live-viewbox="floor"], .zoomwrap > svg');
const initialViewBox = cameraSvg?.getAttribute('viewBox') || '';
const panLongTasks = startLongTaskWindow();
const panStarted = performance.now();
const panMoveRenders = [];
const panTerminalRenders = [];
let relevantDuringGestureFullRenders = 0;
let relevantDuringGestureIntakes = 0;
for (let dragIndex = 0; dragIndex < 4; dragIndex++) {
const pointerId = 992 + dragIndex;
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 200, clientY: rect.top + 200, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
await card.updateComplete;
const panMoveBefore = fullRenderCount;
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 205 + index * 3, clientY: rect.top + 202 + index,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId, pointerType: 'mouse', isPrimary: true,
}));
if (dragIndex === 0 && index === 10) {
const deferredBefore = fullRenderCount;
const deferredIntakeBefore = card._hassSequence;
for (let tick = 0; tick < 3; tick++) {
currentStates = {
...currentStates,
[firstEntity]: { ...currentStates[firstEntity], state: `gesture-${tick}` },
};
card.hass = hassFor(currentStates);
}
await card.updateComplete;
relevantDuringGestureFullRenders += fullRenderCount - deferredBefore;
relevantDuringGestureIntakes += card._hassSequence - deferredIntakeBefore;
}
if ((index + 1) % 10 === 0) {
await card.updateComplete;
await frame();
}
}
const beforeTerminal = fullRenderCount;
panMoveRenders.push(beforeTerminal - panMoveBefore);
stage.dispatchEvent(new PointerEvent('pointerup', {
clientX: rect.left + 265, clientY: rect.top + 222, button: 0, buttons: 0,
bubbles: true, composed: true, pointerId, pointerType: 'mouse', isPrimary: true,
}));
await card.updateComplete;
await frame();
panTerminalRenders.push(fullRenderCount - beforeTerminal);
}
timings.panSeriesMs = performance.now() - panStarted;
interactionLongTasks.panSeries = await panLongTasks.stop();
const movedViewBox = cameraSvg?.getAttribute('viewBox') || '';
const currentView = card._viewOr(card._baseVb());
const currentDevice = card._devices.find((item) => item.id === device.dataset.id);
const position = currentDevice ? card._pos(currentDevice) : null;
const scene = position ? [position.x, position.y] : null;
const deviceRect = device.getBoundingClientRect();
const expectedX = scene ? rect.left + ((scene[0] - currentView.x) / currentView.w) * rect.width : 0;
const expectedY = scene ? rect.top + ((scene[1] - currentView.y) / currentView.h) * rect.height : 0;
const overlayErrorPx = scene ? Math.hypot(
deviceRect.left + deviceRect.width / 2 - expectedX,
deviceRect.top + deviceRect.height / 2 - expectedY,
) : Infinity;
const cameraLongTasks = startLongTaskWindow();
const cameraStarted = performance.now();
const cameraBefore = fullRenderCount;
const pinchPointerA = 1001, pinchPointerB = 1002;
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 360, clientY: rect.top + 300, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId: pinchPointerA,
pointerType: 'touch', isPrimary: true,
}));
stage.dispatchEvent(new PointerEvent('pointerdown', {
clientX: rect.left + 460, clientY: rect.top + 300, button: 0, buttons: 1,
bubbles: true, composed: true, pointerId: pinchPointerB,
pointerType: 'touch', isPrimary: false,
}));
await card.updateComplete;
const pinchMoveBefore = fullRenderCount;
for (let index = 0; index < 20; index++) {
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 360 - index, clientY: rect.top + 300,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId: pinchPointerA, pointerType: 'touch', isPrimary: true,
}));
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + 460 + index, clientY: rect.top + 300,
button: 0, buttons: 1, bubbles: true, composed: true,
pointerId: pinchPointerB, pointerType: 'touch', isPrimary: false,
}));
if ((index + 1) % 5 === 0) await frame();
}
const pinchTerminalBefore = fullRenderCount;
for (const pointerId of [pinchPointerA, pinchPointerB]) {
stage.dispatchEvent(new PointerEvent('pointerup', {
clientX: rect.left + 410, clientY: rect.top + 300,
button: 0, buttons: 0, bubbles: true, composed: true,
pointerId, pointerType: 'touch', isPrimary: pointerId === pinchPointerA,
}));
}
await card.updateComplete;
await frame();
const pinchTerminalRenders = fullRenderCount - pinchTerminalBefore;
for (let index = 0; index < 4; index++) {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: index % 2 ? 120 : -120,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
}
await until(() => !card._cameraTransition.active);
await card.updateComplete;
await frame();
timings.cameraSeriesMs = performance.now() - cameraStarted;
interactionLongTasks.cameraSeries = await cameraLongTasks.stop();
deltas.cameraMoveFullRenders = pinchTerminalBefore - pinchMoveBefore;
deltas.cameraTerminalFullRenders = pinchTerminalRenders;
deltas.cameraFullRenders = fullRenderCount - cameraBefore;
const editorLongTaskWindows = [];
const editorConfigBefore = JSON.stringify(card._serverCfg);
const editorCallsBefore = wsCalls;
const editorPaintBefore = card._liveEditorPaintCount || 0;
let editorElapsed = 0;
const editorMoveRenders = [];
const editorTerminalRenders = [];
card._setMode('plan', false);
card._tool = 'draw';
card._path = [[100, 100]];
card.requestUpdate();
await card.updateComplete;
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
for (let index = 0; index < 5; index++) await frame();
let editorStage = card.renderRoot.querySelector('.stage');
let editorRect = editorStage.getBoundingClientRect();
let editorView = card._viewOr(card._baseVb());
const fromScene = (x, y) => ({
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
});
let editorPartLongTasks = startLongTaskWindow();
let partStarted = performance.now();
let editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
editorStage.dispatchEvent(new PointerEvent('pointermove', {
...fromScene(110 + index * 2, 110 + index),
bubbles: true, composed: true, pointerId: 1101,
pointerType: 'mouse', isPrimary: true,
}));
if ((index + 1) % 10 === 0) await frame();
}
let editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
card._cursorPt = null;
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
card._tool = 'resize';
card.requestUpdate();
await card.updateComplete;
const resizeRoom = card._rszRooms()[0];
const resizeEvent = {
pointerId: 1102, stopPropagation: () => undefined,
preventDefault: () => undefined, target: null,
};
card._rszEdgeDown(resizeEvent, resizeRoom.id, 1);
const resizePlan = card._resize?.plan || card._rszDrag?.plan;
if (!resizePlan) throw new Error('interaction resize plan was not created');
const resizePreflightBefore = physicalPreflightCount;
const resizePreflightMsBefore = physicalPreflightMs;
editorStage = card.renderRoot.querySelector('.stage');
editorRect = editorStage.getBoundingClientRect();
editorView = card._viewOr(card._baseVb());
// A browser delivers pointerdown and pointermove as separate tasks.
// Keep the synthetic harness from attributing both handlers to one
// impossible long task while still timing every move-side preflight.
await new Promise((done) => setTimeout(done, 0));
editorPartLongTasks = startLongTaskWindow();
partStarted = performance.now();
editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
const target = [
resizePlan.a[0] + resizePlan.n[0] * card._gridPitch * (1 + index / 40),
resizePlan.a[1] + resizePlan.n[1] * card._gridPitch * (1 + index / 40),
];
card._rszMove({
...resizeEvent,
clientX: editorRect.left + ((target[0] - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((target[1] - editorView.y) / editorView.h) * editorRect.height,
});
if ((index + 1) % 10 === 0) await frame();
}
editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
deltas.resizeLivePreflightChecks = physicalPreflightCount - resizePreflightBefore;
deltas.resizeLivePreflightMs = physicalPreflightMs - resizePreflightMsBefore;
card._rszCancelDrag();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
card._setMode('decor', false);
card._decorTool = 'select';
const decorShape = card._decorList.find((shape) =>
shape.kind === 'furniture' || shape.kind === 'rect' || shape.kind === 'ellipse');
if (!decorShape) throw new Error('interaction profile has no transformable decor');
card._decorSel = decorShape.id;
card.requestUpdate();
await card.updateComplete;
await until(() => !card._modeTransitionBusy && card._continuity.state === 'steady');
for (let index = 0; index < 5; index++) await frame();
card._dtMeasure();
await card.updateComplete;
editorStage = card.renderRoot.querySelector('.stage');
editorRect = editorStage.getBoundingClientRect();
editorView = card._viewOr(card._baseVb());
const box = card._decorBoxOf(decorShape);
const cornerX = box.x + box.w, cornerY = box.y + box.h;
const decorPointer = (x, y) => ({
pointerId: 1103, pointerType: 'mouse', shiftKey: false,
clientX: editorRect.left + ((x - editorView.x) / editorView.w) * editorRect.width,
clientY: editorRect.top + ((y - editorView.y) / editorView.h) * editorRect.height,
stopPropagation: () => undefined, preventDefault: () => undefined,
currentTarget: null, target: null,
});
card._dtStart(decorPointer(cornerX, cornerY), 'scale', [1, 1]);
await card.updateComplete;
await frame();
editorPartLongTasks = startLongTaskWindow();
partStarted = performance.now();
editorMoveBefore = fullRenderCount;
for (let index = 0; index < 40; index++) {
card._dtMove(decorPointer(cornerX + index, cornerY + index / 2));
if ((index + 1) % 10 === 0) await frame();
}
editorTerminalBefore = fullRenderCount;
editorMoveRenders.push(editorTerminalBefore - editorMoveBefore);
card._cancelDecorGesture();
await card.updateComplete;
await frame();
editorTerminalRenders.push(fullRenderCount - editorTerminalBefore);
editorElapsed += performance.now() - partStarted;
editorLongTaskWindows.push(await editorPartLongTasks.stop());
timings.editorSeriesMs = editorElapsed;
interactionLongTasks.editorSeries = {
supported: editorLongTaskWindows.every((window) => window.supported),
count: editorLongTaskWindows.reduce((sum, window) => sum + window.count, 0),
maxMs: Math.max(0, ...editorLongTaskWindows.map((window) => window.maxMs)),
totalMs: editorLongTaskWindows.reduce((sum, window) => sum + window.totalMs, 0),
};
deltas.editorMoveFullRenders = editorMoveRenders;
deltas.editorTerminalFullRenders = editorTerminalRenders;
deltas.editorLivePaints = (card._liveEditorPaintCount || 0) - editorPaintBefore;
deltas.editorConfigStable = JSON.stringify(card._serverCfg) === editorConfigBefore;
deltas.editorWsWrites = wsCalls - editorCallsBefore;
deltas.editorLongTaskParts = editorLongTaskWindows;
card._setMode('view', false);
await card.updateComplete;
await frame();
deltas.panMoveFullRenders = panMoveRenders;
deltas.terminalFullRenders = panTerminalRenders;
deltas.relevantDuringGestureFullRenders = relevantDuringGestureFullRenders;
deltas.relevantDuringGestureIntakes = relevantDuringGestureIntakes;
deltas.viewBoxMoved = !!initialViewBox && initialViewBox !== movedViewBox;
deltas.overlayErrorPx = Number(overlayErrorPx.toFixed(2));
deltas.heavyNodeStable = card.renderRoot.querySelector('.wallbodies') === heavy;
deltas.diagnosticsScans = diagnosticsScanCount - diagnosticsBefore;
deltas.fullRenderReasons = fullRenderReasons.slice(reasonsBefore);
interactionTimings = timings;
interactionDiagnostics = { supported: requiresInteraction, ...deltas };
if (requiresInteraction && (
deltas.irrelevantFullRenders !== 0 || deltas.irrelevantHassIntakes !== 30
|| deltas.relevantOutsideGestureFullRenders !== 1 || deltas.hoverFullRenders !== 0
|| deltas.hoverTargets.join(',') !== 'device,room,miss'
|| deltas.panMoveFullRenders.some((count) => count !== 0)
|| deltas.terminalFullRenders.some((count) => count !== 1)
|| deltas.relevantDuringGestureFullRenders !== 0 || !deltas.tooltipVisible
|| deltas.relevantDuringGestureIntakes !== 3
|| deltas.cameraMoveFullRenders !== 0 || deltas.cameraTerminalFullRenders !== 1
|| deltas.editorMoveFullRenders.some((count) => count !== 0)
|| deltas.editorTerminalFullRenders.some((count) => count > 1)
|| deltas.resizeLivePreflightChecks < 1
|| deltas.editorLivePaints < 3 || !deltas.editorConfigStable || deltas.editorWsWrites !== 0
|| !deltas.viewBoxMoved || deltas.overlayErrorPx > 1 || !deltas.heavyNodeStable
|| deltas.diagnosticsScans !== 0
)) throw new Error(`interaction structural contract failed: ${JSON.stringify(interactionDiagnostics)}`);
}) : null;
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage');
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
const cacheValue = card._planSnapGeometryCache?.value ?? null;
const configBefore = JSON.stringify(card._serverCfg);
const callsBefore = wsCalls;
const wallFaceCacheBeforePointer = card._wallFaceGraphCache?.length ?? 0;
const view = card._viewOr(card._baseVb());
const rect = stage.getBoundingClientRect();
const fromPlan = (x, y) => ({
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
});
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
.map((line) => ({
line,
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
}))
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
const points = [
firstEndpoint
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
: [40, 40],
longLine
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
: [120, 40],
[10, 10],
];
const seenKinds = new Set();
for (let index = 0; index < 120; index++) {
const point = points[index % points.length];
stage.dispatchEvent(new PointerEvent('pointermove', {
...fromPlan(point[0], point[1]),
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
}));
await card.updateComplete;
const active = card.renderRoot.querySelector(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
);
if (active) seenKinds.add(active.getAttribute('data-kind'));
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
}
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
planSnapDiagnostics = {
supported: requiresPlanSnap,
staticLines,
staticNodes,
activeKinds: [...seenKinds].sort(),
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
=== staticNodes,
configStable: JSON.stringify(card._serverCfg) === configBefore,
wsWrites: wsCalls - callsBefore,
wallFaceCacheStableOnPointer:
(card._wallFaceGraphCache?.length ?? 0) === wallFaceCacheBeforePointer,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !planSnapDiagnostics.wallFaceCacheStableOnPointer
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
if (requiresWallFace) {
const oldPath = card._path;
const oldChainId = card._activeWallChainId;
const oldPartitionIds = card._activeWallChainPartitionIds;
const oldCms = card._wallChainSegmentCms;
const beforePath = [[10, 10]];
card._path = [[10, 10], [20, 10]];
card._activeWallChainId = 'perf-face-chain';
card._activeWallChainPartitionIds = ['perf-face-segment'];
card._wallChainSegmentCms = [15];
const acceptedStarted = performance.now();
card._offerWallFaces(beforePath);
planSnapDiagnostics.wallFaceAcceptedClickMs = performance.now() - acceptedStarted;
planSnapDiagnostics.wallFaceCacheEntries = card._wallFaceGraphCache?.length ?? 0;
card._wallFaceBatch = null;
card._roomDialog = false;
card._path = oldPath;
card._activeWallChainId = oldChainId;
card._activeWallChainPartitionIds = oldPartitionIds;
card._wallChainSegmentCms = oldCms;
if (planSnapDiagnostics.wallFaceAcceptedClickMs > 1000
|| planSnapDiagnostics.wallFaceCacheEntries < 1
|| planSnapDiagnostics.wallFaceCacheEntries > 4) {
throw new Error(`wall-face accepted-click contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
}
}
card._setMode('view');
await card.updateComplete;
}) : null;
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
await card.updateComplete;
const room = card._rszRooms()[0];
const pointerId = 777;
const quietEvent = {
pointerId,
stopPropagation: () => undefined,
preventDefault: () => undefined,
target: null,
};
card._rszEdgeDown(quietEvent, room.id, 1);
// #380: the previous stable owns the active session directly; current
// candidates own it through ResizeController. The explicit contract
// above requires one of these shapes before measurements begin.
const plan = card._resize?.plan || card._rszDrag?.plan;
if (!plan) throw new Error('large-house resize plan was not created');
const target = [
plan.a[0] + plan.n[0] * card._gridPitch,
plan.a[1] + plan.n[1] * card._gridPitch,
];
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
card._rszMove({
...quietEvent,
clientX: rect.left + ((target[0] - view.x) / view.w) * rect.width,
clientY: rect.top + ((target[1] - view.y) / view.h) * rect.height,
});
await card.updateComplete;
card._rszCancelDrag();
card._setMode('view');
await card.updateComplete;
});
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const panZoom = await duration(async () => {
stage.dispatchEvent(new WheelEvent('wheel', {
deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
bubbles: true, cancelable: true,
}));
await card.updateComplete;
});
const settingsDialog = await duration(async () => {
card._openSettingsDialog();
await card.updateComplete;
});
card._settingsDialog = null;
await card.updateComplete;
// #735: every sample mounts a new card, and until here it has visited
// only floors 1 and 2. The cycle would otherwise pay a first visit to
// floor 3 (and, after the interaction editor series moves the config
// epoch, to floor 1 again) inside its window, so one cold floor build
// dominated a metric documented as warmed navigation. Visit every
// fixture floor once in cycle order outside every timed and Long Task
// window, then return to floor 2 so the cycle still starts with 2 -> 1.
for (let floor = 1; floor <= fixture.counts.floors; floor++) {
card._pickSpace(`perf-floor-${floor}`);
await card.updateComplete;
await frame();
}
card._pickSpace('perf-floor-2');
await card.updateComplete;
await frame();
const switchCycleCachesBefore = cacheSnapshot(card);
const switchCycleBuildsBefore = isoStructuralBuildCount(card);
const switchCycle = await duration(async () => {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
// A user cannot produce twelve tab clicks in one JavaScript task.
// Yield between interactions so Long Task entries describe one
// switch, while switchCycleMs still measures the complete cycle.
await new Promise((done) => setTimeout(done, 0));
}
});
// #735 guard: a warmed cycle builds nothing. Any grown hot cache or a
// structural 2.5D build inside the window means a cold floor visit
// leaked back into switchCycleMs. Caches an older base lacks read as 0
// and cannot grow; its build counter is null and is not judged.
const switchCycleCachesAfter = cacheSnapshot(card);
const switchCycleBuildsAfter = isoStructuralBuildCount(card);
const switchCycleBuilt = Object.keys(switchCycleCachesAfter)
.filter((key) => switchCycleCachesAfter[key] > switchCycleCachesBefore[key])
.map((key) => `${key} +${switchCycleCachesAfter[key] - switchCycleCachesBefore[key]}`);
if (switchCycleBuildsBefore != null && switchCycleBuildsAfter !== switchCycleBuildsBefore)
switchCycleBuilt.push(`isoStructuralBuilds +${switchCycleBuildsAfter - switchCycleBuildsBefore}`);
if (switchCycleBuilt.length)
throw new Error(`${profile} switchCycle built a floor inside the window: ${switchCycleBuilt.join(', ')}`);
// #743 structural probe, untimed and outside every Long Task window: six
// warm switches round the floors (the cycle ended on the last floor, so
// each one is a real switch). Each counts the update passes from the
// pick until updateComplete reports quiescence (resolves true). Counting
// right after the first await would miss the #739 K1 second pass: it
// starts after that promise resolves, in the same task. The wait is
// bounded so an endless update loop yields a large count instead of a
// hung job. evaluate.mjs judges the candidate; a base is only reported.
const floorSwitchPasses = [];
if (backdrop) {
for (let index = 0; index < 6; index++) {
const passesBefore = card.__diag.updates;
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
for (let wait = 0; wait < 20 && !(await card.updateComplete); wait++);
floorSwitchPasses.push(card.__diag.updates - passesBefore);
}
}
await forceGc();
const cacheBefore = cacheSnapshot(card);
const heapBefore = performance.memory?.usedJSHeapSize ?? null;
for (let round = 0; round < 4; round++) {
for (let index = 0; index < 12; index++) {
card._pickSpace(`perf-floor-${(index % fixture.counts.floors) + 1}`);
await card.updateComplete;
await new Promise((done) => setTimeout(done, 0));
}
await forceGc();
}
const cacheEntries = cacheSnapshot(card);
const heapAfter = performance.memory?.usedJSHeapSize ?? null;
const cacheGrowth = Object.fromEntries(
Object.keys(cacheEntries).map((key) => [key, cacheEntries[key] - cacheBefore[key]]),
);
const finalProjection = typeof card._effectiveProjection === 'function'
? card._effectiveProjection() : null;
if (requiresIsometric && finalProjection !== 'iso')
throw new Error(`${profile} ended the sample in Flat fallback`);
const finalStage3 = stage3Dense ? stage3Snapshot(card) : null;
if (finalStage3) assertStage3Snapshot(finalStage3, 'final warmed state');
const result = {
sample,
bootDiag,
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(openingUpdate ? { openingUpdateMs: openingUpdate.ms } : {}),
...(overlayInteraction ? { overlayInteractionMs: overlayInteraction.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
...(interactionSeries ? {
interactionSeriesMs: interactionSeries.ms,
...interactionTimings,
interactionDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
panZoomMs: panZoom.ms,
settingsDialogMs: settingsDialog.ms,
switchCycleMs: switchCycle.ms,
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(openingUpdate ? { openingUpdate: openingUpdate.longTasks } : {}),
...(overlayInteraction ? { overlayInteraction: overlayInteraction.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
...(interactionSeries ? interactionLongTasks || {} : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
panZoom: panZoom.longTasks,
settingsDialog: settingsDialog.longTasks,
switchCycle: switchCycle.longTasks,
},
cacheEntries,
cacheGrowth,
heapGrowthBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
preciseGc: typeof globalThis.gc === 'function',
renderedDevices: card._devices?.length ?? 0,
effectiveProjection: finalProjection,
...(isometric ? {
isoStructuralBuilds: {
supported: steadyIsoStructuralBuilds != null && afterStateIsoStructuralBuilds != null,
initial: initialIsoStructuralBuilds,
beforeHaUpdate: steadyIsoStructuralBuilds,
afterHaUpdate: afterStateIsoStructuralBuilds,
haUpdateDelta: steadyIsoStructuralBuilds == null || afterStateIsoStructuralBuilds == null
? null : afterStateIsoStructuralBuilds - steadyIsoStructuralBuilds,
},
} : {}),
...(backdrop ? { floorSwitchPasses: { supported: true, perSwitch: floorSwitchPasses } } : {}),
...(finalStage3 ? {
isoStageRevision: finalStage3.isoStageRevision,
stage3Diagnostics: {
...finalStage3,
steadyUpdateStructuralBuildDelta: requireStage3
? afterInteractionStage3.structuralBuilds - steadyStage3.structuralBuilds
: null,
},
} : {}),
};
card.remove();
await frame();
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric, planSnap, requiresPlanSnap, requiresWallFace,
interaction, requiresInteraction, stage3Dense, requireStage3,
requiresIsoStructuralBuildCounter, requiresStairs, profile, backdrop,
});
// #520: диагностика печатается в лог прогона и в запись не попадает.
const { bootDiag, ...measured } = row;
if (bootDiag) {
console.log(`#520 diag sample ${row.sample}: updates=${bootDiag.updates}`
+ ` updateMs=${bootDiag.updateMs} models=${bootDiag.models}`
+ ` adopts=${bootDiag.adopts} cfgEpoch=${bootDiag.cfgEpoch}`
+ ` epochs=[${(bootDiag.epochs || []).join(' ; ')}]`
+ ` modelReady=${measured.modelReadyMs} firstStable=${measured.firstStableRenderMs}`);
}
if (measuredSample >= 0) rows.push(measured);
}
} finally {
await browser.close();
}
const metricNames = [
'modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs', 'stateUpdateMs',
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (stage3Dense) metricNames.splice(3, 0, 'openingUpdateMs', 'overlayInteractionMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
if (interaction) metricNames.splice(2, 0,
'interactionSeriesMs', 'hoverSeriesMs', 'panSeriesMs', 'cameraSeriesMs',
'editorSeriesMs', 'irrelevantHaTicksMs', 'relevantHaTickMs');
const report = {
schema: 2,
profile,
sourceSha,
generatedAt: new Date().toISOString(),
buildFingerprint,
runtime: {
node: process.version,
chromium,
platform: process.platform,
arch: process.arch,
viewport,
deviceScaleFactor: 1,
reducedMotion: true,
},
fixture: fixtureCounts,
samples,
warmups,
summary: summarizeTimings(rows, metricNames),
longTasks: summarizeLongTasks(rows),
effectiveProjection: [...new Set(rows.map((row) => row.effectiveProjection))],
...(stage3Dense ? {
isoStageRevision: [...new Set(rows.map((row) => row.isoStageRevision))],
stage3Required: requireStage3,
} : {}),
rows,
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`;
if (output) {
mkdirSync(dirname(output), { recursive: true });
writeFileSync(output, text, 'utf8');
console.log(output);
} else {
process.stdout.write(text);
}