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 = { // #485 adds the View-side subscription/render integration seams; the live // model and rendering themselves remain in dedicated modules. // #500 moved config/layout identity and the adoption sequence into // src/config-adoption.ts; the ratchet keeps that win. // 2026-09-11, #534: +1 for `import { keyed }`. The line buys back the 80 ms // that #525 put on every space switch, and there is nothing to move out — // the render itself did not grow by a character. // 2026-09-14, #564: the geometry/index/latch/DOM ownership implementation is // isolated in device-hit-owner.ts. These 81 lines are the remaining card // lifecycle invalidations and the presentation/action integration seams. // 2026-09-23, #624: сняты 112 мёртвых делегатов и полей, которых не читает // ни карточка, ни харнесс (реализация живёт в рантайме и зовётся оттуда), // 190 неиспользуемых импортов и 9 мёртвых констант — 784 строки. Потолок // опущен на выигрыш, запас над фактом три строки. // 2026-09-24, #642: диалог «Оптимизировать планы» уехал в // src/optimize-plans-dialog.ts — ушли пять делегатов, две стрелки-заглушки, // поле фолбэка буфера обмена, дедуп dev-лога и литерал типа состояния, // 62 строки. Потолок опущен на выигрыш, запас прежний. // 2026-09-27, #676: +2 — вызов черновика лестницы в pointerdown сцены и // рамка лестницы в верхнем оверлее (выше тел стен). Всё остальное слоя — // жесты, рамка, магнит, диалог — живёт в stairs-editor.ts и stairs-box.ts. 'src/houseplan-card.ts': 12891, // #478 removed the persisted room-draft editor branch. Keep that reduction. // #485 keeps its large setup surface in editors/radar-section.ts; these are // only the dialog state/save seam and the thin lazy-render adapter. // 2026-09-18, #592: разметка четырёх диалогов настроек уехала в // src/editors/{marker,space-settings,general-settings,room-settings}-dialog.ts // — 1290 строк. Потолок опущен ровно на эту величину (14100 → 12810), запас // над фактом остался прежним, три строки: выигрыш зафиксирован, иначе ядро // отыграет его обратно первой же правкой диалога. // 2026-09-23, #624: снесены мёртвые копии объявлений карточки (warm-boot, // LS_*, GLOW_*, debounce, navigate, lruRead — 31 объявление и 6 типов) и // 196 неиспользуемых импортов — 400 строк. Потолок опущен на выигрыш. // 2026-09-24, #642: диалог «Оптимизировать планы» (превью, запуск, // диагностика preflight, разметка) уехал в src/optimize-plans-dialog.ts за // узкий порт из 18 членов — 362 строки. Потолок опущен на выигрыш. 'src/houseplan-editor-runtime.ts': 11997, }; /** * Храповик: наверх не пускает, вниз — требует зафиксировать выигрыш. * * Вторая половина важнее первой. Без неё вынос двух тысяч строк ничего не * изменит: потолок останется прежним, и через полгода ядро дорастёт до него * обратно — молча и «в рамках бюджета». */ 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}`); } });