diff --git a/docs/TESTING.md b/docs/TESTING.md index d9dba0c9..7bb17e16 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -3587,3 +3587,17 @@ require hands on real hardware — they remain for the human pass. Целочисленность обрезки — единственная часть съёмки, которую можно проверить без браузера: `node --test test/capture-clip.test.mjs`. + +## Ядра фронтенда растут только осознанно (#425, заменяет #34) + +- [ ] `node --test test/core-file-budget.test.mjs` — потолки на + `src/houseplan-card.ts` и `src/houseplan-editor-runtime.ts`. + +Гейт-храповик: наверх не пускает, вниз требует зафиксировать выигрыш. Если он +покраснел, вариантов два, и оба нормальные — вынести из ядра столько же строк, +сколько добавили, либо поднять потолок отдельным решением, объяснив его в +ревью. Молча поднять не выйдет: число живёт в тесте и попадает в дифф. + +Вторая половина правила (уменьшение ниже потолка на 250 строк тоже краснеет) +нужна затем, что без неё вынос двух тысяч строк ничего не меняет: потолок +остаётся прежним, и ядро дорастает до него обратно «в рамках бюджета». diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index c593df10..0682a280 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -933,6 +933,19 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'core-budget-ignores-growth', + guard: 'node --test test/core-file-budget.test.mjs', + because: 'the two cores put on 500-1000 lines per release while everything new ' + + 'goes into new modules — a budget that does not notice growth of its own ' + + 'files is decoration, and the decomposition issue it replaces sat still for ' + + '25 days precisely because nobody was counting (#425, replaces #34)', + patches: [{ + file: 'src/houseplan-card.ts', + find: 'export class HouseplanCard', + replace: `${'\n'.repeat(400)}export class HouseplanCard`, + }], + }, { id: 'capture-allows-partial-raster', guard: 'node --test test/capture-determinism-args.test.mjs', diff --git a/test/core-file-budget.test.mjs b/test/core-file-budget.test.mjs new file mode 100644 index 00000000..81031711 --- /dev/null +++ b/test/core-file-budget.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +// #425, заменяет #34. Декомпозиция фронтенда идёт попутно и продолжит идти, но +// два ядра всё равно прибавляют по 500–1000 строк за релиз: новое уезжает в +// новые модули, а из старых ничего не уходит. Проект «разложить всё по +// каталогам» не начался за 25 дней и не начнётся; вместо него — потолок, +// который делает рост осознанным. +// +// Правило простое: хочешь добавить в ядро — вынеси столько же. Число в дифе, +// решение в ревью. + +// Мера — `split('\n').length`, то есть строки плюс завершающий перевод. +// Та же функция и для потолков, и для измерения: две разные меры разошлись бы +// на единицу, и гейт краснел бы на пустом месте (проверено при написании). +const SLACK = 250; + +// Потолки. Меняются только вручную и только вместе с объяснением в ревью: +// потолок, который вычисляется от текущего размера, потолком не является. +const CAPS = { + 'src/houseplan-card.ts': 13659, + 'src/houseplan-editor-runtime.ts': 14323, +}; + +/** + * Храповик: наверх не пускает, вниз — требует зафиксировать выигрыш. + * + * Вторая половина важнее первой. Без неё вынос двух тысяч строк ничего не + * изменит: потолок останется прежним, и через полгода ядро дорастёт до него + * обратно — молча и «в рамках бюджета». + */ +export function coreBudgetViolations(sizes, caps, slack = SLACK) { + const problems = []; + for (const [file, cap] of Object.entries(caps)) { + const lines = sizes[file]; + if (typeof lines !== 'number') { + problems.push({ file, kind: 'missing', text: `${file}: файл не измерен` }); + continue; + } + if (lines > cap) { + problems.push({ + file, kind: 'grew', over: lines - cap, + text: `${file}: ${lines} строк при потолке ${cap} — выросло на ${lines - cap}.` + + ' Вынесите столько же в отдельный модуль либо поднимите потолок' + + ' отдельным решением, объяснив его в ревью.', + }); + } else if (lines < cap - slack) { + problems.push({ + file, kind: 'shrank', under: cap - lines, + text: `${file}: ${lines} строк при потолке ${cap} — на ${cap - lines} меньше.` + + ' Опустите потолок: незафиксированный выигрыш ядро отыграет обратно.', + }); + } + } + return problems; +} + +const measure = (file) => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').split('\n').length; + +test('ядра не выросли выше потолка и не опустились ниже него молча', () => { + const sizes = Object.fromEntries(Object.keys(CAPS).map((file) => [file, measure(file)])); + const problems = coreBudgetViolations(sizes, CAPS); + assert.deepEqual(problems.map((p) => p.text), [], problems.map((p) => p.text).join('\n')); +}); + +test('рост выше потолка становится нарушением с числом', () => { + const [problem] = coreBudgetViolations({ 'a.ts': 1300 }, { 'a.ts': 1000 }); + assert.equal(problem.kind, 'grew'); + assert.equal(problem.over, 300); + assert.match(problem.text, /выросло на 300/); +}); + +test('заметное уменьшение требует опустить потолок', () => { + const [problem] = coreBudgetViolations({ 'a.ts': 700 }, { 'a.ts': 1000 }); + assert.equal(problem.kind, 'shrank'); + assert.equal(problem.under, 300); + assert.match(problem.text, /Опустите потолок/); +}); + +test('изменение в пределах люфта не трогает никого', () => { + assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []); + assert.deepEqual(coreBudgetViolations({ 'a.ts': 800 }, { 'a.ts': 1000 }), []); + assert.deepEqual(coreBudgetViolations({ 'a.ts': 751 }, { 'a.ts': 1000 }), []); +}); + +test('границы включительно: ровно потолок и ровно люфт нарушением не считаются', () => { + assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []); + assert.deepEqual(coreBudgetViolations({ 'a.ts': 750 }, { 'a.ts': 1000 }), []); + assert.equal(coreBudgetViolations({ 'a.ts': 1001 }, { 'a.ts': 1000 })[0].kind, 'grew'); + assert.equal(coreBudgetViolations({ 'a.ts': 749 }, { 'a.ts': 1000 })[0].kind, 'shrank'); +}); + +test('потолки заданы для двух ядер и ни для чего больше', () => { + assert.deepEqual(Object.keys(CAPS).sort(), + ['src/houseplan-card.ts', 'src/houseplan-editor-runtime.ts']); +}); + +test('потолки — числа в этом файле, а не вычисление от текущего размера', () => { + // AC4. Потолок, который считается от того, что сейчас на диске, разрешает + // любой рост и запрещает только уменьшение — то есть работает наоборот. + const source = readFileSync(new URL('./core-file-budget.test.mjs', import.meta.url), 'utf8'); + const caps = source.slice(source.indexOf('const CAPS'), source.indexOf('};', source.indexOf('const CAPS'))); + assert.match(caps, /'src\/houseplan-card\.ts': \d+/); + assert.doesNotMatch(caps, /measure|readFileSync|process\.env/); + for (const problem of coreBudgetViolations({ 'a.ts': 10 }, { 'a.ts': 10 })) { + assert.fail(`функция обязана принимать потолки аргументом: ${problem.text}`); + } +});