mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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`,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';",
|
||||
|
||||
Reference in New Issue
Block a user