* feat(system): run thumbnail generation as an Activity job
GET /prompt_manager/images/thumbnails/plan counts what a run would do
(targets, fresh, remaining past the cap, source labels) and writes
nothing. POST /prompt_manager/jobs/thumbnails {mode, size, quality}
starts the "thumbnails" job: admission guarded, cancellable at a file
boundary, outcome groups generated / skipped / errors. size is the
longest edge (128-2048), quality the encoder, and rebuild writes fresh
previews again through the same atomic rename, so a failed file keeps
its old preview and originals are only read.
The legacy gallery stream now starts or attaches to the same job and
streams its unchanged frames. A client that goes away no longer stops
the work; the gallery's Cancel asks the server to cancel the job.
* feat(activity): per-kind job descriptors
One descriptor table (activity-kinds.mjs) decides each job kind's pill
verb, toasts, line, tiles, outcomes, openable rows and labels; the
store re-exports it. The scan and image tagging views are unchanged.
A thumbnail job counts Generated, Skipped and Errors, reads
"Generating thumbnails" in the pill and "Thumbnails finished · N
generated · M errors" when it ends.
Any other kind (the health check, a held legacy-page operation) gets
the generic descriptor with no tiles and no Results, instead of the
scan's zero tiles. A running job without a known total shows an
indeterminate progress bar and no percentage, and only a job the
server can cancel shows Cancel.
* feat(system): review and start thumbnail jobs
Sources' Thumbnail generation -> Review options and Overview's Review
thumbnails open the concept's Thumbnail maintenance dialog: scope, quality
(preselected from Image loading quality) and preview size limit, a plan
line from the read-only thumbnail plan (D99) and Start, which starts the
thumbnails Activity job and opens it. A size outside 128-2048 requests
nothing; a refused start shows the busy link toast.
* fix(system): thumbnail job review fixes (#249 S5)
- The legacy stream's first frame is job {id, started}, sent after the
subscription so no runner frame is missed; it reads the registry after
prepare, so a job started in that gap is attached to, not refused.
- Every exit sets a truthful final status line; nothing to do never
reads "Processing file 0 of 0".
- Cancel is checked per file during the planning walk; a job cancelled
there publishes no start frame.
- The ffmpeg fallback keeps the chosen quality (-q:v) and the aspect
ratio; the placeholder uses the chosen JPEG settings.
- A locked preview on Windows reads "Preview in use by another program;
the old one was kept", not the raw WinError.
- Thumbnail work no longer defers the monitor's Needs review recording:
it writes previews only, never records.
- The disconnect test now proves the job outlives its stream (one worker,
9 files, three batches); the attach test waits on an event.
* fix(gallery): cancel the stream's own thumbnail job by id
The settings panel and the startup dialog cancel the job named by the
stream's job frame, and only one their stream started. A Cancel before
the frame waits for it, a double Cancel posts once, and a job another
tab or System started keeps running. The startup dialog now settles on
Cancel instead of staying on "Cancelling...".
* fix(activity): indeterminate pill bar and the nothing-to-do line
The pill's mini bar sweeps while a job's total is unknown instead of
showing an empty fill. A finished thumbnail job with nothing to do shows
the server's final line. Coverage floors raised for gallery.js and
activity.mjs.
94 lines
5.7 KiB
JavaScript
94 lines
5.7 KiB
JavaScript
// System › thumbnail review (#249 System S5 Task 14): the concept's "Thumbnail maintenance" dialog (app.js 1433–1437,
|
||
// `thumbnail-review`) on the thumbnail job (S5 Task 12). Pure parts: the option values the job takes, the preview size
|
||
// check, the Image loading quality preselection (Q3), the D99 plan line and the start outcome.
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const reviewModule = import('../../web/js/workspace/thumbnail-review.mjs');
|
||
|
||
test('the concept options map to the job values in the concept order', async () => {
|
||
const { SCOPE_OPTIONS, QUALITY_OPTIONS, SIZE_LIMIT } = await reviewModule;
|
||
assert.deepEqual(SCOPE_OPTIONS, [['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)']]);
|
||
assert.deepEqual(QUALITY_OPTIONS, [['medium', 'Medium · optimized'], ['high', 'High · original'], ['low', 'Low · fastest']]);
|
||
assert.deepEqual(SIZE_LIMIT, { min: 128, max: 2048, value: 512 });
|
||
});
|
||
|
||
test('size outside 128–2048 reports validity: only whole pixels in range are a size', async () => {
|
||
const { previewSize } = await reviewModule;
|
||
assert.equal(previewSize('512'), 512);
|
||
assert.equal(previewSize('128'), 128);
|
||
assert.equal(previewSize('2048'), 2048);
|
||
for (const text of ['', ' ', '127', '2049', '4096', '512.5', '-512', 'abc', '1e3', '0x200']) {
|
||
assert.equal(previewSize(text), null, text);
|
||
}
|
||
});
|
||
|
||
test('Image loading quality preselects the dialog quality (Q3); anything else is Medium', async () => {
|
||
const { preselectedQuality } = await reviewModule;
|
||
assert.equal(preselectedQuality('low'), 'low');
|
||
assert.equal(preselectedQuality('high'), 'high');
|
||
assert.equal(preselectedQuality('medium'), 'medium');
|
||
assert.equal(preselectedQuality(undefined), 'medium');
|
||
assert.equal(preselectedQuality('original'), 'medium');
|
||
});
|
||
|
||
test('the plan line counts what a missing-only run generates and what is already up to date (D99)', async () => {
|
||
const { planText } = await reviewModule;
|
||
assert.equal(planText({ mode: 'missing', targets: 12, fresh: 30, remaining: 0 }), '12 previews to generate · 30 already up to date.');
|
||
assert.equal(planText({ mode: 'missing', targets: 1, fresh: 1, remaining: 0 }), '1 preview to generate · 1 already up to date.');
|
||
assert.equal(planText({ mode: 'missing', targets: 0, fresh: 0, remaining: 0 }), '0 previews to generate · 0 already up to date.');
|
||
assert.equal(planText({ mode: 'missing', targets: 10000, fresh: 5, remaining: 2500 }),
|
||
'10,000 previews to generate · 5 already up to date · 2,500 more on the next run.');
|
||
});
|
||
|
||
test('Rebuild all says every preview is regenerated (D99)', async () => {
|
||
const { planText } = await reviewModule;
|
||
assert.equal(planText({ mode: 'rebuild', targets: 42, fresh: 30, remaining: 0 }), 'Every preview is regenerated: 42 files.');
|
||
assert.equal(planText({ mode: 'rebuild', targets: 1, fresh: 1, remaining: 0 }), 'Every preview is regenerated: 1 file.');
|
||
assert.equal(planText({ mode: 'rebuild', targets: 10000, fresh: 9000, remaining: 3 }),
|
||
'Every preview is regenerated: 10,000 files · 3 more on the next run.');
|
||
});
|
||
|
||
test('an answer without counts is a failed plan, never zeros', async () => {
|
||
const { planText, PLAN_FAILED } = await reviewModule;
|
||
assert.equal(planText(null), PLAN_FAILED);
|
||
assert.equal(planText({ mode: 'missing', targets: 2 }), PLAN_FAILED);
|
||
assert.equal(planText({ mode: 'missing', targets: -1, fresh: 0, remaining: 0 }), PLAN_FAILED);
|
||
assert.equal(planText({ mode: 'other', targets: 1, fresh: 0, remaining: 0 }), PLAN_FAILED);
|
||
});
|
||
|
||
const scopeOf = () => ({ signal: new AbortController().signal });
|
||
|
||
test('Start starts the thumbnail job with the chosen options and opens it in Activity', async () => {
|
||
const { startThumbnails } = await reviewModule;
|
||
const calls = [];
|
||
const navigated = [];
|
||
const store = { start: async (kind, body) => { calls.push([kind, body]); return { job: { id: 'a'.repeat(32) } }; } };
|
||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'rebuild', size: 1024, quality: 'high' },
|
||
navigate: hash => navigated.push(hash), notify: () => assert.fail('no toast of its own') });
|
||
assert.deepEqual(calls, [['thumbnails', { mode: 'rebuild', size: 1024, quality: 'high' }]]);
|
||
assert.deepEqual(navigated, [`#/activity/${'a'.repeat(32)}`]);
|
||
});
|
||
|
||
test('a refused start shows the busy link toast and stays', async () => {
|
||
const { startThumbnails } = await reviewModule;
|
||
const toasts = [];
|
||
const store = { start: async () => ({ busy: { id: 'b'.repeat(32), title: 'Scan for metadata' } }) };
|
||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing', size: 512, quality: 'medium' },
|
||
navigate: () => assert.fail('stays'), notify: message => toasts.push(message) });
|
||
assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${'b'.repeat(32)}` }, '.']]);
|
||
});
|
||
|
||
test('a failed start says so; a start after the section left says nothing', async () => {
|
||
const { startThumbnails, START_FAILED } = await reviewModule;
|
||
const toasts = [];
|
||
const store = { start: async () => { throw new Error('offline'); } };
|
||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing', size: 512, quality: 'medium' },
|
||
navigate: () => assert.fail('stays'), notify: message => toasts.push(message) });
|
||
assert.deepEqual(toasts, [START_FAILED]);
|
||
const gone = new AbortController();
|
||
gone.abort();
|
||
await startThumbnails({ store, scope: { signal: gone.signal }, options: { mode: 'missing', size: 512, quality: 'medium' },
|
||
navigate: () => assert.fail('stays'), notify: () => assert.fail('silent') });
|
||
});
|