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:
@@ -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}});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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”.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user