Files
houseplan-card/test/performance-budget.test.mjs
T
Claude 51e2f2b510 test(perf): pull the switchCycleMs ceilings down to the warmed level (#747)
Since #735 switchCycleMs times the warmed twelve-switch cycle, and the
absolute ceilings of 7000 ms (flat) and 8000 ms (2.5D) sat 7.6-10.2
times above the level. performance_smoke judges only these ceilings, so
between full runs the warm floor switch that #694/#725 just sped up was
guarded only against a several-fold collapse.

Series: every Full Performance run after #735, both sides (the base is
measured by the candidate runner, so it is warm too), 7 samples each -
36821241343 (#735, base 76558bf2), 36838891001 (#740), 36838952536
(#742) and 36839009721 (#739), the last three against dev 7ff2b5ae.

  flat  large-house-v1 / plan-snap / interaction   666.9-812.7 ms
  2.5D  large-house-isometric / stage3-dense       766.5-1333.9 ms

The 2.5D maximum is the dense pair of 36838952536, whose base on the
same runner read 1249.7 ms against 849.9-982.4 ms elsewhere: runner
noise the series is meant to contain. No 3-sample performance_smoke
median is in the series yet; those profiles join Validate only on a
src/** diff.

Rule (as #692, #675 falls in the same band): one number per family, the
first multiple of 50 ms at or above 1.15 x M and no higher than 1.2 x M,
M being the family's maximum median: flat 1.15 x 812.7 = 934.6 -> 950
(+16.9 %), 2.5D 1.15 x 1333.9 = 1534.0 -> 1550 (+16.2 %). One number
per family keeps the smoke = full (#473 AC4), plan-snap/interaction
"every original ceiling" and dense = historical (#160) contracts; the
price is wider headroom for the faster profiles. The base-relative
comparison of the full workflow (0.35 / 0.2, 250 ms) is unchanged and
stays the detector for smaller growth.

The new test pins both families: one ceiling in every file of a family,
every point of the series passes the smoke budget with --absolute-only,
the ceiling follows the rule and stays inside [1.15, 1.2] x M, doubling
the level fails, and the full profiles' ratio and noise allowance are
unchanged. 7000 left in any flat file or a ceiling under 1.15 x M reds
it. The README records the series and the reasoning.

Issue: #747
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 13:54:35 +03:00

438 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { evaluatePerformanceBudget } from '../demo/performance/evaluate.mjs';
const budgets = {
schema: 1,
profile: 'large-house-v1',
minimumSamples: 2,
timings: {
firstStableRenderMs: {
stat: 'median', maxRegressionRatio: 0.25, noiseAllowanceMs: 10, hardMaxMs: 500,
},
},
longTasks: {
maxSingleMs: 200, maxCountP95: 10, maxTotalP95Ms: 500,
maxSingleRegressionRatio: 0.5, maxSingleNoiseAllowanceMs: 20,
maxCountRegressionRatio: 0.5, countNoiseAllowance: 2,
maxTotalRegressionRatio: 0.5, noiseAllowanceMs: 20,
},
heap: {
required: true, hardMaxGrowthBytes: 1000, maxRegressionRatio: 0.5, noiseAllowanceBytes: 100,
},
cacheEntries: { cleanFloor: 3 },
cacheGrowth: { cleanFloor: 0 },
renderedDevices: 200,
};
const report = ({ timing = 100, heap = 100, cache = 3, growth = 0, long = 20 } = {}) => ({
schema: 2,
profile: 'large-house-v1',
sourceSha: '1'.repeat(40),
buildFingerprint: `fixture-${timing}`,
runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' },
fixture: { rooms: 60 },
summary: { firstStableRenderMs: { median: timing, p95: timing, min: timing, max: timing } },
longTasks: { maxSingleMs: long, countP95: 1, totalP95Ms: long },
rows: [0, 1].map(() => ({
heapGrowthBytes: heap,
preciseGc: true,
longTasks: { load: { supported: true, count: 1, maxMs: long, totalMs: long } },
cacheEntries: { cleanFloor: cache },
cacheGrowth: { cleanFloor: growth },
renderedDevices: 200,
})),
});
test('performance budget accepts a candidate inside relative and absolute limits', () => {
const result = evaluatePerformanceBudget({
baseline: report(), candidate: report({ timing: 120, heap: 150 }), budgets,
});
assert.equal(result.pass, true);
assert.deepEqual(result.failures, []);
});
test('performance budget rejects a timing regression even below the hard ceiling', () => {
const result = evaluatePerformanceBudget({
baseline: report(), candidate: report({ timing: 150 }), budgets,
});
assert.equal(result.pass, false);
assert.ok(result.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'));
});
test('performance budget rejects long tasks, heap growth, cache growth and missing devices', () => {
const candidate = report({ heap: 2000, cache: 4, growth: 1, long: 300 });
candidate.rows.forEach((row) => { row.renderedDevices = 199; });
const result = evaluatePerformanceBudget({ baseline: report(), candidate, budgets });
assert.equal(result.pass, false);
assert.deepEqual(
new Set(result.failures.map((check) => check.id)),
new Set([
'longTask.maxSingleMs',
'longTask.totalP95Ms',
'heap.growthP95Bytes',
'cache.entries.cleanFloor',
'cache.growth.cleanFloor',
'renderedDevices',
]),
);
});
test('performance budget compares single/count Long Tasks to the same-runner baseline', () => {
const baseline = report({ long: 100 });
baseline.longTasks.countP95 = 4;
const candidate = report({ long: 130 });
candidate.longTasks.countP95 = 6;
const result = evaluatePerformanceBudget({ baseline, candidate, budgets });
assert.equal(result.pass, true);
assert.equal(result.checks.find((check) => check.id === 'longTask.maxSingleMs')?.baseline, 100);
assert.equal(result.checks.find((check) => check.id === 'longTask.countP95')?.baseline, 4);
});
test('performance budget refuses incomparable runtime profiles', () => {
const candidate = report();
candidate.runtime.chromium = 'different';
assert.throws(
() => evaluatePerformanceBudget({ baseline: report(), candidate, budgets }),
/runtime mismatch for chromium/,
);
});
test('isometric reports fail closed on exact SHA, effective projection and current Stage 4 metadata', () => {
const isoBudgets = { ...budgets, profile: 'isometric-stage3-dense-v1' };
const valid = report();
Object.assign(valid, {
profile: isoBudgets.profile,
effectiveProjection: ['iso'],
isoStageRevision: ['4'],
stage3Required: true,
});
valid.rows.forEach((row) => Object.assign(row, {
effectiveProjection: 'iso', isoStageRevision: '4',
isoStructuralBuilds: {
supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0,
},
}));
const baseline = structuredClone(valid);
baseline.sourceSha = '2'.repeat(40);
baseline.stage3Required = false;
assert.equal(evaluatePerformanceBudget({
baseline, candidate: valid, budgets: isoBudgets,
baselineSha: baseline.sourceSha, candidateSha: valid.sourceSha,
}).pass, true);
for (const mutate of [
(candidate) => { candidate.sourceSha = null; },
(candidate) => { candidate.effectiveProjection = ['flat']; },
(candidate) => { candidate.rows[0].effectiveProjection = 'flat'; },
(candidate) => { candidate.stage3Required = false; },
(candidate) => { candidate.isoStageRevision = ['2']; },
(candidate) => { candidate.rows[0].isoStageRevision = '2'; },
(candidate) => { candidate.rows[0].isoStructuralBuilds.haUpdateDelta = 1; },
(candidate) => { delete candidate.rows[0].isoStructuralBuilds; },
]) {
const candidate = structuredClone(valid);
mutate(candidate);
assert.throws(() => evaluatePerformanceBudget({
baseline, candidate, budgets: isoBudgets,
}), /sourceSha|effectiveProjection|Stage 4|structural build count/);
}
assert.throws(() => evaluatePerformanceBudget({
baseline, candidate: valid, budgets: isoBudgets, candidateSha: 'f'.repeat(40),
}), /sourceSha does not match/);
});
test('absolute performance smoke needs no baseline and enforces hard ceilings', () => {
const accepted = evaluatePerformanceBudget({
candidate: report({ timing: 450, heap: 900, long: 190 }), budgets, absoluteOnly: true,
});
assert.equal(accepted.pass, true);
assert.equal(accepted.mode, 'absolute');
assert.equal(accepted.checks.find((check) => check.id === 'timing.firstStableRenderMs.median')?.baseline, undefined);
const rejected = evaluatePerformanceBudget({
candidate: report({ timing: 501 }), budgets, absoluteOnly: true,
});
assert.equal(rejected.pass, false);
assert.ok(rejected.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'));
});
test('named interaction windows enforce their own absolute Long Task limits', () => {
const windowBudgets = {
...budgets,
longTaskWindows: {
editorSeries: { maxSingleMs: 150, maxCountP95: 3, maxTotalP95Ms: 300 },
},
};
const candidate = report();
candidate.rows[0].longTasks.editorSeries = {
supported: true, count: 3, maxMs: 149, totalMs: 299,
};
candidate.rows[1].longTasks.editorSeries = {
supported: true, count: 4, maxMs: 151, totalMs: 301,
};
const result = evaluatePerformanceBudget({ baseline: report(), candidate, budgets: windowBudgets });
assert.deepEqual(
new Set(result.failures.map((check) => check.id)),
new Set([
'longTask.editorSeries.maxSingleMs',
'longTask.editorSeries.countP95',
'longTask.editorSeries.totalP95Ms',
]),
);
});
// #473 AC4: smoke-бюджеты диффозависимых профилей повторяют абсолютные потолки
// полных профилей и пригодны для `compare --absolute-only` на трёх образцах.
import { readFileSync } from 'node:fs';
const readBudget = (name) => JSON.parse(readFileSync(new URL(`../demo/performance/${name}`, import.meta.url), 'utf8'));
// Синтетический отчёт для `compare --absolute-only`: все метрики по 1 мс,
// кроме переданных в `timings`.
const absoluteSmokeReport = (smoke, timings = {}) => ({
schema: 2, profile: smoke.profile, sourceSha: '1'.repeat(40), buildFingerprint: 'fixture',
runtime: { node: 'v22.0.0', chromium: '1.2.3', platform: 'linux', arch: 'x64' },
fixture: { rooms: 60 },
summary: Object.fromEntries(Object.keys(smoke.timings).map((metric) => {
const value = timings[metric] ?? 1;
return [metric, { median: value, p95: value, min: value, max: value }];
})),
longTasks: { maxSingleMs: 1, countP95: 1, totalP95Ms: 1 },
...(smoke.profile === 'large-house-isometric-v1' ? { effectiveProjection: ['iso'] } : {}),
rows: [0, 1, 2].map(() => ({
heapGrowthBytes: 1, preciseGc: true,
longTasks: Object.fromEntries(['load', ...Object.keys(smoke.longTaskWindows ?? {})]
.map((name) => [name, { supported: true, count: 1, maxMs: 1, totalMs: 1 }])),
cacheEntries: { ...smoke.cacheEntries },
cacheGrowth: Object.fromEntries(Object.keys(smoke.cacheGrowth).map((key) => [key, 0])),
renderedDevices: smoke.renderedDevices,
...(smoke.profile === 'large-house-isometric-v1'
? {
effectiveProjection: 'iso',
isoStructuralBuilds: { supported: true, initial: 1, beforeHaUpdate: 2, afterHaUpdate: 2, haUpdateDelta: 0 },
} : {}),
})),
});
for (const [smokeName, fullName] of [
['budgets-isometric-smoke.json', 'budgets-large-house-isometric.json'],
['budgets-interaction-smoke.json', 'budgets-large-house-interaction.json'],
]) {
test(`${smokeName} повторяет hardMaxMs полного профиля и держит 3 образца (#473 AC4)`, () => {
const smoke = readBudget(smokeName);
const full = readBudget(fullName);
assert.equal(smoke.profile, full.profile);
assert.equal(smoke.minimumSamples, 3);
assert.deepEqual(Object.keys(smoke.timings), Object.keys(full.timings), 'набор метрик тот же');
for (const [metric, budget] of Object.entries(smoke.timings)) {
assert.equal(budget.hardMaxMs, full.timings[metric].hardMaxMs, `${metric}: потолок отличается от полного`);
assert.equal(budget.stat, full.timings[metric].stat);
// Регрессионных коэффициентов в смоке нет: сравнивать не с чем (§5).
assert.equal(budget.maxRegressionRatio, undefined, `${metric}: в смоке нет относительных лимитов`);
}
assert.equal(smoke.longTasks.maxSingleMs, full.longTasks.maxSingleMs);
assert.equal(smoke.longTasks.maxCountP95, full.longTasks.maxCountP95);
assert.equal(smoke.longTasks.maxTotalP95Ms, full.longTasks.maxTotalP95Ms);
assert.deepEqual(smoke.longTaskWindows, full.longTaskWindows);
assert.equal(smoke.heap.hardMaxGrowthBytes, full.heap.hardMaxGrowthBytes);
assert.deepEqual(smoke.cacheEntries, full.cacheEntries);
assert.deepEqual(smoke.renderedDevices, full.renderedDevices);
// Пригодность для --absolute-only: синтетический отчёт под потолками
// проходит, первый кадр как у de215578 (9 870 мс) — красный.
const ok = evaluatePerformanceBudget({ candidate: absoluteSmokeReport(smoke), budgets: smoke, absoluteOnly: true });
assert.deepEqual(ok.failures, [], 'отчёт под потолками обязан проходить');
const regressed = evaluatePerformanceBudget({
candidate: absoluteSmokeReport(smoke, { firstStableRenderMs: 9870 }), budgets: smoke, absoluteOnly: true,
});
assert.ok(regressed.failures.some((check) => check.id === 'timing.firstStableRenderMs.median'),
'первый кадр 9 870 мс обязан краснеть');
});
}
test('#692 первый стабильный кадр interaction: потолок над уровнем 1.78 с запасом на шум раннера', () => {
const smoke = readBudget('budgets-interaction-smoke.json');
const full = readBudget('budgets-large-house-interaction.json');
const ceiling = full.timings.firstStableRenderMs.hardMaxMs;
assert.equal(ceiling, 3400);
assert.equal(smoke.timings.firstStableRenderMs.hardMaxMs, ceiling, 'смок = полный профиль (#473 AC4)');
// Медианы полного профиля (7 образцов) из performance.yml: линия 1.77 и v1.78.0.
const level177 = [2769.0, 2838.2, 2777.3, 2801.7];
const level178 = [2925.0, 2925.2];
const worst = 3144.8; // тот же SHA v1.78.0, соседний прогон — шум раннера до 7,5 %
for (const median of [...level177, ...level178, worst]) assert.ok(median < ceiling, `${median} краснеет на ${ceiling}`);
assert.ok(ceiling >= Math.max(...level178) * 1.15, 'запас над уровнем 1.78 не меньше 15 %');
assert.ok(ceiling <= Math.max(...level178) * 1.2, 'и не больше 20 %: потолок остаётся гардом');
// Относительное сравнение полного профиля не ослаблено.
assert.equal(full.timings.firstStableRenderMs.maxRegressionRatio, 0.3);
assert.equal(full.timings.firstStableRenderMs.noiseAllowanceMs, 250);
});
test('interaction aggregate keeps hosted-runner headroom without weakening component gates (#483)', () => {
const smoke = readBudget('budgets-interaction-smoke.json');
const full = readBudget('budgets-large-house-interaction.json');
assert.equal(smoke.timings.interactionSeriesMs.hardMaxMs, 3300);
assert.equal(full.timings.interactionSeriesMs.hardMaxMs, 3300);
assert.equal(smoke.timings.hoverSeriesMs.hardMaxMs, 500);
assert.equal(smoke.timings.panSeriesMs.hardMaxMs, 500);
assert.equal(smoke.timings.cameraSeriesMs.hardMaxMs, 500);
assert.equal(smoke.timings.editorSeriesMs.hardMaxMs, 750);
});
// #675: обоснование потолка и ряд замеров — demo/performance/README.md,
// «CI contracts». Три файла держат одно число: смок повторяет полный профиль
// (#473 AC4), плотный двойник — исторический (#160).
test('isometric space switch ceiling covers the 2.5D runner level and still catches #583 (#675)', () => {
const files = [
'budgets-isometric-smoke.json',
'budgets-large-house-isometric.json',
'budgets-isometric-stage3-dense.json',
];
for (const file of files)
assert.equal(readBudget(file).timings.spaceSwitchMs.hardMaxMs, 2200, `${file}: общий потолок spaceSwitchMs`);
const smoke = readBudget('budgets-isometric-smoke.json');
const red = (ms) => evaluatePerformanceBudget({
candidate: absoluteSmokeReport(smoke, { spaceSwitchMs: ms }), budgets: smoke, absoluteOnly: true,
}).failures.some((check) => check.id === 'timing.spaceSwitchMs.median');
assert.equal(red(1867.7), false, 'максимум уровня 2.5D на hosted-раннере (прогон 36306131709) проходит');
assert.equal(red(2719.6), true, 'провал #583 до решётки (прогон 35070397356) краснеет и по spaceSwitchMs');
assert.equal(red(2 * 1798.3), true, 'удвоение текущего уровня краснеет');
// Потолок — не детектор тренда: полный прогон по-прежнему сравнивает с базой
// того же раннера, и рычагом не стали ни коэффициент, ни допуск.
for (const file of files.slice(1)) {
const budget = readBudget(file).timings.spaceSwitchMs;
assert.equal(budget.maxRegressionRatio, 0.2, `${file}: коэффициент не рычаг`);
assert.equal(budget.noiseAllowanceMs, 100, `${file}: допуск не рычаг`);
}
});
// #747: обоснование потолков `switchCycleMs` и ряд — demo/performance/README.md,
// «CI contracts». Ряд — медианы Full Performance после #735 (7 образцов, обе
// стороны: база меряется раннером кандидата, окно тёплое) в порядке
// [база, кандидат] прогонов 36821241343 (#735, база 76558bf2), 36838891001
// (#740), 36838952536 (#742), 36839009721 (#739); у трёх последних база —
// `dev` 7ff2b5ae. Смоковых медиан (3 образца) в ряду пока нет.
const SWITCH_CYCLE_FAMILIES = {
flat: {
ceiling: 950,
smoke: 'budgets-interaction-smoke.json',
files: ['budgets.json', 'budgets-large-house-plan-snap.json', 'budgets-large-house-interaction.json',
'budgets-interaction-smoke.json'],
full: { maxRegressionRatio: 0.35, noiseAllowanceMs: 250 },
medians: {
'large-house-v1': [733.4, 683.3, 812.7, 749.9, 750.3, 800, 716.7, 717.9],
'large-house-plan-snap-v1': [716.7, 700.1, 762.6, 701, 733.3, 783.2, 749.9, 750.8],
'large-house-interaction-v1': [716.1, 754.1, 766.5, 666.9, 720.3, 750.1, 700.4, 703.4],
},
},
isometric: {
ceiling: 1550,
smoke: 'budgets-isometric-smoke.json',
files: ['budgets-large-house-isometric.json', 'budgets-isometric-stage3-dense.json', 'budgets-isometric-smoke.json'],
full: { maxRegressionRatio: 0.2, noiseAllowanceMs: 250 },
medians: {
'large-house-isometric-v1': [1069.5, 1051, 866.5, 766.5, 1134.9, 1100.4, 1135.7, 1089],
'isometric-stage3-dense-v1': [966.7, 983.2, 982.4, 916.5, 1249.7, 1333.9, 849.9, 803.6],
},
},
};
for (const [family, spec] of Object.entries(SWITCH_CYCLE_FAMILIES)) {
test(`#747 switchCycleMs (${family}): потолок над тёплым уровнем после #735 с запасом на шум раннера`, () => {
// Одно число на семью: смок = полный (#473 AC4), plan-snap и interaction
// сохраняют потолки large-house-v1, плотный двойник — исторические (#160).
for (const file of spec.files) {
const budget = readBudget(file).timings.switchCycleMs;
assert.equal(budget.hardMaxMs, spec.ceiling, `${file}: общий потолок switchCycleMs семьи ${family}`);
assert.equal(budget.stat, 'median', `${file}: метрика — медиана, как у соседей`);
}
const smoke = readBudget(spec.smoke);
const red = (ms) => evaluatePerformanceBudget({
candidate: absoluteSmokeReport(smoke, { switchCycleMs: ms }), budgets: smoke, absoluteOnly: true,
}).failures.some((check) => check.id === 'timing.switchCycleMs.median');
const series = Object.values(spec.medians).flat();
for (const median of series) assert.equal(red(median), false, `${median} из ряда краснеет на ${spec.ceiling}`);
const level = Math.max(...series);
// Правило #747: первое кратное 50 мс не ниже 1.15 × M и не выше 1.2 × M —
// полоса #692, в которую попадает и #675.
assert.equal(spec.ceiling, Math.ceil((level * 1.15) / 50) * 50, `потолок — первое кратное 50 над 1.15 × ${level}`);
assert.ok(spec.ceiling >= level * 1.15, 'запас над максимумом ряда не меньше 15 %');
assert.ok(spec.ceiling <= level * 1.2, 'и не больше 20 %: потолок остаётся гардом');
assert.equal(red(2 * level), true, 'удвоение уровня краснеет');
// Меньшее ловит относительное сравнение полного прогона: его коэффициент и
// допуск не рычаг.
for (const file of spec.files.filter((name) => name !== spec.smoke)) {
const budget = readBudget(file).timings.switchCycleMs;
assert.equal(budget.maxRegressionRatio, spec.full.maxRegressionRatio, `${file}: коэффициент не рычаг`);
assert.equal(budget.noiseAllowanceMs, spec.full.noiseAllowanceMs, `${file}: допуск не рычаг`);
}
});
}
test('boundary collision search restores the ordinary isometric allowances (#585)', () => {
const isometric = readBudget('budgets-large-house-isometric.json');
const dense = readBudget('budgets-isometric-stage3-dense.json');
// v1.76.0 temporarily widened exactly these three allowances after #583.
// The boundary-event search retires that exception; the twin profiles keep
// their shared Stage 3/4 contract and the ordinary pre-exception values.
assert.equal(isometric.timings.resizePreviewMs.noiseAllowanceMs, 150);
assert.equal(isometric.timings.panZoomMs.noiseAllowanceMs, 60);
assert.equal(isometric.timings.stateUpdateMs.noiseAllowanceMs, 75,
'профили-близнецы обязаны совпадать по общим метрикам (#160)');
assert.equal(dense.timings.stateUpdateMs.noiseAllowanceMs, 75);
assert.equal(dense.timings.resizePreviewMs.noiseAllowanceMs, 150);
assert.equal(dense.timings.panZoomMs.noiseAllowanceMs, 60);
for (const budget of [isometric, dense]) {
for (const metric of ['resizePreviewMs', 'panZoomMs', 'stateUpdateMs']) {
assert.equal(budget.timings[metric].maxRegressionRatio, 0.2, 'коэффициент не рычаг');
}
assert.equal(budget.timings.resizePreviewMs.hardMaxMs, 2200, 'абсолютный потолок не двигался');
assert.equal(budget.timings.panZoomMs.hardMaxMs, 600);
assert.equal(budget.timings.modelReadyMs.noiseAllowanceMs, 200, 'загрузка допуска не получала');
assert.equal(budget.timings.spaceSwitchMs.noiseAllowanceMs, 100);
}
for (const file of ['budgets.json', 'budgets-large-house-plan-snap.json', 'budgets-large-house-interaction.json']) {
const other = readBudget(file);
assert.equal(other.timings.panZoomMs.noiseAllowanceMs, 60, `${file} допуска жеста не получал`);
assert.equal(other.timings.resizePreviewMs.noiseAllowanceMs, 150, `${file} допуска ресайза не получал`);
}
});
test('isometric long-task count allowance covers the lazy iso-chunk split, nothing else (#507)', () => {
const isometric = readBudget('budgets-large-house-isometric.json');
assert.equal(isometric.longTasks.countNoiseAllowance, 5, 'owner-accepted +2 tasks of the lazy iso-scene-render split plus jitter');
assert.equal(isometric.longTasks.maxCountRegressionRatio, 0.2, 'the ratio is not the lever');
assert.equal(isometric.longTasks.maxCountP95, 30);
assert.equal(isometric.longTasks.maxTotalRegressionRatio, 0.2, 'total work is still gated as before');
assert.equal(isometric.longTasks.maxSingleRegressionRatio, 0.2);
assert.equal(readBudget('budgets-isometric-stage3-dense.json').longTasks.countNoiseAllowance, 5, 'the dense twin shares the lazy iso chunk and the #160 common-ceiling contract');
for (const file of ['budgets.json', 'budgets-large-house-plan-snap.json', 'budgets-large-house-interaction.json']) {
assert.equal(readBudget(file).longTasks.countNoiseAllowance, 3, `${file} keeps the ordinary allowance`);
}
// The v1.73.0 stable comparison against v1.72.0: 16 → 20 passes, 22 does not.
const limit = Math.max(16 * (1 + isometric.longTasks.maxCountRegressionRatio), 16 + isometric.longTasks.countNoiseAllowance);
assert.equal(limit, 21);
});
test('#720: the 2.5D view toggle is reported, not budgeted (owner decision in #694)', () => {
// A one-off settings operation, and since #649 the runner measures a full
// config reload for the candidate but a per-device projection flip for
// v1.77.0 — not the same operation. Load, floor switch and the single
// long-task ceiling keep gating the 2.5D scene build and UI freezes.
for (const name of [
'budgets-isometric-smoke.json',
'budgets-large-house-isometric.json',
'budgets-isometric-stage3-dense.json',
]) {
const budget = readBudget(name);
assert.equal('viewToggleMs' in budget.timings, false, `${name} budgets viewToggleMs`);
for (const metric of ['modelReadyMs', 'firstStableRenderMs', 'spaceSwitchMs'])
assert.ok(budget.timings[metric], `${name} keeps ${metric}`);
assert.ok(budget.longTasks.maxSingleMs > 0, `${name} keeps the single long-task ceiling`);
}
const runner = readFileSync(new URL('../demo/benchmark_large_house.mjs', import.meta.url), 'utf8');
assert.match(runner, /if \(isometric\) metricNames\.splice\(2, 0, 'viewToggleMs'\);/,
'the runner still reports the toggle');
});