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
This commit is contained in:
Claude
2026-09-03 13:21:08 +03:00
parent a6a9757b6d
commit d1091b6d47
4 changed files with 521 additions and 1 deletions
+83
View File
@@ -3,6 +3,13 @@
* Backend upload/parser/storage security is covered by tests_backend; this
* smoke uses deterministic in-memory raster data so it never depends on a
* writable demo server or leaves user files behind.
*
* С #433 сюда входит и путь загрузки: последний блок проходит настоящие
* `uploadFromInput` → `upload` → `delete`, подменяя только транспорт
* (`fetchWithAuth` и одну ветку `callWS`). До этого смок подменял `callWS`
* целиком и до этих методов не доходил — именно поэтому #427 (гасли обе
* кнопки диалога для файла тяжелее 2 МиБ) прожил четыре круга ревью.
* Детерминированная часть того же контракта — `test/decor-image-upload.test.mjs`.
*/
import { launch, checkAll, finish } from './serve.mjs';
@@ -199,6 +206,82 @@ const result = await page.evaluate(async () => {
out.hideDecorHidesImages = !root().querySelector('.decorlayer image[data-kind="image"]');
sp.settings = { ...(sp.settings || {}), hide_decor: false };
await settle();
// ── #433: настоящий uploadFromInput, а не подмена callWS ──────────────────
// Шапка этого файла честно признавалась, что путь загрузки не проверяется
// ничем: смок подменял `callWS` и до `uploadFromInput`/`upload`/`delete` не
// доходил. Цена — #427, четыре круга ревью. Здесь подменяется только
// транспорт (`fetchWithAuth`), а весь путь от change-события до каталога
// проходит продуктовый код собранного бандла.
const id3 = '7'.repeat(64);
const rawUrl3 = `/api/houseplan/content/assets/_/${id3}.png`;
const asset3 = {
asset_id: id3, name: 'small.png', mime: 'image/png',
width: 100, height: 100, bytes: 33, url: rawUrl3, used_by: [],
};
// PNG до конца IHDR: на проходном пути ничего не декодирует ни карточка, ни
// браузер — заголовок читает probeBackdrop, байты уходят как есть.
const pngHead = new Uint8Array(33);
pngHead.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
pngHead.set([0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52], 8);
new DataView(pngHead.buffer).setUint32(16, 100);
new DataView(pngHead.buffer).setUint32(20, 100);
pngHead[24] = 8; pngHead[25] = 2;
const uploads = [];
const toasts = [];
const realToast = c._showToast.bind(c);
const realConfirm = c._confirmDanger.bind(c);
c._showToast = (text) => { toasts.push(text); };
c.hass = { ...c.hass, fetchWithAuth: async (url, init) => {
uploads.push({ url, method: init?.method, name: init?.body?.get('file')?.name });
return { ok: true, json: async () => ({ asset: asset3 }) };
} };
c._decorAssetCatalog = [asset];
c._decorImagePalette = null;
await c._editorRuntime._decorImageUpload({
target: { files: [new File([pngHead], 'small.png', { type: 'image/png' })], value: 'x' },
});
await settle();
out.uploadPassesThroughRealInputHandler = uploads.length === 1
&& uploads[0].url === '/api/houseplan/assets/upload'
&& uploads[0].method === 'POST'
&& uploads[0].name === 'small.png'
&& !c._backdropGuard;
out.uploadedAssetArmsPaletteAndCatalog = c._decorAssetCatalog[0]?.asset_id === id3
&& c._decorAssets.get(id3)?.url === rawUrl3
&& c._decorImagePalette?.asset_id === id3
&& c._decorAssetBusy === false;
// Неподдерживаемый формат: тост и ни одного запроса.
toasts.length = 0;
await c._editorRuntime._decorImageUpload({
target: { files: [new File([pngHead], 'plan.gif', { type: 'image/gif' })], value: 'x' },
});
out.rejectedFormatNeverReachesTransport = uploads.length === 1
&& toasts.length === 1 && toasts[0] === c._t('toast.plan_formats');
// Удаление: подтверждение спрашивается, WS уходит, каталог редеет.
const deletes = [];
const beforeDelete = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/assets/delete') { deletes.push(message.asset_id); return {}; }
return beforeDelete(message);
} };
let confirmAsked = 0;
c._confirmDanger = async () => { confirmAsked++; return true; };
await c._editorRuntime._decorImageDelete(asset3);
await settle();
out.deleteAsksConfirmationAndPrunesCatalog = confirmAsked === 1
&& deletes.length === 1 && deletes[0] === id3
&& !c._decorAssetCatalog.some((row) => row.asset_id === id3)
&& !c._decorAssets.has(id3)
&& c._decorImagePalette === null;
c._showToast = realToast;
c._confirmDanger = realConfirm;
c._decorAssets = new Map([[id, asset], [id2, asset2]]);
c._decorAssetCatalog = [asset, asset2];
await settle();
await customElements.whenDefined('houseplan-space-card');
const config = JSON.parse(JSON.stringify(c._serverCfg));
let staticResolveCalls = 0;
+36
View File
@@ -4849,6 +4849,42 @@ const MUTANT_DEFINITIONS = [
replace: ' stable = True\n',
}],
},
{
id: 'decor-oversize-hides-the-downscale-action',
guard: 'node --test test/decor-image-upload.test.mjs',
because: 'a decor source above the 2 MiB asset limit must still be offered as a reduced '
+ 'copy: the flag forbids keeping the ORIGINAL, and gating the whole action block on it '
+ 'is #427 — a bug that survived four review rounds because this path had no test at all',
patches: [{
file: 'src/backdrop-pick.ts',
find: ' ${hard ? null : html`',
replace: ' ${hard || !allowOriginal ? null : html`',
}],
},
{
id: 'decor-upload-loses-the-replace-flag',
guard: 'node --test test/decor-image-upload.test.mjs',
because: 'the guard dialog must upload into whichever slot the caller asked for: losing the '
+ 'replaceSelection flag silently turns "replace this image" into "arm the palette" and '
+ 'leaves the selected shape pointing at the old asset (#51 AC1, #433)',
patches: [{
file: 'src/decor-image-editor.ts',
find: ' this.hooks.setGuardReplace(replaceSelection);',
replace: ' this.hooks.setGuardReplace(false);',
}],
},
{
id: 'decor-upload-error-codes-collapse',
guard: 'node --test test/decor-image-upload.test.mjs',
because: 'each backend refusal code carries its own message: collapsing too_large into the '
+ 'generic io_error tells the user "something went wrong" where the product knows exactly '
+ 'what went wrong and how to fix it (#433)',
patches: [{
file: 'src/decor-image-editor.ts',
find: " too_large: 'backdrop.too_large_title',\n",
replace: '',
}],
},
{
id: 'pure-backend-test-pulls-home-assistant',
guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider',
+400
View File
@@ -0,0 +1,400 @@
/**
* #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']);
});
+2 -1
View File
@@ -44,6 +44,7 @@
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/iso-openings.ts",
"src/open-spans.ts",
"src/zero-walls.ts",
"src/editors/decor/geometry.ts"
"src/editors/decor/geometry.ts",
"src/backdrop-pick.ts", "src/decor-image-editor.ts"
]
}