diff --git a/tests/js/gallery-metadata.test.js b/tests/js/gallery-metadata.test.js new file mode 100644 index 0000000..3d4f16e --- /dev/null +++ b/tests/js/gallery-metadata.test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); + +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function fixture() { + const context = vm.createContext({ document: { addEventListener() {}, getElementById() {} }, + window: {}, console: { error() {} }, escapeHtml: value => value }); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const manager = Object.create(Manager.prototype); + const requests = new Map(); + manager.extractImageMetadata = url => { + const pending = deferred(); + requests.set(url, pending); + return pending.promise; + }; + manager.displayMetadata = (metadata, panel) => { + panel.innerHTML = metadata?.positivePrompt || 'No metadata'; + manager.currentMetadata = metadata; + }; + manager.stopViewerSlideshow = () => {}; + return { manager, requests, panel: { innerHTML: '', isConnected: true } }; +} + +test('a late response cannot overwrite the selected image or copy state', async () => { + const { manager, requests, panel } = fixture(); + const first = manager.loadImageMetadata('old.png', panel); + const second = manager.loadImageMetadata('new.png', panel); + requests.get('new.png').resolve({ positivePrompt: 'new prompt' }); + await second; + requests.get('old.png').resolve({ positivePrompt: 'old prompt' }); + await first; + assert.equal(panel.innerHTML, 'new prompt'); + assert.equal(manager.currentMetadata.positivePrompt, 'new prompt'); +}); + +test('navigation clears old action data while the new request is pending', async () => { + const { manager, requests, panel } = fixture(); + manager.currentMetadata = { positivePrompt: 'old prompt' }; + const pending = manager.loadImageMetadata('new.png', panel); + assert.equal(manager.currentMetadata, null); + requests.get('new.png').resolve(null); + await pending; + assert.equal(manager.currentMetadata, null); +}); + +test('a stale error cannot replace newer metadata', async () => { + const { manager, requests, panel } = fixture(); + const first = manager.loadImageMetadata('old.png', panel); + const second = manager.loadImageMetadata('new.png', panel); + requests.get('new.png').resolve({ positivePrompt: 'new prompt' }); + await second; + requests.get('old.png').reject(new Error('old request failed')); + await first; + assert.equal(panel.innerHTML, 'new prompt'); +}); + +for (const close of ['destroy viewer', 'remove modal']) { + test(`closing via ${close} prevents pending metadata from restoring action data`, async () => { + const { manager, requests, panel } = fixture(); + const pending = manager.loadImageMetadata('old.png', panel); + if (close === 'destroy viewer') manager.destroyViewer(); + else panel.isConnected = false; + requests.get('old.png').resolve({ positivePrompt: 'closed prompt' }); + await pending; + assert.equal(manager.currentMetadata, null); + assert.notEqual(panel.innerHTML, 'closed prompt'); + }); +} diff --git a/tools/vendor/tests/viewer.spec.mjs b/tools/vendor/tests/viewer.spec.mjs index dbc18fd..f597288 100644 --- a/tools/vendor/tests/viewer.spec.mjs +++ b/tools/vendor/tests/viewer.spec.mjs @@ -181,3 +181,28 @@ test('touch pinch zoom and rotation remain usable', async ({ page, isMobile }) = await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await expect.poll(() => page.evaluate(() => Math.abs(gallery.viewer.imageData.rotate))).toBe(90); }); + +test('metadata follows the selected image when responses finish out of order', async ({ page }) => { + await loadGallery(page); + await page.evaluate(() => { + delete gallery.loadImageMetadata; + window.metadataRequests = new Map(); + gallery.extractImageMetadata = url => new Promise(resolve => metadataRequests.set(url, resolve)); + gallery.openViewer(0); + }); + await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/0.svg'))).toBe(true); + await page.evaluate(() => gallery.viewer.next()); + await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/1.svg'))).toBe(true); + await page.evaluate(() => metadataRequests.get('http://viewer.test/original/1.svg')({ positivePrompt: 'Selected prompt' })); + await expect(page.locator('#metadata-content')).toContainText('Selected prompt'); + await page.evaluate(() => metadataRequests.get('http://viewer.test/original/0.svg')({ positivePrompt: 'Stale prompt' })); + await expect(page.locator('#metadata-content')).toContainText('Selected prompt'); + await expect(page.locator('#metadata-content')).not.toContainText('Stale prompt'); + expect(await page.evaluate(() => gallery.currentMetadata.positivePrompt)).toBe('Selected prompt'); + await page.evaluate(() => gallery.viewer.next()); + await expect.poll(() => page.evaluate(() => metadataRequests.has('http://viewer.test/original/2.svg'))).toBe(true); + await page.keyboard.press('Escape'); + await expect(page.locator('#metadata-sidebar')).toHaveCount(0); + await page.evaluate(() => metadataRequests.get('http://viewer.test/original/2.svg')({ positivePrompt: 'Closed prompt' })); + expect(await page.evaluate(() => gallery.currentMetadata)).toBeNull(); +}); diff --git a/web/js/gallery.js b/web/js/gallery.js index 126ae23..78deff6 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -583,6 +583,8 @@ } destroyViewer({ keepSlideshow = false } = {}) { + this.metadataRequest = null; + this.currentMetadata = null; if (!keepSlideshow) this.stopViewerSlideshow(); if (this.viewer) { this.viewer.clearEnforceFocus(); @@ -647,6 +649,8 @@ } }, hide: () => { + this.metadataRequest = null; + this.currentMetadata = null; this.viewer?.clearEnforceFocus(); this.stopViewerSlideshow(); const sidebar = document.getElementById('metadata-sidebar'); @@ -1189,6 +1193,11 @@ } async loadImageMetadata(imageUrl, container) { + const request = {}; + this.metadataRequest = request; + this.currentMetadata = null; + container.textContent = 'Loading metadata...'; + const isCurrent = () => this.metadataRequest === request && container.isConnected !== false; try { // First try to get linked prompt from database let databasePrompt = null; @@ -1254,8 +1263,9 @@ } } - this.displayMetadata(metadata, container); + if (isCurrent()) this.displayMetadata(metadata, container); } catch (error) { + if (!isCurrent()) return; console.error('Error loading metadata:', error); container.innerHTML = `