* feat(maintenance): repair old Pair prompts' empty category and tags (#550) Squashed from PR #579. Closes #550. * feat(tagging): saved instructions with a default per engine (#431) Squashed from PR #587. * feat(tagging): pick saved instructions in the tagging dialogs (#431) Squashed from PR #588. Closes #431.
This commit is contained in:
@@ -53,8 +53,12 @@ function observed(overrides = {}, capture = {}) {
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
// #431: the saved instructions; the fixture's entry is the default of both engines.
|
||||
const SAVED_ENTRY = { id: '0123456789ab', name: 'Saved', text: DEFAULT_PROMPT };
|
||||
const models = (overrides = {}) => ({
|
||||
success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
|
||||
success: true, models: { 'wd14-swinv2': { downloaded: true } },
|
||||
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY],
|
||||
default_instructions: { gguf: SAVED_ENTRY.id, hf: SAVED_ENTRY.id },
|
||||
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
|
||||
});
|
||||
const SANDSTONE = [
|
||||
@@ -244,10 +248,13 @@ test('dialog starts on the saved model unless a preset or the user chose one', a
|
||||
});
|
||||
|
||||
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
|
||||
for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
|
||||
for (const route of [() => fail(500, null), () => response(200, models({ instructions: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
|
||||
const { c } = await opened({ [`GET ${MODELS}`]: route });
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
|
||||
// #431: the client's Built-in copy (D204's read failure).
|
||||
const { BUILTIN_INSTRUCTIONS } = await load();
|
||||
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: BUILTIN_INSTRUCTIONS,
|
||||
suggestion_limit: 10 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -950,3 +957,85 @@ test('the chip hint says how many skipped tags were not suggested (#431)', async
|
||||
assert.equal(c.state.chipHint, hint, String(skipped));
|
||||
}
|
||||
});
|
||||
|
||||
// D204 (#431): the Saved instructions picker of both dialogs.
|
||||
const PHOTO_ENTRY = { id: '0123456789ac', name: 'Photo', text: 'Photo tags.' };
|
||||
const picker = (overrides = {}) => ({ [`GET ${MODELS}`]: () => response(200, models({ model_type: 'gguf',
|
||||
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY, PHOTO_ENTRY],
|
||||
default_instructions: { gguf: SAVED_ENTRY.id, hf: PHOTO_ENTRY.id }, ...overrides })) });
|
||||
|
||||
test('the picker starts at the engine default; a pick replaces the text and an edit is one-off (D204)', async () => {
|
||||
const { c } = await opened(picker());
|
||||
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [SAVED_ENTRY.id, DEFAULT_PROMPT, false]);
|
||||
const revision = c.state.optionsRevision;
|
||||
c.pickInstructions(PHOTO_ENTRY.id);
|
||||
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
|
||||
assert.equal(c.state.optionsRevision, revision + 1, 'a pick re-renders the textarea');
|
||||
c.setOption('instructions', 'Photo tags, please.');
|
||||
assert.equal(c.state.instructionsEdited, true);
|
||||
assert.equal(c.state.optionsRevision, revision + 1, 'typing never re-renders');
|
||||
// Choosing an entry again replaces the edit without asking; an unknown id changes nothing.
|
||||
c.pickInstructions(PHOTO_ENTRY.id);
|
||||
assert.deepEqual([c.state.options.instructions, c.state.instructionsEdited], ['Photo tags.', false]);
|
||||
c.pickInstructions('nope');
|
||||
assert.equal(c.state.instructionId, PHOTO_ENTRY.id);
|
||||
// Switching engines resets to the new engine's default and drops a one-off edit; WD14 is never "edited".
|
||||
c.setOption('instructions', 'edited');
|
||||
c.setModel('hf');
|
||||
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.instructionsEdited, false);
|
||||
c.setModel('gguf');
|
||||
assert.deepEqual([c.state.instructionId, c.state.options.instructions], [SAVED_ENTRY.id, DEFAULT_PROMPT]);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('a preset shows the entry with its text, else "Edited for this run" (D204)', async () => {
|
||||
const { c } = await opened(picker(), { preset: { model: 'hf', options: { instructions: DEFAULT_PROMPT } } });
|
||||
assert.deepEqual([c.state.instructionId, c.state.instructionsEdited], [SAVED_ENTRY.id, false]);
|
||||
const { c: other } = await opened(picker(), { preset: { model: 'hf', options: { instructions: 'Something else' } } });
|
||||
assert.deepEqual([other.state.instructionId, other.state.options.instructions, other.state.instructionsEdited],
|
||||
[PHOTO_ENTRY.id, 'Something else', true]);
|
||||
for (const controller of [c, other]) controller.dispose();
|
||||
});
|
||||
|
||||
test('a models read without instructions offers Built-in only with the client copy (D204)', async () => {
|
||||
const { BUILTIN_INSTRUCTIONS, readChoices } = await load();
|
||||
assert.equal(BUILTIN_INSTRUCTIONS.startsWith('Generate only comma-separated Danbooru tags'), true);
|
||||
for (const value of [null, {}, { instructions: [] }, { instructions: [{ id: 'x', name: 'X', text: 'y' }] }]) {
|
||||
assert.deepEqual(readChoices(value).entries.map(entry => entry.id), ['builtin'], JSON.stringify(value));
|
||||
}
|
||||
// A default naming no entry is Built-in.
|
||||
assert.deepEqual({ ...readChoices({ instructions: [{ id: 'builtin', name: 'Built-in', text: 't' }], default_instructions: { gguf: 'gone' } }).defaults },
|
||||
{ gguf: 'builtin', hf: 'builtin' });
|
||||
const { c } = await opened(picker({ instructions: 'broken' }));
|
||||
assert.deepEqual([c.state.instructionId, c.state.options.instructions], ['builtin', BUILTIN_INSTRUCTIONS]);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
test('the bulk options pick and reset like the Tag image dialog (D204)', async () => {
|
||||
const { createTaggingOptionsController } = await load();
|
||||
const options = createTaggingOptionsController({ api: harness(defaultRoutes(picker())).api });
|
||||
await options.open();
|
||||
assert.deepEqual([options.state.model, options.state.instructionId, options.state.options.instructions], ['gguf', SAVED_ENTRY.id, DEFAULT_PROMPT]);
|
||||
options.pickInstructions(PHOTO_ENTRY.id);
|
||||
assert.equal(options.request().options.instructions, 'Photo tags.');
|
||||
options.setOption('instructions', 'One-off.');
|
||||
assert.equal(options.state.instructionsEdited, true);
|
||||
assert.equal(options.request().options.instructions, 'One-off.');
|
||||
options.setModel('hf');
|
||||
assert.deepEqual([options.state.instructionId, options.state.instructionsEdited], [PHOTO_ENTRY.id, false]);
|
||||
options.pickInstructions('nope');
|
||||
options.dispose();
|
||||
options.pickInstructions(SAVED_ENTRY.id);
|
||||
assert.equal(options.state.instructionId, PHOTO_ENTRY.id);
|
||||
});
|
||||
|
||||
test('the client Built-in copy equals the server DEFAULT_TAG_TEMPLATE (#431)', async () => {
|
||||
const { BUILTIN_INSTRUCTIONS } = await load();
|
||||
const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '../../py/tagging_defaults.py'), 'utf8');
|
||||
const block = source.slice(source.indexOf('DEFAULT_TAG_TEMPLATE = ('), source.indexOf('\n)\n', source.indexOf('DEFAULT_TAG_TEMPLATE = (')));
|
||||
const text = [...block.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(([, part]) => JSON.parse(`"${part}"`)).join('');
|
||||
assert.ok(text.length > 100);
|
||||
assert.equal(BUILTIN_INSTRUCTIONS, text);
|
||||
});
|
||||
|
||||
@@ -180,7 +180,9 @@ test('the bulk options start from the server defaults and wait for them before S
|
||||
assert.equal(options.state.canStart, false);
|
||||
assert.equal(options.state.canGenerate, false);
|
||||
const opening = options.open();
|
||||
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7,
|
||||
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, suggestion_limit: 7,
|
||||
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, { id: '0123456789ab', name: 'Say', text: 'Say it' }],
|
||||
default_instructions: { gguf: '0123456789ab', hf: 'builtin' },
|
||||
models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } });
|
||||
await opening;
|
||||
assert.equal(options.state.canStart, true);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// System › Auto-tagging "Saved instructions" (#431, D203): Built-in read-only, New, Duplicate, Delete (a deleted default
|
||||
// resets to Built-in), the caps, the editor drafts and the per-engine default selects.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const instructionsModule = import('../../web/js/workspace/system-instructions.mjs');
|
||||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
|
||||
/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */
|
||||
function fakeDOM() {
|
||||
const previous = global.document;
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.listeners = [];
|
||||
this.dataset = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this.title = '';
|
||||
this.disabled = false;
|
||||
this._text = '';
|
||||
}
|
||||
get localName() { return this.tagName.toLowerCase(); }
|
||||
get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } }
|
||||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||||
querySelectorAll(selector) {
|
||||
assert.equal(selector, 'button');
|
||||
const found = [];
|
||||
const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } };
|
||||
walk(this);
|
||||
return found;
|
||||
}
|
||||
focus() { global.document.activeElement = this; }
|
||||
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); });
|
||||
}
|
||||
dispatch(type, event = {}) {
|
||||
return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event })));
|
||||
}
|
||||
}
|
||||
global.document = { createElement: tag => new Element(tag), activeElement: null };
|
||||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; };
|
||||
return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } };
|
||||
}
|
||||
|
||||
|
||||
const PHOTO = { id: '0123456789ab', name: 'Custom template', text: 'Photo tags.' };
|
||||
const ANIME = { id: '0123456789ac', name: 'Anime', text: 'Anime tags.' };
|
||||
const HOST = { autotag_builtin_instructions: 'Built-in text.', autotag_instructions: [PHOTO, ANIME],
|
||||
autotag_default_instructions: { gguf: PHOTO.id, hf: PHOTO.id } };
|
||||
|
||||
async function mounted({ host = HOST } = {}) {
|
||||
const { savedInstructionsBlock } = await instructionsModule;
|
||||
const { createSystemDrafts } = await stateModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const drafts = createSystemDrafts({
|
||||
readBrowser: () => ({}), readRuntime: () => ({}), readHost: async () => ({ values: host, revision: 'sha256:one' }),
|
||||
writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
|
||||
});
|
||||
const scope = createScope();
|
||||
let counter = 0;
|
||||
const random = bytes => bytes.map(() => (counter += 1));
|
||||
const block = savedInstructionsBlock({ scope, drafts, random });
|
||||
const root = global.document.createElement('div');
|
||||
root.append(...block.nodes);
|
||||
scope.cleanup(drafts.subscribe(block.repaint));
|
||||
block.repaint();
|
||||
await drafts.reloadHost();
|
||||
const byId = id => dom.find(root, node => node.id === id);
|
||||
const options = () => byId('instruction-list').children.map(option => [option.children[0].textContent,
|
||||
option.children[1]?.children.map(badge => badge.textContent) ?? [], option.getAttribute('aria-selected')]);
|
||||
const click = id => byId(id).dispatch('click');
|
||||
// A click on an option reaches the list's one listener (as it bubbles in a browser).
|
||||
const pick = name => byId('instruction-list').dispatch('click', { target: byId('instruction-list').children
|
||||
.find(option => option.children[0].textContent === name).children[0] });
|
||||
return { dom, drafts, scope, block, byId, options, click, pick, root };
|
||||
}
|
||||
|
||||
test('helpers: Built-in first, defaults, free names, ids and the server sentences', async () => {
|
||||
const { allEntries, defaultEntry, freeName, newId, listProblem, entryNote } = await instructionsModule;
|
||||
assert.deepEqual(allEntries(HOST).map(entry => entry.name), ['Built-in', 'Custom template', 'Anime']);
|
||||
assert.equal(allEntries(HOST)[0].text, 'Built-in text.');
|
||||
assert.equal(defaultEntry(HOST, 'hf').text, 'Photo tags.');
|
||||
assert.equal(defaultEntry({ ...HOST, autotag_default_instructions: { gguf: 'gone', hf: 'builtin' } }, 'gguf').name, 'Built-in');
|
||||
assert.equal(freeName('New instructions', allEntries(HOST)), 'New instructions');
|
||||
assert.equal(freeName('anime', allEntries(HOST)), 'anime (2)');
|
||||
assert.equal(freeName('Anime', [...allEntries(HOST), { name: 'Anime (2)' }]), 'Anime (3)');
|
||||
let calls = 0;
|
||||
const id = newId([{ id: '000000000000' }], bytes => { calls += 1; return bytes.fill(calls === 1 ? 0 : 171); });
|
||||
assert.equal(id, 'abababababab');
|
||||
assert.match(newId([]), /^[0-9a-f]{12}$/);
|
||||
const list = entries => listProblem({ autotag_instructions: entries });
|
||||
assert.equal(list([PHOTO]), null);
|
||||
assert.equal(list([{ ...PHOTO, name: ' ' }]), 'Saved instructions: entry 1 needs a name.');
|
||||
assert.equal(list([PHOTO, { ...ANIME, name: 'x'.repeat(61) }]), 'Saved instructions: entry 2 name must be at most 60 characters.');
|
||||
assert.equal(list([{ ...PHOTO, name: 'a\nb' }]), 'Saved instructions: entry 1 name must not contain a line break.');
|
||||
assert.equal(list([{ ...PHOTO, name: 'built-in' }]), 'Saved instructions: the name “Built-in” is reserved.');
|
||||
assert.equal(list([PHOTO, { ...ANIME, name: ' custom TEMPLATE ' }]), 'Saved instructions: entry 2 name “custom TEMPLATE” is already used.');
|
||||
assert.equal(list([{ ...PHOTO, text: ' ' }]), 'Saved instructions: entry 1 text must be non-empty text.');
|
||||
assert.equal(list([{ ...PHOTO, text: 'x'.repeat(4001) }]), 'Saved instructions: entry 1 text must be at most 4000 characters.');
|
||||
const long = { ...PHOTO, text: 'x'.repeat(4001) };
|
||||
assert.equal(entryNote(long, [long]), 'The text must be at most 4,000 characters.');
|
||||
});
|
||||
|
||||
test('the list shows Built-in read-only first, the saved entries and their default chips', async () => {
|
||||
const view = await mounted();
|
||||
try {
|
||||
assert.deepEqual(view.options(), [['Built-in', ['Read-only'], 'true'],
|
||||
['Custom template', ['Default for GGUF', 'Default for HuggingFace'], 'false'], ['Anime', [], 'false']]);
|
||||
assert.equal(view.byId('instructions-count').textContent, '2 of 20 saved · Built‑in is not counted.');
|
||||
assert.equal(view.byId('instruction-name').value, 'Built-in');
|
||||
assert.equal(view.byId('instruction-name').readOnly, true);
|
||||
assert.equal(view.byId('instruction-text').value, 'Built-in text.');
|
||||
assert.equal(view.byId('instruction-text').readOnly, true);
|
||||
assert.equal(view.byId('instruction-note').textContent, 'Built-in instructions can’t be changed. Duplicate them to make your own.');
|
||||
assert.equal(view.byId('instruction-delete').disabled, true);
|
||||
assert.equal(view.byId('instruction-list').getAttribute('role'), 'listbox');
|
||||
assert.equal(view.block.nodes[0].dataset.key, 'autotag_instructions');
|
||||
assert.deepEqual(view.byId('instruction-default-gguf').children.map(option => option.textContent), ['Built-in', 'Custom template', 'Anime']);
|
||||
assert.equal(view.byId('instruction-default-gguf').value, PHOTO.id);
|
||||
assert.equal(view.byId('instruction-default-hf').dataset.key, 'autotag_default_instructions');
|
||||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||||
});
|
||||
|
||||
test('editing a saved entry drafts the list; its counts and note follow', async () => {
|
||||
const view = await mounted();
|
||||
try {
|
||||
await view.pick('Anime');
|
||||
assert.equal(view.byId('instruction-name').readOnly, false);
|
||||
assert.equal(view.byId('instruction-delete').disabled, false);
|
||||
view.byId('instruction-name').value = 'Anime tags';
|
||||
await view.byId('instruction-name').dispatch('input');
|
||||
view.byId('instruction-text').value = 'Tags, anime.';
|
||||
await view.byId('instruction-text').dispatch('input');
|
||||
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [PHOTO, { ...ANIME, name: 'Anime tags', text: 'Tags, anime.' }]);
|
||||
assert.equal(view.byId('instruction-name').parentNode.children[0].children[1].textContent, '10 of 60');
|
||||
assert.equal(view.byId('instruction-text').parentNode.children[0].children[1].textContent, '12 of 4,000 characters');
|
||||
view.byId('instruction-name').value = 'custom template';
|
||||
await view.byId('instruction-name').dispatch('input');
|
||||
assert.equal(view.byId('instruction-note').textContent, 'The name “custom template” is already used.');
|
||||
// Built-in is never edited.
|
||||
await view.pick('Built-in');
|
||||
view.byId('instruction-text').value = 'changed';
|
||||
await view.byId('instruction-text').dispatch('input');
|
||||
assert.equal(view.drafts.get('host', 'autotag_instructions')[0], PHOTO);
|
||||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||||
});
|
||||
|
||||
test('New and Duplicate append an entry with a fresh id and a free name; the caps disable them', async () => {
|
||||
const view = await mounted();
|
||||
try {
|
||||
await view.click('instruction-new');
|
||||
const added = view.drafts.get('host', 'autotag_instructions').at(-1);
|
||||
assert.match(added.id, /^[0-9a-f]{12}$/);
|
||||
assert.deepEqual([added.name, added.text], ['New instructions', '']);
|
||||
assert.equal(view.options().at(-1)[2], 'true');
|
||||
assert.equal(view.byId('instruction-note').textContent, 'The text must not be empty.');
|
||||
await view.pick('Built-in');
|
||||
await view.click('instruction-duplicate');
|
||||
assert.deepEqual(view.drafts.get('host', 'autotag_instructions').at(-1).name, 'Built-in copy');
|
||||
assert.equal(view.drafts.get('host', 'autotag_instructions').at(-1).text, 'Built-in text.');
|
||||
const full = await mounted({ host: { ...HOST, autotag_instructions: Array.from({ length: 20 },
|
||||
(_, i) => ({ id: i.toString(16).padStart(12, '0'), name: `n${i}`, text: 't' })) } });
|
||||
try {
|
||||
assert.equal(full.byId('instruction-new').disabled, true);
|
||||
assert.equal(full.byId('instruction-duplicate').disabled, true);
|
||||
assert.equal(full.byId('instruction-cap').hidden, false);
|
||||
await full.click('instruction-new');
|
||||
assert.equal(full.drafts.get('host', 'autotag_instructions').length, 20);
|
||||
} finally { full.scope.dispose(); }
|
||||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||||
});
|
||||
|
||||
test('deleting a default resets that engine to Built-in and says so; a select change clears the note', async () => {
|
||||
const view = await mounted();
|
||||
try {
|
||||
await view.pick('Custom template');
|
||||
await view.click('instruction-delete');
|
||||
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [ANIME]);
|
||||
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: 'builtin' });
|
||||
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
|
||||
assert.equal(view.options()[1][2], 'true', 'the next entry is selected');
|
||||
view.byId('instruction-default-hf').value = ANIME.id;
|
||||
await view.byId('instruction-default-hf').dispatch('change');
|
||||
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: ANIME.id });
|
||||
assert.equal(view.byId('instruction-reset').textContent, '');
|
||||
await view.click('instruction-delete');
|
||||
assert.equal(view.byId('instruction-reset').textContent, 'HuggingFace now uses Built-in.');
|
||||
assert.deepEqual(view.options().map(([name]) => name), ['Built-in']);
|
||||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||||
});
|
||||
|
||||
test('arrow keys move the selection; before the read everything is disabled', async () => {
|
||||
const view = await mounted();
|
||||
try {
|
||||
const list = view.byId('instruction-list');
|
||||
await list.dispatch('keydown', { key: 'ArrowDown' });
|
||||
assert.equal(view.byId('instruction-name').value, 'Custom template');
|
||||
await list.dispatch('keydown', { key: 'End' });
|
||||
assert.equal(view.byId('instruction-name').value, 'Anime');
|
||||
await list.dispatch('keydown', { key: 'ArrowDown' });
|
||||
assert.equal(view.byId('instruction-name').value, 'Anime');
|
||||
await list.dispatch('keydown', { key: 'Home' });
|
||||
assert.equal(view.byId('instruction-name').value, 'Built-in');
|
||||
await list.dispatch('keydown', { key: 'a' });
|
||||
assert.equal(list.getAttribute('aria-activedescendant'), 'instruction-option-builtin');
|
||||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||||
const unread = await mounted({ host: {} });
|
||||
try {
|
||||
assert.equal(unread.byId('instruction-new').disabled, true);
|
||||
assert.equal(unread.byId('instruction-default-gguf').disabled, true);
|
||||
} finally { unread.scope.dispose(); unread.dom.restore(); }
|
||||
});
|
||||
|
||||
test('the reset note goes once the defaults are saved; a long name is cut to fit its copy suffix', async () => {
|
||||
const view = await mounted();
|
||||
try {
|
||||
await view.pick('Custom template');
|
||||
await view.click('instruction-delete');
|
||||
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
|
||||
await view.drafts.save();
|
||||
assert.equal(view.byId('instruction-reset').textContent, '');
|
||||
await view.pick('Anime');
|
||||
view.byId('instruction-name').value = 'x'.repeat(60);
|
||||
await view.byId('instruction-name').dispatch('input');
|
||||
await view.click('instruction-duplicate');
|
||||
const copy = view.drafts.get('host', 'autotag_instructions').at(-1).name;
|
||||
assert.equal(copy, `${'x'.repeat(55)} copy`);
|
||||
await view.click('instruction-duplicate');
|
||||
assert.equal([...view.drafts.get('host', 'autotag_instructions').at(-1).name].length <= 60, true);
|
||||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||||
});
|
||||
|
||||
test('option clicks are one listener on the list, not one per repaint', async () => {
|
||||
const view = await mounted();
|
||||
try {
|
||||
const list = view.byId('instruction-list');
|
||||
const before = list.listeners.length;
|
||||
for (let i = 0; i < 5; i += 1) await view.pick('Anime');
|
||||
assert.equal(list.listeners.length, before);
|
||||
assert.equal(view.byId('instruction-list').children.every(option => option.listeners.length === 0), true);
|
||||
assert.equal(view.byId('instruction-name').value, 'Anime');
|
||||
} finally { view.scope.dispose(); view.dom.restore(); }
|
||||
});
|
||||
@@ -21,8 +21,23 @@ test('the section lists the concept rows in its order, labels and danger marks',
|
||||
assert.deepEqual(MAINTENANCE_SURFACES.map(([heading]) => heading), surfaces.map(text => text.replace('&', '&')));
|
||||
const production = MAINTENANCE_SURFACES.flatMap(([, rows]) => rows.map(([title, description, label, , danger = false]) =>
|
||||
[title, description, label, danger]));
|
||||
assert.deepEqual(production, concept.map(([title, ...rest]) => [title.replace('&', '&'), ...rest]));
|
||||
assert.equal(production.length, 8);
|
||||
// D192 (#550, beyond the concept): "Pair category and tags" follows "Unused prompt components" in Database.
|
||||
const expected = concept.map(([title, ...rest]) => [title.replace('&', '&'), ...rest]);
|
||||
expected.splice(expected.findIndex(([title]) => title === 'Unused prompt components') + 1, 0, PAIR_ROW);
|
||||
assert.deepEqual(production, expected);
|
||||
assert.equal(production.length, 9);
|
||||
});
|
||||
|
||||
const PAIR_ROW = Object.freeze(['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs ' +
|
||||
'saved, from their saved pairs. Only empty fields of never-edited prompts.', 'Review plan', false]);
|
||||
|
||||
test('D192: the Pair category and tags row reviews the pair_metadata plan under its title (#550)', async () => {
|
||||
const { MAINTENANCE_SURFACES } = await maintenance;
|
||||
const { OPERATION_TITLES } = await review;
|
||||
const [, database] = MAINTENANCE_SURFACES[0];
|
||||
const row = database.find(([title]) => title === PAIR_ROW[0]);
|
||||
assert.deepEqual(row, [PAIR_ROW[0], PAIR_ROW[1], PAIR_ROW[2], 'pair_metadata']);
|
||||
assert.equal(OPERATION_TITLES.pair_metadata, 'Pair category and tags');
|
||||
});
|
||||
|
||||
const PLAN = Object.freeze({
|
||||
@@ -210,3 +225,40 @@ test('a rotated-log cleanup the log overtook offers Review again (S12)', async (
|
||||
assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }),
|
||||
{ operation: 'rotated_logs' });
|
||||
});
|
||||
|
||||
// #550 live test: a records operation's rows have no path, only a detail ("Prompt 12: category …").
|
||||
const RECORD_JOB = Object.freeze({
|
||||
id: 'a'.repeat(32), kind: 'maintenance', title: 'Pair category and tags', state: 'finished',
|
||||
admission: 'records', cancellable: false, progress: 100, processed: 0, total: 0, status_line: 'Filled 2 prompts',
|
||||
scope: { operation: 'pair_metadata' }, counters: { changed: 2, protected: 0, errors: 0 },
|
||||
started_at: 1, finished_at: 2,
|
||||
});
|
||||
|
||||
test('#550: path-less record rows are kept with their detail; file rows are unchanged', async () => {
|
||||
const { parseDetail, filterRows } = await jobView;
|
||||
const parsed = parseDetail({ ...RECORD_JOB, outcomes: { changed: [
|
||||
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian' },
|
||||
{ path: '', root: '', root_index: null, detail: '' },
|
||||
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed' },
|
||||
] }, log: [] });
|
||||
assert.deepEqual(parsed.outcomes.changed, [
|
||||
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian', record: true },
|
||||
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed', record: false },
|
||||
]);
|
||||
// The filter reads a record's detail and a file's path.
|
||||
assert.deepEqual(filterRows(parsed.outcomes.changed, 'asian').map(row => row.detail),
|
||||
['Prompt 2325: category “asian”; tags asian']);
|
||||
assert.deepEqual(filterRows(parsed.outcomes.changed, 'a/b').map(row => row.path), ['a/b.png']);
|
||||
});
|
||||
|
||||
test('#550: a records operation says records, not files, for an empty group and a filter', async () => {
|
||||
const { groupCopy, EMPTY_GROUP, NO_MATCH } = await jobView;
|
||||
assert.deepEqual(groupCopy(RECORD_JOB), { empty: 'No records in this group.', noMatch: 'No listed record matches this filter.',
|
||||
placeholder: 'Filter records…' });
|
||||
for (const operation of ['duplicate_prompts', 'unused_components']) {
|
||||
assert.equal(groupCopy({ ...RECORD_JOB, scope: { operation } }).empty, 'No records in this group.');
|
||||
}
|
||||
const files = { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: 'Filter by file path…' };
|
||||
assert.deepEqual(groupCopy({ ...RECORD_JOB, scope: { operation: 'missing_links' } }), files);
|
||||
assert.deepEqual(groupCopy({ ...RECORD_JOB, kind: 'scan', scope: {} }), files);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,8 @@ function fakeDOM() {
|
||||
this.children.push(node);
|
||||
}
|
||||
}
|
||||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||||
focus() { global.document.activeElement = this; }
|
||||
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
@@ -65,7 +67,9 @@ function fakeDOM() {
|
||||
}
|
||||
|
||||
const HOST = { autotag_model_type: 'wd14-swinv2', autotag_existing: 'skip', autotag_general_threshold: 0.35,
|
||||
autotag_character_threshold: 0.85, autotag_suggestion_limit: 10, autotag_template: 'Describe the image as tags.' };
|
||||
autotag_character_threshold: 0.85, autotag_suggestion_limit: 10, autotag_builtin_instructions: 'Built-in tags.',
|
||||
autotag_instructions: [{ id: '0123456789ab', name: 'Describe', text: 'Describe the image as tags.' }],
|
||||
autotag_default_instructions: { gguf: '0123456789ab', hf: 'builtin' } };
|
||||
const entry = (label, size, extra = {}) => ({ name: label, label, size_label: size, downloaded: true, loaded: false, ...extra });
|
||||
const MODELS = {
|
||||
success: true,
|
||||
@@ -124,7 +128,8 @@ test('the section keeps the concept surface, rows, labels and actions in order',
|
||||
for (const option of ['Skip already tagged', 'Merge with existing', 'Replace after review']) assert.ok(body.includes(option) && SOURCE.includes(`'${option}'`));
|
||||
assert.ok(body.includes('<h2>Auto-tagging</h2>') && SOURCE.includes("'h2', 'Auto-tagging'"));
|
||||
assert.ok(body.includes('One tagging configuration for images and prompt-library actions. Every result can be reviewed before applying.'));
|
||||
assert.ok(body.includes('LLM tag template') && SOURCE.includes("'LLM tag template'"));
|
||||
// D203 (#431): Saved instructions replace the concept's template field.
|
||||
assert.ok(body.includes('LLM tag template') && !SOURCE.includes("'LLM tag template'"));
|
||||
assert.ok(body.includes('"Preview tagging review", "tag-review", "", "ca-primary"'));
|
||||
// Prototype copy is never shipped.
|
||||
for (const sample of ['Simulate download', 'Simulate unload', 'Ollama']) assert.ok(!SOURCE.includes(sample), sample);
|
||||
@@ -167,27 +172,25 @@ test('controls draft their host keys; numbers as numbers, other text as typed',
|
||||
const { SETTING_KEYS, coerceNumber, draftValue } = await tagging;
|
||||
assert.deepEqual(SETTING_KEYS, { 'tag-model': 'autotag_model_type', 'tag-existing': 'autotag_existing',
|
||||
'threshold-general': 'autotag_general_threshold', 'threshold-character': 'autotag_character_threshold',
|
||||
'tag-suggestions': 'autotag_suggestion_limit', 'tag-template': 'autotag_template' });
|
||||
'tag-suggestions': 'autotag_suggestion_limit' });
|
||||
assert.deepEqual(['0.4', ' 1.5 ', '15', '', 'abc', '1e-1'].map(coerceNumber), [0.4, 1.5, 15, '', 'abc', 0.1]);
|
||||
assert.equal(draftValue('autotag_suggestion_limit', '15'), 15);
|
||||
assert.equal(draftValue('autotag_template', ' 12 '), ' 12 ');
|
||||
assert.equal(draftValue('autotag_existing', ' 12 '), ' 12 ');
|
||||
assert.equal(draftValue('autotag_existing', undefined), '');
|
||||
const { dom, drafts, scope, byId } = await mounted();
|
||||
try {
|
||||
assert.equal(byId('tag-model').value, 'wd14-swinv2');
|
||||
assert.equal(byId('threshold-general').value, '0.35');
|
||||
assert.equal(byId('tag-template').value, HOST.autotag_template);
|
||||
assert.deepEqual([byId('threshold-general').min, byId('threshold-general').max, byId('threshold-general').step], ['0', '1', '0.05']);
|
||||
assert.deepEqual([byId('tag-suggestions').min, byId('tag-suggestions').max], ['1', '100']);
|
||||
byId('tag-existing').value = 'merge'; await byId('tag-existing').dispatch('change');
|
||||
byId('threshold-general').value = '0.4'; await byId('threshold-general').dispatch('change');
|
||||
byId('tag-suggestions').value = '15'; await byId('tag-suggestions').dispatch('change');
|
||||
byId('tag-template').value = 'Tags only.'; await byId('tag-template').dispatch('input');
|
||||
assert.deepEqual(drafts.changes('host'), { autotag_existing: 'merge', autotag_general_threshold: 0.4,
|
||||
autotag_suggestion_limit: 15, autotag_template: 'Tags only.' });
|
||||
autotag_suggestion_limit: 15 });
|
||||
// Every control names its stored key, so a refused save highlights it (D92).
|
||||
for (const id of Object.keys(SETTING_KEYS)) assert.equal(byId(id).dataset.key, SETTING_KEYS[id]);
|
||||
assert.equal(byId('tag-template').dataset.setting, 'tag-template');
|
||||
assert.equal(byId('tag-template'), undefined);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
@@ -201,7 +204,8 @@ test('opening the section reads the model status once; the select waits for it',
|
||||
assert.deepEqual(byId('tag-model').options.map(option => option.textContent),
|
||||
['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']);
|
||||
assert.deepEqual(surface.children.map(node => node.className),
|
||||
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'field', 'row-actions', 'operation']);
|
||||
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'setting-block', 'setting-row',
|
||||
'setting-row', 'small instruction-reset', 'row-actions', 'operation']);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
const failed = await mounted({ modelsFail: true });
|
||||
try {
|
||||
@@ -302,7 +306,9 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
|
||||
assert.deepEqual(taggingPreset(HOST), { model: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85,
|
||||
suggestion_limit: 10 } });
|
||||
assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'gguf' }), { model: 'gguf',
|
||||
options: { instructions: HOST.autotag_template, suggestion_limit: 10 } });
|
||||
options: { instructions: 'Describe the image as tags.', suggestion_limit: 10 } });
|
||||
// D205 (#431): each language-model engine reads its own drafted default; Built-in's text comes from the server.
|
||||
assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'hf' }).options.instructions, 'Built-in tags.');
|
||||
assert.equal(draftProblem(HOST), null);
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_general_threshold: 1.5 }),
|
||||
{ key: 'autotag_general_threshold', message: 'General-tag confidence must be between 0 and 1.' });
|
||||
@@ -319,15 +325,17 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
|
||||
assert.equal(taggingPreset({ ...HOST, autotag_general_threshold: 0.1 + 0.2 }).options.general_threshold, 0.3);
|
||||
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 2.5 }).key, 'autotag_suggestion_limit');
|
||||
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 0 }, { limit: false }), null);
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: ' ' }),
|
||||
{ key: 'autotag_template', message: 'LLM tag template must be non-empty text.' });
|
||||
assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: 'x'.repeat(4001) }),
|
||||
{ key: 'autotag_template', message: 'LLM tag template must be at most 4000 characters.' });
|
||||
// #431: a saved entry the server would refuse holds back a language-model run, with the server's sentence.
|
||||
const entries = list => ({ ...HOST, autotag_model_type: 'hf', autotag_instructions: list });
|
||||
assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: ' ' }])),
|
||||
{ key: 'autotag_instructions', message: 'Saved instructions: entry 1 text must be non-empty text.' });
|
||||
assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: 'x'.repeat(4001) }])),
|
||||
{ key: 'autotag_instructions', message: 'Saved instructions: entry 1 text must be at most 4000 characters.' });
|
||||
// Characters are code points, as Python's len counts them: 4000 emoji (8000 UTF-16 units) are allowed.
|
||||
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4000) }), null);
|
||||
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4001) }).key, 'autotag_template');
|
||||
// A WD14 run never reads the template, nor a language model the thresholds.
|
||||
assert.equal(draftProblem({ ...HOST, autotag_template: '' }), null);
|
||||
assert.equal(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4000) }])), null);
|
||||
assert.equal(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4001) }])).key, 'autotag_instructions');
|
||||
// A WD14 run never reads the instructions, nor a language model the thresholds.
|
||||
assert.equal(draftProblem({ ...HOST, autotag_instructions: [{ id: '0123456789ab', name: '', text: '' }] }), null);
|
||||
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'gguf', autotag_general_threshold: 7 }), null);
|
||||
|
||||
const none = await mounted({ image: null });
|
||||
@@ -372,9 +380,12 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5
|
||||
['Settings', 'General 0.35 · Character 0.85 · up to 10 tags per image'], ['Backup', 'Create and verify before changes']]);
|
||||
const llm = { ...HOST, autotag_model_type: 'gguf' };
|
||||
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' }, estimate_seconds: null,
|
||||
estimate_device: null, suggestion_limit: 4 }, llm).slice(-3),
|
||||
estimate_device: null, suggestion_limit: 4 }, llm).slice(-4),
|
||||
[['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'],
|
||||
['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
|
||||
['Instructions', 'Describe'], ['Settings', 'Up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
|
||||
// D205 (#431): HF shows its own default by name.
|
||||
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'hf' } }, { ...llm, autotag_model_type: 'hf' }).find(([term]) => term === 'Instructions'),
|
||||
['Instructions', 'Built-in']);
|
||||
assert.equal(batchGrid({}, HOST)[2][1], '0');
|
||||
assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85');
|
||||
assert.equal(existingLabel('odd'), 'odd');
|
||||
@@ -382,7 +393,7 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5
|
||||
options: { general_threshold: 0.35, character_threshold: 0.85 } });
|
||||
// #369: a threshold a hair off the grid is sent snapped to it.
|
||||
assert.equal(batchBody({ ...HOST, autotag_character_threshold: 0.1 + 0.2 }).options.character_threshold, 0.3);
|
||||
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: HOST.autotag_template } });
|
||||
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: 'Describe the image as tags.' } });
|
||||
assert.equal(batchCloseNote(PLAN), null);
|
||||
assert.equal(batchCloseNote({ ...PLAN, review_required: true }), REPLACE_NOTE);
|
||||
assert.equal(REPLACE_NOTE, 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.');
|
||||
|
||||
Reference in New Issue
Block a user