mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
401 lines
19 KiB
JavaScript
401 lines
19 KiB
JavaScript
/**
|
||
* #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']);
|
||
});
|