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 {