mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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
210 lines
9.6 KiB
JavaScript
Executable File
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');
|
|
});
|