mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
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, base76558bf2), 36838891001 (#740), 36838952536 (#742) and 36839009721 (#739), the last three against dev7ff2b5ae. 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
438 lines
24 KiB
JavaScript
438 lines
24 KiB
JavaScript
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');
|
||
});
|