Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-state.test.js
T
Vito 56d0a4904c feat(collections): COLLECTIONS sidebar, collection page and saved views (#331, PR 2) (#410)
* feat(saved-views): keep a collection in a saved view

Images saved views gain a collection key (#331 D140): empty or a collection
id of 1 to 9 digits. Writes refuse favorites together with a collection;
older rows read an empty collection, and a stored row with both opens the
collection, as the listing does for a URL with both.

Part of #331

* feat(collections): store collections in the library database

* feat(collections): add, remove and rename collections and keep members through duplicate cleanup

* feat(collections): add collection routes and the Images collection filter

* feat(images): store images' own categories in the library database (#332)

One new table, image_categories, and its category index, created empty
by a forward-only migration that reads and writes only its own objects.
Startup checks the structure; restore also checks the values. The store
sets, clears and reads own categories by file key (the deepest output
source holding the file and the path below it), and copies a removed
duplicate's category to a kept copy that has none.

* feat(images): duplicate cleanup keeps images' own categories (#332)

The kept copy's own category wins; a kept copy without one receives the
removed copy's, keyed by the deepest output source holding each file.
The removed copy's row is never deleted, so a give-back or a failed
delete loses nothing. The job log reports how many were kept.

* feat(images): set, remove and list images' own categories (#332)

PUT /prompt_manager/images/category sets the own category of up to 500
listed files, DELETE removes it (listed or not), and
GET /prompt_manager/images/categories lists the values whose file is
listed. Targets are the Images listing identity of an output source,
checked by media_target (shared with Collections). The routes never
touch prompts, recipes or links; POST /prompt_manager/bulk/category is
unchanged.

* fix(images): key symlinked output folders and keep key work off the event loop (#332)

Review fixes: a source matches by its configured spelling too, so a
detected output folder that is a symlink keys its files (routes and
duplicate cleanup). File keys are computed in the executor. A path no
source holds is a plain 403, a lone surrogate a 400. The install repairs
a table that lacks its category index instead of refusing to open.

* fix(collections): address review findings

* feat(images): set or remove a category on all N matching images (#332)

The category routes also accept the Images listing filter of "Select
all N matching" (#392) instead of explicit items, resolved now and
capped at 500 images like an explicit selection.

* feat(collections): browse a collection from the sidebar

The sidebar gains the concept's COLLECTIONS group (D136): one row per
collection with its folder icon, name and listed-member count, without the
PROPOSED badge; the label shows once a collection exists. A row opens
#/collection/<id>, the Images page filtered to that collection (D137): the
collection's name as h1, Collection as breadcrumb and title, Edit to rename
or delete it through the saved views' Edit dialog with collection nouns, and
a note counting members the sources do not list now. An unknown or deleted
collection shows a page that says so with a link to Images. A saved view on
a deleted collection lists nothing and says so (MC6, D140). The list is read
again after a restore and the counts 300 ms after a change.

Part of #331

* test(collections): compare the COLLECTIONS group with the concept

The concept hides COLLECTIONS at 950 px and below, which would leave a phone
no way to open a collection; production shows the rows there in the wrapped
layout of every other sidebar nav (D136). The shell comparison now compares
the group instead of filtering it out: the concept fixture seeds Landscapes
(12) and Experiments (8), the rows compare by name order, and at phone width
the nav compares against the concept's own sidebar-nav layout. The
collection rows are checked at 1600x1100 and 390x844 in both themes.

Part of #331

* test(vendor): count the #332 host case in the execution matrix

image-attribution.spec.mjs gains the image category route case, so the
execution config lists 92 tests (3 attribution cases per project).

* test(browser): install the slideshow clock before wrapping setTimeout

The slideshow fixture wrapped window.setTimeout before page.clock.install(),
so the viewer's timer ran on real time and clock.runFor() did not drive it;
a late WebKit timer failed 'slideshow exposes exact state labels' on CI.

* fix(collections): address review findings

- A list read superseded by a newer one (the 300 ms count read, a restore)
  no longer shows "could not be loaded": the collection and saved-view
  pages wait for the list to settle.
- A listing that finds the open collection gone shows the deleted page once
  the list no longer holds it; when the list read fails or still holds it,
  the page keeps the listing's error and Retry instead of staying blank, and
  each failed read is reported again.
- The missing-members note follows every list read and goes away at zero.
- A rename that settles after its Edit dialog closed leaves any dialog
  opened since alone.
- The deleted-collection, list-failed and saved-view failure pages focus
  their title after a route change, as every page does.

Part of #331

* test(coverage): keep main's floors for files this PR does not touch

coverage:update raised nine floors of untouched files from one run;
metadata-autocomplete.js branches then measured 84.84 against 85.

* test(legacy): record the image category routes in the legacy contract snapshot (#332)

#332 adds PUT/DELETE /prompt_manager/images/category and
GET /prompt_manager/images/categories; the legacy pages' routes are
unchanged.

* test(duplicates): a removed copy with a collection and a category moves both (#331, #332)
2026-10-28 21:45:09 +00:00

340 lines
20 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', 'review']) {
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), and every section of it from S12 (D94 retired), not the route.
for (const id of ['images', 'library', 'tags', 'inspect', 'overview', 'activity', 'review', '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},
review: {total: 3}});
assert.ok(counts instanceof Map);
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7], ['review', 3]]);
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],
['review', 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], ['review', null], ['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], ['review', null]]);
});
test('nav counts issue five 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};
if (url.startsWith('/prompt_manager/review/items')) return {success: true, total: 2};
return {success: true, total: 12};
}};
const counts = await loadNavCounts(api, signal);
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5], ['review', 2]]);
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',
'/prompt_manager/review/items']);
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();
});
test('Needs review is a native SAVED VIEWS route titled Review, with its own count', async () => {
const { resolveRoute, isHandoff, routeTitle, routes, loadNavCounts, REVIEW_WRITE_COUNTS, NAV_COUNT_ROUTES } = await source;
const route = resolveRoute('#/review');
assert.equal(route.id, 'review');
assert.equal(route.label, 'Needs review');
assert.equal(routeTitle(route), 'Review');
assert.equal(isHandoff(route), false);
// No href: Overview lists no legacy card for it.
assert.equal(routes.find(item => item.id === 'review').href, undefined);
assert.ok(NAV_COUNT_ROUTES.includes('review'));
assert.deepEqual([...REVIEW_WRITE_COUNTS], ['review']);
assert.ok(Object.isFrozen(REVIEW_WRITE_COUNTS));
const calls = [];
const api = {get: async url => { calls.push(url); return {success: true, total: 4}; }};
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, REVIEW_WRITE_COUNTS);
assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']);
assert.deepEqual([...counts], [['review', 4]]);
});
// Collections (#331 D137): `#/collection/<id>` opens Images filtered to one collection; its breadcrumb is "Collection".
test('collection links resolve to their id, title Collection, and re-render only when the id changes', async () => {
const {resolveRoute,mountRouter,isHandoff,routeTitle,routes,COLLECTION_ROUTE}=await source;
assert.deepEqual(COLLECTION_ROUTE,{id:'collection',label:'Collection'});
assert.ok(Object.isFrozen(COLLECTION_ROUTE));
const route=resolveRoute('#/collection/7');
assert.deepEqual([route.id,route.collectionId,route.hash,routeTitle(route)],['collection',7,'#/collection/7','Collection']);
assert.equal(isHandoff(route),false);
assert.equal(resolveRoute('#/collection/999999999').collectionId,999999999);
assert.equal(routes.some(item=>item.id==='collection'),false,'a collection is not an Overview card or a WORKSPACE link');
// The listing's and routes' id rule: 1 to 9 digits without a leading zero.
for(const hash of ['#/collection','#/collection/','#/collection/0','#/collection/07','#/collection/-2','#/collection/1.5',
'#/collection/1234567890','#/collection/3/x','#/collection/abc','#/collections/3']) {
assert.equal(resolveRoute(hash).id,'images',hash);
}
const win=new EventTarget();win.location={hash:'#/collection/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.collectionId]));
win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
assert.deepEqual(seen,[['collection',3],['collection',4],['saved',undefined],['collection',4]]);
dispose();
});