diff --git a/TESTING.md b/TESTING.md index 27ec459d..8ed81873 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1975,7 +1975,7 @@ keeps the drafts; a save refused for one source says the server's message as the and marks that row. After a save the source list is read again; a monitoring change waits for a restart (the running observer keeps watching until then, and the status says "Restart ComfyUI to apply: Watch for new outputs."). Thumbnail generation's -Review options opens the legacy gallery in a new tab (D94) until its review dialog (S5). +Review options opens the concept's "Thumbnail maintenance" dialog (D99, below). A list that cannot be read shows "Sources could not be loaded. Your saved data has not changed." and Retry, never an empty list. @@ -2004,6 +2004,20 @@ server to cancel the running thumbnail job, and a job cancelled from Activity en stream with the error frame "Thumbnail generation was cancelled". The blocking `POST /images/generate-thumbnails` and the clear stay legacy holds. +The workspace starts it from "Thumbnail maintenance" (`thumbnail-review.mjs`, the +concept's `thumbnail-review`), opened by Sources' Thumbnail generation → Review options +and Overview's Review thumbnails. Scope is Missing thumbnails only (`missing`, the +default) or Rebuild all (explicit opt-in) (`rebuild`); Quality is Medium · optimized / +High · original / Low · fastest (`medium` / `high` / `low`), preselected from +Appearance's Image loading quality (`gallerySettings.imageQuality`, the same values, +Q3); Preview size limit is the longest edge, 512 by default, a whole number from 128 to +2048. A size outside that range marks the field `aria-invalid`, the plan line says +"Enter a size from 128 to 2048 pixels." and nothing is requested; Start reports the +field's validity and keeps the dialog open. Start closes the dialog and starts the job +through the Activity store, which toasts "Thumbnail generation started."; the job opens +in Activity. A start refused because another job holds the slot toasts the D57 busy +link toast and stays. The legacy gallery's thumbnail tools are unchanged. + Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per visit through `latestOnly()` on the section scope; a late answer after the section left paints nothing. Until it answers, the section shows "Loading the overview…"; a failure @@ -2031,7 +2045,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged `-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no reader can keep it byte-identical (`test_health_check_never_writes`). -Recorded deviations (plan D60–D66, D79–D81 renumbered D92–D98 and D111–D113; D121–D128 assigned): +Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D113; D121–D128 assigned): - **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server @@ -2050,7 +2064,7 @@ Recorded deviations (plan D60–D66, D79–D81 renumbered D92–D98 and D111–D The thumbnail row is "Checking thumbnails…" until its own read answers, then "No missing thumbnails", "N missing thumbnails" (the pending count of `/images/thumbnails/status`) or "Thumbnail status unavailable"; Review thumbnails - opens Sources until the thumbnail dialog (S5). "One display configuration" says "Grid + opens the thumbnail dialog (D99). "One display configuration" says "Grid and density settings are shared across views." (no paging). The sources row is "N output sources" with "Monitoring and enrichment share one source list.", prefixed by "M of N configured sources available." or "None configured is available; ComfyUI's @@ -2111,6 +2125,21 @@ Recorded deviations (plan D60–D66, D79–D81 renumbered D92–D98 and D111–D legacy gallery's `/gallery/rescan` and toasts "Files refreshed. N files in M output folders." or "Files could not be refreshed. Try again.". The concept's "sample path" note is prototype copy and is dropped. +- **D99** (Sources/Overview, S5): the concept's `thumbnail-review` dialog ("Thumbnail + maintenance") with production copy. A `p.small[role=status]` line before the actions + says what the server's read-only plan (`GET /images/thumbnails/plan`) counts for the + chosen scope: "N previews to generate · M already up to date." or, for Rebuild all, + "Every preview is regenerated: N files.", with "· R more on the next run" past the + per-run cap; "Counting previews…" while it is read and "The previews could not be + counted." when the read fails (Start still starts). Each scope is read once per + dialog, since size and quality change no count. The primary is "Start" (the + concept's "Simulate thumbnail job"). Overview's Review thumbnails opens the same + dialog (the concept's row starts a sample "Thumbnail check" job directly). Maintenance's + Thumbnail cache row takes the dialog with S7. `thumbnail dialog matches the concept + from sources|overview` (system-concept.spec, both themes and sizes) maps the line and + "Start"; `thumbnail dialog comparison detects mutations` proves they are the only + difference and catches a renamed option, swapped options, swapped actions and a + changed style. - **D111** (Activity, S5): each job kind's pill verb, toasts, line, tiles, outcomes, openable rows and labels come from one descriptor table (`activity-kinds.mjs`; `activity-store.mjs` re-exports it). The scan's (D56) and image tagging's (D89) are @@ -2219,10 +2248,13 @@ and a source row, renames a label and the Add source button and swaps the `--lin token. Paired captures (`{concept,production}-system-{dark,light}-{1600,390}.png`, `{concept,production}-{statistics,health}-{dark,light}.png`, `{concept,production}-appearance-{dark,light}-{1600,390}.png`, -`{concept,production}-sources-{with,without}-notice-{dark,light}-{1600,390}.png`) are test artifacts; there +`{concept,production}-sources-{with,without}-notice-{dark,light}-{1600,390}.png`, +`concept-thumbnail-review-{dark,light}-{1600,390}.png`, +`production-thumbnail-review-{sources,overview}-{dark,light}-{1600,390}.png`) are test artifacts; there are no PNG baselines. `tests/browser/workspace-system.spec.mjs` covers the real counts, the health check and metric 4, a failed check, the thumbnail row against the status -endpoint (painted after the rest when its read is held, unavailable when it fails), the review row (Q6), the section links, the Statistics dialog against the +endpoint (painted after the rest when its read is held, unavailable when it fails), its Review thumbnails +opening the thumbnail dialog and starting the job, the review row (Q6), the section links, the Statistics dialog against the route, a load failure with Retry and late answers after leaving, and Appearance: new visits versus the current view and a saved view, the embedded gallery keeping Grid, metadata off and on request, the cache (counted up to a request barrier), hidden paths, @@ -2232,7 +2264,9 @@ the D97 rows and reset. `tests/browser/workspace-system-sources.spec.mjs` covers notice's Later and Review scan, a scoped scan from one source into Activity, Add source with its server check and Save, a refused folder, Remove down to auto-detect, an absent configured source, the three watch states, the monitoring and worker rows, Refresh -files, the thumbnail hand-off, a save refused by a running scan and a failed read. Its hooks are +files, the thumbnail dialog (its plan line per scope with the preselected quality, an +invalid size that requests nothing, Start into Activity, Rebuild all, a failed plan and +a refused start), a save refused by a running scan and a failed read. Its hooks are `tests/browser/workspace_system_fixture.py` (`/__test__/system/*`: reset, injected failures, an absent source, a deleted linked file, unthumbnailed files, a fake image monitor) on the @@ -2247,7 +2281,7 @@ node --test tests/js/workspace-system-state.test.js tests/js/workspace-system-co tests/js/workspace-system-overview.test.js tests/js/workspace-state.test.js \ tests/js/workspace-system-appearance.test.js tests/js/workspace-preferences.test.js \ tests/js/workspace-inspection-state.test.js tests/js/workspace-images-state.test.js \ - tests/js/workspace-system-sources.test.js + tests/js/workspace-system-sources.test.js tests/js/workspace-thumbnail-review.test.js python3 -m unittest tests.test_settings_sources tests.test_scan_scope -v npm run --prefix tools/vendor test:workflows -- system-concept.spec.mjs workspace-system.spec.mjs \ workspace-system-sources.spec.mjs \ diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index 9317a197..65e7ea9c 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -776,3 +776,131 @@ test('add source comparison detects mutations', async ({ page, request }) => { await undoStyle(); expect(diffInventory(expected, await page.evaluate(measureAddSource))).toEqual([]); }); + +/** + * Runs in the page: the open "Thumbnail maintenance" dialog (the concept's `thumbnail-review`, app.js 1433–1437): its + * title, the body's tree, each `.field` (label, control, options, selection, limits), the `p.small` line and the + * actions, with the concept's geometry and tokens. The concept has no `p.small`, so a probe one stands in for its style. + */ +function measureThumbnailReview() { + const dialog = document.querySelector('dialog#modal[open]'); + const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null; + const name = node => [node.localName, ...[...node.classList].sort()].join('.'); + const text = node => node?.textContent.trim() ?? null; + const body = dialog.querySelector('#modal-body'); + const fields = [...body.querySelectorAll(':scope > .field')].map(field => { + const label = field.querySelector(':scope > label'); + const control = field.querySelector(':scope > select, :scope > input'); + return { + tree: [...field.children].map(name), + ...css(field, 'display', 'flexDirection', 'rowGap', 'marginBottom'), + label: { text: text(label), for: label.htmlFor, ...css(label, 'fontSize', 'fontWeight', 'color') }, + control: { id: control.id, tag: control.localName, type: control.type, + options: control.options ? [...control.options].map(option => option.textContent) : null, + selected: control.options ? control.selectedIndex : null, + value: control.localName === 'input' ? control.value : null, + min: control.getAttribute('min'), max: control.getAttribute('max'), + ...css(control, 'height', 'paddingLeft', 'fontSize', 'color', 'backgroundColor', 'borderTopColor', 'borderTopLeftRadius') }, + }; + }); + let line = body.querySelector(':scope > p.small'); + const probe = !line; + if (probe) { line = document.createElement('p'); line.className = 'small'; body.append(line); } + const plan = { text: probe ? null : text(line), role: probe ? null : line.getAttribute('role'), + ...css(line, 'fontSize', 'color', 'marginTop', 'marginBottom') }; + if (probe) line.remove(); + const actions = body.querySelector(':scope > .dialog-actions'); + const rect = dialog.getBoundingClientRect(); + return { + title: text(dialog.querySelector('#modal-title')), + tree: [...body.children].map(name), + fields, + plan, + actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'), + buttons: [...actions.children].map(button => ({ text: text(button), className: button.className, + ...css(button, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) }, + width: Math.round(rect.width), + }; +} + +/** + * D99: the planned counts line (`p.small[role=status]`) before the actions, from the server's plan for the chosen + * options, and the primary "Start" (the concept's "Simulate thumbnail job"). + */ +function mapThumbnailConcept(reference, plan) { + const [cancel, start] = reference.actions.buttons; + const tree = [...reference.tree]; + tree.splice(tree.indexOf('div.dialog-actions'), 0, 'p.small'); + return { ...reference, tree, + plan: { ...reference.plan, role: 'status', + text: `${number(plan.targets)} ${plan.targets === 1 ? 'preview' : 'previews'} to generate · ${number(plan.fresh)} already up to date.` }, + actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start' }] } }; +} + +async function conceptThumbnailReview(page, theme) { + await conceptSources(page, theme, { notice: false }); + const row = page.locator('#main .operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) }); + await row.getByRole('button', { name: 'Review options', exact: true }).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Thumbnail maintenance'); + await page.mouse.move(0, 0); + return page.evaluate(measureThumbnailReview); +} + +/** Production's dialog from Sources' Review options or Overview's Review thumbnails, once its plan line answered. */ +async function productionThumbnailReview(page, theme, entry) { + if (entry === 'sources') { + await productionSources(page, theme, { notice: false }); + const row = page.locator('#workspace-main .operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) }); + await row.getByRole('button', { name: 'Review options', exact: true }).click(); + } else { + await openProduction(page, 'system/overview', theme); + await page.locator('#workspace-main').getByRole('button', { name: 'Review thumbnails', exact: true }).click(); + } + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Thumbnail maintenance'); + await expect(page.locator('dialog#modal[open] #modal-body > p.small')).toHaveText(/ already up to date\.$/); + await page.mouse.move(0, 0); + return page.evaluate(measureThumbnailReview); +} + +const thumbnailPlan = request => serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing&size=512&quality=medium'); + +for (const entry of ['sources', 'overview']) for (const theme of THEMES) for (const [width, height] of SIZES) { + test(`thumbnail dialog matches the concept from ${entry} ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => { + await page.setViewportSize({ width, height }); + await watchedSources(request); + const plan = await thumbnailPlan(request); + const concept = await conceptThumbnailReview(page, theme); + await page.screenshot({ path: testInfo.outputPath(`concept-thumbnail-review-${theme}-${width}.png`) }); + const production = await productionThumbnailReview(page, theme, entry); + await page.screenshot({ path: testInfo.outputPath(`production-thumbnail-review-${entry}-${theme}-${width}.png`) }); + expect(diffInventory(mapThumbnailConcept(concept, plan), production)).toEqual([]); + }); +} + +test('thumbnail dialog comparison detects mutations', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + await watchedSources(request); + const plan = await thumbnailPlan(request); + const concept = await conceptThumbnailReview(page, 'dark'); + const expected = mapThumbnailConcept(concept, plan); + const production = await productionThumbnailReview(page, 'dark', 'sources'); + expect(diffInventory(expected, production)).toEqual([]); + // Unmapped, the concept differs only where D99 is recorded. + expect(diffInventory(concept, production).map(item => item.path).sort()) + .toEqual(['actions.buttons.1.text', 'plan.role', 'plan.text', 'tree.3', 'tree.4', 'tree.length']); + const paths = async () => diffInventory(expected, await page.evaluate(measureThumbnailReview)).map(item => item.path); + // A changed option label, a swapped option order, a swapped action order and a changed style. + await page.evaluate(() => { document.querySelector('#thumb-quality option').textContent = 'Medium'; }); + expect(await paths()).toEqual(['fields.1.control.options.0']); + await page.evaluate(() => { document.querySelector('#thumb-quality option').textContent = 'Medium · optimized'; }); + await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.prepend(select.lastElementChild); }); + expect(await paths()).toEqual(expect.arrayContaining(['fields.0.control.options.0', 'fields.0.control.options.1'])); + await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.prepend(select.lastElementChild); select.selectedIndex = 0; }); + await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); }); + expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.0.text', 'actions.buttons.1.text'])); + await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); }); + const undoStyle = await mutate(page, { css: 'dialog#modal #thumb-limit { border-color: rgb(255, 0, 0); } dialog#modal #modal-body > p.small { color: rgb(255, 0, 0); }' }); + expect(await paths()).toEqual(expect.arrayContaining(['fields.2.control.borderTopColor', 'plan.color'])); + await undoStyle(); + expect(diffInventory(expected, await page.evaluate(measureThumbnailReview))).toEqual([]); +}); diff --git a/tests/browser/workspace-system-sources.spec.mjs b/tests/browser/workspace-system-sources.spec.mjs index 6450344a..58f8d4ec 100644 --- a/tests/browser/workspace-system-sources.spec.mjs +++ b/tests/browser/workspace-system-sources.spec.mjs @@ -304,16 +304,151 @@ test('Refresh files calls /gallery/rescan and toasts its counts', async ({ page await expect(toast(page)).toHaveText('Files could not be refreshed. Try again.'); }); -test('Thumbnail generation hands off to the existing gallery until its review dialog', async ({ page }) => { +const THUMBNAIL_PLAN_URL = '/prompt_manager/images/thumbnails/plan'; +const THUMBNAIL_JOB_URL = '/prompt_manager/jobs/thumbnails'; +const planCount = value => value.toLocaleString('en-US'); + +/** Open "Thumbnail maintenance" from Sources' Thumbnail generation → Review options. */ +async function openThumbnailReview(page) { const layout = await openSources(page); const thumbnails = layout.locator('.operation').filter({ has: page.getByRole('heading', { name: 'Thumbnail generation', exact: true }) }); await expect(thumbnails.locator('p')).toHaveText('Generate missing previews; rebuilding all is a separate explicit option.'); - const popup = page.waitForEvent('popup'); await thumbnails.getByRole('button', { name: 'Review options', exact: true }).click(); - const gallery = await popup; - expect(new URL(gallery.url()).pathname).toBe('/prompt_manager/gallery'); - await gallery.close(); - await expect(statusOf(page)).toHaveText('No unsaved changes.'); + await expect(dialog(page).locator('#modal-title')).toHaveText('Thumbnail maintenance'); + return dialog(page); +} + +/** The thumbnail requests this page sends: plan reads (their query) and job starts (their body). */ +function thumbnailRequests(page) { + const seen = { plans: [], starts: [] }; + page.on('request', item => { + const url = new URL(item.url()); + if (url.pathname === THUMBNAIL_PLAN_URL) seen.plans.push(Object.fromEntries(url.searchParams)); + if (item.method() === 'POST' && url.pathname === THUMBNAIL_JOB_URL) seen.starts.push(item.postDataJSON()); + }); + return seen; +} + +const missingLine = plan => `${planCount(plan.targets)} ${plan.targets === 1 ? 'preview' : 'previews'} to generate · ` + + `${planCount(plan.fresh)} already up to date.`; + +test('Review options shows the plan counts for the chosen scope/quality/size before Start', async ({ page, request }) => { + await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 3 } })).toBeOK(); + // Image loading quality (Appearance, Q3) preselects the dialog's quality. + await page.addInitScript(() => { if (location.protocol.startsWith('http')) localStorage.setItem('gallerySettings', JSON.stringify({ imageQuality: 'high' })); }); + const seen = thumbnailRequests(page); + const plan = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=missing&size=512&quality=high`); + expect(plan.targets).toBe(3); + const review = await openThumbnailReview(page); + await expect(review.locator('#modal-body > *')).toHaveCount(5); + await expect(review.locator('.field > label')).toHaveText(['Scope', 'Quality', 'Preview size limit']); + await expect(review.locator('#thumb-mode option')).toHaveText(['Missing thumbnails only', 'Rebuild all (explicit opt-in)']); + await expect(review.locator('#thumb-quality option')).toHaveText(['Medium · optimized', 'High · original', 'Low · fastest']); + await expect(review.locator('#thumb-mode')).toHaveValue('missing'); + await expect(review.locator('#thumb-quality')).toHaveValue('high'); + await expect(review.locator('#thumb-limit')).toHaveValue('512'); + await expect(review.locator('#thumb-limit')).toHaveAttribute('min', '128'); + await expect(review.locator('#thumb-limit')).toHaveAttribute('max', '2048'); + const line = review.locator('#modal-body > p.small'); + await expect(line).toHaveAttribute('role', 'status'); + await expect(line).toHaveText(missingLine(plan)); + expect(seen.plans).toEqual([{ mode: 'missing', size: '512', quality: 'high' }]); + await expect(review.locator('.dialog-actions > button')).toHaveText(['Cancel', 'Start']); + // Reviewing starts nothing; Cancel closes. + await review.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + expect(seen.starts).toEqual([]); + expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]); +}); + +test('size outside 128–2048 reports validity and requests nothing', async ({ page, request }) => { + const seen = thumbnailRequests(page); + const review = await openThumbnailReview(page); + const line = review.locator('#modal-body > p.small'); + await expect(line).toHaveText(/ already up to date\.$/); + const size = review.locator('#thumb-limit'); + for (const value of ['4096', '64', '']) { + await size.fill(value); + await expect(size).toHaveAttribute('aria-invalid', 'true'); + await expect(line).toHaveText('Enter a size from 128 to 2048 pixels.'); + expect(await size.evaluate(input => input.validity.valid)).toBe(false); + // Start keeps the dialog open and sends nothing; neither does a scope change. + await review.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(dialog(page).locator('#modal-title')).toHaveText('Thumbnail maintenance'); + await review.locator('#thumb-mode').selectOption('rebuild'); + await expect(line).toHaveText('Enter a size from 128 to 2048 pixels.'); + await review.locator('#thumb-mode').selectOption('missing'); + } + expect(seen.starts).toEqual([]); + expect(seen.plans).toEqual([{ mode: 'missing', size: '512', quality: 'medium' }]); + await size.fill('1024'); + await expect(size).not.toHaveAttribute('aria-invalid'); + await expect(line).toHaveText(/ already up to date\.$/); + expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]); +}); + +test('Start closes the dialog, toasts, and opens the job in Activity', async ({ page, request }) => { + await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 2 } })).toBeOK(); + const seen = thumbnailRequests(page); + const review = await openThumbnailReview(page); + await review.locator('#thumb-quality').selectOption('low'); + await review.locator('#thumb-limit').fill('256'); + await review.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + // The toast says it started (or, for a run this quick, that it already finished); Activity shows its progress. + await expect(toast(page)).toHaveText(/^(Thumbnail generation started\.|Thumbnails finished · 2 generated · 0 errors)$/); + expect(seen.starts).toEqual([{ mode: 'missing', size: 256, quality: 'low' }]); + const id = page.url().split('/').pop(); + const { job } = await serverJson(request, `/prompt_manager/jobs/${id}`); + expect(job).toMatchObject({ kind: 'thumbnails', title: 'Thumbnail generation', scope: { mode: 'missing', size: 256, quality: 'low' } }); + await expect(page.locator('#workspace-main h1')).toHaveText('Thumbnail generation'); + await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished'); +}); + +test('Rebuild all is explicit and the plan line says every preview is regenerated', async ({ page, request }) => { + const seen = thumbnailRequests(page); + const rebuild = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=rebuild&size=512&quality=medium`); + expect(rebuild.targets).toBeGreaterThan(0); + const review = await openThumbnailReview(page); + // Missing thumbnails only is the default; rebuilding is the explicit second option. + await expect(review.locator('#thumb-mode')).toHaveValue('missing'); + const line = review.locator('#modal-body > p.small'); + await expect(line).toHaveText(/ already up to date\.$/); + await review.locator('#thumb-mode').selectOption('rebuild'); + await expect(line).toHaveText(`Every preview is regenerated: ${planCount(rebuild.targets)} files.`); + expect(seen.plans).toEqual([{ mode: 'missing', size: '512', quality: 'medium' }, { mode: 'rebuild', size: '512', quality: 'medium' }]); + // Back and forth reads each scope once. + await review.locator('#thumb-mode').selectOption('missing'); + await expect(line).toHaveText(/ already up to date\.$/); + await review.locator('#thumb-mode').selectOption('rebuild'); + await expect(line).toHaveText(`Every preview is regenerated: ${planCount(rebuild.targets)} files.`); + expect(seen.plans).toHaveLength(2); + await review.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + expect(seen.starts).toEqual([{ mode: 'rebuild', size: 512, quality: 'medium' }]); + const id = page.url().split('/').pop(); + await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished'); +}); + +test('a plan that cannot be read says so; Start still starts', async ({ page }) => { + await page.route(`**${THUMBNAIL_PLAN_URL}?*`, route => route.fulfill({ status: 500, json: { success: false, error: 'disk' } })); + const review = await openThumbnailReview(page); + await expect(review.locator('#modal-body > p.small')).toHaveText('The previews could not be counted.'); + await expect(review.getByRole('button', { name: 'Start', exact: true })).not.toHaveAttribute('aria-disabled'); + await review.getByRole('button', { name: 'Cancel', exact: true }).click(); +}); + +test('a refused start shows the busy link toast', async ({ page, request }) => { + const running = await (await request.post('/__test__/jobs/start', { data: { title: 'Scan for metadata' } })).json(); + const review = await openThumbnailReview(page); + await review.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(dialog(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.'); + await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${running.job.id}`); + await expect(page).toHaveURL(`${workspace}#/system/sources`); + expect((await serverJson(request, '/prompt_manager/jobs')).jobs.map(job => job.kind)).toEqual(['scan']); + await request.post(`/__test__/jobs/${running.job.id}/finish`, { data: { state: 'finished' } }); }); test('a save refused by a running scan shows the busy link toast', async ({ page, request }) => { diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index a11cfdfa..aa5f6954 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -138,9 +138,32 @@ test('thumbnail row reflects the status endpoint', async ({ page, request }) => const layout = await openOverview(page); const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) }); await expect(row.locator('h3')).toHaveText('3 missing thumbnails'); - // Until the thumbnail review dialog (S5), Review thumbnails opens Sources. +}); + +test('Overview Review thumbnails opens the same dialog', async ({ page, request }) => { + await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 2 } })).toBeOK(); + const plan = await serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing&size=512&quality=medium'); + const starts = []; + page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/thumbnails')) starts.push(item.postDataJSON()); }); + const layout = await openOverview(page); + const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) }); + await expect(row.locator('h3')).toHaveText('2 missing thumbnails'); await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click(); - await expect(page).toHaveURL(`${workspace}#/system/sources`); + const review = page.locator('dialog#modal[open]'); + await expect(review.locator('#modal-title')).toHaveText('Thumbnail maintenance'); + await expect(page).toHaveURL(`${workspace}#/system/overview`); + await expect(review.locator('.field > label')).toHaveText(['Scope', 'Quality', 'Preview size limit']); + await expect(review.locator('#modal-body > p.small')).toHaveText(`2 previews to generate · ${number(plan.fresh)} already up to date.`); + // Escape closes it and focus returns to the row's button. + await page.keyboard.press('Escape'); + await expect(review).toHaveCount(0); + await expect(row.getByRole('button', { name: 'Review thumbnails', exact: true })).toBeFocused(); + await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click(); + await review.getByRole('button', { name: 'Start', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + expect(starts).toEqual([{ mode: 'missing', size: 512, quality: 'medium' }]); + const id = page.url().split('/').pop(); + await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished'); }); test('the thumbnail row paints when its own status read answers', async ({ page, request }) => { diff --git a/tests/js/workspace-thumbnail-review.test.js b/tests/js/workspace-thumbnail-review.test.js new file mode 100644 index 00000000..eff49e5b --- /dev/null +++ b/tests/js/workspace-thumbnail-review.test.js @@ -0,0 +1,93 @@ +// System › thumbnail review (#249 System S5 Task 14): the concept's "Thumbnail maintenance" dialog (app.js 1433–1437, +// `thumbnail-review`) on the thumbnail job (S5 Task 12). Pure parts: the option values the job takes, the preview size +// check, the Image loading quality preselection (Q3), the D99 plan line and the start outcome. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const reviewModule = import('../../web/js/workspace/thumbnail-review.mjs'); + +test('the concept options map to the job values in the concept order', async () => { + const { SCOPE_OPTIONS, QUALITY_OPTIONS, SIZE_LIMIT } = await reviewModule; + assert.deepEqual(SCOPE_OPTIONS, [['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)']]); + assert.deepEqual(QUALITY_OPTIONS, [['medium', 'Medium · optimized'], ['high', 'High · original'], ['low', 'Low · fastest']]); + assert.deepEqual(SIZE_LIMIT, { min: 128, max: 2048, value: 512 }); +}); + +test('size outside 128–2048 reports validity: only whole pixels in range are a size', async () => { + const { previewSize } = await reviewModule; + assert.equal(previewSize('512'), 512); + assert.equal(previewSize('128'), 128); + assert.equal(previewSize('2048'), 2048); + for (const text of ['', ' ', '127', '2049', '4096', '512.5', '-512', 'abc', '1e3', '0x200']) { + assert.equal(previewSize(text), null, text); + } +}); + +test('Image loading quality preselects the dialog quality (Q3); anything else is Medium', async () => { + const { preselectedQuality } = await reviewModule; + assert.equal(preselectedQuality('low'), 'low'); + assert.equal(preselectedQuality('high'), 'high'); + assert.equal(preselectedQuality('medium'), 'medium'); + assert.equal(preselectedQuality(undefined), 'medium'); + assert.equal(preselectedQuality('original'), 'medium'); +}); + +test('the plan line counts what a missing-only run generates and what is already up to date (D99)', async () => { + const { planText } = await reviewModule; + assert.equal(planText({ mode: 'missing', targets: 12, fresh: 30, remaining: 0 }), '12 previews to generate · 30 already up to date.'); + assert.equal(planText({ mode: 'missing', targets: 1, fresh: 1, remaining: 0 }), '1 preview to generate · 1 already up to date.'); + assert.equal(planText({ mode: 'missing', targets: 0, fresh: 0, remaining: 0 }), '0 previews to generate · 0 already up to date.'); + assert.equal(planText({ mode: 'missing', targets: 10000, fresh: 5, remaining: 2500 }), + '10,000 previews to generate · 5 already up to date · 2,500 more on the next run.'); +}); + +test('Rebuild all says every preview is regenerated (D99)', async () => { + const { planText } = await reviewModule; + assert.equal(planText({ mode: 'rebuild', targets: 42, fresh: 30, remaining: 0 }), 'Every preview is regenerated: 42 files.'); + assert.equal(planText({ mode: 'rebuild', targets: 1, fresh: 1, remaining: 0 }), 'Every preview is regenerated: 1 file.'); + assert.equal(planText({ mode: 'rebuild', targets: 10000, fresh: 9000, remaining: 3 }), + 'Every preview is regenerated: 10,000 files · 3 more on the next run.'); +}); + +test('an answer without counts is a failed plan, never zeros', async () => { + const { planText, PLAN_FAILED } = await reviewModule; + assert.equal(planText(null), PLAN_FAILED); + assert.equal(planText({ mode: 'missing', targets: 2 }), PLAN_FAILED); + assert.equal(planText({ mode: 'missing', targets: -1, fresh: 0, remaining: 0 }), PLAN_FAILED); + assert.equal(planText({ mode: 'other', targets: 1, fresh: 0, remaining: 0 }), PLAN_FAILED); +}); + +const scopeOf = () => ({ signal: new AbortController().signal }); + +test('Start starts the thumbnail job with the chosen options and opens it in Activity', async () => { + const { startThumbnails } = await reviewModule; + const calls = []; + const navigated = []; + const store = { start: async (kind, body) => { calls.push([kind, body]); return { job: { id: 'a'.repeat(32) } }; } }; + await startThumbnails({ store, scope: scopeOf(), options: { mode: 'rebuild', size: 1024, quality: 'high' }, + navigate: hash => navigated.push(hash), notify: () => assert.fail('no toast of its own') }); + assert.deepEqual(calls, [['thumbnails', { mode: 'rebuild', size: 1024, quality: 'high' }]]); + assert.deepEqual(navigated, [`#/activity/${'a'.repeat(32)}`]); +}); + +test('a refused start shows the busy link toast and stays', async () => { + const { startThumbnails } = await reviewModule; + const toasts = []; + const store = { start: async () => ({ busy: { id: 'b'.repeat(32), title: 'Scan for metadata' } }) }; + await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing', size: 512, quality: 'medium' }, + navigate: () => assert.fail('stays'), notify: message => toasts.push(message) }); + assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${'b'.repeat(32)}` }, '.']]); +}); + +test('a failed start says so; a start after the section left says nothing', async () => { + const { startThumbnails, START_FAILED } = await reviewModule; + const toasts = []; + const store = { start: async () => { throw new Error('offline'); } }; + await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing', size: 512, quality: 'medium' }, + navigate: () => assert.fail('stays'), notify: message => toasts.push(message) }); + assert.deepEqual(toasts, [START_FAILED]); + const gone = new AbortController(); + gone.abort(); + await startThumbnails({ store, scope: { signal: gone.signal }, options: { mode: 'missing', size: 512, quality: 'medium' }, + navigate: () => assert.fail('stays'), notify: () => assert.fail('silent') }); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 3d1704e6..f3df3f01 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -497,6 +497,12 @@ "functions": 98, "statements": 98 }, + "workspace/thumbnail-review.mjs": { + "lines": 96, + "branches": 93, + "functions": 98, + "statements": 96 + }, "workspace/toast.mjs": { "lines": 98, "branches": 98, diff --git a/web/js/workspace/system-overview.mjs b/web/js/workspace/system-overview.mjs index ac902064..07ac65a6 100644 --- a/web/js/workspace/system-overview.mjs +++ b/web/js/workspace/system-overview.mjs @@ -7,6 +7,7 @@ * N issues / Incomplete); the review, backup, thumbnail and sources rows say what the server reports, and nothing * reads 0 before the read answers: a load shows a status line, a failure an alert and Retry. The thumbnail row is * its own read of `/images/thumbnails/status` (a walk of every source): "Checking thumbnails…" until it answers. + * D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs). * - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals". * - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real * result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a @@ -20,6 +21,7 @@ import { closeWorkspaceDialog, openContentDialog } from './workspace-dialogs.mjs import { showWorkspaceToast } from './toast.mjs'; import { noun } from './plural.mjs'; import { operationRow } from './system-controls.mjs'; +import { openThumbnailReview } from './thumbnail-review.mjs'; export const OVERVIEW_URL = '/prompt_manager/system/overview'; export const STATISTICS_URL = '/prompt_manager/system/statistics'; @@ -317,7 +319,7 @@ function surface(heading, rows) { } /** The Overview's nodes, and its thumbnail row (painted by its own read). */ -function overviewNodes(overview, { scope, navigate, openStatistics }) { +function overviewNodes(overview, { scope, navigate, openStatistics, openThumbnails }) { const go = section => () => navigate(`#/system/${section}`); const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review), description: Number.isSafeInteger(overview.needs_review?.pending) @@ -327,12 +329,12 @@ function overviewNodes(overview, { scope, navigate, openStatistics }) { review.querySelector('button').setAttribute('aria-disabled', 'true'); const [sourcesTitle, sourcesDescription] = sourcesCopy(overview.sources); const thumbnails = operationRow({ scope, title: THUMBNAILS_LOADING, description: 'Generate only missing previews when necessary.', - label: 'Review thumbnails', onClick: go('sources') }); + label: 'Review thumbnails', onClick: openThumbnails }); const health = surface('Library health', [ review, operationRow({ scope, title: 'Backup status', description: backupText(overview.backup), label: 'View backups', onClick: go('backups') }), - // Until the thumbnail review dialog (S5), Review thumbnails opens Sources, where thumbnail generation lives. + // D99: Review thumbnails opens the thumbnail dialog (the concept's row starts a sample job directly). thumbnails, ]); const preferences = surface('Workspace preferences', [ @@ -352,7 +354,7 @@ function overviewNodes(overview, { scope, navigate, openStatistics }) { * answer replaces in place (the concept's metrics and columns sit directly in `.settings-layout`). A read that a newer * one superseded, or one answering after the section left, paints nothing. */ -export function mountOverview(container, { api, scope, navigate = navigateTo, notify = showWorkspaceToast, +export function mountOverview(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) { const overviewRead = latestOnly({ lifetime: scope.signal }); const statisticsRead = latestOnly({ lifetime: scope.signal }); @@ -394,7 +396,8 @@ export function mountOverview(container, { api, scope, navigate = navigateTo, no try { const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' })); if (overview === STALE || scope.signal.aborted) return; - const { nodes, thumbnails } = overviewNodes(overview, { scope, navigate, openStatistics }); + const { nodes, thumbnails } = overviewNodes(overview, { scope, navigate, openStatistics, + openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) }); thumbnailRow = thumbnails; sources = overview.sources; swap(nodes); diff --git a/web/js/workspace/system-sources.mjs b/web/js/workspace/system-sources.mjs index a204f073..c3b96cc2 100644 --- a/web/js/workspace/system-sources.mjs +++ b/web/js/workspace/system-sources.mjs @@ -14,8 +14,8 @@ * it to the host draft of `gallery_root_paths`; Remove takes one out. Both wait for the one Save row. * - "Watch for new outputs" (`monitoring_enabled`) and "Worker threads" (`worker_threads`, a range up to the server's * `cpu_count`, D113) are host drafts too. "Enrich existing metadata" reviews a scan of every source. - * - "Thumbnail generation → Review options" opens the existing gallery's thumbnail tool until its review dialog (S5), - * the D94 hand-off for the one capability not native yet. + * - "Thumbnail generation → Review options" opens "Thumbnail maintenance" (thumbnail-review.mjs, S5 Task 14), which + * starts the thumbnail Activity job. * * D98: per-source Remove; an added source reads "not saved yet" until Save; the line under the list says when * ComfyUI's output folder is used instead; "restart pending" for a source the running monitor does not watch yet; the @@ -28,6 +28,7 @@ import { createButton } from '../shared/toolkit/core.js'; import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs'; import { openMetadataScan } from './activity-scan.mjs'; +import { openThumbnailReview } from './thumbnail-review.mjs'; import { settingRow, operationRow } from './system-controls.mjs'; import { HOST_UNAVAILABLE } from './system-appearance.mjs'; import { showWorkspaceToast } from './toast.mjs'; @@ -36,7 +37,6 @@ import { noun } from './plural.mjs'; const SETTINGS_URL = '/prompt_manager/settings'; const VALIDATE_URL = '/prompt_manager/settings/validate-source'; const RESCAN_URL = '/prompt_manager/gallery/rescan'; -const GALLERY_URL = '/prompt_manager/gallery'; const ROOTS_KEY = 'gallery_root_paths'; export const NOTICE_KEY = 'pm_update_notice_dismissed'; @@ -222,8 +222,7 @@ const controlOf = (row, id) => row.querySelector(`[data-setting="${id}"]`); * Activity store (scans start through it), `drafts` the System drafts. */ export function mountSources(container, { api, activity, scope, drafts, notify = showWorkspaceToast, storage = defaultStorage, - openWindow = (url) => window.open(url, '_blank', 'noopener'), latestOnly = window.ApiClient.latestOnly, - STALE = window.ApiClient.STALE }) { + latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) { const statusRead = latestOnly({ lifetime: scope.signal }); // undefined while read, null when the read failed, else GET /settings' `sources`. let sources; @@ -270,9 +269,8 @@ export function mountSources(container, { api, activity, scope, drafts, notify = surface.append(monitoring, workers, operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.', label: 'Review scan', onClick: scanAll }), - // D94: the gallery keeps thumbnail generation until its review dialog (S5). operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.', - label: 'Review options', onClick: () => openWindow(window.ApiClient.resolveUrl(GALLERY_URL)) }), + label: 'Review options', onClick: trigger => openThumbnailReview({ api, store: activity, scope, trigger }) }), // D98: the legacy gallery's Refresh, which rereads every source's file list. operationRow({ scope, title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.', label: 'Refresh', onClick: refresh })); diff --git a/web/js/workspace/thumbnail-review.mjs b/web/js/workspace/thumbnail-review.mjs new file mode 100644 index 00000000..e764f564 --- /dev/null +++ b/web/js/workspace/thumbnail-review.mjs @@ -0,0 +1,172 @@ +/** + * Thumbnail review (#249 System S5 Task 14): the concept's "Thumbnail maintenance" dialog (`thumbnail-review`, app.js + * 1433–1437), opened by Sources' "Thumbnail generation → Review options" and Overview's "Review thumbnails". It starts + * the `thumbnails` Activity job (S5 Task 12) with the chosen options. + * + * - Scope: "Missing thumbnails only" (`missing`, the default) or "Rebuild all (explicit opt-in)" (`rebuild`). + * - Quality: "Medium · optimized" / "High · original" / "Low · fastest" (`medium` / `high` / `low`), preselected from + * Appearance's Image loading quality (`gallerySettings.imageQuality`, the same three values; Q3). + * - Preview size limit: the longest edge, a whole number from 128 to 2048 (concept default 512). A size outside that + * marks the field `aria-invalid`, says so in the plan line and sends nothing; Start reports the field's validity. + * - D99: a `p.small[role=status]` line before the actions says what the server's plan counts for the chosen scope + * (`GET /images/thumbnails/plan`, read-only): "N previews to generate · M already up to date." or, for a rebuild, + * "Every preview is regenerated: N files.", plus "· R more on the next run" past the per-run cap. Each scope is read + * once per dialog (the size and quality change no count). The primary is "Start" (the concept's "Simulate thumbnail + * job"): it closes the dialog and starts the job through the Activity store, which toasts that it started; the job + * opens in Activity, where its progress lives. A start refused because another job holds the slot toasts the D57 + * busy link toast and stays. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs'; +import { busyToast } from './activity-scan.mjs'; +import { readPreferences } from './preferences.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { navigateTo } from './state.mjs'; +import { noun } from './plural.mjs'; + +export const THUMBNAIL_PLAN_URL = '/prompt_manager/images/thumbnails/plan'; +export const THUMBNAILS_KIND = 'thumbnails'; +export const SCOPE_OPTIONS = Object.freeze([['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)']]); +export const QUALITY_OPTIONS = Object.freeze([['medium', 'Medium · optimized'], ['high', 'High · original'], ['low', 'Low · fastest']]); +export const SIZE_LIMIT = Object.freeze({ min: 128, max: 2048, value: 512 }); +export const PLAN_LOADING = 'Counting previews…'; +export const PLAN_FAILED = 'The previews could not be counted.'; +export const SIZE_INVALID = `Enter a size from ${SIZE_LIMIT.min} to ${SIZE_LIMIT.max} pixels.`; +export const START_FAILED = 'Thumbnail generation could not start. Try again.'; + +const QUALITIES = new Set(QUALITY_OPTIONS.map(([value]) => value)); +const count = value => value.toLocaleString('en-US'); +const isCount = value => Number.isSafeInteger(value) && value >= 0; + +/** The preview size for the field's text: a whole number of pixels in range, else null. */ +export function previewSize(text) { + if (typeof text !== 'string' || !/^\d{1,5}$/.test(text)) return null; + const size = Number(text); + return size >= SIZE_LIMIT.min && size <= SIZE_LIMIT.max ? size : null; +} + +/** Q3: Image loading quality preselects the dialog's quality; anything it does not know is Medium. */ +export function preselectedQuality(imageQuality) { + return QUALITIES.has(imageQuality) ? imageQuality : 'medium'; +} + +/** D99: the plan line for the server's `{mode, targets, fresh, remaining}`; an answer without counts failed. */ +export function planText(plan) { + if (!plan || !['missing', 'rebuild'].includes(plan.mode) || ![plan.targets, plan.fresh, plan.remaining].every(isCount)) { + return PLAN_FAILED; + } + const more = plan.remaining ? ` · ${count(plan.remaining)} more on the next run` : ''; + if (plan.mode === 'rebuild') return `Every preview is regenerated: ${count(plan.targets)} ${noun(plan.targets, 'file')}${more}.`; + return `${count(plan.targets)} ${noun(plan.targets, 'preview')} to generate · ${count(plan.fresh)} already up to date${more}.`; +} + +/** + * Start the thumbnail job with `options` (`{mode, size, quality}`) and open its view; a refused or failed start says + * why and stays. Nothing is said once the section that started it is gone. + */ +export async function startThumbnails({ store, scope, options, notify = showWorkspaceToast, navigate = navigateTo }) { + try { + const result = await store.start(THUMBNAILS_KIND, options); + if (scope.signal.aborted) return; + if (result.busy !== undefined) { + notify(busyToast(result.busy)); + return; + } + navigate(`#/activity/${result.job.id}`); + } catch { + if (!scope.signal.aborted) notify(START_FAILED); + } +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +function select(options, value) { + const node = el('select'); + node.append(...options.map(([optionValue, label]) => { + const option = el('option', label); + option.value = optionValue; + return option; + })); + node.value = value; + return node; +} + +const defaultQuality = () => { + try { + return readPreferences().values.imageQuality; + } catch { + return undefined; + } +}; + +/** + * Open "Thumbnail maintenance" on the section's `scope`. `store` is the page's Activity store; `trigger` gets focus + * back when the dialog closes. + */ +export function openThumbnailReview({ api, store, scope, trigger = null, quality = defaultQuality(), notify = showWorkspaceToast, + navigate = navigateTo }) { + const mode = select(SCOPE_OPTIONS, 'missing'); + const encoder = select(QUALITY_OPTIONS, preselectedQuality(quality)); + const size = el('input'); + size.type = 'number'; + size.value = String(SIZE_LIMIT.value); + size.min = String(SIZE_LIMIT.min); + size.max = String(SIZE_LIMIT.max); + const line = el('p', PLAN_LOADING, 'small'); + line.setAttribute('role', 'status'); + const cancel = createButton('Cancel'); + const start = createButton('Start', 'ca-primary'); + const actions = el('div', undefined, 'dialog-actions'); + actions.append(cancel, start); + + // The plan per scope: a promise of its answer (null when it failed); the size and quality change no count. + const plans = new Map(); + let open = true; + const chosenSize = () => previewSize(size.value); + const validate = () => { + const valid = chosenSize() !== null; + size.setCustomValidity(valid ? '' : SIZE_INVALID); + if (valid) size.removeAttribute('aria-invalid'); + else size.setAttribute('aria-invalid', 'true'); + return valid; + }; + const readPlan = chosen => { + const query = new URLSearchParams({ mode: chosen, size: String(chosenSize()), quality: encoder.value }); + return api.get(`${THUMBNAIL_PLAN_URL}?${query}`, { signal: scope.signal, cache: 'no-store' }).catch(() => null); + }; + async function paint() { + if (!validate()) { line.textContent = SIZE_INVALID; return; } + const chosen = mode.value; + if (!plans.has(chosen)) plans.set(chosen, readPlan(chosen)); + const pending = plans.get(chosen); + line.textContent = PLAN_LOADING; + const plan = await pending; + // A later choice painted its own line meanwhile, or the dialog closed. + if (!open || scope.signal.aborted || mode.value !== chosen || !validate()) return; + line.textContent = planText(plan); + } + + scope.listen(mode, 'change', paint); + scope.listen(size, 'input', paint); + scope.listen(cancel, 'click', () => closeWorkspaceDialog()); + scope.listen(start, 'click', () => { + if (!validate()) { + size.reportValidity(); + size.focus(); + return; + } + const options = { mode: mode.value, size: chosenSize(), quality: encoder.value }; + closeWorkspaceDialog(); + startThumbnails({ store, scope, options, notify, navigate }); + }); + openContentDialog({ scope, title: 'Thumbnail maintenance', trigger, + nodes: [field('Scope', mode, 'thumb-mode'), field('Quality', encoder, 'thumb-quality'), + field('Preview size limit', size, 'thumb-limit'), line, actions], + onClose: () => { open = false; } }); + paint(); +}