Files
houseplan-card/demo/benchmark_large_house.mjs
T
2026-09-26 03:41:32 +03:00

1286 lines
64 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',
].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 isometric = profile === 'large-house-isometric-v1' || stage3Dense;
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 fixture = stage3Dense ? makeIsometricStage3DenseFixture() : makeLargeHouseFixture();
const fixtureCounts = stage3Dense ? 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, profile,
}) => {
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,
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 < 1) failures.push('dense fixture produced no bounded nudge');
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();
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 ? card._scenePoint([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;
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));
}
});
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,
},
} : {}),
...(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, profile,
});
// #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);
}