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
+47 -5
View File
@@ -13,6 +13,8 @@ import {
import {
INITIAL_PANEL_ONLY_GZIP_BUDGET, INITIAL_VIEW_CEILING_BAND,
INITIAL_VIEW_GZIP_BUDGET, INITIAL_VIEW_GZIP_CEILING,
LAZY_EDITOR_GZIP_CEILING, LAZY_FURNITURE_ART_GZIP_CEILING, LAZY_GRAPH_CEILING_BAND,
lazyGraphCeilingViolation,
LOW_HEADROOM_ACKNOWLEDGED_CEILING, LOW_HEADROOM_WARNING_BYTES,
SUPPORT_LAZY_MARKERS,
assertBundleBudget, assertSupportBundleOwnership, initialViewCeilingViolation,
@@ -224,10 +226,13 @@ test('bundle manifest separates static initial graph from dynamic editor graph',
'houseplan-assets/iso-scene-render-HASH.js',
'houseplan-assets/pdf-export-HASH.js',
]);
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000));
assert.throws(() => assertBundleBudget(manifest, 1), /exceeds/);
// Этот тест про РАЗДЕЛЕНИЕ графов, а не про их размеры: потолки ленивых
// графов (#593) задаются по самой фикстуре, чтобы она не проверяла лишнего.
const lazyCeilings = [manifest.lazyFurnitureArtGzipBytes, manifest.lazyEditorGzipBytes];
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...lazyCeilings));
assert.throws(() => assertBundleBudget(manifest, 1, undefined, ...lazyCeilings), /exceeds/);
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, manifest.initialPanelOnlyGzipBytes - 1),
() => assertBundleBudget(manifest, 1_000_000, manifest.initialPanelOnlyGzipBytes - 1, ...lazyCeilings),
/panel-only graph.*exceeds/,
);
});
@@ -849,13 +854,15 @@ const runBudgetCli = (initialViewGzipBytes) => {
initialPanelOnlyFiles: ['houseplan-panel.js'],
initialPanelOnlyGzipBytes: 1,
lazyEditorFiles: ['editor.js'],
lazyEditorGzipBytes: 1000,
// #593: у ленивых графов теперь свои потолки, и фикстура обязана лежать
// внутри полосы — иначе CLI краснеет не на том, что проверяет тест.
lazyEditorGzipBytes: LAZY_EDITOR_GZIP_CEILING - 1_000,
lazyLocaleFiles: ['locale.js'],
lazyLocaleGzipBytes: 100,
lazyIsometricFiles: ['isometric.js'],
lazyIsometricGzipBytes: 100,
lazyFurnitureArtFiles: ['furniture-art.js'],
lazyFurnitureArtGzipBytes: 100,
lazyFurnitureArtGzipBytes: LAZY_FURNITURE_ART_GZIP_CEILING - 1_000,
lazyPdfFiles: ['pdf.js'],
lazyPdfGzipBytes: 100,
lazyOnboardingFiles: [],
@@ -887,3 +894,38 @@ test('#438 CLI действительно применяет потолок, а
assert.match(overBudget.output,
new RegExp(`exceeds ${INITIAL_VIEW_GZIP_BUDGET} B budget`));
});
// #593: до этой задачи размеры ленивых графов только печатались в отчёт. Тогда
// «бюджет ленивого графа защищён» было заявлением без гейта — сравнения не
// существовало ни одного, и рост уезжал молча.
test('#593 потолки ленивых графов — гейт, а не строка отчёта', () => {
const manifest = JSON.parse(
readFileSync(new URL('../dist/houseplan-assets.json', import.meta.url), 'utf8'),
);
for (const [bytes, ceiling, label] of [
[manifest.lazyFurnitureArtGzipBytes, LAZY_FURNITURE_ART_GZIP_CEILING, 'lazy furniture art graph'],
[manifest.lazyEditorGzipBytes, LAZY_EDITOR_GZIP_CEILING, 'lazy editor graph'],
]) {
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
assert.equal(violation, null, violation?.text);
// Факт лежит не у края полосы — с тем же запасом, что у стартового графа.
assert.ok(ceiling - bytes > 500, `${label}: сверху меньше 500 Б — это шум`);
assert.ok(bytes - (ceiling - LAZY_GRAPH_CEILING_BAND) > 500,
`${label}: снизу меньше 500 Б — гейт потребует опустить потолок из-за шума`);
}
// Гейт обязан быть исполняемым и на синтетике, обе стороны.
const grew = lazyGraphCeilingViolation(20_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
assert.equal(grew.kind, 'grew');
assert.equal(grew.over, 2_100);
assert.match(grew.text, /lazy furniture art graph 20000 B gzip выше потолка 17900 B на 2100 B/);
const shrank = lazyGraphCeilingViolation(15_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
assert.equal(shrank.kind, 'shrank');
assert.match(shrank.text, /Опустите потолок/);
// Границы полосы включительные — иначе гейт краснеет на равенстве.
assert.equal(lazyGraphCeilingViolation(17_900, { ceiling: 17_900, label: 'x' }), null);
assert.equal(lazyGraphCeilingViolation(15_900, { ceiling: 17_900, label: 'x' }), null);
assert.equal(lazyGraphCeilingViolation(NaN, { ceiling: 17_900, label: 'x' }).kind, 'missing');
// Текст обязан называть граф: «graph выше потолка» не говорит, куда смотреть.
assert.match(lazyGraphCeilingViolation(NaN, { ceiling: 1, label: 'lazy editor graph' }).text,
/lazy editor graph/);
});
+7 -3
View File
@@ -198,8 +198,12 @@ test('the wall magnet places a designer piece while its artwork is still pending
}), null, 'an unknown symbol is still refused');
});
test('furnitureGraphic: legacy art is eager, designer art is undefined until the page runtime settles', () => {
assert.ok(furnitureGraphic('fridge')?.d, 'legacy primitive art needs no chunk');
assert.equal(furnitureArtIsLazy('fridge'), false);
// #593: примитивов, рисовавшихся без чанка, больше нет — все 60 символов
// приходят из ленивого арта. Это ОСОЗНАННАЯ потеря: `docs/FURNITURE.md` обещал,
// что 12 retained-символов рисуются всегда, и обещание снято решением владельца.
test('furnitureGraphic: every symbol waits for the lazy chunk, including the former primitives', () => {
assert.equal(furnitureArtIsLazy('fridge'), true, 'бывший примитив тоже ленив');
assert.equal(furnitureArtIsLazy('sofa'), true);
assert.equal(furnitureArtIsLazy('no_such_symbol'), false, 'неизвестный ID — не символ');
assert.equal(furnitureGraphic('no_such_symbol'), null, 'unknown id is data, not a crash');
});
+88 -13
View File
@@ -3,6 +3,8 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, cpSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { GENERATED_FURNITURE_CATALOG } from '../test-build/furniture-plan-catalog.generated.js';
import { GENERATED_FURNITURE_ART } from '../test-build/furniture-plan-art.generated.js';
@@ -10,7 +12,7 @@ import { FURNITURE } from '../test-build/furniture.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const MANIFEST = JSON.parse(fs.readFileSync(
path.join(ROOT, 'assets', 'furniture', 'houseplan-0.3.0', 'pack.json'), 'utf8'));
path.join(ROOT, 'assets', 'furniture', 'houseplan-0.4.0', 'pack.json'), 'utf8'));
test('the vendored designer pack and generated modules stay in sync', () => {
const result = spawnSync(process.execPath, ['scripts/generate-furniture-assets.mjs', '--check'], {
@@ -18,23 +20,59 @@ test('the vendored designer pack and generated modules stay in sync', () => {
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /44 plan symbols, 33 menu icons/);
assert.match(result.stdout, /60 plan symbols, 33 menu icons/);
});
test('the designer pack has the reviewed cardinality and operations', () => {
assert.equal(GENERATED_FURNITURE_CATALOG.length, 44);
assert.equal(GENERATED_FURNITURE_CATALOG.length, 60);
// #474: catalogue ids and lazy artwork keys are the same set.
assert.deepEqual(Object.keys(GENERATED_FURNITURE_ART).sort(), GENERATED_FURNITURE_CATALOG.map((s) => s.id).sort());
assert.equal(MANIFEST.menu_icons.length, 33);
assert.equal(MANIFEST.symbols.filter((s) => s.operation === 'replace').length, 18);
assert.equal(MANIFEST.symbols.filter((s) => s.operation === 'add').length, 26);
assert.equal(FURNITURE.length, 56);
assert.equal(MANIFEST.symbols.filter((s) => s.operation === 'replace').length, 56);
assert.equal(MANIFEST.symbols.filter((s) => s.operation === 'add').length, 4);
// #593: библиотека РАВНА каталогу. Пока рядом жили 12 примитивов, она была
// объединением, и это объединение имело цену — см. следующий тест.
assert.equal(FURNITURE.length, 60);
});
test('menu-only artwork never becomes an empty user-facing category', () => {
// #593 AC2: пакет 0.4.0 перерисовывает 56 существующих публичных ID и добавляет
// ровно четыре. Ни один сохранённый план не должен потерять свой символ.
test('every public id of pack 0.3.0 survives, and exactly four are new', () => {
const RETIRED_PRIMITIVES = [
'fridge', 'dishwasher', 'washer', 'dryer', 'ac', 'water_heater',
'shower', 'sink', 'stairs', 'fireplace', 'plant', 'rug',
];
const ids = new Set(FURNITURE.map((symbol) => symbol.id));
// 12 идентификаторов, которые до #593 рисовались примитивами в стартовом графе
for (const id of RETIRED_PRIMITIVES) assert.equal(ids.has(id), true, id);
for (const id of ['sofa', 'bed_double', 'wall_unit', 'toilet_built_in']) assert.equal(ids.has(id), true, id);
assert.deepEqual(MANIFEST.symbols.filter((s) => s.operation === 'add').map((s) => s.id).sort(),
['cactus', 'computer', 'hood', 'oven']);
});
// #593 AC2/AC5: ловушка, ради которой примитивы удалены целиком. Обе половины
// прежнего союза несли ОДНИ И ТЕ ЖЕ публичные ID, а `BY_ID` оставлял последнюю
// запись — примитив молча побеждал дизайнерский рисунок, который его заменял.
test('the catalogue has no duplicate ids', () => {
const ids = FURNITURE.map((symbol) => symbol.id);
assert.equal(new Set(ids).size, ids.length,
`дубликаты: ${ids.filter((id, i) => ids.indexOf(id) !== i).join(', ')}`);
});
// #593 AC3: `cactus` живёт в категории «Растение», а не «Тренажёр»: плитка
// категории рисует тренажёр, и открывать её на кактус — врать о содержимом.
test('every category tile opens onto its own symbols, and cactus is a plant', () => {
const categories = new Set(FURNITURE.map((symbol) => symbol.category));
for (const id of ['computer', 'oven', 'hood', 'exercise']) assert.equal(categories.has(id), false);
for (const id of ['coffee_table', 'bed', 'kitchen_cabinet', 'toilet']) assert.equal(categories.has(id), true);
const menuIds = new Set(MANIFEST.menu_icons.map((icon) => icon.id));
const byMenuId = new Map(MANIFEST.menu_icons.map((icon) => [icon.id, icon]));
for (const symbol of MANIFEST.symbols) {
assert.equal(menuIds.has(symbol.menu_icon), true, `${symbol.id} → ${symbol.menu_icon}`);
assert.equal(byMenuId.get(symbol.menu_icon).group, symbol.group, symbol.id);
}
assert.equal(MANIFEST.symbols.find((s) => s.id === 'cactus').menu_icon, 'plant');
assert.equal(categories.has('exercise'), false, 'категория тренажёра пуста и потому скрыта');
assert.equal(categories.size, 32);
for (const id of ['computer', 'oven', 'hood']) assert.equal(categories.has(id), true, id);
});
test('front-view menu artwork is reachable only through the lazy editor graph', () => {
@@ -52,8 +90,45 @@ test('release provenance is normalized to the repository MIT grant', () => {
assert.equal(MANIFEST.author, 'Sergey Matyunin (Matysh)');
assert.equal(MANIFEST.license, 'MIT');
const readme = fs.readFileSync(
path.join(ROOT, 'assets', 'furniture', 'houseplan-0.3.0', 'README.md'), 'utf8');
assert.match(readme, /issuecomment-5454085168/);
assert.match(readme, /issuecomment-5449707137/);
assert.match(readme, /9E969016EE3B4B4E3DB776FEC53C8B387B91368B118EB5E39911483DEF1B0953/);
path.join(ROOT, 'assets', 'furniture', 'houseplan-0.4.0', 'README.md'), 'utf8');
// #593: документ провенанса переехал вместе с пакетом и НЕ ослаблен. Три
// строки те же по смыслу, что и у 0.3.0: грант владельца, имя проверенного
// архива и его SHA-256. Ровно они — единственная исполнимая защита от
// повторения истории #159, где авторство подтвердили в рабочей сессии.
assert.match(readme, /issuecomment-5739841899/);
assert.match(readme, /houseplan-furniture-0\.4\.0\.zip/);
assert.match(readme, /69BA5E0C398542D59F24269F637F57B8EBF31C2836C9D493F084AD29AB299FDE/);
assert.match(readme, /60 top-view drawings/);
});
// #593 AC1: «ровно 60 плановых символов» — единственное, что отличает
// проверенную поставку от произвольной папки с SVG. Утверждение стоит ровно
// столько, сколько стоит его проверка: генератор запускается на поставке, у
// которой символов 59, и обязан отказать.
test('the generator refuses a pack whose cardinality drifted', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'houseplan-pack-'));
try {
fs.mkdirSync(path.join(dir, 'scripts'));
for (const file of ['generate-furniture-assets.mjs', 'furniture-path-join.mjs', 'svg-path-bounds.mjs']) {
cpSync(path.join(ROOT, 'scripts', file), path.join(dir, 'scripts', file));
}
fs.mkdirSync(path.join(dir, 'src'));
const pack = path.join(dir, 'assets', 'furniture', 'houseplan-0.4.0');
cpSync(path.join(ROOT, 'assets', 'furniture', 'houseplan-0.4.0'), pack, { recursive: true });
// Контроль: нетронутая копия проходит. Без него отказ ниже мог бы быть
// отказом окружения, а не отказом по числу символов.
const clean = spawnSync(process.execPath, ['scripts/generate-furniture-assets.mjs'], { cwd: dir, encoding: 'utf8' });
assert.equal(clean.status, 0, clean.stderr || clean.stdout);
const manifest = JSON.parse(fs.readFileSync(path.join(pack, 'pack.json'), 'utf8'));
const dropped = manifest.symbols.pop();
fs.rmSync(path.join(pack, dropped.file));
fs.writeFileSync(path.join(pack, 'pack.json'), JSON.stringify(manifest, null, 2));
const drifted = spawnSync(process.execPath, ['scripts/generate-furniture-assets.mjs'], { cwd: dir, encoding: 'utf8' });
assert.notEqual(drifted.status, 0, 'поставка из 59 символов обязана быть отвергнута');
assert.match(`${drifted.stderr}${drifted.stdout}`, /exactly 60 plan symbols are required/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+46 -20
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { joinFurniturePaths } from '../scripts/furniture-path-join.mjs';
import { svgPathBounds } from '../scripts/svg-path-bounds.mjs';
@@ -33,25 +33,51 @@ test('AC4: путь без начального moveto — ошибка, а не
assert.throws(() => joinFurniturePaths(null), /has no paths/);
});
test('AC4: на реальном символе прежняя склейка уводила детали за viewBox', () => {
// `stairs` и `tv` из меню — единственные, у кого дефект был виден в числах:
// при простом `join(' ')` части рисунка уходили за границу 110 × 110.
for (const id of ['stairs', 'tv']) {
const source = readFileSync(new URL(`../assets/furniture/houseplan-0.3.0/svg/menu/${id}.svg`, import.meta.url), 'utf8');
const paths = [...source.matchAll(/(?:^|\s)d="([^"]*)"/g)].map((match) => match[1]);
assert.ok(paths.length > 1, `${id}: фикстура должна быть многопутёвой`);
const naive = svgPathBounds(paths.join(' '));
const fixed = svgPathBounds(joinFurniturePaths(paths));
const own = paths.map((d) => svgPathBounds(d)).reduce((a, b) => ({
minX: Math.min(a.minX, b.minX), minY: Math.min(a.minY, b.minY),
maxX: Math.max(a.maxX, b.maxX), maxY: Math.max(a.maxY, b.maxY),
}));
assert.ok(naive.maxX > 110 || naive.maxY > 110,
`${id}: прежняя склейка обязана выводить рисунок за viewBox, иначе фикстура не о том`);
for (const key of ['minX', 'minY', 'maxX', 'maxY']) {
assert.ok(Math.abs(fixed[key] - own[key]) < 1e-9,
`${id}.${key}: склейка ${fixed[key]} против «каждый путь из своего нуля» ${own[key]}`);
// #593: пакет 0.4.0 рисует каждый символ ОДНИМ путём — многопутёвых файлов,
// на которых дефект #584 был виден в числах, в нём не осталось. Поэтому
// свидетель разделён надвое: факт о поставке проверяется на поставке, а
// арифметика склейки — на реальных байтах пакета плюс относительный путь,
// который такая поставка однажды снова принесёт. Удалять свидетеля нельзя:
// склейка живёт в генераторе и обязана остаться правильной.
test('#593: в поставке 0.4.0 каждый символ — один путь, и склейка на нём тождественна', () => {
const dir = new URL('../assets/furniture/houseplan-0.4.0/svg/', import.meta.url);
let checked = 0;
for (const kind of ['menu', 'plan']) {
for (const file of readdirSync(new URL(`${kind}/`, dir))) {
const source = readFileSync(new URL(`${kind}/${file}`, dir), 'utf8');
const paths = [...source.matchAll(/(?:^|\s)d="([^"]*)"/g)].map((match) => match[1]);
assert.equal(paths.length, 1, `${kind}/${file}: ожидался ровно один путь`);
assert.equal(joinFurniturePaths(paths), paths[0].trim(),
`${kind}/${file}: склейка одного пути обязана быть тождественной`);
checked += 1;
}
assert.ok(fixed.maxX <= 110 && fixed.maxY <= 110, `${id}: рисунок обязан лежать внутри viewBox`);
}
assert.equal(checked, 93, 'проверены все файлы поставки');
});
test('AC4: на реальных байтах пакета склейка не даёт второму пути уехать', () => {
// Первый путь — настоящий, из поставки; второй начинается относительной `m`,
// как это было у `stairs` и `tv` в 0.3.0. Простой `join(' ')` продолжил бы
// координаты первого, и деталь ушла бы далеко за `viewBox` 110 × 110.
const source = readFileSync(
new URL('../assets/furniture/houseplan-0.4.0/svg/menu/stairs.svg', import.meta.url), 'utf8');
const real = [...source.matchAll(/(?:^|\s)d="([^"]*)"/g)].map((match) => match[1])[0];
const tail = 'm100 100 h5';
const own = svgPathBounds(`M ${tail.slice(1)}`);
assert.deepEqual([own.minX, own.minY, own.maxX, own.maxY], [100, 100, 105, 100],
'хвост сам по себе лежит там, где написан');
const naive = svgPathBounds([real, tail].join(' '));
const fixed = svgPathBounds(joinFurniturePaths([real, tail]));
// Простая конкатенация продолжает координаты первого пути: хвост уезжает
// ровно на его конечную точку, и обе стороны уходят за 110.
assert.ok(naive.maxX > 110 && naive.maxY > 110,
`прежняя склейка обязана уводить хвост за viewBox: ${naive.maxX} × ${naive.maxY}`);
// Правильная склейка кладёт хвост туда, где он написан, не трогая первый путь.
const realBounds = svgPathBounds(real);
assert.equal(fixed.maxX, Math.max(realBounds.maxX, own.maxX));
assert.equal(fixed.maxY, Math.max(realBounds.maxY, own.maxY));
assert.equal(fixed.minX, Math.min(realBounds.minX, own.minX));
assert.equal(fixed.minY, Math.min(realBounds.minY, own.minY));
assert.ok(fixed.maxX <= 110 && fixed.maxY <= 110, 'рисунок обязан лежать внутри viewBox');
});
+10 -12
View File
@@ -14,27 +14,25 @@ import { PLAN_BOUNDS_TOLERANCE } from '../scripts/generate-furniture-assets.mjs'
// Тесты на `viewBox` были зелёными, и предмет 60 × 60 всё равно выглядел
// меньше соседа 60 × 60.
/** Путь символа в его собственном боксе: дизайнерский art либо legacy-примитив. */
const artOf = (symbol) => (symbol.designer
? {
d: GENERATED_FURNITURE_ART[symbol.id].d,
w: GENERATED_FURNITURE_ART[symbol.id].viewW,
h: GENERATED_FURNITURE_ART[symbol.id].viewH,
}
// Legacy-символы рисуются в unit box и приходят тем же публичным путём, что
// и в продакшене.
: { d: furnitureGraphic(symbol.id).d, w: 1, h: 1 });
// #593: развилки «дизайнерский символ или legacy-примитив» больше нет — пакет
// 0.4.0 рисует все 60, и unit box исчез вместе с примитивами.
/** Путь символа в его собственном боксе. */
const artOf = (symbol) => ({
d: GENERATED_FURNITURE_ART[symbol.id].d,
w: GENERATED_FURNITURE_ART[symbol.id].viewW,
h: GENERATED_FURNITURE_ART[symbol.id].viewH,
});
test('AC1: рисунок каждого символа заполняет свой физический бокс', () => {
const misses = [];
for (const symbol of FURNITURE) {
const art = artOf(symbol);
const tolerance = symbol.designer ? PLAN_BOUNDS_TOLERANCE : PLAN_BOUNDS_TOLERANCE / Math.max(symbol.w, symbol.h);
const tolerance = PLAN_BOUNDS_TOLERANCE;
const deviation = boxFillDeviation(art.d, art.w, art.h);
if (deviation > tolerance) misses.push(`${symbol.id}: ${deviation.toFixed(4)} > ${tolerance.toFixed(4)}`);
}
assert.deepEqual(misses, [], 'символы с полями внутри своего бокса');
assert.equal(FURNITURE.length, 56, 'библиотека: 44 дизайнерских + 12 legacy');
assert.equal(FURNITURE.length, 60, 'библиотека: все 60 символов дизайнерские (#593)');
// Положительный контроль. Утверждение «полей нет» стоит ровно столько,
// сколько стоит измерение: та же функция на той же геометрии обязана поля
+52 -18
View File
@@ -5,7 +5,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
FURNITURE, FURNITURE_GROUPS, furnitureSymbol, furnitureOfGroup,
furnitureDefaultCm, furniturePathD, furnitureGraphic, furnitureCorners, furnitureResize,
furnitureDefaultCm, furniturePathD, furnitureGraphic, furnitureArtIsLazy, furnitureCorners, furnitureResize,
furniturePlanScreenScale, furnitureStrokePx,
resizeFurnitureTransform, furnitureRotationAngle, furnitureRenderTransform,
furnitureSignedFieldCm, furnitureSignedFieldValue,
@@ -42,21 +42,51 @@ test('every symbol is well formed: unique id, a known group, positive default si
assert.ok(s.w > 0 && s.h > 0, `${s.id}: default size must be positive`);
assert.match(s.category, /^[a-z0-9_]+$/, `${s.id}: invalid category`);
assert.ok(furnitureGraphic(s.id)?.d, `${s.id}: nothing to draw`);
if (s.g) {
// Retained primitive art stays in the legacy unit box.
for (const p of s.g) {
const nums = p.slice(1);
for (const v of nums) assert.ok(v >= -0.001 && v <= 1.001, `${s.id}: ${v} outside the unit box`);
}
} else {
const art = furnitureGraphic(s.id);
assert.equal(s.designer, true, `${s.id}: designer symbol without the lazy flag`);
assert.deepEqual([art.viewW, art.viewH], [s.w, s.h], `${s.id}: manifest and SVG viewBox differ`);
}
// #593: примитивов в unit box больше нет — у каждого символа свой
// физический viewBox, и он обязан совпадать с манифестом.
const art = furnitureGraphic(s.id);
assert.deepEqual([art.viewW, art.viewH], [s.w, s.h], `${s.id}: manifest and SVG viewBox differ`);
assert.equal(furnitureArtIsLazy(s.id), true, `${s.id}: artwork must come from the lazy chunk`);
}
assert.equal(FURNITURE.length, 56);
assert.equal(FURNITURE.filter((s) => s.designer).length, 44);
assert.equal(FURNITURE.filter((s) => s.g).length, 12);
assert.equal(FURNITURE.length, 60);
assert.equal(furnitureArtIsLazy('houseplan-no-such-symbol'), false);
});
// #593 AC4: пакет 0.4.0 перерисовывает 56 существующих ID, НЕ трогая их
// default-размеры. Таблица зашита в тест: сдвиг любого числа переписал бы
// габарит нового размещения у людей, которые ничего не меняли.
test('#593: default sizes of the 56 pre-existing ids are byte-for-byte the pack 0.3.0 values', () => {
const BEFORE = {
ac: [90, 25], armchair: [90, 90], armchair_office: [65, 65],
bathtub: [170, 75], bathtub_corner: [140, 140], bed_double: [160, 200],
bed_single: [90, 200], bidet: [40, 60], bidet_built_in: [40, 50],
bookshelf: [100, 35], cabinet_shoe: [80, 35], cabinet_sink: [80, 50],
cabinet_tv: [140, 45], chair: [50, 50], chair_bar: [45, 48],
coffee_table: [120, 60], coffee_table_oval: [120, 60], coffee_table_round: [80, 80],
coffee_table_rounded: [120, 60], cooktop_two: [30, 50], desk: [140, 70],
desk_corner: [160, 160], dishwasher: [60, 60], dryer: [60, 60],
fireplace: [120, 40], fridge: [60, 65], kitchen_floor: [60, 60],
kitchen_floor_corner: [90, 90], kitchen_sink: [60, 50], kitchen_sink_double: [90, 50],
kitchen_wall: [60, 35], kitchen_wall_corner: [60, 60], nightstand: [50, 40],
plant: [40, 40], rug: [200, 140], shelf_floor: [100, 35],
shelf_wall: [100, 25], shower: [90, 90], sink: [60, 45],
sofa: [180, 90], sofa_corner_right: [260, 170], sofa_three_seat: [240, 90],
stairs: [100, 280], stove: [60, 60], table_dining: [160, 90],
table_dining_oval: [180, 100], table_dining_rounded: [160, 90], table_round: [110, 110],
toilet: [40, 70], toilet_built_in: [40, 55], tv: [120, 28],
tv_wall: [120, 15], wall_unit: [240, 45], wardrobe: [180, 60],
washer: [60, 60], water_heater: [45, 45],
};
assert.equal(Object.keys(BEFORE).length, 56);
const drift = [];
for (const [id, [w, h]] of Object.entries(BEFORE)) {
const got = furnitureDefaultCm(id);
if (got.w !== w || got.h !== h) drift.push(`${id}: ${got.w}x${got.h} вместо ${w}x${h}`);
}
assert.deepEqual(drift, []);
// Новые ID — единственные, которых в таблице нет.
const added = FURNITURE.map((s) => s.id).filter((id) => !(id in BEFORE)).sort();
assert.deepEqual(added, ['cactus', 'computer', 'hood', 'oven']);
});
test('the three groups the owner named are all populated, and every symbol is in exactly one', () => {
@@ -125,12 +155,16 @@ test('sizes are clamped, not trusted', () => {
// ------------------------------- the drawing --------------------------------
test('designer paths keep their native viewBox; retained paths still scale from the unit box', () => {
test('every path keeps its native viewBox, including the twelve former primitives', () => {
const sofa = furnitureGraphic('sofa');
assert.deepEqual([sofa.viewW, sofa.viewH], [180, 90]);
assert.ok(sofa.d.length > 10);
const fridge = furniturePathD('fridge', 60, 65);
assert.ok(fridge.startsWith('M0 0H60V65H0Z'), fridge.slice(0, 40));
// #593: `fridge` раньше рисовался примитивом из unit box и начинался с
// `M0 0H60V65H0Z`. Теперь это дизайнерский рисунок в своём viewBox.
const fridge = furnitureGraphic('fridge');
assert.deepEqual([fridge.viewW, fridge.viewH], [60, 65]);
assert.ok(fridge.d.startsWith('M'), fridge.d.slice(0, 40));
assert.ok(!fridge.d.startsWith('M0 0H60V65H0Z'), 'примитив не должен пережить пакет 0.4.0');
// a degenerate box draws nothing rather than NaNs
assert.equal(furniturePathD('sofa', 0, 10), '');
assert.ok(!/NaN/.test(furniturePathD('toilet', 40, 70)));
+24 -2
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
@@ -31,9 +31,25 @@ function fixture({ platform = 'linux', schema = CAPTURE_PROVENANCE_SCHEMA, captu
const dir = mkdtempSync(resolve(tmpdir(), 'hp-golden-571-'));
mkdirSync(resolve(dir, 'actual'), { recursive: true });
const index = JSON.parse(readFileSync(resolve(BASELINES, 'baselines-index.json'), 'utf8'));
// #350/#593: сцена, добавленная в матрицу, живёт без эталона до первой
// приёмки. Настоящий прогон отчитывается по ней `missing-baseline`, и
// фикстура обязана моделировать это же, иначе `accept.mjs` справедливо
// ругается на неполный отчёт.
const results = GOLDEN_SCENARIOS.map((scenario) => {
const source = resolve(BASELINES, `${scenario.id}.png`);
const actual = resolve(dir, 'actual', `${scenario.id}.png`);
if (!existsSync(source)) {
// Кадр у новой сцены есть (его и смотрит ревьюер), эталона ещё нет.
// Байты берём у любой принятой сцены: провенанс не о содержимом кадра.
copyFileSync(resolve(BASELINES, `${GOLDEN_SCENARIOS[0].id}.png`), actual);
return {
id: scenario.id,
status: 'missing-baseline',
actualSha256: createHash('sha256').update(readFileSync(actual)).digest('hex'),
baselineSha256: null,
diffRatio: null,
};
}
copyFileSync(source, actual);
const sha = createHash('sha256').update(readFileSync(actual)).digest('hex');
return { id: scenario.id, status: 'passed', actualSha256: sha, baselineSha256: sha, diffRatio: 0 };
@@ -63,8 +79,14 @@ function accept(from, { reason = '', expectFailure = false } = {}) {
const env = { ...process.env };
if (reason) env.HP_ALLOW_FOREIGN_CAPTURE = reason; else delete env.HP_ALLOW_FOREIGN_CAPTURE;
try {
// Сцены без эталона объявляются явно: `--expect-new` — это утверждение
// «я посмотрел на новый кадр», и обойти его фикстура не должна.
const fresh = GOLDEN_SCENARIOS
.filter((scenario) => !existsSync(resolve(BASELINES, `${scenario.id}.png`)))
.map((scenario) => scenario.id);
const stdout = execFileSync(process.execPath,
[resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, `--baselines=${sandbox}`],
[resolve(ROOT, 'demo/golden/accept.mjs'), '--reviewed', `--from=${from}`, `--baselines=${sandbox}`,
...(fresh.length ? [`--expect-new=${fresh.join(',')}`] : [])],
{ cwd: ROOT, env, encoding: 'utf8' });
if (expectFailure) throw new Error('приёмка обязана была отказать, а прошла');
return { stdout, index: JSON.parse(readFileSync(resolve(sandbox, 'baselines-index.json'), 'utf8')), sandbox };
+1 -1
View File
@@ -443,7 +443,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 63);
assert.equal(GOLDEN_MATRIX_VERSION, 64);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
+5
View File
@@ -479,6 +479,9 @@ test('i18n: German values equal to English are explicitly reviewed', () => {
'gs.optimize_live_names',
'gs.optimize_details',
'furn.symbol',
// #593: 'Computer' одинаков в EN и DE — это немецкое слово, не пропуск перевода
'furn.cat_computer',
'furn.sym_computer',
'furn.cat_sofa',
'furn.cat_bidet',
'vac.diag_platform',
@@ -511,6 +514,8 @@ test('i18n: French values equal to English are explicitly reviewed (#371)', () =
'color_picker.confirm',
'color_picker.saturation',
'confirm.delete_partition_openings_item',
// #593: 'Cactus' пишется одинаково по-английски и по-французски
'furn.sym_cactus',
'decor.rect',
'decor.image',
'err.code',