Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2

This commit is contained in:
Vito Sansevero
2026-10-28 20:15:25 -07:00
89 changed files with 7062 additions and 329 deletions
+341 -5
View File
@@ -209,16 +209,18 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
test('renders current image tags before linked tags without duplicates', async () => {
test('renders current image tags before linked tags, each without repeats within its source', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
// D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
[{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
{name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
[{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
@@ -236,6 +238,61 @@ test('none and unavailable render no image chips', async () => {
}
});
// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
// prompt blocks, so per-image tags never read as part of the negative prompt.
function renderVariant(record, variant, options = {}) {
const f = mountedDom(), scope = createScope();
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
view.setState({loading:false, error:false, record});
return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
}
const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
function sectionSummary(f, section) {
const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
}
test('chips name their source: the image or the linked prompt', async () => {
const {inspectorTagChips, tagSectionLabel} = await load();
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
['image', 'image', 'linked', 'linked', 'linked']);
assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
});
test('image tags and linked tags render in their own labelled sections', async () => {
for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
try {
const sections = tagSections(f).map(section => sectionSummary(f, section));
assert.deepEqual(sections, [
{className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
{className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
], variant);
// No bare chip row follows the prompt blocks any more.
const inner = f.container.children.find(n => n.tagName === 'div');
assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
} finally { done(); }
}
});
test('a recipe names its tags and the context names the regions', async () => {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
try {
assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
{className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
{className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
]);
} finally { done(); }
});
test('an empty tag source draws no section', async () => {
let view = renderVariant(tagged(null), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
});
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
@@ -384,6 +441,285 @@ test('the D117 wrap rule targets only the settings summary button', async () =>
assert.doesNotMatch(css, /#viewer-details > div > button/);
});
// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog.
const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null);
const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; };
const COLLECTION_SCOPE = 'a'.repeat(64);
const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'};
function collectionsStore({member = [3], membership, add, remove} = {}) {
const calls = [];
const store = {
calls,
collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}],
membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; },
add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items);
return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; },
remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items);
return {collection:{id, name:'x'}, removed:1}; },
settled: async () => ({}),
};
return store;
}
function footerButtons(f) {
return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim());
}
test('inspector shows Collections… only for a rooted file with a store', async () => {
for (const variant of ['panel', 'full']) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()});
view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}});
const labels = footerButtons(f);
assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`);
// After Prompt history, as the last footer action.
assert.equal(labels.at(-1), 'Collections…');
assert.equal(labels.at(-2), 'Prompt history');
const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0];
assert.equal(button.children[0].tagName, 'svg', 'with the folder icon');
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
// No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button.
const local = await localRecord(localPng('Fox Render.png'));
for (const [shown, store] of [[record(), null], [local, collectionsStore()],
[{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) {
const f = mountedDom(), scope = createScope();
try {
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store});
view.setState({loading:false, error:false, record:shown});
assert.ok(!footerButtons(f).includes('Collections…'));
view.dispose();
} finally { scope.dispose(); f.restore(); }
}
});
/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */
function openCollections(store, extra = {}) {
const {openCollectionsDialog} = dialogModule();
const f = mountedDom(), scope = createScope(), toasts = [], changes = [];
let dialog = null, closed = 0;
openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text),
onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); },
// As the shell does: closing runs the dialog's onClose once.
closeDialog:() => { closed++; dialog?.onClose?.(); }, ...extra});
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
const find = predicate => f.walk(f.container, predicate);
const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox');
const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0];
const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean);
return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text,
done() { scope.dispose(); f.restore(); }};
}
test('the Collections dialog loads the membership and lists every collection', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
assert.equal(view.dialog.title, 'Collections');
assert.ok(view.text().includes('Loading collections…'));
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
await view.settle();
assert.deepEqual(store.calls, [['membership', FILE]]);
const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion');
assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']);
assert.deepEqual(view.boxes().map(box => box.checked), [true, false]);
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(field.maxLength, 100);
assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection');
assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('without collections the dialog says to name one', async () => {
const store = collectionsStore({member:[]});
store.collections = [];
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('No collections yet. Name one below.'));
assert.equal(view.boxes().length, 0);
} finally { view.done(); }
});
test('a failed membership read offers Retry', async () => {
let failing = true;
const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }});
const view = openCollections(store);
try {
await view.settle();
assert.ok(view.text().includes('Collections could not be loaded.'));
assert.equal(view.button('Retry').hidden, false);
assert.equal(view.boxes().length, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
failing = false;
view.button('Retry').fire('click');
await view.settle();
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.button('Retry').hidden, true);
} finally { view.done(); }
});
test('Save sends the new name, each added and each removed collection, in that order', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids ';
view.button('Save').fire('click');
await view.settle(); await view.settle();
assert.deepEqual(store.calls.slice(1), [
['add', {name:'Grids'}, [FILE]],
['add', {collectionId:5}, [FILE]],
['remove', 3, [FILE]],
]);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, ['Collections updated.']);
assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]);
} finally { view.done(); }
});
test('Save without a change sends nothing and closes', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' ';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
assert.deepEqual(view.toasts, []);
assert.deepEqual(view.changes, []);
} finally { view.done(); }
});
test('a failed Save keeps the dialog, says so and shows the current membership', async () => {
let reads = 0;
const store = collectionsStore({membership:() => (reads++ ? [5] : [3]),
remove:() => { throw Object.assign(new Error('x'), {status:500}); }});
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
view.button('Save').fire('click');
// While it saves, × and Escape keep the dialog and Save is busy.
assert.equal(view.dialog.canClose(), false);
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
for (let index = 0; index < 4; index++) await view.settle();
assert.equal(view.closed, 0);
assert.deepEqual(view.toasts, []);
const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0];
assert.equal(alert.textContent, 'The collections could not be updated. Try again.');
// The add went through; the list shows what the server holds now. Review M2: the page hears about it only once
// the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read.
assert.deepEqual(view.changes, []);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
assert.equal(view.dialog.canClose(), true);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
view.button('Cancel').fire('click');
assert.deepEqual(view.changes, [{added:[5], removed:[]}]);
} finally { view.done(); }
});
test('a name the store cannot take is refused on the field, keeping the edits (review L5)', async () => {
const store = collectionsStore();
const view = openCollections(store);
try {
await view.settle();
const [lora, portraits] = view.boxes();
lora.checked = false; portraits.checked = true;
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
const validity = [];
field.setCustomValidity = text => validity.push(text);
field.reportValidity = () => validity.push('reported');
field.value = 'bad\u0001name';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read');
assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']);
assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay');
assert.equal(view.closed, 0);
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
// Editing the field clears its message.
field.fire('input');
assert.equal(validity.at(-1), '');
} finally { view.done(); }
});
test('after a failed Save, Save waits for the membership re-read (review M1)', async () => {
let release;
const store = collectionsStore({remove:() => { throw new Error('x'); }});
store.settled = () => new Promise(resolve => { release = resolve; });
const view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(typeof release, 'function', 'the store is settling');
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
assert.ok(view.text().includes('Loading collections…'));
const sent = store.calls.length;
view.button('Save').fire('click');
await view.settle();
assert.equal(store.calls.length, sent, 'no stale plan is sent');
release({});
for (let index = 0; index < 3; index++) await view.settle();
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
} finally { view.done(); }
});
test('a typed name of an existing collection is that collection checkbox (review L4)', async () => {
// "portraits" names Portraits (not a member): one add by id, no name request.
let store = collectionsStore();
let view = openCollections(store);
try {
await view.settle();
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS ';
view.button('Save').fire('click');
for (let index = 0; index < 3; index++) await view.settle();
assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]);
} finally { view.done(); }
// Unchecking LoRa testing while typing its name keeps it: nothing to send.
store = collectionsStore();
view = openCollections(store);
try {
await view.settle();
view.boxes()[0].checked = false;
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing';
view.button('Save').fire('click');
await view.settle();
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
assert.equal(view.closed, 1);
} finally { view.done(); }
});
test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => {
const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }};
const fallback = {focused:false, focus() { this.focused = true; }};
const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback});
try {
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
assert.equal(view.dialog.focusTarget, field);
await view.settle();
assert.equal(view.boxes()[0].focused, true);
view.button('Cancel').fire('click');
assert.equal(fallback.focused, true);
} finally { view.done(); }
const empty = collectionsStore({member:[]});
empty.collections = [];
const none = openCollections(empty);
try {
await none.settle();
assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true);
} finally { none.done(); }
});
test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => {
const base = await localRecord(localPng('a.png'));
const chipTexts = record => {
+66 -3
View File
@@ -253,15 +253,16 @@ test('an unreadable or malformed models read falls back to the documented defaul
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
const { createImageTaggingController } = await load();
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
let answer = () => fail(500, 'observe-failed');
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'observe-error');
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
assert.equal(c.state.canSave, false);
assert.equal(c.state.canGenerate, false);
assert.equal(c.state.canRetry, true);
assert.equal(c.state.blocked, true, 'a failed capture blocks Generate and Save with its reason');
answer = () => response(200, observed({ tag_revision: -1 }));
await c.retry();
assert.equal(c.state.phase, 'observe-error');
@@ -274,9 +275,71 @@ test('observe failures and malformed answers end in observe-error and retry obse
await c.retry();
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.blocked, false);
assert.equal(h.of('POST', OBSERVE).length, 4);
});
test('#430: a changed source blocks the dialog until it is retargeted, keeping the draft', async () => {
const { createImageTaggingController } = await load();
const SOURCE = 'The source changed. Refresh the image collection before tagging this file.';
const fresh = { root_index: 1, root_scope: 'e'.repeat(64), path: TARGET.path };
const h = harness(defaultRoutes({
[`POST ${OBSERVE}`]: call => (call.body.target.root_scope === SCOPE ? fail(409, 'source-changed', SOURCE) : response(200, observed())),
}));
const c = createImageTaggingController({ api: h.api, target: TARGET });
await c.open();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, SOURCE);
assert.deepEqual([c.state.canSave, c.state.canGenerate, c.state.canRetry, c.state.blocked], [false, false, false, true]);
assert.equal(await c.retry(), false, 'the same stale target is never observed again');
assert.equal(h.of('POST', OBSERVE).length, 1);
assert.throws(() => c.retarget({ ...fresh, root_scope: 'bad' }), TypeError);
await c.retarget(fresh);
await settle();
assert.equal(c.state.phase, 'ready');
assert.deepEqual(h.of('POST', OBSERVE).at(-1).body.target, fresh);
assert.deepEqual({ ...c.target }, fresh);
// Generate and Save now carry the new target.
c.setManual('kept draft');
await c.generate();
assert.deepEqual(h.of('POST', SUGGEST)[0].body.target, fresh);
});
test('#430: a source change answered to Generate or Save also blocks; retarget keeps the typed draft', async () => {
for (const route of [SUGGEST, TAGS]) {
const method = route === TAGS ? 'PUT' : 'POST';
let stale = true;
const { c, h } = await opened({
[`${method} ${route}`]: call => (stale ? fail(409, 'source-changed', 'server says source') :
route === TAGS ? response(200, receipt(call.body.tags)) : response(200, suggested())),
});
c.setManual('kept draft');
if (route === TAGS) await c.save(); else await c.generate();
assert.equal(c.state.phase, 'source-changed');
assert.equal(c.state.statusText, 'server says source');
stale = false;
await c.retarget({ root_index: 2, root_scope: 'f'.repeat(64), path: TARGET.path });
await settle();
assert.equal(c.state.phase, 'ready');
assert.equal(c.state.manual, 'kept draft');
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'the old capture is released');
}
});
test('#430: retarget waits for nothing: it refuses while a save is in flight and after close', async () => {
const gate = deferred();
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => gate.promise });
const saving = c.save();
await flush();
assert.equal(c.state.phase, 'saving');
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
gate.resolve(response(200, receipt(['Minimal'])));
await saving;
c.cancel();
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
assert.equal(h.of('POST', OBSERVE).length, 1);
});
test('model change clears suggestions count and status but keeps manual', async () => {
const { c } = await opened();
c.setManual('Minimal, dune');
@@ -547,7 +610,7 @@ test('write errors keep the draft and allow another save', async () => {
});
test('storage-changed and file-changed disable save until reopen', async () => {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'source-changed'], ['capture-expired', 'capture-expired']]) {
const message = `server says ${code}`;
let answer = () => fail(409, code, message);
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
@@ -59,3 +59,9 @@ test('the toolbar list merges prompt and image categories once each', async () =
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
});
test('the outcome names what was set: images by default, prompts for the Library bar (#437)', async () => {
const {setOutcome} = await source;
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 1 prompt.');
assert.equal(setOutcome({updated:2, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 2 prompts.');
});
+285
View File
@@ -0,0 +1,285 @@
// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove
// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4.
import test from 'node:test';
import assert from 'node:assert/strict';
// Guarded: before the module exists every test fails on an assertion, not on the import.
const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null);
const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; };
const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs');
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
const strings = await import('../../web/js/workspace/collections.mjs');
const SCOPE = 'b'.repeat(64);
const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra });
const target = path => ({ root_index: 0, root_scope: SCOPE, path });
const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`));
const keyOf = item => `${item.root_index}:${item.path}`;
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status });
/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */
function fakeDOM() {
const previous = globalThis.document;
let active = null;
class Element extends EventTarget {
constructor(tag) {
super();
this.tagName = tag.toUpperCase();
this.children = [];
this.parentNode = null;
this.attributes = {};
this.dataset = {};
this.className = '';
this.id = '';
this._text = '';
}
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
get nextElementSibling() {
const siblings = this.parentNode?.children ?? [];
return siblings[siblings.indexOf(this) + 1] ?? null;
}
append(...nodes) {
for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); }
}
after(node) {
node.remove();
node.parentNode = this.parentNode;
this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node);
}
remove() {
if (!this.parentNode) return;
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
this.parentNode = null;
}
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]; }
querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; }
querySelectorAll() { return []; }
focus() { active = this; }
click() { this.dispatchEvent(new Event('click')); }
}
const toast = new Element('div');
const body = new Element('body');
globalThis.document = {
body,
get activeElement() { return active ?? body; },
createElement: tag => new Element(tag),
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
getElementById: id => (id === 'workspace-toast' ? toast : null),
};
return { Element, body, toast, restore() { globalThis.document = previous; } };
}
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
function mountBar(dom, items, checkedKeys, options = {}) {
const scope = createScope();
const grid = new dom.Element('div');
const focusFallback = new dom.Element('h1');
const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {},
activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}),
getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }),
checked: Object.freeze(new Set(checkedKeys)), ...options.props });
return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [],
button: text => bar.element.children[0].children.find(child => child.textContent === text) };
}
function store(overrides = {}) {
const calls = [];
return {
calls,
collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }],
add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); },
remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); },
};
}
test('selection bar lists Add to collection after Set category', () => {
const dom = fakeDOM();
try {
const items = images(2);
const collections = store();
const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } });
assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Export metadata', 'Clear']);
plain.scope.dispose();
// §7.1: Remove from collection beside Add, only while a collection filter is active.
const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } });
assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
'Remove from collection', 'Export metadata', 'Clear']);
filtered.scope.dispose();
// Without the client store (a page that has none) neither shows.
const none = mountBar(dom, items, [keyOf(items[0])]);
assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']);
none.scope.dispose();
} finally { dom.restore(); }
});
test('more than 500 checked toasts and opens nothing', async () => {
const dom = fakeDOM();
try {
const items = images(501);
const collections = store();
const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } });
view.button('Add to collection').click();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.equal(dom.body.children.length, 0, 'no dialog opened');
dom.toast.textContent = '';
view.button('Remove from collection').click();
await flush();
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
assert.deepEqual(collections.calls, []);
view.scope.dispose();
} finally { dom.restore(); }
});
test('Remove from collection removes the checked files and reloads the listing', async () => {
const dom = fakeDOM();
try {
const items = images(3);
let changed = 0;
const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) });
const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7',
onCollectionsChanged: () => { changed++; } } });
const remove = view.button('Remove from collection');
remove.click();
// A second press while the first is in flight is ignored.
assert.equal(remove.getAttribute('aria-disabled'), 'true');
remove.click();
await flush();
assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]);
assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.');
assert.equal(changed, 1);
assert.equal(remove.getAttribute('aria-disabled'), null);
view.scope.dispose();
} finally { dom.restore(); }
});
test('the collection-target rule keeps the all-matching selection to its loaded images', () => {
const { collectionTargets } = module();
assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST });
// All N matching: only when every match is loaded and checked, and at most 500.
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] });
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST });
assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST });
// A file without a listing identity cannot be named; none left is "could not be found".
assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST });
});
const pageScope = () => {
const controller = new AbortController();
return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() };
};
test('Add to collection opens the concept dialog with the existing names as suggestions', () => {
const { openAddToCollection } = module();
let dialog = null;
const collections = store();
openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail,
openInput: args => { dialog = args; } });
assert.equal(dialog.title, 'Add selected images to collection');
assert.equal(dialog.label, 'Collection name');
assert.equal(dialog.action, 'Add');
assert.equal(dialog.maxLength, 100);
assert.equal(dialog.trigger, 'trigger');
assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']);
assert.equal(dialog.value ?? '', '');
});
/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */
async function add(name, answer, { count = 1, extra = {} } = {}) {
const { openAddToCollection } = module();
const toasts = [];
let dialog = null, sourceChanged = 0;
const collections = store({ add: answer });
openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text),
openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra });
await dialog.onApply(name);
return { toasts, calls: collections.calls, sourceChanged };
}
const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created,
added, already, notFound });
test('Add sends the normalized name and the checked files', async () => {
const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 });
assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]);
assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']);
});
test('Add toasts every outcome of §7.4', async () => {
const cases = [
[answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'],
[answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'],
[answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'],
[answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'],
[answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'],
[answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'],
[answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'],
[() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'],
[() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'],
[() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'],
];
for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text);
});
test('a stale source on Add says so and reloads the listing', async () => {
const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); });
assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']);
assert.equal(sourceChanged, 1);
});
test('Add with a name the store cannot take sends nothing and says it failed', async () => {
const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0));
assert.deepEqual(calls, []);
assert.deepEqual(toasts, ['The images could not be added. Try again.']);
});
test('Add after the page left shows no toast', async () => {
const { openAddToCollection } = module();
const scope = pageScope();
const toasts = [];
let dialog = null;
openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text),
openInput: args => { dialog = args; } });
scope.abort();
await dialog.onApply('Grids');
assert.deepEqual(toasts, []);
});
test('Add refuses an over-cap or all-matching selection before any dialog', () => {
const { openAddToCollection } = module();
const toasts = [];
const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text),
openInput: () => assert.fail('no dialog opens'), ...extra });
open({ images: images(501) });
open({ images: images(2), allMatching: true, total: 40 });
assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]);
});
/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */
async function remove(reply, count = 1) {
const { removeFromCollection } = module();
const toasts = [];
let changed = 0, sourceChanged = 0;
const collections = store({ remove: reply });
await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count),
notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } });
return { toasts, changed, sourceChanged, calls: collections.calls };
}
test('Remove toasts every outcome of §7.4', async () => {
assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })),
{ toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] });
assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts,
['Removed 3 images from “Landscapes”.']);
// A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone).
const failed = await remove(() => { throw new TypeError('offline'); });
assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]);
const gone = await remove(() => { throw coded('collection-not-found', 404); });
assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]);
const stale = await remove(() => { throw coded('source-changed', 409); });
assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]);
});
@@ -0,0 +1,172 @@
// Run with: node --test tests/js/workspace-images-source-recovery.test.js
const test = require('node:test');
const assert = require('node:assert/strict');
const load = () => import('../../web/js/workspace/images-source-recovery.mjs');
const OLD = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'sub/a.png', filename: 'a.png' });
const FRESH = Object.freeze({ root_index: 1, root_scope: 'b'.repeat(64), path: 'sub/a.png', filename: 'a.png', url: '/u' });
function fakeApi(answer) {
const calls = [];
return { calls, post: async (url, body, options) => { calls.push({ url, body, options }); return answer(body); } };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('rescopeImage posts only the target and returns the fresh descriptor', async () => {
const { rescopeImage, RESCOPE_URL } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const signal = new AbortController().signal;
const fresh = await rescopeImage(api, OLD, { signal });
assert.deepEqual(fresh, FRESH);
assert.ok(Object.isFrozen(fresh));
assert.deepEqual(api.calls, [{ url: RESCOPE_URL, body: { target: { root_index: 0, root_scope: OLD.root_scope, path: 'sub/a.png' } }, options: { signal } }]);
});
test('rescopeImage answers null for a refusal, a malformed answer, another path or a bad target', async () => {
const { rescopeImage } = await load();
const refused = { post: async () => { throw Object.assign(new Error('gone'), { status: 409, code: 'source-changed' }); } };
assert.equal(await rescopeImage(refused, OLD), null);
const lost = { post: async () => { throw new TypeError('Failed to fetch'); } };
await assert.rejects(rescopeImage(lost, OLD), TypeError, 'a lost answer is not final');
for (const answer of [null, { success: false }, { success: true, image: { ...FRESH, root_scope: 'x' } },
{ success: true, image: { ...FRESH, path: 'other.png' } }, { success: true, image: { ...FRESH, root_index: -1 } }]) {
assert.equal(await rescopeImage(fakeApi(() => answer), OLD), null);
}
const api = fakeApi(() => ({ success: true, image: FRESH }));
for (const image of [null, { ...OLD, root_scope: 'short' }, { ...OLD, path: '' }, { ...OLD, root_index: 1.5 }]) {
assert.equal(await rescopeImage(api, image), null);
}
assert.equal(api.calls.length, 0, 'a malformed target is never sent');
});
test('one rescope per held pair: callers share it, a tried pair answers its first result, reset forgets', async () => {
const { createSourceRecovery } = await load();
let answer = { success: true, image: FRESH };
const api = fakeApi(() => answer);
const applied = [];
const recovery = createSourceRecovery({ api, generation: () => 7, apply: async (image, fresh, started) => { applied.push([image.path, fresh.root_index, started]); return fresh; } });
const [first, second] = await Promise.all([recovery.recover(OLD), recovery.recover({ ...OLD })]);
assert.deepEqual([first, second], [FRESH, FRESH]);
assert.equal(api.calls.length, 1);
assert.deepEqual(applied, [['sub/a.png', 1, 7]]);
answer = { success: false };
assert.deepEqual(await recovery.recover(OLD), FRESH, 'the pair is not asked again');
recovery.reset();
assert.equal(await recovery.recover(OLD), null, 'after a reset the pair is asked again');
assert.equal(api.calls.length, 2);
assert.equal(await recovery.recover({ ...OLD, root_scope: 'bad' }), null);
assert.equal(api.calls.length, 2);
});
test('a refused pair never loops, an aborted page applies nothing and a failing apply is null', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(() => ({ success: true, image: FRESH }));
const page = new AbortController();
let applied = 0;
const recovery = createSourceRecovery({ api, signal: page.signal, apply: async () => { applied += 1; return FRESH; } });
page.abort();
assert.equal(await recovery.recover(OLD), null);
assert.equal(applied, 0);
const failing = createSourceRecovery({ api, apply: async () => { throw new Error('page gone'); } });
assert.equal(await failing.recover(OLD), null);
assert.throws(() => createSourceRecovery({ api }), TypeError);
// An api without post (an embedded gallery's page adapter) finds nothing.
assert.equal(await createSourceRecovery({ api: {}, apply: async () => FRESH }).recover(OLD), null);
});
test('the memo forgets its oldest pairs past its limit', async () => {
const { createSourceRecovery } = await load();
const api = fakeApi(body => ({ success: true, image: { ...FRESH, path: body.target.path } }));
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
for (let index = 0; index < 65; index++) await recovery.recover({ ...OLD, path: `f${index}.png` });
assert.equal(api.calls.length, 65);
await recovery.recover({ ...OLD, path: 'f64.png' });
assert.equal(api.calls.length, 65, 'a recent pair is still remembered');
await recovery.recover({ ...OLD, path: 'f0.png' });
assert.equal(api.calls.length, 66, 'the oldest pair was forgotten');
});
function fakePage({ selected = true, root = 'all', current = true } = {}) {
const log = [];
const page = {
filters: { root, subfolder: root === 'all' ? '' : 'sub' },
current: started => { log.push(['current', started]); return current; },
selected: image => selected && image.path === OLD.path,
inspect: image => log.push(['inspect', image.root_index]),
dropSelection: () => log.push(['drop']),
stream: {
refresh: async () => { log.push(['refresh']); throw new Error('read failed'); },
reset: (filters, options) => log.push(['reset', { ...filters }, options]),
},
updateScope: () => log.push(['scope']),
closeViewer: () => log.push(['close-viewer']),
clearChecks: () => log.push(['clear-checks']),
listsNow: () => true,
rediscover: () => log.push(['rediscover']),
};
return { page, log };
}
test('a renumbered source keeps nothing by its old key: only the file stays selected', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '0' });
assert.equal(await recoveredSource(page)(OLD, FRESH, 3), FRESH);
assert.deepEqual(log, [['current', 3], ['close-viewer'], ['clear-checks'], ['inspect', 1], ['scope'],
['reset', { root: '1', subfolder: 'sub' }, { load: true }], ['rediscover']]);
});
test('another Source filter falls back to All sources; an unselected file drops the old selection', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage({ root: '2', selected: false });
await recoveredSource(page)(OLD, FRESH, 0);
assert.deepEqual(log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
assert.ok(log.some(entry => entry[0] === 'drop'));
assert.ok(!log.some(entry => entry[0] === 'inspect'));
const all = fakePage({ root: 'all' });
await recoveredSource(all.page)(OLD, FRESH, 0);
assert.deepEqual(all.log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
});
test('an unchanged numbering re-reads in place; a page that moved on is left alone', async () => {
const { recoveredSource } = await load();
const same = { ...FRESH, root_index: 0 };
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, same, 1), same);
assert.deepEqual(log, [['current', 1], ['inspect', 0], ['refresh']], 'a failed re-read is not an error');
const unselected = fakePage({ selected: false });
await recoveredSource(unselected.page)(OLD, same, 1);
assert.deepEqual(unselected.log, [['current', 1], ['refresh']]);
const moved = fakePage({ current: false });
assert.equal(await recoveredSource(moved.page)(OLD, FRESH, 1), FRESH, 'the dialog may still retarget');
assert.deepEqual(moved.log, [['current', 1]]);
await flush();
});
test('a scope that still names its root is not stale: nothing is applied', async () => {
const { recoveredSource } = await load();
const { page, log } = fakePage();
assert.equal(await recoveredSource(page)(OLD, { ...OLD, url: '/u' }, 1), null);
assert.deepEqual(log, []);
});
test('a lost rescope answer is not remembered: the pair is asked again', async () => {
const { createSourceRecovery } = await load();
let down = true;
const api = { calls: 0, post: async () => { api.calls += 1; if (down) throw Object.assign(new Error('bad gateway'), { status: 502 }); return { success: true, image: FRESH }; } };
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
assert.equal(await recovery.recover(OLD), null);
down = false;
assert.deepEqual(await recovery.recover(OLD), FRESH);
assert.equal(api.calls, 2);
});
test('the same index under another scope (a changed key) re-reads in place and keeps the selection', async () => {
const { recoveredSource } = await load();
const rekeyed = { ...OLD, root_scope: 'c'.repeat(64) };
const { page, log } = fakePage();
page.stream.refresh = async () => { log.push(['refresh']); };
assert.equal(await recoveredSource(page)(OLD, rekeyed, 2), rekeyed);
assert.deepEqual(log, [['current', 2], ['inspect', 0], ['refresh']]);
assert.ok(!log.some(entry => ['reset', 'drop', 'clear-checks', 'close-viewer'].includes(entry[0])));
});
@@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// #437 D148: the Prompt library's selection bar sets one category on the selected prompts, fenced by the storage epoch.
const source = import('../../web/js/workspace/library-selection.mjs');
test('the dialog is the shared simpleInput with the prompt title, and the route and cap are the legacy ones', async () => {
const {PROMPT_CATEGORY_DIALOG, CATEGORY_URL, MAX_CATEGORY_PROMPTS, TOO_MANY_PROMPTS} = await source;
assert.deepEqual({...PROMPT_CATEGORY_DIALOG}, {title:'Categorize selected prompts', label:'Category', action:'Apply'});
assert.equal(CATEGORY_URL, '/prompt_manager/bulk/category');
assert.equal(MAX_CATEGORY_PROMPTS, 1000);
assert.equal(TOO_MANY_PROMPTS, 'Select at most 1,000 prompts.');
});
test('a confirmed write toasts the shared outcome with "prompt", invalidates and reloads', async () => {
const {writeOutcome} = await source;
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:2, unchanged:1, not_found:0}, null),
{toast:'Category “Studio” set on 3 prompts.', committed:true, reload:true});
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:1, unchanged:0, not_found:2}, null),
{toast:'Category “Studio” set on 1 prompt. 2 could not be found.', committed:true, reload:true});
});
test('none found, a restored storage, an unconfirmed answer and a refused write each say what happened', async () => {
const {writeOutcome, NONE_FOUND, STORAGE_CHANGED, UNCONFIRMED, CATEGORY_FAILED} = await source;
assert.deepEqual(writeOutcome({success:true, category:'X', updated:0, unchanged:0, not_found:3}, null),
{toast:NONE_FOUND, committed:false, reload:true});
assert.deepEqual(writeOutcome(null, {status:409, code:'storage-changed'}), {toast:STORAGE_CHANGED, committed:false, reload:true});
for (const failure of [new TypeError('Failed to fetch'), {status:502}, {status:503}, {status:504}]) {
assert.deepEqual(writeOutcome(null, failure), {toast:UNCONFIRMED, committed:true, reload:true});
}
assert.deepEqual(writeOutcome(null, null), {toast:UNCONFIRMED, committed:true, reload:true});
for (const status of [400, 500]) {
assert.deepEqual(writeOutcome(null, {status}), {toast:CATEGORY_FAILED, committed:false, reload:false});
}
assert.equal(STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No category was changed.');
});
test('the field refuses control characters as validate_category does', async () => {
const {categoryProblem, CONTROL_CHARACTERS} = await source;
assert.equal(categoryProblem('Studio portraits'), null);
assert.equal(categoryProblem('tab\there'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS);
assert.equal(categoryProblem('unicode ✓ é'), null);
});
test('Add tags uses the Images dialog words for prompts, the tag routes and the editor limits (#442)', async () => {
const {PROMPT_TAGS_DIALOG, TAGS_URL, TAG_OPTIONS_URL, MAX_PROMPT_TAGS} = await source;
assert.deepEqual({...PROMPT_TAGS_DIALOG}, {title:'Tag selected prompts', label:'Tags, separated by commas', action:'Apply'});
assert.equal(TAGS_URL, '/prompt_manager/bulk/tags');
assert.equal(TAG_OPTIONS_URL, '/prompt_manager/tags');
assert.equal(MAX_PROMPT_TAGS, 20);
});
test('the Add tags toast says what was added, already present, skipped and missing', async () => {
const {tagsOutcome, writeOutcome, NONE_FOUND} = await source;
assert.equal(tagsOutcome({tags:['a', 'b'], updated:2, unchanged:0, over_limit:0, not_found:0}), 'Added “a, b” to 2 prompts.');
assert.equal(tagsOutcome({tags:['a'], updated:1, unchanged:2, over_limit:1, not_found:3}),
'Added “a” to 1 prompt. 2 prompts already had them. 1 prompt would pass 20 tags and was skipped. 3 could not be found.');
assert.equal(tagsOutcome({tags:['a'], updated:0, unchanged:2, over_limit:0, not_found:0}), '2 prompts already had “a”.');
// Every prompt skipped: found, so the skip sentence, never "none found"; nothing committed.
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:2, not_found:0}, null, 'tags'),
{toast:'2 prompts would pass 20 tags and were skipped.', committed:false, reload:true});
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:0, not_found:2}, null, 'tags'),
{toast:NONE_FOUND, committed:false, reload:true});
});
test('Add tags failures name tags, not the category', async () => {
const {writeOutcome, TAGS_STORAGE_CHANGED, TAGS_UNCONFIRMED, TAGS_FAILED} = await source;
assert.equal(writeOutcome(null, {status:409, code:'storage-changed'}, 'tags').toast, TAGS_STORAGE_CHANGED);
assert.equal(writeOutcome(null, {status:503}, 'tags').toast, TAGS_UNCONFIRMED);
assert.equal(writeOutcome(null, {status:400}, 'tags').toast, TAGS_FAILED);
assert.equal(TAGS_STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No tags were added.');
});
test('the Add tags field refuses what validate_tags refuses', async () => {
const {tagsProblem, TAG_PROBLEMS} = await source;
assert.equal(tagsProblem('red, blue'), null);
assert.equal(tagsProblem(' , ,'), TAG_PROBLEMS.none);
assert.equal(tagsProblem(Array.from({length:21}, (_, i) => `t${i}`).join(',')), TAG_PROBLEMS.many);
assert.equal(tagsProblem(Array.from({length:21}, () => 'same').join(',')), null);
assert.equal(tagsProblem('x'.repeat(51)), TAG_PROBLEMS.long);
assert.equal(tagsProblem('bad\ttag'), TAG_PROBLEMS.control);
});
test('Select all N loaded is offered while some loaded prompts are unchecked (#444 ruling)', async () => {
const {loadedOffer, offersLoaded} = await source;
assert.equal(loadedOffer(3), 'Select all 3 loaded');
assert.equal(loadedOffer(1203), 'Select all 1,203 loaded');
assert.equal(offersLoaded(new Set([1]), [1, 2, 3]), true);
assert.equal(offersLoaded(new Set([1, 2, 3]), [1, 2, 3]), false);
assert.equal(offersLoaded(new Set(), [1, 2]), false);
assert.equal(offersLoaded(new Set([9]), [9]), false);
// More loaded than any action accepts: never offered (Ctrl/Cmd+A still selects them).
assert.equal(offersLoaded(new Set([1]), Array.from({length:1001}, (_, i) => i + 1)), false);
assert.equal(offersLoaded(new Set([1]), Array.from({length:1000}, (_, i) => i + 1)), true);
});
+10
View File
@@ -104,3 +104,13 @@ test('archived variant reads use their own scope and cursor without changing the
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
});
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
const {createLibraryStream}=await source;const A='11111111-1111-4111-8111-111111111111',B='22222222-2222-4222-8222-222222222222';
const pending=[{...page([item(1)],'next'),library_epoch:A},{...page([item(2)],'more'),library_epoch:A},{...page([item(3)]),library_epoch:B}];
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.epoch,null);
await s.reset();assert.equal(s.state.epoch,A);await s.loadMore();assert.equal(s.state.epoch,A);assert.equal(s.state.items.length,2);
await s.loadMore();assert.ok(s.state.error);assert.equal(s.state.items.length,2);assert.equal(s.state.epoch,A);
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.epoch,A);restored.dispose();
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
});
+65
View File
@@ -192,6 +192,71 @@ test('views reload once after a restore this page saw finish; a failed restore c
assert.equal(listeners.size, 0);
});
// #415: a fake document whose open dialogs the test sets, and a MutationObserver stand-in it triggers.
function dialogPage() {
const page = { open: 0, observers: new Set() };
page.doc = { documentElement: {}, querySelector: selector => (selector === 'dialog[open]' && page.open > 0 ? {} : null) };
page.Observer = class {
constructor(callback) { this.callback = callback; }
observe(target, options) { this.target = target; this.options = options; page.observers.add(this); }
disconnect() { page.observers.delete(this); }
};
page.mutate = open => { page.open = open; for (const observer of [...page.observers]) observer.callback([]); };
return page;
}
test('the restore reload runs at once when no dialog is open', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'nothing waits');
reload.request();
assert.equal(reloads, 2);
});
test('the restore reload waits for every open dialog to close, then runs once (#415)', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.request();
assert.equal(reloads, 0, 'an open dialog holds the reload');
assert.equal(page.observers.size, 1, 'one observer for repeated requests');
const [observer] = page.observers;
assert.equal(observer.target, page.doc.documentElement);
assert.deepEqual(observer.options, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
page.mutate(1);
assert.equal(reloads, 0, 'a dialog swapped for another keeps it waiting');
page.mutate(0);
assert.equal(reloads, 1);
assert.equal(page.observers.size, 0, 'the observer stops once it ran');
page.mutate(0);
assert.equal(reloads, 1);
page.open = 1;
reload.request();
page.mutate(0);
assert.equal(reloads, 2, 'a later restore waits again');
});
test('disposing drops a waiting restore reload', async () => {
const { createRestoreReload } = await backups;
const page = dialogPage();
page.open = 1;
let reloads = 0;
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
reload.request();
reload.dispose();
assert.equal(page.observers.size, 0);
page.mutate(0);
assert.equal(reloads, 0);
reload.dispose();
});
test('a downloaded backup the server could not verify says so', async () => {
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
const toasts = [];
+31
View File
@@ -594,6 +594,9 @@ test('an input dialog offers suggestions and a secondary action', async () => {
const [plainField, plainActions] = byId(dom, 'modal-body').children;
assert.equal(plainField.children[1].getAttribute('list'), null);
assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']);
// An empty list (Add to collection with no collections yet, #331 D138) adds no datalist either.
openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} });
assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError);
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError);
@@ -908,6 +911,34 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
}
});
// #415: a finished restore ends an open review (its reviewed plan no longer describes storage) as a dismissal, and
// leaves any other dialog, with what was typed in it, open.
test('closeReviewDialog closes only an open review, as dismissed', async () => {
const { openReviewDialog, openInputDialog, closeReviewDialog, closeWorkspaceDialog } = await dialogsModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const scope = createScope();
try {
closeReviewDialog();
const outcomes = [];
openInputDialog({ scope, title: 'Save view', label: 'Name', value: 'typed', action: 'Save', onApply() {} });
closeReviewDialog();
assert.equal(modal(dom).open, true, 'an input dialog stays');
assert.equal(byId(dom, 'action-input').value, 'typed');
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], cancelLabel: 'Cancel',
primary: { label: 'Restore', danger: true }, onStart: () => outcomes.push('start'), onDismiss: () => outcomes.push('dismiss') });
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
assert.equal(modal(dom), undefined, 'the shell is gone');
closeReviewDialog();
assert.deepEqual(outcomes, ['dismiss']);
} finally {
scope.dispose();
closeWorkspaceDialog();
dom.restore();
}
});
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;