System > Appearance gains a Thumbnails surface: format (WebP, JPEG XL, JPEG), quality and size limit, saved to the server with the settings revision. JPEG XL is offered only when the server has an encoder, else the option is disabled with the server's reason (D130); a note says when this browser cannot show JPEG XL. Saving a new format converts nothing: the row then says previews keep their old format. Thumbnail maintenance gains the Convert outdated scope and names the server's settings instead of its own quality and size fields (D131). Overview's thumbnail row reads N missing, M outdated, K failed and offers Convert outdated while previews are outdated. The workspace decodes a tiny JPEG XL once: where it cannot, cards, the Library and the inspector cover load the original for a .jxl preview, and any preview that fails to load falls back to the original. The viewer and downloads keep using the original (regression test). README explains enabling JPEG XL; TESTING.md records D130 and D131.
184 lines
10 KiB
JavaScript
184 lines
10 KiB
JavaScript
// System › Appearance › Thumbnails (#317): the server's thumbnail format, quality and size limit as host rows of the
|
||
// one Save row, JPEG XL offered only with an encoder (D130), a note where this browser cannot show JPEG XL, and the
|
||
// explicit Convert outdated operation (never automatic).
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const thumbnailsModule = import('../../web/js/workspace/system-thumbnails.mjs');
|
||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||
|
||
/** 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 HOST = { thumbnail_format: 'webp', thumbnail_quality: 80, thumbnail_edge: 512,
|
||
thumbnail_encoders: { webp: { available: true }, jpeg: { available: true }, jxl: { available: true, encoder: 'cjxl', reason: null } } };
|
||
const NO_JXL = { webp: { available: true }, jpeg: { available: true },
|
||
jxl: { available: false, encoder: null, reason: 'No JPEG XL encoder found. Install pillow-jxl-plugin.' } };
|
||
|
||
async function mounted({ host = HOST, browserJxl = true, writeHost = async () => ({ success: true, persisted: true, revision: 'sha256:two' }) } = {}) {
|
||
const { mountThumbnailSettings } = await thumbnailsModule;
|
||
const { createSystemDrafts } = await stateModule;
|
||
const { createScope } = await coreModule;
|
||
const dom = fakeDOM();
|
||
let hostValues = null;
|
||
const drafts = createSystemDrafts({
|
||
readBrowser: () => ({}), readRuntime: () => ({}),
|
||
readHost: async () => ({ values: hostValues, revision: 'sha256:one' }),
|
||
writeBrowser: async () => ({ persisted: true }), writeHost, writeRuntime: async () => ({}),
|
||
});
|
||
const scope = createScope();
|
||
const reviews = [];
|
||
let answer;
|
||
const support = () => new Promise(resolve => { answer = resolve; });
|
||
const surface = mountThumbnailSettings(null, { scope, drafts, api: {}, activity: {}, support,
|
||
openReview: options => reviews.push(options) });
|
||
const byId = id => dom.find(surface, node => node.dataset?.setting === id);
|
||
const notes = () => dom.find(surface, node => node.dataset?.setting === 'thumbnail-format').parentNode.children[0].children.slice(2)
|
||
.map(node => node.textContent);
|
||
return { dom, drafts, scope, surface, byId, reviews, notes, browser: () => answer(browserJxl),
|
||
loadHost: async () => { hostValues = host; await drafts.load('host'); } };
|
||
}
|
||
|
||
test('a Thumbnails surface of three host rows and the Convert outdated operation', async () => {
|
||
const { dom, surface, scope } = await mounted();
|
||
try {
|
||
assert.equal(surface.className, 'surface');
|
||
assert.equal(surface.children[0].textContent, 'Thumbnails');
|
||
const rows = surface.children.slice(2);
|
||
assert.deepEqual(rows.map(row => row.children[0].children[0].textContent),
|
||
['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit', 'Convert outdated thumbnails']);
|
||
assert.deepEqual(rows.map(row => row.className), ['setting-row', 'setting-row', 'setting-row', 'operation']);
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|
||
|
||
test('the rows edit the server settings once they were read', async () => {
|
||
const { FORMAT_OPTIONS } = await thumbnailsModule;
|
||
assert.deepEqual(FORMAT_OPTIONS, [['webp', 'WebP'], ['jxl', 'JPEG XL'], ['jpeg', 'JPEG']]);
|
||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||
try {
|
||
for (const id of ['thumbnail-format', 'thumbnail-quality', 'thumbnail-edge']) assert.equal(byId(id).disabled, true, id);
|
||
await loadHost();
|
||
assert.equal(byId('thumbnail-format').value, 'webp');
|
||
assert.equal(byId('thumbnail-quality').value, '80');
|
||
assert.equal(byId('thumbnail-edge').value, '512');
|
||
assert.equal(byId('thumbnail-format').disabled, false);
|
||
byId('thumbnail-format').value = 'jpeg'; byId('thumbnail-format').dispatch('change');
|
||
byId('thumbnail-quality').value = '60'; byId('thumbnail-quality').dispatch('change');
|
||
byId('thumbnail-edge').value = '300'; byId('thumbnail-edge').dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), { thumbnail_format: 'jpeg', thumbnail_quality: 60, thumbnail_edge: 300 });
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('quality takes 1–100 and the size limit 128–2048 whole numbers only', async () => {
|
||
const { wholeNumber, QUALITY_LIMIT, EDGE_LIMIT } = await thumbnailsModule;
|
||
assert.deepEqual(['1', ' 100 ', '0', '101', '80.5', '', 'x'].map(text => wholeNumber(text, QUALITY_LIMIT)), [1, 100, null, null, null, null, null]);
|
||
assert.deepEqual(['128', '2048', '127', '2049', '1e3'].map(text => wholeNumber(text, EDGE_LIMIT)), [128, 2048, null, null, null]);
|
||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||
try {
|
||
await loadHost();
|
||
byId('thumbnail-quality').value = '0'; byId('thumbnail-quality').dispatch('change');
|
||
byId('thumbnail-edge').value = '4096'; byId('thumbnail-edge').dispatch('change');
|
||
assert.deepEqual(drafts.changes('host'), {});
|
||
assert.deepEqual([...drafts.failed('host')].sort(), ['thumbnail_edge', 'thumbnail_quality']);
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('JPEG XL is disabled with the server reason when no encoder is installed (D130)', async () => {
|
||
const { dom, scope, byId, loadHost, notes } = await mounted({ host: { ...HOST, thumbnail_encoders: NO_JXL } });
|
||
try {
|
||
await loadHost();
|
||
const jxl = byId('thumbnail-format').children.find(option => option.value === 'jxl');
|
||
assert.equal(jxl.disabled, true);
|
||
assert.deepEqual(notes(), [NO_JXL.jxl.reason]);
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
const ready = await mounted();
|
||
try {
|
||
await ready.loadHost();
|
||
assert.equal(ready.byId('thumbnail-format').children.find(option => option.value === 'jxl').disabled, false);
|
||
assert.deepEqual(ready.notes(), []);
|
||
} finally { ready.scope.dispose(); ready.dom.restore(); }
|
||
});
|
||
|
||
test('a browser that cannot show JPEG XL says the originals are shown instead', async () => {
|
||
const { BROWSER_NO_JXL } = await thumbnailsModule;
|
||
const { dom, scope, loadHost, notes, browser } = await mounted({ browserJxl: false });
|
||
try {
|
||
await loadHost();
|
||
browser();
|
||
await new Promise(resolve => setImmediate(resolve));
|
||
assert.deepEqual(notes(), [BROWSER_NO_JXL]);
|
||
} finally { scope.dispose(); dom.restore(); }
|
||
});
|
||
|
||
test('Convert outdated opens Thumbnail maintenance on its convert scope; a format save says previews keep theirs', async () => {
|
||
const { FORMAT_SAVED, CONVERT_DESCRIPTION } = await thumbnailsModule;
|
||
const { dom, drafts, scope, surface, byId, reviews, loadHost } = await mounted();
|
||
try {
|
||
const operation = surface.children.at(-1);
|
||
assert.equal(operation.children[0].children[1].textContent, CONVERT_DESCRIPTION);
|
||
dom.find(operation, node => node.tagName === 'BUTTON').click();
|
||
assert.equal(reviews.length, 1);
|
||
assert.equal(reviews[0].mode, 'convert');
|
||
await loadHost();
|
||
byId('thumbnail-quality').value = '70'; byId('thumbnail-quality').dispatch('change');
|
||
await drafts.save();
|
||
assert.equal(operation.children[0].children[1].textContent, CONVERT_DESCRIPTION, 'quality alone converts nothing');
|
||
byId('thumbnail-format').value = 'jpeg'; byId('thumbnail-format').dispatch('change');
|
||
await drafts.save();
|
||
assert.equal(operation.children[0].children[1].textContent, FORMAT_SAVED);
|
||
assert.equal(reviews.length, 1, 'nothing starts by itself');
|
||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||
});
|