* feat(system): read-only health check and truthful overview counts POST /prompt_manager/system/health runs the health check off-loop as a read registry job of kind "health" (one at a time, beside a scan, not cancellable, no file outcomes) and keeps the last report; GET returns it or null. The check reads one snapshot on its own mode=ro connection, closed in finally: integrity_check(100), foreign_key_check, stored hashes against utils.hashing, duplicate pair rows, the legacy consistency queries (now shared with check_consistency), and image links whose file is gone, counted only under readable sources. An unavailable source, or the absent Needs-review queue, makes its check unavailable and the report incomplete instead of producing a count. GET /prompt_manager/system/overview returns Library-eligible positive prompts and active negative variants, indexed images, the last health summary or null, backup null, the Needs-review count null, the effective sources with whether they were auto-detected, and the thumbnail plan's missing count. GET /prompt_manager/system/statistics lists runs and average rating per category of Library parents, with the /stats totals. * feat(system): native System route, drafts and shared review dialog - #/system/<section> renders the concept's System shell: section nav, settings layout and Save row with truthful status (D92); each section not delivered yet hands off to its legacy tools (D94). Overview's System card opens #/system/overview (Q12). - A module-level draft store saves browser, then host (expected_revision), then runtime; failed domains stay dirty, a stale revision is a conflict. - System row builders (setting, operation, section nav) on the scope. - openReviewDialog and backupNotice extracted from the scan review, which is now a thin caller with identical markup. - Overview images gain a "rated" count (images whose linked prompt is rated); the missing-files health check reports per source, so partial availability is visible. * feat(system): native Overview with health report and statistics System > Overview renders the concept's metrics and its Library health and Workspace preferences surfaces from GET /system/overview: real counts, "Not checked" until a health check ran, no invented review count, backup or thumbnail status (D93). Explore opens "Usage statistics" with the real runs and ratings per category and the Library totals (D96); the statistics route now also returns rated_images. Maintenance shows the concept's notice with Run health checks above its hand-off: the read-only check runs as a registry read job and opens the "Health report" with a chip per real result, missing files per source (D95). A load failure shows Retry and no zeros; late answers never repaint a newer section. Adds the System concept comparison (shell, Overview, both dialogs, the notice, mutation checks), the System workflow spec and its fixture hooks, and the TESTING.md "Native System" section. * test(system): coverage floors for the native Overview Adds the system-overview.mjs baseline and raises the system.mjs floors to their measured coverage; no other floor changes. * fix(system): keep health reports per database and off the event loop - A restore that replaces the database forgets the stored health report, and a check that started before the swap is not kept (database generation stamped at start). - The Overview no longer walks every source for the missing-thumbnail count; the page reads /images/thumbnails/status on its own. - Image sources for the health check and the Overview resolve in the executor; a failure to start the check answers the coded envelope. - Any exception fails only its own check; missing files reads its rows in the snapshot and stats the files after it ended. - The read-only URI keeps Windows UNC paths (empty authority). - An integrity count at SQLite's limit is flagged as capped. * fix(system): thumbnail row read, tracked health job and guarded host save - The Overview's thumbnail row is its own read of the thumbnail status: "Checking thumbnails…" until it answers, unavailable when it fails. - The health job is tracked in the Activity store (new store.track) and followed through its event stream instead of polling every 150 ms. - A host save before a read returned a revision fails without a request; System reads the host values on mount and after a conflict. - "100+ problems found" when the integrity count is capped. - D-NEW-S2-5 is D123 everywhere; the unused Overview hand-off is gone. - The concept spec builds the health report from the concept's own rows and asserts the full unmapped path list; surface tops pin the 18px offset. - TESTING.md: D96's reason, the read-only (WAL/SHM) standard, the UNC URI and the new behavior. Coverage floors raised for the touched files. * fix(system): make two health tests portable to Windows - Windows forbids "?" in file names, so the URI-character open test uses "#", "%" and a space there. - ntpath.realpath on Python 3.11+ returns a NUL-byte path instead of raising, so the bad-path test raises as POSIX does to reach the missing-files failure path on every platform.
291 lines
16 KiB
JavaScript
291 lines
16 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const source = import('../../web/js/workspace/state.mjs');
|
|
|
|
test('workspace routes resolve only supported deep links', async () => {
|
|
const { resolveRoute } = await source;
|
|
for (const route of ['overview', 'images', 'library', 'tags', 'inspect', 'system', 'activity']) {
|
|
assert.equal(resolveRoute(`#/${route}`).id, route);
|
|
}
|
|
for (const hash of ['', '#', '#/unknown', '#/images/other', '#/<script>', '#/activity/abc', `#/activity/${'A'.repeat(32)}`]) {
|
|
assert.equal(resolveRoute(hash).id, 'images');
|
|
}
|
|
});
|
|
|
|
test('tags is a native route titled Tags; only unbuilt routes hand off to legacy tools', async () => {
|
|
const { resolveRoute, routeTitle, isHandoff } = await source;
|
|
const tags = resolveRoute('#/tags');
|
|
assert.equal(tags.label, 'Tags & categories');
|
|
assert.equal(routeTitle(tags), 'Tags');
|
|
assert.equal(tags.href, '/prompt_manager/admin#/tags', 'Overview keeps the existing tag manager card');
|
|
for (const id of ['images', 'library', 'inspect', 'overview', 'system']) {
|
|
const route = resolveRoute(`#/${id}`);
|
|
assert.equal(routeTitle(route), route.label);
|
|
}
|
|
assert.equal(routeTitle(resolveRoute('#/library/7')), 'Prompt library');
|
|
// System is native from S2 (#249 System): its undelivered sections hand off per section (D94), not the route.
|
|
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'system']) assert.equal(isHandoff(resolveRoute(`#/${id}`)), false, id);
|
|
});
|
|
|
|
test('system resolves its sections; #/overview stays the production hub', async () => {
|
|
const { resolveRoute, routeTitle, isHandoff, routes } = await source;
|
|
// `#/system` is the Overview section and keeps the sidebar's hash.
|
|
const system = resolveRoute('#/system');
|
|
assert.deepEqual([system.id, system.section, system.hash, routeTitle(system), isHandoff(system)],
|
|
['system', 'overview', '#/system', 'System', false]);
|
|
for (const section of ['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups', 'diagnostics']) {
|
|
const route = resolveRoute(`#/system/${section}`);
|
|
assert.deepEqual([route.id, route.section, route.hash], ['system', section, `#/system/${section}`]);
|
|
}
|
|
// An unknown child is the Overview section with a replaced hash.
|
|
for (const hash of ['#/system/general', '#/system/', '#/system/__proto__', '#/system/backups/1', '#/system/<b>']) {
|
|
const route = resolveRoute(hash);
|
|
assert.deepEqual([route.id, route.section, route.hash], ['system', 'overview', '#/system/overview'], hash);
|
|
}
|
|
// D28: the production hub is untouched.
|
|
const hub = resolveRoute('#/overview');
|
|
assert.deepEqual([hub.id, hub.section], ['overview', undefined]);
|
|
// Q12: Overview's System card opens the native System Overview, with no legacy href or action.
|
|
const card = routes.find(route => route.id === 'system');
|
|
assert.equal(card.href, '#/system/overview');
|
|
assert.equal(card.native, true);
|
|
assert.equal(card.action, 'Open System');
|
|
});
|
|
|
|
test('the router renders again when only the System section changes', async () => {
|
|
const { mountRouter } = await source;
|
|
const win = new EventTarget();
|
|
win.location = { hash: '#/system' };
|
|
const replacements = [];
|
|
win.history = { state: null, replaceState(state, unused, hash) { replacements.push(hash); win.location.hash = hash; } };
|
|
const seen = [];
|
|
const dispose = mountRouter(win, route => seen.push([route.id, route.section]));
|
|
for (const hash of ['#/system/appearance', '#/system/appearance', '#/system/bogus', '#/system/overview', '#/images']) {
|
|
win.location.hash = hash;
|
|
win.dispatchEvent(new Event('hashchange'));
|
|
}
|
|
assert.deepEqual(seen, [['system', 'overview'], ['system', 'appearance'], ['system', 'overview'], ['images', undefined]]);
|
|
assert.deepEqual(replacements, ['#/system/overview']);
|
|
dispose();
|
|
});
|
|
|
|
test('inspect is the native Inspect a file page with the concept description', async () => {
|
|
const { resolveRoute, routeTitle, isHandoff } = await source;
|
|
const inspect = resolveRoute('#/inspect');
|
|
assert.equal(inspect.label, 'Inspect a file');
|
|
assert.equal(routeTitle(inspect), 'Inspect a file');
|
|
// The concept's heading description (app.js renderMetadata 743).
|
|
assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.');
|
|
assert.equal(isHandoff(inspect), false);
|
|
// Overview keeps its card for the legacy metadata page, which stays working.
|
|
assert.equal(inspect.href, '/prompt_manager/gallery.html');
|
|
assert.equal(inspect.action, 'Open existing metadata inspector');
|
|
});
|
|
|
|
test('a job of the Activity page is its own deep link', async () => {
|
|
const { resolveRoute, routeTitle, isHandoff } = await source;
|
|
const id = '0123456789abcdef'.repeat(2);
|
|
const route = resolveRoute(`#/activity/${id}`);
|
|
assert.deepEqual([route.id, route.jobId, route.hash, routeTitle(route), isHandoff(route)],
|
|
['activity', id, `#/activity/${id}`, 'Activity', false]);
|
|
assert.equal(resolveRoute('#/activity').jobId, undefined);
|
|
assert.equal(route.href, undefined, 'Activity has no legacy card on Overview');
|
|
// The router renders again when only the job changes, and not for the same job.
|
|
const { mountRouter } = await source;
|
|
const other = 'f'.repeat(32);
|
|
const win = new EventTarget();
|
|
win.location = { hash: `#/activity/${id}` };
|
|
win.history = { state: null, replaceState(state, unused, hash) { win.location.hash = hash; } };
|
|
const seen = [];
|
|
const dispose = mountRouter(win, next => seen.push([next.id, next.jobId]));
|
|
win.dispatchEvent(new Event('hashchange'));
|
|
win.location.hash = `#/activity/${other}`; win.dispatchEvent(new Event('hashchange'));
|
|
win.location.hash = '#/activity'; win.dispatchEvent(new Event('hashchange'));
|
|
assert.deepEqual(seen, [['activity', id], ['activity', other], ['activity', undefined]]);
|
|
dispose();
|
|
});
|
|
|
|
test('statistics preserve zero and reject incomplete or invalid counts', async () => {
|
|
const { summarizeStats } = await source;
|
|
assert.deepEqual(summarizeStats({total_prompts:0, total_images:12, total_tags:3}), [
|
|
['Prompts', 0], ['Images', 12], ['Tags', 3],
|
|
]);
|
|
for (const stats of [undefined, null, {}, {total_prompts:0, total_images:-1, total_tags:0},
|
|
{total_prompts:'2', total_images:0, total_tags:0},
|
|
{total_prompts:Infinity, total_images:0, total_tags:0},
|
|
{total_prompts:0, total_images:1.5, total_tags:0}]) {
|
|
assert.throws(() => summarizeStats(stats), /statistics/i);
|
|
}
|
|
});
|
|
|
|
test('router normalizes unknown routes, avoids duplicate renders, and disposes its listener', async () => {
|
|
const { mountRouter } = await source;
|
|
const win = new EventTarget();
|
|
win.location = {hash:'#/missing'};
|
|
const replacements = [];
|
|
win.history = {state: {keep:1}, replaceState(state, unused, hash) {
|
|
replacements.push([state, hash]); win.location.hash = hash;
|
|
}};
|
|
const seen = [];
|
|
const dispose = mountRouter(win, route => seen.push(route.id));
|
|
assert.deepEqual(seen, ['images']);
|
|
assert.deepEqual(replacements, [[{keep:1}, '#/images']]);
|
|
win.location.hash = '#/library';
|
|
win.dispatchEvent(new Event('hashchange'));
|
|
win.dispatchEvent(new Event('hashchange'));
|
|
assert.deepEqual(seen, ['images', 'library']);
|
|
dispose();
|
|
dispose();
|
|
win.location.hash = '#/system';
|
|
win.dispatchEvent(new Event('hashchange'));
|
|
assert.deepEqual(seen, ['images', 'library']);
|
|
});
|
|
|
|
test('positive library deep links preserve the selected identity without duplicate route renders', async () => {
|
|
const {resolveRoute,mountRouter}=await source;
|
|
assert.equal(resolveRoute('#/library/12').positiveId,12);
|
|
for(const hash of ['#/library/0','#/library/-1','#/library/1.5','#/library/9007199254740992','#/library/1/extra']) assert.equal(resolveRoute(hash).id,'images');
|
|
const win=new EventTarget();win.location={hash:'#/library/12'};
|
|
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
|
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
|
|
assert.equal(win.location.hash,'#/library/12');
|
|
win.dispatchEvent(new Event('hashchange'));assert.equal(seen.length,1);
|
|
win.location.hash='#/library/13';win.dispatchEvent(new Event('hashchange'));
|
|
win.location.hash='#/library';win.dispatchEvent(new Event('hashchange'));
|
|
assert.deepEqual(seen,[['library',12],['library',13],['library',undefined]]);dispose();
|
|
});
|
|
|
|
test('nav counts reject negative, non-integer and missing totals', async () => {
|
|
const { summarizeNavCounts } = await source;
|
|
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
|
|
assert.ok(counts instanceof Map);
|
|
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
|
|
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
|
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
|
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
|
const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid});
|
|
assert.equal(result.get('images'), null, JSON.stringify(invalid));
|
|
assert.equal(result.get('favorites'), null);
|
|
assert.equal(result.get('library'), 2);
|
|
assert.equal(result.get('tags'), null);
|
|
}
|
|
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
|
|
});
|
|
|
|
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
|
const { loadNavCounts } = await source;
|
|
let releaseImages;
|
|
const images = new Promise(resolve => { releaseImages = resolve; });
|
|
const api = {get: async url => {
|
|
if (url.includes('favorites=1')) return {success: true, total: 2};
|
|
if (url.startsWith('/prompt_manager/images')) return images;
|
|
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
|
|
throw new Error('Unavailable');
|
|
}};
|
|
const settled = [];
|
|
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
|
|
releaseImages({success: true, total: 40});
|
|
const counts = await done;
|
|
assert.deepEqual(settled.at(-1), ['images', 40]);
|
|
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
|
|
});
|
|
|
|
test('nav counts issue four bounded requests and drop only the failed count', async () => {
|
|
const { loadNavCounts } = await source;
|
|
const calls = [];
|
|
const signal = new AbortController().signal;
|
|
const api = {get: async (url, options) => {
|
|
calls.push([url, options.signal]);
|
|
if (url.startsWith('/prompt_manager/library')) throw new Error('Unavailable');
|
|
if (url.startsWith('/prompt_manager/vocabulary/tags')) return {success: true, total: 5};
|
|
return {success: true, total: 12};
|
|
}};
|
|
const counts = await loadNavCounts(api, signal);
|
|
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
|
|
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
|
assert.deepEqual(urls.map(url => url.pathname),
|
|
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
|
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
|
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
|
assert.equal(urls[0].searchParams.has('favorites'), false);
|
|
// D51: the Favorites count is the Favorites page's own listing.
|
|
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
|
|
assert.equal(urls[1].searchParams.get('favorites'), '1');
|
|
assert.ok(calls.every(([, used]) => used === signal));
|
|
});
|
|
|
|
test('a favorite change re-reads only the Favorites count', async () => {
|
|
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
|
|
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
|
|
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
|
|
const calls = [];
|
|
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
|
|
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
|
|
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
|
|
// A real zero is a count, unlike an outstanding read.
|
|
assert.deepEqual([...counts], [['favorites', 0]]);
|
|
});
|
|
|
|
test('the Favorites route is native and resolves by its hash', async () => {
|
|
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
|
|
const route = resolveRoute('#/favorites');
|
|
assert.equal(route.id, 'favorites');
|
|
assert.equal(route.label, 'Favorites');
|
|
assert.equal(routeTitle(route), 'Favorites');
|
|
assert.equal(isHandoff(route), false);
|
|
// No href: Overview lists no legacy card for it.
|
|
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
|
|
});
|
|
|
|
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
|
|
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
|
|
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
|
|
assert.ok(Object.isFrozen(LIBRARY_WRITE_COUNTS));
|
|
const calls = [];
|
|
const api = {get: async url => {
|
|
calls.push(new URL(url, 'http://host').pathname);
|
|
return {success: true, total: url.startsWith('/prompt_manager/library') ? 4 : 9};
|
|
}};
|
|
const settled = [];
|
|
const counts = await loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]), LIBRARY_WRITE_COUNTS);
|
|
assert.deepEqual(calls, ['/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
|
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
|
|
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
|
|
});
|
|
|
|
test('navigating to the current route re-renders it, as the concept go() does', async () => {
|
|
const {mountRouter,navigateTo}=await source;
|
|
const win=new EventTarget();win.location={hash:'#/library/12'};
|
|
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
|
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
|
|
navigateTo('#/library/12',win);
|
|
assert.deepEqual(seen,[['library',12],['library',12]]);
|
|
// A different route only sets the hash; its hashchange renders it once.
|
|
navigateTo('#/library/13',win);assert.equal(win.location.hash,'#/library/13');assert.equal(seen.length,2);
|
|
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
|
|
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
|
|
});
|
|
|
|
// Saved views (#249 D16): `#/saved/<id>` opens one persisted view; the shell resolves it to its page.
|
|
test('saved-view links resolve to their id and re-render only when the id changes', async () => {
|
|
const {resolveRoute,mountRouter,isHandoff,routes}=await source;
|
|
const route=resolveRoute('#/saved/7');
|
|
assert.deepEqual([route.id,route.savedId,route.hash],['saved',7,'#/saved/7']);
|
|
assert.equal(isHandoff(route),false);
|
|
assert.equal(routes.some(item=>item.id==='saved'),false,'saved views are not an Overview card or a WORKSPACE link');
|
|
for(const hash of ['#/saved','#/saved/0','#/saved/-2','#/saved/1.5','#/saved/9007199254740992','#/saved/3/x','#/saved/abc']) {
|
|
assert.equal(resolveRoute(hash).id,'images',hash);
|
|
}
|
|
const win=new EventTarget();win.location={hash:'#/saved/3'};
|
|
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
|
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.savedId]));
|
|
assert.equal(win.location.hash,'#/saved/3');
|
|
win.dispatchEvent(new Event('hashchange'));
|
|
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
|
|
win.location.hash='#/images';win.dispatchEvent(new Event('hashchange'));
|
|
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
|
|
dispose();
|
|
});
|