From 7654590dce9c2ba001c26ddb7d4aa0c4a58b614f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 31 Oct 2026 22:39:42 -0700 Subject: [PATCH] test(thumbnails): cover the space refusal, Overview's Use this folder and the prune review (#320) The frontend coverage floors of activity-kinds, system-overview and thumbnail-review: a disk without the space shows the review but cannot move (a fixture route fakes the free space), Use this folder on Overview reloads the host values, the prune review's text and rows are pure functions with unit tests, and Overview takes the System drafts it is always mounted with. --- tests/browser/thumbnail_location_fixture.py | 9 +++++++ .../workspace-thumbnail-location.spec.mjs | 27 +++++++++++++++++++ ...orkspace-system-thumbnail-location.test.js | 17 ++++++++++++ web/js/workspace/system-overview.mjs | 7 +++-- web/js/workspace/thumbnail-review.mjs | 24 ++++++++++++----- 5 files changed, 74 insertions(+), 10 deletions(-) diff --git a/tests/browser/thumbnail_location_fixture.py b/tests/browser/thumbnail_location_fixture.py index a7edde6e..0ee2ed6f 100644 --- a/tests/browser/thumbnail_location_fixture.py +++ b/tests/browser/thumbnail_location_fixture.py @@ -52,7 +52,10 @@ def register_thumbnail_location_commands( section.pop("location", None) return config + real_free = thumbnail_move_plan._free + def reset() -> None: + thumbnail_move_plan._free = real_free preview_store.configure(None) PromptManagerConfig.THUMBNAIL_LOCATION = None store = get_config_store() @@ -158,4 +161,10 @@ def register_thumbnail_location_commands( await api._run_in_executor(unlisted) return web.json_response({"success": True}) + @routes.post("/__test__/thumbnail-space/full") + async def space_full(_request: web.Request) -> web.Response: + """Every target disk reports 1 byte free until the next reset.""" + thumbnail_move_plan._free = lambda _path: 1 + return web.json_response({"success": True}) + return reset diff --git a/tests/browser/workspace-thumbnail-location.spec.mjs b/tests/browser/workspace-thumbnail-location.spec.mjs index 1460423a..9c0099e7 100644 --- a/tests/browser/workspace-thumbnail-location.spec.mjs +++ b/tests/browser/workspace-thumbnail-location.spec.mjs @@ -162,6 +162,18 @@ test('Overview shows the notice and Change location opens the row', async ({ pag await expect(block(page).locator('#location-path')).toBeFocused(); }); +test('Overview’s Use this folder takes the folder and the notice goes', async ({ page, request }) => { + await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK(); + await expect(await request.post('/__test__/thumbnail-folder/foreign')).toBeOK(); + await page.goto(`${workspace}#/system/overview`); + const notice = page.locator('#unavailable-notice'); + await expect(notice).toContainText('holds another thumbnail folder'); + const adopted = page.waitForResponse(response => response.url().endsWith('/location/adopt')); + await notice.getByRole('button', { name: 'Use this folder' }).click(); + expect((await adopted).status()).toBe(200); + await expect(notice).toHaveCount(0); +}); + test('Thumbnail maintenance removes the thumbnails of removed sources in folder mode', async ({ page, request }) => { await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK(); await expect(await request.post('/__test__/thumbnail-folder/unlisted')).toBeOK(); @@ -183,3 +195,18 @@ test('Thumbnail maintenance removes the thumbnails of removed sources in folder await expect(page.locator('#workspace-main h1')).toHaveText('Remove thumbnails of removed sources'); await expect(page.locator('#workspace-main')).toContainText('1 thumbnails removed'); }); + +test('a disk without the space shows the review but cannot move', async ({ page, request }) => { + await expect(await request.post('/__test__/thumbnail-space/full')).toBeOK(); + await openSources(page); + const row = block(page); + await row.getByRole('radio', { name: /One folder/ }).check(); + const planned = page.waitForResponse(response => response.url().endsWith('/images/thumbnails/plan')); + await row.getByRole('button', { name: 'Review move…' }).click(); + expect((await planned).status()).toBe(507); + const review = dialog(page); + await expect(review.locator('.review-refusal')).toHaveText(/^There is not enough space for the thumbnails: 1 B free/); + await expect(review.getByRole('button', { name: 'Move thumbnails' })).toHaveAttribute('aria-disabled', 'true'); + await review.getByRole('button', { name: 'Cancel' }).click(); + await expect(dialog(page)).toHaveCount(0); +}); diff --git a/tests/js/workspace-system-thumbnail-location.test.js b/tests/js/workspace-system-thumbnail-location.test.js index 745a0c38..5859cd23 100644 --- a/tests/js/workspace-system-thumbnail-location.test.js +++ b/tests/js/workspace-system-thumbnail-location.test.js @@ -110,6 +110,10 @@ test('a move and a prune show their own tiles in Activity', async () => { assert.match(movePhase({ ...move, scope: { mode: 'move', phase: 'remove' } }), /Removing the old copies/); assert.match(movePhase({ ...move, state: 'finished' }), /1 file that is not a thumbnail was left in place/); assert.match(movePhase({ ...move, state: 'cancelled' }), /^Nothing changed/); + assert.match(movePhase({ ...move, state: 'finished', counters: { moved: 3, left: 2 } }), + /2 files that are not thumbnails were left in place\.$/); + assert.equal(movePhase({ ...move, state: 'finished', counters: undefined }), 'Thumbnails are now read from the new location.'); + assert.match(movePhase({ ...move, scope: { mode: 'move', from: 'folder' } }), /read from the old folder until the switch/); const done = { ...move, state: 'finished', status_line: 'Thumbnails moved to E:\\T: 3 moved · old copies removed.' }; assert.equal(finishToast(done), done.status_line); const prune = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'prune' }, @@ -123,6 +127,8 @@ test('a move and a prune show their own tiles in Activity', async () => { const pruned = { ...prune, state: 'finished', status_line: '1 thumbnails removed' }; assert.equal(viewOf(prune).line(prune), ''); assert.equal(viewOf(pruned).line(pruned), '1 thumbnails removed'); + assert.equal(viewOf(pruned).line({ ...pruned, status_line: null }), ''); + assert.equal(viewOf(done).line({ ...done, status_line: null }), ''); assert.equal(finishToast(pruned), '1 thumbnails removed'); assert.equal(viewOf(prune).kept(prune), '1 removed'); assert.match(finishToast({ ...prune, state: 'finished', status_line: '', title: 'Remove thumbnails of removed sources' }), @@ -140,6 +146,17 @@ test('the prune scope reads its plan line from the server', async () => { assert.equal(planText({ mode: 'prune' }), PLAN_FAILED); }); +test('the prune review names the folder and each removed source', async () => { + const { pruneText, pruneEntries } = await reviewModule; + assert.match(pruneText({ folder: 'E:\\Thumbs' }), /^Only Prompt Manager’s thumbnails in their folders inside E:\\Thumbs are /); + assert.match(pruneText({}), /inside the thumbnail folder are removed\. If a source comes back/); + assert.deepEqual(pruneEntries({ sources: [{ key: 'gone-0123abcd', files: 1240, bytes: 85.2 * 1024 * 1024 }, + { key: 'one-0123abcd', files: 1, bytes: 512 }] }), + [['gone-0123abcd', '1,240 thumbnails · 85.2 MB'], ['one-0123abcd', '1 thumbnail · 512 B']]); + assert.deepEqual(pruneEntries(null), []); + assert.deepEqual(pruneEntries({ sources: 'x' }), []); +}); + test('Move library says where thumbnails kept in one folder stay', async () => { const { reviewGrid } = await libraryModule; const base = { path: 'p', target: 't', size_bytes: 0, config_moves: true }; diff --git a/web/js/workspace/system-overview.mjs b/web/js/workspace/system-overview.mjs index d05a2c0d..d5a7fd8d 100644 --- a/web/js/workspace/system-overview.mjs +++ b/web/js/workspace/system-overview.mjs @@ -407,7 +407,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS * `counts` is the shell's sidebar counts (`{get(id), subscribe(listener), freshen(ids)}`, index.mjs): the Images metric * shows its `images` count and follows it, and a visit re-reads it as the workspace Overview does (#555). */ -export function mountOverview(container, { api, activity, scope, counts = null, drafts = null, navigate = navigateTo, +export function mountOverview(container, { api, activity, scope, drafts, counts = null, navigate = navigateTo, notify = showWorkspaceToast, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) { const overviewRead = latestOnly({ lifetime: scope.signal }); const statisticsRead = latestOnly({ lifetime: scope.signal }); @@ -468,7 +468,7 @@ export function mountOverview(container, { api, activity, scope, counts = null, const { nodes, thumbnails, backup, metrics } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, images: imagesCount(), openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate, - location: drafts?.get('host', 'thumbnail_location') }) }); + location: drafts.get('host', 'thumbnail_location') }) }); imagesValue = metrics.children[2].querySelector('strong'); thumbnailRow = thumbnails; backupRow = backup; @@ -525,9 +525,8 @@ export function mountOverview(container, { api, activity, scope, counts = null, loadThumbnails(); loadBackup(); // #320 D199: the unavailable thumbnail folder's notice, first on the page while it lasts. - if (!drafts) return first; const unavailable = noticeSlot({ api, scope, notify, location: () => drafts.get('host', 'thumbnail_location'), - onChange: () => navigate('#/system/sources'), onDone: () => drafts.reloadHost?.() }); + onChange: () => navigate('#/system/sources'), onDone: () => drafts.reloadHost() }); scope.cleanup(drafts.subscribe(() => unavailable.paint())); // The anchor goes in first, so the notice (inserted after it) leads the section once the anchor is mounted. unavailable.paint(); diff --git a/web/js/workspace/thumbnail-review.mjs b/web/js/workspace/thumbnail-review.mjs index 5a0d850b..6dd3ea0b 100644 --- a/web/js/workspace/thumbnail-review.mjs +++ b/web/js/workspace/thumbnail-review.mjs @@ -48,6 +48,19 @@ export function settingsText(plan) { return `Previews are written as ${label} · quality ${plan.quality} · ${count(plan.size)} px. Change this in System › Appearance.`; } +/** D200: what a prune removes, in `plan.folder` (the thumbnail folder). */ +export function pruneText(plan) { + return `Only Prompt Manager’s thumbnails in their folders inside ${plan?.folder || 'the thumbnail folder'} are ` + + 'removed. If a source comes back, its thumbnails are made again as you browse.'; +} + +/** D200: one `[key, "N thumbnails · size"]` per removed source of a prune plan. */ +export function pruneEntries(plan) { + const sources = Array.isArray(plan?.sources) ? plan.sources : []; + return sources.map(source => [source.key, `${count(source.files)} ${noun(source.files, 'thumbnail')} · ` + + `${sizeText(source.bytes)}`]); +} + /** D99: the plan line for the server's `{mode, targets, fresh, remaining}`; an answer without counts failed. */ export function planText(plan) { if (plan?.mode === 'prune') return typeof plan.line === 'string' && plan.line ? plan.line : PLAN_FAILED; @@ -138,14 +151,13 @@ export function openThumbnailReview({ api, store, scope, trigger = null, mode: i line.textContent = planText(plan); const prune = chosen === 'prune'; settings.hidden = prune; - pruneDetail.hidden = pruneGrid.hidden = !prune || !plan?.sources?.length; + const entries = pruneEntries(plan); + pruneDetail.hidden = pruneGrid.hidden = !prune || !entries.length; if (prune) { - pruneDetail.textContent = `Only Prompt Manager’s thumbnails in their folders inside ${plan?.folder ?? 'the thumbnail ' - + 'folder'} are removed. If a source comes back, its thumbnails are made again as you browse.`; - pruneGrid.replaceChildren(...(plan?.sources ?? []).map(source => { + pruneDetail.textContent = pruneText(plan); + pruneGrid.replaceChildren(...entries.map(([key, text]) => { const entry = el('div'); - entry.append(el('dt', source.key), el('dd', `${count(source.files)} ${noun(source.files, 'thumbnail')} · ` - + `${sizeText(source.bytes)}`)); + entry.append(el('dt', key), el('dd', text)); return entry; })); } else {