Files
houseplan-card/test/decor-image-upload.test.mjs
T
Claude d1091b6d47 test(decor): путь загрузки картинки под автотестом
`src/backdrop-pick.ts` и `src/decor-image-editor.ts` не входили в
tsconfig.test.json — юнитов у них не было вовсе. Смок при этом подменял
`hass.callWS` целиком и до `uploadFromInput`, `upload`, `delete` не
доходил; в шапке файла это было честно написано. AC1 #51 обещал
доказательство «component unit + smoke» — не было ни того, ни другого.

Цена известна: #427 — файл тяжелее 2 МиБ нельзя было добавить ничем,
гасли обе кнопки диалога, включая «уменьшить копию», — прожил четыре
круга ревью именно потому, что этот путь не проверяло ничто.

Оба файла теперь в tsconfig.test.json (компиляция +6 с, без правок
продуктового кода) и покрыты 13 юнитами:

- классификация: проходной файл, тяжелее лимита источника (guard с
  probe.kind='safe' — предупреждение о размере, не об отказе), warn,
  hard, unknown, неподдерживаемый формат;
- состав кнопок диалога на трёх случаях. Декор свыше лимита: «Отмена» и
  «уменьшить копию», без «оставить оригинал» — это и есть #427. Подложка
  на том же файле сохраняет обе кнопки, hard остаётся с одной «Отмена»,
  unknown предупреждает, а не проходит тихо;
- uploadFromInput: сброс input.value, молчание на занятом, отказ формата,
  исключение классификации, передача флага замены в оба слота;
- upload: multipart-поле и URL, порядок каталога, палитра против замены
  ссылки в диалоге, восемь ответов бэкенда с разными текстами, отпускание
  busy на отказе, запрет второго запроса поверх незакончившегося;
- delete: ссылки на ассет, отказ в подтверждении, чистка каталога, карты
  и палитры, различение in_use, сохранность каталога на отказе сервера;
- loadCatalog: отбор валидных строк и тост на отказе.

Смок `smoke_decor_images.mjs` получил последний блок, который проходит
настоящие uploadFromInput → upload → delete в собранном бандле, подменяя
только транспорт: fetchWithAuth и одну ветку callWS.

Свидетели (§2.7, #435). Девять мутаций прогнаны на собранном дереве,
каждая краснит свой тест: условие #427 → тест состава кнобок; снятие
лимита источника; input.value; флаг замены; too_large; busy в upload;
подтверждение удаления; used_by; проглоченный отказ каталога. Мутация
busy сначала вешала прогон и уносила пять тестов cancelledByParent —
тест переписан так, чтобы падать на сравнении, а не по таймауту.

В реестр добавлены три мутанта на продуктовый код: условие #427, потеря
флага замены, схлопывание кодов ошибок.

Гейты: npm test 1806 tests, 1805 pass, 0 fail (было 1791);
mutation-gate --check зелёный. Смок локально не прогнать — Chromium в
песочнице нет, проверит CI.

Issue: #433
User-Visible: no
2026-09-03 13:21:08 +03:00

401 lines
19 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.
/**
* #433: путь загрузки декор-картинки под автотестом.
*
* До этой задачи `src/backdrop-pick.ts` и `src/decor-image-editor.ts` не
* входили в `tsconfig.test.json` — юнитов у них не было вовсе, а смоки
* подменяли `hass.callWS` и до `uploadFromInput`/`upload`/`delete` не
* доходили. Цена известна: #427 — файл тяжелее 2 МиБ нельзя было добавить
* ничем, гасли обе кнопки диалога, — прожил четыре круга ревью.
*
* Здесь проверяется то, что не требует браузера: классификация файла, состав
* кнопок диалога-предупреждения и обе сетевые ноги (upload, delete) с
* подставным транспортом. Браузерная часть — `demo/smoke_decor_images.mjs` и
* `demo/smoke_backdrop_guard.mjs`.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { classifyPlanFile, planFileExt, renderBackdropGuard } from '../test-build/backdrop-pick.js';
import { DecorImageEditor } from '../test-build/decor-image-editor.js';
const ASSET_LIMIT = 2 * 1024 * 1024;
const id = (char) => char.repeat(64);
/** PNG ровно до конца IHDR: probeBackdrop читает только заголовок. */
const pngBytes = (width, height, { colourType = 2, pad = 0 } = {}) => {
const head = new Uint8Array(33 + pad);
head.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
head.set([0, 0, 0, 13], 8);
head.set([0x49, 0x48, 0x44, 0x52], 12); // IHDR
new DataView(head.buffer).setUint32(16, width);
new DataView(head.buffer).setUint32(20, height);
head[24] = 8;
head[25] = colourType;
return head;
};
const pngFile = (width, height, options = {}) => new File(
[pngBytes(width, height, options)], options.name || 'plan.png', { type: 'image/png' },
);
/** i18n-ключи из TemplateResult: `_t` в тестах возвращает сам ключ. */
const keysOf = (template) => {
const out = [];
const walk = (value) => {
if (value == null || typeof value === 'boolean') return;
if (Array.isArray(value)) { value.forEach(walk); return; }
if (typeof value === 'object') {
if (Array.isArray(value.values)) value.values.forEach(walk);
return;
}
if (typeof value === 'string') out.push(value);
};
walk(template);
return out;
};
const guardHost = (guard) => ({
_t: (key) => key,
_showToast: () => {},
_backdropGuard: guard,
requestUpdate: () => {},
});
const editorHost = (over = {}) => ({
_t: (key, vars) => (vars ? `${key} ${JSON.stringify(vars)}` : key),
_errText: (error) => String(error?.message ?? error),
_showToast(text) { this.toasts.push(text); },
toasts: [],
_decorAssetBusy: false,
_backdropGuard: null,
_decorAssets: new Map(),
_decorAssetCatalog: [],
_decorImagePalette: null,
_decorShapeDialog: null,
requestUpdate: () => {},
hass: {},
...over,
});
const editorOf = (host, hooks = {}) => new DecorImageEditor(host, {
decorSnap: (raw) => raw,
geometrySnapshot: () => null,
clearFurniturePreview: () => {},
recordGeometry: () => {},
saveConfig: () => {},
saveShape: () => {},
setGuardReplace: () => {},
furnShiftDetach: () => {},
furnPick: () => {},
furnFieldValue: (cm) => cm,
furnFieldToCm: (value) => value,
...hooks,
});
const catalogRow = (asset_id = id('a'), over = {}) => ({
asset_id,
name: 'plan.png',
mime: 'image/png',
width: 100,
height: 100,
bytes: 33,
url: `/api/houseplan/content/assets/_/${asset_id}.png`,
...over,
});
// --- классификация ----------------------------------------------------------
test('#433 классификация различает проходной файл, лимит источника и отказ', async () => {
assert.equal(planFileExt(new File([], 'x.png', { type: 'image/png' })), 'png');
assert.equal(planFileExt(new File([], 'x.gif', { type: 'image/gif' })), '');
const small = await classifyPlanFile(pngFile(100, 100), ASSET_LIMIT);
assert.deepEqual(small, { kind: 'pass', ext: 'png' });
// Тот же безобидный растр, но тяжелее лимита источника: диалог, а не отказ.
const heavy = await classifyPlanFile(pngFile(100, 100, { pad: ASSET_LIMIT }), ASSET_LIMIT);
assert.equal(heavy.kind, 'guard');
assert.equal(heavy.state.probe.kind, 'safe',
'файл велик, но безопасен — предупреждение относится к размеру, не к декодированию');
// Без лимита источника тот же файл проходит: лимит — параметр вызывающего,
// и именно им подложка отличается от декор-картинки.
assert.deepEqual(
await classifyPlanFile(pngFile(100, 100, { pad: ASSET_LIMIT })),
{ kind: 'pass', ext: 'png' },
);
const warn = await classifyPlanFile(pngFile(6000, 6000), ASSET_LIMIT);
assert.equal(warn.state.probe.kind, 'warn');
const hard = await classifyPlanFile(pngFile(20_000, 100), ASSET_LIMIT);
assert.equal(hard.state.probe.kind, 'hard');
const unknown = await classifyPlanFile(
new File([new Uint8Array(64)], 'x.png', { type: 'image/png' }), ASSET_LIMIT,
);
assert.equal(unknown.state.probe.kind, 'unknown');
assert.deepEqual(await classifyPlanFile(new File([], 'x.gif', { type: 'image/gif' })),
{ kind: 'reject' });
});
// --- состав кнопок диалога --------------------------------------------------
test('#433 состав кнопок диалога-предупреждения для трёх случаев', async () => {
const guardOf = async (file) => (await classifyPlanFile(file, ASSET_LIMIT)).state;
// 1. В пределах лимита диалога нет вовсе — его нечем показывать.
assert.equal(renderBackdropGuard(guardHost(null), () => {}, () => {}, null), null);
// 2. Свыше лимита источника, декор-картинка: allowOriginal=false.
// Это #427 — условие гасило обе кнопки, и «уменьшить копию» тоже.
const heavy = await guardOf(pngFile(100, 100, { pad: ASSET_LIMIT }));
const decor = keysOf(renderBackdropGuard(
guardHost(heavy), () => {}, () => {}, null, async () => {}, false,
));
assert.deepEqual(decor.filter((key) => key.startsWith('btn.') || key.startsWith('backdrop.k')
|| key === 'backdrop.use_downscaled'), ['btn.cancel', 'backdrop.use_downscaled']);
assert.ok(!decor.includes('backdrop.keep_original'),
'оригинал тяжелее лимита сохранить нельзя — именно эта кнопка и лишняя');
assert.ok(decor.includes('backdrop.reduced_dimensions'),
'размеры уменьшенной копии показываются — иначе предложение непроверяемо');
// 3. Подложка на том же файле: обе кнопки остаются.
const backdrop = keysOf(renderBackdropGuard(guardHost(heavy), () => {}, () => {}, null));
assert.ok(backdrop.includes('backdrop.keep_original'));
assert.ok(backdrop.includes('backdrop.use_downscaled'));
// 4. Жёсткий отказ: только «Отмена», ни одного действия.
const hard = keysOf(renderBackdropGuard(
guardHost(await guardOf(pngFile(20_000, 100))), () => {}, () => {}, null,
));
assert.ok(hard.includes('backdrop.too_large_title') && hard.includes('backdrop.too_large_body'));
assert.deepEqual(hard.filter((key) => key.startsWith('btn.')), ['btn.cancel']);
assert.ok(!hard.includes('backdrop.use_downscaled') && !hard.includes('backdrop.keep_original'));
// 5. Неизвестный заголовок — предупреждение, а не тихий проход.
const unknown = keysOf(renderBackdropGuard(
guardHost((await classifyPlanFile(
new File([new Uint8Array(64)], 'x.png', { type: 'image/png' }), ASSET_LIMIT,
)).state), () => {}, () => {}, null,
));
assert.ok(unknown.includes('backdrop.unknown_body'));
assert.ok(unknown.includes('backdrop.use_downscaled'));
});
// --- uploadFromInput: развилка до сети ---------------------------------------
const changeEvent = (files) => ({ target: { files, value: 'stale-path' } });
test('#433 uploadFromInput чистит input и не ходит в сеть без файла или на занятом', async () => {
const host = editorHost({ hass: { fetchWithAuth: () => assert.fail('сети быть не должно') } });
const editor = editorOf(host);
const empty = changeEvent([]);
await editor.uploadFromInput(empty);
assert.equal(empty.target.value, '',
'путь к файлу обязан сбрасываться: иначе повторный выбор того же файла не даст change');
assert.deepEqual(host.toasts, []);
host._decorAssetBusy = true;
const busy = changeEvent([pngFile(100, 100)]);
await editor.uploadFromInput(busy);
assert.equal(busy.target.value, '');
assert.deepEqual(host.toasts, [], 'занятая загрузка молчит, а не жалуется');
assert.equal(host._backdropGuard, null);
});
test('#433 uploadFromInput отбивает неподдерживаемый формат и нечитаемый файл', async () => {
const host = editorHost({ hass: { fetchWithAuth: () => assert.fail('сети быть не должно') } });
const editor = editorOf(host);
await editor.uploadFromInput(changeEvent([new File([], 'x.gif', { type: 'image/gif' })]));
assert.deepEqual(host.toasts, ['toast.plan_formats']);
host.toasts.length = 0;
await editor.uploadFromInput(changeEvent([{
name: 'x.png', type: 'image/png', size: 10,
arrayBuffer: () => Promise.reject(new Error('boom')),
}]));
assert.deepEqual(host.toasts, ['backup.error.invalid_image'],
'исключение классификации — честный отказ, а не тихий пропуск файла дальше');
assert.equal(host._backdropGuard, null);
});
test('#433 uploadFromInput передаёт диалогу флаг замены, а не теряет его', async () => {
for (const replaceSelection of [false, true]) {
const replaces = [];
const host = editorHost({ hass: { fetchWithAuth: () => assert.fail('сети быть не должно') } });
const editor = editorOf(host, { setGuardReplace: (value) => replaces.push(value) });
await editor.uploadFromInput(
changeEvent([pngFile(100, 100, { pad: ASSET_LIMIT })]), replaceSelection,
);
assert.equal(host._backdropGuard?.probe.kind, 'safe');
assert.deepEqual(replaces, [replaceSelection],
'кнопка «уменьшить копию» грузит результат туда же, куда просил вызвавший');
}
});
// --- upload: сетевая нога ----------------------------------------------------
const uploadHost = (respond, over = {}) => {
const calls = [];
const host = editorHost({
hass: {
fetchWithAuth: async (url, init) => {
calls.push({ url, init });
return respond(calls.length);
},
},
...over,
});
return { host, calls };
};
test('#433 успешная загрузка попадает в каталог, палитру и отпускает busy', async () => {
const asset = catalogRow();
const { host, calls } = uploadHost(() => ({ ok: true, json: async () => ({ asset }) }));
host._decorAssetCatalog = [catalogRow(id('b'))];
const file = pngFile(100, 100);
await editorOf(host).upload(file, 'plan.png', false);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, '/api/houseplan/assets/upload');
assert.equal(calls[0].init.method, 'POST');
assert.equal(calls[0].init.body.get('file')?.name, 'plan.png',
'файл уходит как multipart-поле file с именем, которое видит бэкенд');
assert.deepEqual([...host._decorAssets.keys()], [id('a')]);
assert.deepEqual(host._decorAssetCatalog.map((row) => row.asset_id), [id('a'), id('b')],
'свежий ассет первым — палитра показывает только что загруженное');
assert.equal(host._decorImagePalette?.asset_id, id('a'));
assert.equal(host._decorAssetBusy, false);
assert.deepEqual(host.toasts, []);
});
test('#433 замена ссылки в диалоге не трогает палитру', async () => {
const asset = catalogRow();
const { host } = uploadHost(() => ({ ok: true, json: async () => ({ asset }) }), {
_decorShapeDialog: { kind: 'image', assetId: id('c') },
});
await editorOf(host).upload(pngFile(100, 100), 'plan.png', true);
assert.equal(host._decorShapeDialog.assetId, id('a'));
assert.equal(host._decorImagePalette, null, 'замена ссылки не вооружает инструмент рисования');
});
test('#433 каждый код ошибки бэкенда получает свой текст, busy отпускается', async () => {
const cases = [
[{ ok: true, body: { error: 'too_large' } }, 'backdrop.too_large_title'],
[{ ok: true, body: { error: 'capacity_exceeded' } }, 'decor.image_error_capacity'],
[{ ok: true, body: { error: 'unsupported_image' } }, 'backup.error.unsupported_image'],
[{ ok: true, body: { error: 'invalid_format' } }, 'backup.error.invalid_image'],
[{ ok: true, body: { error: 'что-то новое' } }, 'backup.error.io_error'],
[{ ok: false, body: { asset: catalogRow() } }, 'backup.error.io_error'],
[{ ok: true, body: {} }, 'backup.error.io_error'],
// Строка, не прошедшая adoptDecorAssets, — тот же отказ: принять её значило
// бы положить в каталог запись, которой карточка потом не сможет доверять.
[{ ok: true, body: { asset: catalogRow(id('a'), { url: 'https://evil/x.png' }) } },
'backup.error.io_error'],
];
for (const [response, expected] of cases) {
const { host } = uploadHost(() => ({ ok: response.ok, json: async () => response.body }));
await editorOf(host).upload(pngFile(100, 100), 'plan.png', false);
assert.equal(host.toasts.length, 1, JSON.stringify(response));
assert.ok(host.toasts[0].startsWith('decor.image_upload_failed'), host.toasts[0]);
assert.ok(host.toasts[0].includes(expected), `${JSON.stringify(response)} → ${host.toasts[0]}`);
assert.equal(host._decorAssetCatalog.length, 0);
assert.equal(host._decorImagePalette, null);
assert.equal(host._decorAssetBusy, false, 'busy обязан отпускаться и на отказе');
}
});
test('#433 повторный вызов на занятой загрузке не удваивает запрос', async () => {
// Первый запрос висит, второй ответил бы сразу — то есть на снятой проверке
// busy тест падает на сравнении, а не зависает: свидетель, который вешает
// прогон, уносит с собой и остальные тесты файла (проверено).
let release = () => {};
const pending = new Promise((resolve) => {
release = () => resolve({ ok: true, json: async () => ({ asset: catalogRow() }) });
});
const { host, calls } = uploadHost((nth) => (nth === 1 ? pending : {
ok: true, json: async () => ({ asset: catalogRow(id('b')) }),
}));
const editor = editorOf(host);
const first = editor.upload(pngFile(100, 100), 'plan.png', false);
await editor.upload(pngFile(100, 100), 'second.png', false);
assert.equal(calls.length, 1, 'вторая загрузка поверх незакончившейся первой запрещена');
release();
await first;
assert.equal(host._decorAssetBusy, false);
assert.deepEqual(host._decorAssetCatalog.map((row) => row.asset_id), [id('a')]);
});
// --- delete и каталог --------------------------------------------------------
const wsHost = (behaviour, over = {}) => {
const calls = [];
const host = editorHost({
hass: { callWS: async (message) => { calls.push(message); return behaviour(message); } },
_confirmDanger: async () => true,
...over,
});
return { host, calls };
};
test('#433 удаление спрашивает подтверждение и уважает отказ', async () => {
const asset = catalogRow();
const used = { ...asset, used_by: [{ space_id: 's', decor_id: 'd' }] };
const inUse = wsHost(() => ({}));
inUse.host._decorAssetCatalog = [used];
await editorOf(inUse.host).delete(used);
assert.deepEqual(inUse.calls, [], 'ассет под ссылкой не удаляется вовсе');
assert.deepEqual(inUse.host.toasts, ['decor.image_in_use']);
const declined = wsHost(() => ({}), { _confirmDanger: async () => false });
declined.host._decorAssetCatalog = [asset];
await editorOf(declined.host).delete(asset);
assert.deepEqual(declined.calls, [], 'отказ в подтверждении — это отказ');
assert.deepEqual(declined.host._decorAssetCatalog, [asset]);
});
test('#433 подтверждённое удаление чистит каталог, карту и палитру', async () => {
const asset = catalogRow();
const other = catalogRow(id('b'));
const { host, calls } = wsHost(() => ({}), {
_decorAssets: new Map([[id('a'), asset], [id('b'), other]]),
_decorImagePalette: asset,
});
host._decorAssetCatalog = [asset, other];
await editorOf(host).delete(asset);
assert.deepEqual(calls, [{ type: 'houseplan/assets/delete', asset_id: id('a') }]);
assert.deepEqual(host._decorAssetCatalog.map((row) => row.asset_id), [id('b')]);
assert.deepEqual([...host._decorAssets.keys()], [id('b')]);
assert.equal(host._decorImagePalette, null,
'нельзя оставить инструмент вооружённым удалённым ассетом');
});
test('#433 отказ сервера на удалении сохраняет каталог и различает in_use', async () => {
for (const [error, expected] of [
[Object.assign(new Error('nope'), { code: 'in_use' }), 'decor.image_in_use'],
[new Error('boom'), 'backup.error.io_error'],
]) {
const asset = catalogRow();
const { host } = wsHost(() => { throw error; }, {
_decorAssets: new Map([[id('a'), asset]]),
_decorImagePalette: asset,
});
host._decorAssetCatalog = [asset];
await editorOf(host).delete(asset);
assert.deepEqual(host.toasts, [expected]);
assert.deepEqual(host._decorAssetCatalog, [asset], 'на отказе каталог не редеет');
assert.equal(host._decorAssets.size, 1);
assert.equal(host._decorImagePalette, asset);
}
});
test('#433 загрузка каталога отбирает валидные строки и не молчит на отказе', async () => {
const good = catalogRow();
const { host } = wsHost(() => ({ assets: [good, catalogRow(id('b'), { width: 0 })] }));
await editorOf(host).loadCatalog();
assert.deepEqual(host._decorAssetCatalog.map((row) => row.asset_id), [id('a')]);
assert.deepEqual([...host._decorAssets.keys()], [id('a')]);
const broken = wsHost(() => { throw new Error('offline'); });
await editorOf(broken.host).loadCatalog();
assert.deepEqual(broken.host.toasts, ['backup.error.io_error']);
});