From 0d79665b81aa4ff58c608373ec4c5882df4e0695 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 31 Oct 2026 21:48:37 -0700 Subject: [PATCH] test(thumbnails): cover the prune scope, the Overview notice and the space refusal (#320) The frontend coverage gate counted the new branches of thumbnail maintenance, the Activity views, the notices' Change location and the api-client's space refusal as uncovered; page and unit tests now drive them. --- tests/browser/thumbnail_location_fixture.py | 22 ++++++++++++ .../workspace-thumbnail-location.spec.mjs | 36 +++++++++++++++++++ tests/js/api-client.test.js | 10 ++++++ ...orkspace-system-thumbnail-location.test.js | 12 +++++++ 4 files changed, 80 insertions(+) diff --git a/tests/browser/thumbnail_location_fixture.py b/tests/browser/thumbnail_location_fixture.py index 2028b7e6..a7edde6e 100644 --- a/tests/browser/thumbnail_location_fixture.py +++ b/tests/browser/thumbnail_location_fixture.py @@ -136,4 +136,26 @@ def register_thumbnail_location_commands( async def foreign_folder(_request: web.Request) -> web.Response: return web.json_response({"available": await api._run_in_executor(foreign)}) + def unlisted() -> None: + """A source this install no longer lists left its thumbnails here.""" + setting = preview_store.setting() + + def add(marker: dict) -> None: + marker["keys"]["gone-0123abcd"] = { + "path": "/gone", + "identity": "/gone", + "install": setting.install, + } + + preview_location.write_marker(setting.path, add) + gone = Path(setting.path) / "gone-0123abcd" + gone.mkdir() + (gone / "x_thumb.webp").write_bytes(b"preview") + preview_store.refresh() + + @routes.post("/__test__/thumbnail-folder/unlisted") + async def unlisted_source(_request: web.Request) -> web.Response: + await api._run_in_executor(unlisted) + 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 9b132eb4..1460423a 100644 --- a/tests/browser/workspace-thumbnail-location.spec.mjs +++ b/tests/browser/workspace-thumbnail-location.spec.mjs @@ -147,3 +147,39 @@ test('another folder’s marker offers Use this folder', async ({ page, request await expect(notice).toHaveCount(0); await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*thumbs\.$/); }); + +test('Overview shows the notice and Change location opens the row', async ({ page, request }) => { + await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK(); + await request.post('/__test__/thumbnail-folder/remove'); + await page.goto(`${workspace}#/system/overview`); + const notice = page.locator('#unavailable-notice'); + await expect(notice.locator('h2')).toHaveText('The thumbnail folder is unavailable'); + await notice.getByRole('button', { name: 'Change location' }).click(); + await expect(page).toHaveURL(/#\/system\/sources$/); + const sourcesNotice = page.locator('#unavailable-notice'); + await sourcesNotice.getByRole('button', { name: 'Change location' }).click(); + await expect(block(page).getByRole('radio', { name: /One folder/ })).toBeChecked(); + await expect(block(page).locator('#location-path')).toBeFocused(); +}); + +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(); + await openSources(page); + const generation = page.locator('#workspace-main .operation').filter({ hasText: 'Thumbnail generation' }); + await generation.getByRole('button', { name: 'Review options' }).click(); + const scope = dialog(page).locator('#thumb-mode'); + await expect(scope.locator('option')).toHaveText(['Missing thumbnails only', 'Rebuild all (explicit opt-in)', + 'Convert outdated (removes older formats)', 'Remove thumbnails of removed sources']); + await scope.selectOption('prune'); + await expect(dialog(page).locator('[role=status]')).toHaveText( + /^1 source no longer listed: 1 thumbnail \(.+\) are removed\.$/); + await expect(dialog(page).locator('dl.meta-grid dt')).toHaveText(['gone-0123abcd']); + await expect(dialog(page)).toContainText('If a source comes back, its thumbnails are made again as you browse.'); + await scope.selectOption('missing'); + await expect(dialog(page).locator('dl.meta-grid')).toBeHidden(); + await scope.selectOption('prune'); + await dialog(page).getByRole('button', { name: 'Start' }).click(); + await expect(page.locator('#workspace-main h1')).toHaveText('Remove thumbnails of removed sources'); + await expect(page.locator('#workspace-main')).toContainText('1 thumbnails removed'); +}); diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 75fc4875..45d5faa6 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -371,6 +371,16 @@ test("ApiError keeps the job that applied a used plan (plan-used, System S7)", ( } }); +test("ApiError keeps a move refused for space with its review (#320)", () => { + const plan = { totals: { files: 3 }, insufficient: { text: "Not enough space" } }; + assert.equal(new ApiError(507, "no space", "insufficient-space", null, { plan }).plan, plan); + for (const bad of [null, "x", [1], undefined]) { + assert.equal(new ApiError(507, "no space", "insufficient-space", null, { plan: bad }).plan, null); + } + assert.equal(new ApiError(409, "busy", "busy", null, { plan }).plan, null); + assert.equal(new ApiError(507, "no space", "insufficient-space").plan, null); +}); + test("ApiError keeps a validated running job on busy and a validated source on invalid-source", () => { const body = { running: { id: "a".repeat(32), title: "Scan for metadata" }, source: "output/gone" }; const busy = new ApiError(409, "busy", "busy", null, body); diff --git a/tests/js/workspace-system-thumbnail-location.test.js b/tests/js/workspace-system-thumbnail-location.test.js index f89ff10e..745a0c38 100644 --- a/tests/js/workspace-system-thumbnail-location.test.js +++ b/tests/js/workspace-system-thumbnail-location.test.js @@ -115,6 +115,18 @@ test('a move and a prune show their own tiles in Activity', async () => { const prune = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'prune' }, counters: { removed: 1, left: 0, errors: 0 } }; assert.deepEqual(viewOf(prune).tiles.map(([, label]) => label), ['Removed', 'Left in place', 'Errors']); + assert.equal(viewOf(move).line(move), ''); + assert.equal(viewOf(done).line(done), done.status_line); + assert.equal(viewOf(move).kept(move), '3 moved'); + assert.equal(finishToast({ ...move, state: 'cancelled', status_line: '' }), viewOf(move).finishToast({ ...move, + state: 'cancelled', status_line: '' })); + 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(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' }), + /Remove thumbnails of removed sources/); const generate = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'missing' }, counters: { generated: 0, skipped: 0, errors: 0 } }; assert.deepEqual(viewOf(generate).tiles.map(([, label]) => label), ['Generated', 'Skipped', 'Errors']);