mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
ci(perf): budget and harness diffs select the perf-smoke profiles they judge (#770)
The isometric and interaction perf-smoke profiles joined Validate only on a src/** diff. A change of their budgets alone - or of the shared large-house runner, its fixture, the private card contract or the evaluator - ran performance_smoke with Glow alone, so a new ceiling was first judged by the beta candidate, and the profile set of the reuse key stayed "glow" for a diff that changed what that set is judged by. perf_iso and perf_interaction now also take their own budget files (the smoke and the full profile's, which the smoke repeats, #473 AC4) and the large-house harness, which brings in both. The Glow runner and compare.mjs stay out: the Glow smoke always runs and exercises them; the Stage 3 dense fixture belongs to a profile without a smoke. Tests still select nothing. The new unit test reads the profile of each smoke step from validate.yml (its --budgets file), finds every budget of that profile in demo/performance by its "profile" field, and requires each to select the profile, change the profile set of the reuse key computed from the workflow's own set lines, and be an input of the performance_smoke hash. Budgets of profiles without a smoke select neither. On the previous classifier both #770 tests are red: "demo/performance/budgets-isometric-smoke.json судит large-house-isometric-v1 - профиль обязан войти в смок". Issue: #770 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -976,6 +976,9 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
|
||||
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
|
||||
`houseplan-card.ts` — `large-house-interaction-v1`, оба по три образца против
|
||||
абсолютных потолков `hardMaxMs` полных профилей (`budgets-*-smoke.json`).
|
||||
Профиль добавляет и правка того, что его меряет и судит без `src/**`: его
|
||||
бюджетов и общего харнесса large-house — раннера, фикстуры, контракта карточки,
|
||||
оценщика (#770).
|
||||
Это гейт на «в разы», а не «на проценты»: регрессия #160 (первый кадр 9 870 мс
|
||||
против потолка 3 500) ловится ещё в ревью, а не предрелизным гейтом под тегом.
|
||||
Классификацию делает `scripts/classify-changes.mjs`, набор профилей входит в
|
||||
|
||||
@@ -137,7 +137,15 @@ This is a catastrophic-regression guard, not a performance trend detector.
|
||||
Two more profiles join the smoke only when the diff touches their code path
|
||||
(#473, classified by `scripts/classify-changes.mjs`): `large-house-isometric-v1`
|
||||
for `src/iso-*` and `large-house-interaction-v1` for `src/live-*`,
|
||||
`src/render-*`, `houseplan-render-lifecycle.ts` and `houseplan-card.ts`. Both
|
||||
`src/render-*`, `houseplan-render-lifecycle.ts` and `houseplan-card.ts`. Since
|
||||
#770 a profile also joins when the diff touches what measures or judges it
|
||||
without `src/**`: its own budgets (the smoke file and the full profile's, which
|
||||
the smoke repeats) and the shared large-house harness —
|
||||
`benchmark_large_house.mjs`, `demo/fixtures/large-house.mjs`,
|
||||
`card-contract.mjs` and `evaluate.mjs`, which bring in both. Before, a
|
||||
budget-only change ran the smoke with Glow alone, and the new ceiling was first
|
||||
judged by the beta candidate. The Glow runner and `compare.mjs` are exercised by
|
||||
the Glow smoke, which always runs. Both
|
||||
run `--samples=3 --warmups=1` against `budgets-isometric-smoke.json` and
|
||||
`budgets-interaction-smoke.json`, whose ceilings are the `hardMaxMs` values of
|
||||
the full profiles. The set of profiles is part of the `performance_smoke`
|
||||
@@ -200,9 +208,9 @@ ceiling" and the dense twin equal to the historical profile. Smaller growth
|
||||
stays the job of the full workflow's base-relative comparison (unchanged: 0.35
|
||||
flat, 0.2 2.5D, 250 ms noise allowance), and a floor built inside the window
|
||||
already fails the sample through the #735 structural guard, not through time. No
|
||||
3-sample `performance_smoke` median is in the series yet: those profiles join
|
||||
Validate only on a `src/**` diff, so the first beta candidate after #747 is
|
||||
their check.
|
||||
3-sample `performance_smoke` median is in the series yet: at the time those
|
||||
profiles joined Validate only on a `src/**` diff, so the first beta candidate
|
||||
after #747 is their check.
|
||||
|
||||
The 2.5D View toggle (`viewToggleMs`) is reported by the isometric profiles but
|
||||
budgeted by none of them (#720, owner decision in #694 on 2026-09-30). Switching
|
||||
|
||||
@@ -36,12 +36,34 @@ export const CHECK_OF_OUTPUT = {
|
||||
/**
|
||||
* Профили перф-смока (#473 §5): изометрический — при правке изометрии,
|
||||
* профиль взаимодействия — при правке живого пути и оркестраторов кадра.
|
||||
* Только `src/**`: тесты и демо кадр не замедляют. Это НЕ выбор job, а выбор
|
||||
* Тесты кадр не замедляют и профиль не включают. Это НЕ выбор job, а выбор
|
||||
* набора внутри неё, поэтому остаётся фильтром по путям.
|
||||
*
|
||||
* #770: профиль включает и то, что меняет его замер или вердикт без `src/**`:
|
||||
* его бюджеты — смоковый и полный (смок повторяет потолки полного, #473 AC4) —
|
||||
* и общий харнесс large-house: раннер, его фикстуру, контракт приватной
|
||||
* поверхности карточки и оценщик. Прежде правка одного потолка шла в Validate
|
||||
* с одним glow, и новый потолок впервые судил кандидат беты. Glow-раннер и
|
||||
* `compare.mjs` исполняет glow-смок, который идёт всегда.
|
||||
*/
|
||||
const LARGE_HOUSE_HARNESS = [
|
||||
/^demo\/benchmark_large_house\.mjs$/,
|
||||
/^demo\/fixtures\/large-house\.mjs$/,
|
||||
/^demo\/performance\/(card-contract|evaluate)\.mjs$/,
|
||||
];
|
||||
const anyOf = (...patterns) => new RegExp(patterns.map((pattern) => `(?:${pattern.source})`).join('|'));
|
||||
|
||||
export const PERF_PROFILES = {
|
||||
perf_iso: /^src\/iso-[^/]+\.ts$/,
|
||||
perf_interaction: /^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/,
|
||||
perf_iso: anyOf(
|
||||
/^src\/iso-[^/]+\.ts$/,
|
||||
/^demo\/performance\/budgets-(isometric-smoke|large-house-isometric)\.json$/,
|
||||
...LARGE_HOUSE_HARNESS,
|
||||
),
|
||||
perf_interaction: anyOf(
|
||||
/^src\/(live-[^/]+|render-[^/]+|houseplan-render-lifecycle|houseplan-card)\.ts$/,
|
||||
/^demo\/performance\/budgets-(interaction-smoke|large-house-interaction)\.json$/,
|
||||
...LARGE_HOUSE_HARNESS,
|
||||
),
|
||||
};
|
||||
|
||||
/** Совместимость с прежним экспортом: имя выхода → предикат по файлу. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import {
|
||||
CHECK_OF_OUTPUT, CLASSIFIERS, OUTPUTS, PERF_PROFILES, classifyAll, classifyChanges, formatOutputs, mutantsRequested,
|
||||
screenshotsGateMode,
|
||||
@@ -45,12 +45,90 @@ test('дифф по документации не включает ни одно
|
||||
assert.equal(out.backend, 'false');
|
||||
});
|
||||
|
||||
test('тесты и демо перф-профили не включают: кадр они не замедляют', () => {
|
||||
const out = classify(['test/iso-scene-render.test.mjs', 'demo/benchmark_large_house.mjs']);
|
||||
test('тесты и чужие демо-файлы перф-профили не включают: кадр и замер они не меняют', () => {
|
||||
// Glow-раннер и compare.mjs исполняет glow-смок, который идёт всегда;
|
||||
// фикстура плотного двойника принадлежит профилю без смока.
|
||||
const out = classify(['test/iso-scene-render.test.mjs', 'demo/benchmark_glow.mjs',
|
||||
'demo/performance/compare.mjs', 'demo/performance/isometric-stage3-dense-fixture.mjs']);
|
||||
assert.equal(out.perf_iso, 'false');
|
||||
assert.equal(out.perf_interaction, 'false');
|
||||
});
|
||||
|
||||
// #770: смоковый профиль судят его бюджеты, а замеряет общий раннер
|
||||
// large-house. Прежде профиль включал только `src/**`, и правка одного потолка
|
||||
// шла в Validate с одним glow: новый потолок впервые судил кандидат беты.
|
||||
// Профиль шага берётся из самого validate.yml (его `--budgets`), бюджеты
|
||||
// профиля — из demo/performance по полю `profile`: новый файл бюджета этого
|
||||
// профиля обязан включать его без правки теста.
|
||||
const VALIDATE = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
|
||||
const PERF_DIR = new URL('../demo/performance/', import.meta.url);
|
||||
const BUDGETS = readdirSync(PERF_DIR).filter((name) => /^budgets[\w-]*\.json$/.test(name))
|
||||
.map((name) => ({ path: `demo/performance/${name}`, profile: JSON.parse(readFileSync(new URL(name, PERF_DIR), 'utf8')).profile }));
|
||||
const PERF_STEPS = { perf_iso: 'Изометрический профиль по диффу', perf_interaction: 'Профиль взаимодействия по диффу' };
|
||||
const smokeProfileOf = (output) => {
|
||||
const job = VALIDATE.slice(VALIDATE.indexOf('\n performance_smoke:\n'), VALIDATE.indexOf('\n geometry_parity:\n'));
|
||||
const start = job.indexOf(PERF_STEPS[output]);
|
||||
const step = job.slice(start, job.indexOf('- name:', start));
|
||||
assert.ok(start > 0, `${output}: нет шага «${PERF_STEPS[output]}»`);
|
||||
assert.match(step, new RegExp(`if: needs\\.changes\\.outputs\\.${output} == 'true'`));
|
||||
const smokeBudget = step.match(/--absolute-only --budgets=(demo\/performance\/[\w-]+\.json)/)[1];
|
||||
const { profile } = BUDGETS.find((budget) => budget.path === smokeBudget);
|
||||
assert.ok(step.includes(`--profile=${profile} `), `${output}: шаг меряет тот профиль, который судит его бюджет`);
|
||||
return { profile, smokeBudget };
|
||||
};
|
||||
// Набор профилей ключа реюза — тем же текстом, что исполняет шаг `keys` job
|
||||
// `reuse`: `set=glow` и по строке `[ "$PERF_X" = "true" ] && set="$set-…"`.
|
||||
const REUSE = VALIDATE.slice(VALIDATE.indexOf('\n reuse:\n'), VALIDATE.indexOf('\n hacs:\n'));
|
||||
const perfSetOf = (outputs) => {
|
||||
const envOutput = Object.fromEntries([...REUSE.matchAll(/(PERF_\w+): \$\{\{ needs\.changes\.outputs\.(\w+) \}\}/g)]
|
||||
.map(([, env, output]) => [env, output]));
|
||||
let set = REUSE.match(/^\s*set=(\w+)$/m)[1];
|
||||
for (const [, env, suffix] of REUSE.matchAll(/\[ "\$(PERF_\w+)" = "true" \] && set="\$set-(\w+)"/g)) {
|
||||
if (outputs[envOutput[env]] === 'true') set = `${set}-${suffix}`;
|
||||
}
|
||||
return set;
|
||||
};
|
||||
|
||||
test('#770: правка только бюджета включает профиль, который он судит, и меняет ключ реюза', () => {
|
||||
const quiet = perfSetOf(classify([p('docs', 'SUN.md')]));
|
||||
assert.equal(quiet, 'glow', 'без перф-диффа набор — один glow');
|
||||
for (const output of Object.keys(PERF_STEPS)) {
|
||||
const { profile, smokeBudget } = smokeProfileOf(output);
|
||||
const judged = BUDGETS.filter((budget) => budget.profile === profile).map((budget) => budget.path);
|
||||
// Смоковый и полный: смок повторяет потолки полного (#473 AC4), и правка
|
||||
// полного — правка того же профиля.
|
||||
assert.ok(judged.includes(smokeBudget) && judged.length >= 2, `${profile}: ${judged.join(', ')}`);
|
||||
for (const path of judged) {
|
||||
const out = classify([path]);
|
||||
assert.equal(out[output], 'true', `${path} судит ${profile} — профиль обязан войти в смок`);
|
||||
for (const other of Object.keys(PERF_STEPS).filter((name) => name !== output))
|
||||
assert.equal(out[other], 'false', `${path} не судит профиль ${other}`);
|
||||
assert.deepEqual(out.unknown, [], path);
|
||||
// Обе половины ключа `reuse-performance_smoke-<входы>-<набор>` меняются:
|
||||
// файл — вход job (хеш), а набор получает профиль.
|
||||
assert.ok(MANIFEST.performance_smoke.has(path), `${path}: вход хеша performance_smoke`);
|
||||
assert.notEqual(perfSetOf(out), quiet, `${path}: ключ реюза совпал бы с glow-only прогоном`);
|
||||
}
|
||||
}
|
||||
// Бюджеты профилей без смока (полные и glow) смоковых профилей не включают.
|
||||
const smokeProfiles = new Set(Object.keys(PERF_STEPS).map((output) => smokeProfileOf(output).profile));
|
||||
for (const budget of BUDGETS.filter(({ profile }) => !smokeProfiles.has(profile))) {
|
||||
const out = classify([budget.path]);
|
||||
for (const output of Object.keys(PERF_STEPS)) assert.equal(out[output], 'false', `${budget.path} → ${output}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('#770: раннер large-house, его фикстура, контракт карточки и оценщик включают оба смоковых профиля', () => {
|
||||
for (const file of ['demo/benchmark_large_house.mjs', 'demo/fixtures/large-house.mjs',
|
||||
'demo/performance/card-contract.mjs', 'demo/performance/evaluate.mjs']) {
|
||||
const out = classify([file]);
|
||||
assert.equal(out.perf_iso, 'true', file);
|
||||
assert.equal(out.perf_interaction, 'true', file);
|
||||
assert.equal(perfSetOf(out), 'glow-iso-interaction', file);
|
||||
assert.deepEqual(out.unknown, [], file);
|
||||
}
|
||||
});
|
||||
|
||||
test('правка реестра мутантов даёт mutants=true; юниты реестра — тоже вход frontend (#475 r1, #492)', () => {
|
||||
for (const file of ['scripts/mutation-gate.mjs', 'scripts/mutation-registry.mjs',
|
||||
'scripts/mutation-selection.mjs', 'scripts/mutation-evidence.mjs',
|
||||
|
||||
Reference in New Issue
Block a user