mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
Встроенная библиотека мебели была смешанной: 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
84 lines
5.8 KiB
JavaScript
84 lines
5.8 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readdirSync, readFileSync } from 'node:fs';
|
||
import { joinFurniturePaths } from '../scripts/furniture-path-join.mjs';
|
||
import { svgPathBounds } from '../scripts/svg-path-bounds.mjs';
|
||
|
||
// #584. У каждого исходного `<path>` своя текущая точка, и начинается она в
|
||
// нуле. Генератор склеивал строки `d` простым `join(' ')`, поэтому второй путь,
|
||
// начинающийся относительной `m`, продолжал координаты первого. Рисунок уезжал
|
||
// молча: `viewBox` совпадал, метаданные совпадали, тесты были зелёными.
|
||
|
||
test('AC4: каждый исходный путь начинает свою текущую точку заново', () => {
|
||
assert.equal(joinFurniturePaths(['M3.765 40h2', 'm56.26 26.96l-3-3']),
|
||
'M3.765 40h2 M 56.26 26.96 l-3-3');
|
||
});
|
||
|
||
test('AC4: неявные пары после относительного m остаются относительными линиями', () => {
|
||
// `m10 20 5 6` — это перенос и ОТНОСИТЕЛЬНАЯ линия. Без явной `l` пара
|
||
// досталась бы канонизированной `M` и стала бы абсолютной точкой (5, 6).
|
||
assert.equal(joinFurniturePaths(['m10 20 5 6-2-3z']), 'M 10 20 l 5 6-2-3z');
|
||
assert.deepEqual(svgPathBounds(joinFurniturePaths(['m10 20 5 6-2-3z'])),
|
||
svgPathBounds('M10 20 l5 6-2-3z'));
|
||
});
|
||
|
||
test('AC4: относительные команды внутри пути смысла не меняют', () => {
|
||
assert.equal(joinFurniturePaths(['m1e1,-2.5m.5.6']), 'M 1e1 -2.5 m.5.6');
|
||
assert.equal(joinFurniturePaths(['M10 20 5 6Z']), 'M10 20 5 6Z');
|
||
});
|
||
|
||
test('AC4: путь без начального moveto — ошибка, а не тихая склейка', () => {
|
||
assert.throws(() => joinFurniturePaths(['L1 2']), /must begin with M or m/);
|
||
assert.throws(() => joinFurniturePaths([]), /has no paths/);
|
||
assert.throws(() => joinFurniturePaths(null), /has no paths/);
|
||
});
|
||
|
||
// #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.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');
|
||
});
|