* fix(inspector): give image tags their own labelled section (#449) The inspector drew a file's tags as a bare chip row right under the negative prompt, so they read as part of it. The side panel, the viewer details and History's current file now draw them in labelled sections in the prompt sections' frame: "Image tags · N" for the file's own tags, then "Prompt tags · N" or "Recipe tags · N" for the linked tags. Each count is true: repeats are removed within a source only. Recorded as D154. * feat(inspect): choosing a file opens the viewer with its details (#448) On Inspect a file, choosing or dropping a file now opens the shared viewer on it at once, details showing, instead of previewing it and waiting for "Open file metadata". Details show on this page even when "Collapse viewer details by default" is on. An unreadable file or an AVI/WMV note stays on the page; the heading action reopens any selection. A new selection that starts while the viewer shows the previous file closes it first, so its blob URL is never revoked under an open element. Recorded as D155. * feat(viewer): full-screen toggle beside the close button, and a System default (#450) Every viewer gets a button before the close button that switches between Centered (today's framed dialog) and Full screen (the whole browser window, edge to edge); it names the view it switches to. In full screen the details stay toggleable. System > Appearance gains a Viewer surface with "Open viewer in: Centered / Full screen" and the moved "Collapse viewer details by default"; the choice is a browser display preference (viewerOpenMode). The button changes only the current view and hides at 700px and below, where the viewer is already full-bleed. Recorded as D156. * fix(inspect): open a video's viewer only once its preview settles (#448) Replacing a video while both the inline preview and the auto-opened viewer's video were still starting deadlocked WebKit (D62). The viewer now opens for a video once its inline preview reports metadata or an error (or after 15 s). * test(viewer): pin the D97 view-switch spec to the desktop width (#450) * test(inspect): cover the AVI note and an open dialog holding the auto-open back (#448) Frontend coverage for inspect.mjs fell below its floor (branches 91.04 < 92, functions 92.85 < 93): no spec reached the AVI toast or the open-dialog guard, and the video wait had a page-end callback no spec reached. The wait now ends through its own callback at page end, and two specs cover the AVI note and a selection landing under another dialog.
205 lines
12 KiB
JavaScript
205 lines
12 KiB
JavaScript
// System › Appearance (#249 System S3 Task 8): the concept's "Browsing & display" rows (app.js 676–678) on the drafts
|
||
// of the one Save row, their stored values (Q3 mappings), the host timeout's 0–300 whole seconds and the D97 rows.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const gallerySettings = require('../../web/js/gallery-settings.js');
|
||
|
||
const appearanceModule = import('../../web/js/workspace/system-appearance.mjs');
|
||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||
globalThis.GallerySettings = gallerySettings;
|
||
|
||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||
function fakeDOM() {
|
||
const previous = global.document;
|
||
const elements = [];
|
||
class Element {
|
||
constructor(tag) {
|
||
this.tagName = tag.toUpperCase();
|
||
this.children = [];
|
||
this.attributes = {};
|
||
this.listeners = [];
|
||
this.dataset = {};
|
||
this.className = '';
|
||
this.id = '';
|
||
this.value = '';
|
||
this._text = '';
|
||
elements.push(this);
|
||
}
|
||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||
set textContent(text) { this.children = []; this._text = String(text); }
|
||
append(...nodes) {
|
||
for (const node of nodes) {
|
||
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
|
||
child.parentNode = this;
|
||
this.children.push(child);
|
||
}
|
||
}
|
||
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||
removeAttribute(name) { delete this.attributes[name]; }
|
||
addEventListener(type, callback, options = {}) {
|
||
if (options.signal?.aborted) return;
|
||
const entry = { type, callback };
|
||
this.listeners.push(entry);
|
||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||
}
|
||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
|
||
click() { this.dispatch('click'); }
|
||
}
|
||
global.document = {
|
||
createElement: tag => new Element(tag),
|
||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||
};
|
||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||
return {
|
||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||
find: (root, predicate) => walk(root).find(predicate),
|
||
tags: node => node.children.map(child => child.tagName.toLowerCase()),
|
||
restore() { global.document = previous; },
|
||
};
|
||
}
|
||
|
||
const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true,
|
||
imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true,
|
||
sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' };
|
||
|
||
async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) {
|
||
const { mountAppearance } = await appearanceModule;
|
||
const { createSystemDrafts } = await stateModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
let hostValues = null;
|
||
const drafts = createSystemDrafts({
|
||
readBrowser: () => ({ ...browser }), readRuntime: () => ({}),
|
||
readHost: async () => { if (hostFails) throw new Error('offline'); return { values: hostValues, revision: 'sha256:one' }; },
|
||
writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
|
||
});
|
||
await drafts.load('browser');
|
||
const scope = createScope();
|
||
const [surface, viewer] = mountAppearance(null, { scope, drafts });
|
||
const byId = id => dom.find(surface, node => node.dataset?.setting === id) ?? dom.find(viewer, node => node.dataset?.setting === id);
|
||
return { dom, drafts, scope, surface, viewer, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
|
||
fixHost: () => { hostFails = false; } };
|
||
}
|
||
|
||
test('the surface is the concept section with the concept rows in order, then the D97 rows', async () => {
|
||
const { APPEARANCE_ROWS } = await appearanceModule;
|
||
const { dom, surface, scope } = await mounted();
|
||
try {
|
||
assert.equal(surface.className, 'surface');
|
||
assert.equal(surface.children[0].textContent, 'Browsing & display');
|
||
assert.equal(surface.children[1].textContent, 'One set of defaults; toolbar changes apply to the current view.');
|
||
const rows = surface.children.slice(2);
|
||
const titles = rows.map(row => row.children[0].children[0].textContent);
|
||
assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection',
|
||
'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos',
|
||
'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards',
|
||
'Reset display defaults']);
|
||
const continuous = rows[2];
|
||
assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']);
|
||
assert.equal(continuous.children[1].className, 'chip blue');
|
||
assert.equal(continuous.children[1].textContent, 'Infinite scroll');
|
||
assert.equal(rows.at(-1).className, 'operation');
|
||
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 12);
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|
||
|
||
// #450 (D156, amends D97): a "Viewer" surface after "Browsing & display" holds the viewer's two defaults.
|
||
test('the Viewer surface holds Open viewer in and the moved collapse row', async () => {
|
||
const { VIEWER_ROWS } = await appearanceModule;
|
||
const { dom, viewer, scope } = await mounted();
|
||
try {
|
||
assert.equal(viewer.className, 'surface');
|
||
assert.equal(viewer.children[0].textContent, 'Viewer');
|
||
assert.equal(viewer.children[1].textContent, 'How the image viewer opens; the button beside ✕ switches the current view.');
|
||
assert.deepEqual(viewer.children.slice(2).map(row => row.children[0].children[0].textContent), ['Open viewer in', 'Collapse viewer details by default']);
|
||
assert.deepEqual(VIEWER_ROWS.map(row => row[0]), ['viewer-mode', 'viewer-details']);
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|
||
|
||
test('added rows reach their consumers: each control edits its stored key with the Q3 values', async () => {
|
||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||
try {
|
||
// Host rows stay disabled until the server settings answered.
|
||
assert.equal(byId('open-mode').disabled, true);
|
||
assert.equal(byId('timeout').disabled, true);
|
||
await loadHost();
|
||
assert.equal(byId('open-mode').disabled, false);
|
||
assert.equal(byId('timeout').value, '5');
|
||
assert.deepEqual(byId('image-quality').children.map(option => [option.value, option.textContent]),
|
||
[['medium', 'Optimized'], ['high', 'Original'], ['low', 'Fast']]);
|
||
assert.deepEqual(byId('open-mode').children.map(option => [option.value, option.textContent]),
|
||
[['newtab', 'New tab'], ['popup', 'Popup window']]);
|
||
const edits = [['default-view', 'masonry'], ['density', 'large'], ['image-quality', 'low'], ['open-mode', 'popup']];
|
||
for (const [id, value] of edits) { byId(id).value = value; byId(id).dispatch('change'); }
|
||
for (const id of ['metadata-auto', 'metadata-cache', 'video-muted', 'video-loop', 'paths', 'card-details']) {
|
||
byId(id).checked = false; byId(id).dispatch('change');
|
||
}
|
||
for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); }
|
||
assert.deepEqual(byId('viewer-mode').children.map(option => [option.value, option.textContent]), [['centered', 'Centered'], ['fullscreen', 'Full screen']]);
|
||
byId('viewer-mode').value = 'fullscreen'; byId('viewer-mode').dispatch('change');
|
||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low',
|
||
autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false,
|
||
videoAutoplay: true, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' });
|
||
assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' });
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () => {
|
||
const { resultTimeout } = await appearanceModule;
|
||
assert.deepEqual(['0', '30', ' 300 ', '301', '-1', '2.5', '', 'abc', '1e2'].map(resultTimeout), [0, 30, 300, null, null, null, null, null, null]);
|
||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||
try {
|
||
await loadHost();
|
||
const timeout = byId('timeout');
|
||
timeout.value = '45'; timeout.dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), { result_timeout: 45 });
|
||
timeout.value = '301'; timeout.dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), {});
|
||
assert.deepEqual([...drafts.failed('host')], ['result_timeout']);
|
||
timeout.value = '0'; timeout.dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), { result_timeout: 0 });
|
||
assert.deepEqual([...drafts.failed('host')], []);
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('reset restores defaults without touching other keys', async () => {
|
||
const { DISPLAY_KEYS } = await appearanceModule;
|
||
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' };
|
||
const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed });
|
||
try {
|
||
await loadHost();
|
||
drafts.set('host', 'result_timeout', 9);
|
||
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
|
||
assert.equal(reset.textContent, 'Reset');
|
||
reset.click();
|
||
// #451: the default layout is Masonry.
|
||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' });
|
||
// The host fields and every key outside the display defaults are left as they are.
|
||
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
|
||
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('host rows that could not be read stay disabled and say so; a later read clears it', async () => {
|
||
const { HOST_UNAVAILABLE } = await appearanceModule;
|
||
const { dom, drafts, scope, byId, surface, fixHost } = await mounted({ hostFails: true });
|
||
const notes = () => surface.children.slice(2).flatMap(row => row.children[0].children.slice(2).map(note => [row.children[0].children[0].textContent, note.className, note.textContent]));
|
||
try {
|
||
assert.deepEqual(notes(), []);
|
||
assert.equal(await drafts.reloadHost(), false);
|
||
assert.deepEqual(notes(), [['Open from ComfyUI', 'small', HOST_UNAVAILABLE], ['Node search auto-hide (seconds)', 'small', HOST_UNAVAILABLE]]);
|
||
assert.equal(byId('timeout').disabled, true);
|
||
// Repaints keep one line per row.
|
||
drafts.set('browser', 'videoMute', false);
|
||
assert.equal(notes().length, 2);
|
||
fixHost();
|
||
assert.equal(await drafts.reloadHost(), true);
|
||
assert.deepEqual(notes(), []);
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|