The star is only for favorites (maintainer ruling 2026-10-28). The workspace no longer shows or edits a prompt rating: no Rating field in the Library editor, no stars on Library cards, no "Highest rated" sort on Images or the Library, and no rating column or rated-images total in System > Statistics. The editor never sends `rating`, so a workspace save keeps a stored rating as it is. A saved view or Library visit with the retired rating sort opens on Recently added. Data and API are unchanged; legacy pages keep their ratings. Recorded as deviation D153 (amends D14, D23, D96); concept comparisons map the concept through it.
854 lines
47 KiB
JavaScript
854 lines
47 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: [], notes: '' },
|
||
});
|
||
assert.equal(Object.hasOwn(sentBody.parent_metadata, 'rating'), false, 'D153: a new prompt sends no rating');
|
||
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('notes', 'kept');
|
||
await controller.submit();
|
||
phases.push(controller.state.phase);
|
||
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
|
||
assert.equal(controller.state.draft.notes, 'kept', '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({ 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('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({ 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('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, { 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, notes: 'mine' } })
|
||
: 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('notes', 'mine');
|
||
owner = positive({ title: 'Renamed elsewhere', category: 'Elsewhere', 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.notes, 'mine', 'changed field kept');
|
||
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
|
||
assert.equal(controller.state.draft.category, 'Elsewhere');
|
||
assert.equal(server.writes().length, 1);
|
||
await controller.submit();
|
||
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { notes: 'mine' } });
|
||
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('notes', 'dropped');
|
||
await discard.reloadOwner({ keepDraft: false });
|
||
assert.equal(discard.state.draft.notes, '');
|
||
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({ notes: 'applied', 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('notes', 'applied');
|
||
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'], notes: 'n' },
|
||
});
|
||
// D153: the revision's new positive is unrated; the owner's stored rating (3) is neither copied nor written.
|
||
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
|
||
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);
|
||
// D153: the editor has no rating field.
|
||
assert.throws(() => controller.setField('rating', 4), /Unknown editor field: rating/);
|
||
assert.throws(() => controller.setField('notes', 4), 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();
|
||
}
|
||
});
|
||
|
||
// D153 (#447): the workspace no longer edits the 1–5 rating, and a save never clears a stored one.
|
||
test('editing a prompt rated 4 sends no rating and the saved prompt keeps 4', async () => {
|
||
const { createLibraryEditorController, EDITOR_FIELDS } = await source;
|
||
assert.deepEqual(EDITOR_FIELDS, ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
|
||
let owner = positive({ rating: 4, title: 'Portrait' });
|
||
const server = harness(call => library({ owner, write: write => {
|
||
owner = positive({ ...owner, ...write.body.metadata, edit_token: TOKEN2 });
|
||
return response(200, { success: true, library_epoch: EPOCH, positive: owner });
|
||
} })(call));
|
||
const reconciled = [];
|
||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
||
onReconciled: result => reconciled.push(result) });
|
||
await controller.initialize();
|
||
assert.equal(Object.hasOwn(controller.state.draft, 'rating'), false, 'the draft has no rating');
|
||
assert.equal(Object.hasOwn(controller.state.baseline, 'rating'), false, 'the baseline has no rating');
|
||
controller.setField('title', 'Portrait, edited');
|
||
await controller.submit();
|
||
const [write] = server.writes();
|
||
assert.deepEqual(write.body.metadata, { title: 'Portrait, edited' });
|
||
assert.equal(controller.state.phase, 'complete');
|
||
assert.equal(controller.state.receipt.positive.rating, 4);
|
||
assert.equal(reconciled[0].detail.positive.rating, 4, 'the re-read prompt still has its rating');
|
||
controller.dispose();
|
||
});
|
||
|
||
test('a reused rated positive is not reported as unapplied metadata for its rating', async () => {
|
||
const { createLibraryEditorController } = await source;
|
||
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
|
||
positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: 4, notes: '' }) });
|
||
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');
|
||
await controller.submit();
|
||
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
|
||
assert.equal(controller.state.phase, 'complete');
|
||
assert.equal(controller.state.outcome.metadataNotApplied, false);
|
||
controller.dispose();
|
||
});
|