Files
ComfyAssets-ComfyUI_PromptM…/tests/js/workspace-saved-views.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

218 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Saved views (#249 D16): the state whitelist, the concept's default name, the listing parser and the shell's store.
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/workspace/saved-views.mjs');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true, collection: '' };
assert.deepEqual(normalizeViewState('images', full), full);
// #331 D140: a collection id is '' or 1 to 9 digits without a leading zero; an old view without the key reads ''.
for (const collection of ['1', '12', '999999999']) {
assert.equal(normalizeViewState('images', { collection }).collection, collection);
}
for (const collection of ['0', 'x', 12, '012', '1234567890', null]) {
assert.equal(normalizeViewState('images', { collection }).collection, '');
}
// Favorites and a collection are never both a view's filter: the collection wins, as the listing's URL does.
assert.deepEqual(normalizeViewState('images', { ...full, collection: '4' }), { ...full, favorites: false, collection: '4' });
// D51: favorites is a boolean; anything else is the whole listing.
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
// Each rejected value falls back on its own; the rest of the view still applies.
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source, folder or media, and no Name Z–A.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
const state = normalizeViewState('library', { tags });
state.tags.push('two');
assert.deepEqual(tags, ['one']);
assert.throws(() => normalizeViewState('tags', {}), TypeError);
});
test('the default name is the concept search name, else My workspace view, within the name limit', async () => {
const { defaultViewName } = await source;
assert.equal(defaultViewName('neon'), 'Search: neon');
assert.equal(defaultViewName(' neon city '), 'Search: neon city');
assert.equal(defaultViewName(''), 'My workspace view');
assert.equal(defaultViewName(' '), 'My workspace view');
assert.equal(defaultViewName(undefined), 'My workspace view');
assert.equal(defaultViewName('x'.repeat(500)).length, 100);
});
test('the listing keeps usable views in order and normalizes their state', async () => {
const { parseSavedViews, parseSavedView, savedViewHash } = await source;
const views = parseSavedViews({ success: true, views: [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 0, page: 'images', name: 'Bad id', state: {} },
{ id: 4, page: 'tags', name: 'Bad page', state: {} },
{ id: 5, page: 'images', name: ' ', state: {} },
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
] });
assert.deepEqual(views, [
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
]);
assert.equal(parseSavedView(null), null);
assert.throws(() => parseSavedViews({ success: true }), TypeError);
assert.equal(savedViewHash(12), '#/saved/12');
});
test('page filters and saved states convert both ways', async () => {
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
const state = imagesViewState(filters);
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
assert.deepEqual(imagesFilters(state), filters);
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
// #331 D140: the open collection round-trips; a missing one is ''.
const inCollection = { ...filters, favorites: false, collection: '7' };
assert.equal(imagesViewState(inCollection).collection, '7');
assert.deepEqual(imagesFilters(imagesViewState(inCollection)), inCollection);
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
});
function fakeApi() {
const calls = [];
const pending = [];
const api = {
calls,
get(url, options) {
calls.push(['GET', url, options.cache]);
return new Promise((resolve, reject) => pending.push({ resolve, reject, signal: options.signal }));
},
post(url, body) {
calls.push(['POST', url, body]);
return Promise.resolve({ success: true, view: { id: 9, ...body } });
},
patch(url, body) {
calls.push(['PATCH', url, body]);
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
},
del(url) {
calls.push(['DELETE', url]);
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
},
};
return { api, pending };
}
const flush = () => new Promise(resolve => setImmediate(resolve));
test('the store reports loads, keeps the last views after a failed read, and a newer read supersedes', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const controller = new AbortController();
const store = createSavedViews(api, controller.signal);
const seen = [];
store.subscribe(next => seen.push([next.status, next.views.map(view => view.id)]));
store.load();
const settled = store.settled();
pending[0].resolve({ success: true, views: [{ id: 2, page: 'images', name: 'A', state: {} }] });
assert.equal((await settled).status, 'ready');
assert.deepEqual(store.find(2).state, IMAGE_DEFAULTS);
assert.equal(store.find(3), null);
assert.deepEqual(api.calls[0], ['GET', SAVED_VIEWS_URL, 'no-store']);
store.load();
pending[1].reject(new Error('offline'));
await flush();
assert.equal(store.status, 'error');
assert.deepEqual(store.views.map(view => view.id), [2]);
// A second read aborts the first; only the newer one lands.
store.load();
store.load();
assert.equal(pending[2].signal.aborted, true);
pending[2].resolve({ success: true, views: [] });
pending[3].resolve({ success: true, views: [{ id: 5, page: 'library', name: 'B', state: {} }] });
await flush();
assert.deepEqual(store.views.map(view => view.id), [5]);
assert.deepEqual(seen, [['loading', []], ['ready', [2]], ['loading', [2]], ['error', [2]], ['loading', [2]], ['loading', [2]], ['ready', [5]]]);
controller.abort();
});
test('creating a view posts the full state and re-reads the list', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const created = store.create('library', 'Neon', { q: 'neon' });
await flush();
assert.deepEqual(api.calls[0], ['POST', SAVED_VIEWS_URL, { page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } }]);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'library', name: 'Neon', state: { q: 'neon' } }] });
assert.deepEqual(await created, { id: 9, page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } });
assert.equal(store.find(9).name, 'Neon');
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
await assert.rejects(broken.create('images', 'X', {}), TypeError);
});
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
const { createSavedViews, SAVED_VIEWS_URL } = await source;
const { api, pending } = fakeApi();
const store = createSavedViews(api);
const renamed = store.rename(9, 'Dusk');
await flush();
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
assert.equal(store.find(9).name, 'Dusk');
const removed = store.remove(9);
await flush();
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
pending[1].resolve({ success: true, views: [] });
assert.equal(await removed, 9);
assert.equal(store.find(9), null);
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
const gone = store.remove(4);
await flush();
pending[2].resolve({ success: true, views: [] });
assert.equal(await gone, 4);
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
const failed = store.remove(4);
await flush();
pending[3].resolve({ success: true, views: [] });
await assert.rejects(failed, /Locked/);
api.patchResult = Promise.resolve({ success: true, view: null });
const invalid = store.rename(4, 'X');
await flush();
pending[4].resolve({ success: true, views: [] });
await assert.rejects(invalid, TypeError);
assert.equal(pending.length, 5);
assert.throws(() => store.rename(0, 'X'), TypeError);
assert.throws(() => store.remove('9'), TypeError);
});
test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)', async () => {
const { createSavedViews, stateFits, SavedViewTooLarge, MAX_STATE_BYTES } = await source;
assert.equal(MAX_STATE_BYTES, 4096);
assert.equal(stateFits(IMAGE_DEFAULTS), true);
// Each value is within its own limit; their multibyte UTF-8 together passes 4 KB.
const big = { ...IMAGE_DEFAULTS, q: 'é'.repeat(500), folder: 'é'.repeat(1024), tags: Array.from({ length: 20 }, () => 'é'.repeat(50)) };
assert.equal(stateFits(big), false);
const { api } = fakeApi();
const store = createSavedViews(api);
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
assert.deepEqual(api.calls, []);
});