Merge remote-tracking branch 'origin/main' into feat/331-collections-pr2

#332 PR 3 (#428) hands a Tags & categories Browse to the next Images mount;
this branch mounts Images as a collection page too. Both imports are kept; a
collection page neither restores the last visit nor takes the Browse hand-off
(it is not the Images page), and the Images page keeps both the handed
category and the collection filter.
This commit is contained in:
Vito Sansevero
2026-10-28 12:53:30 -07:00
24 changed files with 1253 additions and 258 deletions
+6 -6
View File
@@ -384,7 +384,7 @@ test('the D117 wrap rule targets only the settings summary button', async () =>
assert.doesNotMatch(css, /#viewer-details > div > button/);
});
test('category chip names its origin and shows an own category for any link status (#332)', async () => {
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 => {
const {f, done} = renderFull(record);
@@ -393,11 +393,11 @@ test('category chip names its origin and shows an own category for any link stat
} finally { done(); }
};
const cases = [
[{status:'none', category:'Scans', category_scope:'image'}, 'Image category: Scans'],
[{status:'ambiguous', category:'Scans', category_scope:'image'}, 'Image category: Scans'],
[{status:'linked', category:'Portraits', category_scope:'image'}, 'Image category: Portraits'],
[{status:'linked', category:'Landscapes', category_scope:'positive'}, 'Positive prompt category: Landscapes'],
[{status:'linked', category:'Recipes', category_scope:'pair'}, 'Recipe category: Recipes'],
[{status:'none', category:'Scans', category_scope:'image'}, 'Image category: Scans · this image'],
[{status:'ambiguous', category:'Scans', category_scope:'image'}, 'Image category: Scans · this image'],
[{status:'linked', category:'Portraits', category_scope:'image'}, 'Image category: Portraits · this image'],
[{status:'linked', category:'Landscapes', category_scope:'positive'}, 'Positive prompt category: Landscapes · prompt'],
[{status:'linked', category:'Recipes', category_scope:'pair'}, 'Recipe category: Recipes · recipe'],
];
for (const [library, text] of cases) {
const chips = chipTexts({...base, library:{...base.library, tags:[], tags_scope:null, ...library}});
+50 -42
View File
@@ -1,53 +1,61 @@
const test = require('node:test');
const assert = require('node:assert/strict');
// #332 D140: Set category writes each selected image's own category; Use prompt category removes it.
const source = import('../../web/js/workspace/images-category.mjs');
const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}});
test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => {
const {categoryTargets} = await source;
const images = [
linked(7), linked(3), linked(7),
{path:'u.png', linked:{status:'unlinked', positive_id:null}},
{path:'a.png', linked:{status:'ambiguous', positive_id:4}},
{path:'v.png', linked:{status:'unavailable', positive_id:null}},
{path:'none.png'},
{path:'bad.png', linked:{status:'linked', positive_id:'9'}},
{path:'zero.png', linked:{status:'linked', positive_id:0}},
{path:'frac.png', linked:{status:'linked', positive_id:1.5}},
null,
];
assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8});
assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0});
assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0});
});
test('the outcome toast counts the prompts and the skipped images', async () => {
const {categoryOutcome} = await source;
assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.');
assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.');
assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.');
assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
});
test('the dialog and failure copy is the plan\'s', async () => {
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
assert.equal(CATEGORY_NONE_LINKED, "None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
test('the dialog keeps the concept\'s simpleInput words and names the routes', async () => {
const {CATEGORY_DIALOG, USE_PROMPT_CATEGORY, REMOVE_DIALOG, CATEGORY_URL, IMAGE_CATEGORIES_URL, CATEGORY_FAILED,
CATEGORY_MAX_LENGTH, MAX_CATEGORY_IMAGES} = await source;
assert.deepEqual({...CATEGORY_DIALOG}, {title:'Categorize selected images', label:'Category', action:'Apply'});
assert.equal(USE_PROMPT_CATEGORY, 'Use prompt category');
assert.deepEqual({...REMOVE_DIALOG}, {title:'Use prompt category', action:'Remove'});
assert.equal(CATEGORY_URL, '/prompt_manager/images/category');
assert.equal(IMAGE_CATEGORIES_URL, '/prompt_manager/images/categories');
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
assert.equal(CATEGORY_MAX_LENGTH, 100);
assert.equal(MAX_CATEGORY_IMAGES, 500);
});
test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => {
const {updatedCount} = await source;
assert.equal(updatedCount({updated_count:0}, 3), 0);
assert.equal(updatedCount({updated_count:2}, 3), 2);
for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) {
assert.equal(updatedCount(result, 3), 3, JSON.stringify(result));
test('a set counts unchanged files as set and names files that could not be found', async () => {
const {setOutcome} = await source;
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'Portraits'), 'Category “Portraits” set on 1 image.');
assert.equal(setOutcome({updated:1, unchanged:2, not_found:0}, 'Scans'), 'Category “Scans” set on 3 images.');
assert.equal(setOutcome({updated:2, unchanged:0, not_found:1}, 'Scans'), 'Category “Scans” set on 2 images. 1 could not be found.');
assert.equal(setOutcome({updated:0, unchanged:1, not_found:3}, 'Scans'), 'Category “Scans” set on 1 image. 3 could not be found.');
assert.equal(setOutcome({}, 'Scans'), 'Category “Scans” set on 0 images.');
});
test('a removal says how many lost their own category, or that none had one', async () => {
const {clearOutcome, NONE_HAD_OWN} = await source;
assert.equal(clearOutcome({cleared:1}), 'Removed the image category from 1 image.');
assert.equal(clearOutcome({cleared:4}), 'Removed the image category from 4 images.');
assert.equal(clearOutcome({cleared:0}), NONE_HAD_OWN);
assert.equal(NONE_HAD_OWN, 'None of the selected images had its own category.');
});
test('the confirmation states the count', async () => {
const {removeQuestion} = await source;
assert.equal(removeQuestion(1), "Remove the image category from the 1 selected image? Each one then shows its prompt's " +
'category, or none if it has no linked prompt. This cannot be undone.');
assert.match(removeQuestion(1200), /^Remove the image category from the 1,200 selected images\? /);
});
test('coded failures have their own sentences, anything else the fixed one', async () => {
const {failureMessage, NONE_FOUND, SOURCES_CHANGED, CATEGORY_FAILED} = await source;
assert.equal(failureMessage({code:'files-not-found'}), NONE_FOUND);
assert.equal(NONE_FOUND, 'None of the selected images could be found. Refresh the images and try again.');
assert.equal(failureMessage({code:'source-changed'}), SOURCES_CHANGED);
assert.equal(SOURCES_CHANGED, 'The sources changed. Refresh the images and try again.');
assert.equal(failureMessage({code:'too-many-images', message:'Select at most 500 images.'}), 'Select at most 500 images.');
for (const error of [null, undefined, new Error('x'), {code:'other', message:'secret'}]) {
assert.equal(failureMessage(error), CATEGORY_FAILED);
}
});
test('the category field is limited to the server\'s 100 characters', async () => {
const {CATEGORY_MAX_LENGTH} = await source;
assert.equal(CATEGORY_MAX_LENGTH, 100);
test('the toolbar list merges prompt and image categories once each', async () => {
const {mergeCategories} = await source;
assert.deepEqual(mergeCategories(['Landscapes', 'Portraits'], ['Scans', 'Landscapes']), ['Landscapes', 'Portraits', 'Scans']);
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
});
+37
View File
@@ -567,6 +567,43 @@ test('an input dialog with a fallback applies it for an empty value', async () =
}
});
// #332: Set category's field suggests the categories under its own datalist id, and "Use prompt category" sits
// between Cancel and Apply, closing the dialog before it runs.
test('an input dialog offers suggestions and a secondary action', async () => {
const { openInputDialog, closeWorkspaceDialog, INPUT_SUGGESTIONS_ID } = await dialogsModule;
const dom = fakeDOM();
const scope = (await coreModule).createScope();
try {
const calls = [];
openInputDialog({ scope, title: 'Categorize selected images', label: 'Category', action: 'Apply', maxLength: 100,
suggestions: ['Landscapes', 'Scans'], secondary: { label: 'Use prompt category', onClick: () => calls.push('secondary') },
onApply: value => calls.push(value) });
assert.equal(INPUT_SUGGESTIONS_ID, 'workspace-input-suggestions');
const body = byId(dom, 'modal-body');
const [field, list, actions] = body.children;
const input = field.children[1];
assert.equal(input.getAttribute('list'), 'workspace-input-suggestions');
assert.equal(list.tagName.toLowerCase(), 'datalist');
assert.equal(list.id, 'workspace-input-suggestions');
assert.deepEqual(list.children.map(option => option.value), ['Landscapes', 'Scans']);
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Use prompt category', 'Apply']);
actions.children[1].click();
assert.deepEqual(calls, ['secondary']);
// No suggestions: no datalist and no list attribute.
openInputDialog({ scope, title: 'T', label: 'L', action: 'A', onApply() {} });
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']);
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);
} finally {
closeWorkspaceDialog();
scope.dispose();
dom.restore();
}
});
test('dialogs restore focus to the trigger on Escape', async () => {
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
const dom = fakeDOM();
@@ -553,3 +553,16 @@ test('delete text names the tag, its real links and what stays unchanged', async
assert.equal(deleteTagText('unused', zero),
'“unused” is not linked to any saved prompt or image. Deleting it removes only the name.');
});
test('a category row counts prompts and images, and an image-only row browses Images (#332 D143)', async () => {
const {categoryRowText, categoryBrowseTarget, categoryPreviewText} = await import('../../web/js/workspace/vocabulary-state.mjs');
assert.equal(categoryRowText({value: 'L', prompt_count: 3}), '3 positive prompts');
assert.equal(categoryRowText({value: 'L', prompt_count: 1, image_count: 0}), '1 positive prompt');
assert.equal(categoryRowText({value: 'L', prompt_count: 3, image_count: 2}), '3 positive prompts · 2 images');
assert.equal(categoryRowText({value: 'S', prompt_count: 0, image_count: 1}), '1 image');
assert.equal(categoryBrowseTarget({value: 'L', prompt_count: 3, image_count: 2}), 'library');
assert.equal(categoryBrowseTarget({value: 'S', prompt_count: 0, image_count: 2}), 'images');
const impact = {positive_prompts: 3, other_prompts: 0, negative_variants: 0, archived_negative_variants: 0, images: 2};
assert.equal(categoryPreviewText({source: 'Landscapes', target: 'Landscape', target_exists: false, impact}),
'“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Landscape”.');
});