Files
houseplan-card/test/furniture-visual-bounds.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

232 lines
13 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 { readFileSync } from 'node:fs';
import { FURNITURE, furnitureGraphic, furnitureRenderTransform } from '../test-build/furniture.js';
import { GENERATED_FURNITURE_ART } from '../test-build/furniture-plan-art.generated.js';
import { parseSvgPath } from '../test-build/pdf/svg-path.js';
import { svgPathBounds, boxFillDeviation } from '../scripts/svg-path-bounds.mjs';
import { PLAN_BOUNDS_TOLERANCE } from '../scripts/generate-furniture-assets.mjs';
// #584. Контракт задачи говорит о ВИДИМОМ габарите, поэтому и проверять надо
// рисунок, а не метаданные: до этой задачи `viewBox` совпадал с каталогом у
// всех 44 дизайнерских символов, а рисунок внутри занимал около 88 % стороны.
// Тесты на `viewBox` были зелёными, и предмет 60 × 60 всё равно выглядел
// меньше соседа 60 × 60.
// #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 = 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, 60, 'библиотека: все 60 символов дизайнерские (#593)');
// Положительный контроль. Утверждение «полей нет» стоит ровно столько,
// сколько стоит измерение: та же функция на той же геометрии обязана поля
// УВИДЕТЬ. Числа — из шапки issue: рисунок `kitchen_floor` занимал
// 52,615 × 52,615 внутри viewBox 60 × 60.
const padded = 'M 3.6925 3.6925 L 56.3075 3.6925 L 56.3075 56.3075 L 3.6925 56.3075 Z';
const paddedDeviation = boxFillDeviation(padded, 60, 60);
assert.ok(paddedDeviation > PLAN_BOUNDS_TOLERANCE,
`измерение обязано видеть поля, а вернуло ${paddedDeviation}`);
assert.ok(Math.abs(paddedDeviation - 3.6925) < 1e-9, 'поля меряются, а не округляются');
assert.equal(boxFillDeviation('M 0 0 L 60 0 L 60 60 L 0 60 Z', 60, 60), 0);
});
test('AC2: одинаковые размеры дают одинаковый видимый габарит', () => {
// Пара из шапки issue: оба предмета 60 × 60, но посудомойка была крупнее.
const floor = FURNITURE.find((s) => s.id === 'kitchen_floor');
const dishwasher = FURNITURE.find((s) => s.id === 'dishwasher');
assert.deepEqual([floor.w, floor.h], [60, 60]);
assert.deepEqual([dishwasher.w, dishwasher.h], [60, 60]);
const extent = (symbol) => {
const art = artOf(symbol);
const bounds = svgPathBounds(art.d);
return [
((bounds.maxX - bounds.minX) / art.w) * symbol.w,
((bounds.maxY - bounds.minY) / art.h) * symbol.h,
];
};
const [floorW, floorH] = extent(floor);
const [dishW, dishH] = extent(dishwasher);
assert.ok(Math.abs(floorW - dishW) <= PLAN_BOUNDS_TOLERANCE, `ширина ${floorW} против ${dishW}`);
assert.ok(Math.abs(floorH - dishH) <= PLAN_BOUNDS_TOLERANCE, `глубина ${floorH} против ${dishH}`);
assert.ok(Math.abs(floorW - 60) <= PLAN_BOUNDS_TOLERANCE, 'кухонный модуль занимает заявленные 60 см');
});
test('AC3: после продакшен-трансформа геометрия занимает бокс фигуры', () => {
const symbol = FURNITURE.find((s) => s.id === 'kitchen_floor');
const art = artOf(symbol);
const canvasW = 1000, canvasH = 800;
const shape = { x: 0.2, y: 0.1, w: 0.3, h: 0.25 };
const box = { x: shape.x * canvasW, y: shape.y * canvasH, w: shape.w * canvasW, h: shape.h * canvasH };
const tolerance = 0.1 * Math.max(box.w / symbol.w, box.h / symbol.h);
// Прямые углы: габарит рисунка совпадает с боксом фигуры. Зеркалирование —
// отражение того же бокса, поэтому равенство обязано сохраниться.
for (const angle of [0, 90, 180, 270]) {
for (const flip_h of [false, true]) {
for (const flip_v of [false, true]) {
const transform = furnitureRenderTransform({ ...shape, angle, flip_h, flip_v }, canvasW, canvasH, art.w, art.h);
const bounds = transformedBounds(art.d, transform);
const expected = rotatedBox(box, angle);
const state = `angle=${angle} flip=${flip_h ? 'h' : ''}${flip_v ? 'v' : ''}`;
for (const [key, value] of Object.entries(expected)) {
assert.ok(Math.abs(bounds[key] - value) <= tolerance,
`${state}: ${key} ${bounds[key].toFixed(3)} против ${value.toFixed(3)}`);
}
}
}
}
// Произвольный угол: габарит рисунка обязан ЛЕЖАТЬ в повёрнутом боксе.
// Равенства здесь и не должно быть — у скруглённых углов повёрнутый рисунок
// занимает чуть меньше, чем повёрнутый прямоугольник, и это геометрия, а не
// поля внутри символа.
for (const angle of [15, 37]) {
const transform = furnitureRenderTransform({ ...shape, angle, flip_h: false, flip_v: false }, canvasW, canvasH, art.w, art.h);
const bounds = transformedBounds(art.d, transform);
const expected = rotatedBox(box, angle);
const inside = bounds.minX >= expected.minX - tolerance && bounds.maxX <= expected.maxX + tolerance
&& bounds.minY >= expected.minY - tolerance && bounds.maxY <= expected.maxY + tolerance;
assert.ok(inside, `angle=${angle}: рисунок вышел за повёрнутый бокс`);
const cover = (bounds.maxX - bounds.minX) / (expected.maxX - expected.minX);
assert.ok(cover > 0.97, `angle=${angle}: рисунок занимает лишь ${(cover * 100).toFixed(1)} % повёрнутого бокса`);
}
});
/**
* Тот же разбор трансформа, что применяет браузер: rotate → translate → scale.
* Аффинное преобразование переносит контрольные точки кубик, поэтому границы
* считаются по настоящим экстремумам уже преобразованной кривой, а не по
* выпуклой оболочке: иначе скруглённый угол дал бы габарит больше бокса.
*/
function transformedBounds(d, transform) {
const rotate = /rotate\(([-\d.]+) ([-\d.]+) ([-\d.]+)\)/.exec(transform);
const translate = /translate\(([-\d.]+) ([-\d.]+)\)/.exec(transform);
const scale = /scale\(([-\d.]+) ([-\d.]+)\)/.exec(transform);
const apply = (x, y) => {
let px = x * Number(scale[1]) + Number(translate[1]);
let py = y * Number(scale[2]) + Number(translate[2]);
if (rotate) {
const radians = (Number(rotate[1]) * Math.PI) / 180;
const cx = Number(rotate[2]), cy = Number(rotate[3]);
const dx = px - cx, dy = py - cy;
px = cx + dx * Math.cos(radians) - dy * Math.sin(radians);
py = cy + dx * Math.sin(radians) + dy * Math.cos(radians);
}
return [px, py];
};
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const noteX = (v) => { minX = Math.min(minX, v); maxX = Math.max(maxX, v); };
const noteY = (v) => { minY = Math.min(minY, v); maxY = Math.max(maxY, v); };
const extrema = (p0, p1, p2, p3) => {
const values = [p0, p3];
const a = -p0 + 3 * p1 - 3 * p2 + p3, b = 2 * (p0 - 2 * p1 + p2), c = p1 - p0;
const at = (t) => { if (t > 0 && t < 1) { const u = 1 - t;
values.push(u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3); } };
if (Math.abs(a) < 1e-12) { if (Math.abs(b) > 1e-12) at(-c / b); }
else { const disc = b * b - 4 * a * c; if (disc >= 0) { const r = Math.sqrt(disc);
at((-b + r) / (2 * a)); at((-b - r) / (2 * a)); } }
return values;
};
let cur = [0, 0];
for (const op of parseSvgPath(d)) {
if (op.op === 'Z') continue;
if (op.op === 'M' || op.op === 'L') {
const [x, y] = apply(op.x, op.y);
noteX(x); noteY(y); cur = [x, y];
continue;
}
const [x1, y1] = apply(op.x1, op.y1);
const [x2, y2] = apply(op.x2, op.y2);
const [x, y] = apply(op.x, op.y);
for (const v of extrema(cur[0], x1, x2, x)) noteX(v);
for (const v of extrema(cur[1], y1, y2, y)) noteY(v);
cur = [x, y];
}
return { minX, minY, maxX, maxY };
}
/** Габарит повёрнутого прямоугольника — то, что бокс занимает на экране. */
function rotatedBox(box, angle) {
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
const radians = (angle * Math.PI) / 180;
const corners = [[box.x, box.y], [box.x + box.w, box.y], [box.x, box.y + box.h], [box.x + box.w, box.y + box.h]];
const points = corners.map(([x, y]) => {
const dx = x - cx, dy = y - cy;
return [cx + dx * Math.cos(radians) - dy * Math.sin(radians), cy + dx * Math.sin(radians) + dy * Math.cos(radians)];
});
return {
minX: Math.min(...points.map((p) => p[0])), maxX: Math.max(...points.map((p) => p[0])),
minY: Math.min(...points.map((p) => p[1])), maxY: Math.max(...points.map((p) => p[1])),
};
}
test('границы считаются той же математикой, что у продакшен-экспорта', () => {
// Генератор — .mjs и работает без сборки TypeScript, поэтому у него свой
// разбор пути. Две реализации не имеют права разъехаться молча: сверяем их
// на всей библиотеке.
for (const symbol of FURNITURE) {
const art = artOf(symbol);
const mine = svgPathBounds(art.d);
const production = productionBounds(art.d);
for (const key of ['minX', 'minY', 'maxX', 'maxY']) {
assert.ok(Math.abs(mine[key] - production[key]) < 1e-6,
`${symbol.id}.${key}: ${mine[key]} против ${production[key]}`);
}
}
});
/** Границы по операциям продакшен-парсера: те же экстремумы кубик. */
function productionBounds(d) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
let cur = [0, 0];
const noteX = (v) => { minX = Math.min(minX, v); maxX = Math.max(maxX, v); };
const noteY = (v) => { minY = Math.min(minY, v); maxY = Math.max(maxY, v); };
const extrema = (p0, p1, p2, p3) => {
const values = [p0, p3];
const a = -p0 + 3 * p1 - 3 * p2 + p3, b = 2 * (p0 - 2 * p1 + p2), c = p1 - p0;
const at = (t) => { if (t > 0 && t < 1) { const u = 1 - t;
values.push(u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3); } };
if (Math.abs(a) < 1e-12) { if (Math.abs(b) > 1e-12) at(-c / b); }
else { const disc = b * b - 4 * a * c; if (disc >= 0) { const r = Math.sqrt(disc);
at((-b + r) / (2 * a)); at((-b - r) / (2 * a)); } }
return values;
};
for (const op of parseSvgPath(d)) {
if (op.op === 'M' || op.op === 'L') { noteX(op.x); noteY(op.y); cur = [op.x, op.y]; }
else if (op.op === 'C') {
for (const v of extrema(cur[0], op.x1, op.x2, op.x)) noteX(v);
for (const v of extrema(cur[1], op.y1, op.y2, op.y)) noteY(v);
cur = [op.x, op.y];
}
}
return { minX, minY, maxX, maxY };
}
test('AC5: гейт пака читает репозиторий и падает на символе с полями', async () => {
const generator = readFileSync(new URL('../scripts/generate-furniture-assets.mjs', import.meta.url), 'utf8');
assert.match(generator, /boxFillDeviation/, 'проверка границ обязана быть в гейте, а не только в тестах');
assert.match(generator, /plan symbol must be a single path/);
assert.match(generator, /plan subpaths must start with an absolute M/);
assert.equal(PLAN_BOUNDS_TOLERANCE, 0.1, 'допуск контракта из ТЗ дизайнеру');
// Символ с полями внутри бокса обязан отвергаться: это и есть дефект #584.
const padded = 'M 5 5 L 55 5 L 55 55 L 5 55 Z';
assert.ok(boxFillDeviation(padded, 60, 60) > PLAN_BOUNDS_TOLERANCE);
assert.ok(boxFillDeviation('M 0 0 L 60 0 L 60 60 L 0 60 Z', 60, 60) <= PLAN_BOUNDS_TOLERANCE);
});