From 40dc0e541782ec996304e5820eefc27f3805a2e3 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 01:14:20 +0000 Subject: [PATCH 1/3] feat(collections): add to and remove from collections in the selection bar and the inspector (#331, PR 3) (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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/, 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. * feat(collections): add and remove from the selection bar Add to collection follows Set category in the Images selection bar and opens the concept's simpleInput ("Add selected images to collection", "Collection name", Add) with the existing names as a datalist. Remove from collection follows it while a collection filter is active and reloads the listing in place, keeping the checked files still listed. Every outcome uses the design's strings (section 7.4). The routes take at most 500 explicit files, so an "all N matching" selection is sent only when every match is loaded, checked and at most 500; otherwise the action toasts "Select at most 500 images." (D138, #331). * 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 * feat(collections): add and remove from the inspector and the viewer The shared inspector's footer ends with Collections… (folder icon) after Prompt history for a listed file, in the Images side panel and the viewer. Its dialog reads the file's membership, lists every collection as the concept's checkbox rows, takes an optional new name, and Save sends one request per change (new name, added, removed). A failure keeps the dialog with the membership read again. On a collection page a change to the open collection reloads the listing at once from the side panel and only when the viewer closes from the viewer. The inspector concept comparison draws the button into the concept after Prompt history (D139, #331). * test(collections): compare the collection page and dialog with the concept images-concept compares Add to collection in place in the selection bar (only Remove links… stays omitted) and the collection page's heading, toolbar and filter row against the concept's #collection-landscapes, with D137's Edit drawn into the concept. collections-concept compares the Add to collection dialog with the concept's simpleInput (D138: "Add", an empty field, the datalist of names), in both themes at 1600 and 390 px, with a mutation check. TESTING.md gains the Collections contract (database, keys, missing members, duplicate move, restore, routes, rules, limits, the PR 1 rulings and the all-matching ruling), D136-D140, and amends D28 and D8; docs/application-redesign.md gets the delivery-status line. The coverage baseline adds the two new modules; optional collection callbacks are null rather than no-op defaults so they do not count as uncovered functions. * 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. * fix(collections): address review findings - M1: after a failed Save the Collections dialog shows the membership loading and keeps Save disabled until the re-read lands, so a stale plan cannot be sent. - M2: the changes a Save made reach the page only when the dialog closes, so a collection page's reload cannot remove the dialog before its failure is read. - L4: a New collection name equal to a listed collection ignoring case is that collection's checkbox, checked; never an add then a remove. - L5: a name that cannot be a collection name is refused on the field; nothing is sent, the checkbox edits stay, no re-read. - L6: focus starts on the field and moves to the first checkbox once listed; closing after a re-render replaced the trigger focuses the inspector's current Collections… button or its heading (openContentDialog takes an optional focusTarget). - L3, L7: TESTING.md D138/D139 describe the stale-source and deleted collection outcomes and the above; the viewer spec asserts no listing request while it is open, and a browser test covers M2. * 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) * test(collections): map the Add to collection suggestions to the shared dialog's datalist (#331) After the merge with #332 PR 3 the input dialog places its datalist after the field with the id workspace-input-suggestions; the concept mapping follows. --- TESTING.md | 245 +++++++++++- docs/application-redesign.md | 1 + tests/browser/collections-concept.spec.mjs | 108 ++++++ tests/browser/full-inspector.spec.mjs | 25 +- tests/browser/images-concept.spec.mjs | 53 ++- tests/browser/inspector-concept.spec.mjs | 36 +- tests/browser/workspace-collections.spec.mjs | 363 ++++++++++++++++++ tests/browser/workspace-images.spec.mjs | 2 +- tests/browser/workspace-tagging-bulk.spec.mjs | 2 +- tests/js/workspace-file-inspector.test.js | 279 ++++++++++++++ tests/js/workspace-images-selection.test.js | 285 ++++++++++++++ tests/js/workspace-toolbar.test.js | 3 + tools/vendor/frontend-coverage-baseline.json | 12 + web/js/workspace/collections.mjs | 3 +- web/js/workspace/file-inspector.mjs | 19 +- web/js/workspace/images-collections.mjs | 76 ++++ web/js/workspace/images-selection.mjs | 40 +- web/js/workspace/images.mjs | 34 +- web/js/workspace/index.mjs | 2 +- web/js/workspace/inspector-collections.mjs | 189 +++++++++ web/js/workspace/media-viewer.mjs | 16 +- web/js/workspace/workspace-dialogs.mjs | 9 +- 22 files changed, 1758 insertions(+), 44 deletions(-) create mode 100644 tests/browser/collections-concept.spec.mjs create mode 100644 tests/js/workspace-images-selection.test.js create mode 100644 web/js/workspace/images-collections.mjs create mode 100644 web/js/workspace/inspector-collections.mjs diff --git a/TESTING.md b/TESTING.md index 63c45abc..3875439f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1087,7 +1087,8 @@ Recorded shell deviations: palette, then the OS. - **D5**: retired by Activity (#249 Activity-1). The concept's Activity button and Activity row are shown; their differences are D52 to D54. -- **D28**: no COLLECTIONS, and no Design review. The bottom nav is +- **D28**: no Design review. (Amended by #331: the COLLECTIONS group ships; its rows, + counts and phone layout are D136.) The bottom nav is the concept's Activity and System, plus the production Overview hub (its own outline home glyph, not the concept's `activity` pulse, and no count). The connection reads "Your library, on your server" with a neutral muted dot. @@ -1429,8 +1430,10 @@ Recorded deviations on this page (spec §8): "Removed from favorites." and a rollback toast on failure. - **D8**: every Images card has the concept's select checkbox before the star; the `#bulkbar` shows "N selected" and only the real actions in the concept's order (Add - tags, then D85's Auto-tag selected, Set category, Export metadata, Clear). Add to - collection waits for collections; "Remove links…" is dropped. (Amended by #249 PR D: + tags, then D85's Auto-tag selected, Set category, Add to collection, Export metadata, + Clear); "Remove links…" is dropped. (Amended by #331: Add to collection ships, and + Remove from collection follows it while a collection filter is active, see D138.) + (Amended by #249 PR D: Add tags ships, see "Bulk tagging from the selection bar"; #392 adds range, keyboard, touch and "all N matching" selection, see "Selecting many images", D135.) The selection survives Load more and bounded re-mounting and is cleared whenever the collection is replaced (search, @@ -1914,8 +1917,9 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f Auto-tagging." (the server's own sentence), so no job starts only to fail. Start validates the fields as Generate does (native validity; nothing is sent). The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as - the oracle, with Add to collection and Remove links… removed and a copy of its Add - tags button reading "Auto-tag selected" inserted after it. + the oracle, with Remove links… removed and a copy of its Add tags button reading + "Auto-tag selected" inserted after it (Add to collection compares in place since + #331, D138). - **D90** (plan D67, ruling T3-bulk): the dialog's **How to apply** radio group (`.field[role=radiogroup]`, radios beside their labels): **Apply automatically** (checked; primary "Start auto-tagging") starts the `image-tags` job @@ -2006,7 +2010,7 @@ busy". Units: | Difference | Record | | --- | --- | -| Add tags ships (Add to collection and Remove links… still omitted) | D8 (amended) | +| Add tags ships (Remove links… still omitted; Add to collection ships with #331, D138) | D8 (amended) | | Auto-tag selected and its dialog | D85 (ruling L1) | | How to apply and the Review each image session | D90 (ruling T3-bulk) | | Suggestions count in the bulk dialog | D91 | @@ -2113,6 +2117,235 @@ npm run --prefix tools/vendor test:workflows -- workspace-tagging-lifecycle.spec --project firefox --project webkit ``` +### Collections contract (#331) + +A collection is a named, hand-picked group of images (milestone 7 "Additional +organization"). It never copies, moves or changes a file, a prompt, a tag, a category +or a favorite. Legacy pages have no collections, so the legacy coverage table gains no +row. + +**Database** (`database/image_collections.py`, mixed into `PromptDatabase`): +- `image_collections(id, name, name_key UNIQUE, created_at)` and + `image_collection_items(collection_id → image_collections ON DELETE CASCADE, + root_key, relative_path, added_at, PRIMARY KEY (collection_id, root_key, + relative_path))`, plus `idx_image_collection_items_file(root_key, relative_path)`. + `migrate_image_collections` runs right after the saved-views migration inside its own + savepoint, creates the three objects empty with no backfill, changes no existing + table and reads only `sqlite_master` and its own tables, so its cost does not depend + on library size (checked with an SQLite authorizer on a 23,000-image library; a + second run is a no-op). Startup validates structure only; restore runs the full value + check. +- A name is stored as typed after normalization: trimmed, each run of whitespace one + space, 1 to 100 characters, no control characters (Unicode `Cc`). `name_key` is + `NFC(casefold(NFC(name)))`, so names are unique ignoring case and Unicode form (a + macOS NFD name matches the Windows NFC one). Restore checks `name_key` only as + non-empty bounded text and does not recompute it: Python's Unicode case tables differ + between versions, so a backup made on another Python must still restore. +- A member names a file by location, exactly as a favorite does: the source folder's + real path (`_favorite_root_key`) plus the path below it (`_favorite_relative_key`), + case-folded on Windows, each at most 4,096 characters. A case-only rename keeps a + Windows member; two Linux files that differ only by case are two members; on macOS a + case-only rename hides the member, as it hides a favorite. The Windows folding is + tested on every OS through `ntpath`. +- Each write is one `BEGIN IMMEDIATE` transaction, so two tabs adding the same new + name create one collection. A collection is created only when at least one file is + inserted. + +**Missing members (MC2, MC3):** a member whose file is moved, renamed or deleted +outside PromptManager, or whose source folder is offline, is hidden and its row kept; +it shows again when a file appears at the same path (which may be a different image, +as with favorites). Nothing follows a moved file, and there is no "forget missing" +cleanup; deleting the collection removes those rows. + +**Duplicate cleanup (MC4):** System › Maintenance duplicate files moves the removed +copy's memberships to the kept copy as a union, in the transaction that moves its +favorite and image tags, and the job log reads "Moved N collection membership(s) to the +kept copies". The legacy gallery page's duplicate delete does not move them (as for +favorites). + +**Restore (MC8):** `collections` joins the restore stage's summary keys and +`image_collections`'s `MAX(rowid)` its fingerprint; membership edits are not +fingerprinted, as favorite and tag edits are not. A backup made before collections +restores with no collections (the tables are created empty); the pre-restore safety +backup keeps the old ones. The restore review shows no collections count. + +**Routes** (`py/api/collections.py`; every answer `{"success": bool, ...}`): + +| Route | Request | Success | +| --- | --- | --- | +| `GET /prompt_manager/collections` | none | `{collections: [{id, name, count, missing}]}` in name-key, then id, order | +| `POST /prompt_manager/collections/items` | `{collection_id}` or `{name}`, and `items` | `{collection: {id, name}, created, added, already, not_found}` | +| `DELETE /prompt_manager/collections/items` | `{collection_id, items}` | `{collection: {id, name}, removed}` | +| `GET /prompt_manager/collections/membership` | query `root_index`, `root_scope`, `path`, each once | `{collection_ids: [...]}` | +| `PATCH /prompt_manager/collections/{id}` | `{name}` | `{collection: {id, name}}` | +| `DELETE /prompt_manager/collections/{id}` | none | `{deleted: true}` | + +- Ids are `[1-9][0-9]{0,8}` (path) or that int (body); anything else, or a body with + both or neither of `collection_id` and `name`, is 400. +- `items` holds 1 to 500 targets `{root_index, root_scope, path}`, each checked by the + shared `py/api/media_target.py` (the image-annotation routes use the same check): 400 + for a malformed shape, 403 for a traversal, drive, backslash or non-media path. A + `root_scope` that no longer names its root is 409 `source-changed` ("The sources + changed. Refresh the images and try again."). Any invalid item refuses the request + before a write; two targets that fold to one key count once. +- Add keeps only files the source's listing holds and counts the rest in `not_found`; + a new name with nothing inserted is 404 `files-not-found` and creates nothing. A name + matching an existing collection ignoring case adds to it. Remove accepts any + well-formed target, listed or not. +- An unknown id is 404 `collection-not-found`; a rename to another collection's name is + 409 `name-taken` (a case variant of its own name is allowed). Any other failure is a + logged 500 with "Collections could not be read." or "The collection could not be + changed.", never the exception text. +- Counts: `count` is the members the current listing of every configured source shows + (a folder configured twice counts once), `missing` the stored rows beyond it, + clamped at 0. Both come from one read snapshot of every member and stored count, so a + write between two reads cannot skew them. When any source's scan or read fails, both + are `null` for every collection and the names still return. +- Every database read and every filesystem call (realpath, the scans) runs in the + executor; nothing touches the filesystem on the event loop. +- The Images listing (`/prompt_manager/images/output?mode=workspace`) accepts + `collection` (`""` or an id) among `LISTING_FILTER_KEYS`; the cursor's scope hash + includes it, so a cursor from one collection is refused on another. An unknown + collection is 404 `collection-not-found`, checked before any per-source read; a + failed members read is the listing's 500, never an unfiltered page. A collection and + `favorites=1` are never both applied (the collection wins). + +**Limits (MC12):** names 1 to 100 characters; at most 500 files per add or remove; no +limit on the number of collections or members. + +**Selection bar and "all N matching" (#331 PR 3 ruling):** the collection routes take +explicit files only (no listing filter). With "All N matching selected" (D135), Add to +collection and Remove from collection send the loaded images only when every match is +loaded and checked and there are at most 500; otherwise they toast "Select at most +500 images." and open nothing. Over 500 explicitly checked files toast the same. + +Recorded deviations: +- **D136** (amends D28): the concept's COLLECTIONS group (`#collections-nav`, + index.html 28–31) without the "PROPOSED" badge. One `links()` row per collection + (folder icon, name, `.nav-count`) in name-key order, linking `#/collection/`. The + count follows the nav-count rules of D51 (hidden until loaded, absent when it could + not be read, never a placeholder) and is re-read 300 ms after the last confirmed add, + remove, rename or delete; the list is re-read after a restore. The label is hidden + while no collection exists. At 950 px and below the concept hides + `#collections-nav`, which would leave a phone no way to open a collection: the rows + show in the wrapped nav (`workspace.css`, `/* D136 */`), the label stays hidden as + every sidebar label is, and the counts are hidden as on every concept nav row at that + width. (Open question for the maintainer: CUJ 15 asks for "with counts" at phone + width; production follows the concept's phone rule and hides them.) A failed list + read keeps the last rows. +- **D137**: the collection page `#/collection/` is Images filtered to the + collection: the collection's name is the h1, "Collection" the breadcrumb and title + (concept a1186–1189), with the concept's Images description and every Images control. + Its heading leads with **Edit** ("Edit collection", "Collection name", Delete… + (danger), Cancel, Save; the saved views' D51 dialog with collection nouns): "Collection + renamed.", a taken name stays in the dialog as "A collection named “X” already + exists.", "The collection could not be renamed. Try again." keeps it open. Delete… + asks "Delete “X”? Its N images stay where they are; only the collection is removed." + (N the stored count) and toasts "Collection deleted." (a 404 counts as deleted), + then opens `#/images`; a failure toasts "The collection could not be deleted. Try + again.". A `p.small` after the description reads "N image(s) in this collection + is/are not in your current sources." when members are missing. An unknown id reads + "This collection no longer exists." with an "Open Images" link; a failed list reads + "Collections could not be loaded. Your saved data has not changed." with Retry. The + page neither restores nor replaces the remembered Images state, and its favorites + filter is off (DC10). +- **D138** (amends D8): **Add to collection** follows Set category and opens the + concept's `simpleInput` ("Add selected images to collection", "Collection name") + with the action "Add" (the concept's "Apply in prototype"), an empty field (the + concept pre-fills its sample "Experiments") of at most 100 characters, and a + `datalist` of the existing names (`#action-input-suggestions`; the field is then a + combobox, and Chromium draws its suggestion arrow inside the field, which the + geometry comparison does not see). An empty field keeps the dialog on it. The toasts: "Added N image(s) to + “X”." plus " N was/were already in it." and " N could not be found.", "All selected + images were already in “X”.", "None of the selected images could be found. Refresh + the images and try again." (404 `files-not-found`), "The sources changed. Refresh + the images and try again." (409; the sources and the listing are read again, which + clears the selection), "That collection no longer exists." and "The images could not + be added. Try again.". Otherwise the selection stays and the listing is not reloaded. + **Remove from collection** (beyond the concept) follows Add while a collection filter + is active (a collection page or a saved view on one), with no confirmation: "Removed + N image(s) from “X”.", then the loaded range is read again in place, keeping the + checked files still listed. "That collection no longer exists." (404, a collection + deleted meanwhile) reads the listing again, which then shows the deleted-collection + page; "The sources changed. Refresh the images and try again." (409) reads the + sources and listing again and clears the selection; "The images could not be removed. + Try again." keeps everything. A second press while it runs is ignored. +- **D139** (beyond the concept): the shared inspector's footer ends with + **Collections…** (the folder icon) after Prompt history, for a listed file only + (`rooted-file` with its scope), in the Images side panel and the viewer's details; + Inspect a file and the Prompt library's galleries have no client store and no + button. Its dialog "Collections": "Loading collections…" while the file's membership + is read ("Collections could not be loaded." with Retry on failure; Save waits), one + concept checkbox row (`label.tag-suggestion`) per collection in sidebar order, + checked where the file is a member ("No collections yet. Name one below." without + any), a "New collection" field (at most 100 characters; a name matching an existing + collection adds to it), then Cancel and Save. Focus starts on the field and moves to + the first checkbox once they are listed. A typed name equal to a listed collection + ignoring case is that collection's checkbox, checked (never an add then a remove); a + name that cannot be a collection name is refused on the field with "The collections + could not be updated. Try again.", nothing is sent and the checkbox edits stay. Save + sends one request per change: the new name, each newly checked collection, each + unchecked one; nothing changed sends nothing and closes. All sent: the dialog closes + with "Collections updated.". A failure keeps it open with "The collections could not + be updated. Try again." and the membership read again, Save waiting until that read + lands; × and Escape wait while it saves. The page hears of the changes that went + through only when the dialog closes (a failure included), so a reload cannot remove + the dialog before its message is read. On a collection page a change to the open + collection then reloads the listing at once from the side panel, and from the viewer + only when the viewer closes, so the viewer keeps its place (no listing request while + it is open). Closing returns focus to the trigger, or, when a re-render replaced it, + to the inspector's current Collections… button or its heading. The + inspector comparison draws the button into the concept's panel footer and full + details after Prompt history (a copy of its Prompt history button with the concept's + folder icon), so the browser lays out what follows. +- **D140**: saved views' Images state gains `collection` (`""` or an id, the `source` + encoding); older rows read `""`, and a write with both `favorites: true` and a + collection is 400. Save view on a collection page stores the id; the view reopens + the collection with its other filters under the view's own name (D51), with Remove + from collection and without the missing note. A view whose collection was deleted + (MC6) lists nothing and reads "The collection this view uses was deleted."; it never + falls back to every image, and the view is not changed. + +| Difference | Record | +| --- | --- | +| COLLECTIONS group, rows, counts, phone width | D136 (amends D28) | +| Collection page, Edit, missing note, deleted page | D137 | +| Add to collection dialog and toasts; Remove from collection | D138 (amends D8) | +| Inspector and viewer Collections… and its dialog | D139 | +| Saved views keep a collection; MC6 empty state | D140 | + +Comparisons: `tests/browser/shell-concept.spec.mjs` compares `#collections-nav` with +two seeded collections ("Landscapes" with 12 listed members, "Experiments" with 8), so +the concept's rows and counts compare as they are, mapping out only the badge and, at +390 px, the concept's hidden nav. `tests/browser/images-concept.spec.mjs` compares Add +to collection in place in the selection bar, and the collection page's heading, +breadcrumb, toolbar and filter row against the concept's `#collection-landscapes` +(the grid is not compared: the concept's members come from categories). +`tests/browser/collections-concept.spec.mjs` compares the bulk dialog with the +concept's `simpleInput` (D138 maps "Apply in prototype" to "Add", the sample value to +empty and adds the datalist), at 1600x1100 and 390x844 in dark and light, with a +mutation check. `tests/browser/inspector-concept.spec.mjs` maps D139. + +Tests: Python `tests/test_image_collections_schema.py` (migration, validation, +restore), `tests/test_image_collections.py` (operations, keys, the duplicate move), +`tests/test_api_collections.py` (routes, rules, counts), `tests/test_api_workspace_images.py` +(the `collection` filter), `tests/test_api_duplicate_files.py`, `tests/test_restore_stage.py`, +`tests/test_saved_views.py`; units `tests/js/workspace-collections.test.js` (the client +store and strings), `tests/js/workspace-images-selection.test.js` (the bar's order, the +500 cap, the all-matching rule and every §7.4 string), `tests/js/workspace-file-inspector.test.js` +(D139's button and dialog), `tests/js/workspace-toolbar.test.js` (the datalist); +browser `tests/browser/workspace-collections.spec.mjs` (CUJ 1–15 on the fixture +`tests/browser/workspace_collections_fixture.py`: twelve Landscapes members and one +missing file, eight Experiments members, one loose file and a saved view on +Experiments), `tests/browser/full-inspector.spec.mjs` (the footer with Collections…). + +```sh +python3 -m unittest tests.test_image_collections_schema tests.test_image_collections tests.test_api_collections -q +node --test tests/js/workspace-collections.test.js tests/js/workspace-images-selection.test.js tests/js/workspace-file-inspector.test.js +npm run --prefix tools/vendor test:workflows -- workspace-collections.spec.mjs collections-concept.spec.mjs \ + images-concept.spec.mjs inspector-concept.spec.mjs shell-concept.spec.mjs --project chromium --project touch +``` + ### Prompt library cards and recorded deviations (#249 PR E) The shelf uses the shared concept toolbar and caption (E1) and the concept's diff --git a/docs/application-redesign.md b/docs/application-redesign.md index 536464d0..a47c3997 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -13,6 +13,7 @@ code. This plan proposes implementation order and acceptance gates. - [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209). - [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216) adds the shared current-file inspector with exact generation facts, scoped provenance, raw/download evidence and loaded-collection viewer controls, including a slideshow that pauses at native video. This delivery does not establish whole-application parity, change the default entry point or constitute a release. Recorded chronological run/context browsing continues under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209); image tagging/review, System consolidation and remaining organization capabilities continue under #196. - Local Inspect (#249, PRs L1–L3) replaces the `#/inspect` handoff with the concept's native "Inspect a file" page: one local image or video previews inline and opens the shared viewer and full inspector on a record read entirely in the browser, with nothing uploaded, imported or stored. The legacy metadata page (`web/metadata.html`, served at `/prompt_manager/gallery.html`) stays reachable from Overview; its capabilities map to the shared inspector, including its one-click COPY ALL summary, which is the shared inspector's "Copy settings summary" (D117, LI-4). PR L3 ([#286](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/286)) shipped that button and local roles equal to the server's resolver (`web/js/workspace/local-resolver.mjs`, TESTING.md "Local roles (#249 PR L3, decision LI-1)"). Recorded deviations D60–D66 and D117 are in [TESTING.md](../TESTING.md). +- Collections ([#331](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/331), three PRs) adds named, hand-picked groups of images, stored in the library database by file location as favorites are: a COLLECTIONS sidebar group whose rows open Images filtered to one collection (`#/collection/`) with rename and delete, Add to collection and Remove from collection in the Images selection bar, a Collections… checklist in the shared inspector for the side panel and the viewer, and saved views that keep a collection. Membership survives restarts, source reordering and System › Maintenance duplicate cleanup; no file is copied, moved or changed. ## Scope and compatibility diff --git a/tests/browser/collections-concept.spec.mjs b/tests/browser/collections-concept.spec.mjs new file mode 100644 index 00000000..65986be0 --- /dev/null +++ b/tests/browser/collections-concept.spec.mjs @@ -0,0 +1,108 @@ +import {test,expect} from './fixtures.mjs'; +import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs'; + +// #331 D138: the Images selection bar's "Add to collection" dialog against the concept's own (`bulk-collection` → +// simpleInput, app.js a1392–1398 and a1138), on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes. +// Paired screenshots are artifacts only; there are no PNG baselines. +test.use({locale:'en-US',timezoneId:'UTC'}); + +const CARDS='.gallery-body > .image-grid > .media-card'; + +/** + * Runs in the page: the open "Add selected images to collection" dialog: its title, the body's tree, the field's + * label, input and suggestions, and the actions, with the concept's geometry and tokens. + */ +function measureAddDialog() { + const dialog=document.querySelector('dialog#modal[open]'); + const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null; + const name=node=>[node.localName,...[...node.classList].sort()].join('.'); + const text=node=>node?.textContent.trim()??null; + const body=dialog.querySelector('#modal-body'); + const field=body.querySelector(':scope > .field'); + const label=field?.querySelector(':scope > label'); + const input=field?.querySelector(':scope > input'); + const list=input?.list??null; + const actions=body.querySelector(':scope > .dialog-actions'); + const rect=dialog.getBoundingClientRect(); + return { + title:text(dialog.querySelector('#modal-title')), + tree:[...body.children].map(name), + field:field&&{tree:[...field.children].map(name),height:Math.round(field.getBoundingClientRect().height), + ...css(field,'display','flexDirection','rowGap','marginBottom')}, + label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')}, + input:input&&{id:input.id,type:input.type,value:input.value,maxLength:input.maxLength,list:input.getAttribute('list'), + ...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')}, + suggestions:list&&{id:list.id,display:getComputedStyle(list).display,options:[...list.options].map(option=>option.value)}, + actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','marginTop'), + buttons:[...actions.children].map(button=>({text:text(button),className:button.className, + ...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor')}))}, + width:Math.round(rect.width),height:Math.round(rect.height), + }; +} + +/** + * D138: the action is "Add" (the concept's "Apply in prototype"), the field starts empty (no sample "Experiments") and + * holds at most 100 characters, and a hidden `datalist` of the existing names (the fixture's two) follows the field: + * the shared input dialog's suggestions list, as Set category's (#332 D140). + */ +function mapAddDialogConcept(reference) { + const [cancel,apply]=reference.actions.buttons; + const [field,...rest]=reference.tree; + return {...reference,tree:[field,'datalist',...rest], + input:{...reference.input,value:'',maxLength:100,list:'workspace-input-suggestions'}, + suggestions:{id:'workspace-input-suggestions',display:'none',options:['Experiments','Landscapes']}, + actions:{...reference.actions,buttons:[cancel,{...apply,text:'Add'}]}}; +} + +async function openAddDialog(page,main) { + await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check(); + await page.locator(`${main} #bulkbar`).getByRole('button',{name:'Add to collection',exact:true}).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add selected images to collection'); + await page.mouse.move(0,0); + return page.evaluate(measureAddDialog); +} + +async function conceptAddDialog(page,theme) { + await openConcept(page,'images',theme); + return openAddDialog(page,'#main'); +} + +async function productionAddDialog(page,theme) { + await openProduction(page,'images',theme); + await expect(page.locator(CARDS).first()).toBeVisible(); + return openAddDialog(page,'#workspace-main'); +} + +test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);}); + +for(const theme of THEMES)for(const [width,height] of SIZES) { + test(`add to collection dialog matches the concept ${theme} ${width}x${height}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height}); + const concept=await conceptAddDialog(page,theme); + await page.screenshot({path:testInfo.outputPath(`concept-add-to-collection-${theme}-${width}.png`)}); + const production=await productionAddDialog(page,theme); + await page.screenshot({path:testInfo.outputPath(`production-add-to-collection-${theme}-${width}.png`)}); + expect(concept.input.value,'the concept pre-fills its sample').toBe('Experiments'); + expect(diffInventory(mapAddDialogConcept(concept),production)).toEqual([]); + }); +} + +test('add to collection comparison detects mutations',async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + const concept=await conceptAddDialog(page,'dark'); + const expected=mapAddDialogConcept(concept); + const production=await productionAddDialog(page,'dark'); + expect(diffInventory(expected,production)).toEqual([]); + // Unmapped, the concept differs only where D138 is recorded. + expect(diffInventory(concept,production).map(item=>item.path).sort()).toEqual(expect.arrayContaining( + ['actions.buttons.1.text','tree.1','input.list','input.maxLength','input.value'])); + const paths=async()=>diffInventory(expected,await page.evaluate(measureAddDialog)).map(item=>item.path); + await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Name';}); + expect(await paths()).toEqual(['label.text']); + await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Collection name';}); + const undoStyle=await mutate(page,{css:'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }'}); + expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor','actions.columnGap'])); + await undoStyle(); + await page.evaluate(()=>document.querySelector('#workspace-input-suggestions').remove()); + expect(await paths()).toEqual(expect.arrayContaining(['suggestions'])); +}); diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index 660a23ad..f134e415 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -145,9 +145,10 @@ test('panel matches the concept order and copy',async({page,request})=>{ await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']); await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); const footer=panel.locator('.detail-footer'); - await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']); + // #331 D139: every listed file's footer ends with Collections… (folder icon). + await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); - await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1); + await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2); await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/); // Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format. reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';}; @@ -157,10 +158,11 @@ test('panel matches the concept order and copy',async({page,request})=>{ await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive'); await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters'); await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension '); - await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']); - // Unlinked: no badge, category, tags, pair or history. + await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']); + // Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139). reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); - expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'], + await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']); + expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'], inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']}); await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded')); // Ambiguous saved association: the same honest absence. @@ -258,7 +260,7 @@ test('pair send and history appear only with real roles and link',async({page,re await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible(); await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0); await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png'); - await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0); + await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0); }); test('no provenance, raw or download controls in the side panel',async({page,request})=>{ @@ -286,8 +288,8 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page); await fullFixture(page,request);let details=await viewerDetails(page,'complete.png'); // The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures, - // then the pair send and history buttons (D20). - expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'], + // then the pair send and history buttons (D20) and D139's Collections…. + expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'], inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']}); await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary'); await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal'); @@ -298,7 +300,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']); await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']); await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']); - await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']); + await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`); // File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines. const provenance=details.locator('details').first();await provenance.locator('summary').click(); @@ -323,9 +325,10 @@ test('viewer details hold provenance, raw records and downloads',async({page,req await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible(); await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG'); await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG'); - // No badge, no line; no resolved pair or link, no buttons after the details. + // No badge, no line; no resolved pair or link: after the details only D139's Collections…. reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png'); - expect((await viewerStructure(details)).outer).toEqual(['div']); + expect((await viewerStructure(details)).outer).toEqual(['div','button']); + await expect(details.locator(':scope > button')).toHaveText(['Collections…']); await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence'); // The history button leaves the viewer for the prompt's history. await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png'); diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 6b41ca70..b2156f52 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -30,9 +30,12 @@ function captionDay(seconds,now=new Date()) { return `${name} · ${date.toLocaleDateString('en-US',options)}`; } -/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */ -const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']); -const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); +/** + * D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to + * collection compares in place (D138). + */ +const OMITTED_BULK=Object.freeze(['Remove links…']); +const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); /** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */ const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]); @@ -464,7 +467,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti 'Add to collection','Export metadata','Remove links…','Clear']); expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]); expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected', - 'Set category','Export metadata','Clear']); + 'Set category','Add to collection','Export metadata','Clear']); expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]); expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]); // The bar comparison notices a change to the production bar. @@ -472,6 +475,48 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); }); +/** + * #331 D137: the collection page's Edit leads the heading actions, drawn in the concept as a plain copy of its Import + * button reading "Edit" (no icon, no action), so the browser lays out what follows it; returns an undo. + */ +async function drawCollectionEdit(page) { + await page.evaluate(()=>{ + const actions=document.querySelector('#main .heading-actions'); + const edit=actions.querySelector('button').cloneNode(false); + for(const key of Object.keys(edit.dataset))delete edit.dataset[key]; + edit.textContent='Edit';actions.prepend(edit);window.__collectionEdit=edit; + }); + await settle(page); + return ()=>page.evaluate(()=>{window.__collectionEdit.remove();delete window.__collectionEdit;}); +} + +// #331 D137: `#/collection/` against the concept's `#collection-landscapes` (app.js a1152–1157, a1186–1189): the +// collection's name as the h1, "Collection" as the breadcrumb, the Images description, toolbar and filter row. The grid +// is not compared: the concept's members come from its categories, production's from the stored collection. +for(const [width,height] of SIZES)for(const theme of THEMES)test(`collection page heading, toolbar and filter row match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width,height}); + const manifest=testInfo.manifest; + await openConcept(page,'collection-landscapes',theme);await page.mouse.move(0,0);await settle(page); + await expect(page.locator('#main #page-title')).toHaveText('Landscapes'); + await expect(page.locator('#breadcrumb')).toHaveText('Collection'); + const restore=await drawCollectionEdit(page); + const reference=await inventoryOf(page,'#main'); + await restore(); + await openProduction(page,`collection/${manifest.collections.Landscapes}`,theme);await page.mouse.move(0,0); + await expect(page.locator(CARDS)).toHaveCount(12); + await expect(page.locator('#workspace-main #page-title')).toHaveText('Landscapes'); + await expect(page.locator('#workspace-breadcrumb')).toHaveText('Collection'); + await expect(page).toHaveTitle('Collection · Prompt Manager'); + await settle(page); + const actual=await inventoryOf(page,'#workspace-main'); + expect.soft(actual.heading.actions.buttons.map(item=>item.text),'production heading actions').toEqual(['Edit','Import','New prompt']); + expect.soft(reference.heading.actions.buttons.map(item=>item.text),'mapped concept heading actions').toEqual(['Edit','Import','New prompt']); + expect.soft(compare(reference,actual,manifest,['heading','toolbar','filterRow']),'collection page').toEqual([]); + // The comparison notices a lost Edit. + await mutate(page,{remove:['#workspace-main .heading-actions > button:first-child']}); + expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['heading']).length).toBeGreaterThan(0); +}); + /** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */ const IMAGE_CATEGORY='Portraits'; async function setImageCategory(request,path,category) { diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 0ca21bcb..3b47cd97 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -51,10 +51,11 @@ function withCategoryScope(inventory) { } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must * equal production. `reference.viewerPlain` is the concept's own full details * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at - * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. + * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's + * "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details. */ export function mapInspectorConcept(reference,{viewport}) { const {panel,viewerPlain,fileControls}=reference; @@ -111,6 +112,22 @@ async function openProductionPanel(page,theme) { await settle(page); } +/** + * #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own + * Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo. + */ +async function withCollectionsButton(page,selector) { + await page.evaluate(selector=>{ + const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history')); + const button=history.cloneNode(false); + for(const key of Object.keys(button.dataset))delete button.dataset[key]; + button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…'); + history.after(button);(window.__collectionsButtons??=[]).push(button); + },selector); + await settle(page); + return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});}; +} + /** * The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy * settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON", @@ -129,8 +146,9 @@ async function plainViewerChips(page) { summary.textContent='Copy settings summary';summary.classList.add('settings-summary'); document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary; }); - await settle(page); + const restoreCollections=await withCollectionsButton(page,'#viewer-details'); return async()=>{ + await restoreCollections(); await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; window.__summaryButton.remove();delete window.__summaryButton;}); await restoreSource();await restoreTargets(); @@ -169,10 +187,12 @@ async function viewerOf(page,concept,mutation=null) { return {viewerDetails,viewerPlain,fileControls}; } async function inventory(page,concept=false) { - // D146: the concept panel's category chip with its visible origin, laid out by the concept page itself. + // D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history. const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null; + const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null; if(concept)await settle(page); const {panel}=await inspectorOf(page); + await restoreCollections?.(); await restoreSource?.(); const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept); return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls}; @@ -340,7 +360,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt'); await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']); await expect(rows.nth(1).locator('span.chip')).toHaveCount(0); - await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); + await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']); }); // #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the @@ -414,6 +434,12 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); + // D139: Collections… is part of the mapped concept, so losing it is a difference. + const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']}); + const withoutCollections=await measure(); + expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/); + await restoreButton();await settle(page); + // D146: the visible origin is part of the mapped concept, so losing it is a difference. const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']}); const sourceMutated=await measure(); diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index b87a55b4..a77564ed 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -491,3 +491,366 @@ test('a page that says a collection or view could not be opened takes focus afte await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.'); await expect(heading(page)).toBeFocused(); }); + +// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection. +const bar = page => page.locator('#bulkbar > .bulkbar'); +const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true }); +const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true }); +const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true }); +const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check(); +const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`); + +/** Add the checked images under `name` through the bar's dialog. */ +async function addChecked(page, name) { + await barButton(page, 'Add to collection').click(); + await expect(addDialog(page)).toBeVisible(); + await addField(page).fill(name); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(addDialog(page)).toHaveCount(0); +} + +test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file); + await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Export metadata', 'Clear']); + await barButton(page, 'Add to collection').click(); + // The concept's simpleInput with Add (D138) and the existing names as suggestions. + await expect(addDialog(page).locator('label')).toHaveText('Collection name'); + await expect(addField(page)).toHaveValue(''); + await expect(addField(page)).toHaveAttribute('maxlength', '100'); + await expect(addDialog(page).locator('datalist option')).toHaveCount(2); + expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value))) + .toEqual(['Experiments', 'Landscapes']); + await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']); + // An empty name keeps the dialog on its field. + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(addField(page)).toBeFocused(); + await addField(page).fill(' LoRa testing '); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.'); + await expect(bar(page).locator('strong')).toHaveText('3 selected'); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3'); + await expect(rows(page)).toHaveCount(3); + + // CUJ 2: one of two already in it. + await bar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await check(page, 'loose.png'); + await check(page, 'landscape_02.png'); + await addChecked(page, 'LoRa testing'); + await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.'); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4'); + + // CUJ 3: a case variant joins the same collection. + await bar(page).getByRole('button', { name: 'Clear', exact: true }).click(); + await check(page, 'landscape_03.png'); + await addChecked(page, 'lora TESTING'); + await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.'); + await expect(rows(page)).toHaveCount(3); + await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5'); + const created = (await listed(request)).find(item => item.name === 'LoRa testing'); + expect(created).toMatchObject({ count: 5, missing: 0 }); + + // Everything already in it. + await addChecked(page, 'LoRa testing'); + await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.'); +}); + +test('Remove from collection takes the checked images off the open collection (CUJ 5)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await expect(imageCards(page)).toHaveCount(12); + await check(page, 'landscape_01.png'); + await check(page, 'landscape_02.png'); + await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Remove from collection', 'Export metadata', 'Clear']); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.'); + await expect(imageCards(page)).toHaveCount(10); + await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0); + // The removed files left the listing, so the bar has nothing checked. + await expect(bar(page)).toHaveCount(0); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10'); + // A checked file still listed stays checked across the reload. + await check(page, 'landscape_03.png'); + await check(page, 'landscape_04.png'); + await page.route(`**${API}/items`, async route => { + if (route.request().method() !== 'DELETE') return route.fallback(); + // Remove only landscape_04; landscape_03 stays listed and checked. + const body = route.request().postDataJSON(); + await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) }); + }); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.'); + await expect(imageCards(page)).toHaveCount(9); + await expect(bar(page).locator('strong')).toHaveText('1 selected'); + await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked(); + // No file changed: the plain Images page still lists all of them. + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); +}); + +test('Images without a collection filter has no Remove from collection', async ({ page, request }) => { + const manifest = await seed(request); + await page.goto(`${workspace}#/images`); + await check(page, 'loose.png'); + await expect(barButton(page, 'Add to collection')).toBeVisible(); + await expect(barButton(page, 'Remove from collection')).toHaveCount(0); + // A saved view on a collection has its filter active: Remove shows (design §5). + await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`); + await expect(imageCards(page)).toHaveCount(8); + await check(page, 'experiment_01.png'); + await barButton(page, 'Remove from collection').click(); + await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.'); + await expect(imageCards(page)).toHaveCount(7); +}); + +test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } }; + await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json', + body: JSON.stringify(reply.body) })); + await check(page, 'loose.png'); + await check(page, 'landscape_05.png'); + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('The images could not be added. Try again.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + await expect(rows(page)).toHaveCount(2); + + reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } }; + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + + reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } }; + const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders')); + await addChecked(page, 'Grids'); + await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.'); + await reloaded; + await expect(imageCards(page)).toHaveCount(21); + await expect(bar(page)).toHaveCount(0); +}); + +test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(21); + // Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135). + await page.route('**/prompt_manager/images/output?*', async route => { + // A next page never answers, so the listing stays at its loaded 21 of 900. + if (new URL(route.request().url()).searchParams.has('cursor')) return; + const response = await route.fetch(); + const data = await response.json(); + await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } }); + }); + await page.reload(); + await expect(imageCards(page)).toHaveCount(21); + await page.locator('#image-grid .image-open').first().focus(); + await page.keyboard.press('ControlOrMeta+a'); + await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click(); + await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected'); + await barButton(page, 'Add to collection').click(); + await expect(toast(page)).toHaveText('Select at most 500 images.'); + await expect(addDialog(page)).toHaveCount(0); +}); + +for (const theme of ['dark', 'light']) { + test(`the Add to collection dialog fits 390x844 ${theme}`, async ({ page, request }) => { + await seed(request); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${workspace}#/images`); + await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); + await check(page, 'loose.png'); + await expect(barButton(page, 'Add to collection')).toBeVisible(); + await barButton(page, 'Add to collection').click(); + await expect(addDialog(page)).toBeVisible(); + const fits = await addDialog(page).evaluate(dialog => { + const box = dialog.getBoundingClientRect(); + return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth; + }); + expect(fits).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await addField(page).fill('Phone'); + await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click(); + await expect(toast(page)).toHaveText('Added 1 image to “Phone”.'); + await expect(row(page, 'Phone')).toBeVisible(); + }); +} + +// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer. +const panel = page => page.locator('#image-inspector'); +const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true }); +const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true }); +const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true }); + +async function membership(request, path) { + const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images; + const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path }); + const response = await request.get(`${API}/membership?${query}`); + await expect(response).toBeOK(); + return (await response.json()).collection_ids; +} + +test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => { + const manifest = await seed(request); + const { Landscapes, Experiments } = manifest.collections; + await page.goto(`${workspace}#/images`); + // The newest image, landscape_01, is the panel's. + await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png'); + const open = panel(page).getByRole('button', { name: 'Collections…', exact: true }); + await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…'); + await open.click(); + await expect(collectionsDialog(page)).toBeVisible(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']); + await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']); + await choice(page, 'Landscapes').uncheck(); + await choice(page, 'Experiments').check(); + await newCollection(page).fill('Grids'); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(open).toBeFocused(); + await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1'); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11'); + await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9'); + const grids = (await listed(request)).find(item => item.name === 'Grids'); + expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort()); + expect(Landscapes.id).not.toBe(grids.id); + // The Images page lists the same files; the dialog opened again shows the new state. + await expect(imageCards(page)).toHaveCount(21); + await open.click(); + await expect(choice(page, 'Grids')).toBeChecked(); + await expect(choice(page, 'Experiments')).toBeChecked(); + await expect(choice(page, 'Landscapes')).not.toBeChecked(); + // Save without a change sends nothing. + let writes = 0; + page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; }); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + expect(writes).toBe(0); +}); + +test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => { + const manifest = await seed(request); + await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`); + await expect(imageCards(page)).toHaveCount(12); + await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click(); + const viewer = page.locator('#viewer-dialog[open]'); + await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png'); + // Review L7: no listing request while the viewer is open. + let listings = 0; + const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; }; + page.on('request', countListing); + await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + // Review L6: focus moves to the first checkbox once listed. + await expect(choice(page, 'Experiments')).toBeFocused(); + await choice(page, 'Landscapes').uncheck(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(collectionsDialog(page)).toHaveCount(0); + // The viewer stays on the image; the page keeps listing it while the viewer is open. + await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png'); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11'); + await expect(imageCards(page)).toHaveCount(12); + expect(listings, 'no listing request while the viewer is open').toBe(0); + page.off('request', countListing); + await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click(); + await expect(viewer).toHaveCount(0); + await expect(imageCards(page)).toHaveCount(11); + await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0); +}); + +test('a failed membership read says so and Retry reads it again', async ({ page, request }) => { + await seed(request); + let failing = true; + await page.route(`**${API}/membership?*`, route => failing + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' }) + : route.fallback()); + await page.goto(`${workspace}#/images`); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.'); + await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0); + await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true'); + failing = false; + await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden(); + // A failed write keeps the dialog with the current membership. + await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json', + body: '{"success":false,"error":"The collection could not be changed."}' })); + await choice(page, 'Experiments').check(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.'); + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(choice(page, 'Landscapes')).toBeChecked(); +}); + +test('a file in no collection with no collections at all can start one', async ({ page }) => { + await page.goto(`${workspace}#/images`); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.'); + await newCollection(page).fill('First'); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collections updated.'); + await expect(rows(page)).toHaveCount(1); + await expect(row(page, 'First').locator('.nav-count')).toHaveText('1'); +}); + +for (const theme of ['dark', 'light']) { + test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => { + await seed(request); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${workspace}#/images`); + await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + const fits = await collectionsDialog(page).evaluate(dialog => { + const box = dialog.getBoundingClientRect(); + return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth; + }); + expect(fits).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + }); +} + +// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its +// dialog and message; the listing is read again only once the dialog closes. +test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => { + const manifest = await seed(request); + const { Experiments, Landscapes } = manifest.collections; + // experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'. + const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images; + const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path }; + await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK(); + await page.route(`**${API}/items`, route => { + const body = route.request().postDataJSON(); + return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback(); + }); + await page.goto(`${workspace}#/collection/${Experiments.id}`); + await expect(imageCards(page)).toHaveCount(8); + await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png'); + await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await choice(page, 'Experiments').uncheck(); + await choice(page, 'Landscapes').uncheck(); + await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.'); + // The remove from Experiments went through; the dialog shows it and the page has not reloaded under it. + await expect(choice(page, 'Experiments')).not.toBeChecked(); + await expect(choice(page, 'Landscapes')).toBeChecked(); + await expect(imageCards(page)).toHaveCount(8); + await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(collectionsDialog(page)).toHaveCount(0); + await expect(imageCards(page)).toHaveCount(7); + await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0); +}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 78e5baff..0cb08c9a 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1791,7 +1791,7 @@ test('card checkboxes select images into the concept bar and Clear empties it', await checkbox(first, 'same.png').check(); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); // D85: Add tags (the concept's) and Auto-tag selected lead the real actions. - await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); + await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']); // Toggling neither opens the viewer, nor inspects, nor moves focus. await expect(page.locator('dialog[open]')).toHaveCount(0); await expect(checkbox(first, 'same.png')).toBeFocused(); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs index 6f3d114d..b63fdcf5 100644 --- a/tests/browser/workspace-tagging-bulk.spec.mjs +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' && test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => { await select(page, FIRST); - await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); + await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']); }); test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => { diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index f74621b1..dd593658 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -384,6 +384,285 @@ test('the D117 wrap rule targets only the settings summary button', async () => assert.doesNotMatch(css, /#viewer-details > div > button/); }); +// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog. +const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null); +const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; }; +const COLLECTION_SCOPE = 'a'.repeat(64); +const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'}; +function collectionsStore({member = [3], membership, add, remove} = {}) { + const calls = []; + const store = { + calls, + collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}], + membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; }, + add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items); + return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; }, + remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items); + return {collection:{id, name:'x'}, removed:1}; }, + settled: async () => ({}), + }; + return store; +} +function footerButtons(f) { + return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim()); +} +test('inspector shows Collections… only for a rooted file with a store', async () => { + for (const variant of ['panel', 'full']) { + const f = mountedDom(), scope = createScope(); + try { + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()}); + view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}}); + const labels = footerButtons(f); + assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`); + // After Prompt history, as the last footer action. + assert.equal(labels.at(-1), 'Collections…'); + assert.equal(labels.at(-2), 'Prompt history'); + const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0]; + assert.equal(button.children[0].tagName, 'svg', 'with the folder icon'); + view.dispose(); + } finally { scope.dispose(); f.restore(); } + } + // No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button. + const local = await localRecord(localPng('Fox Render.png')); + for (const [shown, store] of [[record(), null], [local, collectionsStore()], + [{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) { + const f = mountedDom(), scope = createScope(); + try { + const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store}); + view.setState({loading:false, error:false, record:shown}); + assert.ok(!footerButtons(f).includes('Collections…')); + view.dispose(); + } finally { scope.dispose(); f.restore(); } + } +}); + +/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */ +function openCollections(store, extra = {}) { + const {openCollectionsDialog} = dialogModule(); + const f = mountedDom(), scope = createScope(), toasts = [], changes = []; + let dialog = null, closed = 0; + openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text), + onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); }, + // As the shell does: closing runs the dialog's onClose once. + closeDialog:() => { closed++; dialog?.onClose?.(); }, ...extra}); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const find = predicate => f.walk(f.container, predicate); + const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox'); + const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0]; + const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean); + return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text, + done() { scope.dispose(); f.restore(); }}; +} + +test('the Collections dialog loads the membership and lists every collection', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + assert.equal(view.dialog.title, 'Collections'); + assert.ok(view.text().includes('Loading collections…')); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + await view.settle(); + assert.deepEqual(store.calls, [['membership', FILE]]); + const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion'); + assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']); + assert.deepEqual(view.boxes().map(box => box.checked), [true, false]); + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + assert.equal(field.maxLength, 100); + assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection'); + assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + } finally { view.done(); } +}); + +test('without collections the dialog says to name one', async () => { + const store = collectionsStore({member:[]}); + store.collections = []; + const view = openCollections(store); + try { + await view.settle(); + assert.ok(view.text().includes('No collections yet. Name one below.')); + assert.equal(view.boxes().length, 0); + } finally { view.done(); } +}); + +test('a failed membership read offers Retry', async () => { + let failing = true; + const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }}); + const view = openCollections(store); + try { + await view.settle(); + assert.ok(view.text().includes('Collections could not be loaded.')); + assert.equal(view.button('Retry').hidden, false); + assert.equal(view.boxes().length, 0); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + failing = false; + view.button('Retry').fire('click'); + await view.settle(); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true]); + assert.equal(view.button('Retry').hidden, true); + } finally { view.done(); } +}); + +test('Save sends the new name, each added and each removed collection, in that order', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids '; + view.button('Save').fire('click'); + await view.settle(); await view.settle(); + assert.deepEqual(store.calls.slice(1), [ + ['add', {name:'Grids'}, [FILE]], + ['add', {collectionId:5}, [FILE]], + ['remove', 3, [FILE]], + ]); + assert.equal(view.closed, 1); + assert.deepEqual(view.toasts, ['Collections updated.']); + assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]); + } finally { view.done(); } +}); + +test('Save without a change sends nothing and closes', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' '; + view.button('Save').fire('click'); + await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership']); + assert.equal(view.closed, 1); + assert.deepEqual(view.toasts, []); + assert.deepEqual(view.changes, []); + } finally { view.done(); } +}); + +test('a failed Save keeps the dialog, says so and shows the current membership', async () => { + let reads = 0; + const store = collectionsStore({membership:() => (reads++ ? [5] : [3]), + remove:() => { throw Object.assign(new Error('x'), {status:500}); }}); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + view.button('Save').fire('click'); + // While it saves, × and Escape keep the dialog and Save is busy. + assert.equal(view.dialog.canClose(), false); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + for (let index = 0; index < 4; index++) await view.settle(); + assert.equal(view.closed, 0); + assert.deepEqual(view.toasts, []); + const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0]; + assert.equal(alert.textContent, 'The collections could not be updated. Try again.'); + // The add went through; the list shows what the server holds now. Review M2: the page hears about it only once + // the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read. + assert.deepEqual(view.changes, []); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true]); + assert.equal(view.dialog.canClose(), true); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + view.button('Cancel').fire('click'); + assert.deepEqual(view.changes, [{added:[5], removed:[]}]); + } finally { view.done(); } +}); + +test('a name the store cannot take is refused on the field, keeping the edits (review L5)', async () => { + const store = collectionsStore(); + const view = openCollections(store); + try { + await view.settle(); + const [lora, portraits] = view.boxes(); + lora.checked = false; portraits.checked = true; + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + const validity = []; + field.setCustomValidity = text => validity.push(text); + field.reportValidity = () => validity.push('reported'); + field.value = 'bad\u0001name'; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read'); + assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']); + assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay'); + assert.equal(view.closed, 0); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + // Editing the field clears its message. + field.fire('input'); + assert.equal(validity.at(-1), ''); + } finally { view.done(); } +}); + + +test('after a failed Save, Save waits for the membership re-read (review M1)', async () => { + let release; + const store = collectionsStore({remove:() => { throw new Error('x'); }}); + store.settled = () => new Promise(resolve => { release = resolve; }); + const view = openCollections(store); + try { + await view.settle(); + view.boxes()[0].checked = false; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.equal(typeof release, 'function', 'the store is settling'); + assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true'); + assert.ok(view.text().includes('Loading collections…')); + const sent = store.calls.length; + view.button('Save').fire('click'); + await view.settle(); + assert.equal(store.calls.length, sent, 'no stale plan is sent'); + release({}); + for (let index = 0; index < 3; index++) await view.settle(); + assert.equal(view.button('Save').getAttribute('aria-disabled'), null); + } finally { view.done(); } +}); + +test('a typed name of an existing collection is that collection checkbox (review L4)', async () => { + // "portraits" names Portraits (not a member): one add by id, no name request. + let store = collectionsStore(); + let view = openCollections(store); + try { + await view.settle(); + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS '; + view.button('Save').fire('click'); + for (let index = 0; index < 3; index++) await view.settle(); + assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]); + } finally { view.done(); } + // Unchecking LoRa testing while typing its name keeps it: nothing to send. + store = collectionsStore(); + view = openCollections(store); + try { + await view.settle(); + view.boxes()[0].checked = false; + view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing'; + view.button('Save').fire('click'); + await view.settle(); + assert.deepEqual(store.calls.map(call => call[0]), ['membership']); + assert.equal(view.closed, 1); + } finally { view.done(); } +}); + +test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => { + const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }}; + const fallback = {focused:false, focus() { this.focused = true; }}; + const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback}); + try { + const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0]; + assert.equal(view.dialog.focusTarget, field); + await view.settle(); + assert.equal(view.boxes()[0].focused, true); + view.button('Cancel').fire('click'); + assert.equal(fallback.focused, true); + } finally { view.done(); } + const empty = collectionsStore({member:[]}); + empty.collections = []; + const none = openCollections(empty); + try { + await none.settle(); + assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true); + } finally { none.done(); } +}); + test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => { const base = await localRecord(localPng('a.png')); const chipTexts = record => { diff --git a/tests/js/workspace-images-selection.test.js b/tests/js/workspace-images-selection.test.js new file mode 100644 index 00000000..4590bc4c --- /dev/null +++ b/tests/js/workspace-images-selection.test.js @@ -0,0 +1,285 @@ +// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove +// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4. +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Guarded: before the module exists every test fails on an assertion, not on the import. +const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; }; +const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs'); +const { createScope } = await import('../../web/js/shared/toolkit/core.js'); +const strings = await import('../../web/js/workspace/collections.mjs'); + +const SCOPE = 'b'.repeat(64); +const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra }); +const target = path => ({ root_index: 0, root_scope: SCOPE, path }); +const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`)); +const keyOf = item => `${item.root_index}:${item.path}`; +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status }); + +/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */ +function fakeDOM() { + const previous = globalThis.document; + let active = null; + class Element extends EventTarget { + constructor(tag) { + super(); + this.tagName = tag.toUpperCase(); + this.children = []; + this.parentNode = null; + this.attributes = {}; + this.dataset = {}; + this.className = ''; + this.id = ''; + this._text = ''; + } + get textContent() { return this._text + this.children.map(child => child.textContent).join(''); } + set textContent(text) { this.children = []; this._text = String(text); } + get nextElementSibling() { + const siblings = this.parentNode?.children ?? []; + return siblings[siblings.indexOf(this) + 1] ?? null; + } + append(...nodes) { + for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); } + } + after(node) { + node.remove(); + node.parentNode = this.parentNode; + this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node); + } + remove() { + if (!this.parentNode) return; + this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); + this.parentNode = null; + } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } + removeAttribute(name) { delete this.attributes[name]; } + querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; } + querySelectorAll() { return []; } + focus() { active = this; } + click() { this.dispatchEvent(new Event('click')); } + } + const toast = new Element('div'); + const body = new Element('body'); + globalThis.document = { + body, + get activeElement() { return active ?? body; }, + createElement: tag => new Element(tag), + createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }), + getElementById: id => (id === 'workspace-toast' ? toast : null), + }; + return { Element, body, toast, restore() { globalThis.document = previous; } }; +} + +/** The bar over `items` with `checked` keys; `options` adds the collection props. */ +function mountBar(dom, items, checkedKeys, options = {}) { + const scope = createScope(); + const grid = new dom.Element('div'); + const focusFallback = new dom.Element('h1'); + const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {}, + activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}), + getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }), + checked: Object.freeze(new Set(checkedKeys)), ...options.props }); + return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [], + button: text => bar.element.children[0].children.find(child => child.textContent === text) }; +} + +function store(overrides = {}) { + const calls = []; + return { + calls, + collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }], + add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); }, + remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); }, + }; +} + +test('selection bar lists Add to collection after Set category', () => { + const dom = fakeDOM(); + try { + const items = images(2); + const collections = store(); + const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } }); + assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Export metadata', 'Clear']); + plain.scope.dispose(); + // §7.1: Remove from collection beside Add, only while a collection filter is active. + const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } }); + assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', + 'Remove from collection', 'Export metadata', 'Clear']); + filtered.scope.dispose(); + // Without the client store (a page that has none) neither shows. + const none = mountBar(dom, items, [keyOf(items[0])]); + assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']); + none.scope.dispose(); + } finally { dom.restore(); } +}); + +test('more than 500 checked toasts and opens nothing', async () => { + const dom = fakeDOM(); + try { + const items = images(501); + const collections = store(); + const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } }); + view.button('Add to collection').click(); + assert.equal(dom.toast.textContent, 'Select at most 500 images.'); + assert.equal(dom.body.children.length, 0, 'no dialog opened'); + dom.toast.textContent = ''; + view.button('Remove from collection').click(); + await flush(); + assert.equal(dom.toast.textContent, 'Select at most 500 images.'); + assert.deepEqual(collections.calls, []); + view.scope.dispose(); + } finally { dom.restore(); } +}); + +test('Remove from collection removes the checked files and reloads the listing', async () => { + const dom = fakeDOM(); + try { + const items = images(3); + let changed = 0; + const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) }); + const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7', + onCollectionsChanged: () => { changed++; } } }); + const remove = view.button('Remove from collection'); + remove.click(); + // A second press while the first is in flight is ignored. + assert.equal(remove.getAttribute('aria-disabled'), 'true'); + remove.click(); + await flush(); + assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]); + assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.'); + assert.equal(changed, 1); + assert.equal(remove.getAttribute('aria-disabled'), null); + view.scope.dispose(); + } finally { dom.restore(); } +}); + +test('the collection-target rule keeps the all-matching selection to its loaded images', () => { + const { collectionTargets } = module(); + assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] }); + assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST }); + // All N matching: only when every match is loaded and checked, and at most 500. + assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] }); + assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST }); + assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST }); + // A file without a listing identity cannot be named; none left is "could not be found". + assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST }); +}); + +const pageScope = () => { + const controller = new AbortController(); + return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() }; +}; + +test('Add to collection opens the concept dialog with the existing names as suggestions', () => { + const { openAddToCollection } = module(); + let dialog = null; + const collections = store(); + openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail, + openInput: args => { dialog = args; } }); + assert.equal(dialog.title, 'Add selected images to collection'); + assert.equal(dialog.label, 'Collection name'); + assert.equal(dialog.action, 'Add'); + assert.equal(dialog.maxLength, 100); + assert.equal(dialog.trigger, 'trigger'); + assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']); + assert.equal(dialog.value ?? '', ''); +}); + +/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */ +async function add(name, answer, { count = 1, extra = {} } = {}) { + const { openAddToCollection } = module(); + const toasts = []; + let dialog = null, sourceChanged = 0; + const collections = store({ add: answer }); + openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text), + openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra }); + await dialog.onApply(name); + return { toasts, calls: collections.calls, sourceChanged }; +} +const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created, + added, already, notFound }); + +test('Add sends the normalized name and the checked files', async () => { + const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 }); + assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]); + assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']); +}); + +test('Add toasts every outcome of §7.4', async () => { + const cases = [ + [answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'], + [answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'], + [answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'], + [answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'], + [answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'], + [answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'], + [answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'], + [() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'], + [() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'], + [() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'], + ]; + for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text); +}); + +test('a stale source on Add says so and reloads the listing', async () => { + const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); }); + assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']); + assert.equal(sourceChanged, 1); +}); + +test('Add with a name the store cannot take sends nothing and says it failed', async () => { + const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0)); + assert.deepEqual(calls, []); + assert.deepEqual(toasts, ['The images could not be added. Try again.']); +}); + +test('Add after the page left shows no toast', async () => { + const { openAddToCollection } = module(); + const scope = pageScope(); + const toasts = []; + let dialog = null; + openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text), + openInput: args => { dialog = args; } }); + scope.abort(); + await dialog.onApply('Grids'); + assert.deepEqual(toasts, []); +}); + +test('Add refuses an over-cap or all-matching selection before any dialog', () => { + const { openAddToCollection } = module(); + const toasts = []; + const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text), + openInput: () => assert.fail('no dialog opens'), ...extra }); + open({ images: images(501) }); + open({ images: images(2), allMatching: true, total: 40 }); + assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]); +}); + +/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */ +async function remove(reply, count = 1) { + const { removeFromCollection } = module(); + const toasts = []; + let changed = 0, sourceChanged = 0; + const collections = store({ remove: reply }); + await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count), + notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } }); + return { toasts, changed, sourceChanged, calls: collections.calls }; +} + +test('Remove toasts every outcome of §7.4', async () => { + assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })), + { toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] }); + assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts, + ['Removed 3 images from “Landscapes”.']); + // A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone). + const failed = await remove(() => { throw new TypeError('offline'); }); + assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]); + const gone = await remove(() => { throw coded('collection-not-found', 404); }); + assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]); + const stale = await remove(() => { throw coded('source-changed', 409); }); + assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]); +}); diff --git a/tests/js/workspace-toolbar.test.js b/tests/js/workspace-toolbar.test.js index ad4d8641..a776db41 100644 --- a/tests/js/workspace-toolbar.test.js +++ b/tests/js/workspace-toolbar.test.js @@ -594,6 +594,9 @@ test('an input dialog offers suggestions and a secondary action', async () => { const [plainField, plainActions] = byId(dom, 'modal-body').children; assert.equal(plainField.children[1].getAttribute('list'), null); assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']); + // An empty list (Add to collection with no collections yet, #331 D138) adds no datalist either. + openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} }); + assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0); assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError); assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError); assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index b5292c45..965573d9 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -275,6 +275,12 @@ "functions": 98, "statements": 98 }, + "workspace/images-collections.mjs": { + "lines": 98, + "branches": 89, + "functions": 98, + "statements": 98 + }, "workspace/images-export.mjs": { "lines": 98, "branches": 98, @@ -353,6 +359,12 @@ "functions": 98, "statements": 98 }, + "workspace/inspector-collections.mjs": { + "lines": 98, + "branches": 92, + "functions": 98, + "statements": 98 + }, "workspace/library-editor-state.mjs": { "lines": 96, "branches": 92, diff --git a/web/js/workspace/collections.mjs b/web/js/workspace/collections.mjs index 827b3c16..6491536a 100644 --- a/web/js/workspace/collections.mjs +++ b/web/js/workspace/collections.mjs @@ -78,7 +78,8 @@ export function parseCollections(data) { } /** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */ -const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC'); +export const collectionNameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC'); +const nameKey = collectionNameKey; const ordered = list => [...list].sort((a, b) => { const left = nameKey(a.name), right = nameKey(b.name); return left < right ? -1 : left > right ? 1 : a.id - b.id; diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 68bfb4f0..2a3ba905 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -8,6 +8,7 @@ import { showWorkspaceToast } from './toast.mjs'; import { openCopyFallback } from './workspace-dialogs.mjs'; import { displayPreferences } from './preferences.mjs'; import { fallBackToOriginal, thumbnailSource } from './thumbnail-support.mjs'; +import { openCollectionsDialog } from './inspector-collections.mjs'; const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']; const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.'; @@ -220,7 +221,12 @@ function prefixedChip(prefix, text) { /** D124: the inspector's line while "Load metadata on selection" is off. */ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.'; -export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback}) { +/** + * #331 D139: with the client store `collections`, a listed file's footer ends with "Collections…", which opens its + * collections checklist; `onCollectionsChanged({added, removed})` hears the confirmed changes. + */ +export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onLoad, onInteraction = () => {}, copyFallback = openCopyFallback, + collections = null, onCollectionsChanged = null}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; @@ -317,6 +323,17 @@ export function mountFileInspector(container, {api, sender, parentScope, variant }); footer.append(history); } + const member = collections ? revealTarget(record.identity) : null; + if (member) { + const button = createButton(''); + button.dataset.collections = ''; + button.append(createWorkspaceIcon('folder'), ' Collections…'); + // A re-render replaces the button: closing focuses the current one, else the inspector's heading. + const fallback = () => container.querySelector('button[data-collections]') ?? container.querySelector('h2'); + contentScope.listen(button, 'click', () => openCollectionsDialog({scope, collections, target:member, trigger:button, + onChanged:onCollectionsChanged, focusFallback:fallback})); + footer.append(button); + } if (!full && footer.childNodes.length) container.append(footer); } /** diff --git a/web/js/workspace/images-collections.mjs b/web/js/workspace/images-collections.mjs new file mode 100644 index 00000000..69806cae --- /dev/null +++ b/web/js/workspace/images-collections.mjs @@ -0,0 +1,76 @@ +/** + * The Images selection bar's collection actions (#331 D138, design §7.2–7.4): + * + * - **Add to collection** (the concept's `bulk-collection` simpleInput, app.js a1392–1398): "Add selected images to + * collection", "Collection name", Add, with the existing names offered as suggestions. A name that matches a + * collection ignoring case adds to it; a new name creates it. The selection stays and the listing is not reloaded + * (an add never changes it). + * - **Remove from collection** (beyond the concept, only while a collection filter is active): no confirmation; the + * listing then reloads through `onChanged`, keeping the checked files still listed. + * + * The routes take at most 500 explicit files. "All N matching" (#392, D135) is accepted only when every match is + * loaded and checked and there are at most 500 of them; otherwise the action toasts "Select at most 500 images." and + * sends nothing (ruling for #331 PR 3: the collection routes have no filter body). + */ +import { bulkTargets } from './images-tagging-bulk.mjs'; +import { ADD_DIALOG_ACTION, ADD_DIALOG_LABEL, ADD_DIALOG_TITLE, ADD_FAILED_TOAST, FILES_NOT_FOUND_TOAST, MAX_COLLECTION_NAME_LENGTH, + REMOVE_FAILED_TOAST, TOO_MANY_TOAST, addedMessage, isCollectionGone, normalizeCollectionName, removedMessage, + writeFailure } from './collections.mjs'; +import { openInputDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +/** + * The files an action sends for the checked `images` (listing order): `{targets}`, or `{refusal}` with the toast. + * With `allMatching`, `total` is the listing's match count; only a fully loaded match is sent. + */ +export function collectionTargets(images, { allMatching = false, total = images.length } = {}) { + if (allMatching && total !== images.length) return { refusal: TOO_MANY_TOAST }; + const targets = bulkTargets(images); + if (!targets) return { refusal: TOO_MANY_TOAST }; + return targets.length ? { targets } : { refusal: FILES_NOT_FOUND_TOAST }; +} + +/** + * Add to collection over `images` (the checked files, taken now). `collections` is the client store; `openInput` is + * the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing. + */ +export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length, + notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null }) { + const { targets, refusal } = collectionTargets(images, { allMatching, total }); + if (refusal) { notify(refusal); return; } + openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION, + maxLength: MAX_COLLECTION_NAME_LENGTH, suggestions: collections.collections.map(item => item.name), + onApply: async text => { + const name = normalizeCollectionName(text); + if (name === null) { notify(ADD_FAILED_TOAST); return; } + try { + const result = await collections.add({ name }, targets); + if (!scope.signal.aborted) notify(addedMessage(result)); + } catch (error) { + if (scope.signal.aborted) return; + notify(writeFailure(error, ADD_FAILED_TOAST)); + if (error?.code === 'source-changed') onSourceChanged?.(); + } + } }); +} + +/** + * Remove the checked `images` from the open collection `collectionId`. Resolves once the toast is shown; a confirmed + * remove, or a collection found deleted meanwhile, calls `onChanged()` so the page lists it again. + */ +export async function removeFromCollection({ scope, collections, collectionId, images, allMatching = false, total = images.length, + notify = showWorkspaceToast, onChanged = null, onSourceChanged = null }) { + const { targets, refusal } = collectionTargets(images, { allMatching, total }); + if (refusal) { notify(refusal); return; } + try { + const result = await collections.remove(collectionId, targets); + if (scope.signal.aborted) return; + notify(removedMessage(result.collection.name, result.removed)); + onChanged?.(); + } catch (error) { + if (scope.signal.aborted) return; + notify(writeFailure(error, REMOVE_FAILED_TOAST)); + if (isCollectionGone(error)) onChanged?.(); + else if (error?.code === 'source-changed') onSourceChanged?.(); + } +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index 78957d04..217e32a2 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -8,6 +8,7 @@ import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './work import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs'; +import { openAddToCollection, removeFromCollection } from './images-collections.mjs'; const CHECKBOX = '.image-overlays input[type="checkbox"]'; const CHECKBOX_LABEL = '.image-overlays > label'; @@ -42,9 +43,14 @@ function el(tag, text, className) { * `clear` serve the grid's keys (images.mjs). `getListing()` gives `{total, hasMore, positiveCount}` and * `getFilter()` the listing filter: once every loaded image is checked and more match, the bar offers "Select all N * matching", whose actions send that filter to the server. + * + * Collections (#331 D138, design §7): with the client store `collections`, Add to collection follows Set category; + * while a collection filter is active (`collection`, its id) Remove from collection follows it, and a confirmed remove + * calls `onCollectionsChanged()` to reload the listing. A stale source on either calls `onSourceChanged()`. */ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, - getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()) }) { + getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null, + collection = null, onCollectionsChanged = null, onSourceChanged = null }) { let checked = initial; let exporting = false, categorizing = false; // D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox. @@ -75,7 +81,19 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o ...matchingTarget() })); bulk.append(addTags, autoTag); } - bulk.append(setCategory, exportMetadata, clearButton); + bulk.append(setCategory); + if (collections) { + const addToCollection = createButton('Add to collection'); + scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(), + trigger: addToCollection, ...collectionSelection(), onSourceChanged })); + bulk.append(addToCollection); + } + if (collections && collection) { + const removeButton = createButton('Remove from collection'); + scope.listen(removeButton, 'click', () => removeChecked(removeButton)); + bulk.append(removeButton); + } + bulk.append(exportMetadata, clearButton); /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ function render() { @@ -143,6 +161,24 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o function matchingTarget() { return allMatching ? { filter: getFilter(), count: getListing().total } : {}; } + /** The collection actions' view of "all N matching" (#331 ruling): the loaded images and the match count. */ + function collectionSelection() { + return { allMatching, total: allMatching ? getListing().total : checked.size }; + } + /** Remove from collection: one write at a time; the selection stays (a reload keeps what is still listed). */ + let removing = false; + async function removeChecked(button) { + if (removing) return; + removing = true; + const release = busy(button); + try { + await removeFromCollection({ scope, collections, collectionId: Number(collection), images: checkedImages(), + ...collectionSelection(), onChanged: onCollectionsChanged, onSourceChanged }); + } finally { + removing = false; + release(); + } + } /** Toggle one image on its own; it becomes the anchor. */ function toggle(key, on = !isChecked(key)) { settleMatching(); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index f35e42a3..bfde7afe 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -116,10 +116,12 @@ export function selectedImage() { * Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is * off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls * `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a - * deleted collection, D140) the page lists nothing and says so. + * deleted collection, D140) the page lists nothing and says so. `collections` (the shell's client store) adds the + * selection bar's Add to collection, and Remove from collection while a collection is open (D138). */ export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null, - favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) { + favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null, + collections = null} = {}) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); @@ -302,10 +304,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage getFilter:() => { const filter = listingFilter(filters), scope = state?.items[0]?.root_scope; return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter; - }}); + }, + // #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again. + collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged, + onSourceChanged:refreshInspectionCollection}); if (bar) main.append(bar.element); main.append(body); + // #331 D139: a change the inspector or the viewer confirmed that touches the open collection lists it again; the + // viewer reports only once it closes. + const inspectorCollections = embedded ? null : collections; + const collectionsChangedBy = ({added, removed}) => { + if (filters.collection && [...added, ...removed].includes(Number(filters.collection))) collectionChanged(); + }; const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender, + collections:inspectorCollections,onCollectionsChanged:collectionsChangedBy, onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onMediaError:image => recovery.recover(image),onRestoreFocus:image => { const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image)); @@ -322,7 +334,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage selectedImage = image; for (const video of details.querySelectorAll('video')) releaseVideo(video); detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel', - onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection}); + onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onLoad:() => inspection.load(), onRefresh:refreshInspectionCollection, + collections:inspectorCollections, onCollectionsChanged:collectionsChangedBy}); detailInspector.setState(inspection.state); inspection.select(image); for (const [key, card] of rendered) markSelected(card, key === selection); @@ -734,6 +747,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0; if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'}); } + /** + * A confirmed collection change that may change this listing (a remove from the open collection): the loaded range + * is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page + * again from its start, whose failure shows the usual Retry (or the deleted-collection page). + */ + async function collectionChanged() { + if (scope.signal.aborted || !filters.collection || recovering) return; + try { + await stream.refresh(); + } catch { + if (!scope.signal.aborted) reset(); + } + } async function refreshInspectionCollection() { if (scope.signal.aborted) return; recovery.reset(); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 81d1b132..ad50c0db 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -410,7 +410,7 @@ function mountWorkspace() { if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') { mountNewPromptAction(main, api, view); images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites', - collection, onCollectionMissing, onSaveView:saveView('images', view), activity}); + collection, onCollectionMissing, onSaveView:saveView('images', view), activity, collections}); return; } if (route.id === 'library') { diff --git a/web/js/workspace/inspector-collections.mjs b/web/js/workspace/inspector-collections.mjs new file mode 100644 index 00000000..87c8598f --- /dev/null +++ b/web/js/workspace/inspector-collections.mjs @@ -0,0 +1,189 @@ +/** + * The shared inspector's "Collections…" dialog (#331 D139, design §8), beyond the concept: one listed file's + * collections as a checklist in the sidebar's order, a New collection field, then Cancel and Save. + * + * - The file's membership is read when the dialog opens ("Loading collections…"); a failed read says "Collections + * could not be loaded." with Retry, and Save waits for a read that worked. + * - Each row is the concept's checkbox row (`label.tag-suggestion`, app.js a1128). With no collection the list says + * "No collections yet. Name one below." + * - Save sends one request per change, in order: the new name, each newly checked collection, each unchecked one. + * Nothing changed sends nothing and closes. All sent: the dialog closes with "Collections updated.". A failure keeps + * it open with "The collections could not be updated. Try again." and the membership read again; the changes that + * went through still reach `onChanged({added, removed})` (collection ids), as a full success does, but only once the + * dialog closes: a page that lists the file again (a collection page) would otherwise remove the dialog before its + * failure is read. Save waits for the membership read again. + * - A New collection name equal to a listed collection ignoring case is that collection's checkbox, checked. A name + * that cannot be a collection name is refused on the field; nothing is sent and the checkbox edits stay. + * - Focus starts on the field and moves to the first checkbox once they are listed; closing returns it to the + * trigger, or to `focusFallback()` when an inspector re-render removed the trigger. + */ +import { createButton } from '../shared/toolkit/core.js'; +import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs'; +import { showWorkspaceToast } from './toast.mjs'; +import { COLLECTIONS_UPDATED_TOAST, MAX_COLLECTION_NAME_LENGTH, MEMBERSHIP_FAILED_TEXT, MEMBERSHIP_LOADING_TEXT, + NO_COLLECTIONS_TEXT, UPDATE_FAILED_TEXT, collectionNameKey, normalizeCollectionName } from './collections.mjs'; + +export const COLLECTIONS_DIALOG_TITLE = 'Collections'; +export const NEW_COLLECTION_LABEL = 'New collection'; + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * Open the dialog for `target` (`{root_index, root_scope, path}`) with the client store `collections`. `openDialog` + * and `closeDialog` are the dialog seams; `notify` the toast. + */ +export function openCollectionsDialog({ scope, collections, target, trigger = null, notify = showWorkspaceToast, onChanged = null, + focusFallback = null, openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) { + const local = new AbortController(); + const active = () => !scope.signal.aborted && !local.signal.aborted; + let member = null, saving = false, listed = false; + // The confirmed changes, reported once when the dialog closes (review M2). + const changes = { added: [], removed: [] }; + const status = el('p', '', 'small'); + const retry = createButton('Retry'); + const list = el('div'); + list.setAttribute('role', 'group'); + list.setAttribute('aria-label', COLLECTIONS_DIALOG_TITLE); + const input = el('input'); + input.value = ''; + input.maxLength = MAX_COLLECTION_NAME_LENGTH; + input.autocomplete = 'off'; + input.addEventListener('input', () => input.setCustomValidity?.('')); + const problem = el('p', '', 'small'); + problem.setAttribute('role', 'alert'); + problem.hidden = true; + const actions = el('div', undefined, 'dialog-actions'); + const cancel = createButton('Cancel'); + const save = createButton('Save', 'ca-primary'); + actions.append(cancel, save); + const blocked = () => member === null || saving; + const paintSave = () => { + if (blocked()) save.setAttribute('aria-disabled', 'true'); + else save.removeAttribute('aria-disabled'); + }; + retry.addEventListener('click', () => load()); + cancel.addEventListener('click', () => { if (!saving) closeDialog(); }); + save.addEventListener('click', () => { if (!blocked()) submit(); }); + + /** One checkbox row per collection, checked where the file is a member. */ + function renderList() { + list.replaceChildren(); + if (!collections.collections.length) { + list.append(el('p', NO_COLLECTIONS_TEXT, 'small')); + return; + } + for (const item of collections.collections) { + const row = el('label', undefined, 'tag-suggestion'); + const box = el('input'); + Object.assign(box, { type: 'checkbox', id: `collection-choice-${item.id}`, checked: member.has(item.id) }); + box.dataset.collectionId = String(item.id); + row.htmlFor = box.id; + row.append(box, el('span', item.name)); + list.append(row); + } + } + function showStatus(text, failed) { + status.textContent = text; + status.setAttribute('role', failed ? 'alert' : 'status'); + status.hidden = !text; + retry.hidden = !failed; + } + /** Save waits while the membership is read (again). */ + function loading() { + member = null; + paintSave(); + list.replaceChildren(); + showStatus(MEMBERSHIP_LOADING_TEXT, false); + } + async function load() { + loading(); + try { + const ids = await collections.membership(target); + if (!active()) return; + member = new Set(ids); + showStatus('', false); + renderList(); + // The first listing takes focus from the untouched field to the first checkbox (review L6). + if (!listed && !input.value) firstBox().focus(); + listed = true; + } catch { + if (!active()) return; + showStatus(MEMBERSHIP_FAILED_TEXT, true); + } + paintSave(); + } + const boxesOf = () => [...list.children].map(row => row.children?.[0]).filter(box => box?.dataset?.collectionId); + const firstBox = () => boxesOf()[0] ?? input; + /** + * The requests Save sends, in order; null when the new name cannot be a collection name. A name equal to a listed + * collection's ignoring case is that collection's checkbox, checked (review L4). + */ + function plan() { + const typed = input.value.trim(); + const name = typed ? normalizeCollectionName(typed) : ''; + if (name === null) return null; + const named = name ? collections.collections.find(item => collectionNameKey(item.name) === collectionNameKey(name)) : null; + const steps = name && !named ? [{ add: { name } }] : []; + const boxes = boxesOf().map(box => ({ id: Number(box.dataset.collectionId), checked: box.checked })) + .map(box => (box.id === named?.id ? { ...box, checked: true } : box)); + for (const box of boxes) if (box.checked && !member.has(box.id)) steps.push({ add: { collectionId: box.id } }); + for (const box of boxes) if (!box.checked && member.has(box.id)) steps.push({ remove: box.id }); + return steps; + } + async function submit() { + problem.textContent = ''; + problem.hidden = true; + const steps = plan(); + if (steps === null) { + // Review L5: refused on the field; nothing is sent and the checkbox edits stay. + input.setCustomValidity?.(UPDATE_FAILED_TEXT); + input.reportValidity?.(); + input.focus(); + return; + } + if (steps.length === 0) { closeDialog(); return; } + saving = true; + paintSave(); + let failed = false; + for (const step of steps) { + try { + if (step.add) changes.added.push((await collections.add(step.add, [target])).collection.id); + else changes.removed.push((await collections.remove(step.remove, [target])).collection.id); + } catch { + failed = true; + break; + } + if (!active()) return; + if (step.add?.name) input.value = ''; + } + if (!active()) return; + saving = false; + if (!failed) { + closeDialog(); + notify(COLLECTIONS_UPDATED_TOAST); + return; + } + problem.textContent = UPDATE_FAILED_TEXT; + problem.hidden = false; + // Review M1: Save waits from here until the membership is read again. A collection deleted meanwhile is + // re-read by the store; the list then shows what the server holds. + loading(); + await collections.settled(); + if (active()) await load(); + } + function closed() { + local.abort(); + if (changes.added.length || changes.removed.length) onChanged?.({ added: [...changes.added], removed: [...changes.removed] }); + // The shell focuses a trigger still on the page; one an inspector re-render replaced falls back (review L6). + if (trigger && !trigger.isConnected) focusFallback?.()?.focus(); + } + showStatus('', false); + openDialog({ scope, title: COLLECTIONS_DIALOG_TITLE, trigger, canClose: () => !saving, onClose: closed, focusTarget: input, + nodes: [status, retry, list, field(NEW_COLLECTION_LABEL, input, 'collection-new'), problem, actions] }); + load(); +} diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 106932af..10645720 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -19,7 +19,12 @@ function element(tag, className, text) { return node; } -export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter}) { +/** + * #331 D139: with the client store `collections`, the viewer's inspector offers "Collections…"; the changes it confirms + * reach `onCollectionsChanged({added, removed})` once the viewer closes, so the viewer keeps its place while open. + */ +export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,onMediaError,adapter = rootedFileAdapter, + collections = null,onCollectionsChanged = null}) { const scope = createScope(); let active = null, retryVersion = 0; const dispose = () => scope.dispose(); @@ -36,7 +41,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle active?.close(false); const {items,selectedKey,trigger = document.activeElement} = options; let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false; - let originalDownload = null, inspectionState = inspectionController.state; + let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null; const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => { if (!mounted) { initial = [state,selected]; return; } update(state,selected); @@ -99,7 +104,11 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}}; const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(), onLoad:() => inspectionController.load?.(), - onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined}); + onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined,collections, + onCollectionsChanged:({added,removed}) => { + collectionChanges ??= {added:[],removed:[]}; + collectionChanges.added.push(...added);collectionChanges.removed.push(...removed); + }}); function closeViewer(restore = true) { if (closed) return; closed = true;active = null;local.dispose(); @@ -108,6 +117,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle else onRestoreFocus?.(entry); } if (!scope.signal.aborted) onClose?.(); + if (collectionChanges && !scope.signal.aborted) onCollectionsChanged?.(collectionChanges); } active = {close:closeViewer,stop:() => session.stop()}; local.cleanup(() => { diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 0d6d4643..25a1d5ef 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -332,8 +332,9 @@ export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions'; * reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field. * `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field * reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a - * `` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that - * closes the dialog and runs `onClick` (#332). + * `` under the field (Set category's categories, #332; Add to collection's names, #331 D138); the field + * still takes any text. `secondary` (`{label, onClick}`) adds a button between Cancel and the action that closes the + * dialog and runs `onClick` (#332). */ export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null, validate = null, suggestions = null, secondary = null }) { @@ -400,13 +401,13 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe * another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false, * × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it). */ -export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) { +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null, focusTarget = null }) { requireScope(scope); if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function'); - show(title, nodes, { scope, trigger, onClose, canClose }); + show(title, nodes, { scope, trigger, onClose, canClose, focusTarget }); } export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' + From 02190d03a8c7689d9cdffa10e3ff3b695023f879 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 01:30:58 +0000 Subject: [PATCH 2/3] feat(library): add tags to many selected prompts and select every loaded prompt (#442) (#453) * feat(library): set the category on many selected prompts (#437) The Prompt library shelf gets the concept's Images selection checkbox on each card and its .bulkbar (N selected, Set category, Clear), recorded as D148. Set category opens the shared one-value dialog and posts {prompt_ids, category, expected_epoch} to POST /prompt_manager/bulk/category: the server checks the storage epoch the shelf was read under, then sets the category on the Library parents in one BEGIN IMMEDIATE transaction, writing only rows that change. A restored storage answers 409 storage-changed and writes nothing. The legacy admin and images_filter bodies are unchanged. This replaces the legacy admin page's bulk Set Category, a cutover prerequisite (#334). Closes #437 * feat(library): add tags to many selected prompts and select every loaded prompt (#442) The Library selection bar (D148) gets Add tags and select-all of the loaded shelf, the legacy admin page's bulk Add Tags and select-all checkbox. Add tags posts {prompt_ids, tags, expected_epoch} to POST /prompt_manager/bulk/tags: in one BEGIN IMMEDIATE transaction the server checks the storage epoch, normalizes the names as image tags are, gives each Library parent only the names it lacks (casefold), finds or inserts tag rows by exact name only when some prompt gets them, and skips prompts that would pass 20 tags. Prompt tags only. Both bulk/tags forms refuse a non-object body with 400. Ctrl/Cmd+A in the grid and "Select all N loaded" in the bar select every loaded prompt; the offer follows each loaded batch. Closes #442 * docs(library): record Add tags and select-all as D151 (#442) --- TESTING.md | 49 ++- database/library_commands.py | 87 ++++++ py/api/prompts.py | 43 ++- tests/browser/library-concept.spec.mjs | 6 +- .../workspace-library-selection.spec.mjs | 119 +++++++- tests/js/workspace-library-selection.test.js | 52 ++++ tests/test_bulk_library_tags.py | 286 ++++++++++++++++++ web/js/workspace/library-selection.mjs | 148 +++++++-- web/js/workspace/library.mjs | 10 +- 9 files changed, 755 insertions(+), 45 deletions(-) create mode 100644 tests/test_bulk_library_tags.py diff --git a/TESTING.md b/TESTING.md index 3875439f..f44b4515 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2442,13 +2442,15 @@ Category". It is built only from the concept's Images parts: Shift+Tab across windowed cards reach it (the card's controls are `button, input[type=checkbox]`); Home, End and the arrow keys still move between titles. - `div#library-bulkbar` sits between the toolbar and the caption, empty until something - is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear. The + is checked, then the concept's `.bulkbar`: "N selected", Set category, Clear (D151 + adds Add tags before Set category, in the Images bar's order). The selection is a set of prompt ids over the loaded shelf: windowed-out cards keep their state, Load more keeps it, and any shelf reload (search, category, sort, tags, a write) and leaving the page clear it. Clicking a checkbox toggles it and sets the anchor; Shift+click applies the anchor's range with the box's new state (the D135 helpers). Escape in the bar and Clear clear it, focus to the heading. There is no - Ctrl/Cmd+A, Shift+arrow, long-press or "Select all N matching" on the Library. + Shift+arrow, long-press or "Select all N matching" on the Library (D151 adds + Ctrl/Cmd+A and "Select all N loaded"). - Set category opens the shared `simpleInput` dialog ("Categorize selected prompts", `input#action-input` labelled Category, at most 100 characters, the Library's categories as suggestions). A blank value keeps it open; a control character is @@ -2470,7 +2472,8 @@ Category". It is built only from the concept's Images parts: storage was restored since this page loaded. No category was changed." and a reload. No answer, 502, 503 or 504: "The category change could not be confirmed. The Library was reloaded." Any other refusal: "Category could not be set. Try again.", the - selection kept. Set category is `aria-disabled` while the write runs. + selection kept. Every bar action (and D151's select-all offer) is `aria-disabled` while + a write runs. - The bar's DOM and write loop live in `library-selection.mjs`; the toast text (`setOutcome` with the noun "prompt"), the range helpers, the count and the dialog are the Images bar's. One bar core shared with `images-selection.mjs` is a follow-up. @@ -2484,6 +2487,43 @@ mutation); units `tests/js/workspace-library-selection.test.js`, `tests/js/workspace-library-state.test.js` (the epoch); Python `tests/test_bulk_library_category.py`. A Python change: ComfyUI needs a restart. +### Adding tags to selected prompts and selecting all (#442) + +**D151** (beyond the concept, on D148's bar): the Prompt library adds tags to the selected +prompts in one step, the legacy admin page's bulk "Add Tags", and selects every loaded +prompt, as its select-all checkbox did (#444 ruling). The concept has neither. + +- The bar reads "N selected", Add tags, Set category, Clear: the Images bar's order. +- Select all of the loaded shelf: Ctrl/Cmd+A in the grid (Images' key check; the search + box keeps its text meaning) selects every loaded prompt, windowed-out cards included, + and Escape in the grid clears it with focus kept on the card. "Select all N loaded" + follows the count while some loaded prompts are unchecked and at most 1,000 are loaded; + it selects them all and focus moves to Add tags; N follows each loaded batch. It sits + where D135's "Select all N matching" sits, but that offer appears once every loaded + image is checked; this one never reaches beyond the loaded prompts. No "all matching". +- Add tags opens the shared `simpleInput` dialog ("Tag selected prompts", "Tags, + separated by commas", the tag vocabulary `GET /prompt_manager/tags` as suggestions, the + list the editor offers). The field refuses no tag, more than 20, a tag over 50 + characters or with a control character, each with its sentence. Apply posts + `{prompt_ids, tags, expected_epoch}` to `POST /prompt_manager/bulk/tags`: prompt tags + only, never image tags, auto-tags or pair tags. Names are normalized as image tags are + (trim, no commas, casefold repeats folded); registry rows are found and inserted by + their exact name and only when some prompt gets them; a prompt gets only the names it + lacks by casefold. A prompt whose tags would pass 20 is skipped (`over_limit`), so the + editor can still save it. The answer is `{library_epoch, tags, updated, unchanged, + not_found, over_limit}`; the toast "Added “a, b” to N prompts." adds already-had, + skipped and not-found sentences. Storage restored: "Prompt storage was restored since + this page loaded. No tags were added."; unconfirmed: "Adding the tags could not be + confirmed. The Library was reloaded."; refused: "Tags could not be added. Try again." + The legacy `{prompt_ids, tags}` body is unchanged; both forms refuse a body that is not + a JSON object with 400. + +Tests: `tests/browser/workspace-library-selection.spec.mjs` (Add tags with its suggestions, +field refusals and restore refusal; Ctrl/Cmd+A and Select all N loaded across batches and +during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison now includes +Add tags); units `tests/js/workspace-library-selection.test.js`; Python +`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart. + ### Prompt history page and recorded deviations (#249 PR F-a) The history page (`#/library/`) takes the concept's heading, summary, negative @@ -6330,7 +6370,8 @@ Deploying this backend needs a ComfyUI restart (Python and route changes). `#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no longer hands off to the legacy tag manager. That page stays reachable from Overview's "Open existing tag manager" card for tag search and sort, the untagged entry and -multi-tag AND/OR browsing; bulk prompt delete and tag stay on the legacy admin page. A +multi-tag AND/OR browsing; bulk prompt delete stays on the legacy admin page (bulk tags: +the Prompt library's selection bar, D151). A prompt's category is changed in the Prompt library editor, on many prompts at once with the Prompt library's selection bar (D148, #437), or with the legacy admin page's "Set Category" (both `POST /prompt_manager/bulk/category`); diff --git a/database/library_commands.py b/database/library_commands.py index 878a7e98..d328416f 100644 --- a/database/library_commands.py +++ b/database/library_commands.py @@ -23,6 +23,8 @@ _TOKEN = re.compile(r"[0-9a-f]{64}") _PROMPT_COLUMNS = ("category", "rating", "notes") # Ids per IN (...) read: well under SQLite's oldest bound-parameter limit (999). _BULK_CHUNK = 500 +# The tags one prompt may carry: what the editor's save accepts (``validate_tags``). +MAX_LIBRARY_TAGS = 20 class LibraryCommandError(Exception): @@ -363,3 +365,88 @@ class LibraryCommandOperations: "unchanged": len(current) - len(changed), "not_found": len(prompt_ids) - len(current), } + + def bulk_add_library_tags( + self, prompt_ids: list[int], tags: list[str], *, expected_epoch: str + ) -> dict: + """Add tags to many Library parents, all or nothing (#442). + + Names are normalized once (trim, the vocabulary name rules, casefold + repeats folded, at most 20). A parent is given only the names it lacks, + compared with ``casefold`` against its own tags; registry rows are + found and inserted by their exact name, and only for names some parent + gets. A parent whose tags would pass 20 is skipped (``over_limit``) so + the editor can still save it. Pair and image tags are not touched. + """ + from .annotations import ImageTagLimitError, normalize_image_tags + + _validate_epoch(expected_epoch) + _validate_bulk_ids(prompt_ids) + prompt_ids = list(dict.fromkeys(prompt_ids)) + too_many = f"At most {MAX_LIBRARY_TAGS} tags can be added at once" + try: + tags = normalize_image_tags(tags) + except ImageTagLimitError: + # Its message names images; this is a prompt route. + raise ValueError(too_many) from None + if not tags: + raise ValueError("tags must name at least one tag") + if len(tags) > MAX_LIBRARY_TAGS: + raise ValueError(too_many) + with self._pair_transaction() as conn: + epoch = self._library_epoch(conn, expected_epoch) + held = self._library_parent_tags(conn, prompt_ids) + additions, over_limit = {}, 0 + for pid, names in held.items(): + present = {name.casefold() for name in names} + missing = [name for name in tags if name.casefold() not in present] + if not missing: + continue + if len(names) + len(missing) > MAX_LIBRARY_TAGS: + over_limit += 1 + continue + additions[pid] = missing + wanted = list( + dict.fromkeys(n for names in additions.values() for n in names) + ) + tag_ids = self._ensure_tags(conn, wanted) + now = utc_now_iso() + for pid, names in additions.items(): + conn.executemany( + "INSERT OR IGNORE INTO prompt_tags (prompt_id, tag_id) " + "VALUES (?, ?)", + [(pid, tag_ids[name]) for name in names], + ) + self._ensure_library_parent(conn, pid) + conn.execute("UPDATE prompts SET updated_at=? WHERE id=?", (now, pid)) + return { + "library_epoch": epoch, + "tags": tags, + "updated": len(additions), + "unchanged": len(held) - len(additions) - over_limit, + "not_found": len(prompt_ids) - len(held), + "over_limit": over_limit, + } + + def _library_parent_tags( + self, conn: sqlite3.Connection, prompt_ids: list[int] + ) -> dict[int, list[str]]: + """The Library parents among ``prompt_ids`` and their own tag names.""" + held: dict[int, list[str]] = {} + for start in range(0, len(prompt_ids), _BULK_CHUNK): + chunk = prompt_ids[start : start + _BULK_CHUNK] + marks = ",".join("?" for _ in chunk) + for (pid,) in conn.execute( + f"SELECT p.id FROM prompts p WHERE p.id IN ({marks}) " # nosec B608 + f"AND {_ELIGIBLE}", + chunk, + ): + held[pid] = [] + for pid, name in conn.execute( + "SELECT pt.prompt_id,t.name FROM prompt_tags pt " # nosec B608 + f"JOIN tags t ON t.id=pt.tag_id WHERE pt.prompt_id IN ({marks})", + chunk, + ): + if pid in held: + held[pid].append(name) + return held diff --git a/py/api/prompts.py b/py/api/prompts.py index 09001dcd..de2c6716 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -1130,7 +1130,14 @@ class PromptRoutesMixin: async def bulk_add_tags(self, request): """Bulk add tags to prompts.""" try: - data = await request.json() + try: + data = await request.json() + except ValueError: + return bad_request("Invalid JSON body") + if not isinstance(data, dict): + return bad_request("Expected a JSON object") + if "expected_epoch" in data: + return await self._bulk_library_tags(data) prompt_ids = data.get("prompt_ids", []) new_tags = data.get("tags", []) @@ -1220,14 +1227,20 @@ class PromptRoutesMixin: status=500, ) - async def _bulk_library_category(self, data: dict) -> web.Response: - """The workspace Library's fenced form (#437). + async def _bulk_library_tags(self, data: dict) -> web.Response: + """The workspace Library's fenced form (#442). - ``{prompt_ids, category, expected_epoch}``: a non-blank category on - the selected Library parents, refused with 409 ``storage-changed`` - when prompt storage was restored since the page read its epoch. + ``{prompt_ids, tags, expected_epoch}``: prompt tags only, never image tags. """ - fields = {"prompt_ids", "category", "expected_epoch"} + return await self._fenced_library_write( + data, "tags", self.db.bulk_add_library_tags + ) + + async def _fenced_library_write( + self, data: dict, field: str, write + ) -> web.Response: + """One fenced Library bulk write: exact keys, checked ids, 409 on a restore.""" + fields = {"prompt_ids", field, "expected_epoch"} if set(data) != fields: return bad_request( "Request must contain exactly " + ", ".join(sorted(fields)) @@ -1235,10 +1248,7 @@ class PromptRoutesMixin: try: prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"]) result = await self._run_in_executor( - self.db.bulk_set_library_category, - prompt_ids, - data["category"], - expected_epoch=data["expected_epoch"], + write, prompt_ids, data[field], expected_epoch=data["expected_epoch"] ) except LibraryCommandError as exc: return web.json_response( @@ -1254,6 +1264,17 @@ class PromptRoutesMixin: return bad_request(str(exc)) return web.json_response({"success": True, **result}) + async def _bulk_library_category(self, data: dict) -> web.Response: + """The workspace Library's fenced form (#437). + + ``{prompt_ids, category, expected_epoch}``: a non-blank category on + the selected Library parents, refused with 409 ``storage-changed`` + when prompt storage was restored since the page read its epoch. + """ + return await self._fenced_library_write( + data, "category", self.db.bulk_set_library_category + ) + async def _bulk_category_by_filter(self, data: dict) -> web.Response: """Set the category on every linked positive a listing filter admits. diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index 33f6bbff..14de15c5 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -317,7 +317,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet /** * D148 (#437): the selection checkbox overlay of `card` and the `.bulkbar` row inside `main`, with one item checked, * measured alike in the concept's Images page and production's Library: the overlay's inset in its card, the checkbox, - * and the bar's box, tokens and the two actions both bars have (Set category, Clear). + * and the bar's box, tokens and the actions both bars have (Add tags, Set category, Clear; #442). */ const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{ const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));}; @@ -327,7 +327,7 @@ const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{ 'borderTopColor','borderTopLeftRadius','backgroundColor'); const host=document.querySelector(card),overlay=host.querySelector('.image-overlays'),label=overlay.querySelector(':scope > label'); const input=label.querySelector('input'),root=document.querySelector(main),bar=root.querySelector('.bulkbar'); - const shared=['Set category','Clear']; + const shared=['Add tags','Set category','Clear']; return { overlay:{left:px(rect(overlay).left-rect(host).left),top:px(rect(overlay).top-rect(host).top),right:px(rect(host).right-rect(overlay).right), ...css(overlay,'position','display','justifyContent','pointerEvents')}, @@ -357,7 +357,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se const actual=await productionSelection(page,theme); await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`); await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'}); - expect(reference.bar.buttons.map(button=>button.text),'the concept bar has both actions').toEqual(['Set category','Clear']); + expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']); expect(actual).toEqual(reference); // The comparison notices a moved checkbox (spec §7 mutation check). await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'}); diff --git a/tests/browser/workspace-library-selection.spec.mjs b/tests/browser/workspace-library-selection.spec.mjs index 48335b0d..d92eeb76 100644 --- a/tests/browser/workspace-library-selection.spec.mjs +++ b/tests/browser/workspace-library-selection.spec.mjs @@ -11,6 +11,14 @@ const bar=page=>page.locator('#library-bulkbar > .bulkbar'); const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true}); const toast=page=>page.locator('.toast'); const heading=page=>page.locator('#workspace-main h1'); +const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true}); +const chips=(page,id)=>card(page,id).locator('.tags > .chip'); +async function addTags(page,value) { + await bar(page).getByRole('button',{name:'Add tags',exact:true}).click(); + await expect(tagsDialog(page)).toBeVisible(); + await tagsDialog(page).locator('#action-input').fill(value); + await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click(); +} async function shelf(request) { const body=await(await request.get('/prompt_manager/library?limit=100')).json(); @@ -53,7 +61,8 @@ test('Set category writes the selected prompts on the server, reloads the shelf await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `)); await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected'); await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected'); - await expect(bar(page).getByRole('button')).toHaveText(['Set category','Clear']); + // Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling). + await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']); await setCategory(page,' Studio set '); await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.'); await expect(dialog(page)).toHaveCount(0); @@ -175,3 +184,111 @@ test('an answer that may follow a committed write says so and reloads; a refused await expect(page.locator('#library-bulkbar')).toBeEmpty(); await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed'); }); + +// #442: Add tags writes prompt tags (never image tags) on the selected prompts. +function recordTagWrites(page) { + const writes=[]; + page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());}); + return writes; +} +async function promptTags(request,id) { + return (await shelf(request)).find(item=>item.id===id).tags; +} + +test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{ + const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page); + const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags; + await page.goto(route);await expect(cards(page)).toHaveCount(items.length); + await box(page,first.id).check();await box(page,second.id).check(); + await bar(page).getByRole('button',{name:'Add tags',exact:true}).click(); + await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length); + await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0); + await addTags(page,' studio-lit , Night ,studio-lit'); + await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.'); + await expect(page.locator('#library-bulkbar')).toBeEmpty(); + for(const item of [first,second]) { + await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1); + expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night'])); + } + expect(await promptTags(request,third.id)).not.toContain('studio-lit'); + const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch; + expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]); + // A second add of the same names changes nothing and says so. + await box(page,first.id).check();await addTags(page,'Studio-Lit'); + await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.'); +}); + +test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{ + await armUnseenRestores(page); + const items=await shelf(request);const writes=recordTagWrites(page); + await page.goto(route);await expect(cards(page)).toHaveCount(items.length); + await box(page,items[0].id).check(); + await bar(page).getByRole('button',{name:'Add tags',exact:true}).click(); + const field=tagsDialog(page).locator('#action-input'); + for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'], + [Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) { + await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click(); + await expect(tagsDialog(page)).toBeVisible(); + expect(await field.evaluate(node=>node.validationMessage)).toBe(message); + } + await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click(); + expect(writes).toHaveLength(0); + const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body(); + const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}})); + await expect(restore).toBeOK(); + await addTags(page,'after-restore'); + await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.'); + await expect(page.locator('#library-bulkbar')).toBeEmpty(); + expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore'); +}); + +// #444 ruling (D151): select every loaded prompt, as the legacy page's select-all checkbox did for its page. +test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',async({page,request,isMobile})=>{ + await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK(); + if(!isMobile)await page.setViewportSize({width:1600,height:1100}); + const writes=recordTagWrites(page); + await page.goto(route);await expect(cards(page).first()).toBeVisible(); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + for(let index=0;index<3;index++) { + await expect(more).toHaveAttribute('aria-disabled','false'); + const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');}); + await more.evaluate(node=>node.click());await response; + } + await expect(more).toHaveAttribute('aria-disabled','false'); + const loaded=160;expect(await cards(page).count()).toBeLessThan(loaded); + // In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt. + await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a'); + await expect(page.locator('#library-bulkbar')).toBeEmpty(); + // One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action. + const firstId=Number(await cards(page).first().getAttribute('data-prompt-id')); + await box(page,firstId).check(); + const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true}); + await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`); + await offer.click(); + await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`); + await expect(offer).toHaveCount(0); + await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused(); + // Another batch arrives unchecked: the offer comes back with the new N. + { + const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');}); + await more.evaluate(node=>node.click());await response; + } + await expect(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible(); + await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`); + // Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again. + const title=cards(page).first().locator('.card-title > button'); + await title.focus();await page.keyboard.press('Escape'); + await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused(); + await page.keyboard.press('ControlOrMeta+a'); + const all=loaded+40; + await expect(bar(page).locator('strong')).toHaveText(`${all} selected`); + // While the write runs, every bar action and the offer are aria-disabled. + await box(page,firstId).uncheck(); + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1}); + await addTags(page,'everyone'); + for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true'); + release(); + await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`); + expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1); +}); diff --git a/tests/js/workspace-library-selection.test.js b/tests/js/workspace-library-selection.test.js index ad771f9a..7f2388a9 100644 --- a/tests/js/workspace-library-selection.test.js +++ b/tests/js/workspace-library-selection.test.js @@ -42,3 +42,55 @@ test('the field refuses control characters as validate_category does', async () assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS); assert.equal(categoryProblem('unicode ✓ é'), null); }); + +test('Add tags uses the Images dialog words for prompts, the tag routes and the editor limits (#442)', async () => { + const {PROMPT_TAGS_DIALOG, TAGS_URL, TAG_OPTIONS_URL, MAX_PROMPT_TAGS} = await source; + assert.deepEqual({...PROMPT_TAGS_DIALOG}, {title:'Tag selected prompts', label:'Tags, separated by commas', action:'Apply'}); + assert.equal(TAGS_URL, '/prompt_manager/bulk/tags'); + assert.equal(TAG_OPTIONS_URL, '/prompt_manager/tags'); + assert.equal(MAX_PROMPT_TAGS, 20); +}); + +test('the Add tags toast says what was added, already present, skipped and missing', async () => { + const {tagsOutcome, writeOutcome, NONE_FOUND} = await source; + assert.equal(tagsOutcome({tags:['a', 'b'], updated:2, unchanged:0, over_limit:0, not_found:0}), 'Added “a, b” to 2 prompts.'); + assert.equal(tagsOutcome({tags:['a'], updated:1, unchanged:2, over_limit:1, not_found:3}), + 'Added “a” to 1 prompt. 2 prompts already had them. 1 prompt would pass 20 tags and was skipped. 3 could not be found.'); + assert.equal(tagsOutcome({tags:['a'], updated:0, unchanged:2, over_limit:0, not_found:0}), '2 prompts already had “a”.'); + // Every prompt skipped: found, so the skip sentence, never "none found"; nothing committed. + assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:2, not_found:0}, null, 'tags'), + {toast:'2 prompts would pass 20 tags and were skipped.', committed:false, reload:true}); + assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:0, not_found:2}, null, 'tags'), + {toast:NONE_FOUND, committed:false, reload:true}); +}); + +test('Add tags failures name tags, not the category', async () => { + const {writeOutcome, TAGS_STORAGE_CHANGED, TAGS_UNCONFIRMED, TAGS_FAILED} = await source; + assert.equal(writeOutcome(null, {status:409, code:'storage-changed'}, 'tags').toast, TAGS_STORAGE_CHANGED); + assert.equal(writeOutcome(null, {status:503}, 'tags').toast, TAGS_UNCONFIRMED); + assert.equal(writeOutcome(null, {status:400}, 'tags').toast, TAGS_FAILED); + assert.equal(TAGS_STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No tags were added.'); +}); + +test('the Add tags field refuses what validate_tags refuses', async () => { + const {tagsProblem, TAG_PROBLEMS} = await source; + assert.equal(tagsProblem('red, blue'), null); + assert.equal(tagsProblem(' , ,'), TAG_PROBLEMS.none); + assert.equal(tagsProblem(Array.from({length:21}, (_, i) => `t${i}`).join(',')), TAG_PROBLEMS.many); + assert.equal(tagsProblem(Array.from({length:21}, () => 'same').join(',')), null); + assert.equal(tagsProblem('x'.repeat(51)), TAG_PROBLEMS.long); + assert.equal(tagsProblem('bad\ttag'), TAG_PROBLEMS.control); +}); + +test('Select all N loaded is offered while some loaded prompts are unchecked (#444 ruling)', async () => { + const {loadedOffer, offersLoaded} = await source; + assert.equal(loadedOffer(3), 'Select all 3 loaded'); + assert.equal(loadedOffer(1203), 'Select all 1,203 loaded'); + assert.equal(offersLoaded(new Set([1]), [1, 2, 3]), true); + assert.equal(offersLoaded(new Set([1, 2, 3]), [1, 2, 3]), false); + assert.equal(offersLoaded(new Set(), [1, 2]), false); + assert.equal(offersLoaded(new Set([9]), [9]), false); + // More loaded than any action accepts: never offered (Ctrl/Cmd+A still selects them). + assert.equal(offersLoaded(new Set([1]), Array.from({length:1001}, (_, i) => i + 1)), false); + assert.equal(offersLoaded(new Set([1]), Array.from({length:1000}, (_, i) => i + 1)), true); +}); diff --git a/tests/test_bulk_library_tags.py b/tests/test_bulk_library_tags.py new file mode 100644 index 00000000..be8fb9a4 --- /dev/null +++ b/tests/test_bulk_library_tags.py @@ -0,0 +1,286 @@ +"""#442: the workspace Library's fenced bulk "Add tags" (DB command and route).""" + +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from database.execution_history_schema import rotate_history_epoch +from database.library_commands import LibraryCommandError +from database.operations import PromptDatabase +from utils.hashing import generate_prompt_hash + +try: + from test_api_prompts import RouteCoverageCase +except ImportError: + from tests.test_api_prompts import RouteCoverageCase + +URL = "/prompt_manager/bulk/tags" + + +def save(db, text, **metadata): + return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata) + + +def tags_of(db, prompt_id): + with db.model.get_connection() as conn: + rows = conn.execute( + "SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.prompt_id=? ORDER BY t.name", + (prompt_id,), + ).fetchall() + return [row[0] for row in rows] + + +def updated_at(db, prompt_id): + with db.model.get_connection() as conn: + return conn.execute( + "SELECT updated_at FROM prompts WHERE id=?", (prompt_id,) + ).fetchone()[0] + + +def tag_names(db): + with db.model.get_connection() as conn: + return sorted(row[0] for row in conn.execute("SELECT name FROM tags")) + + +class TestBulkLibraryTags(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.db = PromptDatabase(str(Path(self.temp.name) / "tags.db")) + self.epoch = self.db.get_library_options()["library_epoch"] + + def tearDown(self): + self.db.close_all() + self.temp.cleanup() + + def run_bulk(self, ids, tags, epoch=None): + return self.db.bulk_add_library_tags( + ids, tags, expected_epoch=epoch or self.epoch + ) + + def test_adds_only_missing_tags_and_counts_each_outcome(self): + a = save(self.db, "Alpha", tags=["red"]) + b = save(self.db, "Beta", tags=["red", "blue"]) + c = save(self.db, "Gamma") + before_b = updated_at(self.db, b) + result = self.run_bulk([a, b, c, 99999], [" red ", "blue", "Blue"]) + self.assertEqual( + result, + { + "library_epoch": self.epoch, + "tags": ["red", "blue"], + "updated": 2, + "unchanged": 1, + "not_found": 1, + "over_limit": 0, + }, + ) + self.assertEqual(tags_of(self.db, a), ["blue", "red"]) + self.assertEqual(tags_of(self.db, c), ["blue", "red"]) + self.assertEqual(updated_at(self.db, b), before_b) + self.assertTrue(updated_at(self.db, a).endswith("+00:00")) + + def test_a_case_variant_of_a_held_tag_is_not_added_and_names_stay_exact(self): + a = save(self.db, "Alpha", tags=["Portrait"]) + b = save(self.db, "Beta") + result = self.run_bulk([a, b], ["portrait"]) + # Alpha already holds it (casefold); Beta gets the name exactly as typed. + self.assertEqual((result["updated"], result["unchanged"]), (1, 1)) + self.assertEqual(tags_of(self.db, a), ["Portrait"]) + self.assertEqual(tags_of(self.db, b), ["portrait"]) + self.assertEqual(tag_names(self.db), ["Portrait", "portrait"]) + c = save(self.db, "Gamma", tags=["STRASSE"]) + self.assertEqual(self.run_bulk([c], ["straße"])["unchanged"], 1) + + def test_names_nothing_receives_are_never_inserted(self): + full = save(self.db, "Full", tags=[f"t{i}" for i in range(20)]) + self.run_bulk([full, 99999], ["unused"]) + self.assertNotIn("unused", tag_names(self.db)) + + def test_a_prompt_already_over_twenty_that_gains_nothing_is_unchanged(self): + over = save(self.db, "Over", tags=[f"t{i}" for i in range(22)]) + result = self.run_bulk([over], ["t1"]) + self.assertEqual((result["unchanged"], result["over_limit"]), (1, 0)) + + def test_image_and_pair_tags_are_untouched(self): + pair = self.db.save_prompt_pair("Cat", "Blur", tags=["pairtag"]) + positive = self.db.get_prompt_pair(pair)["positive_prompt_id"] + with self.db.model.get_connection() as conn: + before = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0] + self.run_bulk([positive], ["pairtag", "own"]) + self.assertEqual(tags_of(self.db, positive), ["own", "pairtag"]) + with self.db.model.get_connection() as conn: + after = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0] + self.assertEqual(after, before) + + def test_a_prompt_that_would_pass_twenty_tags_is_skipped_untouched(self): + full = save(self.db, "Full", tags=[f"t{i}" for i in range(19)]) + other = save(self.db, "Other") + before = updated_at(self.db, full) + result = self.run_bulk([full, other], ["x", "y"]) + self.assertEqual((result["updated"], result["over_limit"]), (1, 1)) + self.assertEqual(len(tags_of(self.db, full)), 19) + self.assertEqual(updated_at(self.db, full), before) + self.assertEqual(tags_of(self.db, other), ["x", "y"]) + # Exactly twenty is allowed. + self.assertEqual(self.run_bulk([full], ["x"])["updated"], 1) + + def test_changed_prompts_become_registered_library_parents(self): + a = save(self.db, "Alpha") + self.run_bulk([a], ["new"]) + with self.db.model.get_connection() as conn: + self.assertIsNotNone( + conn.execute( + "SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (a,) + ).fetchone() + ) + + def test_negative_only_prompts_are_not_found(self): + pair = self.db.save_prompt_pair("Cat", "Blur") + negative = self.db.get_prompt_pair(pair)["negative_prompt_id"] + result = self.run_bulk([negative], ["x"]) + self.assertEqual((result["updated"], result["not_found"]), (0, 1)) + self.assertEqual(tags_of(self.db, negative), []) + + def test_restored_storage_writes_nothing_not_even_a_tag_name(self): + a = save(self.db, "Alpha") + rotate_history_epoch(self.db.model.get_connection()) + with self.assertRaises(LibraryCommandError) as caught: + self.run_bulk([a], ["fresh"]) + self.assertEqual(caught.exception.code, "storage-changed") + self.assertEqual(tags_of(self.db, a), []) + self.assertNotIn("fresh", tag_names(self.db)) + + def test_a_failure_mid_write_leaves_every_prompt_as_it_was(self): + ids = [save(self.db, f"P{i}") for i in range(3)] + with self.db.model.get_connection() as conn: + conn.execute( + "CREATE TRIGGER fail_third BEFORE INSERT ON prompt_tags " + f"WHEN NEW.prompt_id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END" + ) + with self.assertRaises(sqlite3.DatabaseError): + self.run_bulk(ids, ["x"]) + self.assertEqual([tags_of(self.db, pid) for pid in ids], [[], [], []]) + self.assertNotIn("x", tag_names(self.db)) + + def test_entries_are_counted_after_dedupe_and_the_limit_names_prompts(self): + a = save(self.db, "Alpha") + self.assertEqual(self.run_bulk([a], ["dup"] * 30 + ["Dup"])["updated"], 1) + with self.assertRaises(ValueError) as caught: + self.run_bulk([a], [f"t{i}" for i in range(101)]) + self.assertEqual(str(caught.exception), "At most 20 tags can be added at once") + + def test_many_ids_are_read_in_chunks(self): + ids = [save(self.db, f"Many {i}") for i in range(1000)] + self.assertEqual(self.run_bulk(ids, ["bulk"])["updated"], 1000) + + def test_rejects_invalid_arguments(self): + a = save(self.db, "Alpha") + for ids, tags, epoch in ( + ([a], [], self.epoch), + ([a], [" "], self.epoch), + ([a], "red", self.epoch), + ([a], ["x" * 51], self.epoch), + ([a], ["bad\x01"], self.epoch), + ([a], ["a,b"], self.epoch), + ([a], [f"t{i}" for i in range(21)], self.epoch), + ([a], ["ok"], "not-a-uuid"), + ([], ["ok"], self.epoch), + ): + with self.subTest(tags=tags, epoch=epoch, ids=ids): + with self.assertRaises(ValueError): + self.run_bulk(ids, tags, epoch) + + +class TestBulkLibraryTagsRoute(RouteCoverageCase): + def epoch(self): + return self.api.db.get_library_options()["library_epoch"] + + async def test_fenced_form_adds_tags_and_reports_counts(self): + ids = [self._save_prompt(f"route {i}") for i in range(2)] + status, body = await self._json( + "POST", + URL, + json={"prompt_ids": ids, "tags": ["Fresh"], "expected_epoch": self.epoch()}, + ) + self.assertEqual(status, 200) + self.assertEqual( + body, + { + "success": True, + "library_epoch": self.epoch(), + "tags": ["Fresh"], + "updated": 2, + "unchanged": 0, + "not_found": 0, + "over_limit": 0, + }, + ) + + async def test_restored_storage_answers_409_storage_changed(self): + pid = self._save_prompt("route restore") + stale = self.epoch() + new_epoch = rotate_history_epoch(self.api.db.model.get_connection()) + status, body = await self._json( + "POST", + URL, + json={"prompt_ids": [pid], "tags": ["x"], "expected_epoch": stale}, + ) + self.assertEqual((status, body["code"]), (409, "storage-changed")) + self.assertEqual(body["library_epoch"], new_epoch) + + async def test_fenced_form_validates_its_body(self): + pid = self._save_prompt("route validate") + epoch = self.epoch() + for body in ( + {"prompt_ids": [pid], "tags": [], "expected_epoch": epoch}, + {"prompt_ids": [pid], "tags": "x", "expected_epoch": epoch}, + {"prompt_ids": [pid], "tags": ["x" * 51], "expected_epoch": epoch}, + {"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": "nope"}, + {"prompt_ids": [True], "tags": ["ok"], "expected_epoch": epoch}, + { + "prompt_ids": list(range(1, 1002)), + "tags": ["ok"], + "expected_epoch": epoch, + }, + {"prompt_ids": [pid], "expected_epoch": epoch}, + {"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": epoch, "x": 1}, + ): + with self.subTest(body=body): + status, answer = await self._json("POST", URL, json=body) + self.assertEqual(status, 400) + self.assertFalse(answer["success"]) + self.assertEqual(tags_of(self.api.db, pid), []) + + async def test_fenced_form_reports_an_unexpected_failure(self): + pid = self._save_prompt("route failure") + self._break("bulk_add_library_tags") + status, body = await self._json( + "POST", + URL, + json={"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": self.epoch()}, + ) + self.assertEqual(status, 500) + self.assertFalse(body["success"]) + + async def test_legacy_form_refuses_a_body_that_is_not_an_object(self): + for raw in ("[1, 2]", "not json"): + with self.subTest(raw=raw): + status, body = await self._json( + "POST", URL, data=raw, headers={"Content-Type": "application/json"} + ) + self.assertEqual(status, 400) + self.assertFalse(body["success"]) + + async def test_legacy_form_is_unchanged(self): + pid = self._save_prompt("route legacy") + status, body = await self._json( + "POST", URL, json={"prompt_ids": [pid], "tags": ["Legacy"]} + ) + self.assertEqual((status, body["updated_count"]), (200, 1)) + self.assertNotIn("library_epoch", body) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/js/workspace/library-selection.mjs b/web/js/workspace/library-selection.mjs index e487cf7c..27f2b883 100644 --- a/web/js/workspace/library-selection.mjs +++ b/web/js/workspace/library-selection.mjs @@ -2,19 +2,23 @@ import { createButton } from '../shared/toolkit/core.js'; import { selectionLabel, toggleSelection } from './images-state.mjs'; import { applyKeys, rangeKeys } from './images-multiselect.mjs'; import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, setOutcome } from './images-category.mjs'; +import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs'; import { openInputDialog } from './workspace-dialogs.mjs'; +import { countOf } from './plural.mjs'; import { showWorkspaceToast } from './toast.mjs'; /** - * The Prompt library's selection and its one bulk action, "Set category" (#437, D148, beyond the concept): the - * legacy admin page's "Set Category" in the workspace. Built from the concept's Images parts only: each card's - * `.image-overlays` checkbox and the `.bulkbar` row ("N selected", the action, Clear). The selection is a set of + * The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set + * category" (#437), the legacy admin page's bulk Add Tags and Set Category in the workspace. Built from the concept's + * Images parts only: each card's `.image-overlays` checkbox and the `.bulkbar` row ("N selected", the actions in the + * Images bar's order, Clear). The selection is a set of * prompt ids over the shelf's loaded items, so windowed-out cards keep their state; Shift+click on a checkbox applies * the anchor's range (the Images helpers, D135). The dialog, the range helpers, the count and the toast text are the * Images bar's own; one bar core shared with images-selection.mjs is a follow-up (D148). * - * Apply posts `{prompt_ids, category, expected_epoch}` to `POST /prompt_manager/bulk/category`: the epoch the shelf - * was read under, so a restored storage answers 409 `storage-changed` and nothing is written. + * Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`: + * the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written. + * Add tags writes prompt tags only, never image tags or auto-tags. */ export const CATEGORY_URL = '/prompt_manager/bulk/category'; export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' }); @@ -26,6 +30,17 @@ export const UNCONFIRMED = 'The category change could not be confirmed. The Libr export const NONE_FOUND = 'None of the selected prompts could be found. The Library was reloaded.'; export const CONTROL_CHARACTERS = 'A category cannot contain control characters.'; export { CATEGORY_FAILED }; +export const TAGS_URL = '/prompt_manager/bulk/tags'; +export const TAG_OPTIONS_URL = '/prompt_manager/tags'; +export const PROMPT_TAGS_DIALOG = Object.freeze({ title: 'Tag selected prompts', label: 'Tags, separated by commas', action: 'Apply' }); +// What the editor's save accepts for one prompt (`validate_tags`): the route refuses more. +export const MAX_PROMPT_TAGS = 20; +export const MAX_TAG_LENGTH = 50; +export const TAG_PROBLEMS = Object.freeze({ none: NO_TAGS, many: 'Enter at most 20 tags.', + long: 'A tag can have at most 50 characters.', control: 'A tag cannot contain control characters.' }); +export const TAGS_FAILED = 'Tags could not be added. Try again.'; +export const TAGS_STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No tags were added.'; +export const TAGS_UNCONFIRMED = 'Adding the tags could not be confirmed. The Library was reloaded.'; // Answers that may follow a committed write (the Library editor's unknown outcome): no status, or a gateway's. const UNCONFIRMED_STATUSES = new Set([502, 503, 504]); @@ -43,16 +58,56 @@ function el(tag, text, className) { * What a settled write means for the page: `toast` to show, whether the write may have `committed` (the page then * invalidates its cached Library views) and whether the shelf must `reload` (which clears the selection). */ -export function writeOutcome(result, failure) { +export function writeOutcome(result, failure, kind = 'category') { + const copy = WRITES[kind]; if (failure === null && result?.success) { - const found = (Number(result.updated) || 0) + (Number(result.unchanged) || 0); + const found = ['updated', 'unchanged', 'over_limit'].reduce((sum, key) => sum + (Number(result[key]) || 0), 0); if (!found) return { toast: NONE_FOUND, committed: false, reload: true }; - return { toast: setOutcome(result, result.category, 'prompt'), committed: true, reload: true }; + return { toast: copy.success(result), committed: (Number(result.updated) || 0) > 0, reload: true }; } - if (failure?.code === 'storage-changed') return { toast: STORAGE_CHANGED, committed: false, reload: true }; + if (failure?.code === 'storage-changed') return { toast: copy.storage, committed: false, reload: true }; const status = failure?.status; - if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: UNCONFIRMED, committed: true, reload: true }; - return { toast: CATEGORY_FAILED, committed: false, reload: false }; + if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true }; + return { toast: copy.failed, committed: false, reload: false }; +} + +/** The Add tags toast (#442): what was added, then what already had them, was skipped or could not be found. */ +export function tagsOutcome(result) { + const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0; + const skipped = Number(result?.over_limit) || 0, missing = Number(result?.not_found) || 0; + const names = Array.isArray(result?.tags) ? result.tags.join(', ') : ''; + const parts = []; + if (updated) parts.push(`Added “${names}” to ${countOf(updated, 'prompt')}.`); + if (unchanged) parts.push(`${countOf(unchanged, 'prompt')} already had ${updated ? 'them' : `“${names}”`}.`); + if (skipped) parts.push(`${countOf(skipped, 'prompt')} would pass ${MAX_PROMPT_TAGS} tags and ${skipped === 1 ? 'was' : 'were'} skipped.`); + if (missing) parts.push(`${missing.toLocaleString('en-US')} could not be found.`); + return parts.join(' '); +} + +const WRITES = Object.freeze({ + category: { success: result => setOutcome(result, result.category, 'prompt'), storage: STORAGE_CHANGED, + unconfirmed: UNCONFIRMED, failed: CATEGORY_FAILED }, + tags: { success: tagsOutcome, storage: TAGS_STORAGE_CHANGED, unconfirmed: TAGS_UNCONFIRMED, failed: TAGS_FAILED }, +}); + +/** + * "Select all N loaded" (#444 ruling, D151; the legacy page's select-all checkbox): offered while some loaded prompts + * are unchecked and no more are loaded than an action accepts. It sits where D135's "Select all N matching" sits on + * Images, but that offer appears once every loaded image is checked; this one reaches only the loaded prompts. + */ +export function offersLoaded(selected, keys) { + return selected.size > 0 && keys.length <= MAX_CATEGORY_PROMPTS && keys.some(key => !selected.has(key)); +} +export const loadedOffer = count => `Select all ${count.toLocaleString('en-US')} loaded`; + +/** The Add tags field's check before Apply: the parsed names, as `validate_tags` would judge them. */ +export function tagsProblem(text) { + const tags = parseBulkTags(text); + if (!tags.length) return TAG_PROBLEMS.none; + if (tags.length > MAX_PROMPT_TAGS) return TAG_PROBLEMS.many; + if (tags.some(tag => tag.length > MAX_TAG_LENGTH)) return TAG_PROBLEMS.long; + if (tags.some(tag => /[\x00-\x1f]/.test(tag))) return TAG_PROBLEMS.control; + return null; } /** The field's check before Apply: what `validate_category` refuses beyond the length the field already limits. */ @@ -63,7 +118,7 @@ export function categoryProblem(value) { /** * Mount the selection on the shelf `grid`. `getItems()` is the shelf's loaded items in order, `getEpoch()` the * storage epoch they were read under, `getCategories()` the Library's category list (the field's suggestions). - * `onCommitted(receipt)` runs after a write that may have changed prompts, even once the page is gone (the page + * `onCommitted(kind, receipt)` (`category` or `tags`) runs after a write that may have changed prompts, even once the page is gone (the page * invalidates its cached Library views); `onReload()` then reloads the mounted shelf (which calls `clear`). Focus moves * to `focusFallback` when it was on the bar the reload hides. */ @@ -73,9 +128,19 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge const element = el('div'); element.id = 'library-bulkbar'; const bulk = el('div', undefined, 'bulkbar'), count = el('strong'); + const addTags = createButton('Add tags'); const setCategory = createButton('Set category'); const clearButton = createButton('Clear'); - bulk.append(count, setCategory, clearButton); + const selectLoaded = createButton(''); + const actions = [addTags, setCategory, selectLoaded]; + bulk.append(count, addTags, setCategory, clearButton); + scope.listen(selectLoaded, 'click', () => { + if (writing) return; + selectAll(); + // The pressed offer left the bar: focus moves to its first action, as after D135's offer. + addTags.focus({ preventScroll: true }); + }); + scope.listen(addTags, 'click', () => openTags()); scope.listen(setCategory, 'click', () => openCategory()); scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); }); // Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear. @@ -91,6 +156,12 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge if (!checked.size) { bulk.remove(); return; } const label = selectionLabel(checked.size); if (count.textContent !== label) count.textContent = label; + const keys = ids(); + if (offersLoaded(checked, keys)) { + const offer = loadedOffer(keys.length); + if (selectLoaded.textContent !== offer) selectLoaded.textContent = offer; + if (count.nextElementSibling !== selectLoaded) count.after(selectLoaded); + } else selectLoaded.remove(); if (bulk.parentNode !== element) element.append(bulk); } function paint() { @@ -104,6 +175,10 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge anchor = null; if (checked.size) update(Object.freeze(new Set())); } + /** Every loaded prompt, windowed-out cards included; the anchor stays. */ + function selectAll() { + update(applyKeys(checked, ids(), true)); + } // The checkbox toggles natively; a Shift+click applies the anchor's range with the box's new state. scope.listen(grid, 'click', event => { const box = event.target.closest('.image-overlays > label'); @@ -134,32 +209,53 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge return overlays; } - function openCategory() { - if (writing) return; + /** The selected loaded prompts and the shelf epoch, or null after a toast says why nothing can be sent. */ + function target(failed) { + if (writing) return null; const loaded = new Set(ids()), selected = [...checked].filter(id => loaded.has(id)); - if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return; } + if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return null; } const epoch = getEpoch(); - if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(CATEGORY_FAILED); return; } + if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(failed); return null; } + return { prompt_ids: selected, expected_epoch: epoch }; + } + function openCategory() { + const chosen = target(CATEGORY_FAILED); + if (!chosen) return; openInputDialog({ scope, trigger: setCategory, title: PROMPT_CATEGORY_DIALOG.title, label: PROMPT_CATEGORY_DIALOG.label, action: PROMPT_CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(), - validate: categoryProblem, onApply: category => write({ prompt_ids: selected, category, expected_epoch: epoch }) }); + validate: categoryProblem, onApply: category => write(CATEGORY_URL, { ...chosen, category }, 'category') }); + } + /** Add tags (#442): the tag vocabulary as suggestions, the editor's list; a failed read opens without them. */ + async function openTags() { + const chosen = target(TAGS_FAILED); + if (!chosen) return; + let suggestions = null; + try { + const result = await api.get(TAG_OPTIONS_URL, { signal: scope.signal, cache: 'no-store' }); + if (Array.isArray(result?.tags)) suggestions = result.tags.filter(tag => typeof tag === 'string'); + } catch { + // Suggestions are auxiliary; the field works without them. + } + if (scope.signal.aborted) return; + openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem, + onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') }); } /** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */ - async function write(body) { + async function write(url, body, kind) { writing = true; - setCategory.setAttribute('aria-disabled', 'true'); + for (const button of actions) button.setAttribute('aria-disabled', 'true'); let result = null, failure = null; try { - result = await api.post(CATEGORY_URL, body); + result = await api.post(url, body); } catch (error) { failure = error; } finally { writing = false; - setCategory.removeAttribute('aria-disabled'); + for (const button of actions) button.removeAttribute('aria-disabled'); } - const outcome = writeOutcome(result, failure); + const outcome = writeOutcome(result, failure, kind); // A left page still invalidates what it cached, so a return never shows the old categories. - if (outcome.committed) onCommitted(result?.success ? result : { phase: 'unconfirmed' }); + if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' }); if (scope.signal.aborted) return; const focused = element.contains(document.activeElement); showWorkspaceToast(outcome.toast); @@ -173,7 +269,11 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge element, overlayFor, isChecked: id => checked.has(id), + hasSelection: () => checked.size > 0, clear, + selectAll, + /** A loaded batch: the count and the offer follow it. */ + listingChanged: () => { if (checked.size) render(); }, /** Whether a Set category write is still in flight. */ writing: () => writing, }; diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 907c34f0..3baa22bc 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -178,7 +178,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on let categoryNames=[]; const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null, getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)}, - onCommitted:receipt=>invalidateLibraryAfterWrite({kind:'bulk-category',receipt}),onReload:()=>{loadCategories();reset();}}); + onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}}); const message=el('p','','library-status');message.setAttribute('role','status'); const continuation=el('div',undefined,'library-continuation'); const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; @@ -364,6 +364,9 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on scope.listen(grid,'keydown',event=>{ const card=event.target.closest('article[data-prompt-id]');if(!card) return; const index=Number(card.dataset.index); + // D151 (#444 ruling): Ctrl/Cmd+A selects every loaded prompt and Escape clears, as on Images (focus stays). + if((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase()==='a') {event.preventDefault();selection.selectAll();return;} + if(event.key==='Escape' && selection.hasSelection()) {event.preventDefault();selection.clear();return;} if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1),false,true);} else if(event.key==='Tab') { const controls=card.querySelectorAll(CARD_CONTROLS);const next=index+(event.shiftKey?-1:1); @@ -372,7 +375,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on } }); function render(next) { - if(items!==next.items) { + const batch=items!==next.items; + if(batch) { pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true; if(!items.length) { anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0}; @@ -381,6 +385,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on } else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;} } state=next; + // A loaded batch: the selection bar's count and Select all N loaded follow it (D151). + if(batch) selection.listingChanged(); if(next.variantTotal!==null) { const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`; if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false; From d03d0ed1c5b2befe0fbe9917c3a8e932b453c431 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 29 Oct 2026 01:40:02 +0000 Subject: [PATCH 3/3] fix(history): count cached re-runs of runtime-linked text as tracked runs (#403) (#445) * fix(history): count cached re-runs of runtime-linked text as tracked runs (#403) Since #347 a cached re-run of runtime-linked prompt text (PreviewAny or an LLM into a PromptManager text input) counts in the Library, but its history capture had no raw text, so the history page's "tracked runs" stayed at the number of jobs in which the node executed. The #347 receipt now also keeps the raw, prepend and append text the node gave the history capture. When a cached node's graph inference leaves a field unknown, the capture takes it from the receipt that matches the cache entry, labeled inferred/graph_semantics, so the job joins the prompt's history scope and gets its prompt hint. No schema change and no new evidence vocabulary; no backfill. Fails closed on no receipt, a disagreeing known field, equal outputs with different fields, oversized or invalid text and a full budget (least recently used receipts give up their fields first; counting stays). * docs(testing): link the whitespace history gap to #438 --- TESTING.md | 33 ++- prompt_manager.py | 7 +- prompt_manager_base.py | 16 +- prompt_manager_pair.py | 6 +- prompt_manager_text.py | 7 +- tests/comfyui/execution-history.spec.mjs | 22 +- tests/comfyui/execution-text-link.spec.mjs | 22 +- tests/test_cached_runs.py | 194 ++++++++++++++++- tests/test_execution_context_host.py | 146 ++++++++++++- tests/test_execution_history_hooks.py | 151 +++++++++++++- tests/test_pair_usage_tracking.py | 5 +- tests/test_prompt_usage.py | 10 +- utils/cached_runs.py | 229 ++++++++++++++++++--- utils/execution_context_host.py | 88 +++++++- 14 files changed, 873 insertions(+), 63 deletions(-) diff --git a/TESTING.md b/TESTING.md index f44b4515..c1a37fcf 100644 --- a/TESTING.md +++ b/TESTING.md @@ -5905,8 +5905,26 @@ decide through the same `own_counts`. - A cached node counts at job start like the hook: a job failing later still counts it. A node that executes after its replay in the same job (RAM-pressure freed the entry) skips its own count but still consumes the pending first-use catch-up. -- No backfill: earlier cached jobs keep their counts. The history page's "tracked runs" - can stay below the Library count for these prompts (#403). +- No backfill: earlier cached jobs keep their counts. +- History (#403): the receipt also keeps the raw, prepend and append text the node gave + the history capture. For a cached node whose graph inference leaves a field unknown, + the capture takes it from the receipt matching the cache entry + (`CachedRuns.recorded_fields`), as `inferred` / `graph_semantics`: the node did not run + in this job, the value follows from the run key (the whole upstream closure) plus the + cache proof. The job then joins the prompt's history scope, gets the + `positive_prompt_id` (and Pair) hint, and an image of it can be attributed through its + run, so the history page's "tracked runs" equals the Library count from the restart on. + Fail closed (fields stay unknown, as before): no matching receipt, a receipt that + disagrees with a field the graph knows, executions with equal outputs that recorded + different fields (a conflict that stays for that run key), a field over 64 KiB or + invalid Unicode, more than one run's history (1 MiB) in one receipt, or a full 8 MiB + field budget (least recently used receipts give up their fields first; their counting + stays). No new evidence vocabulary, so older builds still open the database. +- Still different: jobs before the restart (no backfill); list batches (per item in the + Library, once per job in history); text with leading or trailing whitespace (the + Library stores it stripped, history matches the exact raw text, #438); a job in which the + node executes after its replay (RAM-pressure freed the entry: two samples, an + ambiguous capture); jobs while the history writer is fenced (restore, library move). - Tests: `tests/test_cached_runs.py` (whole jobs with the real nodes, a real database, fake host caches); `tests/comfyui/execution-text-link.spec.mjs` "#347" cases on the managed host: PreviewAny into each node kind, 3 jobs changing only the sampler seed, @@ -5914,7 +5932,10 @@ decide through the same `own_counts`. modes, A, B, A from the owned `PMCycledText` under LRU, typed text, a downstream failure, a "run selected output" job that does not need the node, and a rewire to the negative input. The RAM-pressure eviction path cannot be forced on a real host and is - covered by the Python tests. + covered by the Python tests. Since #403 these cases also check the history page's + tracked runs equal the Library count, and `tests/test_execution_history_hooks.py` + (`CachedRuntimeHistoryTests`) runs the real history hook and writer with a real + `CachedRuns`. ```sh python3 -m unittest discover -s tests -p "test_cached_runs.py" @@ -6013,10 +6034,12 @@ Context cases verify A,A,B as three jobs/two combinations/one positive snapshot when every positive field matches; all five registered PM variants; exact raw whitespace and Unicode; known empty negatives; Text negative roles and crossed pairs; fully cached repeats after trigger configuration changes; successful source -text before downstream failure/interruption; unknown cached dynamic raw; unsupported +text before downstream failure/interruption; cached dynamic raw from the reused execution (#403); unsupported transforms and duplicate list invocation ambiguity. Fresh values use `runtime_return`, cached effective STRING outputs use `host_cache_output`, and -submitted literals use graph inference. SQL NULL means unknown; empty text is +submitted literals use graph inference. A cached dynamic raw is unknown unless the +#347 receipt of the execution the cache entry comes from proves it; it is then +`inferred` / `graph_semantics` (graph identity plus a same-process cache receipt, #403). SQL NULL means unknown; empty text is known. Text is never trimmed or normalized by capture. Cache availability and inferred sampler roles prove neither sampler execution nor consumption, and list indices do not identify generated images. External trigger configuration changes diff --git a/prompt_manager.py b/prompt_manager.py index b7bcdc1a..72d8e327 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -275,7 +275,12 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): record_runtime_context( type(self).__name__, text, prepend_text, append_text, encoding_text ) - self._record_cached_item(unique_id, (encoding_text,), storage_text) + self._record_cached_item( + unique_id, + (encoding_text,), + storage_text, + fields=((text, prepend_text, append_text),), + ) return result @classmethod diff --git a/prompt_manager_base.py b/prompt_manager_base.py index 921e1e2a..4d624b4e 100644 --- a/prompt_manager_base.py +++ b/prompt_manager_base.py @@ -243,14 +243,24 @@ class PromptManagerBase: return False def _record_cached_item( - self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None + self, + unique_id: Any, + outputs: tuple, + text: Any, + negative_text: Any = None, + fields: Optional[tuple] = None, ) -> None: - """Remember this execution's outputs and stored text for cached jobs (#347).""" + """Remember this execution's outputs and stored text for cached jobs (#347). + + ``fields`` are the ``(raw, prepend, append)`` texts this execution gave + ``record_runtime_context`` for each output, so a cached job's history + can record them too (#403). + """ if unique_id is None: return try: self._cached_runs().record_item( - unique_id, outputs, stored_facts(text, negative_text) + unique_id, outputs, stored_facts(text, negative_text), fields ) except Exception as error: self.logger.debug(f"Could not record the cached-run receipt: {error}") diff --git a/prompt_manager_pair.py b/prompt_manager_pair.py index d406e23e..e1bf8e45 100644 --- a/prompt_manager_pair.py +++ b/prompt_manager_pair.py @@ -198,7 +198,11 @@ class PromptManagerPair(PromptManagerFocused): type(self).__name__, negative_text, "", "", negative_text, role="negative" ) self._record_cached_item( - unique_id, (positive, negative_text), text, negative_text + unique_id, + (positive, negative_text), + text, + negative_text, + fields=((text, prepend_text, append_text), (negative_text, "", "")), ) return result diff --git a/prompt_manager_text.py b/prompt_manager_text.py index c6e3f070..310eef7a 100644 --- a/prompt_manager_text.py +++ b/prompt_manager_text.py @@ -246,7 +246,12 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): record_runtime_context( type(self).__name__, text, prepend_text, append_text, final_text ) - self._record_cached_item(unique_id, (final_text,), storage_text) + self._record_cached_item( + unique_id, + (final_text,), + storage_text, + fields=((text, prepend_text, append_text),), + ) return result @classmethod diff --git a/tests/comfyui/execution-history.spec.mjs b/tests/comfyui/execution-history.spec.mjs index 388f0d69..09285e63 100644 --- a/tests/comfyui/execution-history.spec.mjs +++ b/tests/comfyui/execution-history.spec.mjs @@ -263,15 +263,18 @@ test.describe('public chronological history with the actual acknowledged host br }); }); -test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => { - const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true }); +// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses. +test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => { + const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true }); const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0]; - const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0]; - expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective); + const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0]; + expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective); + expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' }); + expect(cached.positive_prompt_id).toBe(known.positive_prompt_id); const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id; - const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull(); + const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw); const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json(); - expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId); + expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId); }); test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => { @@ -369,7 +372,7 @@ test('successful source capture survives downstream failure and real interruptio } }); -test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => { +test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => { const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' }); const first = await submit(execution.request, graph); await history(execution.request, first); @@ -377,8 +380,9 @@ test('cached dynamic raw stays unknown while literal affixes and effective cache const second = await submit(execution.request, graph); await history(execution.request, second); const value = (await capture(execution, second)).contexts[0]; - expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' }); - expect(value.evidence.positive.raw.provenance).toBe('unknown'); + expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' }); + expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' }); + expect(value.evidence.positive.prepend.source).toBe('graph_literal'); expect(value.evidence.positive.effective.source).toBe('host_cache_output'); }); diff --git a/tests/comfyui/execution-text-link.spec.mjs b/tests/comfyui/execution-text-link.spec.mjs index 813a0345..8dc82404 100644 --- a/tests/comfyui/execution-text-link.spec.mjs +++ b/tests/comfyui/execution-text-link.spec.mjs @@ -287,6 +287,14 @@ async function capturedContext(execution, id) { return result.captures[result.rows[0].run_id].contexts[0]; } +// #403: the prompt's history "tracked runs", once every job's capture reached SQLite. +async function expectTrackedRuns(execution, promptId, expected) { + await expect.poll(async () => { + const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`); + return response.ok() ? (await response.json()).recorded_run_count : null; + }, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected); +} + async function firstRun(execution, page, testCase) { const prefix = `textlink/${randomUUID()}`; const built = await build(page, { ...testCase, prefix }); @@ -309,6 +317,8 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit const counts = await runCounts(execution, first.context); expect(counts.positive).toBe(3); if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 }); + // #403: the cached jobs are tracked runs of the prompt too + await expectTrackedRuns(execution, first.context.positive_prompt_id, 3); }); } @@ -329,6 +339,7 @@ test.describe('cached runs in one renderer (#347)', () => { else expect(again.cached).toContain(first.promptNode); } expect((await runCounts(execution, first.context)).positive).toBe(3); + await expectTrackedRuns(execution, first.context.positive_prompt_id, 3); } finally { await setCache(execution, 'RAM_PRESSURE'); } }); } @@ -352,6 +363,9 @@ test.describe('cached runs in one renderer (#347)', () => { const bContext = await capturedContext(execution, second.id); expect(bContext.positive.raw).toBe(b); expect((await runCounts(execution, bContext)).positive).toBe(1); + // #403: the third job reuses A's execution, not the newest (B's) + await expectTrackedRuns(execution, first.context.positive_prompt_id, 2); + await expectTrackedRuns(execution, bContext.positive_prompt_id, 1); } finally { await setCache(execution, 'RAM_PRESSURE'); } }); @@ -390,10 +404,14 @@ test.describe('cached runs in one renderer (#347)', () => { } }); expect(rewired.cached).toContain(first.promptNode); expect((await runCounts(execution, first.context)).positive).toBe(1); - // The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint + // #403: a cached job's history takes the raw text of the execution it reuses and names the prompt const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id); expect(cachedContext.positive.effective).toBe(first.context.positive.effective); - expect(cachedContext.positive_prompt_id).toBeNull(); + expect(cachedContext.positive.raw).toBe(first.context.positive.raw); + expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' }); + expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id); expect((await runCounts(execution, first.context)).positive).toBe(2); + // The rewired job used the prompt as its negative: neither number counts it + await expectTrackedRuns(execution, first.context.positive_prompt_id, 2); }); }); diff --git a/tests/test_cached_runs.py b/tests/test_cached_runs.py index b337b6a6..bd52416c 100644 --- a/tests/test_cached_runs.py +++ b/tests/test_cached_runs.py @@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase): return counts -class TestReceipts(unittest.TestCase): +class ReceiptCase(unittest.TestCase): def setUp(self): self.context = ("j1", None) self.runs = CachedRuns( @@ -686,6 +686,8 @@ class TestReceipts(unittest.TestCase): def keys(self): return list(self.runs._receipts) + +class TestReceipts(ReceiptCase): def test_least_recently_used_run_keys_are_dropped(self): self.begin(1) for name in ("a", "b", "c"): @@ -726,6 +728,196 @@ class TestReceipts(unittest.TestCase): self.assertEqual(list(self.runs._attempt.nodes), ["b"]) +class TestRecordedFields(ReceiptCase): + """The texts a receipt keeps for the history capture of a cached job (#403).""" + + F = (("raw a", " pre ", ""),) + + def record(self, item_id, name="a", output="x", fields=F, facts=None): + self.begin(item_id) + self.runs.record_item(name, (output,), facts or Facts("h"), fields) + self.runs.end_attempt(item_id) + + def lookup(self, name="a", values=(("x",),), prompt_id="j9", item_id=9): + self.begin(item_id, prompt_id) + return self.runs.recorded_fields(prompt_id, name, values) + + def test_a_cached_job_reads_the_fields_of_the_execution_it_reuses(self): + self.record(1) + self.assertEqual(self.lookup(), [self.F]) + + def test_list_items_return_their_own_fields_in_order(self): + self.begin(1) + for index, text in enumerate(("x", "y")): + self.context = ("j1", index) + self.runs.record_item("a", (text,), Facts("h"), ((text, "", ""),)) + self.runs.end_attempt(1) + self.assertEqual( + self.lookup(values=(("x", "y"),)), [(("x", "", ""),), (("y", "", ""),)] + ) + + def test_fails_closed(self): + self.record(1) + for case, kwargs in ( + ("other output", dict(values=(("changed",),))), + ("other node", dict(name="b")), + ("unknown node", dict(name="zzz")), + ("no values", dict(values=())), + ("malformed values", dict(values="x")), + ): + with self.subTest(case=case): + self.assertIsNone(self.lookup(**kwargs)) + with self.subTest(case="another job"): + self.begin(9, "j9") + self.assertIsNone(self.runs.recorded_fields("j8", "a", (("x",),))) + with self.subTest(case="no job"): + self.runs.end_attempt(9) + self.assertIsNone(self.runs.recorded_fields("j9", "a", (("x",),))) + + def test_an_execution_without_fields_gives_none(self): + for fields in (None, (("a", "b"),), ((1, "", ""),), (), "abc"): + with self.subTest(fields=fields): + self.runs = CachedRuns(context=lambda: self.context) + self.record(1, fields=fields) + self.assertIsNone(self.lookup()) + + def test_a_lookup_never_counts_or_marks_the_node_replayed(self): + self.record(1) + self.lookup() + self.assertFalse(self.runs.replayed("a")) + + def test_equal_outputs_with_other_raw_text_conflict_and_still_count(self): + key = prompt_node_key(self.g, "a") + self.record(1, fields=(("x ", "", ""),)) + self.record(2, fields=(("x", "", ""),)) + # Counting dedupes by outputs as before; the fields cannot name either + self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2]) + self.assertIsNone(self.lookup()) + self.assertIsNotNone(self.runs._match(key, (("x",),))) + # The conflict stays: a later execution cannot tell which text it was + self.record(3, fields=(("x", "", ""),)) + self.assertIsNone(self.lookup()) + + def test_equal_outputs_with_other_facts_still_count_the_newest(self): + key = prompt_node_key(self.g, "a") + self.record(1, facts=Facts("old"), fields=(("a x", "", ""),)) + self.record(2, facts=Facts("new"), fields=(("x", "a ", ""),)) + items = self.runs._match(key, (("x",),)) + self.assertEqual([item.facts for item in items], [Facts("new")]) + + def test_equal_outputs_and_fields_still_replace(self): + key = prompt_node_key(self.g, "a") + self.record(1) + self.record(2) + self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2]) + self.assertEqual(self.lookup(), [self.F]) + + def test_fields_dropped_by_the_budget_do_not_conflict(self): + self.runs = CachedRuns(context=lambda: self.context, max_field_bytes=0) + self.record(1) + self.runs._max_field_bytes = 100 + self.record(2) + self.assertEqual(self.lookup(), [self.F]) + + def test_oversized_or_invalid_fields_are_not_kept(self): + for raw in ("x" * (module.MAX_FIELD_BYTES + 1), "\ud800"): + with self.subTest(size=len(raw)): + self.runs = CachedRuns(context=lambda: self.context) + self.record(1, fields=((raw, "", ""),)) + self.assertIsNone(self.lookup()) + self.assertEqual(self.runs._field_bytes, 0) + + def test_old_receipts_give_up_their_fields_to_new_ones(self): + self.runs = CachedRuns( + context=lambda: self.context, max_keys=3, max_field_bytes=10 + ) + self.record(1, "a", fields=(("123456", "", ""),)) + self.record(2, "b", fields=(("123456", "", ""),)) + self.assertEqual(self.runs._field_bytes, 6) + self.assertIsNone(self.lookup("a")) + self.assertEqual(self.lookup("b"), [(("123456", "", ""),)]) + # "a" still counts + self.assertIsNotNone(self.runs._match(prompt_node_key(self.g, "a"), (("x",),))) + # Larger than the whole budget: not kept, nothing else given up + self.record(3, "c", fields=(("12345678901", "", ""),)) + self.assertEqual(self.runs._field_bytes, 6) + self.assertEqual(self.lookup("b"), [(("123456", "", ""),)]) + + def test_one_receipt_holds_at_most_one_runs_history(self): + with mock.patch.object(module, "MAX_RECEIPT_FIELD_BYTES", 8): + self.begin(1) + for index, raw in enumerate(("12345", "12345")): + self.context = ("j1", index) + self.runs.record_item("a", (raw,), Facts("h"), ((raw, "", ""),)) + self.runs.end_attempt(1) + self.assertEqual(self.runs._field_bytes, 5) + self.assertIsNone(self.lookup(values=(("12345", "12345"),))) + + def test_the_byte_count_follows_replacement_and_eviction(self): + self.begin(1) + self.runs.record_item("a", ("x",), Facts("h"), (("12345", "", ""),)) + self.runs.record_item("a", ("x",), Facts("h"), (("123", "", ""),)) + self.assertEqual(self.runs._field_bytes, 3) + self.runs.end_attempt(1) + for item_id, raw in ((2, "1"), (3, "22"), (4, "333")): + self.record(item_id, output=raw, fields=((raw, "", ""),)) + # max_receipts=2: only the receipts of jobs 4 and 3 remain + self.assertEqual(self.runs._field_bytes, 5) + for item_id, name in ((5, "b"), (6, "c")): # max_keys=2: "a" is dropped + self.record(item_id, name, fields=(("1", "", ""),)) + self.assertEqual(self.runs._field_bytes, 2) + self.assertEqual( + self.runs._field_bytes, + sum(module._bytes(r) for r in self.runs._receipts.values()), + ) + + +class TestCachedJobsRecordFields(JobCase): + """The real nodes keep the texts they gave the history capture (#403).""" + + def lookup(self, g, node_id="pm", prompt_id="job-x"): + self.item_id += 1 + self.runs.begin_attempt( + ((self.item_id, prompt_id, g, {}, ["save"], {}), self.item_id) + ) + values = cached_string_outputs(self.executor, node_id, g[node_id]["class_type"]) + found = self.runs.recorded_fields(prompt_id, node_id, values) + self.runs.end_attempt(self.item_id) + return found + + def test_each_node_kind_keeps_its_runtime_fields(self): + for kind in ( + "PromptManager", + "PromptManagerFocused", + "PromptManagerSplit", + "PromptManagerText", + ): + with self.subTest(kind=kind): + g = graph(kind) + self.first(g, text=f" {LLM} ") + self.assertEqual( + self.lookup(graph(kind, seed=2)), [((f" {LLM} ", "", ""),)] + ) + + def test_pair_keeps_positive_and_negative_fields(self): + g = graph("PromptManagerPair", negative_text="typed negative") + self.first(g, negative="typed negative") + self.assertEqual( + self.lookup( + graph("PromptManagerPair", negative_text="typed negative", seed=2) + ), + [((LLM, "", ""), ("typed negative", "", ""))], + ) + + def test_a_disabled_node_keeps_no_fields(self): + g = graph() + g["pm"]["inputs"]["save_to_library"] = False + self.first(g) + g2 = graph(seed=2) + g2["pm"]["inputs"]["save_to_library"] = False + self.assertIsNone(self.lookup(g2)) + + class TestCacheReader(unittest.TestCase): def setUp(self): patcher = mock.patch.dict(sys.modules, host_modules()) diff --git a/tests/test_execution_context_host.py b/tests/test_execution_context_host.py index 7e617294..9779ccc5 100644 --- a/tests/test_execution_context_host.py +++ b/tests/test_execution_context_host.py @@ -100,14 +100,19 @@ class HostTests(unittest.TestCase): self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache()) def begin( - self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post " + self, + kind="PromptManager", + raw=" raw ", + prepend=" pre ", + append=" post ", + negative="negative", ): g = { "p": { "class_type": kind, "inputs": { "text": raw, - "negative_text": "negative", + "negative_text": negative, "prepend_text": prepend, "append_text": append, }, @@ -125,7 +130,7 @@ class HostTests(unittest.TestCase): }, }, } - if type(raw) is list or type(prepend) is list: + if any(type(value) is list for value in (raw, prepend, negative)): g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}} g["stateful"] = { "class_type": "PromptManagerText", @@ -445,3 +450,138 @@ class HostTests(unittest.TestCase): ref = install_and_remove() gc.collect() self.assertIsNone(ref()) + + # -- #403: a cached runtime text takes its fields from the reused execution + + class Receipts: + """A fake ``CachedRuns.recorded_fields``: fixed fields, calls recorded.""" + + def __init__(self, fields): + self.fields, self.calls = fields, [] + + def recorded_fields(self, prompt_id, node_id, values): + self.calls.append((prompt_id, node_id, values)) + return self.fields + + def observe_with(self, receipts, outputs, **begin): + self.begin(**begin) + self.executor.caches.outputs.entries["p"] = Entry(None, outputs) + self.observe("execution_start") + host.observe_cached_context( + self.executor, + "execution_cached", + {"prompt_id": JOB, "nodes": ["p"]}, + receipts=receipts, + ) + return self.seal() + + def test_cached_runtime_raw_comes_from_the_matching_receipt(self): + receipts = self.Receipts([(("LLM raw", " pre ", " post "),)]) + result = self.observe_with( + receipts, [object(), ["cached"]], raw=["dynamic", 0] + ).positive + self.assertEqual(receipts.calls, [(JOB, "p", (("cached",),))]) + self.assertEqual(result.raw.value, "LLM raw") + self.assertEqual( + (result.raw.provenance, result.raw.source), ("inferred", "graph_semantics") + ) + self.assertEqual(result.effective.value, "cached") + self.assertEqual(result.effective.source, "host_cache_output") + # Fields the graph already knows are never replaced + self.assertEqual(result.prepend.value, " pre ") + self.assertEqual(result.prepend.source, "graph_literal") + self.assertEqual(result.append.value, " post ") + + def test_cached_runtime_prepend_comes_from_the_matching_receipt(self): + receipts = self.Receipts([((" raw ", "LLM pre", " post "),)]) + result = self.observe_with( + receipts, [object(), ["cached"]], prepend=["dynamic", 0] + ).positive + self.assertEqual(result.prepend.value, "LLM pre") + self.assertEqual(result.prepend.source, "graph_semantics") + self.assertEqual(result.raw.value, " raw ") + self.assertEqual(result.raw.source, "graph_literal") + + def test_a_receipt_disagreeing_with_a_known_field_fills_nothing(self): + receipts = self.Receipts([(("LLM raw", "other pre", " post "),)]) + result = self.observe_with( + receipts, [object(), ["cached"]], raw=["dynamic", 0] + ).positive + self.assertIsNone(result.raw.value) + self.assertEqual(result.prepend.value, " pre ") + + def test_pair_runtime_negative_takes_the_negative_fields(self): + receipts = self.Receipts( + [((" raw ", " pre ", " post "), ("LLM negative", "", ""))] + ) + result = self.observe_with( + receipts, + [object(), object(), ["cached positive"], ["LLM negative"]], + kind="PromptManagerPair", + negative=["dynamic", 0], + ) + self.assertEqual(result.negative.raw.value, "LLM negative") + self.assertEqual(result.negative.raw.source, "graph_semantics") + self.assertEqual(result.positive.raw.value, " raw ") + self.assertEqual(result.positive.raw.source, "graph_literal") + + def test_known_typed_fields_are_never_replaced_by_a_receipt(self): + receipts = self.Receipts([(("other", "other", "other"),)]) + result = self.observe_with(receipts, [object(), ["cached"]]).positive + self.assertEqual( + (result.raw.value, result.prepend.value, result.append.value), + (" raw ", " pre ", " post "), + ) + self.assertEqual(result.raw.source, "graph_literal") + + def test_no_matching_receipt_keeps_the_cached_raw_unknown(self): + for fields in (None, [None], [], "bad", [(("a", "b"),)], [((1, "", ""),)]): + with self.subTest(fields=fields): + result = self.observe_with( + self.Receipts(fields), [object(), ["cached"]], raw=["dynamic", 0] + ).positive + self.assertIsNone(result.raw.value) + self.assertEqual(result.effective.value, "cached") + + def test_a_failing_receipt_lookup_keeps_the_capture(self): + class Broken: + def recorded_fields(self, *args): + raise RuntimeError("boom") + + result = self.observe_with( + Broken(), [object(), ["cached"]], raw=["dynamic", 0] + ).positive + self.assertIsNone(result.raw.value) + self.assertEqual(result.effective.value, "cached") + + def test_pair_slots_take_their_own_receipt_fields(self): + receipts = self.Receipts( + [(("LLM positive", " pre ", " post "), ("LLM negative", "", ""))] + ) + result = self.observe_with( + receipts, + [object(), object(), ["cached positive"], ["cached negative"]], + kind="PromptManagerPair", + raw=["dynamic", 0], + ) + self.assertEqual( + receipts.calls[0], (JOB, "p", (("cached positive",), ("cached negative",))) + ) + self.assertEqual(result.positive.raw.value, "LLM positive") + self.assertEqual(result.positive.effective.value, "cached positive") + # The negative editor is typed: the graph's value stays + self.assertEqual(result.negative.raw.value, "negative") + self.assertEqual(result.negative.raw.source, "graph_literal") + + def test_default_receipts_are_the_process_cached_runs(self): + from utils import cached_runs + + receipts = self.Receipts([(("LLM raw", " pre ", " post "),)]) + with patch.object(cached_runs, "CACHED_RUNS", receipts): + self.begin(raw=["dynamic", 0]) + self.executor.caches.outputs.entries["p"] = Entry( + None, [object(), ["cached"]] + ) + self.observe("execution_start") + self.observe("execution_cached", nodes=["p"]) + self.assertEqual(self.seal().positive.raw.value, "LLM raw") diff --git a/tests/test_execution_history_hooks.py b/tests/test_execution_history_hooks.py index 053640ab..855c4e91 100644 --- a/tests/test_execution_history_hooks.py +++ b/tests/test_execution_history_hooks.py @@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB): return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})]) -class HistoryHooksTests(unittest.TestCase): +class HooksCase(unittest.TestCase): def setUp(self): self.directory = tempfile.TemporaryDirectory() self.addCleanup(self.directory.cleanup) @@ -122,6 +122,8 @@ class HistoryHooksTests(unittest.TestCase): def drain(self, registration): drain_writer(registration.writer, clock=time.monotonic) + +class HistoryHooksTests(HooksCase): def test_package_history_initializes_when_monitor_and_usage_setup_fail(self): try: from tests.test_init_package import ( @@ -767,3 +769,150 @@ class HistoryHooksTests(unittest.TestCase): self.assertEqual( calls, ["execution_start", "execution_cached", "execution_cached"] ) + + +class CachedRuntimeHistoryTests(HooksCase): + """A cached runtime-linked prompt is a tracked run, like an executed one (#403). + + The real history hook and writer, a real ``CachedRuns`` and the real host + adapter over fakes of ComfyUI's executor, cache and executing context. + """ + + TEXT = "a lighthouse the LLM wrote" + GRAPH = { + "str": {"class_type": "PrimitiveString", "inputs": {"value": "seed"}}, + "llm": {"class_type": "PreviewAny", "inputs": {"source": ["str", 0]}}, + "p": {"class_type": "PromptManager", "inputs": {"text": ["llm", 0]}}, + "s": {"class_type": "KSampler", "inputs": {"positive": ["p", 0]}}, + } + + def setUp(self): + super().setUp() + from utils import cached_runs + from utils.cached_runs import CachedRuns + + self.context = None + self.runs = CachedRuns(context=lambda: self.context or (None, None)) + self.cache_entries = {} + entries = self.cache_entries + execution = types.ModuleType("execution") + caching = types.ModuleType("comfy_execution.caching") + package = types.ModuleType("comfy_execution") + utils_module = types.ModuleType("comfy_execution.utils") + entry_type = namedtuple("CacheEntry", "ui outputs") + + class PromptExecutor: + def add_message(self, event, data, broadcast): + return None + + class HierarchicalCache: + def get_local(self, node_id): + return entries.get(node_id) + + execution.PromptExecutor, execution.CacheEntry = PromptExecutor, entry_type + caching.HierarchicalCache = HierarchicalCache + package.caching, package.utils = caching, utils_module + utils_module.get_executing_context = lambda: ( + SimpleNamespace(prompt_id=self.context[0], node_id="p", list_index=0) + if self.context + else None + ) + self.entry_type = entry_type + modules = patch.dict( + sys.modules, + { + "execution": execution, + "comfy_execution": package, + "comfy_execution.caching": caching, + "comfy_execution.utils": utils_module, + }, + ) + modules.start() + self.addCleanup(modules.stop) + swap = patch.object(cached_runs, "CACHED_RUNS", self.runs) + swap.start() + self.addCleanup(swap.stop) + self.executor = PromptExecutor() + self.executor.server = self.server + self.executor.caches = SimpleNamespace(outputs=HierarchicalCache()) + self.prompt_id = self.db.save_prompt(text=self.TEXT) + self.item_id = 0 + + def job(self, cached): + """One job; the node executes unless ``cached`` (ComfyUI's cache serves it).""" + from uuid import uuid4 + + from utils.cached_runs import stored_facts + from utils.execution_context_capture import record_runtime_context + + self.item_id += 1 + job = str(uuid4()) + result = ((0, job, self.GRAPH, {}, ["s"]), self.item_id) + self.queue.results.append(result) + self.queue.get() + self.runs.begin_attempt(result) + self.context = (job, 0) + self.executor.add_message("execution_start", {"prompt_id": job}, False) + if cached: + self.executor.add_message( + "execution_cached", {"prompt_id": job, "nodes": ["p"]}, False + ) + else: + # What PromptManager.encode_prompt records for this text + record_runtime_context("PromptManager", self.TEXT, "", "", self.TEXT) + self.runs.record_item( + "p", (self.TEXT,), stored_facts(self.TEXT), ((self.TEXT, "", ""),) + ) + self.cache_entries["p"] = self.entry_type(None, [object(), [self.TEXT]]) + self.context = None + self.queue.task_done(self.item_id, {}, status(job=job)) + self.runs.end_attempt(self.item_id) + + def tracked_runs(self): + with self.db.recorded_history_snapshot(): + return self.db.list_history_window(self.prompt_id)["recorded_run_count"] + + def test_three_jobs_of_runtime_text_are_three_tracked_runs(self): + registration = self.register() + for cached in (False, True, True): + self.job(cached) + self.drain(registration) + self.assertEqual(self.tracked_runs(), 3) + rows = self.rows() + for row in rows: + contexts = self.db.get_execution_contexts(row["run_id"])["contexts"] + self.assertEqual(contexts[0]["positive"]["raw"], self.TEXT) + self.assertEqual(contexts[0]["positive_prompt_id"], self.prompt_id) + cached = self.db.get_execution_contexts(rows[1]["run_id"])["contexts"][0] + self.assertEqual( + cached["evidence"]["positive"]["raw"], + {"provenance": "inferred", "source": "graph_semantics", "reason": None}, + ) + self.assertEqual( + cached["evidence"]["positive"]["effective"]["source"], "host_cache_output" + ) + # The stored history passes the whole-database check older builds run too + from database.execution_history_schema import history_schema_error + + self.assertIsNone(history_schema_error(self.db.model.get_connection())) + # An image of a cached job can now be attributed through its run (#342) + from database.run_attribution import named_prompt + + self.assertEqual( + [named_prompt(self.db, row["run_id"]) for row in rows], + [self.prompt_id] * 3, + ) + + def test_without_a_receipt_a_cached_job_stays_untracked(self): + registration = self.register() + self.job(False) + self.runs._receipts.clear() # as after a restart with a provider entry + self.job(True) + self.drain(registration) + self.assertEqual(self.tracked_runs(), 1) + cached = self.db.get_execution_contexts(self.rows()[1]["run_id"])["contexts"] + self.assertIsNone(cached[0]["positive"]["raw"]) + self.assertIsNone(cached[0]["positive_prompt_id"]) + from database.run_attribution import named_prompt + + self.assertIsNone(named_prompt(self.db, self.rows()[1]["run_id"])) diff --git a/tests/test_pair_usage_tracking.py b/tests/test_pair_usage_tracking.py index f451cad2..50d806c0 100644 --- a/tests/test_pair_usage_tracking.py +++ b/tests/test_pair_usage_tracking.py @@ -99,7 +99,10 @@ class TestPairUsageTracking(unittest.TestCase): unique_id="1", ) node.cached_runs.record_item.assert_called_once_with( - "1", ("cat", ""), Facts(usage_tracking.generate_prompt_hash("cat"), None) + "1", + ("cat", ""), + Facts(usage_tracking.generate_prompt_hash("cat"), None), + (("cat", "", ""), ("", "", "")), ) def test_validation_rejection_never_counts_or_saves(self): diff --git a/tests/test_prompt_usage.py b/tests/test_prompt_usage.py index c1dd27f1..41bcc7b6 100644 --- a/tests/test_prompt_usage.py +++ b/tests/test_prompt_usage.py @@ -308,7 +308,11 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase): "typed text", prepend_text="pre", prompt=self.GRAPH, unique_id="1" ) node.cached_runs.record_item.assert_called_once_with( - "1", ("pre typed text",), Facts(generate_prompt_hash("typed text")) + "1", + ("pre typed text",), + Facts(generate_prompt_hash("typed text")), + # #403: the texts the history capture got, for a cached job's history + (("typed text", "pre", ""),), ) # Hook-visible text: the node counts nothing itself row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text")) @@ -319,7 +323,9 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase): node = self._node() node.process_text(" ", prompt=self.GRAPH, unique_id="1") - node.cached_runs.record_item.assert_called_once_with("1", (" ",), Facts(None)) + node.cached_runs.record_item.assert_called_once_with( + "1", (" ",), Facts(None), ((" ", "", ""),) + ) if __name__ == "__main__": diff --git a/utils/cached_runs.py b/utils/cached_runs.py index fd73cd2e..e0217c39 100644 --- a/utils/cached_runs.py +++ b/utils/cached_runs.py @@ -15,9 +15,14 @@ role (positive, negative, which Pair outputs are used) and what the hook already saw are recomputed from that job's own graph, and both paths count through the same ``own_counts`` so they cannot drift. +The receipt also keeps the raw, prepend and append text the node gave the +execution history (#403), so the history capture of a cached job can record +the raw text the graph cannot show (``recorded_fields``) and the job becomes a +tracked run of its prompt, as it is a counted one. + Fail closed: when the cached text cannot be matched to a receipt recorded in -this process, nothing is counted (the behavior before #347). In memory only and -bounded; starts no threads. +this process, nothing is counted (the behavior before #347) and the history +fields stay unknown. In memory only and bounded; starts no threads. """ import hashlib @@ -39,6 +44,7 @@ try: from .queue_observers import subscribe_dequeue, subscribe_task_done from .run_keys import prompt_node_key, upstream_nodes from .usage_tracking import find_pair_id_by_hashes, survive_switch + from .execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size except ImportError: from utils.hashing import generate_prompt_hash from utils.logging_config import get_logger @@ -53,6 +59,7 @@ except ImportError: from utils.queue_observers import subscribe_dequeue, subscribe_task_done from utils.run_keys import prompt_node_key, upstream_nodes from utils.usage_tracking import find_pair_id_by_hashes, survive_switch + from utils.execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size logger = get_logger("prompt_manager.cached_runs") @@ -61,9 +68,15 @@ MAX_KEYS = 256 # run keys remembered (the same bound as MAX_KEYED_RUNS) MAX_RECEIPTS = 4 # receipts per run key: a recurring upstream value (A, B, A) MAX_ITEMS = 64 # list items per receipt (the cache reader's bound) MAX_CACHED_NODES = 500 # the execution_cached guard the history capture uses +MAX_FIELD_TEXT_BYTES = 8 * 1024 * 1024 # history fields all receipts keep +MAX_RECEIPT_FIELD_BYTES = MAX_ATTEMPT_BYTES # one receipt: what one run's history holds _EMPTY_HASH = generate_prompt_hash("") Count = Tuple[Any, ...] # ("prompt", hash) or ("pair", positive, negative or None) +# (raw, prepend, append) per STRING slot, as given to record_runtime_context +Fields = Tuple[Tuple[str, str, str], ...] +# Executions with equal outputs recorded different fields: none can be named +_CONFLICT: Fields = () class Facts(NamedTuple): @@ -96,6 +109,8 @@ class Seen(NamedTuple): class _Item(NamedTuple): outputs: Tuple[str, ...] facts: Facts + fields: Optional[Fields] = None + field_bytes: int = 0 class _Receipt: @@ -108,6 +123,18 @@ class _Receipt: def outputs(self) -> Dict[int, Tuple[str, ...]]: return {index: item.outputs for index, item in self.items.items()} + def field_bytes(self) -> int: + return sum(item.field_bytes for item in self.items.values()) + + def strip_fields(self) -> int: + """Drop the history fields (counting is unchanged); the bytes freed.""" + freed = 0 + for index, item in list(self.items.items()): + if item.field_bytes: + freed += item.field_bytes + self.items[index] = item._replace(fields=None, field_bytes=0) + return freed + class _Attempt(NamedTuple): item_id: int @@ -117,6 +144,22 @@ class _Attempt(NamedTuple): replayed: set +def _field_size(fields: Any, slots: int) -> Optional[int]: + """UTF-8 size of well-formed fields for ``slots`` outputs, else None.""" + if type(fields) is not tuple or len(fields) != slots: + return None + size = 0 + for slot in fields: + if type(slot) is not tuple or len(slot) != 3: + return None + for value in slot: + try: + size += text_size(value, MAX_FIELD_BYTES) # the capture's own check + except ValueError: + return None + return size + + def text_digest(value: str) -> str: """Digest of one returned STRING value, exactly as returned.""" return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest() @@ -245,11 +288,14 @@ class CachedRuns: max_keys: int = MAX_KEYS, max_receipts: int = MAX_RECEIPTS, max_items: int = MAX_ITEMS, + max_field_bytes: int = MAX_FIELD_TEXT_BYTES, context: Callable[[], Tuple[Optional[str], Optional[int]]] = _host_context, ) -> None: self._max_keys = max_keys self._max_receipts = max_receipts self._max_items = max_items + self._max_field_bytes = max_field_bytes + self._field_bytes = 0 # UTF-8 bytes of the fields all receipts keep self._context = context self._receipts: "OrderedDict[str, List[_Receipt]]" = OrderedDict() self._attempt: Optional[_Attempt] = None @@ -284,7 +330,10 @@ class CachedRuns: self._attempt = None for run_key, receipts in self._receipts.items(): if receipts and receipts[0].item_id == item_id: - self._receipts[run_key] = _deduplicated(receipts) + before = _bytes(receipts) + deduplicated = _deduplicated(receipts) + self._field_bytes += _bytes(deduplicated) - before + self._receipts[run_key] = deduplicated def _current(self) -> Tuple[Optional[_Attempt], Optional[int]]: """The attempt the executing node belongs to, and its list index.""" @@ -305,8 +354,19 @@ class CachedRuns: with self._lock: return str(node_id) in attempt.replayed - def record_item(self, node_id: Any, outputs: Tuple[Any, ...], facts: Facts) -> None: - """Remember what this execution returned and was entitled to count.""" + def record_item( + self, + node_id: Any, + outputs: Tuple[Any, ...], + facts: Facts, + fields: Optional[Fields] = None, + ) -> None: + """Remember what this execution returned and was entitled to count. + + ``fields`` are the texts the node gave the execution history, one + ``(raw, prepend, append)`` per output; kept only when well formed, + each within the history's field limit and the store within its budget. + """ try: attempt, index = self._current() node = attempt.nodes.get(str(node_id)) if attempt else None @@ -317,14 +377,20 @@ class CachedRuns: return if any(not isinstance(value, str) for value in outputs): return - item = _Item(tuple(text_digest(value) for value in outputs), facts) + size = _field_size(fields, len(outputs)) if fields is not None else None + item = _Item( + tuple(text_digest(value) for value in outputs), + facts, + fields if size is not None else None, + size or 0, + ) self._store(node[1], attempt.item_id, index, item) except Exception as error: logger.debug(f"Could not record a cached-run receipt: {error}") def _store(self, run_key: str, item_id: int, index: int, item: _Item) -> None: with self._lock: - receipts = self._receipts.pop(run_key, []) + receipts = self._take(run_key) if receipts and receipts[0].item_id == item_id: receipt = receipts[0] else: @@ -334,9 +400,44 @@ class CachedRuns: receipt = _Receipt(item_id) receipts.insert(0, receipt) receipt.items[index] = item - self._receipts[run_key] = receipts[: self._max_receipts] + receipts = receipts[: self._max_receipts] + if item.field_bytes and ( + receipt.field_bytes() > MAX_RECEIPT_FIELD_BYTES + or not self._make_room(_bytes(receipts)) + ): + # Counting is as before; this job's history fields stay unknown + receipt.items[index] = item._replace(fields=None, field_bytes=0) + self._put(run_key, receipts) while len(self._receipts) > self._max_keys: - self._receipts.popitem(last=False) + _, dropped = self._receipts.popitem(last=False) + self._field_bytes -= _bytes(dropped) + + def _make_room(self, needed: int) -> bool: + """Free the fields of the least recently used receipts (under the lock). + + Old receipts of workflows never run again must not hold the budget: + their counting stays, only their history fields go. False when + ``needed`` does not fit even then. + """ + if needed > self._max_field_bytes: + return False + for receipts in self._receipts.values(): # least recently used first + if self._field_bytes + needed <= self._max_field_bytes: + break + for receipt in receipts: + self._field_bytes -= receipt.strip_fields() + return self._field_bytes + needed <= self._max_field_bytes + + def _take(self, run_key: str) -> List[_Receipt]: + """Remove a key's receipts and their bytes from the store (under the lock).""" + receipts = self._receipts.pop(run_key, []) + self._field_bytes -= _bytes(receipts) + return list(receipts) + + def _put(self, run_key: str, receipts: List[_Receipt]) -> None: + """Store a key's receipts as the most recently used (under the lock).""" + self._receipts[run_key] = receipts + self._field_bytes += _bytes(receipts) # -- cached nodes ------------------------------------------------------- @@ -393,25 +494,11 @@ class CachedRuns: def _match(self, run_key: str, values: Any) -> Optional[List[_Item]]: """The items of the one receipt whose outputs equal ``values``, in order.""" - if not values: - return None - length = len(values[0]) - if length == 0 or length > self._max_items: - return None - if any(len(slot) != length for slot in values): - return None - wanted = { - index: tuple(text_digest(slot[index]) for slot in values) - for index in range(length) - } with self._lock: - receipts = self._receipts.get(run_key) - if not receipts: - return None - matches = [receipt for receipt in receipts if receipt.outputs() == wanted] + matches = self._matching(run_key, values) if not matches: return None - self._receipts.move_to_end(run_key) + length = len(values[0]) facts = { tuple(receipt.items[index].facts for index in range(length)) for receipt in matches @@ -420,13 +507,99 @@ class CachedRuns: return None return [matches[0].items[index] for index in range(length)] + def _matching(self, run_key: str, values: Any) -> List[_Receipt]: + """Receipts of ``run_key`` whose outputs equal ``values`` (under the lock).""" + if type(values) not in (list, tuple) or not values: + return [] + if any(type(slot) not in (list, tuple) for slot in values): + return [] + length = len(values[0]) + if length == 0 or length > self._max_items: + return [] + if any(len(slot) != length for slot in values): + return [] + if any(type(value) is not str for slot in values for value in slot): + return [] + wanted = { + index: tuple(text_digest(slot[index]) for slot in values) + for index in range(length) + } + receipts = self._receipts.get(run_key) + if not receipts: + return [] + matches = [receipt for receipt in receipts if receipt.outputs() == wanted] + if matches: + self._receipts.move_to_end(run_key) + return matches + + # -- the history capture (#403) ------------------------------------------- + + def recorded_fields( + self, prompt_id: Any, node_id: Any, values: Any + ) -> Optional[List[Fields]]: + """The history fields of the execution a cached node reuses, per list item. + + ``values`` are the node's cached STRING outputs (``cached_string_outputs``). + Only for a node of the job currently executing, and only when every + receipt matching ``values`` kept the same fields; None otherwise. Never + counts and never marks the node replayed. + """ + try: + with self._lock: + attempt = self._attempt + if ( + attempt is None + or type(prompt_id) is not str + or prompt_id != attempt.prompt_id + or type(node_id) is not str + or node_id not in attempt.nodes + ): + return None + matches = self._matching(attempt.nodes[node_id][1], values) + if not matches: + return None + found = [] + for index in range(len(values[0])): + fields = {receipt.items[index].fields for receipt in matches} + if len(fields) != 1 or None in fields or _CONFLICT in fields: + return None + found.append(fields.pop()) + return found + except Exception as error: + logger.debug(f"Could not read a cached run's history fields: {error}") + return None + def _deduplicated(receipts: List[_Receipt]) -> List[_Receipt]: - """The newest receipt replaces older ones with the same outputs.""" + """The newest receipt replaces older ones with the same outputs. + + Counting needs only the outputs. When a replaced receipt recorded other + history fields for equal outputs (a raw text the outputs do not show), + the newest item's fields become a conflict, so no cached job of it is + given either text (#403). A conflict stays until the run key is dropped. + """ if not receipts: return receipts - outputs = receipts[0].outputs() - return [receipts[0]] + [r for r in receipts[1:] if r.outputs() != outputs] + newest, outputs = receipts[0], receipts[0].outputs() + kept = [newest] + for receipt in receipts[1:]: + if receipt.outputs() != outputs: + kept.append(receipt) + continue + for index, item in list(newest.items.items()): + older = receipt.items[index].fields + if older == _CONFLICT or ( + older is not None and item.fields is not None and older != item.fields + ): + newest.items[index] = item._replace(fields=_CONFLICT, field_bytes=0) + return kept + + +def _bytes(receipts: List[_Receipt]) -> int: + """UTF-8 bytes of the history fields ``receipts`` keep.""" + return sum( + item.field_bytes for receipt in receipts for item in receipt.items.values() + ) def _job_nodes(graph: Dict[str, Any], outputs: Any) -> Dict[str, Tuple[str, str]]: diff --git a/utils/execution_context_host.py b/utils/execution_context_host.py index fdf02afd..4a6a74ca 100644 --- a/utils/execution_context_host.py +++ b/utils/execution_context_host.py @@ -151,10 +151,77 @@ def cached_string_outputs( return None +def _receipt_fields( + receipts: object, executor: object, job_id: str, source: object +) -> list | None: + """Per list index, the ``(raw, prepend, append)`` of the execution reused (#403). + + From the #347 receipt whose digests equal every STRING slot of the cache + entry; None when there is none or the answer is malformed. + """ + try: + if receipts is None: + try: + from .cached_runs import CACHED_RUNS as receipts + except ImportError: + from utils.cached_runs import CACHED_RUNS as receipts + values = cached_string_outputs(executor, source.node_id, source.node_type) + if values is None: + return None + found = receipts.recorded_fields(job_id, source.node_id, values) + if type(found) is not list or len(found) != len(values[0]): + return None + position = ( + (1 if source.output_slot in (1, 3) else 0) + if source.node_type == "PromptManagerPair" + else 0 + ) + fields = [] + for slots in found: + slot = slots[position] if type(slots) is tuple else None + if not ( + type(slot) is tuple + and len(slot) == 3 + and all(type(value) is str for value in slot) + ): + return None + fields.append(slot) + return fields + except Exception: + return None + + +def _agrees(inferred: PromptSnapshot, fields: tuple) -> bool: + """Every field the graph knows equals the receipt's; else fill nothing.""" + known = (inferred.raw, inferred.prepend, inferred.append) + return all( + field.value is None or field.value == value + for field, value in zip(known, fields) + ) + + +def _filled(inferred: TextEvidence, value: str) -> TextEvidence: + """A field the graph cannot show, taken from the reused execution (#403).""" + if inferred.value is not None: + return inferred + return TextEvidence(value, "inferred", "graph_semantics", None) + + def observe_cached_context( - executor: object, message_type: object, message_data: object + executor: object, + message_type: object, + message_data: object, + *, + receipts: object = None, ) -> None: - """Copy bounded local strings without cache/provider execution or retention.""" + """Copy bounded local strings without cache/provider execution or retention. + + Raw, prepend and append come from graph inference; a field it leaves + unknown (runtime-only text) is taken from the receipt of the execution the + cache entry is proven to come from (``CachedRuns.recorded_fields``), so the + cached job is a tracked run of its prompt (#403). ``receipts`` defaults to + the process ``CACHED_RUNS``. + """ try: binding = active_capture() if binding is None or type(message_type) is not str: @@ -198,18 +265,29 @@ def observe_cached_context( inferred = manager.source_snapshot(cap, source) if inferred is None: continue + fields = None + if any( + field.value is None + for field in (inferred.raw, inferred.prepend, inferred.append) + ): + fields = _receipt_fields(receipts, executor, cap.job_id, source) for index, value in enumerate(values): if type(value) is not str: continue + raw, prepend, append = inferred.raw, inferred.prepend, inferred.append + if fields is not None and _agrees(inferred, fields[index]): + raw = _filled(raw, fields[index][0]) + prepend = _filled(prepend, fields[index][1]) + append = _filled(append, fields[index][2]) manager.observe( cap, source, index, PromptSnapshot( - inferred.raw, + raw, TextEvidence(value, "recorded", "host_cache_output", None), - inferred.prepend, - inferred.append, + prepend, + append, ), ) except Exception: