Files
houseplan-card/test/furniture-path-join.test.mjs
Codex ef3bc6d133 fix: габарит мебели равен заявленным размерам (#584)
Указанные ширина и глубина теперь совпадают с видимым габаритом предмета.
Раньше рисунок занимал внутри своего бокса около 88 % стороны, поэтому
«Кухонный напольный модуль» 60 × 60 выглядел меньше «Посудомоечной машины»
60 × 60 примерно на 14 % по стороне и на 30 % по площади: цифры совпадали,
картинка врала.

Пак `fix-584-1` от дизайнера (issue, комментарий от 16.09) проверен исполнением
своей метрикой, а не его отчётом: границы считаются аналитически по той же
математике, что у продакшен-экспорта в PDF. Все 44 плановых SVG заполняют
`viewBox` с худшим отклонением 0.000000 при допуске ТЗ 0,1; `pack.json` и 33
иконки меню побайтно прежние. Для сравнения, прежний набор в `dev` давал 65
нарушений того же контракта.

Что сделано в коде:

- `scripts/svg-path-bounds.mjs` — границы рисунка по настоящим экстремумам
  кривых (дуги приводятся к кубикам). Контрольные точки в габарит не входят:
  иначе «заполняет бокс» проверялось бы по выпуклой оболочке и поля проходили
  бы мимо. Генератор — `.mjs` и обязан работать без сборки TypeScript, поэтому
  разбор здесь свой; тест сверяет его с `parseSvgPath` продакшена на всей
  библиотеке, чтобы реализации не разъехались молча.
- `scripts/furniture-path-join.mjs` — склейка нескольких `<path>` с
  канонизацией только начального `moveto`. Приём подсказан патчем в поставке
  дизайнера; реализация и свидетели свои. Прежний `paths.join(' ')` продолжал
  координаты предыдущего пути: замер показал, что у иконок меню `stairs` и `tv`
  части рисунка уезжали за `viewBox` 110 × 110 — до 119. Теперь склейка
  побайтно воспроизводит «каждый путь из своего нуля».
- генератор проверяет плановый символ по контракту: один путь, подконтуры с
  абсолютной `M`, рисунок заполняет `viewBox` (допуск 0,1).
- `src/furniture.ts` — legacy `plant` растянут от центра в 1/0.96. Это
  единственный из двенадцати retained-примитивов с полями (0.02…0.98 unit box);
  форма прежняя, габарит стал заявленным.

Свидетели: `test/furniture-visual-bounds.test.mjs` (AC1–AC3, AC5 и сверка двух
реализаций границ) и `test/furniture-path-join.test.mjs` (AC4, включая реальные
`stairs`/`tv`). У AC1 есть положительный контроль: та же функция обязана видеть
поля на геометрии из шапки issue — утверждение «полей нет» стоит ровно столько,
сколько стоит измерение. Мутанты `furniture-symbol-may-keep-inner-padding` и
`furniture-paths-joined-without-reset` возвращают прежнее поведение и краснеют;
прогнаны лично через `mutation-gate --id=`.

Миграции пользовательских данных нет: координаты, размеры, поворот и
зеркалирование сохраняются.

npm test 2743/2742/0 fail, typecheck чистый, furniture:check зелёный.

Issue: #584
User-Visible: yes
2026-09-16 23:02:11 +03:00

58 lines
3.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { 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/);
});
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]}`);
}
assert.ok(fixed.maxX <= 110 && fixed.maxY <= 110, `${id}: рисунок обязан лежать внутри viewBox`);
}
});