Files
houseplan-card/test/furniture-path-join.test.mjs
T
Claude 7df2740f1c 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
2026-09-19 13:38:49 +03:00

84 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});