867 lines
40 KiB
JavaScript
867 lines
40 KiB
JavaScript
// Run with: node --test tests/js/workspace-review-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/review-state.mjs').catch(error => {
|
||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||
return {};
|
||
});
|
||
async function load() {
|
||
const module = await source;
|
||
assert.equal(typeof module.createReviewController, 'function', 'review-state.mjs is not implemented');
|
||
return module;
|
||
}
|
||
|
||
const EPOCH = '11111111-1111-4111-8111-111111111111';
|
||
const SCOPE = 'c'.repeat(64);
|
||
const SHA = 'd'.repeat(64);
|
||
const PRINT = 'e'.repeat(64);
|
||
const HASH_A = 'a'.repeat(64);
|
||
const HASH_B = 'b'.repeat(64);
|
||
const HASH_C = 'f'.repeat(64);
|
||
const ITEMS = '/prompt_manager/review/items';
|
||
const OBSERVE = '/prompt_manager/image-annotations/observe';
|
||
const CAPTURE = '/prompt_manager/image-annotations/capture/';
|
||
|
||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||
async function settle(times = 8) { for (let i = 0; i < times; i++) await flush(); }
|
||
function response(status, body) {
|
||
return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body };
|
||
}
|
||
const fail = (status, code, error = `failure ${code}`, extra = {}) => response(status, { success: false, code, error, ...extra });
|
||
|
||
function item(id, overrides = {}) {
|
||
const path = `dunes/${id}.png`;
|
||
return {
|
||
id, state: 'pending', revision: 0, created_at: `2026-10-22T10:00:0${id.length}+00:00`, filename: `${id}.png`,
|
||
target: { root_index: 0, root_scope: SCOPE, path }, unavailable: false, candidate_count: 3, limited: false,
|
||
url: `/prompt_manager/images/serve/${path}?root=0&root_scope=${SCOPE}`, is_video: false, ...overrides,
|
||
};
|
||
}
|
||
const candidate = (hash, sampler, label, positive, negative, state = 'resolved') => ({
|
||
hash, sampler_id: sampler, state, reason: '', label,
|
||
positive: positive === null ? null : { text: positive }, negative: negative === null ? null : { text: negative },
|
||
});
|
||
function detailOf(entry, overrides = {}) {
|
||
return {
|
||
success: true, library_epoch: EPOCH, ...entry, total: 2, sha256: SHA, dependency_fingerprint: PRINT,
|
||
candidates: [
|
||
candidate(HASH_C, '9', 'No positive / Soft negative', null, 'blur', 'unresolved'),
|
||
candidate(HASH_A, '12', 'Dunes / Soft negative', 'golden dunes', 'blur'),
|
||
candidate(HASH_B, '28', 'Dunes / Harsh negative', 'golden dunes', 'noise'),
|
||
],
|
||
...overrides,
|
||
};
|
||
}
|
||
const page = (items, overrides = {}) => ({ success: true, library_epoch: EPOCH, total: items.length, items, next_cursor: null, ...overrides });
|
||
const observed = (token = 'capture-1') => ({
|
||
success: true, library_epoch: EPOCH, target: item('one').target, filename: 'one.png', title: 'one.png', tags: [], tag_revision: 0,
|
||
capture: { token, sha256: SHA, preview_url: CAPTURE + token, size: 10, width: 8, height: 8, media_type: 'image',
|
||
expires_at: '2026-10-22T12:00:00+00:00', generate: { supported: true, reason: null } },
|
||
});
|
||
|
||
/** Real createApiClient over a fetch whose answers come from routes['METHOD path'](call). */
|
||
function harness(routes = {}) {
|
||
const calls = [];
|
||
const fetch = (url, init = {}) => {
|
||
const call = { url, method: init.method, body: init.body === undefined ? undefined : JSON.parse(init.body) };
|
||
calls.push(call);
|
||
const path = url.startsWith(CAPTURE) ? CAPTURE : url.split('?')[0];
|
||
const route = routes[`${call.method} ${path}`];
|
||
return new Promise((resolve, reject) => {
|
||
init.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
|
||
Promise.resolve().then(() => (route ? route(call) : response(200, { success: true }))).then(resolve, reject);
|
||
});
|
||
};
|
||
return {
|
||
api: createApiClient(fetch), calls,
|
||
of: (method, prefix) => calls.filter(call => call.method === method && call.url.startsWith(prefix)),
|
||
};
|
||
}
|
||
const QUEUE = [item('one'), item('two')];
|
||
function routes(extra = {}) {
|
||
return {
|
||
[`GET ${ITEMS}`]: () => response(200, page(QUEUE)),
|
||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(QUEUE[0])),
|
||
[`GET ${ITEMS}/two`]: () => response(200, detailOf(QUEUE[1], { candidates: [], limited: true, candidate_count: 0 })),
|
||
[`POST ${OBSERVE}`]: () => response(200, observed()),
|
||
[`POST ${ITEMS}/one/decision`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
|
||
item: { ...QUEUE[0], state: call.body.choice === 'unresolved' ? 'unresolved' : 'selected', revision: 1 } }),
|
||
...extra,
|
||
};
|
||
}
|
||
async function loaded(extra) {
|
||
const { createReviewController } = await load();
|
||
const h = harness(routes(extra));
|
||
const c = createReviewController({ api: h.api });
|
||
await c.load();
|
||
await settle();
|
||
return { c, h };
|
||
}
|
||
|
||
test('notice titles for 0, 1 and 3', async () => {
|
||
const { noticeTitle } = await load();
|
||
assert.equal(noticeTitle(0), 'No images need a decision');
|
||
assert.equal(noticeTitle(1), '1 image needs a decision');
|
||
assert.equal(noticeTitle(3), '3 images need a decision');
|
||
assert.equal(noticeTitle(1234), '1,234 images need a decision');
|
||
});
|
||
|
||
test('loads pending items and the exact count', async () => {
|
||
const { c, h } = await loaded();
|
||
assert.equal(c.state.status, 'ready');
|
||
assert.equal(c.state.total, 2);
|
||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||
const [list] = h.of('GET', ITEMS);
|
||
assert.equal(list.url, `${ITEMS}?limit=40`);
|
||
// The first item is selected, as the concept selects its first queue image.
|
||
assert.equal(c.state.selectedId, 'one');
|
||
assert.ok(Object.isFrozen(c.state));
|
||
});
|
||
|
||
test('selecting a card loads detail and option labels, first workable preselected, unusable disabled', async () => {
|
||
const { c } = await loaded();
|
||
assert.equal(c.state.detailStatus, 'ready');
|
||
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
|
||
['Sampler 9 · No positive / Soft negative', true],
|
||
['Sampler 12 · Dunes / Soft negative', false],
|
||
['Sampler 28 · Dunes / Harsh negative', false],
|
||
]);
|
||
assert.equal(c.state.choice, HASH_A);
|
||
assert.deepEqual(c.state.prompts, { positive: { status: 'resolved', text: 'golden dunes' }, negative: { status: 'resolved', text: 'blur' } });
|
||
c.choose(HASH_C);
|
||
assert.equal(c.state.choice, HASH_A, 'a disabled option cannot be chosen');
|
||
c.choose(HASH_B);
|
||
assert.equal(c.state.prompts.negative.text, 'noise');
|
||
c.choose('unresolved');
|
||
assert.equal(c.state.choice, HASH_B, 'Leave unresolved is its own action');
|
||
await c.select('two');
|
||
await settle();
|
||
assert.equal(c.state.selectedId, 'two');
|
||
assert.deepEqual(c.state.options, []);
|
||
assert.equal(c.state.choice, null);
|
||
assert.equal(c.state.unreadable, null, 'a limited file keeps its own warning');
|
||
assert.match(c.state.warning, /too many sampler paths/i);
|
||
});
|
||
|
||
test('apply sends the selected candidate hash with a fresh capture and the next item opens', async () => {
|
||
const { c, h } = await loaded();
|
||
c.choose(HASH_B);
|
||
const done = await c.apply();
|
||
assert.equal(done, true);
|
||
const [observe] = h.of('POST', OBSERVE);
|
||
assert.deepEqual(observe.body, { target: QUEUE[0].target });
|
||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||
assert.deepEqual(decision.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
|
||
dependency_fingerprint: PRINT, choice: HASH_B });
|
||
assert.equal(c.state.phase, 'idle');
|
||
assert.equal(c.state.selectedId, 'two');
|
||
assert.equal(c.state.toast, 'Association accepted.');
|
||
assert.equal(c.state.decisions, 1);
|
||
// The server releases a used capture itself.
|
||
assert.equal(h.of('DELETE', CAPTURE).length, 0);
|
||
});
|
||
|
||
test('leave unresolved is its own action', async () => {
|
||
const { c, h } = await loaded();
|
||
assert.equal(await c.leaveUnresolved(), true);
|
||
const [unresolved] = h.of('POST', `${ITEMS}/one/decision`);
|
||
assert.equal(unresolved.body.choice, 'unresolved');
|
||
assert.equal(unresolved.body.capture_token, 'capture-1');
|
||
assert.equal(c.state.toast, 'Left unresolved.');
|
||
assert.equal(c.state.selectedId, 'two');
|
||
});
|
||
|
||
const unreadable = () => detailOf(QUEUE[0], { candidates: [
|
||
{ ...candidate(HASH_A, '9', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
|
||
{ ...candidate(HASH_B, '12', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported latent ancestry' },
|
||
{ ...candidate(HASH_C, '14', 'No positive / No negative', null, null, 'unresolved'), reason: 'Unsupported node' },
|
||
] });
|
||
|
||
test('nothing selectable: the reasons are summed, there is no choice and apply sends nothing', async () => {
|
||
const { c, h } = await loaded({ [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
|
||
assert.equal(c.state.choice, null);
|
||
assert.equal(c.state.prompts, null);
|
||
assert.deepEqual(c.state.options.map(option => [option.label, option.disabled]), [
|
||
['Sampler 9 · No positive / No negative (Unsupported latent ancestry)', true],
|
||
['Sampler 12 · No positive / No negative (Unsupported latent ancestry)', true],
|
||
['Sampler 14 · No positive / No negative (Unsupported node)', true],
|
||
]);
|
||
assert.deepEqual(c.state.unreadable, { count: 3, reasons: [{ reason: 'Unsupported latent ancestry', count: 2 }, { reason: 'Unsupported node', count: 1 }] });
|
||
assert.equal(await c.apply(), false);
|
||
assert.equal(h.of('POST', '/').length, 0);
|
||
});
|
||
|
||
const linkRoute = (body = {}) => ({ [`POST ${ITEMS}/one/link`]: call => response(200, { success: true, library_epoch: EPOCH, total: 1,
|
||
item: { ...QUEUE[0], state: 'selected', revision: 1 }, pair: { id: call.body.pair_id ?? 7, image_id: 3, status: 'added' }, ...body }) });
|
||
|
||
test('link posts the chosen prompt and recipe with a fresh capture, then the next item opens', async () => {
|
||
const { c, h } = await loaded({ ...linkRoute(), [`GET ${ITEMS}/one`]: () => response(200, unreadable()) });
|
||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
|
||
const [link] = h.of('POST', `${ITEMS}/one/link`);
|
||
assert.deepEqual(link.body, { expected_epoch: EPOCH, expected_revision: 0, capture_token: 'capture-1', sha256: SHA,
|
||
dependency_fingerprint: PRINT, positive_id: 4, pair_id: 9 });
|
||
assert.equal(c.state.toast, 'Linked to Harbor.');
|
||
assert.equal(c.state.selectedId, 'two');
|
||
assert.equal(c.state.decisions, 1);
|
||
});
|
||
|
||
test('a refused link keeps the item, releases the capture and names the reason', async () => {
|
||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => fail(409, 'association-conflict', 'This image is already linked') });
|
||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: null, title: 'Harbor' }), false);
|
||
assert.equal(c.state.selectedId, 'one');
|
||
assert.equal(c.state.warning, 'This image is already linked');
|
||
assert.equal(c.state.decisions, 0);
|
||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||
});
|
||
|
||
test('an unanswered link resends the same body once; another choice takes a new capture', async () => {
|
||
let answers = 0;
|
||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/link`]: () => {
|
||
answers += 1;
|
||
return answers === 1 ? Promise.reject(new TypeError('Failed to fetch')) : response(200, { success: true, total: 1 });
|
||
} });
|
||
assert.equal(await c.link({ itemId: 'two', positiveId: 4, pairId: 9, title: 'Harbor' }), false, 'not the open item');
|
||
assert.equal(h.of('POST', OBSERVE).length, 0);
|
||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), false);
|
||
assert.match(c.state.warning, /Link image again/);
|
||
assert.equal(await c.link({ itemId: 'one', positiveId: 4, pairId: 9, title: 'Harbor' }), true);
|
||
const [first, second] = h.of('POST', `${ITEMS}/one/link`);
|
||
assert.deepEqual(first.body, second.body);
|
||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||
});
|
||
|
||
test('the last decision empties the page and a later page still comes back from the re-read', async () => {
|
||
let queue = [QUEUE[0]];
|
||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
|
||
queue = [];
|
||
assert.equal(await c.leaveUnresolved(), true);
|
||
await settle();
|
||
assert.equal(c.state.selectedId, null);
|
||
assert.deepEqual(c.state.items, []);
|
||
assert.equal(c.state.warning, '');
|
||
});
|
||
|
||
test('an unavailable file can only be left unresolved, without a capture or a link', async () => {
|
||
const gone = item('one', { target: null, unavailable: true, url: null });
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}`]: () => response(200, page([gone])),
|
||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(gone)),
|
||
});
|
||
assert.ok(c.state.options.every(option => option.disabled));
|
||
assert.equal(c.state.choice, null);
|
||
assert.match(c.state.warning, /not under a current image source/i);
|
||
assert.equal(await c.link({ itemId: 'one', positiveId: 1, pairId: null, title: 'x' }), false);
|
||
assert.equal(await c.leaveUnresolved(), true);
|
||
assert.equal(h.of('POST', OBSERVE).length, 0);
|
||
const [decision] = h.of('POST', `${ITEMS}/one/decision`);
|
||
assert.equal(decision.body.capture_token, null);
|
||
assert.equal(decision.body.sha256, SHA);
|
||
});
|
||
|
||
test('association conflict keeps the selection and shows the inline warning', async () => {
|
||
const message = 'This image is already linked to another prompt or negative, so this sampler path cannot be attached safely.';
|
||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/decision`]: () => fail(409, 'association-conflict', message) });
|
||
assert.equal(await c.apply(), false);
|
||
assert.equal(c.state.selectedId, 'one');
|
||
assert.equal(c.state.choice, HASH_A);
|
||
assert.equal(c.state.phase, 'idle');
|
||
assert.equal(c.state.warning, message);
|
||
// The refused decision's capture is released.
|
||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'no reload');
|
||
});
|
||
|
||
test('storage-changed requires reload', async () => {
|
||
let lists = 0;
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}`]: () => { lists += 1; return response(200, page(QUEUE)); },
|
||
[`POST ${ITEMS}/one/decision`]: () => fail(409, 'storage-changed', 'Prompt storage was restored; reload the review queue.'),
|
||
});
|
||
assert.equal(await c.apply(), false);
|
||
await settle();
|
||
assert.equal(lists, 2, 'the queue is read again under the new epoch');
|
||
assert.equal(c.state.warning, 'Prompt storage was restored; reload the review queue.');
|
||
assert.equal(c.state.canRetry, false);
|
||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||
});
|
||
|
||
test('lost response keeps the selection and retry resends once', async () => {
|
||
let attempts = 0;
|
||
const { c, h } = await loaded({
|
||
[`POST ${ITEMS}/one/decision`]: call => {
|
||
attempts += 1;
|
||
if (attempts === 1) return Promise.reject(new TypeError('Failed to fetch'));
|
||
return response(200, { success: true, library_epoch: EPOCH, total: 1, item: { ...QUEUE[0], state: 'selected', revision: 1 } });
|
||
},
|
||
});
|
||
assert.equal(await c.apply(), false);
|
||
assert.equal(c.state.selectedId, 'one');
|
||
assert.equal(c.state.canRetry, true);
|
||
assert.match(c.state.warning, /may not have been saved/i);
|
||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the capture is kept for the retry');
|
||
const [first] = h.of('POST', `${ITEMS}/one/decision`);
|
||
assert.equal(await c.retry(), true);
|
||
const sent = h.of('POST', `${ITEMS}/one/decision`);
|
||
assert.equal(sent.length, 2);
|
||
assert.deepEqual(sent[1].body, first.body, 'the identical decision is resent');
|
||
assert.equal(h.of('POST', OBSERVE).length, 1, 'no second capture');
|
||
assert.equal(await c.retry(), false, 'nothing left to resend');
|
||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).length, 2);
|
||
});
|
||
|
||
test('a retry answered by review-conflict reloads the queue', async () => {
|
||
let attempts = 0, lists = 0;
|
||
const { c } = await loaded({
|
||
[`GET ${ITEMS}`]: () => { lists += 1; return response(200, page(lists === 1 ? QUEUE : [QUEUE[1]])); },
|
||
[`POST ${ITEMS}/one/decision`]: () => {
|
||
attempts += 1;
|
||
if (attempts === 1) return Promise.reject(new TypeError('Failed to fetch'));
|
||
return fail(409, 'review-conflict', 'This item was decided or changed since it was opened. Reload the queue.');
|
||
},
|
||
});
|
||
await c.apply();
|
||
assert.equal(await c.retry(), false);
|
||
await settle();
|
||
assert.equal(lists, 2);
|
||
assert.equal(c.state.selectedId, 'two');
|
||
});
|
||
|
||
test('a review-conflict on a still pending item reads its detail again', async () => {
|
||
let details = 0, decisions = 0;
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}/one`]: () => { details += 1; return response(200, detailOf({ ...QUEUE[0], revision: details - 1 })); },
|
||
[`POST ${ITEMS}/one/decision`]: () => {
|
||
decisions += 1;
|
||
return decisions === 1 ? fail(409, 'review-conflict', 'This item was decided or changed since it was opened. Reload the queue.')
|
||
: response(200, { success: true, library_epoch: EPOCH, total: 1, item: { ...QUEUE[0], state: 'selected', revision: 2 } });
|
||
},
|
||
});
|
||
assert.equal(await c.apply(), false);
|
||
await settle();
|
||
assert.equal(details, 2, 'the item is read again');
|
||
assert.equal(c.state.detail.revision, 1);
|
||
assert.equal(c.state.warning, 'This item was decided or changed since it was opened. Reload the queue.');
|
||
assert.equal(await c.apply(), true);
|
||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).at(-1).body.expected_revision, 1);
|
||
});
|
||
|
||
test('leave for later writes nothing, opens the next item and wraps', async () => {
|
||
const { c, h } = await loaded();
|
||
await c.leave();
|
||
assert.equal(c.state.selectedId, 'two');
|
||
assert.equal(c.state.toast, null);
|
||
assert.equal(c.state.decisions, 0);
|
||
await c.leave();
|
||
assert.equal(c.state.selectedId, 'one');
|
||
assert.equal(h.of('POST', '/').length, 0);
|
||
assert.equal(h.of('DELETE', '/').length, 0);
|
||
});
|
||
|
||
test('load more pages with the cursor and keeps the selection', async () => {
|
||
const more = [item('three')];
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}`]: call => response(200, call.url.includes('cursor=')
|
||
? page(more, { total: 3 }) : page(QUEUE, { total: 3, next_cursor: 'abc_-1' })),
|
||
});
|
||
assert.equal(c.state.nextCursor, 'abc_-1');
|
||
await c.loadMore();
|
||
assert.equal(h.of('GET', ITEMS).at(-1).url, `${ITEMS}?limit=40&cursor=abc_-1`);
|
||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two', 'three']);
|
||
assert.equal(c.state.nextCursor, null);
|
||
assert.equal(c.state.selectedId, 'one');
|
||
});
|
||
|
||
test('a failed queue read is an error state and load retries', async () => {
|
||
let lists = 0;
|
||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => { lists += 1; return lists === 1 ? fail(500, 'review-failed') : response(200, page(QUEUE)); } });
|
||
assert.equal(c.state.status, 'error');
|
||
await c.load();
|
||
await settle();
|
||
assert.equal(c.state.status, 'ready');
|
||
});
|
||
|
||
test('tag save toast carries the review suffix and the item stays pending', async () => {
|
||
const { c, h } = await loaded({
|
||
['GET /prompt_manager/autotag/models']: () => response(200, { success: true, models: {}, default_prompt: 'x',
|
||
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10 }),
|
||
['PUT /prompt_manager/image-annotations/tags']: call => response(200, { success: true, library_epoch: EPOCH, sha256: SHA,
|
||
tags: call.body.tags, tag_revision: 1, replayed: false }),
|
||
});
|
||
const { createImageTaggingController } = await import('../../web/js/workspace/image-tagging-state.mjs');
|
||
const before = h.calls.filter(call => call.url.startsWith(ITEMS)).length;
|
||
// The Tag dialog opened from Needs review is the shared controller with the review source.
|
||
const tagging = createImageTaggingController({ api: h.api, target: QUEUE[0].target, source: 'review' });
|
||
assert.equal(tagging.state.showScopeNote, true);
|
||
await tagging.open();
|
||
await settle();
|
||
tagging.setManual('dunes');
|
||
await tagging.save();
|
||
await settle();
|
||
assert.equal(tagging.state.toast, '1 tags saved for one.png. Prompt association still awaits review.');
|
||
tagging.dispose();
|
||
assert.equal(h.calls.filter(call => call.url.startsWith(ITEMS)).length, before, 'a tag save neither decides nor re-reads the queue');
|
||
assert.deepEqual(c.state.items.map(entry => [entry.id, entry.state]), [['one', 'pending'], ['two', 'pending']]);
|
||
assert.equal(c.state.phase, 'idle');
|
||
});
|
||
|
||
test('dispose releases a kept capture and stops reads', async () => {
|
||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/decision`]: () => Promise.reject(new TypeError('Failed to fetch')) });
|
||
await c.apply();
|
||
c.dispose();
|
||
await settle();
|
||
const released = h.of('DELETE', CAPTURE);
|
||
assert.equal(released.length, 1);
|
||
assert.equal(released[0].url, CAPTURE + 'capture-1');
|
||
assert.equal(await c.retry(), false);
|
||
});
|
||
|
||
// F2: an open page re-reads its queue when the server says it changed (the review change stream).
|
||
function refreshOf(c) {
|
||
assert.equal(typeof c.refresh, 'function', 'refresh is not implemented');
|
||
return c.refresh();
|
||
}
|
||
test('refresh re-reads the queue quietly and keeps the selection and choice', async () => {
|
||
let queue = QUEUE;
|
||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
|
||
c.choose(HASH_B);
|
||
const statuses = [];
|
||
c.subscribe(state => statuses.push(state.status));
|
||
queue = [...QUEUE, item('three')];
|
||
await refreshOf(c);
|
||
await settle();
|
||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two', 'three']);
|
||
assert.equal(c.state.total, 3);
|
||
assert.equal(c.state.selectedId, 'one');
|
||
assert.equal(c.state.choice, HASH_B);
|
||
assert.equal(c.state.warning, '');
|
||
assert.ok(statuses.every(status => status === 'ready'), `no loading state: ${statuses}`);
|
||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 1, 'the open item is not read again');
|
||
});
|
||
|
||
test('refreshes asked for during a refresh collapse into one more read', async () => {
|
||
let release;
|
||
const held = new Promise(resolve => { release = resolve; });
|
||
let lists = 0;
|
||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: async () => {
|
||
lists += 1;
|
||
if (lists === 2) await held;
|
||
return response(200, page(QUEUE));
|
||
} });
|
||
const first = refreshOf(c);
|
||
await settle();
|
||
refreshOf(c);
|
||
refreshOf(c);
|
||
release();
|
||
await first;
|
||
await settle();
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 3, 'the load, the refresh and one collapsed re-read');
|
||
assert.equal(c.state.status, 'ready');
|
||
});
|
||
|
||
test('a refresh after the open item was decided elsewhere opens the next one and says so', async () => {
|
||
let queue = QUEUE;
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}`]: () => response(200, page(queue)),
|
||
[`GET ${ITEMS}/three`]: () => response(200, detailOf(item('three'))),
|
||
});
|
||
queue = [item('three')];
|
||
await refreshOf(c);
|
||
await settle();
|
||
assert.equal(c.state.selectedId, 'three');
|
||
assert.equal(c.state.detailStatus, 'ready');
|
||
assert.equal(c.state.warning, 'one.png was decided elsewhere, so the next image is open.');
|
||
assert.equal(h.of('GET', `${ITEMS}/three`).length, 1);
|
||
});
|
||
|
||
test('a refresh to an empty queue clears the selection', async () => {
|
||
let queue = QUEUE;
|
||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => response(200, page(queue)) });
|
||
queue = [];
|
||
await refreshOf(c);
|
||
await settle();
|
||
assert.equal(c.state.total, 0);
|
||
assert.deepEqual(c.state.items, []);
|
||
assert.equal(c.state.selectedId, null);
|
||
assert.equal(c.state.status, 'ready');
|
||
});
|
||
|
||
test('a failed refresh keeps the queue and says so', async () => {
|
||
let lists = 0;
|
||
const { c } = await loaded({ [`GET ${ITEMS}`]: () => { lists += 1; return lists === 1 ? response(200, page(QUEUE)) : fail(500, 'review-failed'); } });
|
||
await refreshOf(c);
|
||
await settle();
|
||
assert.equal(c.state.status, 'ready');
|
||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||
assert.equal(c.state.warning, 'The review queue could not be read again. Reload the page to see the latest images.');
|
||
});
|
||
|
||
test('dispose during a decision releases its capture', async () => {
|
||
const { c, h } = await loaded({ [`POST ${ITEMS}/one/decision`]: () => new Promise(() => {}) });
|
||
const applying = c.apply();
|
||
await settle();
|
||
assert.equal(c.state.phase, 'applying');
|
||
c.dispose();
|
||
assert.equal(await applying, false);
|
||
await settle();
|
||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + 'capture-1']);
|
||
await refreshOf(c);
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'a disposed controller reads nothing');
|
||
});
|
||
|
||
test('a change heard during the first read re-reads once after it, and a failed first read stays an error', async () => {
|
||
const { createReviewController } = await load();
|
||
let release;
|
||
let lists = 0;
|
||
const h = harness(routes({ [`GET ${ITEMS}`]: async () => {
|
||
lists += 1;
|
||
if (lists === 1) await new Promise(resolve => { release = resolve; });
|
||
return response(200, page(QUEUE));
|
||
} }));
|
||
const c = createReviewController({ api: h.api });
|
||
const first = c.load();
|
||
await settle();
|
||
refreshOf(c);
|
||
assert.equal(lists, 1, 'the first read is not interrupted');
|
||
release();
|
||
await first;
|
||
await settle();
|
||
assert.equal(lists, 2, 'one re-read after the first read');
|
||
assert.equal(c.state.status, 'ready');
|
||
|
||
const failing = harness(routes({ [`GET ${ITEMS}`]: () => fail(500, 'review-failed') }));
|
||
const broken = createReviewController({ api: failing.api });
|
||
const reading = broken.load();
|
||
refreshOf(broken);
|
||
await reading;
|
||
await settle();
|
||
assert.equal(broken.state.status, 'error');
|
||
assert.equal(failing.of('GET', `${ITEMS}?`).length, 1, 'Retry is the way back, not a re-read');
|
||
});
|
||
|
||
// F3 rulings: a live re-read keeps the loaded depth, and the stream's `ready` reads nothing when the first read is current.
|
||
|
||
/** A keyset pager over `source()`: pages of 40 with an index cursor, each answering `version()`. */
|
||
function pager(source, version = () => 0, { onPage } = {}) {
|
||
return call => {
|
||
const query = new URL(call.url, 'http://x').searchParams;
|
||
const cursor = query.get('cursor');
|
||
const all = source();
|
||
const from = cursor ? Number(cursor.slice(1)) : 0;
|
||
const answer = onPage?.(cursor);
|
||
if (answer) return answer;
|
||
const items = all.slice(from, from + 40);
|
||
const next = from + 40 < all.length ? `c${from + 40}` : null;
|
||
return response(200, page(items, { total: all.length, next_cursor: next, review_revision: version() }));
|
||
};
|
||
}
|
||
const many = (count, prefix = 'i') => Array.from({ length: count }, (_, index) => item(`${prefix}${String(index).padStart(3, '0')}`));
|
||
function syncOf(c, version) {
|
||
assert.equal(typeof c.sync, 'function', 'sync is not implemented');
|
||
return c.sync(version);
|
||
}
|
||
const version = revision => ({ library_epoch: EPOCH, review_revision: revision });
|
||
|
||
test('a refresh keeps the loaded depth: it re-reads as many items as were loaded', async () => {
|
||
let queue = many(100);
|
||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => queue) });
|
||
await c.loadMore();
|
||
assert.equal(c.state.items.length, 80);
|
||
const before = h.of('GET', `${ITEMS}?`).length;
|
||
queue = [...queue, item('late')];
|
||
await refreshOf(c);
|
||
await settle();
|
||
assert.equal(c.state.items.length, 80, 'the Load more page is kept');
|
||
assert.deepEqual(c.state.items.map(entry => entry.id), queue.slice(0, 80).map(entry => entry.id));
|
||
assert.equal(c.state.total, 101);
|
||
assert.equal(c.state.nextCursor, 'c80', 'Load more continues after the kept depth');
|
||
assert.deepEqual(h.of('GET', `${ITEMS}?`).slice(before).map(call => call.url),
|
||
[`${ITEMS}?limit=40`, `${ITEMS}?limit=40&cursor=c40`]);
|
||
assert.equal(c.state.selectedId, 'i000');
|
||
});
|
||
|
||
test('a refresh after decisions elsewhere fills the loaded depth from what is left', async () => {
|
||
let queue = many(85);
|
||
const { c } = await loaded({ [`GET ${ITEMS}`]: pager(() => queue) });
|
||
await c.loadMore();
|
||
await c.loadMore();
|
||
assert.equal(c.state.items.length, 85);
|
||
queue = queue.filter((_, index) => index % 10 !== 5);
|
||
await refreshOf(c);
|
||
await settle();
|
||
assert.equal(c.state.items.length, queue.length);
|
||
assert.equal(c.state.nextCursor, null);
|
||
});
|
||
|
||
test('a deep refresh whose next page meets a restore reads again from the first page', async () => {
|
||
let restored = false;
|
||
let queue = many(60);
|
||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => queue, () => 0, {
|
||
onPage: cursor => {
|
||
if (cursor && restored) { restored = false; return fail(409, 'storage-changed', 'The library was restored.'); }
|
||
return null;
|
||
},
|
||
}) });
|
||
await c.loadMore();
|
||
const before = h.of('GET', `${ITEMS}?`).length;
|
||
restored = true;
|
||
queue = many(60, 'r');
|
||
await refreshOf(c);
|
||
await settle();
|
||
assert.deepEqual(h.of('GET', `${ITEMS}?`).slice(before).map(call => call.url),
|
||
[`${ITEMS}?limit=40`, `${ITEMS}?limit=40&cursor=c40`, `${ITEMS}?limit=40`, `${ITEMS}?limit=40&cursor=c40`]);
|
||
assert.equal(c.state.items.length, 60);
|
||
assert.equal(c.state.items[0].id, 'r000');
|
||
assert.notEqual(c.state.warning, 'The review queue could not be read again. Reload the page to see the latest images.',
|
||
'a restore met mid-read is not a failed re-read');
|
||
assert.equal(c.state.status, 'ready');
|
||
});
|
||
|
||
test('ready naming the version the first read answered reads nothing more', async () => {
|
||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => QUEUE, () => 4) });
|
||
await syncOf(c, version(4));
|
||
await settle();
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'one list read per visit');
|
||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 1);
|
||
});
|
||
|
||
test('ready naming a newer version, another epoch or no version re-reads once', async () => {
|
||
const { c, h } = await loaded({ [`GET ${ITEMS}`]: pager(() => QUEUE, () => 4) });
|
||
await syncOf(c, version(5));
|
||
await settle();
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 2);
|
||
await syncOf(c, { library_epoch: '22222222-2222-4222-8222-222222222222', review_revision: 4 });
|
||
await settle();
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 3);
|
||
await syncOf(c, {});
|
||
await settle();
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 4, 'an unknown version is read, as before versions existed');
|
||
});
|
||
|
||
test('ready heard during the first read re-reads only when that read is older', async () => {
|
||
const { createReviewController } = await load();
|
||
for (const [readRevision, readyRevision, reads] of [[4, 4, 1], [5, 4, 1], [4, 5, 2]]) {
|
||
let release;
|
||
let lists = 0;
|
||
const answer = pager(() => QUEUE, () => (lists === 1 ? readRevision : readyRevision));
|
||
const h = harness(routes({ [`GET ${ITEMS}`]: async call => {
|
||
lists += 1;
|
||
if (lists === 1) await new Promise(resolve => { release = resolve; });
|
||
return answer(call);
|
||
} }));
|
||
const c = createReviewController({ api: h.api });
|
||
const first = c.load();
|
||
await settle();
|
||
syncOf(c, version(readyRevision));
|
||
release();
|
||
await first;
|
||
await settle();
|
||
assert.equal(lists, reads, `first read r${readRevision}, ready r${readyRevision}`);
|
||
c.dispose();
|
||
}
|
||
});
|
||
|
||
test('a change heard during the first read still re-reads after a current ready', async () => {
|
||
const { createReviewController } = await load();
|
||
let release;
|
||
let lists = 0;
|
||
const h = harness(routes({ [`GET ${ITEMS}`]: async call => {
|
||
lists += 1;
|
||
if (lists === 1) await new Promise(resolve => { release = resolve; });
|
||
return pager(() => QUEUE, () => 4)(call);
|
||
} }));
|
||
const c = createReviewController({ api: h.api });
|
||
const first = c.load();
|
||
await settle();
|
||
syncOf(c, version(4));
|
||
refreshOf(c);
|
||
release();
|
||
await first;
|
||
await settle();
|
||
assert.equal(lists, 2);
|
||
});
|
||
|
||
// Final review round (I1, I2, M3–M5, M7).
|
||
test('I1: a change heard during a refused Apply is re-read once the phase is idle again', async () => {
|
||
let queue = [item('one')], releaseDecision;
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}`]: () => response(200, page(queue, { review_revision: queue.length })),
|
||
[`POST ${ITEMS}/one/decision`]: () => new Promise(resolve => {
|
||
releaseDecision = () => resolve(fail(409, 'association-conflict', 'conflict'));
|
||
}),
|
||
});
|
||
const applying = c.apply();
|
||
await settle();
|
||
assert.equal(c.state.phase, 'applying');
|
||
queue = [item('one'), item('two')]; // the monitor records an item while the decision is in flight
|
||
c.refresh();
|
||
await settle();
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 1, 'no read while the decision is in flight');
|
||
releaseDecision();
|
||
await applying;
|
||
await settle();
|
||
assert.equal(c.state.phase, 'idle');
|
||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||
assert.equal(c.state.total, 2);
|
||
assert.equal(c.state.warning, 'conflict', 'the refusal stays the warning');
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 2, 'exactly one owed re-read');
|
||
});
|
||
|
||
test('I1: a change heard during an Apply whose observe is refused is re-read too', async () => {
|
||
let queue = [item('one')], releaseObserve;
|
||
const { c } = await loaded({
|
||
[`GET ${ITEMS}`]: () => response(200, page(queue)),
|
||
[`POST ${OBSERVE}`]: () => new Promise(resolve => {
|
||
releaseObserve = () => resolve(fail(409, 'capture-capacity', 'busy'));
|
||
}),
|
||
});
|
||
const applying = c.apply();
|
||
await settle();
|
||
queue = [item('one'), item('two')];
|
||
c.refresh();
|
||
releaseObserve();
|
||
await applying;
|
||
await settle();
|
||
assert.deepEqual(c.state.items.map(entry => entry.id), ['one', 'two']);
|
||
});
|
||
|
||
const forty = Array.from({ length: 41 }, (_, i) => item(`i${String(i).padStart(2, '0')}`));
|
||
function deepRoutes(state) {
|
||
return {
|
||
[`GET ${ITEMS}`]: call => {
|
||
const cursor = new URL(call.url, 'http://x').searchParams.get('cursor');
|
||
if (cursor) {
|
||
if (state.moreGate) return state.moreGate.then(() => state.more ?? response(200, page([forty[40]], { total: 41 })));
|
||
return state.more ?? response(200, page([forty[40]], { total: 41 }));
|
||
}
|
||
const body = response(200, page(state.first, { total: state.first.length + 1, review_revision: state.revision ?? 1, next_cursor: 'cur' }));
|
||
return state.gate ? state.gate.then(() => state.fail ?? body) : body;
|
||
},
|
||
[`GET ${ITEMS}/i00`]: () => response(200, detailOf(forty[0])),
|
||
};
|
||
}
|
||
|
||
test('I2: Load more during a re-read waits for it and continues from the re-read queue', async () => {
|
||
const state = { first: forty.slice(0, 40) };
|
||
const { c } = await loaded(deepRoutes(state));
|
||
// Another tab decides i05; the re-read is slow and the user clicks Load more meanwhile.
|
||
let open;
|
||
state.first = forty.slice(0, 40).filter(entry => entry.id !== 'i05');
|
||
state.gate = new Promise(resolve => { open = resolve; });
|
||
const refreshing = c.refresh();
|
||
await settle();
|
||
const more = c.loadMore();
|
||
await settle();
|
||
assert.equal(c.state.more, 'loading', 'the click shows as loading while the re-read runs');
|
||
open();
|
||
await Promise.all([refreshing, more]);
|
||
await settle();
|
||
const ids = c.state.items.map(entry => entry.id);
|
||
assert.ok(!ids.includes('i05'), 'the re-read was applied');
|
||
assert.equal(ids.at(-1), 'i40', 'Load more appended the next page after the re-read');
|
||
assert.equal(c.state.more, 'idle');
|
||
});
|
||
|
||
test('I2: a re-read started during Load more takes over the click and reads one page deeper', async () => {
|
||
const state = { first: forty.slice(0, 40) };
|
||
const { c, h } = await loaded(deepRoutes(state));
|
||
let openMore;
|
||
state.moreGate = new Promise(resolve => { openMore = resolve; });
|
||
const more = c.loadMore();
|
||
await settle();
|
||
state.moreGate = null;
|
||
state.first = forty.slice(0, 40).filter(entry => entry.id !== 'i05');
|
||
await c.refresh();
|
||
openMore();
|
||
await more;
|
||
await settle();
|
||
const ids = c.state.items.map(entry => entry.id);
|
||
assert.ok(!ids.includes('i05'));
|
||
assert.equal(ids.at(-1), 'i40', 'the aborted Load more page is part of the re-read');
|
||
assert.equal(c.state.more, 'idle');
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 4, 'load, the aborted more, and the re-read of two pages');
|
||
});
|
||
|
||
test('M7: a failed re-read that took over Load more clears its loading state', async () => {
|
||
const state = { first: forty.slice(0, 40) };
|
||
const { c } = await loaded(deepRoutes(state));
|
||
state.moreGate = new Promise(() => {});
|
||
c.loadMore();
|
||
await settle();
|
||
assert.equal(c.state.more, 'loading');
|
||
state.moreGate = null;
|
||
state.gate = Promise.resolve();
|
||
state.fail = fail(500, 'review-failed');
|
||
await c.refresh();
|
||
await settle();
|
||
assert.equal(c.state.more, 'idle', 'Load more can be clicked again');
|
||
assert.equal(c.state.warning, COPY_REFRESH_FAILED);
|
||
});
|
||
const COPY_REFRESH_FAILED = 'The review queue could not be read again. Reload the page to see the latest images.';
|
||
|
||
test('M3: Apply fences on the epoch of the detail it decides', async () => {
|
||
const OTHER = '22222222-2222-4222-8222-222222222222';
|
||
const { c, h } = await loaded({ [`POST ${OBSERVE}`]: () => response(200, { ...observed(), library_epoch: OTHER }) });
|
||
assert.equal(await c.apply(), false);
|
||
await settle();
|
||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).length, 0, 'a restored storage sends no decision');
|
||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + 'capture-1']);
|
||
assert.equal(h.of('GET', `${ITEMS}?`).length, 2, 'the queue is read again');
|
||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 2, 'the open item is read again under the new storage');
|
||
assert.equal(c.state.phase, 'idle');
|
||
});
|
||
|
||
test('M3: the decision carries the detail epoch', async () => {
|
||
const { c, h } = await loaded();
|
||
assert.equal(await c.apply(), true);
|
||
assert.equal(h.of('POST', `${ITEMS}/one/decision`)[0].body.expected_epoch, EPOCH);
|
||
});
|
||
|
||
test('M3: a quiet re-read under another epoch reads the open item again', async () => {
|
||
const OTHER = '22222222-2222-4222-8222-222222222222';
|
||
let epoch = EPOCH;
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}`]: () => response(200, page(QUEUE, { library_epoch: epoch })),
|
||
[`GET ${ITEMS}/one`]: () => response(200, detailOf(QUEUE[0], { library_epoch: epoch })),
|
||
});
|
||
epoch = OTHER;
|
||
await c.refresh();
|
||
await settle();
|
||
assert.equal(h.of('GET', `${ITEMS}/one`).length, 2);
|
||
assert.equal(c.state.detail.library_epoch, OTHER);
|
||
});
|
||
|
||
test('M4: leaving during the observe releases the capture it then answers', async () => {
|
||
let answer;
|
||
const { c, h } = await loaded({ [`POST ${OBSERVE}`]: () => new Promise(resolve => { answer = resolve; }) });
|
||
const applying = c.apply();
|
||
await settle();
|
||
c.dispose();
|
||
answer(response(200, observed('late')));
|
||
assert.equal(await applying, false);
|
||
await settle();
|
||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + 'late']);
|
||
assert.equal(h.of('POST', `${ITEMS}/one/decision`).length, 0);
|
||
});
|
||
|
||
test('M5: an unanswered decision that landed finishes as recorded, not as decided elsewhere', async () => {
|
||
let queue = QUEUE;
|
||
const { c, h } = await loaded({
|
||
[`GET ${ITEMS}`]: () => response(200, page(queue)),
|
||
[`POST ${ITEMS}/one/decision`]: () => Promise.reject(new TypeError('Failed to fetch')),
|
||
});
|
||
await c.apply();
|
||
assert.equal(c.state.canRetry, true);
|
||
queue = [QUEUE[1]]; // the server did record it: its change arrives
|
||
await c.refresh();
|
||
await settle();
|
||
assert.equal(c.state.phase, 'idle');
|
||
assert.equal(c.state.decisions, 1);
|
||
assert.equal(c.state.selectedId, 'two');
|
||
assert.equal(c.state.toast, 'Association accepted.');
|
||
assert.doesNotMatch(c.state.warning, /decided elsewhere/);
|
||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the server used the capture');
|
||
});
|