* fix(library): close recipe editor follow-ups from the native editor slice - After restored storage, Create text revision and Archive are disabled so they cannot re-read an old numeric ID; only Reload Library remains. - The storage-changed exit in Edit, revision and Add negative states that Reload Library closes the dialog and discards the entries (the concept has no discard confirmation, so the copy stays inline). - When Save becomes disabled after an unconfirmed write or a conflict, focus moves to the recovery action instead of falling to the body; the archive confirmation does the same. - A reuse toast only says the entered parent metadata was not applied when the controller found it differs from the stored metadata. - Prompt history opens on its first negative variant, as the concept does, so the heading Send starts enabled. - "1 linked image" is singular on cards, variant lanes and the embedded gallery count. * fix(library): treat a blank category as equal to a stored uncategorized one A reuse that left the category blank sent "Uncategorized" and compared it with the stored NULL, so the toast claimed the entered metadata was not applied. Both sides now normalize blank and "Uncategorized" the same way before comparing; a genuinely different category still discloses it. * test(coverage): raise frontend coverage floors after editor follow-ups * fix(library): singular counts for one item and honest discard copy - One shared countOf/noun helper (web/js/workspace/plural.mjs) now words linked images, saved negative variants (card chip and history count), distinct recorded runs and the Images toolbar count in the singular for exactly one; every other count keeps its existing wording. - The restored-storage status says "Leaving this dialog discards your entries." because Cancel, the close button and Escape discard the draft too, not only Reload Library. - The new helper gets its coverage floor; no other floor changes. * fix(library): open every prompt history visit on its first negative The concept resets to the first negative variant on every navigation to a prompt (navigate: state.variant = 0) and selects another pair only right after a save names it. The history no longer remembers a clicked variant or an explicit All images choice across visits: the per-visit memory is replaced by a one-shot saved selection that the next history mount (or the same visit's reconciliation) consumes, and a verified pair outside the loaded page is kept only for that saved selection. Within a visit, clicks and All images behave as before.
810 lines
44 KiB
JavaScript
810 lines
44 KiB
JavaScript
// Run with: node --test tests/js/workspace-library-editor-state.test.js
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { createApiClient } = require('../../web/js/api-client.js');
|
|
|
|
// Only an absent module is tolerated, so RED is an assertion failure, not a crash.
|
|
const source = import('../../web/js/workspace/library-editor-state.mjs').catch(error => {
|
|
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
|
return {};
|
|
});
|
|
|
|
const EPOCH = '11111111-1111-4111-8111-111111111111';
|
|
const RESTORED = '22222222-2222-4222-8222-222222222222';
|
|
const TOKEN = 'a'.repeat(64);
|
|
const TOKEN2 = 'b'.repeat(64);
|
|
|
|
const flush = () => new Promise(resolve => setImmediate(resolve));
|
|
async function settle(times = 6) { for (let i = 0; i < times; i++) await flush(); }
|
|
function deferred() {
|
|
let resolve, reject;
|
|
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
function response(status, body) {
|
|
return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body };
|
|
}
|
|
|
|
/** Real createApiClient over a fetch whose answers come from handler(call). */
|
|
function harness(handler) {
|
|
const calls = [];
|
|
const fetch = (url, init = {}) => {
|
|
const call = { url, method: init.method, init, body: init.body === undefined ? undefined : JSON.parse(init.body), aborted: false };
|
|
calls.push(call);
|
|
return new Promise((resolve, reject) => {
|
|
init.signal?.addEventListener('abort', () => {
|
|
call.aborted = true;
|
|
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
});
|
|
Promise.resolve().then(() => handler(call)).then(resolve, reject);
|
|
});
|
|
};
|
|
return { api: createApiClient(fetch), calls, writes: () => calls.filter(call => call.method !== 'GET') };
|
|
}
|
|
|
|
const positive = (overrides = {}) => ({
|
|
id: 7, text: 'First line\nsecond line', title: null, category: 'Studio', tags: ['soft', 'warm'],
|
|
rating: 3, notes: null, edit_token: TOKEN, created_at: '2026-01-01 00:00:00', variant_count: 1, image_count: 2,
|
|
...overrides,
|
|
});
|
|
const variant = (id, negative = 'blurry') => ({
|
|
id, negative_text: negative, category: null, title: null, archived: false,
|
|
explicit_empty: negative === '', image_count: 1, tags: [],
|
|
});
|
|
const pair = (overrides = {}) => ({
|
|
id: 31, positive_prompt_id: 7, negative_prompt_id: 12, title: null, category: null, rating: null, notes: null,
|
|
created_at: '', updated_at: '', last_used_at: null, run_count: 4, archived: false,
|
|
positive_text: 'First line\nsecond line', negative_text: 'blurry', image_count: 2, tags: [],
|
|
...overrides,
|
|
});
|
|
const detail = (epoch = EPOCH, owner = positive(), variants = [variant(31)]) => ({
|
|
success: true, library_epoch: epoch, positive: { ...owner, archived_variant_count: 0, previews: [] },
|
|
variants, unassigned_image_count: 0, variant_total: variants.length, variants_has_more: false, variants_next_cursor: null,
|
|
});
|
|
const options = (epoch = EPOCH) => ({ success: true, library_epoch: epoch, categories: ['Studio', 'Portrait'] });
|
|
const recipe = (overrides = {}) => ({
|
|
success: true, library_epoch: EPOCH, positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' }),
|
|
pair: pair({ id: 40, positive_prompt_id: 9, positive_text: 'a portrait', negative_text: '', negative_prompt_id: null }),
|
|
positive_created: true, pair_created: true, parent_metadata_applied: true, ...overrides,
|
|
});
|
|
|
|
/** Answer the standard Library reads; `write` answers anything else. */
|
|
function library({ epoch = EPOCH, owner = positive(), variants = [variant(31)], selected = pair(), write } = {}) {
|
|
return call => {
|
|
const path = call.url.split('?')[0];
|
|
if (call.method === 'GET' && path === '/prompt_manager/library/options') return response(200, options(epoch));
|
|
if (call.method === 'GET' && path === `/prompt_manager/library/${owner.id}`) return response(200, detail(epoch, owner, variants));
|
|
if (call.method === 'GET' && path === `/prompt_manager/pairs/${selected.id}`) return response(200, { success: true, pair: selected });
|
|
if (write) return write(call);
|
|
throw new Error(`Unexpected ${call.method} ${call.url}`);
|
|
};
|
|
}
|
|
/** Reads for the owner a create receipt returns. */
|
|
const created = (write, epoch = EPOCH) => library({ epoch, owner: recipe().positive, selected: recipe().pair, write });
|
|
|
|
test('new prompt from Images waits for a fresh options epoch', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const read = deferred();
|
|
const committed = [], reconciled = [];
|
|
const server = harness(created(() => response(200, recipe())));
|
|
const original = server.api.get;
|
|
let first = true;
|
|
server.api.get = (url, init) => {
|
|
if (first) { first = false; return read.promise.then(() => original(url, init)); }
|
|
return original(url, init);
|
|
};
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c), onReconciled: r => reconciled.push(r) });
|
|
const initialization = controller.initialize();
|
|
assert.equal(controller.state.phase, 'initializing');
|
|
controller.setField('title', ' Portrait ');
|
|
controller.setField('positive_text', 'a portrait');
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 0, 'pending initialization cannot save');
|
|
read.resolve();
|
|
await initialization;
|
|
const request = server.calls[0];
|
|
assert.equal(request.url, '/prompt_manager/library/options');
|
|
assert.equal(request.init.cache, 'no-store');
|
|
assert.equal(controller.state.phase, 'ready');
|
|
assert.equal(controller.state.draft.title, ' Portrait ', 'typing survives the pending read');
|
|
assert.deepEqual(controller.state.categories, ['Studio', 'Portrait']);
|
|
await controller.submit();
|
|
const [write] = server.writes();
|
|
assert.equal(write.method, 'POST');
|
|
assert.equal(write.url, '/prompt_manager/library');
|
|
const sentBody = write.body;
|
|
assert.equal(sentBody.expected_epoch, EPOCH);
|
|
assert.deepEqual(sentBody, {
|
|
expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '',
|
|
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' },
|
|
});
|
|
assert.equal(controller.state.phase, 'complete');
|
|
assert.equal(committed.length, 1);
|
|
assert.equal(committed[0].kind, 'create');
|
|
assert.equal(reconciled.length, 1);
|
|
assert.equal(reconciled[0].pair.id, 40);
|
|
assert.equal(reconciled[0].detail.positive.id, 9);
|
|
assert.equal(controller.state.receipt.pair.id, 40);
|
|
assert.equal(controller.state.outcome.metadataNotApplied, false);
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 1, 'submit once');
|
|
controller.dispose();
|
|
});
|
|
|
|
test('failed initialization retries only the read and retains draft', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let fail = 2;
|
|
const server = harness(call => {
|
|
if (fail-- > 0) return fail === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, library_epoch: 'not-a-uuid', categories: [] });
|
|
return library()(call);
|
|
});
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
|
const pending = controller.initialize();
|
|
controller.setField('notes', 'kept\n');
|
|
await pending;
|
|
assert.equal(controller.state.phase, 'initialization-error');
|
|
await controller.submit();
|
|
await controller.retryInitialization();
|
|
assert.equal(controller.state.phase, 'initialization-error', 'invalid epoch is not ready');
|
|
await controller.retryInitialization();
|
|
assert.equal(controller.state.phase, 'ready');
|
|
assert.equal(controller.state.draft.notes, 'kept\n');
|
|
assert.equal(controller.state.error, null);
|
|
assert.equal(server.writes().length, 0);
|
|
assert.ok(server.calls.every(call => call.method === 'GET' && call.url === '/prompt_manager/library/options'));
|
|
assert.equal(server.calls.length, 3);
|
|
controller.dispose();
|
|
});
|
|
|
|
test('late initialization cannot enable a replacement dialog', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const answers = [deferred(), deferred(), deferred()];
|
|
let index = 0;
|
|
const server = harness(() => answers[index++].promise);
|
|
const old = createLibraryEditorController({ api: server.api, mode: 'new' });
|
|
const oldInit = old.initialize();
|
|
old.dispose();
|
|
assert.equal(server.calls[0].aborted, true, 'disposal aborts the read');
|
|
assert.equal(old.state.phase, 'disposed');
|
|
const replacement = createLibraryEditorController({ api: server.api, mode: 'new' });
|
|
const replacementInit = replacement.initialize();
|
|
await settle();
|
|
answers[0].resolve(response(200, options(RESTORED)));
|
|
await oldInit;
|
|
await settle();
|
|
assert.equal(replacement.state.phase, 'initializing', 'an older dialog cannot enable this one');
|
|
assert.equal(replacement.state.epoch, null);
|
|
// A retried read supersedes the earlier attempt inside the same dialog too.
|
|
answers[1].reject(new TypeError('offline'));
|
|
await replacementInit;
|
|
assert.equal(replacement.state.phase, 'initialization-error');
|
|
const retry = replacement.retryInitialization();
|
|
await settle();
|
|
answers[2].resolve(response(200, options(EPOCH)));
|
|
await retry;
|
|
assert.equal(replacement.state.epoch, EPOCH);
|
|
assert.equal(replacement.state.phase, 'ready');
|
|
replacement.dispose();
|
|
});
|
|
|
|
test('restore after initialization rejects the old New submission', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let epoch = EPOCH;
|
|
const server = harness(call => created(write => write.body.expected_epoch === RESTORED
|
|
? response(200, recipe({ library_epoch: RESTORED }))
|
|
: response(409, { success: false, code: 'storage-changed', error: 'Prompt storage was restored; refresh the library', library_epoch: RESTORED }), epoch)(call));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
|
await controller.initialize();
|
|
controller.setField('title', 'Portrait');
|
|
controller.setField('positive_text', 'a portrait');
|
|
epoch = RESTORED;
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'storage-changed');
|
|
assert.equal(controller.state.error.code, 'storage-changed');
|
|
assert.equal(controller.state.draft.title, 'Portrait');
|
|
assert.equal(controller.state.epoch, EPOCH, 'the error response epoch is never borrowed');
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 1, 'Save is disabled after storage changed');
|
|
await controller.reloadOwner({ keepDraft: true });
|
|
assert.equal(controller.state.phase, 'ready');
|
|
assert.equal(controller.state.epoch, RESTORED);
|
|
assert.equal(controller.state.reviewRequired, true);
|
|
assert.equal(controller.state.draft.positive_text, 'a portrait');
|
|
assert.equal(server.writes().length, 1, 'reload never replays the write');
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 2);
|
|
assert.equal(server.writes()[1].body.expected_epoch, RESTORED);
|
|
assert.equal(controller.state.phase, 'complete');
|
|
controller.dispose();
|
|
});
|
|
|
|
test('editor recovery preserves hyphenated ApiError codes', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const errors = [], phases = [];
|
|
for (const [code, extra] of [['metadata-conflict', { positive: positive({ edit_token: TOKEN2 }) }], ['storage-changed', {}], ['other-conflict', {}], [null, {}]]) {
|
|
const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) }));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
controller.setField('rating', 5);
|
|
await controller.submit();
|
|
phases.push(controller.state.phase);
|
|
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
|
|
assert.equal(controller.state.draft.rating, 5, 'draft preserved');
|
|
assert.equal(controller.state.receipt, null);
|
|
controller.dispose();
|
|
}
|
|
assert.deepEqual(errors.map(e => e.code), ['metadata-conflict', 'storage-changed']);
|
|
assert.equal(errors[0].status, 409);
|
|
assert.deepEqual(phases, ['conflict', 'storage-changed', 'write-error', 'write-error'], 'an unrelated 409 is generic');
|
|
});
|
|
|
|
test('lost save response never manufactures a receipt', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const committed = [];
|
|
let lose = true;
|
|
const reused = recipe({ positive_created: false, pair_created: false, parent_metadata_applied: false });
|
|
const server = harness(created(() => {
|
|
if (lose) { lose = false; return Promise.reject(new TypeError('Failed to fetch')); }
|
|
return response(200, reused);
|
|
}));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: commit => committed.push(commit) });
|
|
await controller.initialize();
|
|
controller.setField('title', 'Portrait');
|
|
controller.setField('positive_text', 'a portrait');
|
|
controller.setField('category', 'Uncategorized');
|
|
await controller.submit();
|
|
assert.equal(controller.state.receipt, null);
|
|
assert.equal(controller.state.phase, 'unknown-outcome');
|
|
assert.equal(committed.length, 0);
|
|
assert.equal(server.writes().length, 1, 'no automatic retry');
|
|
await settle();
|
|
assert.equal(server.writes().length, 1);
|
|
controller.setField('title', 'Changed after the loss');
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 1, 'Save cannot replace the unconfirmed write');
|
|
await controller.retryUnconfirmed();
|
|
assert.equal(server.writes().length, 2);
|
|
assert.deepEqual(server.writes()[1].body, server.writes()[0].body, 'identical payload');
|
|
assert.equal(controller.state.phase, 'complete');
|
|
assert.equal(controller.state.receipt.positive_created, false);
|
|
assert.equal(controller.state.outcome.metadataNotApplied, false, 'stored metadata equals the submitted draft');
|
|
assert.equal(committed.length, 1);
|
|
controller.dispose();
|
|
});
|
|
|
|
test('reused retry discloses unapplied metadata only when it differs', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let lose = true;
|
|
const other = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
|
|
positive: positive({ id: 9, text: 'a portrait', title: 'Someone else', tags: ['x'] }) });
|
|
const server = harness(created(() => {
|
|
if (lose) { lose = false; return Promise.reject(new TypeError('Failed to fetch')); }
|
|
return response(200, other);
|
|
}));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
|
await controller.initialize();
|
|
controller.setField('title', 'Portrait');
|
|
controller.setField('positive_text', 'a portrait');
|
|
await controller.submit();
|
|
await controller.retryUnconfirmed();
|
|
assert.equal(controller.state.phase, 'complete');
|
|
assert.equal(controller.state.outcome.metadataNotApplied, true);
|
|
assert.equal(controller.state.outcome.pairCreated, true);
|
|
assert.equal(controller.state.outcome.positiveCreated, false);
|
|
controller.dispose();
|
|
});
|
|
|
|
test('confirmed save refresh retry is read only', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let failReads = 1;
|
|
const committed = [], reconciled = [];
|
|
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) };
|
|
const base = library({ write: () => response(200, receipt) });
|
|
const server = harness(call => {
|
|
if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline'));
|
|
return base(call);
|
|
});
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
|
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
|
|
await controller.initialize();
|
|
controller.setField('rating', null);
|
|
controller.setField('notes', 'keep\ntext');
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'saved-refresh-error');
|
|
assert.equal(controller.state.receipt.positive.edit_token, TOKEN2);
|
|
assert.equal(committed.length, 1);
|
|
assert.equal(committed[0].kind, 'metadata');
|
|
const writeCalls = server.writes();
|
|
await controller.submit();
|
|
await controller.retryReconciliation();
|
|
assert.equal(writeCalls.length, 1); // retryReconciliation only GETs
|
|
assert.equal(server.writes().length, 1);
|
|
assert.equal(controller.state.phase, 'complete');
|
|
assert.equal(reconciled.length, 1);
|
|
assert.equal(reconciled[0].commit, committed[0]);
|
|
assert.equal(reconciled[0].pair, null);
|
|
assert.equal(reconciled[0].options.library_epoch, EPOCH);
|
|
controller.dispose();
|
|
});
|
|
|
|
test('legacy title fallback is not an edit', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) };
|
|
const server = harness(library({ write: () => response(200, receipt) }));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
assert.equal(controller.state.draft.title, 'First line', 'displayed fallback seeds the input');
|
|
assert.equal(controller.state.baseline.title, null, 'stored title stays distinct');
|
|
assert.equal(controller.state.draft.tags, 'soft, warm');
|
|
assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative');
|
|
controller.setField('rating', null);
|
|
controller.setField('notes', 'keep\ntext');
|
|
controller.setField('tags', 'warm, soft,');
|
|
controller.setField('category', ' Studio ');
|
|
await controller.submit();
|
|
const [write] = server.writes();
|
|
assert.equal(write.method, 'PATCH');
|
|
assert.equal(write.url, '/prompt_manager/library/7');
|
|
const sentBody = write.body;
|
|
assert.equal(sentBody.expected_epoch, EPOCH);
|
|
assert.equal(sentBody.if_match, TOKEN);
|
|
assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' });
|
|
|
|
const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await unchanged.initialize();
|
|
unchanged.setField('notes', '');
|
|
await unchanged.submit();
|
|
assert.equal(server.writes().length, 1, 'unchanged edit submits nothing');
|
|
assert.equal(unchanged.state.phase, 'complete');
|
|
assert.equal(unchanged.state.receipt, null);
|
|
|
|
const edited = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await edited.initialize();
|
|
edited.setField('title', ' Studio portrait ');
|
|
edited.setField('category', '');
|
|
await edited.submit();
|
|
assert.deepEqual(server.writes()[1].body.metadata, { title: 'Studio portrait', category: '' }, 'Edit keeps a blank category blank');
|
|
for (const item of [controller, unchanged, edited]) item.dispose();
|
|
});
|
|
|
|
test('existing-parent forms read detail, options and the exact selected pair fresh', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const many = Array.from({ length: 40 }, (_, i) => variant(100 - i));
|
|
const outside = pair({ id: 31, run_count: 9 });
|
|
const server = harness(library({ variants: many, selected: outside }));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
assert.equal(controller.state.phase, 'ready');
|
|
assert.equal(controller.state.pair.id, 31);
|
|
assert.equal(controller.state.pair.run_count, 9);
|
|
assert.ok(server.calls.every(call => call.init.cache === 'no-store'));
|
|
assert.deepEqual(server.calls.map(call => call.url.split('?')[0]).sort(),
|
|
['/prompt_manager/library/7', '/prompt_manager/library/options', '/prompt_manager/pairs/31']);
|
|
controller.dispose();
|
|
|
|
for (const broken of [
|
|
library({ selected: pair({ positive_prompt_id: 8 }) }),
|
|
library({ selected: pair({ positive_text: 'other text' }) }),
|
|
call => call.url.startsWith('/prompt_manager/library/options') ? response(200, options(RESTORED)) : library()(call),
|
|
library({ owner: positive({ id: 7, edit_token: 'short' }) }),
|
|
]) {
|
|
const reads = harness(broken);
|
|
const rejected = createLibraryEditorController({ api: reads.api, mode: 'revision', positiveId: 7, pairId: 31 });
|
|
await rejected.initialize();
|
|
assert.equal(rejected.state.phase, 'initialization-error');
|
|
await rejected.submit();
|
|
assert.equal(reads.writes().length, 0);
|
|
rejected.dispose();
|
|
}
|
|
const wrongOwner = harness(call => call.url.startsWith('/prompt_manager/library/7') ? response(200, detail(EPOCH, positive({ id: 8 }))) : library()(call));
|
|
const mismatch = createLibraryEditorController({ api: wrongOwner.api, mode: 'negative', positiveId: 7 });
|
|
await mismatch.initialize();
|
|
assert.equal(mismatch.state.phase, 'initialization-error');
|
|
mismatch.dispose();
|
|
assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'metadata' }), TypeError);
|
|
assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'new', positiveId: 7 }), TypeError);
|
|
assert.throws(() => createLibraryEditorController({ api: server.api, mode: 'archive', positiveId: 7 }), TypeError);
|
|
});
|
|
|
|
test('metadata conflict reload keeps changed fields for review and never replays', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let owner = positive();
|
|
const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2
|
|
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } })
|
|
: response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
controller.setField('rating', '5');
|
|
owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 });
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'conflict');
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 1);
|
|
await controller.reloadOwner({ keepDraft: true });
|
|
assert.equal(controller.state.phase, 'ready');
|
|
assert.equal(controller.state.reviewRequired, true);
|
|
assert.equal(controller.state.draft.rating, 5, 'changed field kept');
|
|
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
|
|
assert.equal(controller.state.draft.notes, 'other writer');
|
|
assert.equal(server.writes().length, 1);
|
|
await controller.submit();
|
|
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } });
|
|
assert.equal(controller.state.phase, 'complete');
|
|
controller.dispose();
|
|
|
|
const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await discard.initialize();
|
|
discard.setField('rating', 1);
|
|
await discard.reloadOwner({ keepDraft: false });
|
|
assert.equal(discard.state.draft.rating, 3);
|
|
assert.equal(discard.state.reviewRequired, false);
|
|
discard.dispose();
|
|
});
|
|
|
|
test('metadata unknown outcome retry reloads the owner instead of replaying', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let owner = positive();
|
|
const server = harness(call => library({ owner, write: () => {
|
|
owner = positive({ rating: 5, edit_token: TOKEN2 });
|
|
return Promise.reject(new TypeError('Failed to fetch'));
|
|
} })(call));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
controller.setField('rating', 5);
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'unknown-outcome');
|
|
await controller.retryUnconfirmed();
|
|
assert.equal(server.writes().length, 1, 'a metadata retry requires review, not a replay');
|
|
assert.equal(controller.state.phase, 'ready');
|
|
assert.equal(controller.state.reviewRequired, true);
|
|
assert.equal(controller.state.owner.edit_token, TOKEN2);
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 1, 'the applied edit is now unchanged');
|
|
assert.equal(controller.state.phase, 'complete');
|
|
controller.dispose();
|
|
});
|
|
|
|
test('pair archive and restore patch the selected pair and reconcile', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let selected = pair();
|
|
const committed = [], reconciled = [];
|
|
const server = harness(call => library({ selected, write: write => {
|
|
selected = pair({ archived: write.body.archived });
|
|
return response(200, { success: true, pair: selected });
|
|
} })(call));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
|
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
|
|
await controller.initialize();
|
|
controller.setField('notes', 'unsaved');
|
|
await controller.setArchive(true);
|
|
const [write] = server.writes();
|
|
assert.equal(write.method, 'PATCH');
|
|
assert.equal(write.url, '/prompt_manager/pairs/31');
|
|
assert.deepEqual(write.body, { archived: true });
|
|
assert.equal(committed[0].kind, 'archive-pair');
|
|
assert.equal(controller.state.phase, 'complete');
|
|
assert.equal(reconciled[0].pair.archived, true);
|
|
controller.dispose();
|
|
|
|
const restore = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
|
onCommitted: commit => committed.push(commit) });
|
|
await restore.initialize();
|
|
assert.equal(restore.state.pair.archived, true);
|
|
await restore.setArchive(false);
|
|
assert.equal(committed[1].kind, 'restore-pair');
|
|
assert.deepEqual(server.writes()[1].body, { archived: false });
|
|
restore.dispose();
|
|
|
|
let optionReads = 0;
|
|
const restored = harness(call => call.url === '/prompt_manager/library/options' && ++optionReads > 1
|
|
? response(200, options(RESTORED)) : library()(call));
|
|
const guarded = createLibraryEditorController({ api: restored.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await guarded.initialize();
|
|
await guarded.setArchive(true);
|
|
assert.equal(guarded.state.phase, 'storage-changed', 'a restored database cannot archive a reused pair ID');
|
|
assert.equal(restored.writes().length, 0);
|
|
guarded.dispose();
|
|
|
|
const none = harness(library());
|
|
const noPair = createLibraryEditorController({ api: none.api, mode: 'metadata', positiveId: 7 });
|
|
await noPair.initialize();
|
|
assert.equal(noPair.state.pair, null);
|
|
assert.equal(noPair.state.draft.negative_text, '');
|
|
await noPair.setArchive(true);
|
|
assert.equal(none.writes().length, 0);
|
|
assert.match(noPair.state.error.message, /saved negative/);
|
|
noPair.dispose();
|
|
});
|
|
|
|
test('add negative sends only the negative under the loaded token', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const archived = recipe({ positive: positive(), pair: pair({ id: 55, archived: true, negative_text: '' }),
|
|
positive_created: false, pair_created: false, parent_metadata_applied: false });
|
|
const server = harness(library({ selected: pair({ id: 55, archived: true, negative_text: '' }), write: () => response(200, archived) }));
|
|
const committed = [];
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'negative', positiveId: 7, onCommitted: c => committed.push(c) });
|
|
await controller.initialize();
|
|
assert.ok(server.calls.every(call => !call.url.startsWith('/prompt_manager/library/options')), 'Add negative needs only the owner read');
|
|
await controller.submit();
|
|
const [write] = server.writes();
|
|
assert.equal(write.url, '/prompt_manager/library/7/variants');
|
|
assert.deepEqual(write.body, { expected_epoch: EPOCH, if_match: TOKEN, negative_text: '' });
|
|
assert.equal(committed[0].kind, 'add-negative');
|
|
assert.equal(controller.state.outcome.pairArchived, true);
|
|
assert.equal(controller.state.outcome.metadataNotApplied, false);
|
|
controller.dispose();
|
|
|
|
const forged = harness(library({ write: () => response(200, { ...archived, positive: positive({ id: 8 }) }) }));
|
|
const unconfirmed = createLibraryEditorController({ api: forged.api, mode: 'negative', positiveId: 7 });
|
|
await unconfirmed.initialize();
|
|
await unconfirmed.submit();
|
|
assert.equal(unconfirmed.state.phase, 'unknown-outcome', 'an invalid receipt is not confirmation');
|
|
assert.equal(unconfirmed.state.receipt, null);
|
|
unconfirmed.dispose();
|
|
});
|
|
|
|
test('revision seeds text from the selected pair and sends Uncategorized for a blank category', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const server = harness(library({ owner: positive({ title: 'Portrait', category: null, notes: 'n' }), variants: [variant(30, 'first'), variant(31)], write: () => response(200, recipe()) }));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'revision', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
assert.equal(controller.state.draft.negative_text, 'blurry', 'never the first arbitrary negative');
|
|
assert.equal(controller.state.draft.positive_text, 'First line\nsecond line');
|
|
controller.setField('positive_text', 'First line\nrevised');
|
|
controller.setField('tags', ' a, b ,a,, ');
|
|
await controller.submit();
|
|
assert.deepEqual(server.writes()[0].body, {
|
|
expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry',
|
|
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' },
|
|
});
|
|
controller.dispose();
|
|
});
|
|
|
|
test('invalid drafts and server failures keep the draft without a receipt', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let status = 500;
|
|
const server = harness(library({ write: () => response(status, { success: false, code: null, error: 'Library command failed' }) }));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
|
await controller.initialize();
|
|
controller.setField('title', ' ');
|
|
controller.setField('positive_text', 'x');
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 0);
|
|
assert.equal(controller.state.phase, 'ready');
|
|
assert.match(controller.state.error.message, /Title/);
|
|
assert.throws(() => controller.setField('unknown', 'x'), TypeError);
|
|
assert.throws(() => controller.setField('rating', 7), TypeError);
|
|
controller.setField('title', 'T');
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'write-error');
|
|
assert.equal(controller.state.draft.title, 'T');
|
|
status = 504;
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'unknown-outcome', 'a gateway timeout cannot confirm either outcome');
|
|
controller.dispose();
|
|
controller.setField('title', 'ignored');
|
|
assert.equal(controller.state.phase, 'disposed');
|
|
});
|
|
|
|
test('disposal suppresses late write results without aborting the write but still reports the commit', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
const answer = deferred();
|
|
const committed = [], changes = [];
|
|
const server = harness(library({ write: () => answer.promise }));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c), onChange: s => changes.push(s) });
|
|
await controller.initialize();
|
|
controller.setField('title', 'T');
|
|
controller.setField('positive_text', 'x');
|
|
const saving = controller.submit();
|
|
await settle();
|
|
assert.equal(controller.state.phase, 'saving');
|
|
assert.ok(Object.isFrozen(controller.state));
|
|
await controller.submit();
|
|
assert.equal(server.writes().length, 1, 'duplicate submit is disabled');
|
|
controller.dispose();
|
|
assert.equal(server.writes()[0].aborted, false, 'abort cannot undo a write');
|
|
const count = changes.length, calls = server.calls.length;
|
|
answer.resolve(response(200, recipe()));
|
|
await saving;
|
|
await settle();
|
|
// A committed receipt still invalidates cached Library views after the dialog went away.
|
|
assert.equal(committed.length, 1);
|
|
assert.equal(committed[0].kind, 'create');
|
|
assert.equal(committed[0].receipt.positive.id, 9);
|
|
assert.equal(server.calls.length, calls, 'no reconciliation read after dispose');
|
|
assert.equal(changes.length, count);
|
|
assert.equal(controller.state.phase, 'disposed');
|
|
});
|
|
|
|
test('disposal during an unconfirmed save reports no commit', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
const answer = deferred();
|
|
const committed = [];
|
|
const server = harness(library({ write: () => answer.promise }));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new', onCommitted: c => committed.push(c) });
|
|
await controller.initialize();
|
|
controller.setField('title', 'T');
|
|
controller.setField('positive_text', 'x');
|
|
const saving = controller.submit();
|
|
await settle();
|
|
controller.dispose();
|
|
answer.resolve(response(200, { success: true }));
|
|
await saving;
|
|
await settle();
|
|
assert.equal(committed.length, 0, 'an invalid receipt is not a commit');
|
|
});
|
|
|
|
test('reconcileLibraryReceipt reads detail, options and the verified pair', async () => {
|
|
const { reconcileLibraryReceipt } = await source;
|
|
assert.equal(typeof reconcileLibraryReceipt, 'function', 'reconcile export');
|
|
const receipt = recipe({ positive: positive(), pair: pair() });
|
|
const server = harness(library());
|
|
const result = await reconcileLibraryReceipt(server.api, { kind: 'add-negative', receipt }, {});
|
|
assert.equal(result.detail.positive.id, 7);
|
|
assert.equal(result.pair.id, 31);
|
|
assert.ok(!result.detail.variants.some(item => item === result.pair), 'the verified pair stays separate');
|
|
assert.equal(server.writes().length, 0);
|
|
const restored = harness(library({ epoch: RESTORED }));
|
|
await assert.rejects(reconcileLibraryReceipt(restored.api, { kind: 'add-negative', receipt }, {}), error => error.code === 'storage-changed');
|
|
const moved = harness(library({ selected: pair({ positive_prompt_id: 8 }) }));
|
|
await assert.rejects(reconcileLibraryReceipt(moved.api, { kind: 'add-negative', receipt }, {}), TypeError);
|
|
await assert.rejects(reconcileLibraryReceipt(server.api, { kind: 'archive', receipt }, {}), TypeError);
|
|
});
|
|
|
|
test('unconfirmed pair archive retry is fenced against restored storage', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
|
let epoch = EPOCH;
|
|
const server = harness(call => library({ epoch, write: () => {
|
|
epoch = RESTORED;
|
|
return Promise.reject(new TypeError('Failed to fetch'));
|
|
} })(call));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
await controller.setArchive(true);
|
|
assert.equal(controller.state.phase, 'unknown-outcome');
|
|
assert.equal(server.writes().length, 1);
|
|
await controller.retryUnconfirmed();
|
|
assert.equal(server.writes().length, 1, 'a restored database cannot take the retried pair write');
|
|
assert.equal(controller.state.phase, 'storage-changed');
|
|
assert.equal(controller.state.error.code, 'storage-changed');
|
|
controller.dispose();
|
|
});
|
|
|
|
test('refreshPair re-reads the selected pair through the owner check and reports failures without changing it', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
let selected = pair();
|
|
let fail = null;
|
|
const server = harness(call => {
|
|
if (fail && call.method === 'GET' && call.url.startsWith('/prompt_manager/pairs/31')) return fail();
|
|
return library({ selected })(call);
|
|
});
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
assert.equal(typeof controller.refreshPair, 'function', 'refreshPair export');
|
|
await controller.initialize();
|
|
assert.equal(controller.state.pairRead, 'fresh');
|
|
|
|
selected = pair({ image_count: 5, run_count: 9 });
|
|
const pending = controller.refreshPair();
|
|
assert.equal(controller.state.pairRead, 'reading');
|
|
await pending;
|
|
assert.equal(controller.state.pairRead, 'fresh');
|
|
assert.equal(controller.state.pair.image_count, 5);
|
|
assert.equal(controller.state.pair.run_count, 9);
|
|
assert.equal(controller.state.pairReadError, null);
|
|
const pairReads = server.calls.filter(call => call.url.startsWith('/prompt_manager/pairs/31'));
|
|
assert.equal(pairReads.at(-1).init.cache, 'no-store');
|
|
|
|
for (const [name, answer] of [
|
|
['other owner text', () => response(200, { success: true, pair: pair({ image_count: 77, positive_text: 'someone else' }) })],
|
|
['other owner id', () => response(200, { success: true, pair: pair({ image_count: 77, positive_prompt_id: 8 }) })],
|
|
['failed read', () => response(500, { success: false, error: 'down' })],
|
|
['success false', () => response(200, { success: false, error: 'gone' })],
|
|
]) {
|
|
fail = answer;
|
|
await controller.refreshPair();
|
|
assert.equal(controller.state.pairRead, 'error', name);
|
|
assert.ok(controller.state.pairReadError instanceof Error, name);
|
|
assert.equal(controller.state.pair.image_count, 5, name);
|
|
assert.equal(controller.state.phase, 'ready', name);
|
|
}
|
|
assert.equal(server.writes().length, 0);
|
|
controller.dispose();
|
|
});
|
|
|
|
test('refreshPair is inert without a selected pair, while busy and after dispose', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
const server = harness(library());
|
|
const none = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7 });
|
|
await none.initialize();
|
|
const before = server.calls.length;
|
|
await none.refreshPair();
|
|
assert.equal(server.calls.length, before);
|
|
assert.equal(none.state.pairRead, 'none');
|
|
none.dispose();
|
|
const disposed = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await disposed.initialize();
|
|
disposed.dispose();
|
|
const count = server.calls.length;
|
|
await disposed.refreshPair();
|
|
assert.equal(server.calls.length, count);
|
|
});
|
|
|
|
test('reused pair outside first forty remains selected without loading every page', async () => {
|
|
const { reconcileLibraryReceipt } = await source;
|
|
const { libraryPromptRoles } = await import('../../web/js/workspace/library-state.mjs');
|
|
const page = Array.from({ length: 40 }, (_, index) => variant(200 - index, `negative ${200 - index}`));
|
|
const oldPair = pair({ id: 3, negative_text: 'the oldest saved negative', negative_prompt_id: 99 });
|
|
const server = harness(call => {
|
|
const path = call.url.split('?')[0];
|
|
if (call.method === 'GET' && path === '/prompt_manager/library/7') {
|
|
return response(200, { ...detail(EPOCH, positive(), page), variant_total: 85, variants_has_more: true, variants_next_cursor: 'c'.repeat(16) });
|
|
}
|
|
return library({ selected: oldPair })(call);
|
|
});
|
|
const receipt = recipe({ positive: positive(), pair: oldPair, positive_created: false, pair_created: false, parent_metadata_applied: false });
|
|
const result = await reconcileLibraryReceipt(server.api, { kind: 'add-negative', receipt }, {});
|
|
const detailResult = result.detail;
|
|
assert.equal(detailResult.variants.length, 40);
|
|
assert.equal(detailResult.variant_total, 85);
|
|
assert.ok(!detailResult.variants.some(item => item.id === oldPair.id), 'the reused pair is outside the bounded page');
|
|
assert.equal(libraryPromptRoles(detailResult, oldPair.id, result.pair).negative.text, oldPair.negative_text);
|
|
const variantCursorRequests = server.calls.filter(call => call.url.includes('variant_cursor'));
|
|
assert.equal(variantCursorRequests.length, 0);
|
|
assert.equal(server.writes().length, 0);
|
|
});
|
|
|
|
test('a refresh failed by restored storage cannot recover through retryReconciliation', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
let restored = false;
|
|
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'saved', edit_token: TOKEN2 }) };
|
|
const server = harness(call => library({ epoch: restored ? RESTORED : EPOCH, write: () => {restored = true; return response(200, receipt);} })(call));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
|
await controller.initialize();
|
|
controller.setField('notes', 'saved');
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'saved-refresh-error');
|
|
assert.equal(controller.state.error.code, 'storage-changed');
|
|
await controller.retryReconciliation();
|
|
assert.equal(controller.state.phase, 'saved-refresh-error', 'only an explicit Library reload can leave this state');
|
|
assert.equal(controller.state.error.code, 'storage-changed');
|
|
assert.equal(server.writes().length, 1);
|
|
controller.dispose();
|
|
});
|
|
|
|
test('a blank category reused against an uncategorized owner is not unapplied metadata; a different one is', async () => {
|
|
const { createLibraryEditorController } = await source;
|
|
for (const [stored, entered, expected] of [[null, '', false], ['', ' ', false], ['Uncategorized', '', false], [null, 'Uncategorized', false],
|
|
[null, 'Landscapes', true], ['Studio', '', true]]) {
|
|
const owner = positive({ id: 9, text: 'a portrait', title: 'Portrait', category: stored, tags: [], rating: null, notes: '' });
|
|
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false, positive: owner });
|
|
const server = harness(created(() => response(200, reused)));
|
|
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
|
await controller.initialize();
|
|
controller.setField('title', 'Portrait');
|
|
controller.setField('positive_text', 'a portrait');
|
|
controller.setField('category', entered);
|
|
await controller.submit();
|
|
assert.equal(controller.state.phase, 'complete');
|
|
assert.equal(controller.state.outcome.metadataNotApplied, expected, `stored ${stored} entered ${JSON.stringify(entered)}`);
|
|
controller.dispose();
|
|
}
|
|
});
|