mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D. Issue: #663 User-Visible: yes
1291 lines
64 KiB
JavaScript
1291 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 requiresStairs = existsSync(resolve(targetRoot, 'src/stairs.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, requiresStairs, 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();
|
|
if (requiresStairs
|
|
&& card.renderRoot.querySelectorAll('[data-hp="stair"]').length !== fixture.counts.stairs) {
|
|
throw new Error(`${profile} did not render the bounded maximum stair collection`);
|
|
}
|
|
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, requiresStairs, 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);
|
|
}
|