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.
This commit is contained in:
+227
-6
@@ -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/<id>`. 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/<id>` 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
|
||||
|
||||
@@ -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/<id>`) 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
|
||||
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
@@ -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/<id>` 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)=>{
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user