Files
houseplan-card/test/furniture-art-runtime.test.mjs
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

210 lines
9.6 KiB
JavaScript
Executable File

// #474: lazy designer furniture artwork. The runtime mirrors LanguageRuntime /
// EditorRuntimeLoader: pending → ready | fallback, fallback is settled, the
// second attempt goes through a nonce URL, a foreign build is terminal, and
// the editor hands the artwork over synchronously.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
FurnitureArtRuntime, configNeedsFurnitureArt, furnitureArtBootPending,
ensureFurnitureArtFor, composeUnsub,
} from '../test-build/furniture-art-runtime.js';
import { resolveFurniturePlacement } from '../test-build/furniture-placement.js';
import { furnitureGraphic, furnitureArtIsLazy, furnitureSymbol } from '../test-build/furniture.js';
const ART = Object.freeze({ sofa: { d: 'M0 0h1', viewW: 180, viewH: 90 } });
const FP = 'build-A';
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const host = () => {
const h = { isConnected: true, updates: 0, requestUpdate() { this.updates++; } };
return h;
};
const runtime = (loads, extra = {}) => {
const attempts = [];
return {
attempts,
runtime: new FurnitureArtRuntime({
expectedFingerprint: FP,
warn: () => {},
load: async (attempt) => {
attempts.push(attempt);
const step = loads[attempts.length - 1];
if (step instanceof Error) throw step;
return step;
},
...extra,
}),
};
};
test('pending until ensure; a successful load installs the artwork and re-renders hosts', async () => {
const { runtime: r, attempts } = runtime([{ GENERATED_FURNITURE_ART: ART, FURNITURE_ART_FINGERPRINT: FP }]);
const h = host();
assert.equal(r.state(), 'pending');
assert.equal(r.art('sofa', h), undefined);
await r.ensure();
assert.equal(r.state(), 'ready');
assert.deepEqual(r.art('sofa'), ART.sofa);
assert.equal(h.updates, 1, 'the host that rendered while pending is re-rendered once');
await r.ensure();
assert.equal(attempts.length, 1, 'ensure is idempotent once settled');
});
test('two failed attempts settle into fallback: state is terminal-free, ensure resolves, hosts re-render', async () => {
const failed = [];
const { runtime: r, attempts } = runtime(
[new Error('net'), new Error('net again')],
{ loadFailed: (terminal) => failed.push(terminal) },
);
const h = host();
r.art('sofa', h);
await r.ensure();
assert.equal(r.state(), 'fallback');
assert.deepEqual(attempts, [0, 1], 'exactly one retry, through attempt 1');
assert.equal(r.terminal, false);
assert.deepEqual(failed, [false]);
assert.equal(h.updates, 1, 'fallback re-renders the waiting host so it stops waiting');
assert.equal(r.art('sofa'), undefined);
await r.ensure();
assert.equal(attempts.length, 2, 'fallback is settled: no background retries');
});
test('a foreign build fingerprint is terminal: no second import, fallback, loadFailed(true)', async () => {
const failed = [];
const { runtime: r, attempts } = runtime(
[{ GENERATED_FURNITURE_ART: ART, FURNITURE_ART_FINGERPRINT: 'build-B' }],
{ loadFailed: (terminal) => failed.push(terminal) },
);
await r.ensure();
assert.equal(r.state(), 'fallback');
assert.equal(r.terminal, true);
assert.deepEqual(attempts, [0], 'a fingerprint mismatch does not retry — only a refresh helps');
assert.deepEqual(failed, [true]);
});
test('the first attempt failing and the second succeeding is ready, not fallback', async () => {
const { runtime: r, attempts } = runtime([new Error('cached failure'), { GENERATED_FURNITURE_ART: ART, FURNITURE_ART_FINGERPRINT: FP }]);
await r.ensure();
assert.equal(r.state(), 'ready');
assert.deepEqual(attempts, [0, 1]);
});
test('adopt is synchronous, rejects a foreign fingerprint and is a no-op once ready', () => {
const warnings = [];
const { runtime: r } = runtime([], { warn: (message) => warnings.push(message) });
const h = host();
r.art('sofa', h);
assert.equal(r.adopt(ART, 'build-B'), false);
assert.equal(r.state(), 'pending');
assert.equal(warnings.length, 1);
assert.equal(r.adopt(ART, FP), true);
assert.equal(r.state(), 'ready');
assert.equal(h.updates, 1);
assert.equal(r.adopt({ other: { d: 'M0 0', viewW: 1, viewH: 1 } }, FP), true, 'repeated adopt is a no-op');
assert.equal(r.art('other'), undefined);
});
test('adopt during an in-flight load wins and the load result is not re-installed', async () => {
let release;
const gate = new Promise((resolve) => { release = resolve; });
const r = new FurnitureArtRuntime({
expectedFingerprint: FP, warn: () => {},
load: async () => { await gate; return { GENERATED_FURNITURE_ART: { late: { d: 'M0 0', viewW: 1, viewH: 1 } }, FURNITURE_ART_FINGERPRINT: FP }; },
});
const pending = r.ensure();
r.adopt(ART, FP);
release();
await pending;
assert.equal(r.state(), 'ready');
assert.deepEqual(r.art('sofa'), ART.sofa);
assert.equal(r.art('late'), undefined);
});
test('onSettled fires once when the runtime leaves pending, and immediately when already settled', async () => {
const { runtime: r } = runtime([{ GENERATED_FURNITURE_ART: ART, FURNITURE_ART_FINGERPRINT: FP }]);
const calls = [];
r.onSettled(() => calls.push('a'));
await r.ensure();
assert.deepEqual(calls, ['a']);
r.onSettled(() => calls.push('b'));
await tick();
assert.deepEqual(calls, ['a', 'b']);
});
test('only a single in-flight load exists, however many callers ensure()', async () => {
const { runtime: r, attempts } = runtime([{ GENERATED_FURNITURE_ART: ART, FURNITURE_ART_FINGERPRINT: FP }]);
await Promise.all([r.ensure(), r.ensure(), r.ensure()]);
assert.equal(attempts.length, 1);
});
// ------------------------------------------------------------ config helpers
const isDesigner = (symbol) => symbol === 'sofa';
test('configNeedsFurnitureArt: only designer furniture counts; legacy, other decor and empty plans do not', () => {
assert.equal(configNeedsFurnitureArt(null, isDesigner), false);
assert.equal(configNeedsFurnitureArt({ spaces: [] }, isDesigner), false);
assert.equal(configNeedsFurnitureArt({ spaces: [{ decor: [{ kind: 'rect' }] }] }, isDesigner), false);
assert.equal(configNeedsFurnitureArt({ spaces: [{ decor: [{ kind: 'furniture', symbol: 'fridge' }] }] }, isDesigner), false);
assert.equal(configNeedsFurnitureArt({ spaces: [null, { decor: null }, { decor: [{ kind: 'furniture', symbol: 'sofa' }] }] }, isDesigner), true);
});
test('boot gate waits only while pending AND the plan needs artwork; fallback counts as settled', async () => {
const withSofa = { spaces: [{ decor: [{ kind: 'furniture', symbol: 'sofa' }] }] };
const { runtime: pendingRuntime } = runtime([]);
assert.equal(furnitureArtBootPending(pendingRuntime, withSofa, isDesigner), true);
assert.equal(furnitureArtBootPending(pendingRuntime, { spaces: [] }, isDesigner), false, 'a plan without furniture never waits');
const { runtime: failed } = runtime([new Error('x'), new Error('y')]);
await failed.ensure();
assert.equal(furnitureArtBootPending(failed, withSofa, isDesigner), false, 'fallback lifts the veil');
const { runtime: ready } = runtime([]);
ready.adopt(ART, FP);
assert.equal(furnitureArtBootPending(ready, withSofa, isDesigner), false);
});
test('ensureFurnitureArtFor starts the load and registers the host only when the plan needs artwork', async () => {
const { runtime: r, attempts } = runtime([{ GENERATED_FURNITURE_ART: ART, FURNITURE_ART_FINGERPRINT: FP }]);
const h = host();
ensureFurnitureArtFor(r, { spaces: [] }, isDesigner, h);
await tick();
assert.equal(attempts.length, 0, 'no furniture — no request');
ensureFurnitureArtFor(r, { spaces: [{ decor: [{ kind: 'furniture', symbol: 'sofa' }] }] }, isDesigner, h);
await r.ensure();
assert.equal(attempts.length, 1);
assert.equal(h.updates, 1, 'the host registered at intake is re-rendered once the artwork lands');
});
test('composeUnsub calls every handle once', () => {
const calls = [];
composeUnsub(() => calls.push(1), () => calls.push(2))();
assert.deepEqual(calls, [1, 2]);
});
// ------------------------------------------------------- library integration
test('the wall magnet places a designer piece while its artwork is still pending (AC6)', () => {
// The real page runtime is untouched here: nothing adopted, nothing ensured.
assert.equal(furnitureArtIsLazy('sofa'), true);
assert.ok(furnitureSymbol('sofa'), 'catalogue is eager');
const placement = resolveFurniturePlacement({
symbol: 'sofa', widthCm: 180, depthCm: 90, point: [0.5, 0.5],
canvasW: 1000, canvasH: 1000, cellCm: 5, gridPitch: 20, walls: [], wallReach: 0, free: true,
});
assert.ok(placement, 'placement needs the catalogue, not the artwork');
assert.equal(resolveFurniturePlacement({
symbol: 'no_such_symbol', widthCm: 10, depthCm: 10, point: [0.5, 0.5],
canvasW: 1000, canvasH: 1000, cellCm: 5, gridPitch: 20, walls: [], wallReach: 0, free: true,
}), null, 'an unknown symbol is still refused');
});
// #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');
});