Files
houseplan-card/test/furniture-assets.test.mjs

177 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, rmSync, cpSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { GENERATED_FURNITURE_CATALOG } from '../test-build/furniture-plan-catalog.generated.js';
import { GENERATED_FURNITURE_ART } from '../test-build/furniture-plan-art.generated.js';
import { FURNITURE, furnitureArtIsLazy, furnitureSymbol } from '../test-build/furniture.js';
import { canonicalFurnitureId } from '../test-build/furniture-id.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OLD_PACK = path.join(ROOT, 'assets', 'furniture', 'houseplan-0.4.0');
const PACK = path.join(ROOT, 'assets', 'furniture', 'houseplan-0.4.1');
const MANIFEST = JSON.parse(fs.readFileSync(
path.join(PACK, 'pack.json'), 'utf8'));
test('the vendored designer pack and generated modules stay in sync', () => {
const result = spawnSync(process.execPath, ['scripts/generate-furniture-assets.mjs', '--check'], {
cwd: ROOT,
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /60 plan symbols, 33 menu icons/);
});
test('the designer pack has the reviewed cardinality and operations', () => {
assert.equal(MANIFEST.pack_version, '0.4.1');
assert.equal(GENERATED_FURNITURE_CATALOG.length, 60);
// #474: catalogue ids and lazy artwork keys are the same set.
assert.deepEqual(Object.keys(GENERATED_FURNITURE_ART).sort(), GENERATED_FURNITURE_CATALOG.map((s) => s.id).sort());
assert.equal(MANIFEST.menu_icons.length, 33);
assert.equal(MANIFEST.symbols.filter((s) => s.operation === 'replace').length, 56);
assert.equal(MANIFEST.symbols.filter((s) => s.operation === 'add').length, 4);
// #593: библиотека РАВНА каталогу. Пока рядом жили 12 примитивов, она была
// объединением, и это объединение имело цену — см. следующий тест.
assert.equal(FURNITURE.length, 60);
});
// #593 keeps 56 earlier ids; #606 replaces only the mislabeled 0.4.0 cactus
// with a new public exercise id and resolves saved cactus at read time.
test('the current catalogue has 60 unique visible ids and the legacy alias stays readable', () => {
const RETIRED_PRIMITIVES = [
'fridge', 'dishwasher', 'washer', 'dryer', 'ac', 'water_heater',
'shower', 'sink', 'stairs', 'fireplace', 'plant', 'rug',
];
const ids = new Set(FURNITURE.map((symbol) => symbol.id));
// 12 идентификаторов, которые до #593 рисовались примитивами в стартовом графе
for (const id of RETIRED_PRIMITIVES) assert.equal(ids.has(id), true, id);
for (const id of ['sofa', 'bed_double', 'wall_unit', 'toilet_built_in']) assert.equal(ids.has(id), true, id);
assert.deepEqual(MANIFEST.symbols.filter((s) => s.operation === 'add').map((s) => s.id).sort(),
['computer', 'exercise', 'hood', 'oven']);
assert.equal(ids.has('cactus'), false);
assert.equal(ids.has('exercise'), true);
assert.equal(canonicalFurnitureId('cactus'), 'exercise');
assert.equal(canonicalFurnitureId('unknown'), 'unknown');
assert.equal(furnitureSymbol('cactus')?.id, 'exercise');
assert.equal(furnitureArtIsLazy('cactus'), true, 'legacy object must load the art chunk before first View frame');
assert.equal(furnitureSymbol('unknown'), null);
});
// #593 AC2/AC5: ловушка, ради которой примитивы удалены целиком. Обе половины
// прежнего союза несли ОДНИ И ТЕ ЖЕ публичные ID, а `BY_ID` оставлял последнюю
// запись — примитив молча побеждал дизайнерский рисунок, который его заменял.
test('the catalogue has no duplicate ids', () => {
const ids = FURNITURE.map((symbol) => symbol.id);
assert.equal(new Set(ids).size, ids.length,
`дубликаты: ${ids.filter((id, i) => ids.indexOf(id) !== i).join(', ')}`);
});
// #606 reverses the bad #593 mapping: all 33 tiles must open onto real art.
test('all 33 category tiles have variants and exercise does not appear among plants', () => {
const categories = new Set(FURNITURE.map((symbol) => symbol.category));
const menuIds = new Set(MANIFEST.menu_icons.map((icon) => icon.id));
const byMenuId = new Map(MANIFEST.menu_icons.map((icon) => [icon.id, icon]));
for (const symbol of MANIFEST.symbols) {
assert.equal(menuIds.has(symbol.menu_icon), true, `${symbol.id} → ${symbol.menu_icon}`);
assert.equal(byMenuId.get(symbol.menu_icon).group, symbol.group, symbol.id);
}
assert.equal(MANIFEST.symbols.find((s) => s.id === 'exercise').menu_icon, 'exercise');
assert.deepEqual(MANIFEST.symbols.filter((s) => s.menu_icon === 'plant').map((s) => s.id), ['plant']);
assert.deepEqual(MANIFEST.symbols.filter((s) => s.menu_icon === 'exercise').map((s) => s.id), ['exercise']);
assert.equal(categories.size, 33);
for (const id of ['computer', 'oven', 'hood']) assert.equal(categories.has(id), true, id);
});
test('0.4.1 changes only the reviewed plan drawings and dishwasher menu art', () => {
const oldPlan = (id) => fs.readFileSync(path.join(OLD_PACK, 'svg', 'plan', `${id}.svg`), 'utf8');
const newPlan = (id) => fs.readFileSync(path.join(PACK, 'svg', 'plan', `${id}.svg`), 'utf8');
assert.equal(newPlan('exercise'), oldPlan('cactus'));
assert.equal(newPlan('bookshelf'), oldPlan('shelf_floor'));
assert.equal(newPlan('shelf_floor'), oldPlan('bookshelf'));
assert.equal(fs.existsSync(path.join(PACK, 'svg', 'plan', 'cactus.svg')), false);
const before = fs.readdirSync(path.join(OLD_PACK, 'svg', 'plan')).filter((s) => s.endsWith('.svg'));
for (const name of before) {
if (['cactus.svg', 'bookshelf.svg', 'shelf_floor.svg'].includes(name)) continue;
assert.equal(newPlan(name.slice(0, -4)), oldPlan(name.slice(0, -4)), name);
}
for (const name of fs.readdirSync(path.join(OLD_PACK, 'svg', 'menu'))) {
if (name === 'dishwasher.svg') continue;
assert.equal(fs.readFileSync(path.join(PACK, 'svg', 'menu', name), 'utf8'),
fs.readFileSync(path.join(OLD_PACK, 'svg', 'menu', name), 'utf8'), name);
}
const dishwasherMenu = fs.readFileSync(path.join(PACK, 'svg', 'menu', 'dishwasher.svg'), 'utf8');
assert.notEqual(dishwasherMenu,
fs.readFileSync(path.join(OLD_PACK, 'svg', 'menu', 'dishwasher.svg'), 'utf8'));
assert.equal(createHash('sha256').update(dishwasherMenu.replaceAll('\r\n', '\n')).digest('hex'),
'dd8bceaf329e0c78f9f5c3d7684f69cecc1adbfb6f2cd108306bf70b7c483048');
assert.match(dishwasherMenu, /stroke="currentColor"/);
});
test('front-view menu artwork is reachable only through the lazy editor graph', () => {
const card = fs.readFileSync(path.join(ROOT, 'src', 'houseplan-card.ts'), 'utf8');
const furniture = fs.readFileSync(path.join(ROOT, 'src', 'furniture.ts'), 'utf8');
const runtime = fs.readFileSync(path.join(ROOT, 'src', 'houseplan-editor-runtime.ts'), 'utf8');
const decorImageEditor = fs.readFileSync(path.join(ROOT, 'src', 'decor-image-editor.ts'), 'utf8');
assert.doesNotMatch(card, /furniture-menu-art\.generated/);
assert.doesNotMatch(furniture, /furniture-menu-art\.generated/);
assert.match(runtime, /from '\.\/decor-image-editor'/);
assert.match(decorImageEditor, /from '\.\/furniture-menu-art\.generated'/);
});
test('release provenance is normalized to the repository MIT grant', () => {
assert.equal(MANIFEST.author, 'Sergey Matyunin (Matysh)');
assert.equal(MANIFEST.license, 'MIT');
const readme = fs.readFileSync(
path.join(PACK, 'README.md'), 'utf8');
// #593: документ провенанса переехал вместе с пакетом и НЕ ослаблен. Три
// строки те же по смыслу, что и у 0.3.0: грант владельца, имя проверенного
// архива и его SHA-256. Ровно они — единственная исполнимая защита от
// повторения истории #159, где авторство подтвердили в рабочей сессии.
assert.match(readme, /issuecomment-5739841899/);
assert.match(readme, /houseplan-furniture-0\.4\.0\.zip/);
assert.match(readme, /69BA5E0C398542D59F24269F637F57B8EBF31C2836C9D493F084AD29AB299FDE/);
assert.match(readme, /60 top-view drawings/);
assert.match(readme, /corrected derivative/i);
assert.match(readme, /issue #640/);
assert.match(readme, /dishwasher\.zip/);
assert.match(readme, /1A9B1BDAF490B35812F51E4DD3CB1C425224EC768219C31B979DD6A84E890203/);
assert.match(readme, /JustBusiness supplied this asset/);
});
// #593 AC1: «ровно 60 плановых символов» — единственное, что отличает
// проверенную поставку от произвольной папки с SVG. Утверждение стоит ровно
// столько, сколько стоит его проверка: генератор запускается на поставке, у
// которой символов 59, и обязан отказать.
test('the generator refuses a pack whose cardinality drifted', () => {
const dir = mkdtempSync(path.join(tmpdir(), 'houseplan-pack-'));
try {
fs.mkdirSync(path.join(dir, 'scripts'));
for (const file of ['generate-furniture-assets.mjs', 'furniture-path-join.mjs', 'svg-path-bounds.mjs']) {
cpSync(path.join(ROOT, 'scripts', file), path.join(dir, 'scripts', file));
}
fs.mkdirSync(path.join(dir, 'src'));
const pack = path.join(dir, 'assets', 'furniture', 'houseplan-0.4.1');
cpSync(PACK, pack, { recursive: true });
// Контроль: нетронутая копия проходит. Без него отказ ниже мог бы быть
// отказом окружения, а не отказом по числу символов.
const clean = spawnSync(process.execPath, ['scripts/generate-furniture-assets.mjs'], { cwd: dir, encoding: 'utf8' });
assert.equal(clean.status, 0, clean.stderr || clean.stdout);
const manifest = JSON.parse(fs.readFileSync(path.join(pack, 'pack.json'), 'utf8'));
const dropped = manifest.symbols.pop();
fs.rmSync(path.join(pack, dropped.file));
fs.writeFileSync(path.join(pack, 'pack.json'), JSON.stringify(manifest, null, 2));
const drifted = spawnSync(process.execPath, ['scripts/generate-furniture-assets.mjs'], { cwd: dir, encoding: 'utf8' });
assert.notEqual(drifted.status, 0, 'поставка из 59 символов обязана быть отвергнута');
assert.match(`${drifted.stderr}${drifted.stdout}`, /exactly 60 plan symbols are required/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});