feat(furniture): пакет 0.4.0 — 60 дизайнерских символов вместо 44 плюс 12 примитивов

Встроенная библиотека мебели была смешанной: 44 предмета рисовал дизайнер, а
12 — код, прямоугольниками и эллипсами в unit box. Они выбивались из стиля, и
три категории меню (`computer`, `hood`, `oven`) вообще не открывались, потому
что вида сверху для них не было.

Пакет 0.4.0 перерисовывает все 56 существующих публичных ID и добавляет четыре
новых: `computer`, `hood`, `oven`, `cactus`. Каталог становится равен поставке:
60 символов, 32 непустые категории.

Совместимость. Ни один сохранённый план не мигрирует: положение, размер,
поворот, отражение, цвет, прозрачность и толщина линии не трогаются, а
default-размеры всех 56 прежних ID совпадают со старыми до числа — таблица
зашита в `test/furniture.test.mjs`, чтобы сдвиг было видно тестом, а не глазами.

Ловушка, ради которой примитивы удалены целиком. Библиотека собиралась союзом
каталога и `LEGACY_FURNITURE`, и обе половины несли ОДНИ И ТЕ ЖЕ публичные ID.
`BY_ID` — это `Map`, она оставляет последнюю запись, поэтому примитив молча
побеждал дизайнерский рисунок, который его заменял: задача выглядит выполненной,
12 предметов не обновились, ни один существующий тест этого не видит. Поэтому
`LEGACY_FURNITURE`, `RETAINED_IDS`, тип `Prim`, `box()` и `primitivePathD`
удалены, а на дубликаты в каталоге стоит тест и мутант.

Два продуктовых решения владельца (#593):

- `cactus` живёт в категории `plant`, а не `exercise`, как предлагал пакет:
  плитка «Тренажёр» рисует тренажёр, и открывать её на кактус — врать о
  содержимом палитры. `exercise` остаётся скрытой пустой категорией, непустых
  становится 32, а не 33;
- при отказе ленивого чанка с рисунками не рисуется НИЧЕГО. Прежде 12
  примитивов рисовались всегда, и `docs/FURNITURE.md` это обещал; обещание
  снято. Это единственное место, где задача ухудшает наблюдаемое поведение, и
  оно проверяется смоком, а не словом.

Бюджеты. Ревью ТЗ показало, что «бюджет ленивого графа защищён» было
заявлением без гейта: размеры печатались в отчёт и ни с чем не сравнивались.
Добавлены `lazyGraphCeilingViolation` и два настоящих потолка со своим мутантом.
Стартовый граф ушёл ниже полосы, поэтому `INITIAL_VIEW_GZIP_CEILING` опущен
292 400 → 291 400 — вторая половина храповика, без которой выигрыш отыграется.

Провенанс. Документ пакета воссоздан под 0.4.0: грант владельца, имя архива и
его SHA-256. Это единственная исполнимая защита от повторения истории #159, и
она не ослаблена — число проверок в тесте не уменьшилось, а мутант подменяет
хеш и обязан её покраснить.

Свидетели: пять новых мутантов, новая golden-сцена `furniture-new-symbols-light`
для четырёх новых ID, матрица 63 → 64. Шестой мутант правится, а не добавляется:
`furniture-placement-needs-art` ссылался на удалённое поле `g` и переставал
компилироваться — раннер честно отличал это от «пойман» и отчитывался
setup-failure. Ложь мутанта осталась прежней по смыслу, убрана только ветка про
примитив.

i18n: 7 ключей × 4 локали. `furn.cat_exercise` не добавлен — категория скрыта.

Issue: #593
User-Visible: yes
This commit is contained in:
Claude
2026-09-19 13:38:49 +03:00
parent ad20e23cc4
commit 7df2740f1c
245 changed files with 2003 additions and 1636 deletions
+61 -2
View File
@@ -291,7 +291,7 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* оставлял под прежним центром 354 Б сверху — внутри шумовой полосы метрики.
* Новый центр даёт 1 054 Б сверху и 946 Б до нижней границы полосы.
*/
export const INITIAL_VIEW_GZIP_CEILING = 292_400;
export const INITIAL_VIEW_GZIP_CEILING = 291_400;
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
@@ -373,6 +373,53 @@ export function initialViewCeilingViolation(bytes, {
return null;
}
/**
* #593: потолки ленивых графов. До этой задачи их размеры только печатались в
* отчёт, и заявление «бюджет ленивого графа защищён» было неправдой: сравнения
* не существовало ни одного. Задача, которая перерисовывает всю библиотеку
* мебели, живёт ровно в этих двух графах — значит она их и закрывает.
*
* Значения сняты с собранного бандла и поставлены с тем же запасом, что у
* стартового графа: полоса 2000 Б и обязательный зазор больше 500 Б с обеих
* сторон, чтобы ни семибайтный шум не красил гейт, ни молчаливый выигрыш не
* оставался незафиксированным.
*/
export const LAZY_FURNITURE_ART_GZIP_CEILING = 17_900;
export const LAZY_EDITOR_GZIP_CEILING = 222_900;
export const LAZY_GRAPH_CEILING_BAND = 2_000;
/**
* Потолок ленивого графа: `null`, пока значение внутри полосы.
*
* Отдельная функция, а не параметр `initialViewCeilingViolation`: текст отказа
* обязан называть граф, иначе «graph 17 000 B выше потолка» не говорит, какой
* именно граф вырос и куда смотреть.
*/
export function lazyGraphCeilingViolation(bytes, { ceiling, label, band = LAZY_GRAPH_CEILING_BAND } = {}) {
if (!Number.isFinite(bytes)) {
return { kind: 'missing', text: `${label} не измерен — потолок проверить нечем` };
}
if (bytes > ceiling) {
return {
kind: 'grew',
over: bytes - ceiling,
text: `${label} ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
+ ' Ленивый граф грузится не в первом кадре, но он всё равно чей-то трафик:'
+ ' поднимите потолок в этом же коммите, объяснив рост, либо уменьшите содержимое.',
};
}
if (bytes < ceiling - band) {
return {
kind: 'shrank',
under: ceiling - bytes,
text: `${label} ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш`
+ ' граф отыграет обратно.',
};
}
return null;
}
/** Тревога о запасе: `null`, пока его хватает или пока долг признан. */
export function lowHeadroomWarning(headroom, {
threshold = LOW_HEADROOM_WARNING_BYTES,
@@ -400,6 +447,8 @@ export function assertBundleBudget(
manifest,
budget = INITIAL_VIEW_GZIP_BUDGET,
panelOnlyBudget = INITIAL_PANEL_ONLY_GZIP_BUDGET,
lazyFurnitureArtCeiling = LAZY_FURNITURE_ART_GZIP_CEILING,
lazyEditorCeiling = LAZY_EDITOR_GZIP_CEILING,
) {
assertBundleManifest(manifest);
if (!manifest.lazyEditorFiles?.length) {
@@ -449,6 +498,14 @@ export function assertBundleBudget(
+ ` ${panelOnlyBudget} B budget`,
);
}
// #593: два ленивых графа впервые становятся гейтом, а не строкой в отчёте.
for (const [bytes, ceiling, label] of [
[manifest.lazyFurnitureArtGzipBytes, lazyFurnitureArtCeiling, 'lazy furniture art graph'],
[manifest.lazyEditorGzipBytes, lazyEditorCeiling, 'lazy editor graph'],
]) {
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
if (violation) throw new Error(violation.text);
}
return {
initialViewGzipBytes: manifest.initialViewGzipBytes,
initialPanelGzipBytes: manifest.initialPanelGzipBytes,
@@ -477,7 +534,9 @@ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
`initial panel-only: ${result.initialPanelOnlyGzipBytes} B gzip`
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
`lazy editor: ${result.lazyEditorGzipBytes} B gzip`,
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
`lazy furniture art: ${result.lazyFurnitureArtGzipBytes} B gzip`
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
+3 -3
View File
@@ -6,7 +6,7 @@ import process from 'node:process';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PACK = path.join(ROOT, 'assets', 'furniture', 'houseplan-0.3.0');
const PACK = path.join(ROOT, 'assets', 'furniture', 'houseplan-0.4.0');
// Deliberately not named *manifest.json: HACS rejects a repository that
// contains any second file matching that suffix outside the integration.
const MANIFEST = path.join(PACK, 'pack.json');
@@ -128,11 +128,11 @@ function loadPack() {
exactKeys(manifest,
['schema_version', 'view_schema', 'pack_id', 'pack_version', 'title_ru', 'title_en', 'author', 'license', 'source_url', 'menu_icons', 'symbols'],
[], 'manifest');
if (manifest.schema_version !== 1 || manifest.view_schema !== 2 || manifest.pack_id !== 'houseplan' || manifest.pack_version !== '0.3.0') fail('unsupported manifest identity');
if (manifest.schema_version !== 1 || manifest.view_schema !== 2 || manifest.pack_id !== 'houseplan' || manifest.pack_version !== '0.4.0') fail('unsupported manifest identity');
if (manifest.author !== 'Sergey Matyunin (Matysh)' || manifest.license !== 'MIT') fail('release provenance is incomplete');
text(manifest.title_ru, 'manifest.title_ru'); text(manifest.title_en, 'manifest.title_en'); text(manifest.source_url, 'manifest.source_url');
if (!Array.isArray(manifest.menu_icons) || manifest.menu_icons.length !== 33) fail('exactly 33 menu icons are required');
if (!Array.isArray(manifest.symbols) || manifest.symbols.length !== 44) fail('exactly 44 plan symbols are required');
if (!Array.isArray(manifest.symbols) || manifest.symbols.length !== 60) fail('exactly 60 plan symbols are required');
const menuIds = new Set();
const menu = manifest.menu_icons.map((entry, index) => {
+73 -3
View File
@@ -8666,6 +8666,73 @@ const MUTANT_DEFINITIONS = [
},
// #474: designer furniture artwork is a lazy chunk. Each protective contract
// of the runtime and its integration gets one witness.
{
id: 'furniture-legacy-shadows-designer-art',
guard: 'node --test test/furniture-assets.test.mjs test/furniture.test.mjs',
because: '#593: до пакета 0.4.0 библиотека была объединением каталога и 12 примитивов, '
+ 'и обе половины несли ОДНИ И ТЕ ЖЕ публичные ID. `BY_ID` оставляет последнюю запись, '
+ 'поэтому примитив молча побеждал дизайнерский рисунок, который его заменял: задача '
+ 'выглядит выполненной, а 12 предметов не обновились',
patches: [{
file: 'src/furniture.ts',
find: '}));\n\nconst BY_ID = new Map(FURNITURE.map((s) => [s.id, s]));',
replace: '})).concat(GENERATED_FURNITURE_CATALOG.slice(0, 12).map((symbol) => ({\n'
+ ' id: symbol.id,\n'
+ ' group: symbol.group as FurnitureGroup,\n'
+ ' category: symbol.category,\n'
+ ' w: symbol.w,\n'
+ ' h: symbol.h,\n'
+ '})));\n\nconst BY_ID = new Map(FURNITURE.map((s) => [s.id, s]));',
}],
},
{
id: 'furniture-pack-accepts-foreign-symbol-count',
guard: 'node --test test/furniture-assets.test.mjs',
because: '#593: «ровно 60 плановых символов» — единственное, что отличает проверенную '
+ 'поставку от произвольной папки с SVG. Без жёсткого числа в пакет можно дослать файл, '
+ 'и он приедет в бандл, не показавшись ни на одном ревью',
patches: [{
file: 'scripts/generate-furniture-assets.mjs',
find: "if (!Array.isArray(manifest.symbols) || manifest.symbols.length !== 60) fail('exactly 60 plan symbols are required');",
replace: "if (!Array.isArray(manifest.symbols) || manifest.symbols.length < 1) fail('exactly 60 plan symbols are required');",
}],
},
{
id: 'furniture-cactus-lands-in-exercise',
guard: 'node --test test/furniture-assets.test.mjs',
because: '#593: плитка категории «Тренажёр» рисует тренажёр. Открывать её на кактус — '
+ 'врать о содержимом палитры; решение владельца — кактус в «Растении»',
patches: [{
file: 'assets/furniture/houseplan-0.4.0/pack.json',
find: ' "file": "svg/plan/cactus.svg",\n "menu_icon": "plant",',
replace: ' "file": "svg/plan/cactus.svg",\n "menu_icon": "exercise",',
}],
},
{
id: 'furniture-provenance-doc-loses-its-hash',
guard: 'node --test test/furniture-assets.test.mjs',
because: '#593/#159: SHA-256 проверенного архива и ссылка на грант — единственная '
+ 'исполнимая защита от повторения истории #159. Если тест их не сверяет, документ '
+ 'провенанса можно переписать под любую поставку, и никто не заметит',
patches: [{
file: 'assets/furniture/houseplan-0.4.0/README.md',
find: '`69BA5E0C398542D59F24269F637F57B8EBF31C2836C9D493F084AD29AB299FDE`',
replace: '`0000000000000000000000000000000000000000000000000000000000000000`',
}],
},
{
id: 'bundle-budget-lazy-ceiling-never-fires',
guard: 'node --test test/bundle-assets.test.mjs',
because: '#593: два ленивых графа впервые получили потолки. Функция, всегда возвращающая '
+ 'null, возвращает прежнее положение дел — размер печатается в отчёт и не сравнивается '
+ 'ни с чем, а «бюджет защищён» остаётся заявлением без гейта',
patches: [{
file: 'scripts/bundle-budget.mjs',
find: 'export function lazyGraphCeilingViolation(bytes, { ceiling, label, band = LAZY_GRAPH_CEILING_BAND } = {}) {',
replace: 'export function lazyGraphCeilingViolation(bytes, { ceiling, label, band = LAZY_GRAPH_CEILING_BAND } = {}) {\n'
+ ' if (Number.isFinite(bytes)) return null; // mutant',
}],
},
{
id: 'furniture-art-eager-import',
guard: 'npm run build && node scripts/bundle-budget.mjs',
@@ -8680,8 +8747,8 @@ const MUTANT_DEFINITIONS = [
// A well-meaning "fall back to the bundled artwork" is exactly how the
// chunk would creep back into the initial graph.
file: 'src/furniture.ts',
find: ' if (symbol.designer) return FURNITURE_ART_RUNTIME.art(id, host) ?? null;',
replace: ' if (symbol.designer) return FURNITURE_ART_RUNTIME.art(id, host) ?? EAGER_ART[id] ?? null;',
find: ' return FURNITURE_ART_RUNTIME.art(id, host) ?? null;',
replace: ' return FURNITURE_ART_RUNTIME.art(id, host) ?? EAGER_ART[id] ?? null;',
}],
},
{
@@ -8729,7 +8796,10 @@ const MUTANT_DEFINITIONS = [
patches: [{
file: 'src/furniture-placement.ts',
find: ' if (!furnitureSymbol(symbol) || !(canvasW > 0) || !(canvasH > 0)',
replace: " if (!(furnitureSymbol(symbol) && (furnitureSymbol(symbol)?.g || FURNITURE_ART_RUNTIME.art(symbol))) || !(canvasW > 0) || !(canvasH > 0)",
// #593: поля `g` у символа больше нет — примитивов не осталось, и мутант,
// ссылавшийся на него, переставал компилироваться (setup-failure вместо
// проверки). Ложь та же по смыслу: магнит начинает требовать арт.
replace: " if (!(furnitureSymbol(symbol) && FURNITURE_ART_RUNTIME.art(symbol)) || !(canvasW > 0) || !(canvasH > 0)",
}, {
file: 'src/furniture-placement.ts',
find: "import { clampFurnSize, cmToNorm, furnitureSymbol } from './furniture';",