diff --git a/TESTING.md b/TESTING.md index b21f77c9..c5c6ea5e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -901,7 +901,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. @@ -1243,8 +1244,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, @@ -1624,8 +1627,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 @@ -1716,7 +1720,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 | @@ -1821,6 +1825,223 @@ 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, then the listing reloads), "That collection no + longer exists." and "The images could not be added. Try again.". 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; "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. 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; × and Escape wait while it saves. On a collection page a + change to the open collection reloads the listing at once from the side panel, and + from the viewer only when the viewer closes, so the viewer keeps its place. 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 7df56194..53deebc9 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–L2) 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, except its one-click COPY ALL summary, whose ruled replacement ("Copy settings summary") is pending. Recorded deviations D60–D66 are in [TESTING.md](../TESTING.md). Local roles equal to the server's resolver follow in PR L3. +- 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..00672c22 --- /dev/null +++ b/tests/browser/collections-concept.spec.mjs @@ -0,0 +1,106 @@ +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 input. + */ +function mapAddDialogConcept(reference) { + const [cancel,apply]=reference.actions.buttons; + return {...reference,field:{...reference.field,tree:[...reference.field.tree,'datalist']}, + input:{...reference.input,value:'',maxLength:100,list:'action-input-suggestions'}, + suggestions:{id:'action-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','field.tree.2','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('#action-input-suggestions').remove()); + expect(await paths()).toEqual(expect.arrayContaining(['suggestions'])); +}); diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 08479b3d..f8a45f44 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'}]); @@ -461,7 +464,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. @@ -469,6 +472,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); +}); + // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the // `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match. test('images comparison detects mutations',async({page},testInfo)=>{ diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 07a7705b..947b1126 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -269,6 +269,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, @@ -341,6 +347,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/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index 0611b676..064b6723 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -210,7 +210,7 @@ export const DEFERRED_TEXT = 'Metadata loads when you ask for it.'; * 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 = () => {}}) { + collections = null, onCollectionsChanged = null}) { if (parentScope.signal.aborted) return {setState() {}, dispose() {}}; const scope = childScope(parentScope); let contentScope; diff --git a/web/js/workspace/images-collections.mjs b/web/js/workspace/images-collections.mjs index 939a6f5e..69806cae 100644 --- a/web/js/workspace/images-collections.mjs +++ b/web/js/workspace/images-collections.mjs @@ -35,7 +35,7 @@ export function collectionTargets(images, { allMatching = false, total = images. * 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 = () => {} }) { + 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, @@ -49,7 +49,7 @@ export function openAddToCollection({ scope, collections, images, trigger = null } catch (error) { if (scope.signal.aborted) return; notify(writeFailure(error, ADD_FAILED_TOAST)); - if (error?.code === 'source-changed') onSourceChanged(); + if (error?.code === 'source-changed') onSourceChanged?.(); } } }); } @@ -59,18 +59,18 @@ export function openAddToCollection({ scope, collections, images, trigger = null * 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 = () => {}, onSourceChanged = () => {} }) { + 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(); + 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(); + 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 6031fa71..174ceb57 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -49,7 +49,7 @@ function el(tag, text, className) { */ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, getListing, getFilter, checked: initial = Object.freeze(new Set()), collections = null, collection = null, - onCollectionsChanged = () => {}, onSourceChanged = () => {} }) { + 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. diff --git a/web/js/workspace/inspector-collections.mjs b/web/js/workspace/inspector-collections.mjs index a7b780fc..c2a3cbcd 100644 --- a/web/js/workspace/inspector-collections.mjs +++ b/web/js/workspace/inspector-collections.mjs @@ -31,7 +31,7 @@ function el(tag, text, className) { * 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 = () => {}, +export function openCollectionsDialog({ scope, collections, target, trigger = null, notify = showWorkspaceToast, onChanged = null, openDialog = openContentDialog, closeDialog = closeWorkspaceDialog }) { const local = new AbortController(); const active = () => !scope.signal.aborted && !local.signal.aborted; @@ -140,7 +140,7 @@ export function openCollectionsDialog({ scope, collections, target, trigger = nu } if (!active()) return; saving = false; - if (added.length || removed.length) onChanged({ added, removed }); + if (added.length || removed.length) onChanged?.({ added, removed }); if (!failed) { closeDialog(); notify(COLLECTIONS_UPDATED_TOAST); diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index 05e16685..77a6471a 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -24,7 +24,7 @@ function element(tag, className, text) { * 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,adapter = rootedFileAdapter, - collections = null,onCollectionsChanged = () => {}}) { + collections = null,onCollectionsChanged = null}) { const scope = createScope(); let active = null, retryVersion = 0; const dispose = () => scope.dispose(); @@ -117,7 +117,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle else onRestoreFocus?.(entry); } if (!scope.signal.aborted) onClose?.(); - if (collectionChanges && !scope.signal.aborted) onCollectionsChanged(collectionChanges); + if (collectionChanges && !scope.signal.aborted) onCollectionsChanged?.(collectionChanges); } active = {close:closeViewer,stop:() => session.stop()}; local.cleanup(() => {