* feat(vocabulary): add workspace vocabulary state * feat(vocabulary): render the native Tags & categories screen * feat(vocabulary): add guarded vocabulary dialogs * fix(vocabulary): recover unconfirmed deletes and list every category in Manage * refactor(vocabulary): use the shared plural helper * test(vocabulary): compare the native screen with the approved concept * fix(vocabulary): wrap long tag names so row actions stay visible on narrow screens * docs(vocabulary): document the native vocabulary screen and gates Also aligns the shell link focus-ring offset with the concept (3px) and makes the chrome comparison poll the document title. * fix(vocabulary): refresh lists when a write lands after its dialog closes A rename, delete, create or category apply that commits after its dialog was disposed (Chromium force-closing the modal on a repeated Escape, or Back while the write is in flight) returned before onInvalidate, so the lists were never re-read and a cached Library shelf kept showing the old names. Like the recipe editor, a disposed command now still reports any outcome that may have changed storage (receipt, conflict, restore, unconfirmed) without rendering; only a failed write stays silent. * fix(vocabulary): give the Manage categories datalist its own id The proposed-name datalist reused the recipe editor's `categories` id. It was safe only because routes dispose each other's dialogs; it is now `vocabulary-category-options`. * docs(vocabulary): correct vocabulary testing notes Name the Tag column and the Create tag heading action, say that a failed Load more moves focus to Retry, place bulk actions on the admin page, describe late writes after a closed dialog, Browse's exact filter and the full deviation list, and use a test port that is not the live concept server's. * test(vocabulary): raise the vocabulary dialog branch floor coverage:update after the late-write and datalist fixes: workspace/vocabulary-dialog.mjs branches 87 -> 88. No floor is lowered. * fix(vocabulary): keep 390px row actions visible regardless of font metrics The one-column Tags table reserved a fixed 6em for the tag name. With CI's stock DejaVu Sans fallback (about 9% wider than the pinned image's) Tag + Prompts + Images + stacked actions needed 351px of a 336px wrap, so Browse and Rename / merge were pushed out of view on every row. At 700px and below the Tag column now has no fixed minimum and the actions ask for no more than their stacked width, so all remaining space goes to the tag name: short names stay on one line wherever the concept's own table fits, and a wider font wraps the name instead of clipping an action. A new test widens the metrics with letter spacing and fails without this change in every engine. * ci: run browser jobs in the pinned Playwright image Concept comparisons measure geometry, which follows the fonts a browser falls back to. The concept names Inter, which neither environment ships: the pinned image falls back to WenQuanYi Zen Hei, a stock ubuntu-latest runner to DejaVu Sans. Under DejaVu the concept's own Tags table needs more than a 390px viewport (its page scrolls sideways and, under touch emulation, the widened layout viewport resizes its dialogs), so the 390px comparisons failed in CI while passing locally. Both browser jobs now run in mcr.microsoft.com/playwright:v1.64.1-noble, the image local runs use, instead of installing browsers on the runner. A tooling test keeps the tag equal to @playwright/test in the lockfile. * fix(vocabulary): ignore a category preview edited while it is pending Editing the Category or proposed name while a slow Preview was in flight was a no-op in the saving phase, so the late response still became an applicable preview: the form showed the new values while Apply would submit the old source and target. An edit during a pending preview now marks it stale. Its late result returns the dialog to idle (Preview again) instead of showing an impact or offering Apply; a plain failure of a stale preview is not shown either, while a restore or conflict still is, since it describes storage. The inputs stay editable during the request, as in the recipe editor, which disables only its actions while saving. * ci: use the image's Python and Node in containerized browser jobs Inside mcr.microsoft.com/playwright:v1.64.1-noble, actions/setup-python failed before any test ran: `spawn /__t/Python/3.12.15/x64/bin/pip ENOENT`. The toolcache builds come from the runner, and their entry points name host interpreter paths that do not exist in the container. The jobs now use the image's Python 3.12 and Node 24. The image has no ensurepip and the job runs as uid 1001 without root, so a checksum-pinned pip wheel bootstraps a user-space venv for requirements-dev.txt; a step checks that the image Node is 24. The tooling test forbids setup-python and setup-node in these jobs and requires the pinned bootstrap.
517 lines
27 KiB
JavaScript
517 lines
27 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const {ApiError} = require('../../web/js/api-client.js');
|
|
// Guarded import: a missing module fails the assertions, not the file load.
|
|
const imported = import('../../web/js/workspace/vocabulary-state.mjs').catch(() => ({}));
|
|
const source = imported.then(module => {
|
|
for (const name of ['createVocabularyStream', 'createVocabularyCommand', 'browseFilters']) {
|
|
assert.equal(typeof module[name], 'function', `vocabulary-state.mjs exports ${name}`);
|
|
}
|
|
return module;
|
|
});
|
|
|
|
const EPOCH = '11111111-2222-4333-8444-555555555555';
|
|
const OTHER_EPOCH = '99999999-2222-4333-8444-555555555555';
|
|
const TOKEN = 'a'.repeat(64);
|
|
const tag = (id, name = `tag-${id}`, prompt_count = id) => ({id, name, prompt_count});
|
|
const category = (value, prompt_count = 1) => ({value, prompt_count});
|
|
const tagPage = (tags, cursor = null, {epoch = EPOCH, total = 3} = {}) =>
|
|
({success: true, library_epoch: epoch, total, has_more: cursor !== null, next_cursor: cursor, tags});
|
|
const categoryPage = (categories, cursor = null, {epoch = EPOCH, total = 3} = {}) =>
|
|
({success: true, library_epoch: epoch, total, has_more: cursor !== null, next_cursor: cursor, categories});
|
|
const queued = pages => {
|
|
const calls = [];
|
|
return {calls, api: {get: async (url, options) => {calls.push({url, options}); return pages.shift();}}};
|
|
};
|
|
const deferred = () => {
|
|
const requests = [];
|
|
return {requests, api: {get: (url, {signal}) => new Promise((resolve, reject) => requests.push({url, signal, resolve, reject}))}};
|
|
};
|
|
const params = url => new URL(url, 'https://x').searchParams;
|
|
const impact = {positive_prompts: 3, other_prompts: 1, negative_variants: 2, archived_negative_variants: 1};
|
|
const preview = (overrides = {}) => ({success: true, library_epoch: EPOCH, source: 'Landscapes', target: 'Scenery',
|
|
target_exists: false, impact, impact_token: TOKEN, ...overrides});
|
|
const tagReceipt = (overrides = {}) => ({success: true, library_epoch: EPOCH, created: true, tag: tag(7, 'sunset', 0), ...overrides});
|
|
const renameReceipt = (overrides = {}) => ({success: true, library_epoch: EPOCH, merged: false, tag: tag(1, 'dusk', 2),
|
|
components_changed: 2, pairs_changed: 1, ...overrides});
|
|
|
|
test('tag stream reads bounded fresh pages, carries the first epoch and dedupes appended ids', async () => {
|
|
const {createVocabularyStream} = await source;
|
|
const {calls, api} = queued([tagPage([tag(1), tag(2)], 'next'), tagPage([tag(2), tag(3)])]);
|
|
const states = [];
|
|
const stream = createVocabularyStream({api, kind: 'tags', onChange: state => states.push(state)});
|
|
assert.equal(Object.isFrozen(stream.state), true);
|
|
assert.equal(await stream.loadMore(), 2);
|
|
assert.equal(stream.state.epoch, EPOCH);
|
|
assert.equal(await stream.loadMore(), 1);
|
|
assert.deepEqual(stream.state.items.map(row => row.id), [1, 2, 3]);
|
|
assert.equal(Object.isFrozen(stream.state.items[0]), true);
|
|
assert.equal(stream.state.hasMore, false);
|
|
assert.equal(stream.state.total, 3);
|
|
assert.equal(await stream.loadMore(), 0);
|
|
assert.equal(calls.length, 2);
|
|
assert.match(calls[0].url, /^\/prompt_manager\/vocabulary\/tags\?/);
|
|
assert.equal(params(calls[0].url).get('limit'), '100');
|
|
assert.equal(params(calls[0].url).has('cursor'), false);
|
|
assert.equal(params(calls[1].url).get('cursor'), 'next');
|
|
assert.equal(calls[0].options.cache, 'no-store');
|
|
assert.ok(states.some(state => state.loading));
|
|
stream.dispose();
|
|
});
|
|
|
|
test('category stream dedupes by value and keeps the first occurrence when keyset order shifts', async () => {
|
|
const {createVocabularyStream} = await source;
|
|
const {calls, api} = queued([categoryPage([category('Landscapes', 3), category('Portraits', 2)], 'c1'),
|
|
categoryPage([category('Landscapes', 9), category('Studio', 1)])]);
|
|
const stream = createVocabularyStream({api, kind: 'categories'});
|
|
await stream.loadMore();
|
|
assert.equal(await stream.loadMore(), 1);
|
|
assert.deepEqual(stream.state.items.map(row => [row.value, row.prompt_count]),
|
|
[['Landscapes', 3], ['Portraits', 2], ['Studio', 1]]);
|
|
assert.match(calls[0].url, /^\/prompt_manager\/vocabulary\/categories\?/);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('malformed and nonprogressing pages are rejected atomically', async () => {
|
|
const {createVocabularyStream} = await source;
|
|
const malformedTags = [
|
|
null, {}, {...tagPage([]), success: undefined}, {...tagPage([]), library_epoch: 'nope'},
|
|
tagPage([tag(1, 'a', 1.5)]), tagPage([tag(1, 'a', -1)]), tagPage([tag(1, 'a', '3')]),
|
|
tagPage([tag(0)]), tagPage([{...tag(1), name: 2}]), tagPage([tag(1), tag(1, 'other')]),
|
|
{...tagPage([]), has_more: true}, {...tagPage([], 'next'), has_more: false},
|
|
{...tagPage([]), next_cursor: ''}, {...tagPage([]), total: 1.5}, {...tagPage([]), tags: 'x'},
|
|
{...tagPage([]), tags: undefined, categories: []},
|
|
];
|
|
for (const malformed of malformedTags) {
|
|
const stream = createVocabularyStream({api: {get: async () => malformed}, kind: 'tags'});
|
|
await stream.loadMore();
|
|
assert.ok(stream.state.error, JSON.stringify(malformed));
|
|
assert.equal(stream.state.items.length, 0);
|
|
assert.equal(stream.state.epoch, null);
|
|
stream.dispose();
|
|
}
|
|
for (const malformed of [categoryPage([category('A'), category('A')]), categoryPage([category('A', 1.5)]),
|
|
categoryPage([{value: 3, prompt_count: 1}]), categoryPage([category('')])]) {
|
|
const stream = createVocabularyStream({api: {get: async () => malformed}, kind: 'categories'});
|
|
await stream.loadMore();
|
|
assert.ok(stream.state.error, JSON.stringify(malformed));
|
|
stream.dispose();
|
|
}
|
|
const repeated = createVocabularyStream({api: {get: async () => tagPage([tag(1)], 'same')}, kind: 'tags'});
|
|
await repeated.loadMore();
|
|
await repeated.loadMore();
|
|
assert.ok(repeated.state.error);
|
|
assert.deepEqual(repeated.state.items.map(row => row.id), [1]);
|
|
assert.equal(repeated.state.cursor, 'same');
|
|
repeated.dispose();
|
|
assert.throws(() => createVocabularyStream({api: {}, kind: 'images'}), TypeError);
|
|
});
|
|
|
|
test('reset aborts the older request and its late response cannot replace the first page', async () => {
|
|
const {createVocabularyStream} = await source;
|
|
const {requests, api} = deferred();
|
|
const stream = createVocabularyStream({api, kind: 'tags'});
|
|
const first = stream.loadMore();
|
|
const second = stream.reset();
|
|
assert.equal(requests[0].signal.aborted, true);
|
|
assert.equal(params(requests[1].url).has('cursor'), false);
|
|
requests[1].resolve(tagPage([tag(2)]));
|
|
assert.equal(await second, 1);
|
|
requests[0].resolve(tagPage([tag(1)], 'old', {epoch: OTHER_EPOCH}));
|
|
assert.equal(await first, 0);
|
|
assert.deepEqual(stream.state.items.map(row => row.id), [2]);
|
|
assert.equal(stream.state.epoch, EPOCH);
|
|
const third = stream.reset();
|
|
requests[2].reject(new Error('late failure'));
|
|
const fourth = stream.reset();
|
|
await third;
|
|
assert.equal(stream.state.error, null);
|
|
requests[3].resolve(tagPage([tag(5)]));
|
|
await fourth;
|
|
assert.deepEqual(stream.state.items.map(row => row.id), [5]);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('dispose aborts the in-flight read and ignores its late result', async () => {
|
|
const {createVocabularyStream} = await source;
|
|
const {requests, api} = deferred();
|
|
const states = [];
|
|
const stream = createVocabularyStream({api, kind: 'categories', onChange: state => states.push(state)});
|
|
const pending = stream.loadMore();
|
|
stream.dispose();
|
|
assert.equal(requests[0].signal.aborted, true);
|
|
const count = states.length;
|
|
requests[0].resolve(categoryPage([category('A')]));
|
|
assert.equal(await pending, 0);
|
|
assert.equal(states.length, count);
|
|
assert.equal(await stream.loadMore(), 0);
|
|
assert.equal(await stream.reset(), 0);
|
|
assert.equal(requests.length, 1);
|
|
});
|
|
|
|
test('a changed epoch on append is an error that requires a reset', async () => {
|
|
const {createVocabularyStream} = await source;
|
|
const {calls, api} = queued([tagPage([tag(1)], 'next'), tagPage([tag(2)], null, {epoch: OTHER_EPOCH}),
|
|
tagPage([tag(4)], null, {epoch: OTHER_EPOCH})]);
|
|
const stream = createVocabularyStream({api, kind: 'tags'});
|
|
await stream.loadMore();
|
|
assert.equal(await stream.loadMore(), 0);
|
|
assert.equal(stream.state.error.code, 'storage-changed');
|
|
assert.equal(stream.state.resetRequired, true);
|
|
assert.deepEqual(stream.state.items.map(row => row.id), [1]);
|
|
assert.equal(stream.state.epoch, EPOCH);
|
|
assert.equal(await stream.loadMore(), 0);
|
|
assert.equal(calls.length, 2);
|
|
assert.equal(await stream.reset(), 1);
|
|
assert.equal(stream.state.resetRequired, false);
|
|
assert.equal(stream.state.epoch, OTHER_EPOCH);
|
|
assert.deepEqual(stream.state.items.map(row => row.id), [4]);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('failed reads keep loaded rows and cursor so Retry continues', async () => {
|
|
const {createVocabularyStream} = await source;
|
|
let calls = 0;
|
|
const stream = createVocabularyStream({kind: 'tags', api: {get: async () => {
|
|
calls += 1;
|
|
if (calls === 1) return tagPage([tag(1)], 'next');
|
|
if (calls === 2) throw new Error('offline');
|
|
return tagPage([tag(2)]);
|
|
}}});
|
|
await stream.loadMore();
|
|
await stream.loadMore();
|
|
assert.match(stream.state.error.message, /offline/);
|
|
assert.equal(stream.state.resetRequired, false);
|
|
assert.equal(stream.state.cursor, 'next');
|
|
assert.equal(await stream.loadMore(), 1);
|
|
assert.equal(stream.state.error, null);
|
|
stream.dispose();
|
|
});
|
|
|
|
test('create tag sends the epoch once, completes with the receipt and re-reads streams from the first page', async () => {
|
|
const {createVocabularyCommand, createVocabularyStream} = await source;
|
|
const reads = queued([tagPage([tag(1)], 'next'), tagPage([tag(1), tag(7, 'sunset', 0)])]);
|
|
const stream = createVocabularyStream({api: reads.api, kind: 'tags'});
|
|
await stream.loadMore();
|
|
const posts = [];
|
|
const invalidations = [];
|
|
const command = createVocabularyCommand({
|
|
api: {post: async (url, body) => {posts.push({url, body}); return tagReceipt();}},
|
|
onInvalidate: event => {invalidations.push(event); stream.reset();},
|
|
});
|
|
assert.equal(command.state.phase, 'idle');
|
|
await command.createTag('sunset', EPOCH);
|
|
assert.deepEqual(posts, [{url: '/prompt_manager/vocabulary/tags', body: {expected_epoch: EPOCH, name: 'sunset'}}]);
|
|
assert.equal(command.state.phase, 'done');
|
|
assert.equal(command.state.kind, 'create-tag');
|
|
assert.equal(command.state.receipt.tag.name, 'sunset');
|
|
assert.equal(Object.isFrozen(command.state.receipt), true);
|
|
assert.equal(invalidations.length, 1);
|
|
assert.equal(invalidations[0].phase, 'done');
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(params(reads.calls[1].url).has('cursor'), false);
|
|
assert.deepEqual(stream.state.items.map(row => row.id), [1, 7]);
|
|
await command.createTag('again', EPOCH);
|
|
assert.equal(posts.length, 1, 'done is terminal until reset()');
|
|
command.reset();
|
|
assert.equal(command.state.phase, 'idle');
|
|
stream.dispose();
|
|
});
|
|
|
|
test('rename and delete carry the exact tag identity and invalidate after success', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
const calls = [];
|
|
const invalidations = [];
|
|
const command = createVocabularyCommand({
|
|
api: {
|
|
patch: async (url, body) => {calls.push({method: 'patch', url, body}); return renameReceipt({merged: true});},
|
|
post: async (url, body) => {calls.push({method: 'post', url, body});
|
|
return {success: true, library_epoch: EPOCH, deleted: true, components_changed: 1, pairs_changed: 0};},
|
|
},
|
|
onInvalidate: event => invalidations.push(event),
|
|
});
|
|
await command.renameTag({id: 1, name: 'dawn', prompt_count: 2}, 'dusk', EPOCH);
|
|
assert.deepEqual(calls[0], {method: 'patch', url: '/prompt_manager/vocabulary/tags/1',
|
|
body: {expected_epoch: EPOCH, name: 'dawn', new_name: 'dusk'}});
|
|
assert.equal(command.state.receipt.merged, true);
|
|
command.reset();
|
|
await command.deleteTag({id: 1, name: 'dawn'}, EPOCH);
|
|
assert.deepEqual(calls[1], {method: 'post', url: '/prompt_manager/vocabulary/tags/1/delete',
|
|
body: {expected_epoch: EPOCH, name: 'dawn'}});
|
|
assert.equal(command.state.phase, 'done');
|
|
assert.equal(command.state.kind, 'delete-tag');
|
|
assert.deepEqual(invalidations.map(event => event.phase), ['done', 'done']);
|
|
command.reset();
|
|
assert.throws(() => command.renameTag({id: 0, name: 'x'}, 'y', EPOCH), TypeError);
|
|
assert.throws(() => command.createTag('x', 'not-an-epoch'), TypeError);
|
|
assert.throws(() => command.deleteTag({id: 1}, EPOCH), TypeError);
|
|
assert.equal(calls.length, 2);
|
|
});
|
|
|
|
test('409 storage-changed and vocabulary-conflict map to recovery phases; other failures stay honest', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
const cases = [
|
|
[new ApiError(409, 'restored', 'storage-changed'), 'storage-changed', true],
|
|
[new ApiError(409, 'changed', 'vocabulary-conflict'), 'conflict', true],
|
|
[new ApiError(409, 'other', null), 'write-error', false],
|
|
[new ApiError(400, 'Tag names cannot contain commas', null), 'write-error', false],
|
|
[new ApiError(502, 'Bad gateway'), 'unknown-outcome', true],
|
|
[new ApiError(200, 'Invalid JSON response'), 'unknown-outcome', true],
|
|
];
|
|
for (const [error, phase, invalidates] of cases) {
|
|
const invalidations = [];
|
|
const command = createVocabularyCommand({api: {post: async () => {throw error;}}, onInvalidate: event => invalidations.push(event)});
|
|
await command.createTag('sunset', EPOCH);
|
|
assert.equal(command.state.phase, phase, `${error.status} ${error.code}`);
|
|
assert.equal(command.state.error, error);
|
|
assert.equal(invalidations.length, invalidates ? 1 : 0);
|
|
command.dispose();
|
|
}
|
|
const invalid = createVocabularyCommand({api: {post: async () => tagReceipt({library_epoch: OTHER_EPOCH})}});
|
|
await invalid.createTag('sunset', EPOCH);
|
|
assert.equal(invalid.state.phase, 'unknown-outcome');
|
|
const retryable = createVocabularyCommand({api: {post: async () => {throw new ApiError(400, 'bad');}}});
|
|
await retryable.createTag('a,b', EPOCH);
|
|
await retryable.createTag('ab', EPOCH);
|
|
assert.equal(retryable.state.phase, 'write-error');
|
|
});
|
|
|
|
test('a network TypeError is an unknown outcome and is never retried automatically', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
let calls = 0;
|
|
const command = createVocabularyCommand({api: {patch: async () => {calls += 1; throw new TypeError('Failed to fetch');}}});
|
|
await command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
|
|
assert.equal(command.state.phase, 'unknown-outcome');
|
|
assert.equal(calls, 1);
|
|
await command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
assert.equal(calls, 1);
|
|
command.reset();
|
|
assert.equal(command.state.phase, 'idle');
|
|
assert.equal(command.state.error, null);
|
|
});
|
|
|
|
test('a second Apply while saving is ignored', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
const posts = [];
|
|
let release;
|
|
const command = createVocabularyCommand({api: {post: (url, body) => {
|
|
posts.push({url, body});
|
|
if (url.endsWith('/preview')) return Promise.resolve(preview());
|
|
return new Promise(resolve => {release = resolve;});
|
|
}}});
|
|
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
const shown = command.state.preview;
|
|
const first = command.applyCategory(shown);
|
|
assert.equal(command.state.phase, 'saving');
|
|
await command.applyCategory(shown);
|
|
await command.createTag('x', EPOCH);
|
|
await command.previewCategory('Landscapes', 'Other', EPOCH);
|
|
command.edit();
|
|
command.reset();
|
|
assert.equal(command.state.phase, 'saving');
|
|
assert.equal(posts.length, 2);
|
|
release({success: true, library_epoch: EPOCH, source: 'Landscapes', target: 'Scenery', consolidated: false, impact});
|
|
await first;
|
|
assert.equal(command.state.phase, 'done');
|
|
assert.deepEqual(posts[1], {url: '/prompt_manager/vocabulary/categories/apply',
|
|
body: {expected_epoch: EPOCH, source: 'Landscapes', target: 'Scenery', impact_token: TOKEN}});
|
|
});
|
|
|
|
test('category preview shows real impact; editing its inputs returns to idle and a stale preview cannot apply', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
const posts = [];
|
|
const invalidations = [];
|
|
const responses = [preview(), preview({target: 'Scenery', target_exists: true})];
|
|
const command = createVocabularyCommand({
|
|
api: {post: async (url, body) => {posts.push({url, body}); return responses.shift();}},
|
|
onInvalidate: event => invalidations.push(event),
|
|
});
|
|
await command.previewCategory('Landscapes', ' Scenery ', EPOCH);
|
|
assert.deepEqual(posts[0], {url: '/prompt_manager/vocabulary/categories/preview',
|
|
body: {expected_epoch: EPOCH, source: 'Landscapes', target: ' Scenery '}});
|
|
assert.equal(command.state.phase, 'previewed');
|
|
assert.equal(command.state.kind, 'preview-category');
|
|
const stale = command.state.preview;
|
|
assert.deepEqual(stale.impact, impact);
|
|
assert.equal(invalidations.length, 0, 'a preview writes nothing');
|
|
command.edit();
|
|
assert.equal(command.state.phase, 'idle');
|
|
assert.equal(command.state.preview, null);
|
|
await command.applyCategory(stale);
|
|
assert.equal(posts.length, 1);
|
|
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
assert.equal(command.state.preview.target_exists, true);
|
|
await command.applyCategory(stale);
|
|
assert.equal(posts.length, 2, 'only the preview on screen can be applied');
|
|
command.edit();
|
|
command.edit();
|
|
assert.equal(command.state.phase, 'idle');
|
|
for (const malformed of [preview({source: 'Portraits'}), preview({impact_token: 'short'}),
|
|
preview({impact: {...impact, positive_prompts: 1.5}}), preview({library_epoch: OTHER_EPOCH})]) {
|
|
const check = createVocabularyCommand({api: {post: async () => malformed}});
|
|
await check.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
assert.equal(check.state.phase, 'write-error', JSON.stringify(malformed));
|
|
assert.equal(check.state.preview, null);
|
|
}
|
|
const lost = createVocabularyCommand({api: {post: async () => {throw new TypeError('Failed to fetch');}}});
|
|
await lost.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
assert.equal(lost.state.phase, 'write-error', 'a lost preview read wrote nothing');
|
|
const conflicted = createVocabularyCommand({api: {post: async () => {throw new ApiError(409, 'gone', 'vocabulary-conflict');}}});
|
|
await conflicted.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
assert.equal(conflicted.state.phase, 'conflict');
|
|
assert.throws(() => command.previewCategory(3, 'Scenery', EPOCH), TypeError);
|
|
});
|
|
|
|
test('an edit while a preview is pending discards its late result so Apply cannot send the old values', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
const posts = [];
|
|
const pending = [];
|
|
const command = createVocabularyCommand({api: {post: (url, body) => {
|
|
posts.push({url, body});
|
|
return new Promise((resolve, reject) => pending.push({resolve, reject}));
|
|
}}});
|
|
const first = command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
assert.equal(command.state.phase, 'saving');
|
|
command.edit();
|
|
assert.equal(command.state.phase, 'saving', 'the request is still in flight');
|
|
pending.shift().resolve(preview());
|
|
await first;
|
|
assert.equal(command.state.phase, 'idle', 'the late preview describes values no longer on screen');
|
|
assert.equal(command.state.preview, null);
|
|
await command.applyCategory(preview());
|
|
assert.equal(posts.length, 1, 'nothing is applied from a discarded preview');
|
|
// A fresh preview of the edited values is applicable as usual.
|
|
const second = command.previewCategory('Landscapes', 'Vistas', EPOCH);
|
|
pending.shift().resolve(preview({target: 'Vistas'}));
|
|
await second;
|
|
assert.equal(command.state.phase, 'previewed');
|
|
assert.equal(command.state.preview.target, 'Vistas');
|
|
// A discarded preview's plain failure is not shown either; a recovery outcome still is, since it describes storage.
|
|
command.edit();
|
|
const failed = command.previewCategory('Landscapes', 'Vistas', EPOCH);
|
|
command.edit();
|
|
pending.shift().reject(new TypeError('Failed to fetch'));
|
|
await failed;
|
|
assert.equal(command.state.phase, 'idle');
|
|
assert.equal(command.state.error, null);
|
|
const restored = command.previewCategory('Landscapes', 'Vistas', EPOCH);
|
|
command.edit();
|
|
pending.shift().reject(new ApiError(409, 'restored', 'storage-changed'));
|
|
await restored;
|
|
assert.equal(command.state.phase, 'storage-changed');
|
|
});
|
|
|
|
test('a token conflict on apply requires a new preview', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
const responses = [() => preview(), () => {throw new ApiError(409, 'changed', 'vocabulary-conflict');}, () => preview()];
|
|
const command = createVocabularyCommand({api: {post: async () => responses.shift()()}});
|
|
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
await command.applyCategory(command.state.preview);
|
|
assert.equal(command.state.phase, 'conflict');
|
|
assert.equal(command.state.preview, null);
|
|
await command.previewCategory('Landscapes', 'Scenery', EPOCH);
|
|
assert.equal(command.state.phase, 'previewed');
|
|
});
|
|
|
|
test('dispose ignores late command results but a landed write still invalidates once', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
let release;
|
|
const states = [];
|
|
const invalidations = [];
|
|
const command = createVocabularyCommand({api: {post: () => new Promise(resolve => {release = resolve;})},
|
|
onChange: state => states.push(state), onInvalidate: event => invalidations.push(event)});
|
|
const pending = command.createTag('sunset', EPOCH);
|
|
command.dispose();
|
|
const count = states.length;
|
|
const receipt = tagReceipt();
|
|
release(receipt);
|
|
await pending;
|
|
assert.equal(states.length, count, 'a disposed dialog is never rendered again');
|
|
// The write committed after its dialog went away: cached Library views must still be dropped.
|
|
assert.equal(invalidations.length, 1);
|
|
assert.equal(invalidations[0].phase, 'done');
|
|
assert.equal(invalidations[0].kind, 'create-tag');
|
|
assert.deepEqual(invalidations[0].receipt, receipt);
|
|
assert.ok(Object.isFrozen(invalidations[0]));
|
|
assert.equal(command.state.phase, 'disposed');
|
|
await command.createTag('x', EPOCH);
|
|
assert.equal(invalidations.length, 1);
|
|
});
|
|
|
|
test('after dispose an unconfirmed write invalidates and a failed write does not', async () => {
|
|
const {createVocabularyCommand} = await source;
|
|
const cases = [
|
|
[new TypeError('Failed to fetch'), 'unknown-outcome', 1],
|
|
[new ApiError(409, 'changed', 'vocabulary-conflict'), 'conflict', 1],
|
|
[new ApiError(400, 'Tag names cannot contain commas'), null, 0],
|
|
];
|
|
for (const [error, phase, expected] of cases) {
|
|
let fail;
|
|
const invalidations = [];
|
|
const command = createVocabularyCommand({api: {patch: () => new Promise((resolve, reject) => {fail = reject;})},
|
|
onInvalidate: event => invalidations.push(event)});
|
|
const pending = command.renameTag({id: 3, name: 'dawn'}, 'dusk', EPOCH);
|
|
command.dispose();
|
|
fail(error);
|
|
await pending;
|
|
assert.equal(invalidations.length, expected, error.message);
|
|
if (expected) assert.deepEqual({...invalidations[0]}, {phase, kind: 'rename-tag', receipt: null});
|
|
assert.equal(command.state.phase, 'disposed');
|
|
}
|
|
});
|
|
|
|
test('browseFilters hands Library one exact constraint', async () => {
|
|
const {browseFilters} = await source;
|
|
assert.deepEqual(browseFilters('tags', 'a'), {text: '', category: '', tags: ['a'], sort: 'newest'});
|
|
assert.deepEqual(browseFilters('categories', ' A '), {text: '', category: ' A ', tags: [], sort: 'newest'});
|
|
assert.throws(() => browseFilters('images', 'a'), TypeError);
|
|
assert.throws(() => browseFilters('tags', 2), TypeError);
|
|
assert.throws(() => browseFilters('categories', ''), TypeError);
|
|
});
|
|
|
|
const helpers = imported.then(module => {
|
|
// Count wording comes from the shared plural.mjs helper (#243); this module no longer exports its own.
|
|
assert.equal(module.noun, undefined);
|
|
for (const name of ['readTagImpact', 'categoryPreviewText', 'deleteTagText']) {
|
|
assert.equal(typeof module[name], 'function', `vocabulary-state.mjs exports ${name}`);
|
|
}
|
|
return module;
|
|
});
|
|
const zero = {positive_prompts: 0, other_prompts: 0, negative_variants: 0, archived_negative_variants: 0};
|
|
|
|
test('readTagImpact reads one fresh, validated impact for the exact tag id', async () => {
|
|
const {readTagImpact} = await helpers;
|
|
const calls = [];
|
|
const signal = new AbortController().signal;
|
|
const api = {get: async (url, options) => {calls.push({url, options}); return {success: true, library_epoch: EPOCH, impact};}};
|
|
const value = await readTagImpact(api, {id: 4, name: 'mist'}, {signal});
|
|
assert.deepEqual(calls, [{url: '/prompt_manager/vocabulary/tags/4/impact', options: {signal, cache: 'no-store'}}]);
|
|
assert.deepEqual(value, {library_epoch: EPOCH, impact});
|
|
assert.ok(Object.isFrozen(value) && Object.isFrozen(value.impact));
|
|
for (const malformed of [null, {success: false}, {success: true, library_epoch: 'x', impact},
|
|
{success: true, library_epoch: EPOCH, impact: {...impact, other_prompts: -1}}, {success: true, library_epoch: EPOCH}]) {
|
|
await assert.rejects(readTagImpact({get: async () => malformed}, {id: 4, name: 'mist'}), TypeError, JSON.stringify(malformed));
|
|
}
|
|
assert.throws(() => readTagImpact(api, {id: 0, name: 'mist'}), TypeError);
|
|
});
|
|
|
|
test('category preview text states the real impact and whether it renames or consolidates', async () => {
|
|
const {categoryPreviewText} = await helpers;
|
|
assert.equal(categoryPreviewText(preview()),
|
|
'“Landscapes” is used by 3 positive prompts, 3 negative variants (1 archived) and 1 other saved prompt. Apply renames it to “Scenery”.');
|
|
assert.equal(categoryPreviewText(preview({target: 'Architecture', target_exists: true,
|
|
impact: {...zero, positive_prompts: 1, negative_variants: 1}})),
|
|
'“Landscapes” is used by 1 positive prompt and 1 negative variant. Apply consolidates it into the existing “Architecture”.');
|
|
assert.equal(categoryPreviewText(preview({impact: {...zero, other_prompts: 2}})),
|
|
'“Landscapes” is used by 2 other saved prompts. Apply renames it to “Scenery”.');
|
|
});
|
|
|
|
test('delete text names the tag, its real links and what stays unchanged', async () => {
|
|
const {deleteTagText} = await helpers;
|
|
assert.equal(deleteTagText('mist', impact),
|
|
'Deleting “mist” removes the name and its links from 3 positive prompts, 3 negative variants (1 archived) and 1 other saved prompt. The prompts, negative variants, images and history themselves are kept.');
|
|
assert.equal(deleteTagText('unused', zero),
|
|
'“unused” is not linked to any saved prompt. Deleting it removes only the name.');
|
|
});
|