From d1091b6d476a529efa42ab175c84ba203f91fdc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 13:20:58 +0300 Subject: [PATCH] =?UTF-8?q?test(decor):=20=D0=BF=D1=83=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D0=BD=D0=BA=D0=B8=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- demo/smoke_decor_images.mjs | 83 +++++++ scripts/mutation-gate.mjs | 36 +++ test/decor-image-upload.test.mjs | 400 +++++++++++++++++++++++++++++++ tsconfig.test.json | 3 +- 4 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 test/decor-image-upload.test.mjs diff --git a/demo/smoke_decor_images.mjs b/demo/smoke_decor_images.mjs index 17c58745..6dd80670 100644 --- a/demo/smoke_decor_images.mjs +++ b/demo/smoke_decor_images.mjs @@ -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; diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index cd7d7d18..fd7f1cfc 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', diff --git a/test/decor-image-upload.test.mjs b/test/decor-image-upload.test.mjs new file mode 100644 index 00000000..edb6344d --- /dev/null +++ b/test/decor-image-upload.test.mjs @@ -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']); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 95e70591..49e0ad8c 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -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" ] }