diff --git a/TESTING.md b/TESTING.md index d871719b..e012bc6e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4138,6 +4138,15 @@ legacy page workflows), not by this check. | Maintenance select all / clear all / run | admin | `#selectAllMaintenanceBtn`, `#clearAllMaintenanceBtn` | `selectAllMaintenance()`, `clearAllMaintenance()` | plan dialog shows the server's scope, protected, backup policy and media lines with the backup box (D100); Cancel writes nothing (workspace-system-maintenance.spec.mjs) | | Database backup | admin | — | `backupDatabase()` | Back up downloads through the api client and revokes the object URL (D103) (workspace-system-backups.spec.mjs) | | Restore from file | admin | `#restoreModal`, `#restoreFileInput`, `#confirmRestore`, `#cancelRestore` | `showRestoreModal()`, `confirmRestore()` | Review restore stages the chosen file and shows the real summary with the backup box (D105) (workspace-system-backups.spec.mjs) | +| Gallery thumbnail generation | gallery | `#generateThumbnailsBtn`, `#thumbnailStatus`, `#thumbnailProgress`, `#progressText`, `#progressPercent`, `#cancelThumbnailsBtn`, `#progressBar`, `#progressDetails`, `#progressETA` | `generateThumbnails()`, `generateThumbnailsWithProgress()` | Start closes the dialog, toasts, and opens the job in Activity (workspace-system-sources.spec.mjs) | +| Default view mode, image quality | gallery | `#imageQualitySelect`, `#defaultViewModeSelect` | — | defaults apply to a new visit but not to the current view or a saved view (workspace-system.spec.mjs) | +| Image info on cards; sidebar collapsed | gallery | `#showImageInfoToggle`, `#sidebarCollapsedByDefaultToggle` | — | added rows reach their consumers (workspace-system.spec.mjs) | +| Video autoplay, mute, loop | gallery | `#videoAutoplayToggle`, `#videoMuteToggle`, `#videoLoopToggle` | — | video defaults reach the viewer (workspace-system.spec.mjs) | +| Auto-load metadata, cache metadata, show file paths | gallery | `#autoLoadMetadataToggle`, `#cacheMetadataToggle`, `#showFilePathsToggle` | — | metadata off shows an explicit load and loads once on request (workspace-system.spec.mjs) | +| Browser cache clear | gallery | `#clearCacheBtn` | `clearCache()` | Metadata cache reviews and clears only owned browser entries when there are some (D102) (workspace-system-maintenance.spec.mjs) | +| Clear server thumbnails | gallery | — | — | Thumbnail cache opens the S5 dialog (workspace-system-maintenance.spec.mjs) | +| Refresh file listing | gallery | `#rescanFolderBtn` | `rescanFolder()` | Refresh files calls /gallery/rescan and toasts its counts (workspace-system-sources.spec.mjs) | +| Duplicate image scan, review, delete | gallery | `#scanDuplicatesBtn`, `#dupScanPill`, `#dupScanPillText`, `#duplicatesModal`, `#closeDuplicatesBtn`, `#duplicatesScanStatus`, `#dupProgressText`, `#dupProgressBar`, `#duplicatesContent`, `#duplicatesList`, `#duplicatesFooter`, `#duplicatesCount`, `#selectAllBtn`, `#removeDuplicatesBtn` | `scanDuplicates()`, `attachDuplicateStream()`, `resumeDuplicateScan()`, `applyDuplicateEvent()`, `renderDuplicateProgress()`, `setDuplicateButton()`, `showDuplicatePill()`, `hideDuplicatePill()`, `openDuplicatesModal()`, `showDuplicatesModal()`, `hideDuplicatesModal()`, `displayDuplicates()`, `selectAllDuplicates()`, `removeDuplicates()` | Find duplicates starts a job; its view links Review groups; Keep radios build a duplicate_files plan (D101) (workspace-system-maintenance.spec.mjs) | ### Browser cache ownership (#222) @@ -4156,17 +4165,15 @@ page writes a key that is neither a preference nor inventoried, writes one it ca CacheStorage or IndexedDB; `the storage scan reports every way of writing browser storage` pins those patterns. It skips `web/lib` (third-party builds) and the pinned Toolkit build (`web/js/shared/toolkit`, never hand-edited, verified by `npm run check`; -its storage belongs to the Toolkit repository and a new pin is reviewed on its own). The -gallery's server thumbnail clear stays its own request and runs first: refused or -unreachable, it changes nothing and says so (the old code toasted success when the -request failed); refused browser storage is reported, never hidden -(`tests/js/legacy-refusals.test.js`; the browser case `the gallery cache clear keeps -preferences and other tools caches` in `workflows.spec.mjs`). +its storage belongs to the Toolkit repository and a new pin is reviewed on its own). Since +System S13 (#330) the legacy gallery no longer offers Clear cache: System › Maintenance's +Metadata cache and Thumbnail cache reviews own both parts (removal table above), and +`cache-ownership.js` serves the native review. -The legacy duplicates dialog's View Full now names its source (`?root=`, with the -path URL-encoded) when the gallery's output folder is a listed source; before, the serve -route opened the first source holding a file at that path -(`test_view_full_names_the_gallery_folder_source`). +The duplicates dialog's View Full named its source (`?root=`, with the path +URL-encoded) when the gallery's output folder is a listed source +(`test_view_full_names_the_gallery_folder_source`); S13 removed the legacy dialog, and the +route keeps that behaviour for System › Maintenance's duplicate review. ### Library location contract (#249 System S6b) diff --git a/tests/browser/legacy-system-links.spec.mjs b/tests/browser/legacy-system-links.spec.mjs index 21b2cb83..56250bea 100644 --- a/tests/browser/legacy-system-links.spec.mjs +++ b/tests/browser/legacy-system-links.spec.mjs @@ -15,6 +15,11 @@ const LABELS = Object.freeze({ diagnostics: 'Diagnostics & logs', }); +async function openPageOptions(page) { + await page.locator('#pageOptionsBtn').click(); + await expect(page.locator('#settingsModal')).toBeVisible(); +} + // One row per System link of the design's §2 map: the page, the control's label, how to find it, the section it opens // and what a test must arrange before the control shows. const LINKS = Object.freeze([ @@ -22,6 +27,11 @@ const LINKS = Object.freeze([ { page: 'admin', label: 'Maintenance', locate: page => page.locator('#maintenanceBtn'), section: 'maintenance' }, { page: 'admin', label: 'Backup DB', locate: page => page.locator('#backupBtn'), section: 'backups' }, { page: 'admin', label: 'Restore DB', locate: page => page.locator('#restoreBtn'), section: 'backups' }, + { page: 'gallery', label: 'Settings', locate: page => page.locator('#settingsBtn'), section: 'appearance' }, + { page: 'gallery', label: 'Page options Maintenance', reveal: openPageOptions, + locate: page => page.locator('#pageOptionsMaintenanceLink'), section: 'maintenance' }, + { page: 'gallery', label: 'Page options Sources & scans', reveal: openPageOptions, + locate: page => page.locator('#pageOptionsSourcesLink'), section: 'sources' }, { page: 'admin', label: 'Diagnostics', locate: page => page.locator('#diagnosticsBtn'), section: 'diagnostics' }, { page: 'admin', label: 'View Logs', locate: page => page.locator('#logsBtn'), section: 'diagnostics' }, ]); @@ -80,6 +90,10 @@ for (const link of LINKS) { } test('a System link sends no request from the legacy page', async ({ page, context, request }) => { + // The gallery's thumbnail startup check (K9) reads its status on a timer of its own; it is off here. + await page.addInitScript(() => { + localStorage.setItem('gallerySettings', JSON.stringify({ checkThumbnailsAtStartup: false })); + }); for (const link of LINKS) { const control = await openLink(page, request, link); // The page's own start-up reads (list, film strips, settings) finish first; then the click must add none. @@ -193,3 +207,45 @@ test('Page options keeps the page-only settings admin', async ({ page, request } await page.locator('#pageOptionsBtn').click(); await expect(page.locator('#infiniteScrollDefault')).toBeChecked(); }); + +test('Page options keeps the page-only settings gallery', async ({ page }) => { + // Native Appearance keys and the startup prompt's own keys are in the shared object before the page opens. + const stored = { defaultViewMode: 'list', imageQuality: 'high', showImageInfo: false, videoMute: false, + autoLoadMetadata: false, workspaceLayout: 'masonry', thumbnailPromptDismissed: 'sig', thumbnailsGenerated: true, + checkThumbnailsAtStartup: false }; + await page.addInitScript(value => { + if (!sessionStorage.getItem('seeded')) { + sessionStorage.setItem('seeded', '1'); + localStorage.setItem('gallerySettings', value); + } + }, JSON.stringify(stored)); + const listed = page.waitForResponse(item => item.url().includes('/prompt_manager/images/output?')); + await page.goto(PAGES.gallery); + await listed; + await openPageOptions(page); + const dialog = page.locator('#settingsModal'); + await expect(dialog.locator('h3')).toHaveText('Page options'); + // Exactly the page-only settings (G5). + expect(await dialog.locator('input, select, textarea').evaluateAll(fields => fields.map(field => field.id))).toEqual([ + 'checkThumbnailsAtStartup', 'lazyLoadingToggle', 'defaultLimitSelect', 'gridColumnsSelect', 'infiniteScrollToggle', + 'debugModeToggle', 'apiTimeoutInput']); + await page.locator('#defaultLimitSelect').selectOption('50'); + await page.locator('#debugModeToggle').check(); + await page.locator('#saveSettingsBtn').click(); + await expect(dialog).toBeHidden(); + const read = () => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings'))); + let saved = await read(); + expect(saved).toMatchObject({ ...stored, defaultLimit: 50, debugMode: true }); + + // Reset puts back only the page options' defaults; the Appearance and startup-prompt keys stay. + await openPageOptions(page); + page.once('dialog', dialog => dialog.accept()); + await page.locator('#resetSettingsBtn').click(); + await expect(page.getByText('Page options reset to defaults')).toBeVisible(); + await expect(page.locator('#defaultLimitSelect')).toHaveValue('100'); + saved = await read(); + expect(saved).toMatchObject({ defaultViewMode: 'list', imageQuality: 'high', showImageInfo: false, videoMute: false, + autoLoadMetadata: false, workspaceLayout: 'masonry', thumbnailPromptDismissed: 'sig', thumbnailsGenerated: true, + defaultLimit: 100, debugMode: false, checkThumbnailsAtStartup: true, lazyLoading: true, gridColumns: 8, + infiniteScroll: false, apiTimeout: 30 }); +}); diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index e68e0695..849839b1 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -104,33 +104,6 @@ for (const leave of ['beforeunload', 'pagehide']) { }); } -// #222: the gallery's Clear cache clears the server's thumbnails by its own request and keeps this browser's -// preferences and other tools' storage; nothing of Prompt Manager's is cached in a browser, and the toast says so. -test('the gallery cache clear keeps preferences and other tools caches', async ({ page }) => { - await page.addInitScript(() => { - if (sessionStorage.getItem('seeded')) return; - sessionStorage.setItem('seeded', '1'); - localStorage.setItem('pm_workspace_theme', 'light'); - localStorage.setItem('other-tool-setting', 'keep'); - localStorage.setItem('pm_other_tool', 'keep'); - }); - await page.goto('/prompt_manager/gallery'); - await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2); - await page.evaluate(async () => { await (await caches.open('other-tool-gallery-thumbnails')).put('/x', new Response('x')); }); - const clears = []; - page.on('request', req => { if (req.url().includes('/clear-thumbnails')) clears.push(req.method()); }); - page.once('dialog', dialog => dialog.accept()); - await page.locator('#settingsBtn').click(); - await page.locator('#clearCacheBtn').click(); - await expect(page.getByText("Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser.")) - .toBeVisible(); - expect(clears).toEqual(['POST']); - expect(await page.evaluate(async () => ({ - theme: localStorage.getItem('pm_workspace_theme'), other: localStorage.getItem('other-tool-setting'), - prefixed: localStorage.getItem('pm_other_tool'), notice: localStorage.getItem('pm_update_notice_dismissed'), - caches: await caches.keys(), - }))).toEqual({ theme: 'light', other: 'keep', prefixed: 'keep', notice: '3.2.0', caches: ['other-tool-gallery-thumbnails'] }); -}); // #379: the reviewed tags are that image's own tags; no prompt changes. test('actual gallery review generates and persists tags for the selected root\'s image', async ({ page, request }) => { @@ -226,7 +199,7 @@ test('gallery infinite scroll appends a real backend page while retaining existi await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK(); await page.goto('/prompt_manager/gallery'); await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100); - await page.locator('#settingsBtn').click(); + await page.locator('#pageOptionsBtn').click(); await page.locator('#infiniteScrollToggle').check(); await page.locator('#saveSettingsBtn').click(); await expect(page.locator('#settingsModal')).toBeHidden(); @@ -288,98 +261,3 @@ test('prompt deletion requires confirmation and can retry after a network failur await expect(await request.get(url)).toBeOK(); } }); - -// The duplicate scan reads the output root only, but the fixture's input root holds a different -// shared/same.png. A bare /images/serve/ URL tries every gallery root in turn, so once the -// output copy is deleted it answers 200 with the input copy: check each file in its own root. -const serveIn = (root, path) => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}?root=${root}`; -const rootOf = url => new URL(url, 'http://gallery.test').searchParams.get('root'); - -// Which copy the scan keeps depends on file times: the fixture stamps shared/same.png with the -// wall clock, and the kernel can stamp its bulk copies a fraction of a millisecond earlier. -// listOrder rewrites the finished scan's groups so a test can pick the order it needs. -async function openDuplicateScan(page, request, listOrder = null) { - await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK(); - if (listOrder) { - await page.route('**/prompt_manager/scan_duplicates/stream', async route => { - const response = await route.fetch(); - const body = (await response.text()).replace(/^data: (.*)$/gm, (line, json) => { - const event = JSON.parse(json); - if (event.type !== 'complete') return line; - for (const group of event.duplicates) group.images = listOrder(group.images); - return `data: ${JSON.stringify(event)}`; - }); - await route.fulfill({ response, body }); - }); - } - await page.goto('/prompt_manager/gallery'); - await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4); - const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original)); - const output = rootOf(originals.find(url => url.includes('/bulk/'))); - const input = rootOf(originals.find(url => url.includes('/shared/same.png') && rootOf(url) !== output)); - expect(output).not.toBeNull(); - expect(input).not.toBeNull(); - await page.locator('#settingsBtn').click(); - await page.locator('#scanDuplicatesBtn').click(); - await expect(page.locator('#duplicatesCount')).toHaveText('1'); - const candidates = page.locator('.duplicate-checkbox'); - await expect(candidates).toHaveCount(2); - return { originals, output, input, candidates }; -} - -async function expectOnlySelectedGone(page, request, { originals, output, input }, selected, retained) { - expect((await request.get(serveIn(output, selected))).status()).toBe(404); - await expect(await request.get(serveIn(output, retained))).toBeOK(); - await expect(await request.get(serveIn(input, 'shared/same.png'))).toBeOK(); - await page.reload(); - await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3); - const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original)); - expect(remaining).toHaveLength(originals.length - 1); - for (const url of remaining) { - expect(originals).toContain(url); - await expect(await request.get(url)).toBeOK(); - } -} - -test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => { - const scan = await openDuplicateScan(page, request); - const { output, candidates } = scan; - const selected = await candidates.first().getAttribute('data-image-path'); - const retained = await candidates.last().getAttribute('data-image-path'); - await candidates.first().check(); - let deletes = 0; - page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; }); - confirmNextDialog(page, false, 'permanently delete 1 duplicate image files'); - await page.locator('#removeDuplicatesBtn').click(); - expect(deletes).toBe(0); - await expect(await request.get(serveIn(output, selected))).toBeOK(); - await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 }); - confirmNextDialog(page, true, 'permanently delete 1 duplicate image files'); - await page.locator('#removeDuplicatesBtn').click(); - await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible(); - await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled(); - await expect(await request.get(serveIn(output, selected))).toBeOK(); - confirmNextDialog(page, true, 'permanently delete 1 duplicate image files'); - const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images')); - await page.locator('#removeDuplicatesBtn').click(); - expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 }); - await expect(page.locator('#duplicatesModal')).toBeHidden(); - await expectOnlySelectedGone(page, request, scan, selected, retained); -}); - -test('deleting a duplicate whose path another gallery root also holds removes only the scanned copy', async ({ page, request }) => { - // The order CI saw: a bulk copy stamped first, so shared/same.png is the first Delete candidate. - const ciOrder = ['bulk/0000.png', 'shared/same.png', 'bulk/0001.png']; - const rank = image => ciOrder.indexOf(image.path.replace(/\\/g, '/')); - const scan = await openDuplicateScan(page, request, images => [...images].sort((a, b) => rank(a) - rank(b))); - const selected = await scan.candidates.first().getAttribute('data-image-path'); - const retained = await scan.candidates.last().getAttribute('data-image-path'); - expect(selected.replace(/\\/g, '/')).toBe('shared/same.png'); - await scan.candidates.first().check(); - confirmNextDialog(page, true, 'permanently delete 1 duplicate image files'); - const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images')); - await page.locator('#removeDuplicatesBtn').click(); - expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 }); - await expect(page.locator('#duplicatesModal')).toBeHidden(); - await expectOnlySelectedGone(page, request, scan, selected, retained); -}); diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index a8fbf658..172deccd 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -76,35 +76,79 @@ test("the workspace keys have their own defaults and validation", () => { assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid"); }); -test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { +// #330 S13: the legacy gallery's Page options edit only the page-only keys; System › Appearance owns the rest, so a Save +// or a Reset from the legacy page keeps every other stored key. +function legacyGallery(stored, controls, extra = {}) { const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const settings = require("../../web/js/gallery-settings.js"); - const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]); - const controls = { - lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" }, - defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false }, - autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false }, - sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false }, - apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true }, - videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true }, - }; + const storage = new Map([["gallerySettings", JSON.stringify(stored)]]); const context = vm.createContext({ document: { addEventListener() {}, getElementById: id => controls[id] }, window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings, - localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, + localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), + removeItem: key => storage.delete(key) }, + ...extra, }); const file = path.join(__dirname, "../../web/js/gallery.js"); const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file }); const manager = Object.create(Manager.prototype); - Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} }); + const calls = []; + Object.assign(manager, { showNotification: (message, type) => calls.push([type, message]), hideSettings() {}, + applySettings: applied => calls.push(["apply", applied.lazyLoading]) }); + return { manager, storage, calls, saved: () => JSON.parse(storage.get("gallerySettings")) }; +} + +const PAGE_OPTION_CONTROLS = () => ({ + checkThumbnailsAtStartup: { checked: false }, lazyLoadingToggle: { checked: false }, defaultLimitSelect: { value: "200" }, + gridColumnsSelect: { value: "6" }, infiniteScrollToggle: { checked: true }, debugModeToggle: { checked: true }, + apiTimeoutInput: { value: "45" }, +}); + +test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { + const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1, + defaultViewMode: "list", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig" }, PAGE_OPTION_CONTROLS()); manager.saveSettings(); - const saved = JSON.parse(storage.get("gallerySettings")); - assert.equal(saved.workspaceLayout, "masonry"); - assert.equal(saved.workspaceDensity, "compact"); - assert.equal(saved.someOtherTool, 1); - assert.equal(saved.defaultViewMode, "list"); - assert.equal(saved.showImageInfo, false); - assert.equal(saved.videoMute, false); + const result = saved(); + assert.equal(result.workspaceLayout, "masonry"); + assert.equal(result.workspaceDensity, "compact"); + assert.equal(result.someOtherTool, 1); + assert.equal(result.defaultViewMode, "list"); + assert.equal(result.showImageInfo, false); + assert.equal(result.videoMute, false); + assert.equal(result.thumbnailPromptDismissed, "sig"); + // The page-only keys come from the dialog. + assert.equal(result.lazyLoading, false); + assert.equal(result.debugMode, true); + assert.equal(result.checkThumbnailsAtStartup, false); + assert.equal(result.defaultLimit, 200); + assert.equal(result.gridColumns, 6); + assert.equal(result.infiniteScroll, true); + assert.equal(result.apiTimeout, 45); +}); + +test("a legacy gallery Reset restores only the page options and keeps every other stored key", () => { + const controls = PAGE_OPTION_CONTROLS(); + const { manager, saved, calls } = legacyGallery({ workspaceLayout: "masonry", defaultViewMode: "list", videoMute: false, + thumbnailPromptDismissed: "sig", thumbnailsGenerated: true, lazyLoading: false, debugMode: true, defaultLimit: 200, + gridColumns: 6, infiniteScroll: true, apiTimeout: 45, checkThumbnailsAtStartup: false }, controls, + { confirm: message => (calls.push(["confirm", message]), true) }); + manager.resetSettings(); + const result = saved(); + const { DEFAULT_SETTINGS } = require("../../web/js/gallery-settings.js"); + for (const key of ["checkThumbnailsAtStartup", "lazyLoading", "defaultLimit", "gridColumns", "infiniteScroll", "debugMode", + "apiTimeout"]) { + assert.equal(result[key], DEFAULT_SETTINGS[key], key); + } + assert.equal(result.workspaceLayout, "masonry"); + assert.equal(result.defaultViewMode, "list"); + assert.equal(result.videoMute, false); + assert.equal(result.thumbnailPromptDismissed, "sig"); + assert.equal(result.thumbnailsGenerated, true); + // The dialog shows the defaults again and the page applies them. + assert.equal(controls.lazyLoadingToggle.checked, DEFAULT_SETTINGS.lazyLoading); + assert.equal(controls.apiTimeoutInput.value, DEFAULT_SETTINGS.apiTimeout); + assert.deepEqual(calls, [["confirm", "Reset these page options to their defaults?"], ["apply", DEFAULT_SETTINGS.lazyLoading], + ["success", "Page options reset to defaults"]]); }); diff --git a/tests/js/gallery-thumbnails.test.js b/tests/js/gallery-thumbnails.test.js index cb135161..118306a2 100644 --- a/tests/js/gallery-thumbnails.test.js +++ b/tests/js/gallery-thumbnails.test.js @@ -112,11 +112,12 @@ test('saving gallery settings retains the remembered thumbnail dismissal', () => }); // #249 System S5: the stream's work is now an Activity job that outlives the page's connection, so the gallery's -// Cancel asks the server to stop the job. The stream's first frame, `job {id, started}`, names the job; the gallery +// Cancel asks the server to stop the job. Since #330 the startup dialog is the gallery's only thumbnail stream (System +// › Sources & scans generates the rest). The stream's first frame, `job {id, started}`, names the job; the gallery // cancels by that id, only a job this stream started, and a Cancel before the id waits for it (review I1). const JOB = 'b'.repeat(32); -function streamFixture({ modal = false, postFails = false } = {}) { +function streamFixture({ postFails = false } = {}) { const posts = []; const warnings = []; const gets = []; @@ -146,18 +147,20 @@ function streamFixture({ modal = false, postFails = false } = {}) { return { success: true }; } } }); const emit = (type, data) => (listeners[type] || []).forEach(handler => handler({ data: JSON.stringify(data) })); + // The dialog's Cancel runs the handler and from then on reads as cancelled, as the startup dialog does. + let cancelled = false; const ui = { addStatusMessage() {}, progressText: element(), progressPercent: element(), progressBar: element(), - progressDetails: element(), progressETA: element(), isCancelled: () => false, - onCancel: handler => { ui.cancel = handler; } }; - const result = modal ? manager.generateThumbnailsWithProgressInModal(ui) - : manager.generateThumbnailsWithProgress({ quality: 'medium' }); + progressDetails: element(), progressETA: element(), isCancelled: () => cancelled, + onCancel: handler => { ui.cancel = () => { cancelled = true; handler(); }; } }; + const result = manager.generateThumbnailsWithProgressInModal(ui); const outcome = result.then(value => ({ value }), error => ({ error: error.message })); - const cancel = () => (modal ? ui.cancel() : elements.cancelThumbnailsBtn.click()); + const cancel = () => ui.cancel(); const settle = () => new Promise(resolve => setImmediate(resolve)); return { emit, source, posts, gets, warnings, outcome, cancel, settle }; } const cancelUrl = id => `/prompt_manager/jobs/${id}/cancel`; +const CANCELLED = 'Thumbnail generation cancelled; previews already written are kept'; test('Cancel before the job id arrives cancels that job once it does', async () => { const f = streamFixture(); @@ -165,8 +168,8 @@ test('Cancel before the job id arrives cancels that job once it does', async () await f.settle(); assert.deepEqual(f.posts, []); assert.equal(f.source.closed, 0); // kept open until the stream names its job - assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' }); - f.emit('status', { phase: 'scanning', message: 'Scanning…' }); + assert.deepEqual(await f.outcome, { error: CANCELLED }); + // The job frame is the stream's first (the dialog closes the stream on any later frame once cancelled). f.emit('job', { id: JOB, started: true }); await f.settle(); assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]])); @@ -185,7 +188,7 @@ test('Cancel after the id cancels it at once, and a double Cancel posts once', a await f.settle(); assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]])); assert.equal(f.source.closed, 1); - assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' }); + assert.deepEqual(await f.outcome, { error: CANCELLED }); assert.deepEqual(f.gets, []); }); @@ -216,11 +219,11 @@ test('a cancel post that fails is reported, not thrown', async () => { await f.settle(); assert.equal(f.posts.length, 1); assert.equal(f.warnings.length, 1); - assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' }); + assert.deepEqual(await f.outcome, { error: CANCELLED }); }); test('the startup dialog cancels its own job by id too', async () => { - const f = streamFixture({ modal: true }); + const f = streamFixture(); f.cancel(); await f.settle(); assert.deepEqual(f.posts, []); diff --git a/tests/js/legacy-refusals.test.js b/tests/js/legacy-refusals.test.js index f43f912b..78d15c9b 100644 --- a/tests/js/legacy-refusals.test.js +++ b/tests/js/legacy-refusals.test.js @@ -65,87 +65,6 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) { const BUSY = 'Scan for metadata already running'; -test('gallery cache clear reports a refused server clear and never claims success', async () => { - const calls = []; - const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => { - calls.push(url); - return jsonResponse(409, { success: false, error: BUSY }); - }); - page.getSettings = () => ({ thumbnailsGenerated: true }); - dom.getElementById('thumbnailStatus').textContent = 'Generated'; - - await page.clearCache(); - - assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']); - assert.deepEqual(notifications, [['error', `Thumbnails were not cleared: ${BUSY}`]]); - assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated'); - assert.equal(storage.get('gallerySettings'), undefined); // thumbnailsGenerated is not reset -}); - -test('gallery cache clear says what it cleared and keeps preferences and other data (#222)', async () => { - const calls = []; - const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => { - calls.push(url); - return jsonResponse(200, { success: true, cleared_count: 3 }); - }); - page.getSettings = () => ({ thumbnailsGenerated: true }); - - await page.clearCache(); - - // The server's thumbnails are cleared by their own explicit call; nothing Prompt Manager owns is cached in this - // browser, so no browser entry is removed and the toast says exactly that. - assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']); - assert.deepEqual(notifications, [['success', - "Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser."]]); - assert.equal(dom.nodes.thumbnailStatus.textContent, 'Not generated'); - assert.equal(JSON.parse(storage.get('gallerySettings')).thumbnailsGenerated, false); - assert.equal(storage.get('userData'), 'keep'); - assert.equal(storage.get('pm_cached'), '1'); - assert.equal(storage.get('pm_workspace_theme'), 'light'); - assert.equal(storage.get('pm_update_notice_dismissed'), '3.3.0'); -}); - -test("gallery cache clear never deletes another tool's gallery or thumbnail caches (#222)", async () => { - const names = new Set(['comfy-gallery', 'thumbnail-cache']); - const caches = { keys: async () => [...names], delete: async name => names.delete(name) }; - const { page, notifications } = load('gallery.js', 'GalleryManager', - async () => jsonResponse(200, { success: true }), { caches, window: { caches } }); - page.getSettings = () => ({}); - - await page.clearCache(); - - assert.deepEqual([...names], ['comfy-gallery', 'thumbnail-cache']); - assert.equal(notifications.at(-1)[0], 'success'); -}); - -test('gallery cache clear reports a lost server clear instead of success', async () => { - const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async () => { - throw new TypeError('Failed to fetch'); - }); - page.getSettings = () => ({ thumbnailsGenerated: true }); - dom.getElementById('thumbnailStatus').textContent = 'Generated'; - - await page.clearCache(); - - assert.deepEqual(notifications, [['error', 'Thumbnails were not cleared: the server could not be reached.']]); - assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated'); - assert.equal(storage.get('gallerySettings'), undefined); -}); - -test('gallery cache clear says so when browser storage is denied', async () => { - const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); }, - getItem() { throw new Error('SecurityError'); }, setItem() { throw new Error('SecurityError'); }, - removeItem() { throw new Error('SecurityError'); } }; - const { page, notifications } = load('gallery.js', 'GalleryManager', - async () => jsonResponse(200, { success: true }), { localStorage: denied }); - page.getSettings = () => ({}); - - await page.clearCache(); - - assert.deepEqual(notifications, [['warning', - 'Thumbnails cleared; original images untouched. This browser refused access to its storage, so nothing there was cleared.']]); -}); - test('admin settings save shows why config.json refused it', async () => { const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.'; const { page, notifications } = load('admin.js', 'PromptAdmin', diff --git a/web/gallery.html b/web/gallery.html index 5613555f..3ce82d2a 100644 --- a/web/gallery.html +++ b/web/gallery.html @@ -8,8 +8,6 @@ - - @@ -76,9 +74,14 @@ class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors"> List View - - -
- Status: Not generated -
- Thumbnails speed up gallery loading. Original images always used for viewing and metadata. -
-
+
-
+

Prompt to generate thumbnails if missing when opening gallery

@@ -218,43 +205,9 @@
- - -
- - -
-
Image Loading
-
-
- - -
-
- - -
+
+ +
@@ -264,13 +217,6 @@

Display Settings

-
- - -
-
- - -
-
- - -
@@ -305,78 +243,6 @@
- - -
-
Video Settings
-
- - -
-
- - -
-
- - -
-
-
- - -
-

Metadata Settings

- -
-
- - -
-
- - -
-
- - -
-
-
- - -
-

Maintenance

- -
-
-
-
Clear Cache
-

Clear cached metadata and thumbnails

-
- -
-
-
-
Rescan Output Folder
-

Refresh the image list from disk

-
- -
-
-
-
Find Duplicate Images
-

Scan for duplicate images based on file content

-
- -
-
@@ -395,6 +261,14 @@
+

Thumbnails, cache, file listing and duplicates are in System. + Maintenance + · + Sources & scans +

+
- - - - - -