Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e9fa0682b | ||
|
|
02190d03a8 | ||
|
|
40dc0e5417 | ||
|
|
8b12e95bc6 | ||
|
|
ac378f07d3 | ||
|
|
827de84f40 | ||
|
|
9eb5e9453d | ||
|
|
f5856076ce | ||
|
|
4040007264 | ||
|
|
56d0a4904c | ||
|
|
e86c0dcf05 | ||
|
|
39877202b2 |
@@ -214,3 +214,7 @@ coverage/
|
||||
|
||||
# Old library files a Move library renamed (never deleted; #249 S6b)
|
||||
*.moved-to-*
|
||||
|
||||
# Per-install key behind the workspace scopes and cursors (#430)
|
||||
workspace-scope.key
|
||||
.workspace-scope.key.*.tmp
|
||||
|
||||
+514
-23
@@ -551,6 +551,49 @@ hold a walk open with a gated scan instead of sleeping.
|
||||
time to network idle and the 10 slowest requests, from a fresh browser
|
||||
context each run.
|
||||
|
||||
## Open pages across restarts and source changes (#430)
|
||||
|
||||
A page addresses a listed file as `{root_index, root_scope, path}`. The root
|
||||
scope is an HMAC over the root's index and canonical path, so the server refuses
|
||||
(409 `source-changed`) an action aimed at a root that was renumbered or replaced
|
||||
since the page listed it (a same-named `ComfyUI_00001_.png` sits in every output
|
||||
folder). Its key, and the listing cursors' key, derive from one random secret in
|
||||
`workspace-scope.key` beside the database (`utils/workspace_keys.py`; owner-only,
|
||||
64 hex characters; never in config.json or the database, so no backup or export
|
||||
carries it; Move library copies it, best effort). A ComfyUI restart therefore keeps
|
||||
every scope and cursor an open page holds: `tests/test_api_workspace_restart.py`
|
||||
proves it with a second API on its own server and with two fresh interpreters on
|
||||
the same database. The key file is created with a temp file and `os.link` (of two
|
||||
first starts one file wins; without hard links `O_EXCL`), re-read after any write,
|
||||
replaced with a WARNING when corrupt, and a folder that cannot hold it gives that
|
||||
process a per-process key (one WARNING; open pages then refresh after a restart).
|
||||
Tests never write it outside their own temp folders.
|
||||
|
||||
The cursor is `{"v": 2, "key"}` with the listing scope (it hashes every absolute
|
||||
root) bound into its MAC only, never sent. Bump `v` whenever the key tuple's shape
|
||||
changes: a cursor now outlives a restart and an upgrade. A cursor the server
|
||||
cannot verify (another key, root list or filter set) answers 400
|
||||
`cursor-stale`; Images then shows "The image collection changed. Refresh image
|
||||
collection to continue." and its Retry becomes **Refresh image collection**.
|
||||
|
||||
When a source really changed, `POST /prompt_manager/images/rescope` (`{"target":
|
||||
{...}}`, validated by `media_target`, index ≤ 4095) maps the held target to the
|
||||
first current root whose scope at the old index equals the held scope, and answers
|
||||
the file's current workspace descriptor; a removed or replaced root, another key
|
||||
or a foreign scope is 409 `source-changed`, a missing file 404 `file-missing`, a
|
||||
path leaving the root or a preview 403 (always `no-store`). Only the Images roots
|
||||
are mapped. Images (`images-source-recovery.mjs`) asks once per held
|
||||
`(key, scope)` pair, shared by the inspector (an inspection that turns
|
||||
`refreshRequired` with `code: "source-changed"`), the viewer (a media load error)
|
||||
and the Tag image dialog. Found under another index, nothing is kept by an old key
|
||||
(`root_index:path` could name another source's file): the viewer closes, the checks
|
||||
clear, a Source filter on the old index follows the file (another one falls back to
|
||||
All sources), only that file stays selected and the collection lists again; a
|
||||
manual Refresh forgets every pair. Not found, the existing visible Refresh stays.
|
||||
Browser tests: `workspace-stale-source.spec.mjs` (`/__test__/sources/reverse` and
|
||||
`/__test__/scope-key/rotate`, both undone by every reset); the manual-Refresh
|
||||
recovery specs in `workspace-images.spec.mjs` answer every rescope 409.
|
||||
|
||||
## Windows portability preflight
|
||||
|
||||
Before changing paths, SQLite, streams, restore or cleanup:
|
||||
@@ -1044,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.
|
||||
@@ -1352,7 +1396,7 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
|
||||
(click, Enter, Space); Info selects the card into the side panel; the star toggles
|
||||
the favorite; arrows, Home and End move between cards. Bounded rendering keeps the
|
||||
DOM small, and returning to Images restores filters, layout, size, selection and
|
||||
scroll.
|
||||
scroll (a layout or size the toolbar never changed follows newer defaults, D157).
|
||||
- **Continuation** (`#gallery-continuation`): scrolling near it loads the next page;
|
||||
`Load more images` and `Retry` do the same by hand, with a toast per batch.
|
||||
- **Viewer** (`dialog#viewer-dialog`): "<title> · <filename>", the concept toolbar
|
||||
@@ -1386,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,
|
||||
@@ -1403,9 +1449,9 @@ Recorded deviations on this page (spec §8):
|
||||
checked images' own categories: it opens the concept's `simpleInput` dialog
|
||||
("Categorize selected images", `input#action-input` labelled Category) and writes
|
||||
`PUT`/`DELETE /prompt_manager/images/category`, never a prompt (see D140). Prompt
|
||||
categories are changed in the Prompt library editor and on the legacy admin page's
|
||||
"Set Category"; `POST /prompt_manager/bulk/category` is unchanged and serves that
|
||||
legacy page only. The write carries no page signal: leaving Images while it runs lets
|
||||
categories are changed in the Prompt library editor, the Prompt library's selection
|
||||
bar (D148, #437) and on the legacy admin page's "Set Category"; both bulk ones write
|
||||
`POST /prompt_manager/bulk/category`. The write carries no page signal: leaving Images while it runs lets
|
||||
it finish without a toast, and the return loads the collection afresh instead of the
|
||||
saved visit. Its success reloads the categories and the listing together (clearing
|
||||
the selection, focus to the heading, and, as any replaced collection does, the
|
||||
@@ -1636,6 +1682,29 @@ Recorded deviations on this page (spec §8):
|
||||
`scroll-margin-block-end: 96px`, so it is never under the toast.
|
||||
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
|
||||
the toast's band at 1600 and 390 pixels and measures both boxes.
|
||||
- **D157** (#451, maintainer request, beyond the concept): Images opens in **Masonry**
|
||||
when this browser has no Default layout (`gallery-settings.js`
|
||||
`DEFAULT_SETTINGS.workspaceLayout`); the concept starts at Grid (`state.view`, app.js
|
||||
208). A stored choice is kept, and an invalid value reads as Masonry. Saved views,
|
||||
favorites, collections and the embedded galleries keep their fixed Grid/Comfortable.
|
||||
The return to Images (the last visit) keeps a layout or thumbnail size its toolbar
|
||||
changed; a value it kept from the defaults it started with follows a Default layout or
|
||||
Thumbnail size saved since, in this tab or another (a toolbar List then Default =
|
||||
Masonry stays List; a toolbar List then back to Grid counts as untouched). A cover crop
|
||||
of a generated image (Grid and List card art, Library card art, the side panel's and
|
||||
History's inspector cover, the Tag image preview) uses `object-position: 50% 20%`
|
||||
instead of the concept's centre: it acts only when the image is taller than its box
|
||||
(portraits keep heads and faces), and a wider image stays centred. Masonry shows the
|
||||
whole image and keeps the centre. The concept comparisons (`openProduction`) and the
|
||||
specs of the Grid's own geometry start production from the concept's state, a stored
|
||||
Default layout of Grid (`startInGrid`, concept-inventory.mjs, only while the browser
|
||||
stored nothing); the comparisons do not measure `object-position`.
|
||||
`a fresh browser opens Images in Masonry (#451)`, `a Default layout saved later reaches
|
||||
the next Images visit in this tab (#451)`, `... in another tab ... (#451)` and `cover
|
||||
crops keep the top of a portrait image; Masonry stays centred (#451)`
|
||||
(workspace-system.spec.mjs) cover it. A browser whose legacy gallery wrote the old
|
||||
default (`"grid"`) keeps Grid until Default layout is changed or "Reset display
|
||||
defaults" is saved.
|
||||
|
||||
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
|
||||
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
|
||||
@@ -1697,10 +1766,11 @@ wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." eve
|
||||
for one tag, plus " Prompt association still awaits review." from Needs review.
|
||||
Tagging one file never changes another file (an equal-byte copy included), a
|
||||
prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
|
||||
changed storage or file, expired capture, unconfirmed save, unavailable model,
|
||||
video) are text in `#tag-generation-status` only; **Retry** (D116) leads the
|
||||
actions while the controller allows it, where the other workspace dialogs place
|
||||
their Retry. The Tags
|
||||
unavailable model, video) are text in `#tag-generation-status` only; a state that
|
||||
blocks Save (a failed or refused capture, a changed source, file or storage, an
|
||||
expired capture, an unconfirmed save) shows its reason beside the actions instead
|
||||
(D147); **Retry** (D116) leads the action buttons while the controller allows it,
|
||||
where the other workspace dialogs place their Retry. The Tags
|
||||
page re-reads its lists after every confirmed save, so the Images counts follow.
|
||||
|
||||
The image-tagging plan's deviation numbers were renumbered by +23 because the
|
||||
@@ -1744,8 +1814,8 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
|
||||
its "Auto-tag images" always opens the dialog. Production opens it for the image
|
||||
selected in Images when it was last left; with none it is `aria-disabled` and
|
||||
pressing it toasts "Select an image in Images first." (no new chrome).
|
||||
- **D116** (ruling T5): **Retry** leads `.dialog-actions` (before Cancel and the
|
||||
primary) while a failed observation, a failed Generate, an unconfirmed save or an
|
||||
- **D116** (ruling T5): **Retry** leads the buttons of `.dialog-actions` (before
|
||||
Cancel and the primary; D147's notice row sits above them) while a failed observation, a failed Generate, an unconfirmed save or an
|
||||
answer that needs a fresh observation (changed storage or file, expired capture)
|
||||
can be retried; the concept has no failure states, so it has no Retry. After a
|
||||
failed Generate it takes Generate's own path (an invalid field reports its
|
||||
@@ -1756,6 +1826,25 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
|
||||
Browser tests: "D116: Retry leads the actions after an unconfirmed save…", "D116:
|
||||
Retry after an expired capture observes again and keeps the typed draft (M5)" and
|
||||
the paired `production-unknown-outcome` capture.
|
||||
- **D147** (#430, beyond the Global Constraints' "status text only"): a state
|
||||
that blocks Save shows its reason in `div.tag-action-notice` (its sentence in a
|
||||
`role="status"` span, written only when it changes), the first, full-width row of
|
||||
the sticky `.dialog-actions`, above the buttons; `#tag-generation-status` is then
|
||||
empty, so the sentence shows once. Generate and Save are `aria-disabled` and look
|
||||
it (`.tagging-modal [aria-disabled="true"]`: opacity .55, `not-allowed`). A failed
|
||||
or refused capture replaces the empty preview with a same-size
|
||||
`div.tag-image-preview.tag-preview-empty` "Preview unavailable" (`role="img"`).
|
||||
A changed source (409 `source-changed` from observe, Generate or Save, phase
|
||||
`source-changed`, never retried as is) first asks the page where the file is
|
||||
(`onSourceChanged`, see "Open pages across restarts and source changes"): found,
|
||||
the dialog stays open and observes it there (`controller.retarget`, the typed
|
||||
draft kept, the old capture released; refused while a save is in flight); not
|
||||
found, the notice shows the server's sentence and **Refresh image collection**
|
||||
(Images and Library-embedded galleries; **Reload page** elsewhere), which closes
|
||||
the dialog first. Opened, generated and empty states are unchanged, so the concept
|
||||
comparisons are not. Browser tests: `tests/browser/workspace-stale-source.spec.mjs`;
|
||||
the file-changed and unconfirmed-save checks in `workspace-tagging.spec.mjs` and
|
||||
`tagging-concept.spec.mjs` read the notice.
|
||||
- **D29** (no prototype copy) in the dialog: no "Prototype · suggestions are
|
||||
simulated…" note, the status reads "Suggestions from <model>. Approve tags to save."
|
||||
(not "Sample suggestions from …"), and the toast has no " in this tab".
|
||||
@@ -1808,7 +1897,8 @@ production-versus-concept difference of PR C and its record:
|
||||
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
|
||||
| Auto-tag images `aria-disabled` without a selected image | D115 |
|
||||
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
|
||||
| States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
|
||||
| States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
|
||||
| A state that blocks Save (failed capture, changed source, file or storage, unconfirmed save): its reason and Refresh beside the actions, a "Preview unavailable" box | D147 (#430) |
|
||||
|
||||
### Bulk tagging from the selection bar (#249 PR D)
|
||||
|
||||
@@ -1850,8 +1940,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
|
||||
@@ -1942,7 +2033,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 |
|
||||
@@ -2049,6 +2140,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/<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; 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
|
||||
@@ -2132,6 +2452,101 @@ npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
|
||||
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Selecting prompts and Set category (#437)
|
||||
|
||||
**D148** (beyond the concept, whose Library cards have no checkbox): the Prompt library
|
||||
selects prompts and sets their category in one step, the legacy admin page's bulk "Set
|
||||
Category". It is built only from the concept's Images parts:
|
||||
|
||||
- Each `article.prompt-card` leads with the Images `.image-overlays > label > input`
|
||||
checkbox ("Select <title>"), placed by the concept's overlay CSS over the art; the card
|
||||
carries `position: relative` (the concept puts it on `.media-art`). The checkbox comes
|
||||
before the title in tab order (on Images it follows the image button). Tab and
|
||||
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 (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
|
||||
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
|
||||
refused on the field ("A category cannot contain control characters."). More than
|
||||
1,000 selected toasts "Select at most 1,000 prompts." and opens nothing.
|
||||
- Apply posts `{prompt_ids, category, expected_epoch}` to `POST
|
||||
/prompt_manager/bulk/category`, the epoch being the shelf's first page's
|
||||
`library_epoch` (kept with a returning visit; a later page from another epoch is a
|
||||
shelf error). The server checks the epoch, then sets the category on the Library
|
||||
parents among the ids in one `BEGIN IMMEDIATE` transaction, writing (and registering as
|
||||
Library parents, as the editor's PATCH does) only rows whose category differs, and
|
||||
answers `{library_epoch, category, updated, unchanged, not_found}`. A restored storage
|
||||
answers 409 `storage-changed` and writes nothing. The legacy `{prompt_ids, category}`
|
||||
and `{images_filter, category}` bodies are unchanged.
|
||||
- Outcomes: "Category “X” set on N prompts." (plus "M could not be found."), then the
|
||||
shelf and its categories reload and cached Library views and the sidebar counts are
|
||||
invalidated, also when the page was left during the write. None found: "None of the
|
||||
selected prompts could be found. The Library was reloaded." Storage restored: "Prompt
|
||||
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. 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.
|
||||
|
||||
Tests: `tests/browser/workspace-library-selection.spec.mjs` (set, blank and control
|
||||
characters, Clear/Escape/filter, Shift+click range and Tab across the window edge, the
|
||||
fence after a return visit, an unseen restore, refused and unconfirmed writes);
|
||||
`tests/browser/library-concept.spec.mjs` ("the Library selection checkbox and bar match
|
||||
the concept's Images ones", 1600x1100 and 390x844, both themes, with a moved-checkbox
|
||||
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/<id>`) takes the concept's heading, summary, negative
|
||||
@@ -3282,7 +3697,11 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
mid-response is not discarded by the page: the server's 15-minute expiry removes it. When a restore the page saw running
|
||||
finishes, the cached Images and Library visits are dropped, the sidebar counts and
|
||||
saved views are read again and the open route is shown again (the Activity job view
|
||||
stays). A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
stays). #415: an open review (`openReviewDialog`) then closes as dismissed, since its plan no
|
||||
longer describes storage; while any other dialog is open, all of that waits until the last
|
||||
one has closed, so a dialog keeps what was typed and its next write meets the restored-storage
|
||||
refusal, as when the page never saw the restore. `followRestore` (tests/browser/unseen-restore.mjs)
|
||||
makes the page see a restore run and finish deterministically. A refused file toasts "This file can't be restored: <validator reason>.". A
|
||||
review value may wrap anywhere (`#modal-body > dl.meta-grid dd`, CSS under D105), so a
|
||||
long file name keeps the concept's two equal columns at phone width.
|
||||
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
|
||||
@@ -4852,7 +5271,7 @@ category; without one it shows the category its link supplies.
|
||||
category can still be removed. An unexpected failure is a logged 500 ("The
|
||||
category could not be changed." / "Image categories could not be read.") without
|
||||
the exception text. The routes never read or write `prompts`, `prompt_pairs` or
|
||||
`generated_images`; `POST /prompt_manager/bulk/category` is unchanged.
|
||||
`generated_images`; `POST /prompt_manager/bulk/category` (prompts only) is not one of them.
|
||||
- Duplicate cleanup (System › Maintenance): the kept copy's own category wins. A
|
||||
kept copy without one receives the removed copy's (`COPY_SQL`, part of the
|
||||
cleanup's query-plan check). The removed copy's row is never deleted, so a
|
||||
@@ -4870,7 +5289,8 @@ category; without one it shows the category its link supplies.
|
||||
the side panel, the viewer and History's media inspector (D145); Tags & categories
|
||||
counts images on each category row and lists image-only values (D143), and Manage
|
||||
category previews and renames image rows too (D144). Prompt categories are changed in
|
||||
the Prompt library editor and on the legacy admin page's "Set Category". Concept
|
||||
the Prompt library editor, the Prompt library's selection bar (D148, #437) and on the
|
||||
legacy admin page's "Set Category". Concept
|
||||
comparisons, at 1600x1100 and 390x844 in both themes: `images-concept.spec.mjs` (the
|
||||
Set category dialog and the options), `inspector-concept.spec.mjs` (the chip),
|
||||
`history-concept.spec.mjs` (History's chip) and `vocabulary-visual.spec.mjs` (rows and
|
||||
@@ -5342,6 +5762,75 @@ same as a file the scan could not attribute, so the file stays on disk and in th
|
||||
gallery as unlinked, and its favorite and tags (kept by file) are untouched. Every
|
||||
run prints this. Queue-linked batch images can't be proven after the fact and read
|
||||
`unlink`, so review before applying. Stop ComfyUI first.
|
||||
|
||||
### Foreign workflows (#247): the shared per-file decision
|
||||
|
||||
The output scan decides each file through `utils/file_prompt.py`
|
||||
(`decide_file_prompt`, the ladder), which the monitor will share for outputs of
|
||||
workflows without a PromptManager node. It does no file or database I/O.
|
||||
|
||||
- **Save to library (#90).** Before anything else, a file whose PromptManager
|
||||
node had Save to library off is `not_saved`, and one whose switch is wired
|
||||
from a node the file cannot show is `not_saved_wired`: nothing is written, no
|
||||
recorded-run fallback, no Needs review item. A resolved pair whose negative's
|
||||
node is off stores the positive alone (source `opted-out-negative`). The
|
||||
#90 graph walk follows only prompt-like inputs, so the ladder also walks
|
||||
every input upstream of each sampler or guider positive (not negative, model,
|
||||
clip, latent or image inputs; every PromptManager node of a graph without a
|
||||
sampler) and checks the text later steps name: a resolved or inspected
|
||||
positive whose path passes such a node (`side_opted_out`), or a legacy answer
|
||||
that is such a node's text (resolved, else literal). A switch that is off
|
||||
makes the file `not_saved`; one wired from a node the graph cannot read makes
|
||||
it `not_saved_wired`, as #90's direct check does.
|
||||
- **Order.** The default profile decides first, exactly as before (resolved
|
||||
pair, A1111, sampler-less). Only a graph it leaves ambiguous or unresolved
|
||||
reaches the inspector's resolve (`inspection_resolution` in
|
||||
`utils/prompt_inspector.py`, the inspection profile, then the recorded saver
|
||||
anchor of #386; `name_anchor=False`: the file-name anchor never decides a
|
||||
link; the scan's legacy floor may still link the file, as before #247, and a
|
||||
file nothing links is queued with the anchor's choice per #390), and only that positive's `storage_text` is stored (never
|
||||
prepend or append text, never `recorded_texts`). Then the #170 browser parser
|
||||
(`graph_prompt_text`), the legacy floor, then the recorded run's stored
|
||||
prompt. The legacy answer is refused when a PromptManager-family node has a
|
||||
prepend or append part and the answer is not that node's own `text` (the
|
||||
parser joins the parts, so the stored text cannot be told from it). History is asked once per file and only at that step; the scan's
|
||||
batch lookup stays lazy and makes one `recorded_runs` pass for the stored
|
||||
prompt and the saver (`_BatchHistory`).
|
||||
- **Policies.** `SCAN` keeps the legacy floor for every undecided file.
|
||||
`MONITOR` waits for history inside the output folder (`history`) and stores
|
||||
nothing for a file the inspector calls ambiguous; an untraceable or
|
||||
malformed one still gets the legacy floor.
|
||||
- **Junk.** `junk_text` refuses `blank`, `number` (the #252 `_numeric` rule),
|
||||
`lora-only` (only `<lora:...>` tags and separators) and `model-file` (one
|
||||
token ending in a model extension) at whichever step found the text and
|
||||
nothing later is tried; the file reports `found`, except at the undecided
|
||||
graph step, which keeps its own status (an ambiguous file is still queued).
|
||||
- **One prompt per file.** `database/scanned_prompts.py` stores a positive in
|
||||
one `BEGIN IMMEDIATE` transaction: a file already linked to another prompt
|
||||
(either spelling, `file_link_rows`/`link_conflict`, the pair ingester's rule)
|
||||
is `conflict` and nothing is written; a failed link rolls the new prompt back
|
||||
(`found`); two writers of one text store one prompt. A recorded run's stored
|
||||
prompt links the same way (`link_stored_prompt_or_raise`). A rescan therefore
|
||||
never adds a second link to a file an older scan linked to the legacy answer;
|
||||
the row reads "Kept: already linked to a different prompt". Link rows are
|
||||
matched under the path and its `realpath` spelling (`file_link_rows`), so a
|
||||
file the scan stored through the resolved folder is found when it arrives
|
||||
through a symlinked or junction folder. The reverse order (a row stored under
|
||||
the watcher's raw spelling, then a scan whose root resolves differently) is
|
||||
matched only when that raw spelling equals the realpath. Every scan write
|
||||
runs through `survive_library_switch`, so a library move reruns it.
|
||||
- **Marks.** Every prompt ingestion creates, pair components included, carries
|
||||
category `scanned`, tag `auto-scanned` and the note `Auto-scanned from
|
||||
<file>` (`SCAN_MARKS`); a reused prompt keeps its fields. Run counts are
|
||||
never touched.
|
||||
|
||||
Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class),
|
||||
`tests/test_scanned_prompts.py` (store, conflict, rollback, concurrency with
|
||||
`FirstWriterHold`, pair marks), `tests/test_pair_scan.py` `TestScanDecision`,
|
||||
`tests/test_run_attribution.py` (dataset branches linked by the recorded
|
||||
saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and
|
||||
`tests/test_recorded_saver_anchor.py` `TestInspectionResolution`.
|
||||
|
||||
## Linked prompt text (#343)
|
||||
|
||||
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
|
||||
@@ -5904,9 +6393,11 @@ 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 actions (delete, tag, category) stay on the
|
||||
legacy admin page. A prompt's category is changed in the Prompt library editor or with
|
||||
the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged);
|
||||
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`);
|
||||
the Images selection bar's Set category sets images' own categories only (D140, #332),
|
||||
and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes
|
||||
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
|
||||
|
||||
@@ -21,6 +21,10 @@ _UUID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")
|
||||
_TOKEN = re.compile(r"[0-9a-f]{64}")
|
||||
# Parent metadata stored on the prompt row; title lives in prompt_library_state.
|
||||
_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):
|
||||
@@ -85,6 +89,13 @@ def _validate_epoch(expected_epoch: str) -> None:
|
||||
raise ValueError("expected_epoch must be a canonical UUID")
|
||||
|
||||
|
||||
def _validate_bulk_ids(prompt_ids: list[int]) -> None:
|
||||
if not isinstance(prompt_ids, list) or not prompt_ids:
|
||||
raise ValueError("prompt_ids must be a non-empty list")
|
||||
if any(type(pid) is not int or not 1 <= pid <= 2**63 - 1 for pid in prompt_ids):
|
||||
raise ValueError("prompt_ids must be positive integers")
|
||||
|
||||
|
||||
def _validate_guards(positive_id: int, expected_epoch: str, if_match: str) -> None:
|
||||
if type(positive_id) is not int or not 1 <= positive_id <= 2**63 - 1:
|
||||
raise ValueError("Invalid positive prompt ID")
|
||||
@@ -308,3 +319,134 @@ class LibraryCommandOperations:
|
||||
positive_created=False,
|
||||
pair_created=pair_created,
|
||||
)
|
||||
|
||||
def bulk_set_library_category(
|
||||
self, prompt_ids: list[int], category: str, *, expected_epoch: str
|
||||
) -> dict:
|
||||
"""Set one category on many Library parents, all or nothing (#437).
|
||||
|
||||
Guarded by the storage epoch the page read. Only rows whose category
|
||||
differs are written (their ``updated_at`` and edit token change) and
|
||||
registered as Library parents, as the editor's PATCH does; ids
|
||||
that are missing or are not Library parents count as not found.
|
||||
"""
|
||||
_validate_epoch(expected_epoch)
|
||||
_validate_bulk_ids(prompt_ids)
|
||||
prompt_ids = list(dict.fromkeys(prompt_ids))
|
||||
if not isinstance(category, str) or not category.strip():
|
||||
raise ValueError("Category is required")
|
||||
validate_category(category)
|
||||
category = category.strip()
|
||||
with self._pair_transaction() as conn:
|
||||
epoch = self._library_epoch(conn, expected_epoch)
|
||||
current = {}
|
||||
for start in range(0, len(prompt_ids), _BULK_CHUNK):
|
||||
chunk = prompt_ids[start : start + _BULK_CHUNK]
|
||||
marks = ",".join("?" for _ in chunk)
|
||||
current.update(
|
||||
conn.execute(
|
||||
f"SELECT p.id,p.category FROM prompts p " # nosec B608
|
||||
f"WHERE p.id IN ({marks}) AND {_ELIGIBLE}",
|
||||
chunk,
|
||||
).fetchall()
|
||||
)
|
||||
changed = [pid for pid, value in current.items() if value != category]
|
||||
for pid in changed:
|
||||
self._ensure_library_parent(conn, pid)
|
||||
now = utc_now_iso()
|
||||
conn.executemany(
|
||||
"UPDATE prompts SET category=?,updated_at=? WHERE id=?",
|
||||
[(category, now, pid) for pid in changed],
|
||||
)
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"category": category,
|
||||
"updated": len(changed),
|
||||
"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
|
||||
|
||||
@@ -56,9 +56,11 @@ from .models import BUSY_TIMEOUT_SECONDS, PromptModel
|
||||
try:
|
||||
from ..utils import library_location as ll
|
||||
from ..utils.logging_config import get_logger
|
||||
from ..utils.workspace_keys import KEY_FILE_NAME
|
||||
except ImportError:
|
||||
from utils import library_location as ll
|
||||
from utils.logging_config import get_logger
|
||||
from utils.workspace_keys import KEY_FILE_NAME
|
||||
|
||||
logger = get_logger("prompt_manager.database.library_move")
|
||||
|
||||
@@ -491,7 +493,8 @@ class _Move:
|
||||
sweep_move_temps(target)
|
||||
if ll.read_marker(target).get("state") == ll.MARKER_PENDING:
|
||||
# An earlier move stopped before it switched: its files are ours.
|
||||
for name in (ll.DATABASE_NAME, ll.CONFIG_NAME) + self.plan.backups:
|
||||
names = (ll.DATABASE_NAME, ll.CONFIG_NAME, KEY_FILE_NAME)
|
||||
for name in names + self.plan.backups:
|
||||
_remove(os.path.join(target, name))
|
||||
for suffix in ("-wal", "-shm", "-journal"):
|
||||
_remove(os.path.join(target, ll.DATABASE_NAME + suffix))
|
||||
@@ -536,6 +539,7 @@ class _Move:
|
||||
raise MoveFailed("copy-failed") from error
|
||||
_check_copies([(self.plan.config_path, temp, expected)])
|
||||
copies = copies + [(self.plan.config_path, temp, expected, ll.CONFIG_NAME)]
|
||||
copies = copies + self._key_copy()
|
||||
self.marker = write_marker(
|
||||
target, ll.MARKER_PENDING, self.plan.db_path, self.plan.config_path
|
||||
)
|
||||
@@ -556,6 +560,28 @@ class _Move:
|
||||
self.checkpoint("marked")
|
||||
return files
|
||||
|
||||
def _key_copy(self) -> list:
|
||||
"""The workspace key's verified copy (#430), or none: it is re-creatable.
|
||||
|
||||
Without it the moved library starts with a new key, so pages left open
|
||||
across the restart refresh once; that never fails the move.
|
||||
"""
|
||||
source = os.path.join(self.plan.source_dir, KEY_FILE_NAME)
|
||||
if not os.path.isfile(source):
|
||||
return []
|
||||
temp = self.temp(".key")
|
||||
try:
|
||||
expected = _hash_copy(source, temp)
|
||||
_check_copies([(source, temp, expected)])
|
||||
except (OSError, MoveFailed) as error:
|
||||
_remove(temp)
|
||||
logger.warning(
|
||||
f"Library move: the workspace key was not copied ({error}); "
|
||||
"open workspace pages refresh once after the restart"
|
||||
)
|
||||
return []
|
||||
return [(source, temp, expected, KEY_FILE_NAME)]
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if self.moved:
|
||||
return
|
||||
|
||||
+111
-15
@@ -1,7 +1,14 @@
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
"""Atomic metadata enrichment for a single known generation context.
|
||||
|
||||
Also the helpers both ingestion store paths share (#247): the file's link
|
||||
rows and the one-prompt-per-file rule (``file_link_rows``,
|
||||
``link_conflict``) and the marks a prompt created by ingestion carries
|
||||
(``Marks``, ``mark_created``).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .models import normalize_image_path
|
||||
from .operations import _coerce_file_info
|
||||
@@ -37,16 +44,85 @@ def blank_positive(resolution: Dict[str, Any]) -> bool:
|
||||
return not (isinstance(text, str) and text.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Marks:
|
||||
"""What a prompt ingestion creates carries, so it can be found and removed.
|
||||
|
||||
``note`` is formatted with ``name``, the file's name.
|
||||
"""
|
||||
|
||||
category: str
|
||||
tags: Tuple[str, ...]
|
||||
note: str
|
||||
|
||||
|
||||
SCAN_MARKS = Marks("scanned", ("auto-scanned",), "Auto-scanned from {name}")
|
||||
RUN_MARKS = Marks("scanned", ("auto-scanned",), "Saved from a workflow run: {name}")
|
||||
|
||||
|
||||
def mark_created(conn: Any, db: Any, prompt_id: int, marks: Marks, name: str) -> None:
|
||||
"""Apply ``marks`` to a prompt row the caller just created on ``conn``."""
|
||||
conn.execute(
|
||||
"UPDATE prompts SET category = ?, notes = ? WHERE id = ?",
|
||||
(marks.category, marks.note.format(name=name), prompt_id),
|
||||
)
|
||||
db._sync_prompt_tags(conn, prompt_id, list(marks.tags))
|
||||
|
||||
|
||||
def file_link_rows(conn: Any, image_path: str) -> List[Any]:
|
||||
"""The file's link rows, each with its prompt's hash.
|
||||
|
||||
Matched under the path and its resolved spelling (an output folder
|
||||
reached through a symlink or junction), each normalized and raw;
|
||||
deduplicated by row id.
|
||||
"""
|
||||
rows: Dict[Any, Any] = {}
|
||||
for spelling in dict.fromkeys((image_path, _resolved(image_path))):
|
||||
for row in conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalize_image_path(spelling), spelling),
|
||||
).fetchall():
|
||||
rows.setdefault(row["id"], row)
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
def _resolved(path: str) -> str:
|
||||
try:
|
||||
return os.path.realpath(path)
|
||||
except (OSError, ValueError):
|
||||
return path
|
||||
|
||||
|
||||
def link_conflict(rows: List[Any], prompt_hash: str) -> bool:
|
||||
"""One prompt per file: more than one row, or a row of another prompt."""
|
||||
return len(rows) > 1 or bool(rows and rows[0]["positive_hash"] != prompt_hash)
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
|
||||
db: Any,
|
||||
image_path: str,
|
||||
metadata: Dict[str, Any],
|
||||
resolution: Dict[str, Any],
|
||||
*,
|
||||
marks: Optional[Marks] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Store a resolved pair and link the file to it in one transaction.
|
||||
|
||||
With ``marks`` (ingestion from files), each pair component this call
|
||||
creates carries them; without (the PromptManager node path) components
|
||||
are created bare, as always.
|
||||
"""
|
||||
state = resolution.get("state", "unresolved")
|
||||
if state == "resolved" and blank_positive(resolution):
|
||||
state = "unresolved"
|
||||
if state != "resolved":
|
||||
return {"status": state, "pairs_added": 0, "links_added": 0}
|
||||
with db._pair_transaction() as conn:
|
||||
return _ingest_resolved_pair_in(conn, db, image_path, metadata, resolution)
|
||||
return _ingest_resolved_pair_in(
|
||||
conn, db, image_path, metadata, resolution, marks=marks
|
||||
)
|
||||
|
||||
|
||||
def _conflict() -> Dict[str, Any]:
|
||||
@@ -61,6 +137,7 @@ def _ingest_resolved_pair_in(
|
||||
resolution: Dict[str, Any],
|
||||
*,
|
||||
provenance: Dict[str, Any] | None = None,
|
||||
marks: Optional[Marks] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""``ingest_resolved_pair`` inside the caller's open ``_pair_transaction``.
|
||||
|
||||
@@ -69,7 +146,8 @@ def _ingest_resolved_pair_in(
|
||||
negative is a ``conflict`` and nothing is written. The caller owns the
|
||||
transaction, so its own later failure rolls the link back too.
|
||||
``provenance`` replaces the link's default resolver provenance (Needs
|
||||
review records the user's selection there).
|
||||
review records the user's selection there). ``marks`` as in
|
||||
``ingest_resolved_pair``.
|
||||
"""
|
||||
positive, negative = resolution["positive"], resolution["negative"]
|
||||
positive_text = positive.get("storage_text", positive["text"])
|
||||
@@ -82,15 +160,8 @@ def _ingest_resolved_pair_in(
|
||||
"negative": negative,
|
||||
}
|
||||
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
|
||||
images = conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalized, image_path),
|
||||
).fetchall()
|
||||
if len(images) > 1 or (
|
||||
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
|
||||
):
|
||||
images = file_link_rows(conn, image_path)
|
||||
if link_conflict(images, generate_prompt_hash(positive_text)):
|
||||
return _conflict()
|
||||
existing = images[0] if images else None
|
||||
if existing:
|
||||
@@ -115,7 +186,12 @@ def _ingest_resolved_pair_in(
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None,
|
||||
),
|
||||
).fetchone()
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
if marks is None:
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
else:
|
||||
pair_id = _save_marked_pair(
|
||||
conn, db, positive_text, negative_text, marks, os.path.basename(image_path)
|
||||
)
|
||||
pair = conn.execute("SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)).fetchone()
|
||||
pairs_added = int(prior_pair is None)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
@@ -197,6 +273,26 @@ def _ingest_resolved_pair_in(
|
||||
}
|
||||
|
||||
|
||||
def _save_marked_pair(
|
||||
conn: Any,
|
||||
db: Any,
|
||||
positive_text: str,
|
||||
negative_text: str,
|
||||
marks: Marks,
|
||||
name: str,
|
||||
) -> int:
|
||||
"""``_save_pair`` that marks each component it creates."""
|
||||
texts = [positive_text] + ([negative_text] if negative_text.strip() else [])
|
||||
ids = []
|
||||
for text in texts:
|
||||
prompt_id, created = db._pair_component_outcome(conn, text)
|
||||
if created:
|
||||
mark_created(conn, db, prompt_id, marks, name)
|
||||
ids.append(prompt_id)
|
||||
negative_id = ids[1] if len(ids) > 1 else None
|
||||
return db._save_pair_components(conn, ids[0], negative_id)[0]
|
||||
|
||||
|
||||
def _document_form(conn: Any, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""``values`` with the workflow and prompt graph texts stored once.
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ def _runs_by_location(db: Any, names: List[str]) -> Optional[Dict[tuple, dict]]:
|
||||
return found
|
||||
|
||||
|
||||
def _recorded_runs(
|
||||
def recorded_runs(
|
||||
db: Any,
|
||||
image_paths: Iterable[str],
|
||||
output_root: Optional[str],
|
||||
@@ -201,7 +201,8 @@ def _recorded_runs(
|
||||
return attributed
|
||||
|
||||
|
||||
def _named_prompt(db: Any, run_id: str) -> Optional[int]:
|
||||
def named_prompt(db: Any, run_id: str) -> Optional[int]:
|
||||
"""The one stored prompt every positive context of ``run_id`` names, else None."""
|
||||
contexts = db.get_execution_contexts(run_id).get("contexts") or []
|
||||
named = {
|
||||
context.get("positive_prompt_id")
|
||||
@@ -228,11 +229,11 @@ def stored_run_prompts(
|
||||
paths = list(dict.fromkeys(path for path in image_paths if path))
|
||||
result: Dict[str, Optional[int]] = dict.fromkeys(paths)
|
||||
prompts: Dict[str, Optional[int]] = {}
|
||||
for path, (run_id, _nodes) in _recorded_runs(
|
||||
for path, (run_id, _nodes) in recorded_runs(
|
||||
db, paths, output_root, path_style
|
||||
).items():
|
||||
if run_id not in prompts:
|
||||
prompts[run_id] = _named_prompt(db, run_id)
|
||||
prompts[run_id] = named_prompt(db, run_id)
|
||||
result[path] = prompts[run_id]
|
||||
return result
|
||||
|
||||
@@ -275,7 +276,7 @@ def recorded_output(
|
||||
) -> Optional[RecordedOutput]:
|
||||
"""The recorded run facts for ``image_path``, else None (#386).
|
||||
|
||||
The file must be attributed to one run (``_recorded_runs``: one run
|
||||
The file must be attributed to one run (``recorded_runs``: one run
|
||||
reported its location and it was written during that run). Read-only; it
|
||||
never links anything.
|
||||
"""
|
||||
@@ -286,7 +287,7 @@ def recorded_output(
|
||||
if own_transaction: # outputs and texts from one state of history
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
found = _recorded_runs(db, [image_path], output_root, path_style)
|
||||
found = recorded_runs(db, [image_path], output_root, path_style)
|
||||
if image_path not in found:
|
||||
return None
|
||||
run_id, nodes = found[image_path]
|
||||
|
||||
@@ -35,6 +35,9 @@ _LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_MEDIA = ("all", "image", "video")
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
|
||||
# A collection id as the listing's ``collection`` query takes it (#331): none
|
||||
# ("") or 1 to 9 digits without a leading zero.
|
||||
_COLLECTION = re.compile(r"|[1-9][0-9]{0,8}")
|
||||
FALLBACK_NAME = "Saved view"
|
||||
|
||||
|
||||
@@ -55,6 +58,9 @@ _STATE_FIELDS = {
|
||||
"tags": ("tags", None, ()),
|
||||
# The Favorites page's filter (#249 D51): a view saved there reopens it.
|
||||
"favorites": ("bool", None, False),
|
||||
# A view saved on a collection page (#331 D140) reopens that
|
||||
# collection; never together with favorites.
|
||||
"collection": ("collection", None, ""),
|
||||
},
|
||||
"library": {
|
||||
"q": ("text", 500, ""),
|
||||
@@ -125,6 +131,8 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None:
|
||||
raise SavedViewInvalid(f"State '{key}' is not a supported value")
|
||||
if kind == "source" and not _SOURCE.fullmatch(value):
|
||||
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
|
||||
if kind == "collection" and not _COLLECTION.fullmatch(value):
|
||||
raise SavedViewInvalid(f"State '{key}' must be empty or a collection id")
|
||||
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
|
||||
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
|
||||
|
||||
@@ -142,6 +150,8 @@ def encode_state(page: object, state: object) -> str:
|
||||
)
|
||||
for key, (kind, limit, _default) in fields.items():
|
||||
_validate_value(key, kind, limit, state[key])
|
||||
if state.get("favorites") is True and state.get("collection"):
|
||||
raise SavedViewInvalid("A view cannot filter by favorites and a collection")
|
||||
encoded = json.dumps(
|
||||
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
@@ -181,6 +191,10 @@ def normalize_state(page: str, raw: object) -> dict:
|
||||
except SavedViewInvalid:
|
||||
value = default
|
||||
state[key] = list(value) if kind == "tags" else value
|
||||
# Never written by this build; a stored row with both opens the collection,
|
||||
# as the listing does for a URL with both (#331 D140).
|
||||
if state.get("favorites") is True and state.get("collection"):
|
||||
state["favorites"] = False
|
||||
return state
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Link an output file to the prompt of one text, creating it when new (#247).
|
||||
|
||||
The store step of ingestion from file metadata when the decision is a
|
||||
positive text (``utils.file_prompt``). The prompt insert and the link run in
|
||||
one ``BEGIN IMMEDIATE`` transaction: two writers never store one text twice,
|
||||
a failed link leaves no new prompt behind, and a file stays linked to one
|
||||
prompt (a file already linked to another prompt is a ``conflict``).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .pair_ingestion import Marks, file_link_rows, link_conflict, mark_created
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
_LOGGER = logging.getLogger("prompt_manager.database.scanned_prompts")
|
||||
|
||||
|
||||
def store_scanned_prompt_or_raise(
|
||||
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
|
||||
) -> Tuple[str, Optional[int]]:
|
||||
"""``(status, prompt id)`` after linking ``path`` to ``text``'s prompt.
|
||||
|
||||
``added`` (prompt created, carrying ``marks``), ``linked`` (stored text
|
||||
reused), ``unchanged`` (already linked to it) or ``conflict`` (linked to
|
||||
another prompt; nothing written, id None). Database errors are raised
|
||||
and roll the whole store back, so a caller can run it again.
|
||||
"""
|
||||
with db._pair_transaction() as conn:
|
||||
rows = file_link_rows(conn, path)
|
||||
if link_conflict(rows, generate_prompt_hash(text)):
|
||||
return "conflict", None
|
||||
if rows:
|
||||
return "unchanged", rows[0]["prompt_id"]
|
||||
prompt_id, created = db._pair_component_outcome(conn, text)
|
||||
if created:
|
||||
mark_created(conn, db, prompt_id, marks, os.path.basename(path))
|
||||
db._insert_image_link(conn, prompt_id, path, canonical)
|
||||
return ("added" if created else "linked"), prompt_id
|
||||
|
||||
|
||||
def store_scanned_prompt(
|
||||
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
|
||||
) -> Tuple[str, Optional[int]]:
|
||||
"""The scan's form: an error is logged and reported as ``("found", None)``.
|
||||
|
||||
A write that failed because Move library switched runs again on the new
|
||||
library (``survive_library_switch``); the rerun re-checks the link rows.
|
||||
"""
|
||||
try:
|
||||
return db.survive_library_switch(
|
||||
lambda: store_scanned_prompt_or_raise(db, path, canonical, text, marks)
|
||||
)
|
||||
except Exception as error:
|
||||
_LOGGER.error(
|
||||
f"Failed to store the prompt of {os.path.basename(path)}: {error}"
|
||||
)
|
||||
return "found", None
|
||||
|
||||
|
||||
def link_stored_prompt_or_raise(
|
||||
db: Any, path: str, canonical: Dict[str, Any], prompt_id: int
|
||||
) -> Optional[str]:
|
||||
"""Link ``path`` to the stored prompt ``prompt_id`` (recorded history, #342).
|
||||
|
||||
``linked``, ``unchanged`` (already linked to it), ``conflict`` (linked to
|
||||
another prompt; nothing written) or None when the prompt is gone. One
|
||||
``BEGIN IMMEDIATE`` transaction; database errors are raised.
|
||||
"""
|
||||
with db._pair_transaction() as conn:
|
||||
rows = file_link_rows(conn, path)
|
||||
if len(rows) > 1 or (rows and rows[0]["prompt_id"] != prompt_id):
|
||||
return "conflict"
|
||||
if rows:
|
||||
return "unchanged"
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM prompts WHERE id = ?", (prompt_id,)
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
return None
|
||||
db._insert_image_link(conn, prompt_id, path, canonical)
|
||||
return "linked"
|
||||
@@ -12,7 +12,8 @@ code. This plan proposes implementation order and acceptance gates.
|
||||
- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates.
|
||||
- [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.
|
||||
- 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/<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
|
||||
|
||||
|
||||
+6
-82
@@ -74,9 +74,11 @@ from .autotag_routes import AutotagRoutesMixin
|
||||
from .lora_integration import LoraIntegrationMixin
|
||||
from .lora_import_routes import LoraImportRoutesMixin
|
||||
from .image_categories import ImageCategoryRoutesMixin
|
||||
from .source_rescope import SourceRescopeMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.file_prompt import parse_comfyui_prompt
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.video_metadata import is_video_path, read_video_metadata
|
||||
except ImportError:
|
||||
@@ -86,6 +88,7 @@ except ImportError:
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.file_prompt import parse_comfyui_prompt
|
||||
from utils.logging_config import get_logger
|
||||
from utils.video_metadata import is_video_path, read_video_metadata
|
||||
|
||||
@@ -340,6 +343,7 @@ class PromptManagerAPI(
|
||||
LoraIntegrationMixin,
|
||||
LoraImportRoutesMixin,
|
||||
ImageCategoryRoutesMixin,
|
||||
SourceRescopeMixin,
|
||||
):
|
||||
"""REST API handler for PromptManager operations and web interface.
|
||||
|
||||
@@ -706,6 +710,7 @@ class PromptManagerAPI(
|
||||
self._register_lora_routes(routes)
|
||||
self._register_lora_import_routes(routes)
|
||||
self._register_image_category_routes(routes)
|
||||
self._register_rescope_routes(routes)
|
||||
|
||||
# Register gzip compression middleware (once)
|
||||
global _gzip_registered
|
||||
@@ -1031,85 +1036,4 @@ class PromptManagerAPI(
|
||||
|
||||
def _parse_comfyui_prompt(self, metadata):
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata."""
|
||||
result = {
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"parameters": {},
|
||||
"positive_prompt": None,
|
||||
"negative_prompt": None,
|
||||
}
|
||||
|
||||
# Check for A1111/Forge style parameters first.
|
||||
# The chunk is one block: a possibly multi-line positive prompt, then a
|
||||
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
# Taking lines[0] as the positive prompt truncates any multi-line prompt
|
||||
# to one line, so split on the settings line instead.
|
||||
if "parameters" in metadata:
|
||||
import re as _re
|
||||
|
||||
params = metadata["parameters"]
|
||||
body = params
|
||||
settings = ""
|
||||
_sm = _re.search(r"\nSteps:\s", body)
|
||||
if _sm:
|
||||
settings = body[_sm.start() + 1 :].strip()
|
||||
body = body[: _sm.start()]
|
||||
negative = None
|
||||
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if _nm:
|
||||
negative = body[_nm.end() :].strip()
|
||||
body = body[: _nm.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
# Store raw parameters too
|
||||
result["parameters"]["parameters"] = params
|
||||
if settings:
|
||||
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
|
||||
_fm = _re.search(
|
||||
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if _fm:
|
||||
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
|
||||
1
|
||||
).strip()
|
||||
|
||||
# If no A1111 format found, proceed with ComfyUI parsing
|
||||
if result["positive_prompt"] is None:
|
||||
# Check for direct prompt field
|
||||
if "prompt" in metadata:
|
||||
try:
|
||||
prompt_data = json.loads(metadata["prompt"])
|
||||
result["prompt"] = prompt_data
|
||||
except json.JSONDecodeError:
|
||||
result["prompt"] = metadata["prompt"]
|
||||
|
||||
# Check for workflow
|
||||
if "workflow" in metadata:
|
||||
try:
|
||||
workflow_data = json.loads(metadata["workflow"])
|
||||
result["workflow"] = workflow_data
|
||||
except json.JSONDecodeError:
|
||||
result["workflow"] = metadata["workflow"]
|
||||
|
||||
# Check for other common ComfyUI fields
|
||||
common_fields = [
|
||||
"positive",
|
||||
"negative",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"seed",
|
||||
]
|
||||
for field in common_fields:
|
||||
if field in metadata:
|
||||
try:
|
||||
result["parameters"][field] = json.loads(metadata[field])
|
||||
except json.JSONDecodeError:
|
||||
result["parameters"][field] = metadata[field]
|
||||
|
||||
return result
|
||||
return parse_comfyui_prompt(metadata)
|
||||
|
||||
+153
-248
@@ -40,45 +40,51 @@ except ImportError:
|
||||
from utils import exit_signal
|
||||
|
||||
try:
|
||||
from ...database.pair_ingestion import blank_positive, ingest_resolved_pair
|
||||
from ...database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from ...database.run_attribution import (
|
||||
RecordedOutput,
|
||||
host_output_directory,
|
||||
stored_run_prompt,
|
||||
stored_run_prompts,
|
||||
named_prompt,
|
||||
recorded_runs,
|
||||
)
|
||||
from ...utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
from ...database.scanned_prompts import (
|
||||
link_stored_prompt_or_raise,
|
||||
store_scanned_prompt,
|
||||
)
|
||||
from ...utils.file_prompt import (
|
||||
NEVER,
|
||||
NOT_SAVED,
|
||||
NOT_SAVED_WIRED,
|
||||
PAIR,
|
||||
Policy,
|
||||
decide_file_prompt,
|
||||
)
|
||||
from ...utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from ...utils.review_capture import reviewable
|
||||
except ImportError:
|
||||
from database.pair_ingestion import blank_positive, ingest_resolved_pair
|
||||
from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from database.run_attribution import (
|
||||
RecordedOutput,
|
||||
host_output_directory,
|
||||
stored_run_prompt,
|
||||
stored_run_prompts,
|
||||
named_prompt,
|
||||
recorded_runs,
|
||||
)
|
||||
from utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
from database.scanned_prompts import (
|
||||
link_stored_prompt_or_raise,
|
||||
store_scanned_prompt,
|
||||
)
|
||||
from utils.file_prompt import (
|
||||
NEVER,
|
||||
NOT_SAVED,
|
||||
NOT_SAVED_WIRED,
|
||||
PAIR,
|
||||
Policy,
|
||||
decide_file_prompt,
|
||||
)
|
||||
from utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from utils.review_capture import reviewable
|
||||
|
||||
|
||||
try:
|
||||
from ...database.library import display_title
|
||||
from ...utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from database.library import display_title
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
|
||||
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
|
||||
@@ -258,7 +264,7 @@ SCAN_DETAILS = {
|
||||
"skipped": "Skipped · already linked",
|
||||
"ambiguous": "Needs review: more than one prompt could match",
|
||||
"unresolved": "Needs review: the prompt could not be traced in the workflow",
|
||||
"conflict": "Needs review: already linked to a different prompt",
|
||||
"conflict": "Kept: already linked to a different prompt",
|
||||
"failed": "Error: the prompt could not be stored",
|
||||
"error": "Error: the file could not be processed (see the server log)",
|
||||
"found": "Metadata found, but no prompt text to store",
|
||||
@@ -325,22 +331,6 @@ def _file_size(path):
|
||||
return None
|
||||
|
||||
|
||||
def _with_file_size(canonical, media_file):
|
||||
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
||||
|
||||
Stored as ``generated_images.file_size``; the "new" scan scope skips a known
|
||||
file only while its size is unchanged.
|
||||
"""
|
||||
info = canonical.get("file_info")
|
||||
info = dict(info) if isinstance(info, dict) else {}
|
||||
if info.get("size") is None:
|
||||
size = _file_size(media_file)
|
||||
if size is not None:
|
||||
info["size"] = size
|
||||
if info:
|
||||
canonical["file_info"] = info
|
||||
|
||||
|
||||
def _scan_roots(output_dirs):
|
||||
"""(absolute root, public label, listing index), the most specific first.
|
||||
|
||||
@@ -596,18 +586,56 @@ class _BatchHistory:
|
||||
"""A scan batch's recorded-history lookup, made on the first file needing it.
|
||||
|
||||
Most files link through their metadata and never ask; the first one that
|
||||
does triggers one lookup for the whole batch (``_recorded_runs_for``).
|
||||
does triggers one ``recorded_runs`` pass for the whole batch, from which
|
||||
both the stored prompt (``get``) and the recorded saver (``recorded``)
|
||||
of every file are read. A failure is logged and names nothing.
|
||||
"""
|
||||
|
||||
def __init__(self, api, media_files):
|
||||
self._api = api
|
||||
self._files = media_files
|
||||
self._found = None
|
||||
self._files = [str(path) for path in media_files]
|
||||
self._runs = None
|
||||
self._prompts = {}
|
||||
|
||||
def _found(self):
|
||||
if self._runs is None:
|
||||
try:
|
||||
self._runs = recorded_runs(
|
||||
self._api.db, self._files, host_output_directory(), None
|
||||
)
|
||||
except Exception:
|
||||
self._api.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
self._runs = {}
|
||||
return self._runs
|
||||
|
||||
def get(self, path):
|
||||
if self._found is None:
|
||||
self._found = self._api._recorded_runs_for(self._files)
|
||||
return self._found.get(path)
|
||||
"""The stored prompt id history attributes to ``path``, else None."""
|
||||
found = self._found().get(path)
|
||||
if found is None:
|
||||
return None
|
||||
run_id = found[0]
|
||||
if run_id not in self._prompts:
|
||||
try:
|
||||
self._prompts[run_id] = named_prompt(self._api.db, run_id)
|
||||
except Exception:
|
||||
self._api.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
self._prompts[run_id] = None
|
||||
return self._prompts[run_id]
|
||||
|
||||
def recorded(self, path):
|
||||
"""``RecordedOutput(saver, {})`` for an attributed file, else None.
|
||||
|
||||
``saver`` is set only when the run reported the file from one node.
|
||||
"""
|
||||
found = self._found().get(path)
|
||||
if found is None:
|
||||
return None
|
||||
nodes = found[1]
|
||||
return RecordedOutput(next(iter(nodes)) if len(nodes) == 1 else None, {})
|
||||
|
||||
|
||||
class AdminRoutesMixin:
|
||||
@@ -1672,24 +1700,6 @@ class AdminRoutesMixin:
|
||||
note(media_file, status)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _graph_prompt_text(metadata):
|
||||
"""The positive prompt the browser parser traces from the graphs, or None.
|
||||
|
||||
Records parse as the legacy pages parsed them (NaN reads as null).
|
||||
"""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
def _ingest_scanned_file(
|
||||
self,
|
||||
media_file,
|
||||
@@ -1700,148 +1710,81 @@ class AdminRoutesMixin:
|
||||
records=None,
|
||||
history=None,
|
||||
):
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
|
||||
"""Store the prompt the shared per-file decision names (#247).
|
||||
|
||||
Returns the file's scan status, or None without prompt metadata. With
|
||||
``detailed``, a stored or linked file returns a dict instead: its
|
||||
``status``, the prompt's display ``title`` and, for a resolved pair,
|
||||
the pair counts. When the resolver finds the file ambiguous (whether
|
||||
or not the browser parser then links it) and ``ambiguous`` is a list,
|
||||
``media_file`` is appended to it; only a file that then returns
|
||||
``"ambiguous"`` (nothing linked it) is a Needs review case. An
|
||||
ambiguous result with nothing to choose (``reviewable`` is False: fewer
|
||||
than two candidates carry a prompt) that nothing links returns
|
||||
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``.
|
||||
The parsed records of either ambiguous file go to
|
||||
``records["canonical"]`` when ``records`` is a dict.
|
||||
``utils.file_prompt.decide_file_prompt`` (scan policy) decides; this
|
||||
stores the result. Returns the file's scan status, or None without
|
||||
prompt metadata. With ``detailed``, a stored or linked file returns a
|
||||
dict instead: its ``status``, the prompt's display ``title`` and, for
|
||||
a resolved pair, the pair counts. When the resolver finds the file
|
||||
ambiguous (whether or not a later step then links it) and
|
||||
``ambiguous`` is a list, ``media_file`` is appended to it; only a file
|
||||
that then returns ``"ambiguous"`` (nothing linked it) is a Needs
|
||||
review case. An ambiguous result with nothing to choose
|
||||
(``reviewable`` is False: fewer than two candidates carry a prompt)
|
||||
goes to ``untraced`` instead and returns ``"unresolved"`` when nothing
|
||||
links it. The parsed records of either ambiguous file go to
|
||||
``records["canonical"]`` when ``records`` is a dict. ``history`` is
|
||||
the batch's lazy recorded-history lookup.
|
||||
"""
|
||||
if not metadata:
|
||||
# No prompt metadata (--disable-metadata, bare savers): only the
|
||||
# recorded run can say which prompt made it (#342)
|
||||
return self._link_recorded_run(media_file, {}, detailed, history)
|
||||
canonical = dict(metadata)
|
||||
for key in ("prompt", "workflow"):
|
||||
if isinstance(canonical.get(key), str):
|
||||
try:
|
||||
canonical[key] = json.loads(canonical[key])
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: no prompt, link,
|
||||
# recorded-run fallback or Needs review item (#90)
|
||||
return "not_saved"
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it, never a scan
|
||||
return "not_saved_wired"
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return self._link_recorded_run(media_file, canonical, detailed, history)
|
||||
_with_file_size(canonical, media_file)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: store the positive alone (#90)
|
||||
prompt_text = (
|
||||
resolution["positive"]
|
||||
.get("storage_text", resolution["positive"]["text"])
|
||||
.strip()
|
||||
)
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {
|
||||
"status": status,
|
||||
"title": self._scan_title(media_file, prompt_text),
|
||||
}
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
# Ambiguous without a choice: unresolved unless something links it.
|
||||
unchoosable = resolution["state"] == "ambiguous" and not reviewable(resolution)
|
||||
if unchoosable:
|
||||
if untraced is not None:
|
||||
untraced.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = canonical
|
||||
elif resolution["state"] == "ambiguous" and ambiguous is not None:
|
||||
ambiguous.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = canonical
|
||||
if resolution["state"] == "resolved":
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
path = str(media_file)
|
||||
if history is None: # one file: history is still asked once
|
||||
history = _BatchHistory(self, [media_file])
|
||||
decision = decide_file_prompt(
|
||||
metadata,
|
||||
file_name=Path(path).name,
|
||||
file_size=functools.partial(_file_size, media_file),
|
||||
in_output=False, # only the monitor policy reads it
|
||||
lookup=functools.partial(history.recorded, path),
|
||||
policy=Policy.SCAN,
|
||||
)
|
||||
if decision.kind in (NOT_SAVED, NOT_SAVED_WIRED):
|
||||
# Save to library off (#90): no write, recorded-run fallback or
|
||||
# Needs review item
|
||||
return decision.kind
|
||||
self._note_review(media_file, decision, ambiguous, untraced, records)
|
||||
if decision.kind == NEVER:
|
||||
if decision.try_recorded_run:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, decision.canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return decision.unlinked_status
|
||||
if decision.kind == PAIR:
|
||||
# A rerun after a library move re-checks the link rows.
|
||||
result = self.db.survive_library_switch(
|
||||
lambda: ingest_resolved_pair(
|
||||
self.db,
|
||||
path,
|
||||
decision.canonical,
|
||||
decision.resolution,
|
||||
marks=SCAN_MARKS,
|
||||
)
|
||||
)
|
||||
if not detailed:
|
||||
return result["status"]
|
||||
return {
|
||||
**result,
|
||||
"title": self._scan_title(media_file, resolution["positive"]["text"]),
|
||||
}
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
):
|
||||
# The resolver could not decide: link the prompt the inspector and the
|
||||
# legacy pages show for this file (the browser parser, in Python).
|
||||
# Graphs without a recognized sampler keep the legacy extractor below.
|
||||
prompt_text = self._graph_prompt_text(metadata)
|
||||
if not prompt_text:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return "unresolved" if unchoosable else resolution["state"]
|
||||
elif resolution["state"] == "ambiguous":
|
||||
return "unresolved" if unchoosable else "ambiguous"
|
||||
elif resolution["state"] == "unresolved" and not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
elif canonical.get("prompt"):
|
||||
# No node takes a positive or negative input (a guider or wrapper
|
||||
# graph): the one wired text, never a guess (#252).
|
||||
found = sampler_less_prompt(canonical["prompt"])
|
||||
prompt_text = found["text"]
|
||||
if prompt_text is None and isinstance(canonical.get("parameters"), str):
|
||||
# A metadata saver also wrote A1111 text: its positive prompt.
|
||||
parsed = self._parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
prompt_text = parsed.strip() if parsed and parsed.strip() else None
|
||||
if prompt_text is None:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return "unresolved" if found["state"] == "ambiguous" else "found"
|
||||
else:
|
||||
prompt_text = self._parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
title_text = decision.resolution["positive"]["text"]
|
||||
return {**result, "title": self._scan_title(media_file, title_text)}
|
||||
status, _prompt_id = store_scanned_prompt(
|
||||
self.db, path, decision.canonical, decision.text, SCAN_MARKS
|
||||
)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
|
||||
return {"status": status, "title": self._scan_title(media_file, decision.text)}
|
||||
|
||||
def _recorded_runs_for(self, media_files):
|
||||
"""``{path: prompt id or None}`` from recorded history for a scan batch.
|
||||
|
||||
One output lookup per chunk of names (``stored_run_prompts``), so a
|
||||
batch never queries history once per file. An empty dict on failure.
|
||||
"""
|
||||
try:
|
||||
return stored_run_prompts(
|
||||
self.db, [str(path) for path in media_files], host_output_directory()
|
||||
)
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
return {}
|
||||
@staticmethod
|
||||
def _note_review(media_file, decision, ambiguous, untraced, records):
|
||||
"""Hand an ambiguous file to the batch's Needs review lists."""
|
||||
if decision.review == "untraced":
|
||||
if untraced is not None:
|
||||
untraced.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = decision.canonical
|
||||
elif decision.review == "ambiguous" and ambiguous is not None:
|
||||
ambiguous.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = decision.canonical
|
||||
|
||||
def _link_recorded_run(self, media_file, canonical, detailed=False, history=None):
|
||||
"""Link a file recorded history attributes to one stored prompt (#342).
|
||||
@@ -1850,31 +1793,25 @@ class AdminRoutesMixin:
|
||||
``{"status", "title"}``), or None when history names no single stored
|
||||
prompt for the file (``database.run_attribution``). Never creates a
|
||||
prompt. Works without any prompt metadata in the file. ``history`` is
|
||||
the batch's precomputed lookup; without it the file is looked up alone.
|
||||
the batch's lookup (``_BatchHistory``). A file already linked to
|
||||
another prompt is a ``conflict``: one prompt per file.
|
||||
"""
|
||||
if history is not None:
|
||||
prompt_id = history.get(str(media_file))
|
||||
else:
|
||||
try:
|
||||
prompt_id = stored_run_prompt(
|
||||
self.db, str(media_file), host_output_directory()
|
||||
)
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
f"History attribution failed for {media_file.name}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
if history is None:
|
||||
history = _BatchHistory(self, [media_file])
|
||||
prompt_id = history.get(str(media_file))
|
||||
if prompt_id is None:
|
||||
return None
|
||||
try:
|
||||
image_id = self.db.link_image_to_prompt(
|
||||
prompt_id, str(media_file), metadata=canonical
|
||||
status = self.db.survive_library_switch(
|
||||
lambda: link_stored_prompt_or_raise(
|
||||
self.db, str(media_file), canonical, prompt_id
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link {media_file.name} from history: {e}")
|
||||
return "found"
|
||||
status = "linked" if image_id else "unchanged"
|
||||
if status is None:
|
||||
return None
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
prompt = self.db.get_prompt_by_id(prompt_id) or {}
|
||||
@@ -1894,35 +1831,3 @@ class AdminRoutesMixin:
|
||||
except Exception:
|
||||
self.logger.warning("Scan row title could not be read; using its text")
|
||||
return display_title((), text)
|
||||
|
||||
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
|
||||
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
|
||||
prompt_hash = generate_prompt_hash(prompt_text)
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
try:
|
||||
image_id = self.db.link_image_to_prompt(
|
||||
existing["id"], str(media_file), metadata=canonical
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to link {media_file.name} to existing prompt: {e}"
|
||||
)
|
||||
return "found"
|
||||
return "linked" if image_id else "unchanged"
|
||||
|
||||
prompt_id = self.db.save_prompt(
|
||||
prompt_text,
|
||||
"scanned",
|
||||
["auto-scanned"],
|
||||
None,
|
||||
f"Auto-scanned from {media_file.name}",
|
||||
prompt_hash,
|
||||
)
|
||||
if not prompt_id:
|
||||
return "found"
|
||||
try:
|
||||
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
|
||||
return "added"
|
||||
|
||||
+74
-17
@@ -13,6 +13,7 @@ import re
|
||||
import stat
|
||||
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
|
||||
import sys
|
||||
import threading
|
||||
import time as _time
|
||||
import urllib.parse
|
||||
from bisect import bisect_left, bisect_right, insort
|
||||
@@ -81,6 +82,7 @@ try:
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
from ...utils.workspace_keys import gallery_root_scope, workspace_keys_for
|
||||
except ImportError:
|
||||
from database.image_collections import CollectionMissing
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
@@ -105,6 +107,7 @@ except ImportError:
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
from utils.workspace_keys import gallery_root_scope, workspace_keys_for
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
@@ -518,7 +521,8 @@ MAX_JSON_BODY_BYTES = 1024 * 1024
|
||||
_READ_CHUNK = 64 * 1024
|
||||
|
||||
|
||||
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
|
||||
# Guards the first load of an API's workspace keys (#430, _workspace_keys).
|
||||
_WORKSPACE_KEYS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
|
||||
@@ -618,15 +622,37 @@ def _rated_key(entry: _Listed, rank: int) -> tuple:
|
||||
return (-rank, *_workspace_key(entry, "rating"))
|
||||
|
||||
|
||||
def _workspace_cursor(key: tuple, scope: str) -> str:
|
||||
# Bump whenever the cursor's key tuple changes shape: a cursor now outlives a
|
||||
# restart and an upgrade (#430), so an old shape must be refused, not parsed.
|
||||
_CURSOR_VERSION = 2
|
||||
_CURSOR_STALE = "The image collection changed; refresh the gallery"
|
||||
|
||||
|
||||
class CursorStale(ValueError):
|
||||
"""A well-formed cursor this listing cannot verify (#430).
|
||||
|
||||
Another key, another root list or other filters: the page refreshes its
|
||||
collection instead of retrying the same cursor.
|
||||
"""
|
||||
|
||||
|
||||
def _cursor_mac(secret: bytes, scope: str, payload: bytes) -> bytes:
|
||||
# The listing scope (it hashes every absolute root) is bound in the MAC
|
||||
# only, never sent: the scope is 64 hex, so the "\n" join is unambiguous.
|
||||
return hmac.new(secret, scope.encode() + b"\n" + payload, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _workspace_cursor(key: tuple, scope: str, secret: bytes) -> str:
|
||||
"""The signed keyset cursor of *key*; *secret* is the API's cursor key."""
|
||||
payload = json.dumps(
|
||||
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
|
||||
{"v": _CURSOR_VERSION, "key": key}, separators=(",", ":"), ensure_ascii=False
|
||||
).encode()
|
||||
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
|
||||
signature = _cursor_mac(secret, scope, payload)
|
||||
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
|
||||
|
||||
|
||||
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
||||
def _workspace_cursor_key(token: str, scope: str, order: str, secret: bytes) -> tuple:
|
||||
"""The keyset key of *token*; CursorStale when it is not this listing's."""
|
||||
try:
|
||||
if len(token) > 4096:
|
||||
raise ValueError()
|
||||
@@ -634,13 +660,14 @@ def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
||||
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
|
||||
)
|
||||
signature, payload = raw[:32], raw[32:]
|
||||
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
if len(signature) != 32 or not payload:
|
||||
raise ValueError()
|
||||
if not hmac.compare_digest(signature, _cursor_mac(secret, scope, payload)):
|
||||
raise CursorStale(_CURSOR_STALE)
|
||||
data = json.loads(payload)
|
||||
key = data["key"]
|
||||
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
|
||||
raise ValueError()
|
||||
if data["v"] != _CURSOR_VERSION or not isinstance(key, list):
|
||||
raise CursorStale(_CURSOR_STALE)
|
||||
types = (
|
||||
(str, str, int, (float, int))
|
||||
if order in ("name", "name-desc")
|
||||
@@ -654,6 +681,8 @@ def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
||||
):
|
||||
raise ValueError()
|
||||
return tuple(key)
|
||||
except CursorStale:
|
||||
raise
|
||||
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
|
||||
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
|
||||
|
||||
@@ -1338,13 +1367,27 @@ def _in_subfolder(rel_dir, subfolder):
|
||||
class ImageRoutesMixin:
|
||||
"""Mixin providing image and gallery-related API endpoints."""
|
||||
|
||||
def _workspace_keys(self):
|
||||
"""This API's scope and cursor keys, loaded once (#430).
|
||||
|
||||
They derive from the secret persisted beside the database, so a restart
|
||||
keeps every scope and cursor an open page holds. The first call reads
|
||||
(or writes) one small file; later calls return the cached keys, which
|
||||
a live library move or restore does not change before a restart.
|
||||
"""
|
||||
keys = self.__dict__.get("_workspace_keys_cache")
|
||||
if keys is None:
|
||||
with _WORKSPACE_KEYS_LOCK:
|
||||
keys = self.__dict__.get("_workspace_keys_cache")
|
||||
if keys is None:
|
||||
db = getattr(self, "db", None)
|
||||
model = getattr(db, "model", None)
|
||||
keys = workspace_keys_for(getattr(model, "db_path", None))
|
||||
self.__dict__["_workspace_keys_cache"] = keys
|
||||
return keys
|
||||
|
||||
def _gallery_root_scope(self, root: Path, index: int) -> str:
|
||||
payload = json.dumps(
|
||||
["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
|
||||
return gallery_root_scope(self._workspace_keys(), str(_canonical(root)), index)
|
||||
|
||||
def _gallery_scope_error(
|
||||
self, query: Mapping, roots: list[Path]
|
||||
@@ -2008,6 +2051,8 @@ class ImageRoutesMixin:
|
||||
limit, _offset = parse_page_params(request.query, default_limit=100)
|
||||
except ValueError:
|
||||
return bad_request("limit and offset must be integers")
|
||||
# #430: the first use may create the key file; never on the event loop.
|
||||
cursor_secret = (await self._run_in_executor(self._workspace_keys)).cursor
|
||||
try:
|
||||
root_index = _workspace_root(request.query, output_dirs)
|
||||
filters = _workspace_filters(request.query)
|
||||
@@ -2031,7 +2076,15 @@ class ImageRoutesMixin:
|
||||
).hexdigest()
|
||||
token = request.query.get("cursor")
|
||||
after = (
|
||||
_workspace_cursor_key(token, scope, filters["order"]) if token else None
|
||||
_workspace_cursor_key(token, scope, filters["order"], cursor_secret)
|
||||
if token
|
||||
else None
|
||||
)
|
||||
except CursorStale as exc:
|
||||
return web.json_response(
|
||||
{"success": False, "code": "cursor-stale", "error": str(exc)},
|
||||
status=400,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
@@ -2472,7 +2525,11 @@ class ImageRoutesMixin:
|
||||
positive_count = _COUNT_OMITTED
|
||||
has_more = len(page) > limit
|
||||
page = page[:limit]
|
||||
next_cursor = _workspace_cursor(key(page[-1]), scope) if has_more else None
|
||||
next_cursor = (
|
||||
_workspace_cursor(key(page[-1]), scope, self._workspace_keys().cursor)
|
||||
if has_more
|
||||
else None
|
||||
)
|
||||
return (
|
||||
[(entry.path, entry.mtime, entry.root, entry.index) for entry in page],
|
||||
total,
|
||||
|
||||
+60
-1
@@ -8,8 +8,10 @@ import sqlite3
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.library_commands import LibraryCommandError
|
||||
from ...database.pairs import PairDependencyError
|
||||
except ImportError:
|
||||
from database.library_commands import LibraryCommandError
|
||||
from database.pairs import PairDependencyError
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -1128,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", [])
|
||||
|
||||
@@ -1180,6 +1189,8 @@ class PromptRoutesMixin:
|
||||
)
|
||||
if "images_filter" in data:
|
||||
return await self._bulk_category_by_filter(data)
|
||||
if "expected_epoch" in data:
|
||||
return await self._bulk_library_category(data)
|
||||
if not data.get("prompt_ids"):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No prompt IDs provided"}, status=400
|
||||
@@ -1216,6 +1227,54 @@ class PromptRoutesMixin:
|
||||
status=500,
|
||||
)
|
||||
|
||||
async def _bulk_library_tags(self, data: dict) -> web.Response:
|
||||
"""The workspace Library's fenced form (#442).
|
||||
|
||||
``{prompt_ids, tags, expected_epoch}``: prompt tags only, never image tags.
|
||||
"""
|
||||
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))
|
||||
)
|
||||
try:
|
||||
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
result = await self._run_in_executor(
|
||||
write, prompt_ids, data[field], expected_epoch=data["expected_epoch"]
|
||||
)
|
||||
except LibraryCommandError as exc:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": exc.code,
|
||||
"library_epoch": exc.epoch,
|
||||
"error": "Prompt storage was restored; refresh the library",
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
except ValueError as exc:
|
||||
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.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The rule (maintainer test of #284/#288): a file is queued only when its
|
||||
final outcome is ambiguous and unlinked. The strict resolver must find it
|
||||
``ambiguous``, the scan's legacy fallback (the browser parser,
|
||||
``_graph_prompt_text``) must not link it, and no saved row may link it
|
||||
``graph_prompt_text``) must not link it, and no saved row may link it
|
||||
already. A file the fallback links, an exact pair the ingester stored and an
|
||||
already linked file are never queued. Neither is an ambiguous file with
|
||||
nothing to choose: fewer than two of its candidates carry a prompt
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Where a stale Images target is now (#430): ``POST /prompt_manager/images/rescope``.
|
||||
|
||||
A page addresses a listed file as ``{root_index, root_scope, path}``. When the
|
||||
sources are renumbered its scope no longer names the root at that index, and
|
||||
every per-file action answers 409 ``source-changed``. Before it gives up, the
|
||||
page asks here. The target is mapped only when its scope is, at its old
|
||||
index, the scope of one of the current roots: the server issued that scope
|
||||
for exactly that folder, so the answer (the file's current listing
|
||||
descriptor) names nothing the page could not already address. A removed or
|
||||
replaced root, a scope of another key and a foreign scope stay refused.
|
||||
|
||||
Only the Images roots (``_get_all_output_dirs``) are mapped; a LoRA-linked
|
||||
root's target is never re-scoped here.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .images import (
|
||||
_forbidden,
|
||||
_is_thumbnail_cache_path,
|
||||
_json_body_or_error,
|
||||
_media_access_error,
|
||||
)
|
||||
from .media_target import media_target
|
||||
from .prompts import bad_request
|
||||
|
||||
# Far above any real source count; keeps the index out of huge-int territory.
|
||||
MAX_ROOT_INDEX = 4095
|
||||
SOURCE_GONE = (
|
||||
"The source of this image changed or was removed. Refresh the image "
|
||||
"collection to find it again."
|
||||
)
|
||||
|
||||
|
||||
def _coded(status: int, code: str, message: str) -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": message}, status=status
|
||||
)
|
||||
|
||||
|
||||
def _no_store(response: web.Response) -> web.Response:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
class SourceRescopeMixin:
|
||||
"""The rescope route; composed into ``PromptManagerAPI``."""
|
||||
|
||||
def _register_rescope_routes(self, routes):
|
||||
@routes.post("/prompt_manager/images/rescope")
|
||||
async def rescope_route(request):
|
||||
return _no_store(await self.rescope_image(request))
|
||||
|
||||
def _current_root_for(self, scope: str, old_index: int, roots: list) -> int | None:
|
||||
"""The index of the first current root *scope* was issued for at *old_index*."""
|
||||
for index, root in enumerate(roots):
|
||||
if hmac.compare_digest(scope, self._gallery_root_scope(root, old_index)):
|
||||
return index
|
||||
return None
|
||||
|
||||
def _rescope_blocking(self, scope: str, old_index: int, relative: str):
|
||||
"""The answer to one rescope (blocking: it resolves paths)."""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
index = self._current_root_for(scope, old_index, roots)
|
||||
if index is None:
|
||||
return _coded(409, "source-changed", SOURCE_GONE)
|
||||
root = Path(roots[index])
|
||||
path = root / relative
|
||||
denied = _media_access_error(path, [root])
|
||||
if denied is not None:
|
||||
return denied
|
||||
try:
|
||||
mtime = path.stat().st_mtime if path.is_file() else None
|
||||
except OSError:
|
||||
mtime = None
|
||||
if mtime is None:
|
||||
return _coded(404, "file-missing", "Image file not found")
|
||||
[image] = self._format_workspace_page([(path, mtime, root, index)])
|
||||
return web.json_response({"success": True, "image": image})
|
||||
|
||||
async def rescope_image(self, request) -> web.Response:
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
return error
|
||||
if set(data) != {"target"}:
|
||||
return bad_request("Send exactly one target")
|
||||
target, error = media_target(data["target"])
|
||||
if error is not None:
|
||||
return error
|
||||
old_index, scope, relative = target
|
||||
if old_index > MAX_ROOT_INDEX:
|
||||
return bad_request("Invalid gallery root")
|
||||
if _is_thumbnail_cache_path(relative):
|
||||
return _forbidden("Previews are not re-scoped; use the original")
|
||||
return await self._run_in_executor(
|
||||
self._rescope_blocking, scope, old_index, relative
|
||||
)
|
||||
@@ -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']));
|
||||
});
|
||||
@@ -118,9 +118,27 @@ export async function openConcept(page,route,theme,motion='no-preference') {
|
||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* D157 (#451): production opens Images in Masonry when the browser has no Default layout; the concept starts at Grid.
|
||||
* A comparison (and a test of the Grid's own geometry) starts production from the concept's state, a stored Default
|
||||
* layout of Grid, once per tab and only while this browser stored nothing (a test's own settings win).
|
||||
*/
|
||||
export async function startInGrid(page) {
|
||||
if (page.conceptLayoutSeeded) return;
|
||||
page.conceptLayoutSeeded = true;
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
if (!location.pathname.startsWith('/prompt_manager/') || sessionStorage.getItem('pm-d157-seeded')) return;
|
||||
sessionStorage.setItem('pm-d157-seeded', '1');
|
||||
if (localStorage.getItem('gallerySettings') === null) localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' }));
|
||||
} catch { /* A page without storage keeps the production default. */ }
|
||||
});
|
||||
}
|
||||
|
||||
/** Production at `#/<route>` with the D4 Theme select, once the sidebar's real counts (D27) have settled. */
|
||||
export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await startInGrid(page);
|
||||
await page.goto('about:blank');await page.goto(`${PRODUCTION_URL}#/${route}`);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||||
|
||||
+67
-17
@@ -28,6 +28,11 @@ import weakref
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows: no flock, and no /dev/shm to share
|
||||
fcntl = None
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Set to 1 to run seeders on the live database as before #384 (the equivalence
|
||||
@@ -43,8 +48,13 @@ MEMORY_ROOT = Path("/dev/shm")
|
||||
# the browsers and the other workers; with less free than this, the scratch
|
||||
# copy goes on disk instead.
|
||||
MEMORY_FREE_BYTES = 32 * 1024 * 1024
|
||||
# Scratch folders are named <prefix><owner pid>-<random>.
|
||||
# Scratch folders are created as <prefix>new-<owner pid>-<random>, locked, then
|
||||
# renamed to <prefix>held-<owner pid>-<random>. The pid is for people only:
|
||||
# ownership is the flock on OWNER_LOCK, which every PID namespace sees (#419).
|
||||
SCRATCH_PREFIX = "pm-reset-"
|
||||
STAGING_PREFIX = f"{SCRATCH_PREFIX}new-"
|
||||
HELD_PREFIX = f"{SCRATCH_PREFIX}held-"
|
||||
OWNER_LOCK = "owner.lock"
|
||||
|
||||
|
||||
def _quote(name: str) -> str:
|
||||
@@ -96,29 +106,40 @@ def table_digests(conn: sqlite3.Connection, schema: str = "main") -> dict[str, s
|
||||
return result
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
def _hold(folder: Path, *, create: bool) -> int:
|
||||
"""Take ``folder``'s owner lock without waiting; the open descriptor."""
|
||||
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||
fd = os.open(folder / OWNER_LOCK, flags, 0o600)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
return True # it exists but belongs to someone else
|
||||
return True
|
||||
os.close(fd)
|
||||
raise
|
||||
return fd
|
||||
|
||||
|
||||
def remove_orphaned_scratch(root: Path) -> None:
|
||||
"""Remove scratch folders of servers that died without cleaning up.
|
||||
|
||||
A server killed outright (SIGKILL, a crash) never runs ``close``, and
|
||||
tmpfs keeps its folder in memory until reboot. The folder name carries
|
||||
the owner's process id; a folder whose process is gone is removed.
|
||||
tmpfs keeps its folder in memory until reboot. A live owner holds the
|
||||
flock on the folder's owner lock; the kernel drops it when the owner
|
||||
dies, in whatever PID namespace it ran (a container with ``--ipc=host``
|
||||
shares /dev/shm but not the pids, #419). A folder whose lock is free is
|
||||
removed. Folders without a lock (staging, or named before #419) cannot
|
||||
be proven orphaned and are left alone.
|
||||
"""
|
||||
if os.name != "posix":
|
||||
return # os.kill(pid, 0) would terminate the process on Windows
|
||||
for folder in root.glob(f"{SCRATCH_PREFIX}*-*"):
|
||||
pid = folder.name[len(SCRATCH_PREFIX) :].split("-", 1)[0]
|
||||
if pid.isdigit() and not _process_alive(int(pid)):
|
||||
if fcntl is None:
|
||||
return
|
||||
for folder in root.glob(f"{HELD_PREFIX}*"):
|
||||
try:
|
||||
fd = _hold(folder, create=False)
|
||||
except OSError:
|
||||
continue # held by its owner, no lock to judge by, or gone
|
||||
try:
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class _SeedApi:
|
||||
@@ -162,6 +183,7 @@ class ScratchSeeder:
|
||||
self._fallback = Path(directory)
|
||||
self._memory_root = memory_root
|
||||
self._directory: Path | None = None
|
||||
self._owner_lock: int | None = None
|
||||
self._on_live = on_live
|
||||
self._scratch = None
|
||||
self._unsynced: "weakref.WeakSet[sqlite3.Connection]" = weakref.WeakSet()
|
||||
@@ -266,6 +288,9 @@ class ScratchSeeder:
|
||||
if self._directory is not None:
|
||||
shutil.rmtree(self._directory, ignore_errors=True)
|
||||
self._directory = None
|
||||
if self._owner_lock is not None:
|
||||
os.close(self._owner_lock) # after the removal: never a free lock
|
||||
self._owner_lock = None
|
||||
|
||||
def _scratch_directory(self) -> Path:
|
||||
"""A folder in memory (tmpfs) where there is one, else ``directory``.
|
||||
@@ -274,10 +299,35 @@ class ScratchSeeder:
|
||||
kernel is writing back the pages they rewrite.
|
||||
"""
|
||||
root = self._memory_root
|
||||
if root is not None and root.is_dir() and os.access(root, os.W_OK):
|
||||
if (
|
||||
fcntl is not None
|
||||
and root is not None
|
||||
and root.is_dir()
|
||||
and os.access(root, os.W_OK)
|
||||
):
|
||||
remove_orphaned_scratch(root)
|
||||
if shutil.disk_usage(root).free >= MEMORY_FREE_BYTES:
|
||||
prefix = f"{SCRATCH_PREFIX}{os.getpid()}-"
|
||||
return Path(tempfile.mkdtemp(prefix=prefix, dir=root))
|
||||
held = self._held_memory_directory(root)
|
||||
if held is not None:
|
||||
return held
|
||||
self._fallback.mkdir(parents=True, exist_ok=True)
|
||||
return self._fallback
|
||||
|
||||
def _held_memory_directory(self, root: Path) -> Path | None:
|
||||
"""A new folder under ``root`` that this seeder holds the lock of.
|
||||
|
||||
It is locked under the staging name, which no cleaner judges, and
|
||||
only then renamed: no cleaner ever sees it held-named and unlocked.
|
||||
"""
|
||||
staging = Path(
|
||||
tempfile.mkdtemp(prefix=f"{STAGING_PREFIX}{os.getpid()}-", dir=root)
|
||||
)
|
||||
try:
|
||||
fd = _hold(staging, create=True)
|
||||
except OSError:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
return None # no locks on this filesystem: use the disk
|
||||
held = root / (HELD_PREFIX + staging.name[len(STAGING_PREFIX) :])
|
||||
os.rename(staging, held)
|
||||
self._owner_lock = fd
|
||||
return held
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/<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);
|
||||
});
|
||||
|
||||
/** #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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -30,6 +30,9 @@ export function mapLibraryConcept(reference) {
|
||||
// A shorter toolbar moves what follows it up by as much.
|
||||
const rise=library.toolbar.height-toolbar.height;
|
||||
const cards=library.cards.map((card,index)=>({...card,
|
||||
// D148 (#437): each card leads with the concept's Images `.image-overlays` selection checkbox, placed absolutely
|
||||
// over the art, so nothing else moves; it is compared with the concept's own overlay below.
|
||||
children:['image-overlays',...card.children],
|
||||
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
|
||||
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
|
||||
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
|
||||
@@ -311,6 +314,56 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 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]]));};
|
||||
const rect=node=>node.getBoundingClientRect(),px=value=>Math.round(value*100)/100;
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','color');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginBottom','borderTopWidth','borderTopStyle',
|
||||
'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=['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')},
|
||||
label:css(label,'pointerEvents'),checkbox:{width:px(rect(input).width),height:px(rect(input).height),checked:input.checked,...css(input,'accentColor')},
|
||||
bar:{left:px(rect(bar).left-rect(root).left),...css(bar,'display','alignItems','columnGap','rowGap','flexWrap'),...edges(bar),...font(bar),
|
||||
count:{text:bar.querySelector(':scope > strong').textContent,...font(bar.querySelector(':scope > strong'))},
|
||||
buttons:[...bar.querySelectorAll(':scope > button')].filter(button=>shared.includes(button.textContent.trim()))
|
||||
.map(button=>({text:button.textContent.trim(),height:px(rect(button).height),...font(button),...edges(button)}))},
|
||||
};
|
||||
},{card,main});
|
||||
async function conceptSelection(page,theme) {
|
||||
await openConcept(page,'images',theme);
|
||||
await page.locator('#image-grid .media-card').first().locator('input[data-select]').check();
|
||||
await expect(page.locator('#bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
|
||||
return measureSelection(page,{card:'#image-grid .media-card',main:'#main'});
|
||||
}
|
||||
async function productionSelection(page,theme) {
|
||||
await openProductionLibrary(page,theme,'no-preference');
|
||||
await page.locator(CARDS).first().locator('.image-overlays input').check();
|
||||
await expect(page.locator('#library-bulkbar .bulkbar')).toBeVisible();await page.mouse.move(0,0);await settle(page);
|
||||
return measureSelection(page,{card:CARDS,main:'#workspace-main'});
|
||||
}
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library selection checkbox and bar match the concept's Images ones ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const reference=await conceptSelection(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`concept-images-selection-${width}-${theme}`);
|
||||
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 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 }'});
|
||||
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
|
||||
});
|
||||
|
||||
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
|
||||
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1800,height:1100});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import { startInGrid } from './concept-inventory.mjs';
|
||||
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
@@ -38,6 +39,9 @@ async function chooseDestination(page) {
|
||||
|
||||
test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{
|
||||
const host=await fakeHost(context,page);
|
||||
// #430: the file is never found again here, so the manual Refresh is what this covers (the found-again path is
|
||||
// workspace-stale-source.spec.mjs).
|
||||
await page.route('**/prompt_manager/images/rescope',route=>route.fulfill({status:409,json:{success:false,code:'source-changed',error:'The source of this image changed or was removed.'}}));
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});});
|
||||
try {
|
||||
const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png');
|
||||
@@ -249,6 +253,8 @@ test('responsive gallery defers observed size writes and still fits the new view
|
||||
});}
|
||||
};
|
||||
});
|
||||
// The Grid's observed sizes (D157: the default layout is Masonry).
|
||||
await startInGrid(page);
|
||||
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
|
||||
await expect(page.locator('.media-card')).toHaveCount(4);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
|
||||
@@ -46,7 +46,9 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
from tests.browser.source_change_fixture import register_source_change_commands
|
||||
from tests.browser.workspace_review_fixture import register_review_commands
|
||||
from tests.browser.workspace_collections_fixture import register_collections_commands
|
||||
from tests.browser.workspace_diagnostics_fixture import (
|
||||
register_diagnostics_commands,
|
||||
repoint_logs,
|
||||
@@ -230,8 +232,10 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
restore_sources = register_source_change_commands(routes, api)
|
||||
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
|
||||
register_review_commands(routes, api, root)
|
||||
register_collections_commands(routes, api, root)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
register_system_commands(app, routes, api, root)
|
||||
@@ -437,6 +441,7 @@ def create_app(root: Path) -> web.Application:
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
nonlocal history_fixture
|
||||
request_holds.release()
|
||||
restore_sources()
|
||||
# No preview queued by the last test may land in the new fixture.
|
||||
await api._run_in_executor(api.close_auto_thumbnails)
|
||||
library_editor_commands.reset()
|
||||
|
||||
@@ -76,13 +76,41 @@ function savedNav(nav) {
|
||||
return {...nav,rows:nav.rows.map(row=>row.text==='Needs review'?{...row,count:{...row.count,text:'0'}}:row)};
|
||||
}
|
||||
|
||||
/**
|
||||
* D136 (#331): the concept's COLLECTIONS rows (app.js a336–339) as production lists them, by name (DC1): the fixture's
|
||||
* "Landscapes" and "Experiments" hold the concept's 12 and 8 images, so text and counts compare as they are and each
|
||||
* place keeps the concept's row geometry. At <=950px the concept hides the nav, which would leave a phone no way to
|
||||
* open a collection: production shows it as the concept lays out every other sidebar nav there (its SAVED VIEWS nav),
|
||||
* its rows styled as that nav's rows. Text, widths and wrap gaps then follow the rows' own content.
|
||||
*/
|
||||
function collectionsNav(nav,navs) {
|
||||
const byName=[...nav.rows].sort((a,b)=>a.text<b.text?-1:a.text>b.text?1:0);
|
||||
if(nav.shown)return {...nav,rows:byName.map((row,index)=>({...row,gapAfter:nav.rows[index].gapAfter}))};
|
||||
const saved=navs.find(item=>item.id==='saved-nav');
|
||||
const {id,label,rows,...frame}=saved;
|
||||
return {...nav,...frame,id:nav.id,label:nav.label,rows:byName.map(row=>({...rows[0],text:row.text,
|
||||
count:row.count&&rows[0].count?{...rows[0].count,text:row.count.text}:rows[0].count,
|
||||
icon:rows[0].icon&&row.icon?{...rows[0].icon,d:row.icon.d}:row.icon,
|
||||
width:'(content)',hugs:'(content)',gapAfter:'(content)',current:null}))};
|
||||
}
|
||||
|
||||
/** The production collections nav at <=950px with its content-sized values replaced, as collectionsNav maps them. */
|
||||
function contentFree(navs) {
|
||||
return navs.map(nav=>nav.id!=='collections-nav'||!nav.shown?nav:{...nav,
|
||||
rows:nav.rows.map(row=>({...row,width:'(content)',hugs:'(content)',gapAfter:'(content)'}))});
|
||||
}
|
||||
|
||||
/** D136: where the concept hides COLLECTIONS, the production rows' content-sized values are not compared. */
|
||||
function comparableNavs(reference,navs) {
|
||||
return reference.chrome.navs.find(nav=>nav.id==='collections-nav')?.shown?navs:contentFree(navs);
|
||||
}
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||||
.filter(nav=>nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id==='collections-nav')return collectionsNav(nav,chrome.navs);
|
||||
if(nav.id==='saved-nav')return savedNav(nav);
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
@@ -99,9 +127,8 @@ function mapShellConcept(reference) {
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||||
// D49: SAVED VIEWS without its "PROPOSED" badge. D136: COLLECTIONS without it either.
|
||||
labels:chrome.labels.map(label=>label.text.endsWith(' PROPOSED')?{...label,text:label.text.replace(/ PROPOSED$/,'')}:label),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
@@ -134,8 +161,9 @@ function headingFrame(heading) {
|
||||
function compare(route,reference,actual) {
|
||||
const expected=mapShellConcept(reference);
|
||||
const heading=FULL_HEADING.has(route);
|
||||
const chrome={...actual.chrome,navs:comparableNavs(reference,actual.chrome.navs)};
|
||||
return [
|
||||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
|
||||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome,noPageScroll:actual.noPageScroll}),
|
||||
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
|
||||
.map(item=>({...item,path:`heading.${item.path}`})),
|
||||
];
|
||||
@@ -242,7 +270,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
|
||||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||||
await openProduction(page,'images','dark');
|
||||
const actual=await save('Save','#workspace-main');
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,comparableNavs(reference,actual.chrome.navs))).toEqual([]);
|
||||
const referenceRows=reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows;
|
||||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||||
.toEqual([['Favorites',referenceRows[0].count],['Needs review',{...referenceRows[1].count,text:'0'}],['Golden hour',null]]);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Source changes under an open workspace page (#430), undone by every reset.
|
||||
|
||||
- ``POST /__test__/sources/reverse``: the image sources are listed in reverse
|
||||
order, as System › Sources would renumber them, so every held
|
||||
``root_scope`` names another index and the page must re-scope its file.
|
||||
- ``POST /__test__/scope-key/rotate``: the API's scope and cursor keys
|
||||
change, as when ``workspace-scope.key`` is lost before a restart: a held
|
||||
scope then cannot be mapped and the page offers Refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from utils.workspace_keys import WorkspaceKeys
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
_CACHE = "_workspace_keys_cache"
|
||||
|
||||
|
||||
def register_source_change_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI
|
||||
) -> Callable[[], None]:
|
||||
"""Register the commands; returns the restore every reset runs."""
|
||||
# Imported here: py.config needs the host stubs create_app installs first.
|
||||
from py.config import GalleryConfig
|
||||
|
||||
saved: dict = {}
|
||||
|
||||
def restore() -> None:
|
||||
if "roots" in saved:
|
||||
GalleryConfig.MONITORING_DIRECTORIES = saved.pop("roots")
|
||||
api.invalidate_gallery_cache(retire=True)
|
||||
if "keys" in saved:
|
||||
keys = saved.pop("keys")
|
||||
if keys is None:
|
||||
api.__dict__.pop(_CACHE, None)
|
||||
else:
|
||||
api.__dict__[_CACHE] = keys
|
||||
|
||||
@routes.post("/__test__/sources/reverse")
|
||||
async def reverse(request: web.Request) -> web.Response:
|
||||
if request.query or request.can_read_body:
|
||||
raise web.HTTPBadRequest()
|
||||
roots = list(GalleryConfig.MONITORING_DIRECTORIES)
|
||||
saved.setdefault("roots", roots)
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots[::-1]
|
||||
await api._run_in_executor(api.invalidate_gallery_cache, True)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/scope-key/rotate")
|
||||
async def rotate(request: web.Request) -> web.Response:
|
||||
if request.query or request.can_read_body:
|
||||
raise web.HTTPBadRequest()
|
||||
saved.setdefault("keys", api.__dict__.get(_CACHE))
|
||||
api.__dict__[_CACHE] = WorkspaceKeys(
|
||||
secrets.token_bytes(32), secrets.token_bytes(32), False
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
return restore
|
||||
@@ -231,6 +231,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
|
||||
const status = () => tagDialog(page).locator('#tag-generation-status');
|
||||
// D147: a state that blocks Save shows its reason beside the actions, not in the status line.
|
||||
const notice = () => tagDialog(page).locator('.dialog-actions > .tag-action-notice');
|
||||
|
||||
// Unavailable model.
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
@@ -269,7 +271,8 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await expect(notice()).toHaveText('The file changed since it was previewed. Reopen the Tag dialog.');
|
||||
await expect(status()).toHaveText('');
|
||||
await shot('production-file-changed');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
@@ -278,7 +281,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
await expect(notice()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
await shot('production-unknown-outcome');
|
||||
|
||||
@@ -52,3 +52,43 @@ export async function unseenRestore(page, restoring) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
|
||||
// A restore the open page does see run and finish (#415), driven by the test instead of the 15 s poll's timing. The
|
||||
// job is shaped as the server's (py/api/backup_restore.py: kind `restore`, one `errors` group, not cancellable).
|
||||
const RESTORE_JOB_ID = '415415415415415415415415415415ab';
|
||||
const restoreJob = state => ({ id: RESTORE_JOB_ID, kind: 'restore', title: 'Database restore', state, started_at: 1,
|
||||
finished_at: state === 'running' ? null : 2, progress: 0, status_line: state === 'running' ? '' : 'Restored the test snapshot.',
|
||||
processed: 0, total: 0, counters: { errors: 0 }, scope: {}, listed: { errors: 0 }, log_length: 0, cancellable: false });
|
||||
/** The Activity pill's link while the page follows that restore. */
|
||||
export const SEEN_RESTORE_PILL = `#/activity/${RESTORE_JOB_ID}`;
|
||||
/** The toast the page shows when it sees that restore finish (activity-kinds.mjs `restore.finishToast`). */
|
||||
export const SEEN_RESTORE_TOAST = 'Database restore finished · Restored the test snapshot.';
|
||||
|
||||
/**
|
||||
* Call before the page's first navigation, after `armUnseenRestores(page)` (a later route wins). The page's first
|
||||
* job-list read lists a restore job running, so its Activity store follows it; its job stream is refused, so the store
|
||||
* reads the list again, and every later read is held until `finish()`, which answers them with the job finished and
|
||||
* resolves once one of those answers reached the page. The real restore the test runs meanwhile goes through the API.
|
||||
*/
|
||||
export async function followRestore(page) {
|
||||
let release;
|
||||
const released = new Promise(resolve => { release = resolve; });
|
||||
let first = true;
|
||||
await page.route(`${JOBS}/*/events`, route => route.abort().catch(() => {}));
|
||||
await page.route(JOBS, async route => {
|
||||
if (first) {
|
||||
first = false;
|
||||
return route.fulfill({ json: { success: true, jobs: [restoreJob('running')] } }).catch(() => {});
|
||||
}
|
||||
await released;
|
||||
// A read the page has aborted since (a newer read supersedes it) can no longer be answered.
|
||||
return route.fulfill({ json: { success: true, jobs: [restoreJob('finished')] } }).catch(() => {});
|
||||
});
|
||||
return {
|
||||
async finish() {
|
||||
const answered = page.waitForResponse(response => new URL(response.url()).pathname === '/prompt_manager/jobs');
|
||||
release();
|
||||
await answered;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
// Collections (#331): the sidebar COLLECTIONS group (D136, concept index.html 28–31, app.js 336–339), the collection
|
||||
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/collections';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const breadcrumb = page => page.locator('#workspace-breadcrumb');
|
||||
const description = page => main(page).locator('.heading p.muted');
|
||||
const missingNote = page => main(page).locator('.heading p.small');
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const imageCards = page => page.locator('article[data-image-key]');
|
||||
const collectionsNav = page => page.getByRole('navigation', { name: 'Collections', exact: true });
|
||||
const collectionsLabel = page => page.locator('aside.sidebar .sidebar-label', { hasText: 'COLLECTIONS' });
|
||||
const rows = page => collectionsNav(page).locator('a[data-route^="collection/"]');
|
||||
const row = (page, name) => rows(page).filter({ hasText: name });
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
const editButton = page => main(page).locator('.heading-actions > button').first();
|
||||
const editDialog = page => page.getByRole('dialog', { name: 'Edit collection', exact: true });
|
||||
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete collection', exact: true });
|
||||
const nameField = page => editDialog(page).getByRole('textbox', { name: 'Collection name', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
|
||||
async function seed(request) {
|
||||
const response = await request.post('/__test__/collections/seed');
|
||||
await expect(response).toBeOK();
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get(API);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).collections;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.collectionErrors = [];
|
||||
page.on('pageerror', error => page.collectionErrors.push(error.message));
|
||||
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
|
||||
page.on('dialog', dialog => { page.collectionErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.collectionErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('a collection row opens its page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
// D136: name order, the listed-member count, no "PROPOSED" badge.
|
||||
await expect(collectionsLabel(page)).toBeVisible();
|
||||
await expect(collectionsLabel(page)).toHaveText('COLLECTIONS');
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(rows(page).nth(0)).toContainText('Experiments');
|
||||
await expect(rows(page).nth(1)).toContainText('Landscapes');
|
||||
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
|
||||
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('12');
|
||||
await expect(row(page, 'Landscapes')).toHaveAttribute('href', `#/collection/${id}`);
|
||||
await expect(row(page, 'Landscapes').locator('svg')).toHaveCount(1);
|
||||
await expect(page.locator('aside.sidebar .new-label')).toHaveCount(0);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(page).toHaveTitle('Collection · Prompt Manager');
|
||||
await expect(row(page, 'Landscapes')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(description(page)).toHaveText('Your generations, their prompts, and the next idea.');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
for (const path of manifest.files.Landscapes) {
|
||||
await expect(page.locator(`article[data-image-key$=":${path}"]`)).toHaveCount(1);
|
||||
}
|
||||
// MC3: the member whose file is gone is not listed and is counted in the note.
|
||||
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
|
||||
|
||||
// Every Images filter narrows the members.
|
||||
await search(page).fill('landscape_03');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await search(page).fill('');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('without collections the group shows no label and no rows', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(collectionsNav(page)).toHaveCount(1);
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
await expect(collectionsLabel(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test('a collection page neither restores nor replaces the Images return state', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('experiment');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(search(page)).toHaveValue('');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
await search(page).fill('landscape_01');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(search(page)).toHaveValue('experiment');
|
||||
expect(manifest.collections.Landscapes.count).toBe(12);
|
||||
});
|
||||
|
||||
test('Edit renames the open collection; a taken name stays in the dialog', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(editButton(page)).toHaveText('Edit');
|
||||
await editButton(page).click();
|
||||
await expect(nameField(page)).toHaveValue('Landscapes');
|
||||
await expect(editDialog(page).locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
|
||||
|
||||
// CUJ 3: names are unique ignoring case.
|
||||
await nameField(page).fill('experiments');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(editDialog(page).getByRole('alert')).toHaveText('A collection named “experiments” already exists.');
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
|
||||
await nameField(page).fill(' Landscape grids ');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Collection renamed.');
|
||||
await expect(heading(page)).toHaveText('Landscape grids');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(row(page, 'Landscape grids')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(rows(page).nth(1)).toContainText('Landscape grids');
|
||||
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments', 'Landscape grids']);
|
||||
});
|
||||
|
||||
test('a failed rename keeps the dialog; a failed delete changes nothing', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
let failing = true;
|
||||
await page.route(`**${API}/${id}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"The collection could not be changed."}' })
|
||||
: route.fallback());
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill('Tests');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The collection could not be renamed. Try again.');
|
||||
await expect(editDialog(page)).toBeVisible();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await expect(deleteDialog(page).locator('p')).toHaveText(
|
||||
'Delete “Experiments”? Its 8 images stay where they are; only the collection is removed.');
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The collection could not be deleted. Try again.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
expect(await listed(request)).toHaveLength(2);
|
||||
failing = false;
|
||||
});
|
||||
|
||||
test('Delete removes the collection only after confirming, and leaves every file', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await editButton(page).click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
// The stored count: 12 listed members and the missing one.
|
||||
await expect(deleteDialog(page).locator('p')).toHaveText(
|
||||
'Delete “Landscapes”? Its 13 images stay where they are; only the collection is removed.');
|
||||
await deleteDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(deleteDialog(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toHaveLength(2);
|
||||
|
||||
await editButton(page).click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collection deleted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments']);
|
||||
});
|
||||
|
||||
test('an unknown collection says it no longer exists, with the way back to Images', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/collection/999999`);
|
||||
await expect(heading(page)).toHaveText('Collection');
|
||||
await expect(breadcrumb(page)).toHaveText('Collection');
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
await main(page).getByRole('link', { name: 'Open Images', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
});
|
||||
|
||||
test('a failed list says so and Retry reads it again', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(description(page)).toHaveText('Collections could not be loaded. Your saved data has not changed.');
|
||||
await expect(description(page)).toHaveAttribute('role', 'alert');
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
failing = false;
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a saved view keeps its collection, and says so once the collection is deleted (MC6)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const view = manifest.saved_view;
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await savedNav(page).locator(`a[data-route="saved/${view.id}"]`).click();
|
||||
await expect(heading(page)).toHaveText(view.name);
|
||||
await expect(breadcrumb(page)).toHaveText(view.name);
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
// A saved view is not the collection page: no missing note.
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
|
||||
// Save view on a collection page stores the collection.
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
await search(page).fill('landscape_1');
|
||||
await expect(imageCards(page)).toHaveCount(3);
|
||||
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Grid seed');
|
||||
await save.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const views = (await (await request.get('/prompt_manager/saved-views')).json()).views;
|
||||
const saved = views.find(item => item.name === 'Grid seed');
|
||||
expect(saved.state).toMatchObject({ q: 'landscape_1', favorites: false, collection: String(manifest.collections.Landscapes.id) });
|
||||
|
||||
await expect(await request.delete(`${API}/${manifest.collections.Experiments.id}`)).toBeOK();
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText(view.name);
|
||||
await expect(main(page).locator('.empty')).toHaveText('The collection this view uses was deleted.');
|
||||
await expect(main(page).locator('.empty')).toBeVisible();
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
|
||||
});
|
||||
|
||||
test('a collection deleted in another tab shows the deleted-collection page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(await request.delete(`${API}/${id}`)).toBeOK();
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
// D136 / CUJ 15: the rows show and open at desktop and phone width in both themes. The concept's own phone rule hides
|
||||
// every nav count there, so the rows keep their counts only at desktop width.
|
||||
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
|
||||
test(`collection rows show and open at ${width}x${height} ${theme}`, async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
for (const name of ['Experiments', 'Landscapes']) await expect(row(page, name)).toBeVisible();
|
||||
const count = row(page, 'Landscapes').locator('.nav-count');
|
||||
if (width > 950) await expect(count).toBeVisible();
|
||||
else await expect(count).toBeHidden();
|
||||
// Phone width: the label stays hidden as every sidebar label is; desktop shows it.
|
||||
if (width > 950) await expect(collectionsLabel(page)).toBeVisible();
|
||||
else await expect(collectionsLabel(page)).toBeHidden();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await row(page, 'Landscapes').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${manifest.collections.Landscapes.id}$`));
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
});
|
||||
}
|
||||
|
||||
test('a finished restore reads the collections again', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
|
||||
try {
|
||||
const backup = await request.get('/prompt_manager/backup');
|
||||
await expect(backup).toBeOK();
|
||||
const bytes = await backup.body();
|
||||
const [experiments] = await listed(request);
|
||||
await expect(await request.delete(`${API}/${experiments.id}`)).toBeOK();
|
||||
await page.goto(`${workspace}#/system/backups`);
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
const chooser = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
|
||||
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
|
||||
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(/^Database restore finished · /);
|
||||
// The list was read again without a reload.
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
|
||||
} finally {
|
||||
await request.post('/__test__/jobs/restart');
|
||||
await request.post('/__test__/system/reset');
|
||||
}
|
||||
});
|
||||
|
||||
test('renaming a collection deleted in another tab says so and shows the deleted-collection page', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(heading(page)).toHaveText('Experiments');
|
||||
await editButton(page).click();
|
||||
await expect(await request.delete(`${API}/${id}`)).toBeOK();
|
||||
await nameField(page).fill('Tests');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('That collection no longer exists.');
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */
|
||||
async function holdListReads(page, answer = route => route.fallback()) {
|
||||
const held = [];
|
||||
let open = false;
|
||||
await page.route(`**${API}`, route => {
|
||||
if (route.request().method() !== 'GET') return route.fallback();
|
||||
if (open) return answer(route);
|
||||
held.push(route);
|
||||
return undefined;
|
||||
});
|
||||
return {
|
||||
held,
|
||||
async release() {
|
||||
open = true;
|
||||
for (const route of held.splice(0)) await answer(route).catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json();
|
||||
const [file] = listing.images;
|
||||
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
|
||||
await expect(heading(page)).toHaveText('Landscapes');
|
||||
// Another tab makes "Fresh"; this tab does not list it yet.
|
||||
const added = await request.post(`${API}/items`, { data: { name: 'Fresh',
|
||||
items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } });
|
||||
await expect(added).toBeOK();
|
||||
const fresh = (await added.json()).collection.id;
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
const hold = await holdListReads(page);
|
||||
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
|
||||
// supersedes that one while both are held.
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill('Landscape grids');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collection renamed.');
|
||||
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
|
||||
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
|
||||
await expect(main(page)).not.toContainText('Collections could not be loaded.');
|
||||
await hold.release();
|
||||
await expect(heading(page)).toHaveText('Fresh');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Experiments;
|
||||
let gone = true;
|
||||
await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`)
|
||||
? route.fulfill({ status: 404, contentType: 'application/json',
|
||||
body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
// The shell's re-read is held, then fails: the page must not stay blank.
|
||||
const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: '{"success":false,"error":"Collections could not be read."}' }));
|
||||
await row(page, 'Experiments').click();
|
||||
await expect.poll(() => hold.held.length).toBe(1);
|
||||
await hold.release();
|
||||
const retry = main(page).getByRole('button', { name: 'Retry', exact: true });
|
||||
await expect(retry).toBeVisible();
|
||||
await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.');
|
||||
gone = false;
|
||||
await page.unroute(`**${API}`);
|
||||
await retry.click();
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
|
||||
// The list read again still lists it: the same Retry, never a blank page.
|
||||
gone = true;
|
||||
await page.evaluate(() => { window.location.hash = '#/images'; });
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await row(page, 'Experiments').click();
|
||||
await expect(retry).toBeVisible();
|
||||
gone = false;
|
||||
await retry.click();
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
});
|
||||
|
||||
test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { id } = manifest.collections.Landscapes;
|
||||
let missing = null;
|
||||
await page.route(`**${API}`, async route => {
|
||||
if (route.request().method() !== 'GET' || missing === null) return route.fallback();
|
||||
const response = await route.fetch();
|
||||
const data = await response.json();
|
||||
data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item);
|
||||
return route.fulfill({ response, json: data });
|
||||
});
|
||||
await page.goto(`${workspace}#/collection/${id}`);
|
||||
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
|
||||
const rename = async name => {
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill(name);
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collection renamed.');
|
||||
};
|
||||
missing = 3;
|
||||
await rename('Landscapes A');
|
||||
await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.');
|
||||
missing = 0;
|
||||
await rename('Landscapes B');
|
||||
await expect(missingNote(page)).toHaveCount(0);
|
||||
missing = 1;
|
||||
await rename('Landscapes C');
|
||||
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
|
||||
});
|
||||
|
||||
test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', 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);
|
||||
let release;
|
||||
const released = new Promise(resolve => { release = resolve; });
|
||||
await page.route(`**${API}/${id}`, async route => {
|
||||
if (route.request().method() !== 'PATCH') return route.fallback();
|
||||
await released;
|
||||
return route.fallback();
|
||||
});
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill('Later');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(editDialog(page)).toHaveCount(0);
|
||||
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
await expect(save).toBeVisible();
|
||||
release();
|
||||
await expect(heading(page)).toHaveText('Later');
|
||||
await expect(save).toBeVisible();
|
||||
});
|
||||
|
||||
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
|
||||
await expect(description(page)).toHaveText('This collection no longer exists.');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
|
||||
await page.route(`**${API}`, route => route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
|
||||
: route.fallback());
|
||||
await page.evaluate(() => { window.location.hash = '#/images'; });
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await page.evaluate(() => { window.location.hash = '#/collection/999998'; });
|
||||
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible();
|
||||
await expect(heading(page)).toBeFocused();
|
||||
|
||||
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
|
||||
: route.fallback());
|
||||
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
|
||||
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);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||
import { seedConcept, openConcept, openProduction, startInGrid } from './concept-inventory.mjs';
|
||||
|
||||
const workspace = '/prompt_manager/workspace#/images';
|
||||
const cards = page => page.locator('article[data-image-key]');
|
||||
@@ -569,6 +569,10 @@ test('source and folder discovery failure stays visible and Import Rescan restor
|
||||
async function staleSourceRecovery(page, request, roots) {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 80 } })).toBeOK();
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
// #430: the page first asks the server where a stale file is now. These tests cover the manual Refresh, so the
|
||||
// file is never found; workspace-stale-source.spec.mjs covers a renumbered file that is found again.
|
||||
await page.route('**/prompt_manager/images/rescope', route => route.fulfill({ status: 409,
|
||||
json: { success: false, code: 'source-changed', error: 'The source of this image changed or was removed.' } }));
|
||||
await page.goto(workspace);
|
||||
const source = refineDialog(page).getByLabel('Source', { exact: true });
|
||||
const folder = refineDialog(page).getByLabel('Folder', { exact: true });
|
||||
@@ -810,6 +814,8 @@ test('leaving during Rescan cancels it without replacing the next workspace', as
|
||||
|
||||
test('Images uses approved geometry while keeping real controls', async ({page,request}) => {
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
// The approved Grid geometry (D157: the default layout is Masonry).
|
||||
await startInGrid(page);
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(24);
|
||||
@@ -1787,7 +1793,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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
@@ -454,6 +454,37 @@ async function leaveAndReturn(page,id) {
|
||||
}
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
|
||||
|
||||
// #415: a restore the page sees finish while New prompt is open keeps the dialog and its entries; the Library is shown
|
||||
// again from the restored data only once the dialog has closed.
|
||||
test('a restore seen finishing keeps New prompt open and reloads the Library after it closes',async({page,request})=>{
|
||||
const f=page.fixture;const snapshot=await request.get('/__test__/library-editor/snapshot');await expect(snapshot).toBeOK();
|
||||
const buffer=await snapshot.body();
|
||||
const current=await detail(request,f.positive_id,'active');
|
||||
await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{title:'Retitled elsewhere'}}})).toBeOK();
|
||||
const restoreRun=await followRestore(page);
|
||||
await page.goto(`${workspace}#/library`);const card=page.locator(`article[data-prompt-id="${f.positive_id}"]`);
|
||||
await expect(card).toContainText('Retitled elsewhere');
|
||||
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
|
||||
await headingActions(page).getByRole('button',{name:'+ New prompt',exact:true}).click();
|
||||
const dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await field(dialog,'Title').fill('Typed before restore');await field(dialog,'Positive prompt').fill('A prompt typed while storage was restored');
|
||||
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer}}})).toBeOK();
|
||||
page.writes.length=0;
|
||||
await restoreRun.finish();
|
||||
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(field(dialog,'Title')).toHaveValue('Typed before restore');
|
||||
await expect(field(dialog,'Positive prompt')).toHaveValue('A prompt typed while storage was restored');
|
||||
await expect(card).toContainText('Retitled elsewhere');
|
||||
await button(dialog,'Save').click();
|
||||
await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.');
|
||||
await expect(button(dialog,'Save')).toBeDisabled();expect(page.writes).toHaveLength(1);
|
||||
await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(card).not.toContainText('Retitled elsewhere');await expect(card).toContainText('Editor portrait');
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
|
||||
// #437 D148: select prompts on the Library shelf and set their category in one step (the legacy admin's Set Category).
|
||||
const route='/prompt_manager/workspace#/library';
|
||||
const cards=page=>page.locator('article[data-prompt-id]');
|
||||
const card=(page,id)=>page.locator(`article[data-prompt-id="${id}"]`);
|
||||
const box=(page,id)=>card(page,id).locator('.image-overlays input[type="checkbox"]');
|
||||
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();
|
||||
return body.items;
|
||||
}
|
||||
/** Every POST to the bulk category route, with its body. */
|
||||
function recordWrites(page) {
|
||||
const writes=[];
|
||||
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/category')writes.push(item.postDataJSON());});
|
||||
return writes;
|
||||
}
|
||||
/** The first mounted card's index once two frames in a row agree (the shelf's window follows scrolling a frame later). */
|
||||
async function stableFirstIndex(page) {
|
||||
return page.evaluate(()=>new Promise(resolve=>{
|
||||
const first=()=>document.querySelector('article[data-prompt-id]')?.dataset.index;
|
||||
let previous=first();
|
||||
const check=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>{const now=first();if(now===previous)resolve(Number(now));else{previous=now;check();}}));
|
||||
check();
|
||||
}));
|
||||
}
|
||||
async function setCategory(page,value) {
|
||||
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
await dialog(page).locator('#action-input').fill(value);
|
||||
await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK();
|
||||
page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
|
||||
|
||||
test('Set category writes the selected prompts on the server, reloads the shelf and clears the selection',async({page,request})=>{
|
||||
const items=await shelf(request);const [first,second,third]=items;
|
||||
const writes=recordWrites(page);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
// The checkbox is named after the card's title, as the Images overlay names its file.
|
||||
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');
|
||||
// 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);
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
for(const item of [first,second]) await expect(card(page,item.id).locator('.card-title .chip')).toHaveText('Studio set');
|
||||
await expect(card(page,third.id).locator('.card-title .chip')).toHaveText(third.category || 'Uncategorized');
|
||||
await expect(box(page,first.id)).not.toBeChecked();
|
||||
const after=await shelf(request);
|
||||
expect(after.filter(item=>item.category==='Studio set').map(item=>item.id).sort()).toEqual([first.id,second.id].sort());
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
expect(writes).toEqual([{prompt_ids:[first.id,second.id],category:'Studio set',expected_epoch:epoch}]);
|
||||
// The toolbar's category list now offers the new value, and so does the next dialog.
|
||||
await expect(page.locator('#category-filter option',{hasText:'Studio set'})).toHaveCount(1);
|
||||
await box(page,third.id).check();
|
||||
await bar(page).getByRole('button',{name:'Set category',exact:true}).click();
|
||||
await expect(dialog(page).locator('datalist option[value="Studio set"]')).toHaveCount(1);
|
||||
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a blank or control-character category keeps the dialog open; Clear and Escape clear the selection',async({page,request})=>{
|
||||
const items=await shelf(request);const writes=recordWrites(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:'Set category',exact:true}).click();
|
||||
const field=dialog(page).locator('#action-input');
|
||||
await field.fill(' ');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
||||
await expect(dialog(page)).toBeVisible();await expect(field).toBeFocused();
|
||||
await field.fill('bad\tvalue');await dialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect(await field.evaluate(node=>node.validationMessage)).toBe('A category cannot contain control characters.');
|
||||
await dialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(writes).toHaveLength(0);
|
||||
await bar(page).getByRole('button',{name:'Clear',exact:true}).click();
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
|
||||
await expect(box(page,items[0].id)).not.toBeChecked();
|
||||
await box(page,items[1].id).check();
|
||||
await bar(page).getByRole('button',{name:'Set category',exact:true}).focus();await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(heading(page)).toBeFocused();
|
||||
// A filter change replaces the shelf and clears the selection too.
|
||||
await box(page,items[1].id).check();await expect(bar(page)).toBeVisible();
|
||||
await page.locator('#search').fill('portrait');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
});
|
||||
|
||||
test('Shift+click selects a range across windowed cards, Tab reaches the checkbox at the window edge, and a return keeps the fence',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=recordWrites(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 firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
|
||||
await box(page,firstId).check();
|
||||
// Windowed: fewer cards are mounted than are loaded.
|
||||
expect(await cards(page).count()).toBeLessThan(160);
|
||||
// Tab from the last mounted card's last control lands on the next (unmounted) card's checkbox.
|
||||
const last=cards(page).last();const lastIndex=Number(await last.getAttribute('data-index'));
|
||||
await last.locator('button').last().focus();await page.keyboard.press('Tab');
|
||||
const next=page.locator(`article[data-index="${lastIndex+1}"]`);
|
||||
await expect(next.locator('.image-overlays input')).toBeFocused();
|
||||
// Shift+click that card's checkbox: every card from the first to it is selected, mounted or not.
|
||||
await next.locator('.image-overlays > label').click({modifiers:['Shift']});
|
||||
await expect(bar(page).locator('strong')).toHaveText(`${lastIndex+2} selected`);
|
||||
// Shift+Tab from a checkbox whose previous card is unmounted lands on that card's last control.
|
||||
// Halfway down the loaded cards (short of the continuation, so no page loads), once the window stops moving.
|
||||
await page.evaluate(()=>{const grid=document.querySelector('.library-grid');scrollTo(0,grid.getBoundingClientRect().top+scrollY+grid.offsetHeight/2);});
|
||||
await expect.poll(()=>page.locator('article[data-index="0"]').count()).toBe(0);
|
||||
const topIndex=await stableFirstIndex(page);
|
||||
await page.locator(`article[data-index="${topIndex}"] .image-overlays input`).focus();await page.keyboard.press('Shift+Tab');
|
||||
await expect(page.locator(`article[data-index="${topIndex-1}"] .card-footer .row-actions > button`).last()).toBeFocused();
|
||||
// Leaving for a prompt's history and coming back restores the shelf without a read; its epoch still fences.
|
||||
await page.locator(`article[data-index="${topIndex}"]`).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(cards(page).first()).toBeVisible();await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
const visible=Number(await cards(page).first().getAttribute('data-prompt-id'));
|
||||
await box(page,visible).check();await setCategory(page,'Returned');
|
||||
await expect(toast(page)).toHaveText('Category “Returned” set on 1 prompt.');
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
expect(writes).toEqual([{prompt_ids:[visible],category:'Returned',expected_epoch:epoch}]);
|
||||
});
|
||||
|
||||
test('a restore the page did not see refuses the write, changes nothing and reloads the shelf',async({page,request})=>{
|
||||
await armUnseenRestores(page);
|
||||
const items=await shelf(request);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
||||
await box(page,items[0].id).check();
|
||||
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();
|
||||
const reload=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor');});
|
||||
await setCategory(page,'After restore');
|
||||
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No category was changed.');
|
||||
await reload;await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
expect((await shelf(request)).filter(item=>item.category==='After restore')).toEqual([]);
|
||||
// The reloaded shelf reads the new epoch: the next write goes through.
|
||||
await box(page,items[0].id).check();await setCategory(page,'After reload');
|
||||
await expect(toast(page)).toHaveText('Category “After reload” set on 1 prompt.');
|
||||
});
|
||||
|
||||
test('an answer that may follow a committed write says so and reloads; a refused write keeps the selection',async({page,request})=>{
|
||||
const items=await shelf(request);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
||||
await box(page,items[0].id).check();
|
||||
await page.route('**/prompt_manager/bulk/category',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1});
|
||||
await setCategory(page,'Refused');
|
||||
await expect(toast(page)).toHaveText('Category could not be set. Try again.');
|
||||
await expect(bar(page).locator('strong')).toHaveText('1 selected');
|
||||
let sent=false;
|
||||
await page.route('**/prompt_manager/bulk/category',async route=>{sent=true;await route.fetch();await route.abort('failed');},{times:1});
|
||||
await setCategory(page,'Unconfirmed');
|
||||
await expect(toast(page)).toHaveText('The category change could not be confirmed. The Library was reloaded.');
|
||||
expect(sent).toBe(true);
|
||||
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);
|
||||
});
|
||||
@@ -618,7 +618,8 @@ test('card matches the concept and never wraps the category mid-word',async({pag
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
|
||||
await expect(card).toHaveClass('prompt-card');
|
||||
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
|
||||
// D148 (#437): the concept's Images selection overlay leads the card.
|
||||
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['image-overlays','card-art','card-content','card-footer']);
|
||||
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
|
||||
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
|
||||
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
|
||||
|
||||
@@ -23,7 +23,7 @@ const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
|
||||
|
||||
async function refine(page, values) {
|
||||
await filtersButton(page).click();
|
||||
@@ -73,7 +73,8 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
// D136: the COLLECTIONS label stays hidden while no collection is listed.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label:not([hidden])')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// #430: an open workspace page whose sources changed under it. A ComfyUI restart no longer changes any scope (the
|
||||
// key is persisted, see tests/test_api_workspace_restart.py); what is left is a real source change. Renumbered
|
||||
// sources (`/__test__/sources/reverse`) are found again: the Tag image dialog stays open on its file and works, and
|
||||
// the panel keeps the file. A scope that cannot be mapped (`/__test__/scope-key/rotate`, a lost key) blocks Generate
|
||||
// and Save visibly, says why beside them and offers Refresh. Both commands are undone by every reset.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png';
|
||||
const SOURCE_CHANGED = 'The source changed. Refresh the image collection before tagging this file.';
|
||||
const card = (page, path, root = 0) => page.locator(`article[data-image-key="${root}:${path}"]`);
|
||||
const cards = page => page.locator('.gallery-body article[data-image-key]');
|
||||
const tagButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
|
||||
const infoButton = (page, path, root = 0) => card(page, path, root).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const primary = page => dialog(page).locator('.dialog-actions .ca-primary');
|
||||
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const answered = (page, suffix, method = 'POST') => page.waitForResponse(item => new URL(item.url()).pathname.endsWith(suffix) && item.request().method() === method);
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
page.staleErrors = [];
|
||||
page.on('pageerror', error => page.staleErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, request, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.staleErrors).toEqual([]);
|
||||
// The next spec file starts on the standard sources and key.
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
});
|
||||
|
||||
async function openTag(page, path = FIRST, root = 0) {
|
||||
const observed = answered(page, '/image-annotations/observe');
|
||||
await tagButton(page, path, root).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
return observed;
|
||||
}
|
||||
async function savedTags(request, path, root) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const item = listing.images.find(image => image.path === path && image.root_index === root);
|
||||
const target = { root_index: root, root_scope: item.root_scope, path };
|
||||
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
|
||||
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
|
||||
return observed.tags;
|
||||
}
|
||||
|
||||
test('renumbered sources under an open Tag dialog: it finds the file again, keeps the draft and Generate works', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
expect((await openTag(page)).status()).toBe(200);
|
||||
await manual(page).fill('kept_draft');
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
|
||||
// Generate is refused (the held scope names another index now); the page asks where the file is and retargets.
|
||||
const refused = answered(page, '/image-annotations/suggest');
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
const observedAgain = answered(page, '/image-annotations/observe');
|
||||
await generate(page).click();
|
||||
expect((await refused).status()).toBe(409);
|
||||
const mapped = await (await rescoped).json();
|
||||
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
|
||||
const observed = await observedAgain;
|
||||
expect(observed.status()).toBe(200);
|
||||
expect(observed.request().postDataJSON().target).toEqual({ root_index: 1, root_scope: mapped.image.root_scope, path: FIRST });
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
await expect(notice(page)).toHaveCount(0);
|
||||
await expect(manual(page)).toHaveValue('kept_draft');
|
||||
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
|
||||
|
||||
// The page listed again under the new numbering and kept the file selected.
|
||||
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
|
||||
await expect(card(page, FIRST, 0)).toHaveCount(0);
|
||||
|
||||
const suggested = answered(page, '/image-annotations/suggest');
|
||||
await generate(page).click();
|
||||
expect((await suggested).status()).toBe(200);
|
||||
await expect(chips(page).first()).toBeVisible();
|
||||
const saved = answered(page, '/image-annotations/tags', 'PUT');
|
||||
await primary(page).click();
|
||||
expect((await saved).request().postDataJSON().target.root_index).toBe(1);
|
||||
await expect(toast(page)).toContainText('tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST, 1)).toContain('kept_draft');
|
||||
});
|
||||
|
||||
test('the inspector finds a renumbered file again; checked files from the old numbering are cleared', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await card(page, FIRST).getByRole('checkbox', { name: /^Select / }).check();
|
||||
await expect(page.locator('.bulkbar')).toBeVisible();
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
|
||||
const refused = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
|
||||
new URL(item.url()).searchParams.get('root') === '0');
|
||||
const reread = page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${SECOND}` &&
|
||||
new URL(item.url()).searchParams.get('root') === '1');
|
||||
await infoButton(page, SECOND).click();
|
||||
expect((await refused).status()).toBe(409);
|
||||
expect((await reread).status()).toBe(200);
|
||||
await expect(card(page, SECOND, 1)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page)).not.toContainText('The source changed');
|
||||
await expect(panel(page).getByRole('button', { name: 'Refresh image collection', exact: true })).toHaveCount(0);
|
||||
// A checked key of the old list could name a same-named file in another source: nothing stays checked.
|
||||
await expect(page.locator('.bulkbar')).toBeHidden();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a scope that cannot be mapped: Generate and Save are visibly disabled, the reason and Refresh sit beside them', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
expect((await openTag(page)).status()).toBe(409);
|
||||
expect((await rescoped).status()).toBe(409);
|
||||
|
||||
await expect(notice(page)).toContainText(SOURCE_CHANGED);
|
||||
await expect(status(page)).toHaveText('');
|
||||
const refresh = notice(page).getByRole('button', { name: 'Refresh image collection', exact: true });
|
||||
await expect(refresh).toBeVisible();
|
||||
for (const button of [generate(page), primary(page)]) {
|
||||
await expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(button).toHaveCSS('opacity', '0.55');
|
||||
}
|
||||
// The notice is its own first row of the sticky actions, above the buttons.
|
||||
const rows = await dialog(page).locator('.dialog-actions').evaluate(actions => {
|
||||
const noticeBox = actions.firstElementChild.getBoundingClientRect();
|
||||
return { first: actions.firstElementChild.className, below: [...actions.querySelectorAll(':scope > button')].every(button => button.getBoundingClientRect().top >= noticeBox.bottom - 1) };
|
||||
});
|
||||
expect(rows).toEqual({ first: 'tag-action-notice', below: true });
|
||||
const preview = dialog(page).locator('.tag-image-preview');
|
||||
await expect(preview).toHaveText('Preview unavailable');
|
||||
await expect(preview).toHaveAttribute('role', 'img');
|
||||
const suggest = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggest.push(item.url()); });
|
||||
// Pressed anyway (Playwright refuses an aria-disabled target, so the press is dispatched), nothing is sent.
|
||||
await generate(page).dispatchEvent('click');
|
||||
await primary(page).dispatchEvent('click');
|
||||
expect(suggest).toEqual([]);
|
||||
await page.screenshot({ path: test.info().outputPath('stale-source-tag-dialog.png') });
|
||||
|
||||
// Refresh closes the dialog and lists again with the current scopes; Tag then works.
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
|
||||
await refresh.click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await relisted;
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
expect((await openTag(page)).status()).toBe(200);
|
||||
await expect(notice(page)).toHaveCount(0);
|
||||
await expect(generate(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('an unmappable inspector read keeps its visible Refresh, and Refresh recovers the panel', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await page.request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
await infoButton(page, SECOND).click();
|
||||
expect((await rescoped).status()).toBe(409);
|
||||
await expect(panel(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?'));
|
||||
await panel(page).getByRole('button', { name: 'Refresh image collection', exact: true }).click();
|
||||
await relisted;
|
||||
await expect(cards(page).first()).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page)).not.toContainText('The source changed');
|
||||
});
|
||||
|
||||
test('a Load more cursor the server cannot verify offers Refresh instead of a dead Retry', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 60 } })).toBeOK();
|
||||
await page.goto(images);
|
||||
await expect(cards(page).first()).toBeVisible();
|
||||
await expect(page.locator('#gallery-continuation')).toContainText('more load as you scroll');
|
||||
await expect(await request.post('/__test__/scope-key/rotate')).toBeOK();
|
||||
const stale = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.has('cursor'));
|
||||
await page.locator('#gallery-continuation').scrollIntoViewIfNeeded();
|
||||
const answer = await stale;
|
||||
expect(answer.status()).toBe(400);
|
||||
expect((await answer.json()).code).toBe('cursor-stale');
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
await expect(continuation).toContainText('The image collection changed. Refresh image collection to continue.');
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && !new URL(item.url()).searchParams.has('cursor'));
|
||||
await continuation.getByRole('button', { name: 'Refresh image collection', exact: true }).click();
|
||||
expect((await relisted).status()).toBe(200);
|
||||
await expect(continuation).not.toContainText('The image collection changed');
|
||||
});
|
||||
|
||||
test('a viewer whose media source was renumbered closes onto the file, kept selected', async ({ page, request }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
const mapped = await (await rescoped).json();
|
||||
expect([mapped.image.root_index, mapped.image.path]).toEqual([1, FIRST]);
|
||||
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
|
||||
// Opened again, it shows the file under its current source.
|
||||
await card(page, FIRST, 1).locator('.image-open').click();
|
||||
await expect(page.locator('#viewer-dialog[open]')).toBeVisible();
|
||||
await expect(page.locator('#viewer-dialog').getByText('Media could not be loaded. Retry this file.', { exact: true })).toBeHidden();
|
||||
await expect.poll(() => page.locator('#viewer-dialog img').first().evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('a collection page finds a renumbered member again and stays on its members', async ({ page, request }) => {
|
||||
// Members are stored by root path, not index (#331), so a renumbering keeps them; the page's scopes go stale.
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const first = listing.images.find(image => image.path === FIRST && image.root_index === 0);
|
||||
const name = `Stale ${test.info().workerIndex}-${Date.now()}`;
|
||||
const added = await request.post('/prompt_manager/collections/items', { data: { name, items: [
|
||||
{ root_index: 0, root_scope: first.root_scope, path: FIRST }] } });
|
||||
await expect(added).toBeOK();
|
||||
const collections = (await (await request.get('/prompt_manager/collections')).json()).collections;
|
||||
const { id } = collections.find(item => item.name === name);
|
||||
await page.goto(`/prompt_manager/workspace#/collection/${id}`);
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(await request.post('/__test__/sources/reverse')).toBeOK();
|
||||
|
||||
const rescoped = answered(page, '/images/rescope');
|
||||
const relisted = page.waitForResponse(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('collection') === String(id));
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
expect((await (await rescoped).json()).image.root_index).toBe(1);
|
||||
expect((await relisted).status()).toBe(200);
|
||||
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, FIRST, 1)).toHaveClass(/\bselected\b/);
|
||||
// Tag image works on the member under its new source.
|
||||
expect((await openTag(page, FIRST, 1)).status()).toBe(200);
|
||||
await expect(notice(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept } from './concept-inventory.mjs';
|
||||
import { followRestore, SEEN_RESTORE_PILL, SEEN_RESTORE_TOAST } from './unseen-restore.mjs';
|
||||
|
||||
// System › Backup & export (#249 System S9 Task 24) on the S8 backend and the concept-shaped fixture: Back up and its
|
||||
// status line (D103), the restore review (D105) and its job, the exports, Import / inspect and the recovery policy
|
||||
@@ -265,6 +266,24 @@ test('a finished restore shows the open page again from the restored data', asyn
|
||||
expect(await libraryTotal(request)).toBe(restoredTotal);
|
||||
});
|
||||
|
||||
// #415: an open dialog waits out a finished restore, except a review: the plan it staged no longer describes storage,
|
||||
// so it closes as dismissed (its stage discarded), as before.
|
||||
test('a restore seen finishing elsewhere closes an open restore review and discards its stage', async ({ page, request }) => {
|
||||
const bytes = await backupBytes(request);
|
||||
const restoreRun = await followRestore(page);
|
||||
await openBackups(page);
|
||||
await expect(page.locator('#activity-pill')).toHaveAttribute('href', SEEN_RESTORE_PILL);
|
||||
await chooseBackup(page, bytes);
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
|
||||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
|
||||
const discarded = page.waitForResponse(response => response.url().includes(`${PREVIEW_URL}/`) && response.request().method() === 'DELETE');
|
||||
await restoreRun.finish();
|
||||
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
|
||||
expect((await discarded).status()).toBe(200);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
|
||||
});
|
||||
|
||||
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
|
||||
const before = await serverJson(request, '/__test__/system/digest');
|
||||
await openBackups(page);
|
||||
|
||||
@@ -472,7 +472,7 @@ async function openImages(page) {
|
||||
|
||||
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
|
||||
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
|
||||
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
|
||||
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' } } });
|
||||
expect(created.status()).toBe(201);
|
||||
const view = (await created.json()).view;
|
||||
try {
|
||||
@@ -481,17 +481,19 @@ test('defaults apply to a new visit but not to the current view or a saved view'
|
||||
await page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'List', exact: true }).click();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
await openAppearance(page);
|
||||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||||
await page.locator('#default-view').selectOption('masonry');
|
||||
// #451: a fresh browser's Default layout is Masonry.
|
||||
await expect(page.locator('#default-view')).toHaveValue('masonry');
|
||||
await page.locator('#default-view').selectOption('grid');
|
||||
await page.locator('#density').selectOption('compact');
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const stored = await storedSettings(page);
|
||||
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['masonry', 'compact']);
|
||||
// Back in Images (the page session's last visit): the current view keeps its own layout and size.
|
||||
expect([stored.workspaceLayout, stored.workspaceDensity]).toEqual(['grid', 'compact']);
|
||||
// Back in Images (the page session's last visit): the toolbar's List is the current view's own and stays; the
|
||||
// size the toolbar never changed follows the newer default (#451).
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'list');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'comfortable');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
|
||||
// The saved view opens as before.
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
@@ -501,15 +503,109 @@ test('defaults apply to a new visit but not to the current view or a saved view'
|
||||
await openImages(page);
|
||||
await page.reload();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'masonry');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-layout', 'grid');
|
||||
await expect(page.locator('.image-grid')).toHaveAttribute('data-density', 'compact');
|
||||
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }))
|
||||
await expect(page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Grid', exact: true }))
|
||||
.toHaveAttribute('aria-pressed', 'true');
|
||||
} finally {
|
||||
await request.delete(`/prompt_manager/saved-views/${view.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Choose a Default layout in System › Appearance and save it. */
|
||||
async function saveDefaultLayout(page, value) {
|
||||
await openAppearance(page);
|
||||
await page.locator('#default-view').selectOption(value);
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
}
|
||||
|
||||
const imageLayoutOf = page => page.locator('#workspace-main .image-grid');
|
||||
const layoutButton = (page, name) => page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name, exact: true });
|
||||
|
||||
test('a fresh browser opens Images in Masonry (#451)', async ({ page }) => {
|
||||
await openImages(page);
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
|
||||
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
|
||||
// Nothing was stored for it: the default is the gallery settings' own.
|
||||
expect(await page.evaluate(() => localStorage.getItem('gallerySettings'))).toBeNull();
|
||||
// A stored explicit choice is kept.
|
||||
await page.evaluate(() => localStorage.setItem('gallerySettings', JSON.stringify({ workspaceLayout: 'grid' })));
|
||||
await page.reload();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
|
||||
});
|
||||
|
||||
test('a Default layout saved later reaches the next Images visit in this tab (#451)', async ({ page }) => {
|
||||
// The visit starts from Grid, then System saves Masonry without a reload.
|
||||
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
|
||||
await openImages(page);
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
|
||||
await page.locator('#system-nav a[data-route="system"]').click();
|
||||
await expect(page.locator('#workspace-main .settings-layout')).toBeVisible();
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
await page.locator('#default-view').selectOption('masonry');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
|
||||
await expect(layoutButton(page, 'Masonry')).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('a Default layout saved in another tab reaches this tab\'s next Images visit (#451)', async ({ page, context }) => {
|
||||
await page.addInitScript(() => { if (!localStorage.getItem('gallerySettings')) localStorage.setItem('gallerySettings', '{"workspaceLayout":"grid"}'); });
|
||||
await openImages(page);
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
|
||||
const other = await context.newPage();
|
||||
try {
|
||||
await saveDefaultLayout(other, 'list');
|
||||
} finally {
|
||||
await other.close();
|
||||
}
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
|
||||
// A toolbar choice afterwards is this view's own again: a return keeps it.
|
||||
await layoutButton(page, 'Masonry').click();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(page.locator('#workspace-main .prompt-card').first()).toBeVisible();
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
|
||||
});
|
||||
|
||||
// #451 D157: a cover crop keeps a portrait image's top; Masonry shows the whole image and keeps the centre.
|
||||
test('cover crops keep the top of a portrait image; Masonry stays centred (#451)', async ({ page }) => {
|
||||
const position = locator => locator.evaluate(node => getComputedStyle(node).objectPosition);
|
||||
const portrait = page.locator('#workspace-main article.media-card .image-open img[width="1024"][height="1536"]').first();
|
||||
await openImages(page);
|
||||
await layoutButton(page, 'Grid').click();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'grid');
|
||||
await expect(portrait).toBeVisible();
|
||||
expect(await position(portrait)).toBe('50% 20%');
|
||||
await layoutButton(page, 'List').click();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'list');
|
||||
expect(await position(portrait)).toBe('50% 20%');
|
||||
// The side panel's cover (also History's media inspector).
|
||||
await portrait.locator('xpath=ancestor::article[1]').locator('.image-card-actions button').first().click();
|
||||
await expect(panel(page).locator('.detail-cover')).toBeVisible();
|
||||
expect(await position(panel(page).locator('.detail-cover'))).toBe('50% 20%');
|
||||
await layoutButton(page, 'Masonry').click();
|
||||
await expect(imageLayoutOf(page)).toHaveAttribute('data-layout', 'masonry');
|
||||
expect(await position(portrait)).toBe('50% 50%');
|
||||
// The Library's card art and a prompt history's shared gallery.
|
||||
await page.goto(`${workspace}#/library`);
|
||||
const art = page.locator('#workspace-main .prompt-card .card-art img').first();
|
||||
await expect(art).toBeVisible();
|
||||
expect(await position(art)).toBe('50% 20%');
|
||||
await page.goto(`${workspace}#/library/${page.manifest.prompts[0].positive_id}`);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
expect(await position(imageCards(page).first().locator('.image-open img'))).toBe('50% 20%');
|
||||
});
|
||||
|
||||
test('the defaults reach the Images page only; embedded galleries keep their own presentation', async ({ page }) => {
|
||||
await openAppearance(page);
|
||||
await page.locator('#default-view').selectOption('list');
|
||||
@@ -787,12 +883,13 @@ test('reset restores defaults without touching other keys', async ({ page }) =>
|
||||
await expect(page.locator('#default-view')).toHaveValue('list');
|
||||
const reset = page.locator('#workspace-main .operation').filter({ hasText: 'Reset display defaults' }).getByRole('button', { name: 'Reset', exact: true });
|
||||
await reset.click();
|
||||
await expect(page.locator('#default-view')).toHaveValue('grid');
|
||||
// #451 D157: the default layout is Masonry.
|
||||
await expect(page.locator('#default-view')).toHaveValue('masonry');
|
||||
await expect(page.locator('#video-muted')).toBeChecked();
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
await saveAppearance(page, 'Saved in this browser.');
|
||||
const stored = await storedSettings(page);
|
||||
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'grid',
|
||||
expect(stored).toEqual({ gridColumns: 5, defaultLimit: 200, thumbnailPromptDismissed: 'kept', workspaceLayout: 'masonry',
|
||||
videoMute: true, showImageInfo: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -21,6 +21,8 @@ const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model'
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
// D147: the reason a state blocks Save, on its own row in the sticky actions.
|
||||
const notice = page => dialog(page).locator('.dialog-actions > .tag-action-notice');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
|
||||
@@ -728,7 +730,8 @@ test('D116: Retry leads the actions after an unconfirmed save and resends the sa
|
||||
let lost = true;
|
||||
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
await expect(notice(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
await expect(status(page)).toHaveText('');
|
||||
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
|
||||
import { captureCanonicalState, historyGeometry, compareCanonicalHistory, nativeDialogState, exerciseCurrentPairSend, assertRecordedHistory } from './history-canonical-comparison.mjs';
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { startInGrid } from './concept-inventory.mjs';
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
@@ -415,6 +416,8 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
|
||||
|
||||
test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => {
|
||||
await request.post('/__test__/reset', { data: { workspace_visual: true } });
|
||||
// The Grid's footer reservations (D157: the default layout is Masonry).
|
||||
await startInGrid(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(24);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore} from './unseen-restore.mjs';
|
||||
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
@@ -552,6 +552,31 @@ test('conflict and restore states keep the dialog and write nothing',async({page
|
||||
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// #415: a restore the page sees finish while Create tag is open keeps the dialog and its entries; the page is shown
|
||||
// again from the restored data only once the dialog has closed.
|
||||
test('a restore seen finishing keeps an open dialog and reloads the page after it closes',async({page,request})=>{
|
||||
const saved=await backup(request);
|
||||
await expect(await request.put('/prompt_manager/tags/mist',{data:{new_name:'fog'}})).toBeOK();
|
||||
const restoreRun=await followRestore(page);const writes=trackWrites(page);await openPage(page);
|
||||
await expect(page.locator('#activity-pill')).toHaveAttribute('href',SEEN_RESTORE_PILL);
|
||||
await expect(tagRow(page,'fog')).toHaveCount(1);
|
||||
const box=await openDialog(page,main(page).getByRole('button',{name:'+ Create tag',exact:true}),'Create tag');
|
||||
await field(box,'Tag name').fill('sunrise');
|
||||
await expect(await request.post('/prompt_manager/restore',{multipart:{database_file:{name:'vocabulary.db',mimeType:'application/octet-stream',buffer:saved}}})).toBeOK();
|
||||
await restoreRun.finish();
|
||||
await expect(toast(page)).toHaveText(SEEN_RESTORE_TOAST);
|
||||
await expect(box).toBeVisible();await expect(field(box,'Tag name')).toHaveValue('sunrise');
|
||||
await expect(tagRow(page,'fog')).toHaveCount(1);
|
||||
await button(box,'Apply').click();
|
||||
await expect(note(box)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was changed.');
|
||||
expect(await actionNames(box)).toEqual(['Reload','Cancel','Apply']);
|
||||
expect(tagNames(await snapshot(request))).not.toContain('sunrise');
|
||||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||||
await expect(tagRow(page,'mist')).toHaveCount(1);await expect(tagRow(page,'fog')).toHaveCount(0);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
expect(writes.filter(write=>write.url==='/prompt_manager/vocabulary/tags')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('lost response is outcome-unknown',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
await page.route('**/prompt_manager/vocabulary/tags/*',async route=>{
|
||||
|
||||
@@ -548,12 +548,16 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
'nav#saved-nav', 'div.sidebar-label', 'nav#collections-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge. D136: COLLECTIONS without it either, its label hidden
|
||||
// and its nav empty while no collection exists.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS', 'COLLECTIONS']);
|
||||
await expect(sidebar.locator('.sidebar-label').nth(2)).toBeHidden();
|
||||
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
|
||||
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
|
||||
await expect(page.locator('nav#collections-nav')).toHaveAttribute('aria-label', 'Collections');
|
||||
await expect(page.locator('nav#collections-nav > a')).toHaveCount(0);
|
||||
await expect(page.locator('.new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
@@ -580,7 +584,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(4);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Owned, deterministic collections for the workspace collection workflows (#331).
|
||||
|
||||
Twenty-one PNGs under the output source: twelve listed members of
|
||||
"Landscapes", eight of "Experiments" and one loose file in neither. Landscapes
|
||||
also holds one member whose file does not exist (MC3: hidden, counted as
|
||||
missing). One saved view reopens "Experiments" (D140). Members are added
|
||||
through ``ImageCollectionOperations`` with favorites' keys
|
||||
(``_favorite_root_key``, ``_favorite_relative_key``), as the routes add them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from tests.browser.folder_trash import replace_folder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
FOLDER = "collections"
|
||||
LANDSCAPES = "Landscapes"
|
||||
EXPERIMENTS = "Experiments"
|
||||
LANDSCAPE_FILES = tuple(f"landscape_{index:02d}.png" for index in range(1, 13))
|
||||
EXPERIMENT_FILES = tuple(f"experiment_{index:02d}.png" for index in range(1, 9))
|
||||
LOOSE_FILE = "loose.png"
|
||||
MISSING_FILE = "gone.png"
|
||||
SAVED_VIEW = "Experiments view"
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
"media_review_items",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
_IMAGES_STATE = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
"sort": "newest",
|
||||
"source": "all",
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": [],
|
||||
"favorites": False,
|
||||
}
|
||||
|
||||
|
||||
def _write_png(path: Path, shade: int, stamp: float) -> None:
|
||||
with Image.new("RGB", (64, 48), (shade, 120, 255 - shade)) as image:
|
||||
image.save(path)
|
||||
os.utime(path, (stamp, stamp))
|
||||
|
||||
|
||||
def seed_collections(api: PromptManagerAPI, root: Path) -> dict:
|
||||
"""Replace this server's disposable rows and files with the two collections."""
|
||||
# The API package needs the host stubs the server installs before importing it.
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
replace_folder(root / name)
|
||||
folder = root / "output" / FOLDER
|
||||
folder.mkdir()
|
||||
# Newest first in this order: Landscapes, then Experiments, then the loose file.
|
||||
now = time.time()
|
||||
ordered = (*LANDSCAPE_FILES, *EXPERIMENT_FILES, LOOSE_FILE)
|
||||
for index, name in enumerate(ordered):
|
||||
_write_png(folder / name, (index * 11) % 256, now - 60 - index * 60)
|
||||
root_key = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||
members = {
|
||||
LANDSCAPES: (*LANDSCAPE_FILES, MISSING_FILE),
|
||||
EXPERIMENTS: EXPERIMENT_FILES,
|
||||
}
|
||||
ids = {}
|
||||
for name, files in members.items():
|
||||
added = db.add_to_collection(
|
||||
name=name,
|
||||
items=[
|
||||
(root_key, _favorite_relative_key(f"{FOLDER}/{file}")) for file in files
|
||||
],
|
||||
)
|
||||
ids[name] = added["collection"]["id"]
|
||||
view = db.create_saved_view(
|
||||
"images", SAVED_VIEW, {**_IMAGES_STATE, "collection": str(ids[EXPERIMENTS])}
|
||||
)
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"collections": {
|
||||
LANDSCAPES: {"id": ids[LANDSCAPES], "count": 12, "missing": 1},
|
||||
EXPERIMENTS: {"id": ids[EXPERIMENTS], "count": 8, "missing": 0},
|
||||
},
|
||||
"files": {
|
||||
LANDSCAPES: [f"{FOLDER}/{file}" for file in LANDSCAPE_FILES],
|
||||
EXPERIMENTS: [f"{FOLDER}/{file}" for file in EXPERIMENT_FILES],
|
||||
"loose": f"{FOLDER}/{LOOSE_FILE}",
|
||||
},
|
||||
"saved_view": {"id": view["id"], "name": SAVED_VIEW},
|
||||
}
|
||||
|
||||
|
||||
def register_collections_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
||||
) -> None:
|
||||
"""Register the fixed seed command; no path or SQL comes from a request."""
|
||||
|
||||
@routes.post("/__test__/collections/seed")
|
||||
async def seed(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if data != {} or request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
manifest = await api._run_in_executor(seed_collections, api, root)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
@@ -174,6 +174,8 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
@@ -193,6 +195,10 @@ _TABLES = (
|
||||
)
|
||||
|
||||
|
||||
# The concept's collections and the slice of its images each holds (#331).
|
||||
CONCEPT_COLLECTIONS = (("Landscapes", slice(0, 12)), ("Experiments", slice(12, 20)))
|
||||
|
||||
|
||||
def concept_starred(prompt_index: int, round_index: int) -> bool:
|
||||
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
|
||||
return round_index == 0 and prompt_index % 2 == 0
|
||||
@@ -495,6 +501,19 @@ def seed_workspace_concept(
|
||||
)
|
||||
images.reverse()
|
||||
runs.reverse()
|
||||
# The concept's COLLECTIONS rows (app.js a336–339): "Landscapes" holds the
|
||||
# first 12 images in the concept's order and "Experiments" the next 8, so
|
||||
# the rows and counts compare as the concept shows them (#331 D136).
|
||||
collections = {}
|
||||
for name, members in CONCEPT_COLLECTIONS:
|
||||
added = db.add_to_collection(
|
||||
name=name,
|
||||
items=[
|
||||
(favorite_root, _favorite_relative_key(image["path"]))
|
||||
for image in images[members]
|
||||
],
|
||||
)
|
||||
collections[name] = added["collection"]["id"]
|
||||
seedless = []
|
||||
for number in range(seedless_images):
|
||||
name = f"seedless_{number + 1:05}.png"
|
||||
@@ -531,4 +550,5 @@ def seed_workspace_concept(
|
||||
"images": images,
|
||||
"runs": runs,
|
||||
"tags": tags,
|
||||
"collections": collections,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -78,6 +78,8 @@ LIMITED_SAMPLERS = 17
|
||||
# More than one queue page (40), so Load more has a second page.
|
||||
MAX_ITEMS = 45
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -56,6 +56,8 @@ def _valid_missing(value) -> bool:
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
@@ -192,9 +194,16 @@ def tagging_snapshot(api: PromptManagerAPI) -> dict:
|
||||
|
||||
|
||||
def register_tagging_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
|
||||
routes: web.RouteTableDef,
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
model,
|
||||
on_reset=lambda: None,
|
||||
) -> None:
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input.
|
||||
|
||||
``on_reset`` runs first on every tagging reset (the source-change undo).
|
||||
"""
|
||||
# The fake download (``/__test__/tagging/download``): waits on ``event``,
|
||||
# then answers ``result``. Every reset sets the event and puts back the
|
||||
# model's raising default, so no test can leave a worker hanging.
|
||||
@@ -228,6 +237,7 @@ def register_tagging_commands(
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= extra <= MAX_EXTRA_IMAGES:
|
||||
raise web.HTTPBadRequest()
|
||||
on_reset()
|
||||
model.unavailable = set()
|
||||
model.missing_packages = {}
|
||||
model.suggestions = None
|
||||
|
||||
@@ -112,6 +112,8 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -83,6 +83,8 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"POST /prompt_manager/images/generate-thumbnails",
|
||||
"POST /prompt_manager/images/generate-thumbnails/progress",
|
||||
"POST /prompt_manager/images/link",
|
||||
"POST /prompt_manager/images/rescope",
|
||||
"POST /prompt_manager/images/reveal",
|
||||
"POST /prompt_manager/jobs/autotag",
|
||||
"POST /prompt_manager/jobs/duplicates",
|
||||
|
||||
@@ -64,12 +64,15 @@ test("infinite scrolling is off by default and must be a boolean", () => {
|
||||
|
||||
test("the workspace keys have their own defaults and validation", () => {
|
||||
const { WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES } = require("../../web/js/gallery-settings.js");
|
||||
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "grid");
|
||||
// #451: Images opens in Masonry unless the browser chose otherwise.
|
||||
assert.equal(DEFAULT_SETTINGS.workspaceLayout, "masonry");
|
||||
assert.equal(DEFAULT_SETTINGS.workspaceDensity, "comfortable");
|
||||
assert.deepEqual(WORKSPACE_LAYOUTS, ["grid", "list", "masonry"]);
|
||||
assert.deepEqual(WORKSPACE_DENSITIES, ["comfortable", "large", "compact"]);
|
||||
assert.equal(normalizeGallerySettings({ workspaceLayout: "masonry" }).workspaceLayout, "masonry");
|
||||
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "grid");
|
||||
assert.equal(normalizeGallerySettings({ workspaceLayout: "grid" }).workspaceLayout, "grid");
|
||||
assert.equal(normalizeGallerySettings({ workspaceLayout: "mosaic" }).workspaceLayout, "masonry");
|
||||
assert.equal(normalizeGallerySettings(null).workspaceLayout, "masonry");
|
||||
assert.equal(normalizeGallerySettings({ workspaceDensity: "large" }).workspaceDensity, "large");
|
||||
assert.equal(normalizeGallerySettings({ workspaceDensity: 3 }).workspaceDensity, "comfortable");
|
||||
// The legacy gallery's own view mode is separate: it has no masonry.
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// Collections (#331 D136–D140): the client store, the route hash, the names, every user-facing string and the
|
||||
// mapping of the routes' error codes.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/collections.mjs');
|
||||
|
||||
const FILE = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'grids/one.png' });
|
||||
|
||||
function apiError(status, code) {
|
||||
return Object.assign(new Error(code || `HTTP ${status}`), { status, code: code || null });
|
||||
}
|
||||
|
||||
function fakeApi() {
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const api = {
|
||||
calls,
|
||||
get(url, options) {
|
||||
calls.push(['GET', url, options?.cache]);
|
||||
if (url.includes('/membership?')) return api.membershipResult ?? Promise.resolve({ success: true, collection_ids: [2] });
|
||||
return new Promise((resolve, reject) => pending.push({ resolve, reject }));
|
||||
},
|
||||
post(url, body) {
|
||||
calls.push(['POST', url, body]);
|
||||
return api.postResult ?? Promise.resolve({ success: true, collection: { id: 5, name: 'Grids' }, created: true,
|
||||
added: body.items.length, already: 0, not_found: 0 });
|
||||
},
|
||||
patch(url, body) {
|
||||
calls.push(['PATCH', url, body]);
|
||||
return api.patchResult ?? Promise.resolve({ success: true, collection: { id: 2, name: body.name } });
|
||||
},
|
||||
del(url, options) {
|
||||
calls.push(['DELETE', url, options?.body]);
|
||||
return api.deleteResult ?? Promise.resolve(options?.body
|
||||
? { success: true, collection: { id: options.body.collection_id, name: 'Portraits' }, removed: options.body.items.length }
|
||||
: { success: true, deleted: true });
|
||||
},
|
||||
};
|
||||
return { api, pending };
|
||||
}
|
||||
|
||||
const LISTED = { success: true, collections: [
|
||||
{ id: 4, name: 'Experiments', count: 8, missing: 0 },
|
||||
{ id: 2, name: 'Landscapes', count: 12, missing: 1 },
|
||||
] };
|
||||
|
||||
/** A store whose first read answered with `data`; the test's own timer seam replaces the 300 ms count timer. */
|
||||
async function readyStore(data = LISTED) {
|
||||
const { createCollections } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const timers = [];
|
||||
const controller = new AbortController();
|
||||
const store = createCollections(api, controller.signal, {
|
||||
setTimer: (run, delay) => { timers.push({ run, delay, cleared: false }); return timers.length; },
|
||||
clearTimer: id => { if (timers[id - 1]) timers[id - 1].cleared = true; },
|
||||
});
|
||||
store.load();
|
||||
pending.shift().resolve(data);
|
||||
await store.settled();
|
||||
return { store, api, pending, timers, controller };
|
||||
}
|
||||
|
||||
test('the route hash, the name rule and the limits', async () => {
|
||||
const { collectionHash, normalizeCollectionName, MAX_COLLECTION_NAME_LENGTH, MAX_COLLECTION_ITEMS,
|
||||
COLLECTION_COUNT_DELAY_MS } = await source;
|
||||
assert.equal(collectionHash(12), '#/collection/12');
|
||||
assert.throws(() => collectionHash(0), TypeError);
|
||||
assert.throws(() => collectionHash('3'), TypeError);
|
||||
assert.equal(MAX_COLLECTION_NAME_LENGTH, 100);
|
||||
assert.equal(MAX_COLLECTION_ITEMS, 500);
|
||||
assert.equal(COLLECTION_COUNT_DELAY_MS, 300);
|
||||
// Trimmed, each run of whitespace one space, 1 to 100 characters, no control characters (design §1).
|
||||
assert.equal(normalizeCollectionName(' LoRa testing grids '), 'LoRa testing grids');
|
||||
assert.equal(normalizeCollectionName('x'.repeat(100)), 'x'.repeat(100));
|
||||
for (const bad of ['', ' ', 'x'.repeat(101), 'bad\u0007name', 7, null, undefined]) {
|
||||
assert.equal(normalizeCollectionName(bad), null, String(bad));
|
||||
}
|
||||
// A tab or newline is whitespace, collapsed rather than refused.
|
||||
assert.equal(normalizeCollectionName('a\tb\nc'), 'a b c');
|
||||
});
|
||||
|
||||
test('the listing keeps usable rows in the server order, with counts only when they are counts', async () => {
|
||||
const { parseCollections, parseCollection } = await source;
|
||||
assert.deepEqual(parseCollections({ success: true, collections: [
|
||||
{ id: 3, name: 'b', count: 2, missing: 0 },
|
||||
{ id: 0, name: 'bad id' },
|
||||
{ id: 1, name: ' ', count: 1, missing: 0 },
|
||||
{ id: 7, name: 'A', count: null, missing: null },
|
||||
{ id: 9, name: 'c', count: -1, missing: 1.5 },
|
||||
] }), [
|
||||
{ id: 3, name: 'b', count: 2, missing: 0 },
|
||||
{ id: 7, name: 'A', count: null, missing: null },
|
||||
{ id: 9, name: 'c', count: null, missing: null },
|
||||
]);
|
||||
assert.equal(parseCollection(null), null);
|
||||
assert.throws(() => parseCollections({ success: true }), TypeError);
|
||||
});
|
||||
|
||||
test('every user-facing string of the design (§7.4, §8)', async () => {
|
||||
const c = await source;
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 0 }), 'Added 1 image to “X”.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 3, already: 0, notFound: 0 }), 'Added 3 images to “X”.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 1, notFound: 0 }),
|
||||
'Added 1 image to “X”. 1 was already in it.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 2, already: 3, notFound: 1 }),
|
||||
'Added 2 images to “X”. 3 were already in it. 1 could not be found.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 2 }),
|
||||
'Added 1 image to “X”. 2 could not be found.');
|
||||
assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 0, already: 4, notFound: 0 }),
|
||||
'All selected images were already in “X”.');
|
||||
assert.equal(c.FILES_NOT_FOUND_TOAST, 'None of the selected images could be found. Refresh the images and try again.');
|
||||
assert.equal(c.SOURCE_CHANGED_TOAST, 'The sources changed. Refresh the images and try again.');
|
||||
assert.equal(c.ADD_FAILED_TOAST, 'The images could not be added. Try again.');
|
||||
assert.equal(c.removedMessage('X', 1), 'Removed 1 image from “X”.');
|
||||
assert.equal(c.removedMessage('X', 4), 'Removed 4 images from “X”.');
|
||||
assert.equal(c.REMOVE_FAILED_TOAST, 'The images could not be removed. Try again.');
|
||||
assert.equal(c.COLLECTION_GONE_TOAST, 'That collection no longer exists.');
|
||||
assert.deepEqual([c.COLLECTION_NOUNS.title, c.COLLECTION_NOUNS.label, c.COLLECTION_NOUNS.deleteTitle],
|
||||
['Edit collection', 'Collection name', 'Delete collection']);
|
||||
assert.equal(c.COLLECTION_NOUNS.renamed, 'Collection renamed.');
|
||||
assert.equal(c.RENAMED_TOAST, 'Collection renamed.');
|
||||
assert.equal(c.nameTakenMessage('LoRa'), 'A collection named “LoRa” already exists.');
|
||||
assert.equal(c.RENAME_FAILED_TOAST, 'The collection could not be renamed. Try again.');
|
||||
// The stored count: listed members plus missing ones.
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: 1, missing: 0 }),
|
||||
'Delete “X”? Its 1 image stays where it is; only the collection is removed.');
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: 3, missing: 1 }),
|
||||
'Delete “X”? Its 4 images stay where they are; only the collection is removed.');
|
||||
assert.equal(c.deleteQuestion({ name: 'X', count: null, missing: null }),
|
||||
'Delete “X”? Its images stay where they are; only the collection is removed.');
|
||||
assert.equal(c.COLLECTION_NOUNS.deleteText({ name: 'X', count: 2, missing: 0 }), c.deleteQuestion({ name: 'X', count: 2, missing: 0 }));
|
||||
assert.equal(c.DELETED_TOAST, 'Collection deleted.');
|
||||
assert.equal(c.DELETE_FAILED_TOAST, 'The collection could not be deleted. Try again.');
|
||||
assert.equal(c.DELETED_PAGE_TEXT, 'This collection no longer exists.');
|
||||
assert.equal(c.OPEN_IMAGES_LINK, 'Open Images');
|
||||
assert.equal(c.VIEW_COLLECTION_DELETED, 'The collection this view uses was deleted.');
|
||||
assert.equal(c.LIST_FAILED_TEXT, 'Collections could not be loaded. Your saved data has not changed.');
|
||||
assert.equal(c.missingNote(1), '1 image in this collection is not in your current sources.');
|
||||
assert.equal(c.missingNote(5), '5 images in this collection are not in your current sources.');
|
||||
for (const none of [0, null, undefined, -1, 1.5]) assert.equal(c.missingNote(none), '');
|
||||
assert.equal(c.TOO_MANY_TOAST, 'Select at most 500 images.');
|
||||
assert.deepEqual([c.ADD_DIALOG_TITLE, c.ADD_DIALOG_LABEL, c.ADD_DIALOG_ACTION],
|
||||
['Add selected images to collection', 'Collection name', 'Add']);
|
||||
assert.equal(c.COLLECTIONS_UPDATED_TOAST, 'Collections updated.');
|
||||
assert.equal(c.UPDATE_FAILED_TEXT, 'The collections could not be updated. Try again.');
|
||||
assert.equal(c.MEMBERSHIP_LOADING_TEXT, 'Loading collections…');
|
||||
assert.equal(c.MEMBERSHIP_FAILED_TEXT, 'Collections could not be loaded.');
|
||||
assert.equal(c.NO_COLLECTIONS_TEXT, 'No collections yet. Name one below.');
|
||||
});
|
||||
|
||||
test('a write failure maps the routes\' error codes to their strings', async () => {
|
||||
const { writeFailure, renameFailure, isCollectionGone, COLLECTION_GONE_TOAST, SOURCE_CHANGED_TOAST,
|
||||
FILES_NOT_FOUND_TOAST, ADD_FAILED_TOAST, RENAME_FAILED_TOAST } = await source;
|
||||
assert.equal(writeFailure(apiError(404, 'collection-not-found'), ADD_FAILED_TOAST), COLLECTION_GONE_TOAST);
|
||||
assert.equal(writeFailure(apiError(409, 'source-changed'), ADD_FAILED_TOAST), SOURCE_CHANGED_TOAST);
|
||||
assert.equal(writeFailure(apiError(404, 'files-not-found'), ADD_FAILED_TOAST), FILES_NOT_FOUND_TOAST);
|
||||
for (const other of [apiError(500), apiError(400), apiError(404), new TypeError('x'), null]) {
|
||||
assert.equal(writeFailure(other, ADD_FAILED_TOAST), ADD_FAILED_TOAST);
|
||||
}
|
||||
assert.equal(isCollectionGone(apiError(404, 'collection-not-found')), true);
|
||||
assert.equal(isCollectionGone(apiError(404)), false);
|
||||
// The Edit dialog: a taken name stays in the dialog; a gone collection closes it; anything else keeps it open.
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(409, 'name-taken')), { field: 'A collection named “LoRa” already exists.' });
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(404, 'collection-not-found')), { toast: COLLECTION_GONE_TOAST, close: true });
|
||||
assert.deepEqual(renameFailure('LoRa')(apiError(500)), { toast: RENAME_FAILED_TOAST });
|
||||
});
|
||||
|
||||
test('the store reports loads, keeps the last list after a failed read, and a newer read supersedes', async () => {
|
||||
const { createCollections, COLLECTIONS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createCollections(api);
|
||||
assert.equal(store.status, 'loading');
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push([next.status, next.collections.map(item => item.id)]));
|
||||
store.load();
|
||||
const settled = store.settled();
|
||||
pending[0].resolve(LISTED);
|
||||
assert.equal((await settled).status, 'ready');
|
||||
assert.deepEqual(api.calls[0], ['GET', COLLECTIONS_URL, 'no-store']);
|
||||
assert.deepEqual(store.find(2), { id: 2, name: 'Landscapes', count: 12, missing: 1 });
|
||||
assert.equal(store.find(3), null);
|
||||
assert.deepEqual(store.collections.map(item => item.name), ['Experiments', 'Landscapes']);
|
||||
|
||||
store.load();
|
||||
pending[1].reject(new Error('offline'));
|
||||
await store.settled();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.deepEqual(store.collections.map(item => item.id), [4, 2]);
|
||||
|
||||
store.load();
|
||||
store.load();
|
||||
pending[3].resolve({ success: true, collections: [{ id: 8, name: 'New', count: 0, missing: 0 }] });
|
||||
pending[2].resolve(LISTED);
|
||||
await store.settled();
|
||||
assert.deepEqual(store.collections.map(item => item.id), [8]);
|
||||
assert.deepEqual(seen.at(-1), ['ready', [8]]);
|
||||
// A malformed listing is a failed read.
|
||||
store.load();
|
||||
pending[4].resolve({ success: true });
|
||||
await store.settled();
|
||||
assert.equal(store.status, 'error');
|
||||
});
|
||||
|
||||
test('adding by name or id sends only the file identity and re-reads the counts 300 ms after the last change', async () => {
|
||||
const { store, api, pending, timers } = await readyStore();
|
||||
const { COLLECTION_ITEMS_URL } = await source;
|
||||
const result = await store.add({ name: ' Grids ' }, [{ ...FILE, title: 'ignored', favorite: true }]);
|
||||
assert.deepEqual(api.calls.at(-1), ['POST', COLLECTION_ITEMS_URL, { name: 'Grids', items: [FILE] }]);
|
||||
assert.deepEqual(result, { collection: { id: 5, name: 'Grids' }, created: true, added: 1, already: 0, notFound: 0 });
|
||||
// The new collection is listed at once (its count arrives with the re-read), in name order.
|
||||
assert.deepEqual(store.collections.map(item => [item.id, item.count]), [[4, 8], [5, null], [2, 12]]);
|
||||
api.postResult = Promise.resolve({ success: true, collection: { id: 2, name: 'Landscapes' }, created: false, added: 0,
|
||||
already: 1, not_found: 1 });
|
||||
const again = await store.add({ collectionId: 2 }, [FILE, { ...FILE, path: 'b.png' }]);
|
||||
assert.deepEqual(api.calls.at(-1)[2], { collection_id: 2, items: [FILE, { ...FILE, path: 'b.png' }] });
|
||||
assert.deepEqual(again, { collection: { id: 2, name: 'Landscapes' }, created: false, added: 0, already: 1, notFound: 1 });
|
||||
// One trailing re-read for the run of writes.
|
||||
assert.deepEqual(timers.map(timer => [timer.delay, timer.cleared]), [[300, true], [300, false]]);
|
||||
const reads = api.calls.filter(call => call[0] === 'GET').length;
|
||||
timers.at(-1).run();
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
pending.shift().resolve(LISTED);
|
||||
await store.settled();
|
||||
});
|
||||
|
||||
test('a write is refused before sending when its target or files are not valid', async () => {
|
||||
const { store, api } = await readyStore();
|
||||
const sent = api.calls.length;
|
||||
const many = Array.from({ length: 501 }, (_, index) => ({ ...FILE, path: `${index}.png` }));
|
||||
for (const [target, items] of [
|
||||
[{ name: '' }, [FILE]], [{ name: 'x'.repeat(101) }, [FILE]], [{ collectionId: 0 }, [FILE]], [{}, [FILE]],
|
||||
[{ name: 'a', collectionId: 2 }, [FILE]], [{ collectionId: 2 }, []], [{ collectionId: 2 }, many],
|
||||
[{ collectionId: 2 }, [{ ...FILE, root_index: -1 }]], [{ collectionId: 2 }, [{ ...FILE, path: '' }]],
|
||||
[{ collectionId: 2 }, [{ ...FILE, root_scope: 3 }]], [{ collectionId: 2 }, 'a.png'],
|
||||
]) {
|
||||
await assert.rejects(store.add(target, items), TypeError);
|
||||
}
|
||||
await assert.rejects(store.remove(0, [FILE]), TypeError);
|
||||
await assert.rejects(store.remove(2, []), TypeError);
|
||||
await assert.rejects(store.rename(2, ' '), TypeError);
|
||||
await assert.rejects(store.delete(-1), TypeError);
|
||||
await assert.rejects(store.membership({ root_index: 'x', root_scope: 'a', path: 'p' }), TypeError);
|
||||
assert.equal(api.calls.length, sent);
|
||||
});
|
||||
|
||||
test('a write to a collection that is gone re-reads the list at once and rejects with the error', async () => {
|
||||
const { store, api, pending } = await readyStore();
|
||||
const { isCollectionGone } = await source;
|
||||
api.postResult = Promise.reject(apiError(404, 'collection-not-found'));
|
||||
const reads = api.calls.filter(call => call[0] === 'GET').length;
|
||||
await assert.rejects(store.add({ collectionId: 2 }, [FILE]), error => isCollectionGone(error));
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
pending.shift().resolve({ success: true, collections: [LISTED.collections[0]] });
|
||||
await store.settled();
|
||||
assert.equal(store.find(2), null);
|
||||
// Another failure re-reads nothing.
|
||||
api.deleteResult = Promise.reject(apiError(500));
|
||||
await assert.rejects(store.remove(4, [FILE]), error => error.status === 500);
|
||||
assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1);
|
||||
});
|
||||
|
||||
test('remove sends the collection and files; membership reads one file\'s collections', async () => {
|
||||
const { store, api, timers } = await readyStore();
|
||||
const { COLLECTION_ITEMS_URL, COLLECTION_MEMBERSHIP_URL } = await source;
|
||||
assert.deepEqual(await store.remove(2, [FILE]), { collection: { id: 2, name: 'Portraits' }, removed: 1 });
|
||||
assert.deepEqual(api.calls.at(-1), ['DELETE', COLLECTION_ITEMS_URL, { collection_id: 2, items: [FILE] }]);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.deepEqual(await store.membership(FILE), [2]);
|
||||
const [method, url] = api.calls.at(-1);
|
||||
assert.equal(method, 'GET');
|
||||
const query = new URL(url, 'http://local');
|
||||
assert.equal(query.pathname, COLLECTION_MEMBERSHIP_URL);
|
||||
assert.deepEqual(Object.fromEntries(query.searchParams), { root_index: '0', root_scope: FILE.root_scope, path: FILE.path });
|
||||
api.membershipResult = Promise.resolve({ success: true, collection_ids: [1, 'x'] });
|
||||
await assert.rejects(store.membership(FILE), TypeError);
|
||||
});
|
||||
|
||||
test('rename and delete change the listed row at once and re-read the counts', async () => {
|
||||
const { store, api, timers } = await readyStore();
|
||||
const { COLLECTIONS_URL } = await source;
|
||||
const renamed = await store.rename(2, ' A grids ');
|
||||
assert.deepEqual(api.calls.at(-1), ['PATCH', `${COLLECTIONS_URL}/2`, { name: 'A grids' }]);
|
||||
assert.deepEqual(renamed, { id: 2, name: 'A grids' });
|
||||
// The row keeps its counts and moves to its new place in name order.
|
||||
assert.deepEqual(store.collections.map(item => [item.id, item.name, item.count]), [[2, 'A grids', 12], [4, 'Experiments', 8]]);
|
||||
assert.equal(await store.delete(4), 4);
|
||||
assert.deepEqual(api.calls.at(-1), ['DELETE', `${COLLECTIONS_URL}/4`, undefined]);
|
||||
assert.deepEqual(store.collections.map(item => item.id), [2]);
|
||||
// Already gone (another tab): the outcome the user asked for.
|
||||
api.deleteResult = Promise.reject(apiError(404, 'collection-not-found'));
|
||||
assert.equal(await store.delete(2), 2);
|
||||
assert.deepEqual(store.collections, []);
|
||||
api.patchResult = Promise.reject(apiError(409, 'name-taken'));
|
||||
await assert.rejects(store.rename(9, 'Taken'), error => error.code === 'name-taken');
|
||||
assert.equal(timers.filter(timer => !timer.cleared).length, 1);
|
||||
});
|
||||
|
||||
test('a disposed store stops its count timer, drops a read in flight and reads no more', async () => {
|
||||
const { store, timers, controller, api, pending } = await readyStore();
|
||||
await store.rename(2, 'Kept');
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push(next));
|
||||
store.load();
|
||||
controller.abort();
|
||||
assert.equal(timers.at(-1).cleared, true);
|
||||
pending.shift().resolve(LISTED);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(seen.filter(next => next.status === 'ready').length, 0);
|
||||
const sent = api.calls.length;
|
||||
await store.load();
|
||||
assert.equal(api.calls.length, sent);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -253,15 +253,16 @@ test('an unreadable or malformed models read falls back to the documented defaul
|
||||
|
||||
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
|
||||
let answer = () => fail(500, 'observe-failed');
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
|
||||
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(c.state.canRetry, true);
|
||||
assert.equal(c.state.blocked, true, 'a failed capture blocks Generate and Save with its reason');
|
||||
answer = () => response(200, observed({ tag_revision: -1 }));
|
||||
await c.retry();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
@@ -274,9 +275,71 @@ test('observe failures and malformed answers end in observe-error and retry obse
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.blocked, false);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 4);
|
||||
});
|
||||
|
||||
test('#430: a changed source blocks the dialog until it is retargeted, keeping the draft', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const SOURCE = 'The source changed. Refresh the image collection before tagging this file.';
|
||||
const fresh = { root_index: 1, root_scope: 'e'.repeat(64), path: TARGET.path };
|
||||
const h = harness(defaultRoutes({
|
||||
[`POST ${OBSERVE}`]: call => (call.body.target.root_scope === SCOPE ? fail(409, 'source-changed', SOURCE) : response(200, observed())),
|
||||
}));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
assert.equal(c.state.phase, 'source-changed');
|
||||
assert.equal(c.state.statusText, SOURCE);
|
||||
assert.deepEqual([c.state.canSave, c.state.canGenerate, c.state.canRetry, c.state.blocked], [false, false, false, true]);
|
||||
assert.equal(await c.retry(), false, 'the same stale target is never observed again');
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
assert.throws(() => c.retarget({ ...fresh, root_scope: 'bad' }), TypeError);
|
||||
await c.retarget(fresh);
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.deepEqual(h.of('POST', OBSERVE).at(-1).body.target, fresh);
|
||||
assert.deepEqual({ ...c.target }, fresh);
|
||||
// Generate and Save now carry the new target.
|
||||
c.setManual('kept draft');
|
||||
await c.generate();
|
||||
assert.deepEqual(h.of('POST', SUGGEST)[0].body.target, fresh);
|
||||
});
|
||||
|
||||
test('#430: a source change answered to Generate or Save also blocks; retarget keeps the typed draft', async () => {
|
||||
for (const route of [SUGGEST, TAGS]) {
|
||||
const method = route === TAGS ? 'PUT' : 'POST';
|
||||
let stale = true;
|
||||
const { c, h } = await opened({
|
||||
[`${method} ${route}`]: call => (stale ? fail(409, 'source-changed', 'server says source') :
|
||||
route === TAGS ? response(200, receipt(call.body.tags)) : response(200, suggested())),
|
||||
});
|
||||
c.setManual('kept draft');
|
||||
if (route === TAGS) await c.save(); else await c.generate();
|
||||
assert.equal(c.state.phase, 'source-changed');
|
||||
assert.equal(c.state.statusText, 'server says source');
|
||||
stale = false;
|
||||
await c.retarget({ root_index: 2, root_scope: 'f'.repeat(64), path: TARGET.path });
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'kept draft');
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'the old capture is released');
|
||||
}
|
||||
});
|
||||
|
||||
test('#430: retarget waits for nothing: it refuses while a save is in flight and after close', async () => {
|
||||
const gate = deferred();
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => gate.promise });
|
||||
const saving = c.save();
|
||||
await flush();
|
||||
assert.equal(c.state.phase, 'saving');
|
||||
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
|
||||
gate.resolve(response(200, receipt(['Minimal'])));
|
||||
await saving;
|
||||
c.cancel();
|
||||
assert.equal(await c.retarget({ ...TARGET, root_index: 3 }), false);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
});
|
||||
|
||||
test('model change clears suggestions count and status but keeps manual', async () => {
|
||||
const { c } = await opened();
|
||||
c.setManual('Minimal, dune');
|
||||
@@ -547,7 +610,7 @@ test('write errors keep the draft and allow another save', async () => {
|
||||
});
|
||||
|
||||
test('storage-changed and file-changed disable save until reopen', async () => {
|
||||
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
|
||||
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'source-changed'], ['capture-expired', 'capture-expired']]) {
|
||||
const message = `server says ${code}`;
|
||||
let answer = () => fail(409, code, message);
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
|
||||
|
||||
@@ -59,3 +59,9 @@ test('the toolbar list merges prompt and image categories once each', async () =
|
||||
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
|
||||
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
|
||||
});
|
||||
|
||||
test('the outcome names what was set: images by default, prompts for the Library bar (#437)', async () => {
|
||||
const {setOutcome} = await source;
|
||||
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 1 prompt.');
|
||||
assert.equal(setOutcome({updated:2, unchanged:0, not_found:0}, 'A', 'prompt'), 'Category “A” set on 2 prompts.');
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// Run with: node --test tests/js/workspace-images-source-recovery.test.js
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const load = () => import('../../web/js/workspace/images-source-recovery.mjs');
|
||||
|
||||
const OLD = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'sub/a.png', filename: 'a.png' });
|
||||
const FRESH = Object.freeze({ root_index: 1, root_scope: 'b'.repeat(64), path: 'sub/a.png', filename: 'a.png', url: '/u' });
|
||||
|
||||
function fakeApi(answer) {
|
||||
const calls = [];
|
||||
return { calls, post: async (url, body, options) => { calls.push({ url, body, options }); return answer(body); } };
|
||||
}
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('rescopeImage posts only the target and returns the fresh descriptor', async () => {
|
||||
const { rescopeImage, RESCOPE_URL } = await load();
|
||||
const api = fakeApi(() => ({ success: true, image: FRESH }));
|
||||
const signal = new AbortController().signal;
|
||||
const fresh = await rescopeImage(api, OLD, { signal });
|
||||
assert.deepEqual(fresh, FRESH);
|
||||
assert.ok(Object.isFrozen(fresh));
|
||||
assert.deepEqual(api.calls, [{ url: RESCOPE_URL, body: { target: { root_index: 0, root_scope: OLD.root_scope, path: 'sub/a.png' } }, options: { signal } }]);
|
||||
});
|
||||
|
||||
test('rescopeImage answers null for a refusal, a malformed answer, another path or a bad target', async () => {
|
||||
const { rescopeImage } = await load();
|
||||
const refused = { post: async () => { throw Object.assign(new Error('gone'), { status: 409, code: 'source-changed' }); } };
|
||||
assert.equal(await rescopeImage(refused, OLD), null);
|
||||
const lost = { post: async () => { throw new TypeError('Failed to fetch'); } };
|
||||
await assert.rejects(rescopeImage(lost, OLD), TypeError, 'a lost answer is not final');
|
||||
for (const answer of [null, { success: false }, { success: true, image: { ...FRESH, root_scope: 'x' } },
|
||||
{ success: true, image: { ...FRESH, path: 'other.png' } }, { success: true, image: { ...FRESH, root_index: -1 } }]) {
|
||||
assert.equal(await rescopeImage(fakeApi(() => answer), OLD), null);
|
||||
}
|
||||
const api = fakeApi(() => ({ success: true, image: FRESH }));
|
||||
for (const image of [null, { ...OLD, root_scope: 'short' }, { ...OLD, path: '' }, { ...OLD, root_index: 1.5 }]) {
|
||||
assert.equal(await rescopeImage(api, image), null);
|
||||
}
|
||||
assert.equal(api.calls.length, 0, 'a malformed target is never sent');
|
||||
});
|
||||
|
||||
test('one rescope per held pair: callers share it, a tried pair answers its first result, reset forgets', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
let answer = { success: true, image: FRESH };
|
||||
const api = fakeApi(() => answer);
|
||||
const applied = [];
|
||||
const recovery = createSourceRecovery({ api, generation: () => 7, apply: async (image, fresh, started) => { applied.push([image.path, fresh.root_index, started]); return fresh; } });
|
||||
const [first, second] = await Promise.all([recovery.recover(OLD), recovery.recover({ ...OLD })]);
|
||||
assert.deepEqual([first, second], [FRESH, FRESH]);
|
||||
assert.equal(api.calls.length, 1);
|
||||
assert.deepEqual(applied, [['sub/a.png', 1, 7]]);
|
||||
answer = { success: false };
|
||||
assert.deepEqual(await recovery.recover(OLD), FRESH, 'the pair is not asked again');
|
||||
recovery.reset();
|
||||
assert.equal(await recovery.recover(OLD), null, 'after a reset the pair is asked again');
|
||||
assert.equal(api.calls.length, 2);
|
||||
assert.equal(await recovery.recover({ ...OLD, root_scope: 'bad' }), null);
|
||||
assert.equal(api.calls.length, 2);
|
||||
});
|
||||
|
||||
test('a refused pair never loops, an aborted page applies nothing and a failing apply is null', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
const api = fakeApi(() => ({ success: true, image: FRESH }));
|
||||
const page = new AbortController();
|
||||
let applied = 0;
|
||||
const recovery = createSourceRecovery({ api, signal: page.signal, apply: async () => { applied += 1; return FRESH; } });
|
||||
page.abort();
|
||||
assert.equal(await recovery.recover(OLD), null);
|
||||
assert.equal(applied, 0);
|
||||
const failing = createSourceRecovery({ api, apply: async () => { throw new Error('page gone'); } });
|
||||
assert.equal(await failing.recover(OLD), null);
|
||||
assert.throws(() => createSourceRecovery({ api }), TypeError);
|
||||
// An api without post (an embedded gallery's page adapter) finds nothing.
|
||||
assert.equal(await createSourceRecovery({ api: {}, apply: async () => FRESH }).recover(OLD), null);
|
||||
});
|
||||
|
||||
test('the memo forgets its oldest pairs past its limit', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
const api = fakeApi(body => ({ success: true, image: { ...FRESH, path: body.target.path } }));
|
||||
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
|
||||
for (let index = 0; index < 65; index++) await recovery.recover({ ...OLD, path: `f${index}.png` });
|
||||
assert.equal(api.calls.length, 65);
|
||||
await recovery.recover({ ...OLD, path: 'f64.png' });
|
||||
assert.equal(api.calls.length, 65, 'a recent pair is still remembered');
|
||||
await recovery.recover({ ...OLD, path: 'f0.png' });
|
||||
assert.equal(api.calls.length, 66, 'the oldest pair was forgotten');
|
||||
});
|
||||
|
||||
function fakePage({ selected = true, root = 'all', current = true } = {}) {
|
||||
const log = [];
|
||||
const page = {
|
||||
filters: { root, subfolder: root === 'all' ? '' : 'sub' },
|
||||
current: started => { log.push(['current', started]); return current; },
|
||||
selected: image => selected && image.path === OLD.path,
|
||||
inspect: image => log.push(['inspect', image.root_index]),
|
||||
dropSelection: () => log.push(['drop']),
|
||||
stream: {
|
||||
refresh: async () => { log.push(['refresh']); throw new Error('read failed'); },
|
||||
reset: (filters, options) => log.push(['reset', { ...filters }, options]),
|
||||
},
|
||||
updateScope: () => log.push(['scope']),
|
||||
closeViewer: () => log.push(['close-viewer']),
|
||||
clearChecks: () => log.push(['clear-checks']),
|
||||
listsNow: () => true,
|
||||
rediscover: () => log.push(['rediscover']),
|
||||
};
|
||||
return { page, log };
|
||||
}
|
||||
|
||||
test('a renumbered source keeps nothing by its old key: only the file stays selected', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const { page, log } = fakePage({ root: '0' });
|
||||
assert.equal(await recoveredSource(page)(OLD, FRESH, 3), FRESH);
|
||||
assert.deepEqual(log, [['current', 3], ['close-viewer'], ['clear-checks'], ['inspect', 1], ['scope'],
|
||||
['reset', { root: '1', subfolder: 'sub' }, { load: true }], ['rediscover']]);
|
||||
});
|
||||
|
||||
test('another Source filter falls back to All sources; an unselected file drops the old selection', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const { page, log } = fakePage({ root: '2', selected: false });
|
||||
await recoveredSource(page)(OLD, FRESH, 0);
|
||||
assert.deepEqual(log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
|
||||
assert.ok(log.some(entry => entry[0] === 'drop'));
|
||||
assert.ok(!log.some(entry => entry[0] === 'inspect'));
|
||||
const all = fakePage({ root: 'all' });
|
||||
await recoveredSource(all.page)(OLD, FRESH, 0);
|
||||
assert.deepEqual(all.log.find(entry => entry[0] === 'reset')[1], { root: 'all', subfolder: '' });
|
||||
});
|
||||
|
||||
test('an unchanged numbering re-reads in place; a page that moved on is left alone', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const same = { ...FRESH, root_index: 0 };
|
||||
const { page, log } = fakePage();
|
||||
assert.equal(await recoveredSource(page)(OLD, same, 1), same);
|
||||
assert.deepEqual(log, [['current', 1], ['inspect', 0], ['refresh']], 'a failed re-read is not an error');
|
||||
const unselected = fakePage({ selected: false });
|
||||
await recoveredSource(unselected.page)(OLD, same, 1);
|
||||
assert.deepEqual(unselected.log, [['current', 1], ['refresh']]);
|
||||
const moved = fakePage({ current: false });
|
||||
assert.equal(await recoveredSource(moved.page)(OLD, FRESH, 1), FRESH, 'the dialog may still retarget');
|
||||
assert.deepEqual(moved.log, [['current', 1]]);
|
||||
await flush();
|
||||
});
|
||||
|
||||
test('a scope that still names its root is not stale: nothing is applied', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const { page, log } = fakePage();
|
||||
assert.equal(await recoveredSource(page)(OLD, { ...OLD, url: '/u' }, 1), null);
|
||||
assert.deepEqual(log, []);
|
||||
});
|
||||
|
||||
test('a lost rescope answer is not remembered: the pair is asked again', async () => {
|
||||
const { createSourceRecovery } = await load();
|
||||
let down = true;
|
||||
const api = { calls: 0, post: async () => { api.calls += 1; if (down) throw Object.assign(new Error('bad gateway'), { status: 502 }); return { success: true, image: FRESH }; } };
|
||||
const recovery = createSourceRecovery({ api, apply: async (_image, fresh) => fresh });
|
||||
assert.equal(await recovery.recover(OLD), null);
|
||||
down = false;
|
||||
assert.deepEqual(await recovery.recover(OLD), FRESH);
|
||||
assert.equal(api.calls, 2);
|
||||
});
|
||||
|
||||
test('the same index under another scope (a changed key) re-reads in place and keeps the selection', async () => {
|
||||
const { recoveredSource } = await load();
|
||||
const rekeyed = { ...OLD, root_scope: 'c'.repeat(64) };
|
||||
const { page, log } = fakePage();
|
||||
page.stream.refresh = async () => { log.push(['refresh']); };
|
||||
assert.equal(await recoveredSource(page)(OLD, rekeyed, 2), rekeyed);
|
||||
assert.deepEqual(log, [['current', 2], ['inspect', 0], ['refresh']]);
|
||||
assert.ok(!log.some(entry => ['reset', 'drop', 'clear-checks', 'close-viewer'].includes(entry[0])));
|
||||
});
|
||||
@@ -141,6 +141,22 @@ test('the Favorites page asks the listing for favorites only (#249 D51)', async
|
||||
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
|
||||
});
|
||||
|
||||
test('a collection page asks the listing for that collection only (#331 D137)', async () => {
|
||||
const {createImageStream}=await source;let query;
|
||||
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
|
||||
await stream.reset({collection:'12',q:'fog'});
|
||||
assert.equal(query.get('collection'),'12');assert.equal(query.get('q'),'fog');assert.equal(query.has('favorites'),false);
|
||||
await stream.reset({collection:7});
|
||||
assert.equal(query.get('collection'),'7');
|
||||
for(const collection of ['',null,undefined]) {
|
||||
await stream.reset({collection});
|
||||
assert.equal(query.has('collection'),false);
|
||||
}
|
||||
for(const collection of ['0','07','x',-1,1.5,'1234567890',true])await assert.rejects(stream.reset({collection}),/Invalid collection filter/);
|
||||
// Favorites and a collection are never both a page's filter.
|
||||
await assert.rejects(stream.reset({collection:'3',favorites:true}),/Invalid collection filter/);
|
||||
});
|
||||
|
||||
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const calls=[];
|
||||
@@ -509,7 +525,35 @@ test('defaults apply to a new visit but not to the current view or a saved view'
|
||||
assert.deepEqual(initialImagesView({ saved: { q: '' }, preferences }), { layout: 'grid', density: 'comfortable' });
|
||||
// Without readable preferences nothing changes.
|
||||
assert.deepEqual(initialImagesView({}), { layout: 'grid', density: 'comfortable' });
|
||||
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'grid', density: 'comfortable' });
|
||||
// #451: a preference object without a layout falls back to the default layout, Masonry.
|
||||
assert.deepEqual(initialImagesView({ preferences: {} }), { layout: 'masonry', density: 'comfortable' });
|
||||
});
|
||||
|
||||
test('a default saved after the last visit started reaches the next visit unless the toolbar changed it (#451)', async () => {
|
||||
const { initialImagesView } = await source;
|
||||
const preferences = { workspaceLayout: 'masonry', workspaceDensity: 'compact' };
|
||||
const started = { layout: 'grid', density: 'comfortable' };
|
||||
// The visit kept the defaults it started from: the newer defaults win.
|
||||
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started }, preferences }),
|
||||
{ layout: 'masonry', density: 'compact' });
|
||||
// A toolbar choice is the current view's own, whatever the defaults became; each value on its own.
|
||||
assert.deepEqual(initialImagesView({ prior: { layout: 'list', density: 'comfortable', defaults: started }, preferences }),
|
||||
{ layout: 'list', density: 'compact' });
|
||||
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'large', defaults: started }, preferences }),
|
||||
{ layout: 'masonry', density: 'large' });
|
||||
// Unchanged defaults: the visit returns as it was.
|
||||
assert.deepEqual(initialImagesView({ prior: { layout: 'masonry', density: 'compact', defaults: { layout: 'masonry', density: 'compact' } }, preferences }),
|
||||
{ layout: 'masonry', density: 'compact' });
|
||||
// A saved view and an embedded gallery never get a prior; without preferences the prior is kept.
|
||||
assert.deepEqual(initialImagesView({ prior: { layout: 'grid', density: 'comfortable', defaults: started } }),
|
||||
{ layout: 'grid', density: 'comfortable' });
|
||||
});
|
||||
|
||||
test('the defaults a visit starts from are the fresh-visit defaults (#451)', async () => {
|
||||
const { visitDefaults } = await source;
|
||||
assert.deepEqual(visitDefaults({ workspaceLayout: 'list', workspaceDensity: 'large' }), { layout: 'list', density: 'large' });
|
||||
assert.deepEqual(visitDefaults({}), { layout: 'masonry', density: 'comfortable' });
|
||||
assert.deepEqual(visitDefaults(null), { layout: 'grid', density: 'comfortable' });
|
||||
});
|
||||
|
||||
test('listingFilter is the listing query as an object, without paging (#392)', async () => {
|
||||
@@ -517,5 +561,7 @@ test('listingFilter is the listing query as an object, without paging (#392)', a
|
||||
assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}),
|
||||
{q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'});
|
||||
assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'});
|
||||
// #331: "Select all N matching" on a collection page acts on that collection's listing.
|
||||
assert.deepEqual(listingFilter({collection:'4', q:'cat'}), {q:'cat', sort:'newest', collection:'4'});
|
||||
assert.throws(() => listingFilter({sort:'sideways'}), TypeError);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// #437 D148: the Prompt library's selection bar sets one category on the selected prompts, fenced by the storage epoch.
|
||||
const source = import('../../web/js/workspace/library-selection.mjs');
|
||||
|
||||
test('the dialog is the shared simpleInput with the prompt title, and the route and cap are the legacy ones', async () => {
|
||||
const {PROMPT_CATEGORY_DIALOG, CATEGORY_URL, MAX_CATEGORY_PROMPTS, TOO_MANY_PROMPTS} = await source;
|
||||
assert.deepEqual({...PROMPT_CATEGORY_DIALOG}, {title:'Categorize selected prompts', label:'Category', action:'Apply'});
|
||||
assert.equal(CATEGORY_URL, '/prompt_manager/bulk/category');
|
||||
assert.equal(MAX_CATEGORY_PROMPTS, 1000);
|
||||
assert.equal(TOO_MANY_PROMPTS, 'Select at most 1,000 prompts.');
|
||||
});
|
||||
|
||||
test('a confirmed write toasts the shared outcome with "prompt", invalidates and reloads', async () => {
|
||||
const {writeOutcome} = await source;
|
||||
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:2, unchanged:1, not_found:0}, null),
|
||||
{toast:'Category “Studio” set on 3 prompts.', committed:true, reload:true});
|
||||
assert.deepEqual(writeOutcome({success:true, category:'Studio', updated:1, unchanged:0, not_found:2}, null),
|
||||
{toast:'Category “Studio” set on 1 prompt. 2 could not be found.', committed:true, reload:true});
|
||||
});
|
||||
|
||||
test('none found, a restored storage, an unconfirmed answer and a refused write each say what happened', async () => {
|
||||
const {writeOutcome, NONE_FOUND, STORAGE_CHANGED, UNCONFIRMED, CATEGORY_FAILED} = await source;
|
||||
assert.deepEqual(writeOutcome({success:true, category:'X', updated:0, unchanged:0, not_found:3}, null),
|
||||
{toast:NONE_FOUND, committed:false, reload:true});
|
||||
assert.deepEqual(writeOutcome(null, {status:409, code:'storage-changed'}), {toast:STORAGE_CHANGED, committed:false, reload:true});
|
||||
for (const failure of [new TypeError('Failed to fetch'), {status:502}, {status:503}, {status:504}]) {
|
||||
assert.deepEqual(writeOutcome(null, failure), {toast:UNCONFIRMED, committed:true, reload:true});
|
||||
}
|
||||
assert.deepEqual(writeOutcome(null, null), {toast:UNCONFIRMED, committed:true, reload:true});
|
||||
for (const status of [400, 500]) {
|
||||
assert.deepEqual(writeOutcome(null, {status}), {toast:CATEGORY_FAILED, committed:false, reload:false});
|
||||
}
|
||||
assert.equal(STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No category was changed.');
|
||||
});
|
||||
|
||||
test('the field refuses control characters as validate_category does', async () => {
|
||||
const {categoryProblem, CONTROL_CHARACTERS} = await source;
|
||||
assert.equal(categoryProblem('Studio portraits'), null);
|
||||
assert.equal(categoryProblem('tab\there'), CONTROL_CHARACTERS);
|
||||
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);
|
||||
});
|
||||
@@ -104,3 +104,13 @@ test('archived variant reads use their own scope and cursor without changing the
|
||||
const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false);
|
||||
assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'}));
|
||||
});
|
||||
test('the stream keeps the first page\'s storage epoch and refuses a later page from another one (#437)',async()=>{
|
||||
const {createLibraryStream}=await source;const A='11111111-1111-4111-8111-111111111111',B='22222222-2222-4222-8222-222222222222';
|
||||
const pending=[{...page([item(1)],'next'),library_epoch:A},{...page([item(2)],'more'),library_epoch:A},{...page([item(3)]),library_epoch:B}];
|
||||
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.epoch,null);
|
||||
await s.reset();assert.equal(s.state.epoch,A);await s.loadMore();assert.equal(s.state.epoch,A);assert.equal(s.state.items.length,2);
|
||||
await s.loadMore();assert.ok(s.state.error);assert.equal(s.state.items.length,2);assert.equal(s.state.epoch,A);
|
||||
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.epoch,A);restored.dispose();
|
||||
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
|
||||
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ test('normalizes new workspace keys and keeps legacy keys', () => {
|
||||
// Invalid workspace values fall back to the defaults, as every legacy key does.
|
||||
store.text = JSON.stringify({ workspaceLayout: 'mosaic', workspaceDensity: 7, videoMute: 'no' });
|
||||
const fallback = module.readPreferences(() => store).values;
|
||||
assert.equal(fallback.workspaceLayout, 'grid');
|
||||
assert.equal(fallback.workspaceLayout, 'masonry'); // #451: the default layout
|
||||
assert.equal(fallback.workspaceDensity, 'comfortable');
|
||||
assert.equal(fallback.videoMute, true);
|
||||
// The legacy normalizer knows the new keys and still keeps unknown ones.
|
||||
@@ -94,7 +94,7 @@ test('denied storage reports tab-only', () => {
|
||||
module.resetTabPreferences();
|
||||
const read = module.readPreferences(denied);
|
||||
assert.equal(read.persisted, false);
|
||||
assert.equal(read.values.workspaceLayout, 'grid');
|
||||
assert.equal(read.values.workspaceLayout, 'masonry');
|
||||
const result = module.writePreferences({ workspaceLayout: 'list', videoAutoplay: true }, denied);
|
||||
assert.deepEqual(result, { persisted: false, conflicts: [] });
|
||||
// The change is kept for this tab: every reader in it (a new Images visit, the viewer) sees it.
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/saved-views.mjs');
|
||||
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
|
||||
|
||||
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
|
||||
@@ -12,8 +12,17 @@ test('a saved state always holds every page key and keeps only values its page a
|
||||
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
|
||||
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
|
||||
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true, collection: '' };
|
||||
assert.deepEqual(normalizeViewState('images', full), full);
|
||||
// #331 D140: a collection id is '' or 1 to 9 digits without a leading zero; an old view without the key reads ''.
|
||||
for (const collection of ['1', '12', '999999999']) {
|
||||
assert.equal(normalizeViewState('images', { collection }).collection, collection);
|
||||
}
|
||||
for (const collection of ['0', 'x', 12, '012', '1234567890', null]) {
|
||||
assert.equal(normalizeViewState('images', { collection }).collection, '');
|
||||
}
|
||||
// Favorites and a collection are never both a view's filter: the collection wins, as the listing's URL does.
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, collection: '4' }), { ...full, favorites: false, collection: '4' });
|
||||
// D51: favorites is a boolean; anything else is the whole listing.
|
||||
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
|
||||
// Each rejected value falls back on its own; the rest of the view still applies.
|
||||
@@ -63,11 +72,17 @@ test('the listing keeps usable views in order and normalizes their state', async
|
||||
|
||||
test('page filters and saved states convert both ways', async () => {
|
||||
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
|
||||
const state = imagesViewState(filters);
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
|
||||
assert.deepEqual(imagesFilters(state), filters);
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
|
||||
// #331 D140: the open collection round-trips; a missing one is ''.
|
||||
const inCollection = { ...filters, favorites: false, collection: '7' };
|
||||
assert.equal(imagesViewState(inCollection).collection, '7');
|
||||
assert.deepEqual(imagesFilters(imagesViewState(inCollection)), inCollection);
|
||||
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
|
||||
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
|
||||
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
|
||||
|
||||
@@ -311,3 +311,29 @@ test('Needs review is a native SAVED VIEWS route titled Review, with its own cou
|
||||
assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']);
|
||||
assert.deepEqual([...counts], [['review', 4]]);
|
||||
});
|
||||
|
||||
// Collections (#331 D137): `#/collection/<id>` opens Images filtered to one collection; its breadcrumb is "Collection".
|
||||
test('collection links resolve to their id, title Collection, and re-render only when the id changes', async () => {
|
||||
const {resolveRoute,mountRouter,isHandoff,routeTitle,routes,COLLECTION_ROUTE}=await source;
|
||||
assert.deepEqual(COLLECTION_ROUTE,{id:'collection',label:'Collection'});
|
||||
assert.ok(Object.isFrozen(COLLECTION_ROUTE));
|
||||
const route=resolveRoute('#/collection/7');
|
||||
assert.deepEqual([route.id,route.collectionId,route.hash,routeTitle(route)],['collection',7,'#/collection/7','Collection']);
|
||||
assert.equal(isHandoff(route),false);
|
||||
assert.equal(resolveRoute('#/collection/999999999').collectionId,999999999);
|
||||
assert.equal(routes.some(item=>item.id==='collection'),false,'a collection is not an Overview card or a WORKSPACE link');
|
||||
// The listing's and routes' id rule: 1 to 9 digits without a leading zero.
|
||||
for(const hash of ['#/collection','#/collection/','#/collection/0','#/collection/07','#/collection/-2','#/collection/1.5',
|
||||
'#/collection/1234567890','#/collection/3/x','#/collection/abc','#/collections/3']) {
|
||||
assert.equal(resolveRoute(hash).id,'images',hash);
|
||||
}
|
||||
const win=new EventTarget();win.location={hash:'#/collection/3'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.collectionId]));
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['collection',3],['collection',4],['saved',undefined],['collection',4]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -162,7 +162,8 @@ test('reset restores defaults without touching other keys', async () => {
|
||||
const reset = dom.find(surface.children.at(-1), node => node.tagName === 'BUTTON');
|
||||
assert.equal(reset.textContent, 'Reset');
|
||||
reset.click();
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'grid', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
|
||||
// #451: the default layout is Masonry.
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
|
||||
// The host fields and every key outside the display defaults are left as they are.
|
||||
assert.deepEqual(drafts.changes('host'), { result_timeout: 9 });
|
||||
assert.deepEqual([...DISPLAY_KEYS].sort(), Object.keys(BROWSER).sort());
|
||||
|
||||
@@ -192,6 +192,71 @@ test('views reload once after a restore this page saw finish; a failed restore c
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
|
||||
// #415: a fake document whose open dialogs the test sets, and a MutationObserver stand-in it triggers.
|
||||
function dialogPage() {
|
||||
const page = { open: 0, observers: new Set() };
|
||||
page.doc = { documentElement: {}, querySelector: selector => (selector === 'dialog[open]' && page.open > 0 ? {} : null) };
|
||||
page.Observer = class {
|
||||
constructor(callback) { this.callback = callback; }
|
||||
observe(target, options) { this.target = target; this.options = options; page.observers.add(this); }
|
||||
disconnect() { page.observers.delete(this); }
|
||||
};
|
||||
page.mutate = open => { page.open = open; for (const observer of [...page.observers]) observer.callback([]); };
|
||||
return page;
|
||||
}
|
||||
|
||||
test('the restore reload runs at once when no dialog is open', async () => {
|
||||
const { createRestoreReload } = await backups;
|
||||
const page = dialogPage();
|
||||
let reloads = 0;
|
||||
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
|
||||
reload.request();
|
||||
assert.equal(reloads, 1);
|
||||
assert.equal(page.observers.size, 0, 'nothing waits');
|
||||
reload.request();
|
||||
assert.equal(reloads, 2);
|
||||
});
|
||||
|
||||
test('the restore reload waits for every open dialog to close, then runs once (#415)', async () => {
|
||||
const { createRestoreReload } = await backups;
|
||||
const page = dialogPage();
|
||||
page.open = 1;
|
||||
let reloads = 0;
|
||||
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
|
||||
reload.request();
|
||||
reload.request();
|
||||
assert.equal(reloads, 0, 'an open dialog holds the reload');
|
||||
assert.equal(page.observers.size, 1, 'one observer for repeated requests');
|
||||
const [observer] = page.observers;
|
||||
assert.equal(observer.target, page.doc.documentElement);
|
||||
assert.deepEqual(observer.options, { subtree: true, childList: true, attributes: true, attributeFilter: ['open'] });
|
||||
page.mutate(1);
|
||||
assert.equal(reloads, 0, 'a dialog swapped for another keeps it waiting');
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 1);
|
||||
assert.equal(page.observers.size, 0, 'the observer stops once it ran');
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 1);
|
||||
page.open = 1;
|
||||
reload.request();
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 2, 'a later restore waits again');
|
||||
});
|
||||
|
||||
test('disposing drops a waiting restore reload', async () => {
|
||||
const { createRestoreReload } = await backups;
|
||||
const page = dialogPage();
|
||||
page.open = 1;
|
||||
let reloads = 0;
|
||||
const reload = createRestoreReload(() => { reloads += 1; }, { doc: page.doc, Observer: page.Observer });
|
||||
reload.request();
|
||||
reload.dispose();
|
||||
assert.equal(page.observers.size, 0);
|
||||
page.mutate(0);
|
||||
assert.equal(reloads, 0);
|
||||
reload.dispose();
|
||||
});
|
||||
|
||||
test('a downloaded backup the server could not verify says so', async () => {
|
||||
const { downloadDatabaseBackup, BACKUP_DONE, backupUnverified } = await scan;
|
||||
const toasts = [];
|
||||
|
||||
@@ -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);
|
||||
@@ -908,6 +911,34 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
|
||||
}
|
||||
});
|
||||
|
||||
// #415: a finished restore ends an open review (its reviewed plan no longer describes storage) as a dismissal, and
|
||||
// leaves any other dialog, with what was typed in it, open.
|
||||
test('closeReviewDialog closes only an open review, as dismissed', async () => {
|
||||
const { openReviewDialog, openInputDialog, closeReviewDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
closeReviewDialog();
|
||||
const outcomes = [];
|
||||
openInputDialog({ scope, title: 'Save view', label: 'Name', value: 'typed', action: 'Save', onApply() {} });
|
||||
closeReviewDialog();
|
||||
assert.equal(modal(dom).open, true, 'an input dialog stays');
|
||||
assert.equal(byId(dom, 'action-input').value, 'typed');
|
||||
openReviewDialog({ scope, title: 'Restore preview', grid: [['Integrity', 'Verified']], cancelLabel: 'Cancel',
|
||||
primary: { label: 'Restore', danger: true }, onStart: () => outcomes.push('start'), onDismiss: () => outcomes.push('dismiss') });
|
||||
closeReviewDialog();
|
||||
assert.deepEqual(outcomes, ['dismiss']);
|
||||
assert.equal(modal(dom), undefined, 'the shell is gone');
|
||||
closeReviewDialog();
|
||||
assert.deepEqual(outcomes, ['dismiss']);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('review dialog backup box keeps the dialog open and toasts the outcome', async () => {
|
||||
const { openReviewDialog, closeWorkspaceDialog, SCAN_BACKUP_COPY } = await dialogsModule;
|
||||
const { downloadDatabaseBackup, BACKUP_DONE, BACKUP_FAILED } = await scanModule;
|
||||
|
||||
+15
-9
@@ -2205,24 +2205,30 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
|
||||
|
||||
def test_ingest_link_failures_are_tolerated(self):
|
||||
"""A failed link rolls the new prompt back: found, nothing left behind."""
|
||||
f = Path("gen.png")
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a dog"))}
|
||||
self.api.db.link_image_to_prompt = _raise
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.api.db._insert_image_link = _raise
|
||||
with self.assertLogs("prompt_manager.database.scanned_prompts", "ERROR"):
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_ingest_save_failure_counts_as_found(self):
|
||||
f = Path("gen.png")
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a bird"))}
|
||||
self.api.db.save_prompt = lambda *a, **k: None
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
with patch(
|
||||
"py.api.admin.store_scanned_prompt", return_value=("found", None)
|
||||
) as store:
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(store.call_args.args[3], "a bird")
|
||||
|
||||
def test_batch_counts_and_per_file_errors(self):
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a fish"))}
|
||||
self.api.db.get_prompt_by_hash = _raise
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
with patch("py.api.admin.store_scanned_prompt", side_effect=_raise):
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
self.assertEqual(
|
||||
counts,
|
||||
{
|
||||
|
||||
@@ -2556,9 +2556,10 @@ class TestRootScopeServing(ImageAPITestCase):
|
||||
listing = await self.client.get("/prompt_manager/images/output?mode=workspace")
|
||||
descriptor = (await listing.json())["images"][0]
|
||||
self.assertRegex(descriptor.get("root_scope", ""), r"^[0-9a-f]{64}$")
|
||||
with patch.object(
|
||||
images_module, "_WORKSPACE_CURSOR_SECRET", b"different-process-key"
|
||||
):
|
||||
from utils.workspace_keys import WorkspaceKeys
|
||||
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
await self.assert_no_access("image.png", scope=descriptor["root_scope"])
|
||||
|
||||
@@ -1418,9 +1418,10 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
async def test_root_scope_rotated_key_and_removed_index_skip_reader_and_db(self):
|
||||
png(self.output_dir / "image.png", prompt=graph())
|
||||
descriptor = await self.scoped_descriptor()
|
||||
import py.api.images as images_module
|
||||
from utils.workspace_keys import WorkspaceKeys
|
||||
|
||||
with patch.object(images_module, "_WORKSPACE_CURSOR_SECRET", b"new-key"):
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
await self.assert_scope_skips_access(descriptor.get("root_scope", "0" * 64))
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""POST /prompt_manager/images/rescope: a stale target's current identity (#430).
|
||||
|
||||
A page that holds ``{root_index, root_scope, path}`` after the sources were
|
||||
renumbered asks where that file is now. The server maps the target only when
|
||||
its scope was issued, at its old index, for one of the current roots, so the
|
||||
answer names nothing the page could not already address; a removed or
|
||||
replaced root, a foreign scope or a changed key stay refused.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
except ImportError: # imported as tests.test_api_rescope
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
|
||||
from utils.workspace_keys import WorkspaceKeys # noqa: E402
|
||||
|
||||
RESCOPE = "/prompt_manager/images/rescope"
|
||||
|
||||
|
||||
class TestRescopeRoute(ImageAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
self.other_dir = self.comfy_dir / "other"
|
||||
self.other_dir.mkdir()
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
make_png(self.other_dir / "sub" / "a.png", color=(0, 0, 255))
|
||||
return app
|
||||
|
||||
def target(self, path="sub/a.png", index=0, root=None):
|
||||
root = root or self.output_dir
|
||||
return {
|
||||
"root_index": index,
|
||||
"root_scope": self.api._gallery_root_scope(root, index),
|
||||
"path": path,
|
||||
}
|
||||
|
||||
async def post(self, body):
|
||||
response = await self.client.post(RESCOPE, json=body)
|
||||
data = await response.json()
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
|
||||
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
|
||||
return response.status, data
|
||||
|
||||
async def listed(self):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
return {
|
||||
(item["root_index"], item["path"]): item
|
||||
for item in (await response.json())["images"]
|
||||
}
|
||||
|
||||
async def test_an_unchanged_root_answers_the_listing_descriptor(self):
|
||||
status, data = await self.post({"target": self.target()})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["image"], (await self.listed())[(0, "sub/a.png")])
|
||||
|
||||
async def test_a_renumbered_root_maps_to_its_new_index(self):
|
||||
held = self.target()
|
||||
self.api._get_all_output_dirs = lambda: [self.other_dir, self.output_dir]
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual(status, 200)
|
||||
image = data["image"]
|
||||
self.assertEqual((image["root_index"], image["path"]), (1, "sub/a.png"))
|
||||
self.assertEqual(
|
||||
image["root_scope"], self.api._gallery_root_scope(self.output_dir, 1)
|
||||
)
|
||||
self.assertEqual(image, (await self.listed())[(1, "sub/a.png")])
|
||||
|
||||
async def test_a_removed_replaced_or_foreign_root_is_source_changed(self):
|
||||
held = self.target()
|
||||
cases = {
|
||||
"removed": lambda: [],
|
||||
"replaced": lambda: [self.other_dir],
|
||||
}
|
||||
for name, roots in cases.items():
|
||||
with self.subTest(name):
|
||||
self.api._get_all_output_dirs = roots
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
for scope in ("0" * 64, self.api._gallery_root_scope(self.output_dir, 1)):
|
||||
with self.subTest(scope=scope[:8]):
|
||||
status, data = await self.post(
|
||||
{"target": {**held, "root_scope": scope}}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
|
||||
async def test_a_changed_key_cannot_be_mapped(self):
|
||||
held = self.target()
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
|
||||
async def test_the_first_of_two_spellings_of_one_root_wins(self):
|
||||
held = self.target()
|
||||
self.api._get_all_output_dirs = lambda: [
|
||||
self.other_dir,
|
||||
self.output_dir,
|
||||
self.output_dir / "sub" / "..",
|
||||
]
|
||||
status, data = await self.post({"target": held})
|
||||
self.assertEqual((status, data["image"]["root_index"]), (200, 1))
|
||||
|
||||
async def test_a_missing_file_is_not_found(self):
|
||||
status, data = await self.post({"target": self.target("sub/gone.png")})
|
||||
self.assertEqual((status, data["code"]), (404, "file-missing"))
|
||||
(self.output_dir / "dir.png").mkdir()
|
||||
status, data = await self.post({"target": self.target("dir.png")})
|
||||
self.assertEqual((status, data["code"]), (404, "file-missing"))
|
||||
|
||||
async def test_a_path_leaving_the_root_is_refused(self):
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
self._make_symlink(outside, self.output_dir / "escape.png")
|
||||
status, _data = await self.post({"target": self.target("escape.png")})
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
async def test_previews_are_not_rescoped(self):
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
status, _data = await self.post(
|
||||
{"target": self.target("thumbnails/a_thumb.png")}
|
||||
)
|
||||
self.assertEqual(status, 403)
|
||||
|
||||
async def test_malformed_bodies_are_refused_before_any_file_access(self):
|
||||
good = self.target()
|
||||
bodies = [
|
||||
b"{",
|
||||
[],
|
||||
{},
|
||||
{"target": good, "extra": 1},
|
||||
{"target": {**good, "root_index": -1}},
|
||||
{"target": {**good, "root_index": True}},
|
||||
{"target": {**good, "root_index": 4096}},
|
||||
# Past Python's int-to-text digit limit: never a 500.
|
||||
b'{"target": {"root_index": 1' + b"0" * 5000 + b"}}",
|
||||
{"target": {**good, "root_scope": "A" * 64}},
|
||||
{"target": {**good, "root_scope": "abc"}},
|
||||
{"target": {**good, "path": ""}},
|
||||
{"target": {**good, "path": "../a.png"}},
|
||||
{"target": {**good, "path": "/abs.png"}},
|
||||
{"target": {**good, "path": "C:/a.png"}},
|
||||
{"target": {**good, "path": "sub\\a.png"}},
|
||||
{"target": {**good, "path": "sub//a.png"}},
|
||||
{"target": {**good, "path": "notes.txt"}},
|
||||
{"target": {**good, "path": "x" * 2000 + ".png"}},
|
||||
]
|
||||
with patch("pathlib.Path.is_file", side_effect=AssertionError("file access")):
|
||||
for body in bodies:
|
||||
with self.subTest(body=str(body)[:60]):
|
||||
if isinstance(body, bytes):
|
||||
response = await self.client.post(
|
||||
RESCOPE,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
else:
|
||||
response = await self.client.post(RESCOPE, json=body)
|
||||
self.assertIn(response.status, (400, 403))
|
||||
self.assertFalse((await response.json())["success"])
|
||||
@@ -1882,9 +1882,13 @@ class TestWorkspaceCursorEncoding(unittest.TestCase):
|
||||
for order in ("newest", "oldest", "name", "name-desc"):
|
||||
with self.subTest(sort=order):
|
||||
key = _workspace_key(entry, order)
|
||||
token = _workspace_cursor(key, "scope")
|
||||
token = _workspace_cursor(key, "scope", b"k" * 32)
|
||||
self.assertLessEqual(len(token), 4096)
|
||||
self.assertEqual(_workspace_cursor_key(token, "scope", order), key)
|
||||
self.assertEqual(
|
||||
_workspace_cursor_key(token, "scope", order, b"k" * 32), key
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
_workspace_cursor_key(token, "scope", order, b"o" * 32)
|
||||
|
||||
|
||||
# The pre-#379 candidate read, kept as the differential reference for the
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""A ComfyUI restart keeps an open workspace page's scopes and cursors (#430).
|
||||
|
||||
A restart is a new ``PromptManagerAPI`` on the same database in a new
|
||||
process. Here a second API instance on its own aiohttp server stands in for
|
||||
it (``tests/test_workspace_keys.py`` also derives the scope in a real child
|
||||
process); it must accept every ``root_scope`` and listing cursor the first one
|
||||
handed out, while a changed root list or key still refuses them.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import quote
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
except ImportError: # imported as tests.test_api_workspace_restart
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from utils.workspace_keys import KEY_FILE_NAME, WorkspaceKeys # noqa: E402
|
||||
|
||||
LISTING = "/prompt_manager/images/output"
|
||||
|
||||
|
||||
def _payload(token):
|
||||
raw = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
|
||||
return json.loads(raw[32:])
|
||||
|
||||
|
||||
class TestRestartKeepsTheOpenPage(ImageAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
for index in range(3):
|
||||
make_png(self.output_dir / f"image_{index}.png")
|
||||
return app
|
||||
|
||||
async def first_page(self, client=None, **params):
|
||||
response = await (client or self.client).get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", **params}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
return await response.json()
|
||||
|
||||
async def restarted(self):
|
||||
"""A second API on the same database and roots, on its own server."""
|
||||
api = PromptManagerAPI(db=PromptDatabase(self._temp_db.name))
|
||||
api._get_all_output_dirs = lambda: [self.output_dir]
|
||||
api._find_comfyui_output_dir = lambda: str(self.output_dir)
|
||||
app = web.Application()
|
||||
routes = web.RouteTableDef()
|
||||
api.add_routes(routes)
|
||||
app.router.add_routes(routes)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
|
||||
async def close():
|
||||
await client.close()
|
||||
api.close_auto_thumbnails()
|
||||
api.db.close_all()
|
||||
|
||||
self.addAsyncCleanup(close)
|
||||
return api, client
|
||||
|
||||
async def test_a_restart_keeps_every_held_scope_and_cursor_valid(self):
|
||||
page = await self.first_page()
|
||||
image, cursor = page["images"][0], page["next_cursor"]
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(os.path.dirname(self._temp_db.name), KEY_FILE_NAME)
|
||||
)
|
||||
)
|
||||
_api, client = await self.restarted()
|
||||
|
||||
more = await client.get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
|
||||
)
|
||||
self.assertEqual(more.status, 200, await more.text())
|
||||
self.assertNotEqual((await more.json())["images"][0]["path"], image["path"])
|
||||
|
||||
inspected = await client.get(
|
||||
f"/prompt_manager/images/inspect/{quote(image['path'])}",
|
||||
params={"root": "0", "root_scope": image["root_scope"]},
|
||||
)
|
||||
self.assertEqual(inspected.status, 200, await inspected.text())
|
||||
served = await client.get(
|
||||
f"/prompt_manager/images/serve/{quote(image['path'])}",
|
||||
params={"root": "0", "root_scope": image["root_scope"]},
|
||||
)
|
||||
self.assertEqual(served.status, 200)
|
||||
relisted = await self.first_page(client)
|
||||
self.assertEqual(relisted["images"][0]["root_scope"], image["root_scope"])
|
||||
|
||||
async def test_a_key_change_refuses_the_cursor_as_stale(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
rotated = WorkspaceKeys(b"n" * 32, b"m" * 32, False)
|
||||
with patch.object(self.api, "_workspace_keys", return_value=rotated):
|
||||
response = await self.client.get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual((await response.json())["code"], "cursor-stale")
|
||||
|
||||
async def test_renumbered_roots_refuse_the_cursor_as_stale(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
other = self.comfy_dir / "other"
|
||||
other.mkdir()
|
||||
self.api._get_all_output_dirs = lambda: [other, self.output_dir]
|
||||
response = await self.client.get(
|
||||
LISTING, params={"mode": "workspace", "limit": "1", "cursor": cursor}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual((await response.json())["code"], "cursor-stale")
|
||||
|
||||
async def test_another_listing_refuses_the_cursor_as_stale(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
response = await self.client.get(
|
||||
LISTING,
|
||||
params={"mode": "workspace", "limit": "1", "cursor": cursor, "q": "x"},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual((await response.json())["code"], "cursor-stale")
|
||||
|
||||
async def test_a_malformed_cursor_is_a_plain_bad_request(self):
|
||||
# A 32-byte signature over a payload is well formed: a wrong one is stale.
|
||||
for cursor in ("!!", "a" * 5000, base64.urlsafe_b64encode(b"x" * 20).decode()):
|
||||
with self.subTest(cursor=cursor[:10]):
|
||||
response = await self.client.get(
|
||||
LISTING,
|
||||
params={"mode": "workspace", "limit": "1", "cursor": cursor},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertNotEqual((await response.json()).get("code"), "cursor-stale")
|
||||
|
||||
async def test_the_cursor_carries_no_digest_of_the_root_paths(self):
|
||||
cursor = (await self.first_page())["next_cursor"]
|
||||
payload = _payload(cursor)
|
||||
# v2: the listing scope (it hashes every absolute root) is in the MAC only.
|
||||
self.assertEqual(set(payload), {"v", "key"})
|
||||
self.assertEqual(payload["v"], 2)
|
||||
|
||||
async def test_another_database_folder_gets_other_scopes(self):
|
||||
page = await self.first_page()
|
||||
other_db = self.comfy_dir / "elsewhere" / "prompts.db"
|
||||
other_db.parent.mkdir()
|
||||
api = PromptManagerAPI(db=PromptDatabase(str(other_db)))
|
||||
self.addCleanup(api.db.close_all)
|
||||
self.addCleanup(api.close_auto_thumbnails)
|
||||
self.assertNotEqual(
|
||||
api._gallery_root_scope(self.output_dir, 0), page["images"][0]["root_scope"]
|
||||
)
|
||||
|
||||
|
||||
# A fresh interpreter: the API on the given database, as after a restart.
|
||||
_CHILD = """
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from tests.host_stubs import install_folder_paths_stub
|
||||
install_folder_paths_stub()
|
||||
sys.modules.setdefault("server", MagicMock())
|
||||
from database.operations import PromptDatabase
|
||||
from py.api import PromptManagerAPI
|
||||
from py.api.images import _workspace_cursor_key
|
||||
api = PromptManagerAPI(db=PromptDatabase(sys.argv[2]))
|
||||
scope = api._gallery_root_scope(Path(sys.argv[3]), 0)
|
||||
secret = api._workspace_keys().cursor
|
||||
key = _workspace_cursor_key(sys.argv[4], "s" * 64, "newest", secret)
|
||||
api.close_auto_thumbnails()
|
||||
api.db.close_all()
|
||||
print(json.dumps({"scope": scope, "key": list(key)}))
|
||||
"""
|
||||
|
||||
|
||||
class TestRestartInANewProcess(ImageAPITestCase):
|
||||
"""The real thing: two other interpreters on the same database."""
|
||||
|
||||
def child(self, cursor):
|
||||
import subprocess
|
||||
|
||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
_CHILD,
|
||||
repo,
|
||||
self._temp_db.name,
|
||||
str(self.output_dir),
|
||||
cursor,
|
||||
],
|
||||
cwd=self.comfy_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
|
||||
return json.loads(result.stdout.strip().splitlines()[-1])
|
||||
|
||||
async def test_new_processes_derive_the_same_scope_and_accept_the_cursor(self):
|
||||
from py.api.images import _workspace_cursor
|
||||
|
||||
make_png(self.output_dir / "image.png")
|
||||
listing = await self.client.get(LISTING, params={"mode": "workspace"})
|
||||
scope = (await listing.json())["images"][0]["root_scope"]
|
||||
key = (-12.5, "image.png", "0" * 64, 0)
|
||||
cursor = _workspace_cursor(key, "s" * 64, self.api._workspace_keys().cursor)
|
||||
for _restart in range(2):
|
||||
self.assertEqual(self.child(cursor), {"scope": scope, "key": list(key)})
|
||||
@@ -22,6 +22,9 @@ from database.operations import PromptDatabase
|
||||
from tests.browser import fast_reset
|
||||
from tests.browser.fast_reset import ScratchSeeder, table_digests
|
||||
|
||||
# The owner lock of /dev/shm scratch folders needs POSIX flock (#419).
|
||||
HAS_FLOCK = unittest.skipUnless(os.name == "posix", "needs POSIX flock")
|
||||
|
||||
|
||||
def digests(db: PromptDatabase) -> dict:
|
||||
return table_digests(db.model.get_connection())
|
||||
@@ -275,6 +278,7 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
self.assertEqual(digests(self.live), expected)
|
||||
self.assertFalse((self.root / "unused").exists())
|
||||
|
||||
@HAS_FLOCK
|
||||
def test_the_scratch_lives_under_the_memory_root_when_there_is_one(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
@@ -288,6 +292,16 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
seeder.close()
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
|
||||
@unittest.skipIf(os.name == "posix", "without flock the memory root is unused")
|
||||
def test_without_flock_the_scratch_stays_on_disk(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
|
||||
self.addCleanup(seeder.close)
|
||||
seeder.run(reseed)
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
self.assertTrue((self.root / "fallback" / "seed.db").exists())
|
||||
|
||||
def test_a_full_memory_root_puts_the_scratch_on_disk(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
@@ -303,26 +317,32 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
self.assertTrue((self.root / "fallback" / "seed.db").exists())
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "process probe is POSIX only")
|
||||
def test_scratch_folders_of_dead_servers_are_removed(self):
|
||||
@HAS_FLOCK
|
||||
def test_the_scratch_folder_is_held_by_a_lock_until_close(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
orphan = memory / f"pm-reset-{dead.pid}-abc"
|
||||
alive = memory / f"pm-reset-{os.getpid()}-def"
|
||||
other = memory / "pm-reset-notapid"
|
||||
for folder in (orphan, alive, other):
|
||||
folder.mkdir()
|
||||
(folder / "seed.db").write_bytes(b"x")
|
||||
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
|
||||
self.addCleanup(seeder.close)
|
||||
seeder.run(reseed)
|
||||
self.assertFalse(orphan.exists())
|
||||
self.assertTrue(alive.exists())
|
||||
self.assertTrue(other.exists())
|
||||
own = [p for p in memory.glob(f"pm-reset-{os.getpid()}-*") if p != alive]
|
||||
self.assertEqual(len(own), 1)
|
||||
(folder,) = memory.iterdir()
|
||||
self.assertTrue(folder.name.startswith(f"pm-reset-held-{os.getpid()}-"))
|
||||
self.assertFalse(lock_is_free(folder / "owner.lock"))
|
||||
seeder.close()
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
|
||||
@HAS_FLOCK
|
||||
def test_a_lock_that_cannot_be_taken_puts_the_scratch_on_disk(self):
|
||||
memory = self.root / "memory"
|
||||
memory.mkdir()
|
||||
seeder = ScratchSeeder(self.api, self.root / "fallback", memory_root=memory)
|
||||
self.addCleanup(seeder.close)
|
||||
refuse = mock.patch.object(
|
||||
fast_reset.fcntl, "flock", side_effect=OSError("no locks here")
|
||||
)
|
||||
with refuse:
|
||||
seeder.run(reseed)
|
||||
self.assertEqual(list(memory.iterdir()), [])
|
||||
self.assertTrue((self.root / "fallback" / "seed.db").exists())
|
||||
|
||||
def test_close_releases_the_scratch_files(self):
|
||||
self.seeder.run(reseed)
|
||||
@@ -331,5 +351,95 @@ class TestScratchSeeder(unittest.TestCase):
|
||||
self.seeder.close() # idempotent
|
||||
|
||||
|
||||
# A process that takes the owner lock of a folder, says so, and waits.
|
||||
HOLDER = """
|
||||
import fcntl, os, sys, time
|
||||
fd = os.open(os.path.join(sys.argv[1], "owner.lock"), os.O_RDWR | os.O_CREAT)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
print("held", flush=True)
|
||||
time.sleep(600)
|
||||
"""
|
||||
|
||||
|
||||
def lock_is_free(path: Path) -> bool:
|
||||
fd = os.open(path, os.O_RDWR)
|
||||
try:
|
||||
fast_reset.fcntl.flock(fd, fast_reset.fcntl.LOCK_EX | fast_reset.fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return False
|
||||
finally:
|
||||
os.close(fd)
|
||||
return True
|
||||
|
||||
|
||||
@HAS_FLOCK
|
||||
class TestOrphanedScratch(unittest.TestCase):
|
||||
"""Only folders whose owner lock is free are orphans (#419)."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.memory = Path(self.tmp.name)
|
||||
|
||||
def folder(self, name: str) -> Path:
|
||||
folder = self.memory / name
|
||||
folder.mkdir()
|
||||
(folder / "seed.db").write_bytes(b"x")
|
||||
return folder
|
||||
|
||||
def hold(self, folder: Path) -> subprocess.Popen:
|
||||
holder = subprocess.Popen(
|
||||
[sys.executable, "-c", HOLDER, str(folder)],
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
self.addCleanup(holder.wait)
|
||||
self.addCleanup(holder.stdout.close)
|
||||
self.addCleanup(holder.kill)
|
||||
self.assertEqual(holder.stdout.readline().strip(), "held")
|
||||
return holder
|
||||
|
||||
def test_a_folder_whose_owner_was_killed_is_removed(self):
|
||||
folder = self.folder("pm-reset-held-123-abc")
|
||||
holder = self.hold(folder)
|
||||
holder.kill()
|
||||
holder.wait()
|
||||
fast_reset.remove_orphaned_scratch(self.memory)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
def test_a_held_folder_is_kept_whatever_pid_its_name_carries(self):
|
||||
# Under --ipc=host the owner's pid is from another PID namespace: it
|
||||
# does not exist here, but its lock does.
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
folder = self.folder(f"pm-reset-held-{dead.pid}-abc")
|
||||
self.hold(folder)
|
||||
fast_reset.remove_orphaned_scratch(self.memory)
|
||||
self.assertTrue(folder.exists())
|
||||
self.assertTrue((folder / "seed.db").exists())
|
||||
|
||||
def test_folders_without_an_owner_lock_are_kept(self):
|
||||
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
dead.wait()
|
||||
legacy = self.folder(f"pm-reset-{dead.pid}-abc")
|
||||
staging = self.folder(f"pm-reset-new-{dead.pid}-def")
|
||||
unlocked = self.folder(f"pm-reset-held-{dead.pid}-ghi")
|
||||
fast_reset.remove_orphaned_scratch(self.memory)
|
||||
for folder in (legacy, staging, unlocked):
|
||||
self.assertTrue(folder.exists(), folder.name)
|
||||
|
||||
def test_a_folder_held_by_another_seeder_in_this_process_is_kept(self):
|
||||
api = types.SimpleNamespace(db=PromptDatabase(str(self.memory / "live.db")))
|
||||
self.addCleanup(api.db.close_all)
|
||||
root = self.memory / "shm"
|
||||
root.mkdir()
|
||||
other = ScratchSeeder(api, self.memory / "fallback", memory_root=root)
|
||||
self.addCleanup(other.close)
|
||||
other.run(reseed)
|
||||
(folder,) = root.iterdir()
|
||||
fast_reset.remove_orphaned_scratch(root)
|
||||
self.assertTrue(folder.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""#437: the workspace Library's fenced bulk "Set category" (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/category"
|
||||
|
||||
|
||||
def save(db, text, **metadata):
|
||||
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
|
||||
|
||||
|
||||
def row(db, prompt_id):
|
||||
with db.model.get_connection() as conn:
|
||||
found = conn.execute(
|
||||
"SELECT category, updated_at FROM prompts WHERE id=?", (prompt_id,)
|
||||
).fetchone()
|
||||
return tuple(found) if found else None
|
||||
|
||||
|
||||
class TestBulkLibraryCategory(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "bulk.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, category, epoch=None):
|
||||
return self.db.bulk_set_library_category(
|
||||
ids, category, expected_epoch=epoch or self.epoch
|
||||
)
|
||||
|
||||
def test_sets_changed_rows_and_counts_unchanged_and_missing(self):
|
||||
a = save(self.db, "Alpha", category="Old")
|
||||
b = save(self.db, "Beta", category="New")
|
||||
c = save(self.db, "Gamma")
|
||||
before_b = row(self.db, b)
|
||||
result = self.run_bulk([a, b, c, 99999], " New ")
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"category": "New",
|
||||
"updated": 2,
|
||||
"unchanged": 1,
|
||||
"not_found": 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(row(self.db, a)[0], "New")
|
||||
self.assertEqual(row(self.db, c)[0], "New")
|
||||
# An unchanged row keeps its timestamp (and so its edit token).
|
||||
self.assertEqual(row(self.db, b), before_b)
|
||||
|
||||
def test_changed_category_rotates_the_edit_token_only_for_changed_rows(self):
|
||||
a = save(self.db, "Alpha", category="Old")
|
||||
b = save(self.db, "Beta", category="New")
|
||||
tokens = {
|
||||
pid: self.db.get_library_positive(pid)["positive"]["edit_token"]
|
||||
for pid in (a, b)
|
||||
}
|
||||
self.run_bulk([a, b], "New")
|
||||
self.assertNotEqual(
|
||||
self.db.get_library_positive(a)["positive"]["edit_token"], tokens[a]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_library_positive(b)["positive"]["edit_token"], tokens[b]
|
||||
)
|
||||
|
||||
def test_changed_rows_become_registered_library_parents_like_the_editor(self):
|
||||
a = save(self.db, "Alpha", category="Old")
|
||||
b = save(self.db, "Beta", category="Same")
|
||||
|
||||
def registered(pid):
|
||||
with self.db.model.get_connection() as conn:
|
||||
return bool(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (pid,)
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
self.assertEqual((registered(a), registered(b)), (False, False))
|
||||
self.run_bulk([a, b], "Same")
|
||||
self.assertEqual((registered(a), registered(b)), (True, False))
|
||||
|
||||
def test_updated_at_is_utc_iso_text(self):
|
||||
a = save(self.db, "Alpha")
|
||||
self.run_bulk([a], "Utc")
|
||||
self.assertTrue(row(self.db, a)[1].endswith("+00:00"))
|
||||
|
||||
def test_negative_only_prompts_are_not_library_parents(self):
|
||||
pair = self.db.save_prompt_pair("Cat", "Blur")
|
||||
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
|
||||
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
result = self.run_bulk([positive, negative], "Animals")
|
||||
self.assertEqual((result["updated"], result["not_found"]), (1, 1))
|
||||
self.assertIsNone(row(self.db, negative)[0])
|
||||
|
||||
def test_restored_storage_writes_nothing(self):
|
||||
a = save(self.db, "Alpha", category="Old")
|
||||
new_epoch = rotate_history_epoch(self.db.model.get_connection())
|
||||
with self.assertRaises(LibraryCommandError) as caught:
|
||||
self.run_bulk([a], "New")
|
||||
self.assertEqual(caught.exception.code, "storage-changed")
|
||||
self.assertEqual(caught.exception.epoch, new_epoch)
|
||||
self.assertEqual(row(self.db, a)[0], "Old")
|
||||
|
||||
def test_a_failure_mid_write_leaves_every_row_as_it_was(self):
|
||||
ids = [save(self.db, f"P{i}", category="Old") for i in range(3)]
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"CREATE TRIGGER fail_third BEFORE UPDATE OF category ON prompts "
|
||||
f"WHEN OLD.id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
|
||||
)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.run_bulk(ids, "New")
|
||||
self.assertEqual([row(self.db, pid)[0] for pid in ids], ["Old"] * 3)
|
||||
|
||||
def test_many_ids_are_read_in_chunks(self):
|
||||
ids = [save(self.db, f"Many {i}") for i in range(1000)]
|
||||
result = self.run_bulk(ids, "Bulk")
|
||||
self.assertEqual(result["updated"], 1000)
|
||||
|
||||
def test_rejects_invalid_arguments(self):
|
||||
a = save(self.db, "Alpha")
|
||||
for ids, category, epoch in (
|
||||
([a], "", self.epoch),
|
||||
([a], " ", self.epoch),
|
||||
([a], None, self.epoch),
|
||||
([a], "x" * 101, self.epoch),
|
||||
([a], "bad\x01", self.epoch),
|
||||
([a], "ok", "not-a-uuid"),
|
||||
([], "ok", self.epoch),
|
||||
):
|
||||
with self.subTest(category=category, epoch=epoch, ids=ids):
|
||||
with self.assertRaises(ValueError):
|
||||
self.run_bulk(ids, category, epoch)
|
||||
|
||||
|
||||
class TestBulkLibraryCategoryRoute(RouteCoverageCase):
|
||||
def epoch(self):
|
||||
return self.api.db.get_library_options()["library_epoch"]
|
||||
|
||||
async def test_fenced_form_sets_categories_and_reports_counts(self):
|
||||
ids = [self._save_prompt(f"route {i}", category="Old") for i in range(2)]
|
||||
status, body = await self._json(
|
||||
"POST",
|
||||
URL,
|
||||
json={
|
||||
"prompt_ids": ids,
|
||||
"category": "Fresh",
|
||||
"expected_epoch": self.epoch(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"success": True,
|
||||
"library_epoch": self.epoch(),
|
||||
"category": "Fresh",
|
||||
"updated": 2,
|
||||
"unchanged": 0,
|
||||
"not_found": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[1])["category"], "Fresh")
|
||||
|
||||
async def test_restored_storage_answers_409_storage_changed(self):
|
||||
pid = self._save_prompt("route restore", category="Old")
|
||||
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], "category": "Fresh", "expected_epoch": stale},
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "storage-changed"))
|
||||
self.assertFalse(body["success"])
|
||||
self.assertEqual(body["library_epoch"], new_epoch)
|
||||
self.assertIsInstance(body["error"], str)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Old")
|
||||
|
||||
async def test_fenced_form_validates_its_body(self):
|
||||
pid = self._save_prompt("route validate")
|
||||
epoch = self.epoch()
|
||||
for body in (
|
||||
{"prompt_ids": [pid], "category": "", "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "category": " ", "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "category": 5, "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "category": "x" * 101, "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "category": "ok", "expected_epoch": "nope"},
|
||||
{"prompt_ids": [pid], "category": "ok", "expected_epoch": 7},
|
||||
{"prompt_ids": [], "category": "ok", "expected_epoch": epoch},
|
||||
{"prompt_ids": [True], "category": "ok", "expected_epoch": epoch},
|
||||
{
|
||||
"prompt_ids": list(range(1, 1002)),
|
||||
"category": "ok",
|
||||
"expected_epoch": epoch,
|
||||
},
|
||||
{"prompt_ids": [pid], "expected_epoch": epoch},
|
||||
{
|
||||
"prompt_ids": [pid],
|
||||
"category": "ok",
|
||||
"expected_epoch": epoch,
|
||||
"extra": 1,
|
||||
},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
status, answer = await self._json("POST", URL, json=body)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertFalse(answer["success"])
|
||||
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
|
||||
|
||||
async def test_fenced_form_reports_an_unexpected_failure(self):
|
||||
pid = self._save_prompt("route failure")
|
||||
self._break("bulk_set_library_category")
|
||||
status, body = await self._json(
|
||||
"POST",
|
||||
URL,
|
||||
json={
|
||||
"prompt_ids": [pid],
|
||||
"category": "ok",
|
||||
"expected_epoch": self.epoch(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(body["success"])
|
||||
|
||||
async def test_legacy_form_is_unchanged(self):
|
||||
pid = self._save_prompt("route legacy", category="Old")
|
||||
status, body = await self._json(
|
||||
"POST", URL, json={"prompt_ids": [pid], "category": "Legacy"}
|
||||
)
|
||||
self.assertEqual((status, body["updated_count"]), (200, 1))
|
||||
self.assertNotIn("library_epoch", body)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Legacy")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,705 @@
|
||||
"""The per-file prompt decision shared by the scan and the monitor (#247).
|
||||
|
||||
One test per case, named by the ladder step of the design
|
||||
(``docs/egm/247-foreign-workflows``). The ladder does no file or database
|
||||
I/O: history comes in through ``lookup``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from database.run_attribution import RecordedOutput
|
||||
from tests.test_pair_scan import flux_graph, sampled_graph
|
||||
from tests.test_prompt_attribution import issue_graph, pair_evidence
|
||||
from tests import test_save_to_library as save_tests
|
||||
from utils import file_prompt
|
||||
from utils.file_prompt import Policy, decide_file_prompt, junk_text
|
||||
from utils.prompt_inspector import InspectionResult
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||
import inspection_parity as parity
|
||||
import saver_anchor_graphs as shapes
|
||||
|
||||
SHARED_NAME = "IMG_GEN_00371_.png"
|
||||
LORA = "<lora:style:1.00>"
|
||||
|
||||
|
||||
def _decide(
|
||||
metadata,
|
||||
*,
|
||||
policy=Policy.SCAN,
|
||||
lookup=None,
|
||||
in_output=True,
|
||||
file_name=SHARED_NAME,
|
||||
file_size=None,
|
||||
):
|
||||
return decide_file_prompt(
|
||||
metadata,
|
||||
file_name=file_name,
|
||||
file_size=file_size,
|
||||
in_output=in_output,
|
||||
lookup=lookup,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def _graph(graph):
|
||||
return {"prompt": json.dumps(graph)}
|
||||
|
||||
|
||||
class _Lookup:
|
||||
"""A history lookup that counts its calls."""
|
||||
|
||||
def __init__(self, saver=None):
|
||||
self.calls = 0
|
||||
self.found = RecordedOutput(saver, {}) if saver is not None else None
|
||||
|
||||
def __call__(self):
|
||||
self.calls += 1
|
||||
return self.found
|
||||
|
||||
|
||||
def _inspected(state, malformed=False):
|
||||
resolution = {"state": state, "positive": None, "negative": None}
|
||||
return InspectionResult(resolution, malformed=malformed)
|
||||
|
||||
|
||||
def _untraced_by_both():
|
||||
"""A sampled graph whose positive no profile traces; the legacy parser does."""
|
||||
return mock.patch.object(
|
||||
file_prompt, "inspection_resolution", return_value=_inspected("unresolved")
|
||||
)
|
||||
|
||||
|
||||
def _gated(graph):
|
||||
"""``graph`` past the default profile's node gate, so it is unresolved."""
|
||||
graph = dict(graph)
|
||||
for index in range(510):
|
||||
graph[f"pad{index}"] = {"class_type": "Note", "inputs": {}}
|
||||
return graph
|
||||
|
||||
|
||||
def _prompt_manager_branches(prepend="PRE", append="APP"):
|
||||
"""Two KSampler branches, each fed by a PromptManagerText string."""
|
||||
graph = sampled_graph()
|
||||
del graph["1"]
|
||||
sampler = graph.pop("3")
|
||||
for index, text in enumerate(("a cat", "a dog"), start=1):
|
||||
base = index * 10
|
||||
graph[str(base)] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": text, "prepend_text": prepend, "append_text": append},
|
||||
}
|
||||
graph[str(base + 1)] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": [str(base), 0]},
|
||||
}
|
||||
graph[str(base + 2)] = {
|
||||
**sampler,
|
||||
"inputs": {**sampler["inputs"], "positive": [str(base + 1), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def _numeric_first_branches():
|
||||
"""Three branches; the legacy parser's answer (the first) is a number."""
|
||||
graph = sampled_graph("2048")
|
||||
for index, text in ((5, "a cat"), (6, "a dog")):
|
||||
graph[str(index * 10)] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text},
|
||||
}
|
||||
graph[str(index * 10 + 1)] = {
|
||||
**graph["3"],
|
||||
"inputs": {**graph["3"]["inputs"], "positive": [str(index * 10), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
class TestStepsBeforeResolving(unittest.TestCase):
|
||||
def test_step_1_no_metadata_tries_the_recorded_run(self):
|
||||
for metadata in ({}, None):
|
||||
decision = _decide(metadata)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.unlinked_status)
|
||||
self.assertEqual(decision.canonical, {})
|
||||
|
||||
def test_step_3_neither_prompt_nor_parameters_tries_the_recorded_run(self):
|
||||
decision = _decide({"workflow": json.dumps({"nodes": []})})
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.unlinked_status)
|
||||
self.assertEqual(decision.canonical, {"workflow": {"nodes": []}})
|
||||
|
||||
def test_strings_are_parsed_and_malformed_ones_kept(self):
|
||||
decision = _decide({"prompt": "{not json", "workflow": "{}"})
|
||||
self.assertEqual(decision.canonical["prompt"], "{not json")
|
||||
self.assertEqual(decision.canonical["workflow"], {})
|
||||
|
||||
def test_the_file_size_is_recorded_unless_the_metadata_has_one(self):
|
||||
meta = _graph(sampled_graph())
|
||||
self.assertEqual(
|
||||
_decide(meta, file_size=42).canonical["file_info"], {"size": 42}
|
||||
)
|
||||
meta["file_info"] = {"size": 7, "width": 3}
|
||||
self.assertEqual(
|
||||
_decide(meta, file_size=42).canonical["file_info"], {"size": 7, "width": 3}
|
||||
)
|
||||
self.assertNotIn("file_info", _decide(_graph(sampled_graph())).canonical)
|
||||
|
||||
def test_a_file_size_reader_is_called_only_for_prompt_metadata(self):
|
||||
reads = []
|
||||
|
||||
def size():
|
||||
reads.append(1)
|
||||
return 9
|
||||
|
||||
for metadata in ({}, {"workflow": "{}"}):
|
||||
_decide(metadata, file_size=size)
|
||||
self.assertEqual(reads, [])
|
||||
decision = _decide(_graph(sampled_graph()), file_size=size)
|
||||
self.assertEqual((reads, decision.canonical["file_info"]), ([1], {"size": 9}))
|
||||
|
||||
|
||||
def _off_behind(middle, key):
|
||||
"""A switched-off PromptManagerText reaching the encoder through
|
||||
``middle``'s ``key`` input."""
|
||||
graph = save_tests.run_graph()
|
||||
graph["1"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "SECRET OFF TEXT", "save_to_library": False},
|
||||
}
|
||||
graph["7"] = {"class_type": middle, "inputs": {key: ["1", 0]}}
|
||||
graph["8"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
|
||||
}
|
||||
graph["3"]["inputs"]["positive"] = ["8", 0]
|
||||
return graph
|
||||
|
||||
|
||||
class TestSaveToLibrarySteps(unittest.TestCase):
|
||||
"""#90: a PromptManager node with Save to library off stores nothing."""
|
||||
|
||||
def test_step_2_an_opted_out_file_is_not_saved(self):
|
||||
lookup = _Lookup("9")
|
||||
graph = save_tests.run_graph(save=False)
|
||||
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
|
||||
graph["5"]["inputs"]["positive"] = ["2", 0] # ambiguous otherwise
|
||||
for policy in Policy:
|
||||
decision = _decide(_graph(graph), policy=policy, lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_2_a_wired_switch_is_not_saved_wired(self):
|
||||
graph = save_tests.run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(graph), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved_wired")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_8_1_an_off_node_behind_a_pass_through_is_not_saved(self):
|
||||
"""The #90 graph walk does not follow ``anything`` or ``value``; the
|
||||
inspector does, so its positive is checked against the switch."""
|
||||
for middle, key in (("easy showAnything", "anything"), ("MyNode", "value")):
|
||||
with self.subTest(middle=middle):
|
||||
graph = _off_behind(middle, key)
|
||||
for policy in Policy:
|
||||
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertIsNone(decision.text)
|
||||
|
||||
def test_step_8_4_a_legacy_answer_from_an_off_node_is_not_saved(self):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
graph["9"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "on text"}}
|
||||
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
|
||||
graph["5"]["inputs"]["positive"] = ["9", 0]
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertIsNone(decision.text)
|
||||
|
||||
def test_step_2_an_off_node_on_any_positive_input_is_not_saved(self):
|
||||
"""Any input name upstream of a positive, not only prompt-like ones."""
|
||||
guider = _off_behind("MyNode", "value")
|
||||
guider["3"] = {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"model": ["4", 0], "conditioning": ["8", 0]},
|
||||
}
|
||||
prepended = _off_behind("MyNode", "value")
|
||||
prepended["1"]["inputs"]["prepend_text"] = "pre"
|
||||
pair = _off_behind("MyNode", "value")
|
||||
pair["1"] = {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "x", "negative_text": "y", "save_to_library": False},
|
||||
}
|
||||
pair["2"]["inputs"]["text"] = ["1", 1]
|
||||
cases = {"sampler-less guider": guider, "prepend": prepended, "pair": pair}
|
||||
for name, graph in cases.items():
|
||||
with self.subTest(name=name):
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(graph), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_2_a_wired_switch_behind_a_pass_through_is_not_saved_wired(self):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
graph["1"]["inputs"]["save_to_library"] = ["20", 0]
|
||||
graph["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
self.assertEqual(_decide(_graph(graph)).kind, "not_saved_wired")
|
||||
|
||||
def test_a_switched_on_node_behind_a_pass_through_is_stored(self):
|
||||
"""Regression guard: on, or no switch at all (older files)."""
|
||||
for value in (True, None):
|
||||
with self.subTest(value=value):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
if value is None:
|
||||
del graph["1"]["inputs"]["save_to_library"]
|
||||
else:
|
||||
graph["1"]["inputs"]["save_to_library"] = value
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.text, "SECRET OFF TEXT")
|
||||
|
||||
def test_an_off_node_on_a_negative_input_name_never_blocks(self):
|
||||
"""The walk skips negative inputs: #90's own rule (``neg``,
|
||||
``neg_text``, ``text_neg``) and any name containing "negative"."""
|
||||
for key in (
|
||||
"neg",
|
||||
"neg_text",
|
||||
"text_neg",
|
||||
"negative_text",
|
||||
"negative2",
|
||||
"negativeprompt",
|
||||
"NegativePrompt",
|
||||
):
|
||||
with self.subTest(key=key):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "bad hands", "save_to_library": False},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "a cat", "save_to_library": True},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "PipeEncode",
|
||||
"inputs": {"pos_text": ["9", 0], key: ["1", 0]},
|
||||
},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["7", 0],
|
||||
"negative": ["7", 1],
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertIsNone(file_prompt._switched_off_upstream(graph))
|
||||
self.assertNotIn(
|
||||
_decide(_graph(graph)).kind, ("not_saved", "not_saved_wired")
|
||||
)
|
||||
|
||||
def test_the_guards_accept_malformed_nodes(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerText", "inputs": None},
|
||||
"2": {"class_type": "PromptManagerText"},
|
||||
}
|
||||
self.assertIsNone(file_prompt._off_text(graph, "x"))
|
||||
side = {"provenance": ["not", "a", "dict"]}
|
||||
with mock.patch.object(file_prompt, "side_opted_out", return_value=True):
|
||||
self.assertIsNone(file_prompt._side_switch(graph, side))
|
||||
|
||||
def test_the_off_text_guard_reads_a_nodes_resolved_text(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": ["30", 0], "save_to_library": False},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "SECRET OFF TEXT"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "WIRED", "save_to_library": ["9", 0]},
|
||||
},
|
||||
"9": {"class_type": "SomeBoolNode", "inputs": {}},
|
||||
}
|
||||
self.assertEqual(file_prompt._off_text(graph, " SECRET OFF TEXT"), "not_saved")
|
||||
self.assertEqual(file_prompt._off_text(graph, "WIRED"), "not_saved_wired")
|
||||
self.assertIsNone(file_prompt._off_text(graph, "other"))
|
||||
|
||||
def test_steps_5_and_7_check_the_resolved_positive_too(self):
|
||||
"""A resolved positive through an off node the #90 walk missed."""
|
||||
cases = {
|
||||
"pair": save_tests.run_graph(save=False),
|
||||
"negative off": save_tests.run_graph(save=False, negative_save=False),
|
||||
}
|
||||
for name, graph in cases.items():
|
||||
with (
|
||||
self.subTest(name=name),
|
||||
mock.patch.object(
|
||||
file_prompt, "metadata_opted_out", return_value=False
|
||||
),
|
||||
):
|
||||
self.assertEqual(_decide(_graph(graph)).kind, "not_saved")
|
||||
|
||||
def test_step_5_a_junk_positive_is_never_stored(self):
|
||||
"""Pinned: the positive alone still passes the junk gate, and a blank
|
||||
positive (never storable) stores nothing either."""
|
||||
graph = save_tests.run_graph(save=True, negative_save=False)
|
||||
graph["1"]["inputs"]["text"] = "2048"
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
graph["1"]["inputs"]["text"] = ""
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual((decision.kind, decision.text), ("never", None))
|
||||
|
||||
def test_step_2_a_workflow_only_opt_out_comes_before_step_3(self):
|
||||
workflow = {
|
||||
"nodes": [{"id": 1, "type": "PromptManager", "widgets_values": [False]}]
|
||||
}
|
||||
decision = _decide({"workflow": json.dumps(workflow)})
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
|
||||
def test_step_5_an_opted_out_negative_stores_the_positive_alone(self):
|
||||
graph = save_tests.run_graph(save=True, negative_save=False)
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "opted-out-negative")
|
||||
self.assertEqual(decision.text, save_tests.POSITIVE)
|
||||
|
||||
|
||||
class TestResolvedSteps(unittest.TestCase):
|
||||
def test_step_6_a_blank_positive_counts_as_unresolved(self):
|
||||
meta = {
|
||||
"prompt": json.dumps(issue_graph()),
|
||||
"comfyassets_prompt_pairs": json.dumps(pair_evidence()),
|
||||
}
|
||||
decision = _decide(meta, policy=Policy.MONITOR, in_output=False)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.resolution["state"], "unresolved")
|
||||
|
||||
def test_step_7_a_resolved_pair(self):
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(sampled_graph("cat", "blur")), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "pair")
|
||||
self.assertEqual(decision.source, "resolver")
|
||||
self.assertEqual(decision.text, "cat")
|
||||
self.assertEqual(decision.resolution["negative"]["text"], "blur")
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0, "decided before step 8: history not asked")
|
||||
|
||||
def test_step_7_a_blank_negative_stays_a_pair(self):
|
||||
decision = _decide(_graph(sampled_graph("cat", "")))
|
||||
self.assertEqual(decision.kind, "pair")
|
||||
self.assertEqual(decision.resolution["negative"]["text"], "")
|
||||
|
||||
|
||||
class TestUndecidedGraph(unittest.TestCase):
|
||||
def test_step_8_1_the_inspector_alone_decides(self):
|
||||
decision = _decide({"prompt": parity.build("f03")["prompt"]})
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "inspector")
|
||||
self.assertEqual(decision.text, parity.POS)
|
||||
self.assertEqual(decision.resolution["state"], "unresolved")
|
||||
|
||||
def test_step_8_1_the_recorded_saver_names_its_branch(self):
|
||||
lookup = _Lookup("23")
|
||||
decision = _decide(_graph(shapes.shared_prefix()), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "recorded-run")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertEqual(lookup.calls, 1)
|
||||
|
||||
def test_step_8_1_stores_the_storage_text_without_prepend_or_append(self):
|
||||
graph = shapes.shared_prefix()
|
||||
graph["20"]["inputs"]["text"] = ["25", 1]
|
||||
graph["25"] = {
|
||||
"class_type": "PromptManagerSplit",
|
||||
"inputs": {
|
||||
"text": shapes.BRANCH_TEXTS[1],
|
||||
"prepend_text": "trigger",
|
||||
"append_text": "detail",
|
||||
},
|
||||
}
|
||||
decision = _decide(_graph(graph), lookup=_Lookup("23"))
|
||||
self.assertEqual(decision.source, "recorded-run")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
|
||||
|
||||
def test_step_8_1_a_file_name_naming_another_saver_anchors_nothing(self):
|
||||
graph = shapes.branch_bench()
|
||||
for index in (1, 3, 4):
|
||||
graph[str(index * 10 + 3)]["inputs"]["filename_prefix"] = "bench/shared"
|
||||
decision = _decide(
|
||||
_graph(graph), lookup=_Lookup("43"), file_name="s45_branch_2_00001_.png"
|
||||
)
|
||||
# The name contradicts history's branch 4: no recorded anchor.
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
|
||||
def test_step_8_1_the_file_name_anchor_alone_never_decides(self):
|
||||
"""#390: the file-name anchor never decides a link. The scan's legacy
|
||||
floor may still link the file (as before #247), and a file nothing
|
||||
links is queued with the anchor's choice per #390; the monitor
|
||||
stores nothing."""
|
||||
name = "s45_branch_2_00001_.png"
|
||||
scan = _decide(_graph(shapes.branch_bench()), lookup=_Lookup(), file_name=name)
|
||||
self.assertEqual((scan.kind, scan.source), ("positive", "legacy-parser"))
|
||||
self.assertEqual(scan.review, "ambiguous")
|
||||
monitor = _decide(
|
||||
_graph(shapes.branch_bench()),
|
||||
policy=Policy.MONITOR,
|
||||
lookup=_Lookup(),
|
||||
file_name=name,
|
||||
)
|
||||
self.assertEqual(monitor.kind, "never")
|
||||
recorded = _decide(
|
||||
_graph(shapes.branch_bench()), lookup=_Lookup("43"), file_name=name
|
||||
)
|
||||
# The recorded saver still decides: history says branch 4, but the
|
||||
# file name names branch 2, which contradicts it.
|
||||
self.assertNotEqual(recorded.source, "recorded-run")
|
||||
recorded = _decide(_graph(shapes.branch_bench()), lookup=_Lookup("43"))
|
||||
self.assertEqual(recorded.source, "recorded-run")
|
||||
self.assertEqual(recorded.text, shapes.BRANCH_TEXTS[3])
|
||||
|
||||
def test_step_8_2_the_monitor_waits_for_history_inside_the_output_folder(self):
|
||||
decision = _decide(_graph(shapes.shared_prefix()), policy=Policy.MONITOR)
|
||||
self.assertEqual(decision.kind, "history")
|
||||
|
||||
def test_step_8_2_outside_the_output_folder_steps_8_3_and_8_4_apply(self):
|
||||
decision = _decide(
|
||||
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, in_output=False
|
||||
)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
with _untraced_by_both():
|
||||
decision = _decide(
|
||||
_graph(_gated(sampled_graph())), policy=Policy.MONITOR, in_output=False
|
||||
)
|
||||
self.assertEqual(
|
||||
(decision.kind, decision.source), ("positive", "legacy-parser")
|
||||
)
|
||||
|
||||
def test_step_8_3_an_ambiguous_file_is_left_unlinked_by_the_monitor(self):
|
||||
lookup = _Lookup("missing")
|
||||
decision = _decide(
|
||||
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, lookup=lookup
|
||||
)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(lookup.calls, 1)
|
||||
|
||||
def test_step_8_4_joined_prompt_manager_text_is_never_the_legacy_answer(self):
|
||||
"""The legacy parser returns prepend + text + append; the stored text
|
||||
of a PromptManager node cannot be told from it."""
|
||||
decision = _decide(_graph(_prompt_manager_branches()), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
# A linked part the parser does not follow: its answer is the node's
|
||||
# own text, which is what the node stores (#170 parity).
|
||||
linked = _prompt_manager_branches(prepend=["99", 0], append="")
|
||||
decision = _decide(_graph(linked), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
|
||||
|
||||
def test_step_8_4_prompt_manager_text_without_parts_keeps_the_floor(self):
|
||||
"""Regression guard (passes on the base): no prepend or append."""
|
||||
graph = _prompt_manager_branches(prepend="", append=" ")
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
|
||||
|
||||
def test_step_8_4_an_ambiguous_file_gets_the_legacy_answer_in_the_scan(self):
|
||||
decision = _decide(_graph(shapes.shared_prefix()), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[0])
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
|
||||
def test_step_8_4_an_untraceable_file_gets_the_legacy_answer_in_both(self):
|
||||
for policy in Policy:
|
||||
for inspected in (_inspected("unresolved"), _inspected("x", True)):
|
||||
with (
|
||||
self.subTest(policy=policy, malformed=inspected.malformed),
|
||||
mock.patch.object(
|
||||
file_prompt, "inspection_resolution", return_value=inspected
|
||||
),
|
||||
):
|
||||
decision = _decide(
|
||||
_graph(_gated(sampled_graph("cat"))),
|
||||
policy=policy,
|
||||
lookup=_Lookup(),
|
||||
)
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
self.assertEqual(decision.text, "cat")
|
||||
|
||||
def test_step_8_4_nothing_traced_tries_the_recorded_run(self):
|
||||
graph = sampled_graph()
|
||||
graph["5"] = {**graph["3"]}
|
||||
for sampler in ("3", "5"):
|
||||
graph[sampler]["inputs"]["positive"] = ["9", 0]
|
||||
graph["9"] = {"class_type": "BasicGuider", "inputs": {"width": 2048}}
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
# Ambiguous without a choice is reported unresolved.
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
self.assertEqual(decision.review, "untraced")
|
||||
|
||||
|
||||
class TestStepsWithoutAnUndecidedGraph(unittest.TestCase):
|
||||
def test_step_9_ambiguous_parameters_only(self):
|
||||
meta = {
|
||||
"parameters": (
|
||||
"cat\nNegative prompt: blur\nNegative prompt: noise"
|
||||
"\nSteps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
decision = _decide(meta)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_10_unresolved_without_a1111_text(self):
|
||||
decision = _decide({"prompt": {}, "parameters": 5})
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_11_sampler_less_with_one_text(self):
|
||||
decision = _decide(_graph(flux_graph()))
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "sampler-less"))
|
||||
self.assertEqual(decision.text, "a red fox")
|
||||
|
||||
def test_step_11_sampler_less_with_several_texts(self):
|
||||
graph = flux_graph()
|
||||
graph["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "a lora note"},
|
||||
}
|
||||
graph["22"]["inputs"]["model"] = ["50", 0]
|
||||
graph["6"]["inputs"]["clip"] = ["50", 1]
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
meta = {**_graph(graph), "parameters": "a red fox\nNegative prompt: x"}
|
||||
decision = _decide(meta)
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
|
||||
self.assertEqual(decision.text, "a red fox")
|
||||
|
||||
def test_step_11_sampler_less_without_a_text(self):
|
||||
graph = {"40": {"class_type": "easy showAnything", "inputs": {"text": "x"}}}
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
|
||||
def test_step_12_a1111_only(self):
|
||||
decision = _decide({"parameters": "a cat"})
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
|
||||
self.assertEqual(decision.text, "a cat")
|
||||
decision = _decide({"parameters": "Negative prompt: blur"})
|
||||
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
|
||||
def test_step_12_parsed_metadata_never_raises(self):
|
||||
# The monitor hands parsed values: a dict prompt, non-string parameters.
|
||||
decision = _decide({"prompt": {}, "parameters": "a cat"})
|
||||
self.assertEqual(decision.text, "a cat")
|
||||
decision = _decide({"parameters": 5})
|
||||
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
|
||||
|
||||
|
||||
class TestJunkGate(unittest.TestCase):
|
||||
JUNK = {
|
||||
"blank": " ",
|
||||
"number": "2048",
|
||||
"lora-only": f"{LORA}, <lora:detail:0.5> |",
|
||||
"model-file": "models/checkpoints/Flux-Dev.SafeTensors",
|
||||
}
|
||||
|
||||
def test_each_junk_class_is_named(self):
|
||||
for name, text in self.JUNK.items():
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(junk_text(text), name)
|
||||
for ending in ("ckpt", "pt", "pth", "bin", "gguf", "sft"):
|
||||
self.assertEqual(junk_text(f"model.{ending}"), "model-file")
|
||||
|
||||
def test_near_misses_are_not_junk(self):
|
||||
for text in (
|
||||
f"a red fox {LORA}",
|
||||
"a.safetensors b.safetensors",
|
||||
"nan",
|
||||
"a portrait saved as final.safetensors",
|
||||
"4k",
|
||||
"C:\\My Models\\x.safetensors",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(junk_text(text))
|
||||
|
||||
def test_step_13_junk_from_any_step_is_never_stored(self):
|
||||
cases = {
|
||||
"resolver": _graph(sampled_graph("2048", "blur")),
|
||||
"sampler-less": _graph(flux_graph(text=LORA)),
|
||||
"a1111": {"parameters": "model.safetensors"},
|
||||
}
|
||||
for step, meta in cases.items():
|
||||
with self.subTest(step=step):
|
||||
decision = _decide(meta)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.source.startswith("junk:"), decision.source)
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
|
||||
def test_step_13_a_junk_legacy_answer_is_never_stored(self):
|
||||
with _untraced_by_both():
|
||||
decision = _decide(_graph(_gated(sampled_graph(LORA))), lookup=_Lookup())
|
||||
self.assertEqual(decision.source, "junk:lora-only")
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_13_junk_at_step_8_keeps_that_steps_status_and_review(self):
|
||||
# An ambiguous file whose legacy answer is a number is still a
|
||||
# Needs review case: its status stays "ambiguous".
|
||||
decision = _decide(_graph(_numeric_first_branches()), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
|
||||
|
||||
class TestParseComfyuiPrompt(unittest.TestCase):
|
||||
def test_a_parsed_prompt_and_non_string_values_are_kept(self):
|
||||
parsed = file_prompt.parse_comfyui_prompt(
|
||||
{"prompt": {"1": {}}, "workflow": {"nodes": []}, "steps": 20}
|
||||
)
|
||||
self.assertEqual(parsed["prompt"], {"1": {}})
|
||||
self.assertEqual(parsed["workflow"], {"nodes": []})
|
||||
self.assertEqual(parsed["parameters"]["steps"], 20)
|
||||
self.assertIsNone(parsed["positive_prompt"])
|
||||
|
||||
def test_non_string_parameters_are_ignored(self):
|
||||
parsed = file_prompt.parse_comfyui_prompt({"parameters": {"a": 1}})
|
||||
self.assertIsNone(parsed["positive_prompt"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Ingestion keeps the default resolver profile; only inspection routes moved (D1).
|
||||
"""Ingestion decides with the default resolver profile first (D1, #247 D-Q1).
|
||||
|
||||
The live monitor and the admin scan write pairs and links to the user's
|
||||
database, so their resolver semantics stay byte-for-byte today's. f03 (506
|
||||
executable nodes) separates the profiles: the default profile's 500-node gate
|
||||
leaves it unresolved, while the inspection profile resolves it.
|
||||
database, so the default profile decides every file it can, byte-for-byte as
|
||||
before. f03 (506 executable nodes) separates the profiles: the default
|
||||
profile's 500-node gate leaves it unresolved, while the inspection profile
|
||||
resolves it. Only such an undecided file reaches the inspector's resolve
|
||||
(``inspection_resolution``), and only its positive is stored (#247).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -68,24 +70,26 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
|
||||
spy.assert_default_and_gated(self)
|
||||
handler.db_manager.link_image_to_prompt_or_raise.assert_called_once()
|
||||
|
||||
def test_admin_scan_ingestion_uses_the_default_profile(self):
|
||||
def test_admin_scan_ingestion_uses_the_default_profile_first(self):
|
||||
from py.api import admin
|
||||
from utils import file_prompt
|
||||
|
||||
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
||||
mixin.db = Mock()
|
||||
mixin.db.get_prompt_by_hash.return_value = None
|
||||
mixin.db.save_prompt.return_value = 7
|
||||
spy = _Spy()
|
||||
with patch.object(admin, "resolve_prompt_pair", spy):
|
||||
with (
|
||||
patch.object(file_prompt, "resolve_prompt_pair", spy),
|
||||
patch.object(
|
||||
admin, "store_scanned_prompt", return_value=("added", 7)
|
||||
) as store,
|
||||
):
|
||||
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
|
||||
spy.assert_default_and_gated(self)
|
||||
# The resolver leaves f03 unresolved; the scan links the prompt the
|
||||
# browser parser traces, as the inspector shows it.
|
||||
# The default profile leaves f03 unresolved; the inspector's resolve
|
||||
# names the prompt the inspector shows, and the scan stores it.
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual(
|
||||
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
|
||||
)
|
||||
mixin.db.link_image_to_prompt.assert_called_once()
|
||||
self.assertEqual(store.call_args.args[3], "a red fox in snow, detailed fur")
|
||||
mixin.db.link_image_to_prompt.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -209,6 +209,38 @@ class TestMove(_MoveCase):
|
||||
)
|
||||
self.assertEqual(ll.read_marker(self.target)["state"], ll.MARKER_MOVED)
|
||||
|
||||
def test_the_workspace_key_moves_with_the_library(self):
|
||||
# #430: open pages keep their scopes after the restart that follows.
|
||||
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
|
||||
|
||||
secret = load_install_secret(self.ext)
|
||||
result = self.move()
|
||||
self.assertIn(KEY_FILE_NAME, result.files)
|
||||
self.assertEqual(load_install_secret(self.target), secret)
|
||||
if os.name == "posix":
|
||||
mode = os.stat(os.path.join(self.target, KEY_FILE_NAME)).st_mode
|
||||
self.assertEqual(mode & 0o777, 0o600)
|
||||
|
||||
def test_a_key_that_cannot_be_copied_never_fails_the_move(self):
|
||||
from utils.workspace_keys import KEY_FILE_NAME, load_install_secret
|
||||
|
||||
load_install_secret(self.ext)
|
||||
real = lm._hash_copy
|
||||
|
||||
def failing(source, destination):
|
||||
if os.path.basename(source) == KEY_FILE_NAME:
|
||||
raise OSError("disk error")
|
||||
return real(source, destination)
|
||||
|
||||
with mock.patch.object(lm, "_hash_copy", failing):
|
||||
with self.assertLogs("prompt_manager.database.library_move", "WARNING"):
|
||||
result = self.move()
|
||||
self.assertNotIn(KEY_FILE_NAME, result.files)
|
||||
self.assertEqual(_rows(self.new_db), self.before["rows"])
|
||||
self.assertFalse(
|
||||
any(n.startswith(lm.TEMP_PREFIX) for n in os.listdir(self.target))
|
||||
)
|
||||
|
||||
def test_new_files_are_private(self):
|
||||
if os.name != "posix":
|
||||
self.skipTest("POSIX permissions")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Metadata scanning enriches known pairs and conservatively leaves conflicts."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def graph(positive="cat", negative="blur"):
|
||||
@@ -449,3 +452,159 @@ class TestSamplerLessScan(AdminAPITestCase):
|
||||
path = self.output_dir / "saver.png"
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
|
||||
class TestScanDecision(AdminAPITestCase):
|
||||
"""The scan applies the shared per-file decision (#247): one prompt per
|
||||
file, no junk text, marks on every prompt it creates."""
|
||||
|
||||
def _path(self, name):
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
return path
|
||||
|
||||
def _links(self, path):
|
||||
rows = self.api.db.model.get_connection().execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
|
||||
)
|
||||
return [row[0] for row in rows]
|
||||
|
||||
def _prompt(self, text):
|
||||
return self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
|
||||
|
||||
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
|
||||
path = self._path("fox.png")
|
||||
other = self.api.db.save_prompt(
|
||||
"a dog", prompt_hash=generate_prompt_hash("a dog")
|
||||
)
|
||||
self.api.db.link_image_to_prompt(other, str(path))
|
||||
meta = {"prompt": json.dumps(flux_graph())}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
|
||||
self.assertEqual(self._links(path), [other])
|
||||
self.assertIsNone(self._prompt("a red fox"))
|
||||
|
||||
def test_junk_text_is_never_stored(self):
|
||||
cases = {
|
||||
"lora.png": {"prompt": json.dumps(flux_graph(text="<lora:style:1.00>"))},
|
||||
"model.png": {"parameters": "models/flux-dev.safetensors"},
|
||||
}
|
||||
for name, meta in cases.items():
|
||||
with self.subTest(name=name):
|
||||
path = self._path(name)
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
|
||||
self.assertEqual(self._links(path), [])
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_created_prompts_carry_the_scan_marks(self):
|
||||
path = self._path("pair.png")
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
for text in ("cat", "blur"):
|
||||
prompt = self._prompt(text)
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
self.assertEqual(prompt["notes"], "Auto-scanned from pair.png")
|
||||
|
||||
def test_outcome_of_a_positive_reports_its_title(self):
|
||||
"""Regression guard (passes on the base): the detailed positive row."""
|
||||
path = self._path("fox.png")
|
||||
meta = {"prompt": json.dumps(flux_graph())}
|
||||
first = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(first, {"status": "added", "title": "a red fox"})
|
||||
again = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(again["status"], "unchanged")
|
||||
other = self._path("fox2.png")
|
||||
linked = self.api._ingest_scanned_file(other, meta, detailed=True)
|
||||
self.assertEqual(linked["status"], "linked")
|
||||
|
||||
def test_outcome_of_a_pair_reports_its_counts(self):
|
||||
"""Regression guard (passes on the base): the detailed pair row."""
|
||||
path = self._path("pair.png")
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
result = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(result["status"], "added")
|
||||
self.assertEqual((result["pairs_added"], result["links_added"]), (1, 1))
|
||||
self.assertEqual(result["title"], "cat")
|
||||
|
||||
def test_outcome_without_a_store_reports_the_unlinked_status(self):
|
||||
"""Regression guard (passes on the base): never-stored rows."""
|
||||
path = self._path("notes.png")
|
||||
meta = {"prompt": json.dumps({"40": {"class_type": "Textbox", "inputs": {}}})}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
|
||||
self.assertIsNone(self.api._ingest_scanned_file(path, {}))
|
||||
|
||||
def test_review_lists_get_the_default_profiles_ambiguous_files(self):
|
||||
"""Regression guard (passes on the base): Needs review inputs."""
|
||||
g = sampled_graph()
|
||||
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
|
||||
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
|
||||
ambiguous, untraced, records = [], [], {}
|
||||
path = self._path("two.png")
|
||||
status = self.api._ingest_scanned_file(
|
||||
path,
|
||||
{"prompt": json.dumps(g)},
|
||||
ambiguous=ambiguous,
|
||||
untraced=untraced,
|
||||
records=records,
|
||||
)
|
||||
# Linked by the legacy floor, still listed (queued only if unlinked).
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual((ambiguous, untraced), ([path], []))
|
||||
self.assertEqual(records["canonical"]["prompt"], g)
|
||||
|
||||
def test_an_ambiguous_file_with_a_junk_answer_is_still_queued(self):
|
||||
from tests.test_file_prompt import _numeric_first_branches
|
||||
|
||||
path = self._path("numbers.png")
|
||||
meta = {"prompt": json.dumps(_numeric_first_branches())}
|
||||
with patch.object(
|
||||
type(self.api), "_record_scanned_review", autospec=True
|
||||
) as record:
|
||||
counts = self.api._ingest_scan_batch_sync([(path, meta)])
|
||||
self.assertEqual(counts["ambiguous"], 1, counts)
|
||||
record.assert_called_once()
|
||||
self.assertEqual(self._links(path), [])
|
||||
|
||||
def test_a_conflict_row_says_the_link_was_kept(self):
|
||||
from py.api.admin import _scan_outcome
|
||||
|
||||
_group, row, _line = _scan_outcome(self._path("c.png"), [], "conflict")
|
||||
self.assertEqual(row["detail"], "Kept: already linked to a different prompt")
|
||||
|
||||
def test_a_library_move_reruns_the_scan_writes(self):
|
||||
from py.api import admin
|
||||
|
||||
real = admin.ingest_resolved_pair
|
||||
model = self.api.db.model
|
||||
|
||||
def moved_once(*args, **kwargs):
|
||||
if not calls:
|
||||
calls.append(1)
|
||||
model._generation += 1
|
||||
raise sqlite3.OperationalError("closed")
|
||||
return real(*args, **kwargs)
|
||||
|
||||
calls = []
|
||||
path = self._path("pair.png")
|
||||
with patch.object(admin, "ingest_resolved_pair", side_effect=moved_once):
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(calls, [1])
|
||||
|
||||
def test_an_off_node_behind_a_pass_through_stores_nothing(self):
|
||||
"""#90 regression guard for the scan (main stored nothing here)."""
|
||||
from tests.test_save_to_library import run_graph
|
||||
|
||||
g = run_graph(save=False)
|
||||
g["1"]["class_type"] = "PromptManagerText"
|
||||
g["7"] = {"class_type": "easy showAnything", "inputs": {"anything": ["1", 0]}}
|
||||
g["8"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
|
||||
}
|
||||
g["3"]["inputs"]["positive"] = ["8", 0]
|
||||
path = self._path("off.png")
|
||||
meta = {"prompt": json.dumps(g)}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "not_saved")
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
self.assertEqual(self._links(path), [])
|
||||
|
||||
@@ -375,5 +375,89 @@ class TestRecordedTexts(unittest.TestCase):
|
||||
self.assertEqual(evidence["resolution"]["sampler_id"], sampler)
|
||||
|
||||
|
||||
class TestInspectionResolution(unittest.TestCase):
|
||||
"""``inspection_resolution``: the inspector's resolve and recorded anchor,
|
||||
shared with ingestion (#247), so the two cannot drift."""
|
||||
|
||||
def _result(self, graph, saver=None, *, as_text=True, name="IMG_GEN_00371_.png"):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
prompt = json.dumps(graph) if as_text else graph
|
||||
return inspection_resolution({"prompt": prompt}, name, saver)
|
||||
|
||||
def test_the_recorded_saver_names_its_branch_by_record(self):
|
||||
result = self._result(shapes.shared_prefix(), _saver(2))
|
||||
self.assertTrue(result.by_record)
|
||||
self.assertFalse(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "resolved")
|
||||
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
|
||||
self.assertIs(result.anchored, result.resolution)
|
||||
|
||||
def test_without_a_saver_nothing_is_anchored(self):
|
||||
result = self._result(shapes.shared_prefix())
|
||||
self.assertEqual(result.resolution["state"], "ambiguous")
|
||||
self.assertIsNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_a_saver_that_names_nothing_keeps_the_open_result(self):
|
||||
result = self._result(shapes.shared_prefix(), "missing")
|
||||
self.assertEqual(result.resolution["state"], "ambiguous")
|
||||
self.assertIsNotNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_a_file_that_resolves_alone_is_never_anchored(self):
|
||||
result = self._result(
|
||||
shapes.branch_bench(), _saver(4), name="s45_branch_2_00001_.png"
|
||||
)
|
||||
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
|
||||
self.assertIsNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_parsed_metadata_resolves_as_the_file_text_does(self):
|
||||
# The monitor hands parsed dicts; the scan hands the file's strings.
|
||||
parsed = self._result(shapes.shared_prefix(), _saver(3), as_text=False)
|
||||
text = self._result(shapes.shared_prefix(), _saver(3))
|
||||
self.assertEqual(parsed.resolution, text.resolution)
|
||||
|
||||
def test_malformed_metadata_is_reported_as_malformed(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
result = inspection_resolution({"prompt": "{not json"}, None, None)
|
||||
self.assertTrue(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "unresolved")
|
||||
self.assertEqual(result.resolution["reason_code"], "malformed")
|
||||
|
||||
def test_metadata_without_a_role_record_is_absent(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
result = inspection_resolution({"workflow": "{}"}, None, "9")
|
||||
self.assertFalse(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "absent")
|
||||
|
||||
def test_without_the_name_anchor_a_file_name_names_no_branch(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
graph = {"prompt": json.dumps(shapes.branch_bench())}
|
||||
name = "s45_branch_2_00001_.png"
|
||||
named = inspection_resolution(graph, name, None)
|
||||
self.assertEqual(_positive(named.resolution), shapes.BRANCH_TEXTS[1])
|
||||
blind = inspection_resolution(graph, name, None, name_anchor=False)
|
||||
self.assertEqual(blind.resolution["state"], "ambiguous")
|
||||
# The recorded saver still decides, and the name still contradicts it.
|
||||
recorded = inspection_resolution(graph, name, _saver(2), name_anchor=False)
|
||||
self.assertTrue(recorded.by_record)
|
||||
shared = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
bench = shapes.branch_bench()
|
||||
for index in (1, 3, 4):
|
||||
bench[_saver(index)]["inputs"]["filename_prefix"] = "bench/shared"
|
||||
contradicted = inspection_resolution(
|
||||
{"prompt": json.dumps(bench)}, name, _saver(4), name_anchor=False
|
||||
)
|
||||
self.assertFalse(contradicted.by_record)
|
||||
self.assertTrue(
|
||||
inspection_resolution(shared, name, _saver(4), name_anchor=False).by_record
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -17,6 +17,8 @@ from database.run_attribution import (
|
||||
from tests import test_execution_history_database as history_tests
|
||||
from tests.background_stops import close_handler
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
from tests.test_pair_scan import sampled_graph
|
||||
from tests.test_execution_context_database import known, snapshot
|
||||
from tests.test_prompt_attribution import issue_graph, llm_text_graph, make_tracker
|
||||
from utils.execution_contexts import (
|
||||
@@ -326,7 +328,7 @@ class TestRecordedOutputNode(RecordedRuns, unittest.TestCase):
|
||||
self.assertIsNone(recorded_output_lookup(broken)(self.image))
|
||||
with (
|
||||
mock.patch(
|
||||
"database.run_attribution._recorded_runs",
|
||||
"database.run_attribution.recorded_runs",
|
||||
side_effect=sqlite3.OperationalError("locked"),
|
||||
),
|
||||
self.assertLogs("prompt_manager.run_attribution", "WARNING"),
|
||||
@@ -451,6 +453,86 @@ class TestScanUsesHistory(RecordedRuns, AdminAPITestCase):
|
||||
self.assertIsNone(self.linked_prompt(path))
|
||||
self.assertEqual(db.list_prompt_pairs()["pairs"], [])
|
||||
|
||||
def scan_dataset(self, names, nodes):
|
||||
"""Two branches of the shared-prefix dataset graph, one run, then a scan."""
|
||||
paths = [self.write(name) for name in names]
|
||||
self.record_run(
|
||||
self.api.db,
|
||||
str(self.output_dir),
|
||||
[("", path.name) for path in paths],
|
||||
[],
|
||||
nodes=nodes,
|
||||
)
|
||||
meta = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
return paths, self.api._ingest_scan_batch_sync([(p, meta) for p in paths])
|
||||
|
||||
def test_the_recorded_saver_links_each_dataset_file_to_its_branch(self):
|
||||
"""#247: before, the legacy parser gave every branch the first prompt."""
|
||||
paths, counts = self.scan_dataset(
|
||||
["IMG_GEN_00001_.png", "IMG_GEN_00002_.png"], ["13", "23"]
|
||||
)
|
||||
self.assertEqual(counts["added"], 2, counts)
|
||||
for path, text in zip(paths, shapes.BRANCH_TEXTS):
|
||||
prompt = self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
|
||||
self.assertEqual(self.linked_prompt(path), prompt["id"])
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
|
||||
def test_a_file_the_legacy_answer_linked_is_left_as_it_is(self):
|
||||
"""#247 Q5: a rescan never adds a second link to the branch prompt."""
|
||||
db = self.api.db
|
||||
legacy = shapes.BRANCH_TEXTS[0]
|
||||
legacy_id = db.save_prompt(legacy, prompt_hash=generate_prompt_hash(legacy))
|
||||
path = self.write("IMG_GEN_00002_.png")
|
||||
db.link_image_to_prompt(legacy_id, str(path))
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [], nodes=["23"])
|
||||
meta = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
|
||||
self.assertEqual(self.linked_prompt(path), legacy_id)
|
||||
self.assertIsNone(
|
||||
db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[1]))
|
||||
)
|
||||
|
||||
def test_a_recorded_run_never_adds_a_second_link(self):
|
||||
db = self.api.db
|
||||
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
|
||||
other = db.save_prompt(text="a dog", prompt_hash=generate_prompt_hash("a dog"))
|
||||
path = self.write("bare_00003_.png")
|
||||
db.link_image_to_prompt(other, str(path))
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, {}), "conflict")
|
||||
rows = db.model.get_connection().execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
|
||||
)
|
||||
self.assertEqual([row[0] for row in rows], [other])
|
||||
|
||||
def test_a_single_file_asks_history_once(self):
|
||||
db = self.api.db
|
||||
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
|
||||
path = self.write("llm_00002_.png")
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
|
||||
meta = {"prompt": json.dumps(llm_text_graph())}
|
||||
statements = []
|
||||
conn = db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
try:
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "linked")
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
lookups = [s for s in statements if "FROM execution_run_outputs" in s]
|
||||
self.assertEqual(len(lookups), 1)
|
||||
|
||||
def test_a_file_the_recorded_saver_links_is_not_queued(self):
|
||||
"""A default-ambiguous file linked at the inspector's step is no
|
||||
Needs review case."""
|
||||
with mock.patch.object(
|
||||
type(self.api), "_record_scanned_review", autospec=True
|
||||
) as record:
|
||||
_paths, counts = self.scan_dataset(
|
||||
["IMG_GEN_00003_.png", "IMG_GEN_00004_.png"], ["13", "23"]
|
||||
)
|
||||
self.assertEqual(counts["added"], 2, counts)
|
||||
record.assert_not_called()
|
||||
|
||||
|
||||
class TestHostHelpers(unittest.TestCase):
|
||||
"""The host lookups degrade to "no proof", never to an error."""
|
||||
@@ -752,13 +834,17 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
|
||||
"""M5: a scan batch asks history once per chunk of names, not per file."""
|
||||
|
||||
def test_a_batch_its_metadata_links_never_asks_history(self):
|
||||
"""R3: the batch lookup is made only when a file needs it."""
|
||||
"""R3: the batch lookup is made only when a file needs it.
|
||||
|
||||
Since #247 every file the default profile leaves undecided asks (the
|
||||
recorded saver may name its branch), so these files resolve as pairs.
|
||||
"""
|
||||
files = []
|
||||
for n in range(3):
|
||||
path = self.output_dir / f"typed_{n:05}_.png"
|
||||
path.write_bytes(b"png")
|
||||
files.append(path)
|
||||
meta = {"prompt": json.dumps(issue_graph(text="typed mountains"))}
|
||||
meta = {"prompt": json.dumps(sampled_graph("typed mountains"))}
|
||||
statements = []
|
||||
conn = self.api.db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
@@ -802,6 +888,72 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
|
||||
}
|
||||
self.assertEqual(linked, {f.name for f in files[:4]})
|
||||
|
||||
def test_the_batch_saver_equals_the_files_recorded_output(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
db = self.api.db
|
||||
names = ["one_node.png", "two_nodes.png", "two_runs.png", "unrecorded.png"]
|
||||
files = []
|
||||
for name in names:
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
files.append(path)
|
||||
root = str(self.output_dir)
|
||||
self.record_run(db, root, [("", "one_node.png")], [], nodes=["13"])
|
||||
self.record_run(
|
||||
db, root, [("", "two_nodes.png")] * 2, [], item=1, nodes=["13", "23"]
|
||||
)
|
||||
self.record_run(db, root, [("", "two_runs.png")], [], item=2)
|
||||
self.record_run(db, root, [("", "two_runs.png")], [], item=3)
|
||||
history = _BatchHistory(self.api, files)
|
||||
with mock.patch(
|
||||
"database.run_attribution.host_output_directory", return_value=root
|
||||
):
|
||||
for path in files:
|
||||
with self.subTest(path=path.name):
|
||||
expected = recorded_output(db, str(path), root)
|
||||
found = history.recorded(str(path))
|
||||
if expected is None:
|
||||
self.assertIsNone(found)
|
||||
else:
|
||||
self.assertEqual(found.saver_id, expected.saver_id)
|
||||
self.assertEqual(history.recorded(str(files[0])).saver_id, "13")
|
||||
self.assertIsNone(history.recorded(str(files[1])).saver_id)
|
||||
|
||||
def test_a_failed_batch_lookup_is_logged_and_names_nothing(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
path = self.output_dir / "x.png"
|
||||
history = _BatchHistory(self.api, [path])
|
||||
with (
|
||||
mock.patch(
|
||||
"py.api.admin.recorded_runs", side_effect=sqlite3.OperationalError("x")
|
||||
),
|
||||
self.assertLogs("prompt_manager", "WARNING"),
|
||||
):
|
||||
self.assertIsNone(history.recorded(str(path)))
|
||||
self.assertIsNone(history.get(str(path)))
|
||||
|
||||
def test_a_failed_prompt_lookup_is_logged_and_names_nothing(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
path = self.output_dir / "one.png"
|
||||
path.write_bytes(b"png")
|
||||
root = str(self.output_dir)
|
||||
self.record_run(self.api.db, root, [("", path.name)], [LLM_TEXT])
|
||||
history = _BatchHistory(self.api, [path])
|
||||
with (
|
||||
mock.patch(
|
||||
"database.run_attribution.host_output_directory", return_value=root
|
||||
),
|
||||
mock.patch(
|
||||
"py.api.admin.named_prompt", side_effect=sqlite3.OperationalError("x")
|
||||
),
|
||||
self.assertLogs("prompt_manager", "WARNING"),
|
||||
):
|
||||
self.assertIsNone(history.get(str(path)))
|
||||
self.assertEqual(history.recorded(str(path)).saver_id, "9")
|
||||
|
||||
|
||||
class TestLinkedOutputRoot(RecordedRuns, unittest.TestCase):
|
||||
"""R2: ComfyUI's output folder reached through a symlink or junction."""
|
||||
|
||||
@@ -40,6 +40,7 @@ IMAGES_STATE = {
|
||||
"media": "all",
|
||||
"tags": ["fluffy"],
|
||||
"favorites": False,
|
||||
"collection": "",
|
||||
}
|
||||
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
|
||||
IMAGES_DEFAULTS = {
|
||||
@@ -51,6 +52,7 @@ IMAGES_DEFAULTS = {
|
||||
"media": "all",
|
||||
"tags": [],
|
||||
"favorites": False,
|
||||
"collection": "",
|
||||
}
|
||||
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
|
||||
# A row saved by an older build: no favorites key, a retired key, a sort
|
||||
@@ -230,6 +232,48 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(len(listed["views"]), 20)
|
||||
|
||||
async def test_write_requires_the_collection_key(self):
|
||||
# #331 D140: a view saved on a collection page keeps the collection id.
|
||||
without = {k: v for k, v in IMAGES_STATE.items() if k != "collection"}
|
||||
good = {"page": "images", "name": "Grid", "state": IMAGES_STATE}
|
||||
cases = [
|
||||
{**good, "state": without},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "0"}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "x"}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": 12}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "012"}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": "1" * 10}},
|
||||
{**good, "state": {**IMAGES_STATE, "collection": None}},
|
||||
# Favorites and a collection are never both a view's filter.
|
||||
{
|
||||
**good,
|
||||
"state": {**IMAGES_STATE, "favorites": True, "collection": "3"},
|
||||
},
|
||||
]
|
||||
for body in cases:
|
||||
with self.subTest(body=body):
|
||||
response, data = await self.send("POST", body=body)
|
||||
self.assertEqual(response.status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
for collection in ("1", "12", "9" * 9):
|
||||
view = await self.create(state={"collection": collection})
|
||||
self.assertEqual(view["state"]["collection"], collection)
|
||||
self.assertIs(view["state"]["favorites"], False)
|
||||
|
||||
async def test_an_old_row_without_a_collection_reads_empty(self):
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', 'Old', ?, 0, 'x', 'x')",
|
||||
(json.dumps({"q": "cat", "favorites": True}),),
|
||||
)
|
||||
_, data = await self.send("GET")
|
||||
(view,) = data["views"]
|
||||
self.assertEqual(
|
||||
view["state"], {**IMAGES_DEFAULTS, "q": "cat", "favorites": True}
|
||||
)
|
||||
self.assertEqual(view["state"]["collection"], "")
|
||||
|
||||
async def test_rename_and_delete_validate_id_and_body(self):
|
||||
view = await self.create()
|
||||
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
|
||||
@@ -618,6 +662,17 @@ class TestSavedViewsSchema(unittest.TestCase):
|
||||
),
|
||||
{**IMAGES_DEFAULTS, "folder": "kept"},
|
||||
)
|
||||
# A stored row with both filters (never written by this build) opens the
|
||||
# collection, as a listing URL with both does.
|
||||
self.assertEqual(
|
||||
normalize_state("images", {"favorites": True, "collection": "4"}),
|
||||
{**IMAGES_DEFAULTS, "collection": "4"},
|
||||
)
|
||||
for bad in ("0", "x", 12, "012"):
|
||||
with self.subTest(collection=bad):
|
||||
self.assertEqual(
|
||||
normalize_state("images", {"collection": bad}), IMAGES_DEFAULTS
|
||||
)
|
||||
self.assertEqual(normalize_state("history", {}), {})
|
||||
|
||||
def test_validation_errors_are_saved_view_invalid(self):
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Storing a file's prompt from its text: one transaction, one prompt each (#247)."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
from tests import test_execution_history_database as history_tests
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
import test_execution_history_database as history_tests
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import (
|
||||
RUN_MARKS,
|
||||
SCAN_MARKS,
|
||||
Marks,
|
||||
ingest_resolved_pair,
|
||||
)
|
||||
from database.scanned_prompts import store_scanned_prompt, store_scanned_prompt_or_raise
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
META = {"prompt": {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}}}
|
||||
|
||||
|
||||
def resolved(positive="cat", negative="blur"):
|
||||
return {
|
||||
"state": "resolved",
|
||||
"positive": {"text": positive, "provenance": {"source": "api"}},
|
||||
"negative": {"text": negative, "provenance": {"source": "api"}},
|
||||
}
|
||||
|
||||
|
||||
class StoreCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "test.db"))
|
||||
self.path = str(Path(self.temp.name) / "gen.png")
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def prompts(self):
|
||||
rows = self.db.model.get_connection().execute(
|
||||
"SELECT id, text FROM prompts ORDER BY id"
|
||||
)
|
||||
return [tuple(row) for row in rows]
|
||||
|
||||
def links(self):
|
||||
rows = self.db.model.get_connection().execute(
|
||||
"SELECT prompt_id, image_path FROM generated_images ORDER BY id"
|
||||
)
|
||||
return [tuple(row) for row in rows]
|
||||
|
||||
def save(self, text, **fields):
|
||||
return self.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **fields
|
||||
)
|
||||
|
||||
|
||||
class TestStoreScannedPrompt(StoreCase):
|
||||
def test_a_new_text_is_created_with_the_scan_marks_and_linked(self):
|
||||
status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, " a cat ", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(status, "added")
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertEqual(prompt["text"], "a cat")
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
self.assertEqual(prompt["notes"], "Auto-scanned from gen.png")
|
||||
self.assertEqual(prompt["run_count"], 0)
|
||||
self.assertEqual(self.links(), [(prompt_id, self.path)])
|
||||
|
||||
def test_a_run_created_prompt_names_the_workflow_run(self):
|
||||
_status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", RUN_MARKS
|
||||
)
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertEqual(prompt["notes"], "Saved from a workflow run: gen.png")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
|
||||
def test_a_stored_text_is_reused_by_hash_and_keeps_its_fields(self):
|
||||
existing = self.save("A Cat", category="mine", notes="kept")
|
||||
status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat ", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual((status, prompt_id), ("linked", existing))
|
||||
prompt = self.db.get_prompt_by_id(existing)
|
||||
self.assertEqual((prompt["category"], prompt["notes"]), ("mine", "kept"))
|
||||
self.assertEqual(prompt["tags"], [])
|
||||
self.assertEqual(len(self.prompts()), 1)
|
||||
|
||||
def test_a_repeat_is_unchanged(self):
|
||||
first = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
again = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(again, ("unchanged", first[1]))
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
|
||||
other = self.save("a dog")
|
||||
# Linked under another spelling of the same file.
|
||||
spelled = os.path.join(self.temp.name, ".", "gen.png")
|
||||
self.db.link_image_to_prompt(other, spelled)
|
||||
result = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result, ("conflict", None))
|
||||
self.assertEqual(self.prompts(), [(other, "a dog")])
|
||||
self.assertEqual(self.links(), [(other, spelled)])
|
||||
|
||||
def test_a_failed_link_raises_and_leaves_no_prompt_behind(self):
|
||||
with mock.patch.object(
|
||||
self.db, "_insert_image_link", side_effect=sqlite3.OperationalError("x")
|
||||
):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
with self.assertLogs("prompt_manager", "ERROR"):
|
||||
result = store_scanned_prompt(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result, ("found", None))
|
||||
self.assertEqual(self.prompts(), [])
|
||||
self.assertEqual(self.links(), [])
|
||||
|
||||
def test_two_concurrent_writers_store_one_prompt_and_one_link(self):
|
||||
hold = history_tests.FirstWriterHold()
|
||||
outcome = self.db._pair_component_outcome
|
||||
started = threading.Barrier(2)
|
||||
|
||||
def held_open(*args, **kwargs):
|
||||
hold()
|
||||
return outcome(*args, **kwargs)
|
||||
|
||||
def write(_index):
|
||||
started.wait(timeout=30)
|
||||
try:
|
||||
return store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)[0]
|
||||
finally:
|
||||
self.db.model.close()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
self.db, "_pair_component_outcome", side_effect=held_open
|
||||
),
|
||||
ThreadPoolExecutor(max_workers=2) as pool,
|
||||
):
|
||||
statuses = sorted(pool.map(write, range(2)))
|
||||
self.assertEqual(statuses, ["added", "unchanged"])
|
||||
self.assertEqual(len(self.prompts()), 1)
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
def test_a_library_move_reruns_the_store(self):
|
||||
from database import scanned_prompts
|
||||
|
||||
real = scanned_prompts.store_scanned_prompt_or_raise
|
||||
calls = []
|
||||
|
||||
def moved_once(*args, **kwargs):
|
||||
if not calls:
|
||||
calls.append(1)
|
||||
self.db.model._generation += 1
|
||||
raise sqlite3.OperationalError("closed")
|
||||
return real(*args, **kwargs)
|
||||
|
||||
with mock.patch.object(
|
||||
scanned_prompts, "store_scanned_prompt_or_raise", side_effect=moved_once
|
||||
):
|
||||
status, _id = store_scanned_prompt(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual((status, calls), ("added", [1]))
|
||||
|
||||
def _linked_folder(self):
|
||||
"""``(out_real, out_link)``: a symlink to the same output folder."""
|
||||
from tests.test_api_duplicate_files import TestReview
|
||||
|
||||
if not TestReview._can_symlink(self.temp.name): # pragma: no cover
|
||||
self.skipTest("symlinks are not available")
|
||||
real = Path(self.temp.name) / "out_real"
|
||||
real.mkdir()
|
||||
link = Path(self.temp.name) / "out_link"
|
||||
os.symlink(str(real), str(link))
|
||||
(real / "a.png").write_bytes(b"png")
|
||||
return os.path.realpath(str(real)), str(link)
|
||||
|
||||
def test_a_file_linked_through_its_real_folder_is_found_through_a_link(self):
|
||||
"""The scan stores realpath spellings, the monitor the watcher's."""
|
||||
real, link = self._linked_folder()
|
||||
other = self.save("a dog")
|
||||
self.db.link_image_to_prompt(other, os.path.join(real, "a.png"))
|
||||
through_link = os.path.join(link, "a.png")
|
||||
self.assertEqual(
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, through_link, META, "a cat", SCAN_MARKS
|
||||
),
|
||||
("conflict", None),
|
||||
)
|
||||
self.assertEqual(
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, through_link, META, "a dog", SCAN_MARKS
|
||||
),
|
||||
("unchanged", other),
|
||||
)
|
||||
pair = ingest_resolved_pair(self.db, through_link, META, resolved())
|
||||
self.assertEqual(pair["status"], "conflict")
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
|
||||
class TestPairMarks(StoreCase):
|
||||
def test_marks_reach_only_created_components(self):
|
||||
kept = self.save("cat", category="mine")
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, META, resolved(), marks=RUN_MARKS
|
||||
)
|
||||
self.assertEqual(result["status"], "added")
|
||||
positive = self.db.get_prompt_by_id(kept)
|
||||
self.assertEqual((positive["category"], positive["tags"]), ("mine", []))
|
||||
negative_id = next(pid for pid, text in self.prompts() if text == "blur")
|
||||
negative = self.db.get_prompt_by_id(negative_id)
|
||||
self.assertEqual(negative["category"], "scanned")
|
||||
self.assertEqual(negative["tags"], ["auto-scanned"])
|
||||
self.assertEqual(negative["notes"], "Saved from a workflow run: gen.png")
|
||||
|
||||
def test_without_marks_created_components_stay_bare(self):
|
||||
"""Regression guard (passes on the base): the node path is unchanged."""
|
||||
ingest_resolved_pair(self.db, self.path, META, resolved())
|
||||
for prompt_id, _text in self.prompts():
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertIsNone(prompt["category"])
|
||||
self.assertEqual(prompt["tags"], [])
|
||||
|
||||
def test_a_blank_negative_creates_only_the_positive(self):
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, META, resolved(negative=""), marks=SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result["status"], "added")
|
||||
self.assertEqual([text for _id, text in self.prompts()], ["cat"])
|
||||
|
||||
def test_marks_are_a_frozen_value(self):
|
||||
self.assertEqual(
|
||||
SCAN_MARKS, Marks("scanned", ("auto-scanned",), SCAN_MARKS.note)
|
||||
)
|
||||
with self.assertRaises(AttributeError):
|
||||
SCAN_MARKS.category = "x"
|
||||
|
||||
|
||||
class TestPublicRunAttribution(unittest.TestCase):
|
||||
def test_recorded_runs_and_named_prompt_are_public(self):
|
||||
from database import run_attribution
|
||||
|
||||
self.assertTrue(callable(run_attribution.recorded_runs))
|
||||
self.assertTrue(callable(run_attribution.named_prompt))
|
||||
self.assertFalse(hasattr(run_attribution, "_recorded_runs"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""The per-install workspace secret (#430, utils/workspace_keys.py).
|
||||
|
||||
A ComfyUI restart must not change the gallery root scopes or the workspace
|
||||
cursors while the output roots stay the same, so the secret that keys them is
|
||||
persisted beside the database instead of drawn per process.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import utils.workspace_keys as workspace_keys # noqa: E402
|
||||
from utils.workspace_keys import ( # noqa: E402
|
||||
KEY_FILE_NAME,
|
||||
WorkspaceKeys,
|
||||
gallery_root_scope,
|
||||
load_install_secret,
|
||||
workspace_keys_for,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class TempFolderCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(folder.cleanup)
|
||||
self.folder = Path(os.path.realpath(folder.name))
|
||||
self.key_file = self.folder / KEY_FILE_NAME
|
||||
|
||||
def leftovers(self):
|
||||
return sorted(p.name for p in self.folder.iterdir() if p.name != KEY_FILE_NAME)
|
||||
|
||||
|
||||
class TestLoadInstallSecret(TempFolderCase):
|
||||
def test_a_missing_file_is_created_with_64_hex_and_owner_only_mode(self):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(len(secret), 32)
|
||||
text = self.key_file.read_text(encoding="ascii")
|
||||
self.assertEqual(text, secret.hex() + "\n")
|
||||
self.assertEqual(self.leftovers(), [], "no temp file is left behind")
|
||||
if os.name == "posix":
|
||||
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode) & 0o077, 0)
|
||||
|
||||
def test_an_existing_file_is_reused(self):
|
||||
first = load_install_secret(str(self.folder))
|
||||
self.assertEqual(load_install_secret(str(self.folder)), first)
|
||||
|
||||
def test_an_uppercase_or_unterminated_file_is_still_read(self):
|
||||
self.key_file.write_text("AB" * 32, encoding="ascii")
|
||||
self.assertEqual(
|
||||
load_install_secret(str(self.folder)), bytes.fromhex("ab" * 32)
|
||||
)
|
||||
|
||||
def test_a_corrupt_file_is_replaced_with_a_warning(self):
|
||||
for content in (b"", b"short", b"zz" * 32, b"ab" * 33, b"\xff" * 64):
|
||||
with self.subTest(content=content):
|
||||
self.key_file.write_bytes(content)
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(len(secret), 32)
|
||||
self.assertEqual(
|
||||
self.key_file.read_text(encoding="ascii"), secret.hex() + "\n"
|
||||
)
|
||||
self.assertIn("replaced", "\n".join(logs.output))
|
||||
self.assertNotIn(secret.hex(), "\n".join(logs.output))
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_a_lost_creation_race_reads_the_winners_file(self):
|
||||
winner = "cd" * 32 + "\n"
|
||||
real_link = os.link
|
||||
|
||||
def racing_link(source, target, *args, **kwargs):
|
||||
Path(target).write_text(winner, encoding="ascii")
|
||||
return real_link(source, target, *args, **kwargs) # FileExistsError
|
||||
|
||||
with patch.object(workspace_keys.os, "link", racing_link):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(secret, bytes.fromhex("cd" * 32))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), winner)
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_a_file_system_without_hard_links_falls_back_to_replace(self):
|
||||
def no_link(*args, **kwargs):
|
||||
raise OSError(1, "Operation not permitted")
|
||||
|
||||
with patch.object(workspace_keys.os, "link", no_link):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), secret.hex() + "\n")
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_an_unwritable_folder_returns_none_and_leaves_nothing(self):
|
||||
def refuse(*args, **kwargs):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
with patch.object(workspace_keys.os, "open", refuse):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
self.assertFalse(self.key_file.exists())
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder / "missing")))
|
||||
|
||||
def test_an_unreadable_key_path_returns_none(self):
|
||||
self.key_file.mkdir() # a folder where the file should be
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
|
||||
def test_a_refused_replace_of_a_corrupt_key_falls_back(self):
|
||||
# Windows refuses os.replace while another process holds the file open.
|
||||
self.key_file.write_text("broken", encoding="ascii")
|
||||
|
||||
def refuse(*args, **kwargs):
|
||||
raise PermissionError(13, "Access is denied")
|
||||
|
||||
with patch.object(workspace_keys.os, "replace", refuse):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), "broken")
|
||||
self.assertEqual(self.leftovers(), [])
|
||||
|
||||
def test_a_lost_race_against_an_unfinished_file_is_unusable_not_replaced(self):
|
||||
def racing_link(source, target, *args, **kwargs):
|
||||
Path(target).write_text("ab", encoding="ascii") # still being written
|
||||
raise FileExistsError(target)
|
||||
|
||||
with patch.object(workspace_keys.os, "link", racing_link):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
self.assertIsNone(load_install_secret(str(self.folder)))
|
||||
self.assertEqual(self.key_file.read_text(encoding="ascii"), "ab")
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "POSIX permission bits")
|
||||
def test_an_existing_key_readable_by_others_is_tightened(self):
|
||||
secret = load_install_secret(str(self.folder))
|
||||
os.chmod(self.key_file, 0o644)
|
||||
self.assertEqual(load_install_secret(str(self.folder)), secret)
|
||||
self.assertEqual(stat.S_IMODE(self.key_file.stat().st_mode), 0o600)
|
||||
|
||||
def test_old_temp_files_are_swept_and_fresh_ones_kept(self):
|
||||
old = self.folder / f".{KEY_FILE_NAME}.dead.tmp"
|
||||
fresh = self.folder / f".{KEY_FILE_NAME}.live.tmp"
|
||||
other = self.folder / "prompts.db"
|
||||
for path in (old, fresh, other):
|
||||
path.write_text("x")
|
||||
os.utime(old, (1, 1))
|
||||
load_install_secret(str(self.folder))
|
||||
self.assertEqual(self.leftovers(), sorted([fresh.name, other.name]))
|
||||
|
||||
|
||||
class TestWorkspaceKeys(TempFolderCase):
|
||||
def test_purposes_are_domain_separated_and_stable(self):
|
||||
keys = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
self.assertTrue(keys.persisted)
|
||||
self.assertNotEqual(keys.root_scope, keys.cursor)
|
||||
again = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
self.assertEqual(
|
||||
(again.root_scope, again.cursor), (keys.root_scope, keys.cursor)
|
||||
)
|
||||
self.assertNotIn(
|
||||
self.key_file.read_text().strip(),
|
||||
(keys.root_scope.hex(), keys.cursor.hex()),
|
||||
)
|
||||
|
||||
def test_another_database_folder_gets_other_keys(self):
|
||||
other = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(other.cleanup)
|
||||
first = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
second = workspace_keys_for(os.path.join(other.name, "prompts.db"))
|
||||
self.assertNotEqual(first.root_scope, second.root_scope)
|
||||
|
||||
def test_an_unusable_folder_falls_back_to_a_process_key_with_a_warning(self):
|
||||
with patch.object(workspace_keys, "load_install_secret", return_value=None):
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING) as logs:
|
||||
keys = workspace_keys_for(str(self.folder / "prompts.db"))
|
||||
self.assertFalse(keys.persisted)
|
||||
self.assertEqual(len(keys.root_scope), 32)
|
||||
self.assertIn("restart", "\n".join(logs.output))
|
||||
|
||||
def test_memory_and_blank_paths_never_write_into_the_working_folder(self):
|
||||
for path in (":memory:", "", None):
|
||||
with self.subTest(path=path):
|
||||
with patch.object(workspace_keys, "load_install_secret") as load:
|
||||
with self.assertLogs("prompt_manager.api", logging.WARNING):
|
||||
keys = workspace_keys_for(path)
|
||||
load.assert_not_called()
|
||||
self.assertFalse(keys.persisted)
|
||||
|
||||
def test_the_scope_is_the_historic_payload_under_the_root_scope_key(self):
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
keys = WorkspaceKeys(b"r" * 32, b"c" * 32, True)
|
||||
payload = json.dumps(
|
||||
["prompt-manager-gallery-root-v1", 3, "/x/ü"],
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
self.assertEqual(
|
||||
gallery_root_scope(keys, "/x/ü", 3),
|
||||
hmac.new(b"r" * 32, payload, hashlib.sha256).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
class TestRestartKeepsScopes(TempFolderCase):
|
||||
"""A new process or a reloaded module derives the same scope (#430)."""
|
||||
|
||||
def test_a_module_reload_keeps_the_scope(self):
|
||||
db = str(self.folder / "prompts.db")
|
||||
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
|
||||
reloaded = importlib.reload(workspace_keys)
|
||||
self.addCleanup(importlib.reload, workspace_keys)
|
||||
self.assertEqual(
|
||||
reloaded.gallery_root_scope(reloaded.workspace_keys_for(db), "/out", 0),
|
||||
before,
|
||||
)
|
||||
|
||||
def test_a_new_process_derives_the_same_scope(self):
|
||||
db = str(self.folder / "prompts.db")
|
||||
before = gallery_root_scope(workspace_keys_for(db), "/out", 0)
|
||||
code = (
|
||||
"import sys\n"
|
||||
"from utils.workspace_keys import gallery_root_scope, workspace_keys_for\n"
|
||||
"print(gallery_root_scope(workspace_keys_for(sys.argv[1]), '/out', 0))\n"
|
||||
)
|
||||
# -I would drop the repo from sys.path; the child imports only stdlib code.
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code, db],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=True,
|
||||
)
|
||||
self.assertEqual(result.stdout.strip(), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+45
-15
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"admin.js": {
|
||||
"lines": 37,
|
||||
"branches": 61,
|
||||
"functions": 32,
|
||||
"statements": 37
|
||||
"lines": 47,
|
||||
"branches": 64,
|
||||
"functions": 50,
|
||||
"statements": 47
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
@@ -38,7 +38,7 @@
|
||||
"focused-node.js": {
|
||||
"lines": 97,
|
||||
"branches": 87,
|
||||
"functions": 90,
|
||||
"functions": 91,
|
||||
"statements": 97
|
||||
},
|
||||
"gallery-settings.js": {
|
||||
@@ -48,10 +48,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"gallery.js": {
|
||||
"lines": 49,
|
||||
"branches": 68,
|
||||
"functions": 50,
|
||||
"statements": 49
|
||||
"lines": 54,
|
||||
"branches": 72,
|
||||
"functions": 57,
|
||||
"statements": 54
|
||||
},
|
||||
"html-escape.js": {
|
||||
"lines": 98,
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"image-helpers.js": {
|
||||
"lines": 96,
|
||||
"branches": 91,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -203,6 +203,12 @@
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/collections.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 93,
|
||||
@@ -260,7 +266,7 @@
|
||||
"workspace/image-tagging.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 92,
|
||||
"functions": 88,
|
||||
"functions": 89,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/images-category.mjs": {
|
||||
@@ -269,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,
|
||||
@@ -293,6 +305,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-source-recovery.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -341,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,
|
||||
@@ -365,6 +389,12 @@
|
||||
"functions": 93,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/library-selection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 86,
|
||||
"functions": 84,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/library-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
@@ -409,7 +439,7 @@
|
||||
},
|
||||
"workspace/local-resolver.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 86,
|
||||
"branches": 87,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -529,7 +559,7 @@
|
||||
},
|
||||
"workspace/system-diagnostics.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 86,
|
||||
"branches": 87,
|
||||
"functions": 94,
|
||||
"statements": 95
|
||||
},
|
||||
@@ -625,7 +655,7 @@
|
||||
},
|
||||
"workspace/vocabulary-dialog.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 88,
|
||||
"branches": 89,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -638,7 +668,7 @@
|
||||
"workspace/vocabulary.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 91,
|
||||
"functions": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/workspace-dialogs.mjs": {
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
"""The per-file prompt decision shared by the output scan and the monitor (#247).
|
||||
|
||||
``decide_file_prompt`` is the ladder: which prompt one output file names,
|
||||
from its metadata alone plus, when the graph cannot decide, the save node
|
||||
recorded history says wrote it. It does no file or database I/O; the caller
|
||||
reads the metadata, the file size and history (through ``lookup``) and
|
||||
stores the ``Decision``.
|
||||
|
||||
The default resolver profile decides first, as ingestion always has. Only
|
||||
for a file it leaves ambiguous or unresolved does the ladder ask the
|
||||
inspector's resolve (``inspection_resolution``: the inspection profile, then
|
||||
the recorded saver anchor; never the file-name saver anchor, whose files go
|
||||
to Needs review by the #390 ruling), then the #170 browser parser (the legacy floor).
|
||||
The policies differ in one place: for a file the inspector calls ambiguous,
|
||||
the scan keeps the legacy floor and the monitor stores nothing. Junk text
|
||||
(``junk_text``) is never stored, whichever step found it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from .prompt_graph import (
|
||||
PROMPT_MANAGER_TYPES,
|
||||
_positive_roots,
|
||||
is_negative_input,
|
||||
metadata_opted_out,
|
||||
resolve_text,
|
||||
save_setting,
|
||||
side_opted_out,
|
||||
)
|
||||
from .prompt_inspector import inspection_resolution
|
||||
from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt
|
||||
from .review_capture import reviewable
|
||||
|
||||
try:
|
||||
from ..database.pair_ingestion import blank_positive
|
||||
except ImportError:
|
||||
from database.pair_ingestion import blank_positive
|
||||
|
||||
|
||||
class Policy(Enum):
|
||||
SCAN = "scan"
|
||||
MONITOR = "monitor"
|
||||
|
||||
|
||||
PAIR = "pair"
|
||||
POSITIVE = "positive"
|
||||
HISTORY = "history"
|
||||
NOT_SAVED = "not_saved"
|
||||
NOT_SAVED_WIRED = "not_saved_wired"
|
||||
NEVER = "never"
|
||||
KINDS = (PAIR, POSITIVE, HISTORY, NOT_SAVED, NOT_SAVED_WIRED, NEVER)
|
||||
|
||||
_UNDECIDED = ("ambiguous", "unresolved")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""What to store for one file (see the design's "The ladder").
|
||||
|
||||
``kind``: one of ``KINDS``. ``text``: the storage text of the positive.
|
||||
``canonical``: the parsed metadata stored with the link. ``resolution``:
|
||||
the resolution that decided a ``pair``, else the default profile's
|
||||
result. ``source``: which step named the text (log lines and the scan's
|
||||
outcome detail). ``review``: ``ambiguous`` or ``untraced`` when the
|
||||
default profile was ambiguous (the scan's Needs review lists).
|
||||
``try_recorded_run``: the scan tries the recorded run's stored prompt
|
||||
when nothing else links. ``unlinked_status``: the scan status when
|
||||
nothing links (None: no prompt metadata).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
text: Optional[str] = None
|
||||
canonical: dict = field(default_factory=dict)
|
||||
resolution: Optional[dict] = None
|
||||
source: str = "none"
|
||||
review: Optional[str] = None
|
||||
try_recorded_run: bool = False
|
||||
unlinked_status: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _File:
|
||||
"""The ladder's inputs beside the metadata."""
|
||||
|
||||
metadata: Mapping
|
||||
canonical: dict
|
||||
resolution: dict
|
||||
review: Optional[str]
|
||||
file_name: Optional[str]
|
||||
in_output: bool
|
||||
lookup: Optional[Callable[[], Any]]
|
||||
policy: Policy
|
||||
|
||||
|
||||
def decide_file_prompt(
|
||||
metadata: Any,
|
||||
*,
|
||||
file_name: Optional[str],
|
||||
file_size: Union[None, int, Callable[[], Optional[int]]],
|
||||
in_output: bool,
|
||||
lookup: Optional[Callable[[], Any]],
|
||||
policy: Policy,
|
||||
) -> Decision:
|
||||
"""The ladder for one file's raw ``metadata``.
|
||||
|
||||
``prompt``/``workflow`` may be JSON strings (the scan) or parsed values
|
||||
(the monitor). ``lookup`` returns the file's ``RecordedOutput`` or None;
|
||||
it is None when history must not be asked yet. It is called at most
|
||||
once, and only for a graph the default profile leaves undecided.
|
||||
``file_size`` may be a callable: it is read only for a file with prompt
|
||||
metadata whose ``file_info`` has no size.
|
||||
"""
|
||||
if not metadata:
|
||||
return Decision(NEVER, try_recorded_run=True)
|
||||
canonical = _parsed(metadata)
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: nothing is stored,
|
||||
# no recorded-run fallback, no Needs review item (#90)
|
||||
return Decision(NOT_SAVED, canonical=canonical)
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it
|
||||
return Decision(NOT_SAVED_WIRED, canonical=canonical)
|
||||
switched = _switched_off_upstream(canonical.get("prompt"))
|
||||
if switched is not None:
|
||||
# The #90 walk follows only prompt-like inputs; any input name
|
||||
# upstream of a positive can carry a switched-off node's text
|
||||
return Decision(switched, canonical=canonical)
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return Decision(NEVER, canonical=canonical, try_recorded_run=True)
|
||||
_with_size(canonical, file_size)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
switched = (
|
||||
_side_switch(canonical.get("prompt"), resolution["positive"])
|
||||
if resolution["state"] == "resolved"
|
||||
else None
|
||||
)
|
||||
if switched is not None:
|
||||
# The positive passes a node that is off, though the #90 graph walk
|
||||
# did not reach it (steps 5 and 7)
|
||||
return Decision(switched, canonical=canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: the positive alone, never a pair
|
||||
positive = resolution["positive"]
|
||||
text = positive.get("storage_text", positive["text"]).strip()
|
||||
return _gated(
|
||||
Decision(
|
||||
POSITIVE,
|
||||
text=text,
|
||||
canonical=canonical,
|
||||
resolution=resolution,
|
||||
source="opted-out-negative",
|
||||
),
|
||||
None,
|
||||
)
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
found = _File(
|
||||
metadata,
|
||||
canonical,
|
||||
resolution,
|
||||
_review(resolution),
|
||||
file_name,
|
||||
in_output,
|
||||
lookup,
|
||||
policy,
|
||||
)
|
||||
return _gated(_decide(found), found)
|
||||
|
||||
|
||||
def _decide(found: _File) -> Decision:
|
||||
state = found.resolution["state"]
|
||||
graph = found.canonical.get("prompt")
|
||||
if state == "resolved":
|
||||
positive = found.resolution["positive"]
|
||||
text = positive.get("storage_text", positive["text"])
|
||||
return _decision(found, PAIR, text, "resolver")
|
||||
if graph and state in _UNDECIDED:
|
||||
return _undecided_graph(found)
|
||||
if state == "ambiguous":
|
||||
return _never(found, _unlinked(found))
|
||||
if state == "unresolved" and not isinstance(found.canonical.get("parameters"), str):
|
||||
return _never(found, "unresolved")
|
||||
if graph:
|
||||
return _sampler_less(found, graph)
|
||||
text = _a1111_positive(found.metadata)
|
||||
if text is None:
|
||||
return _never(found, "found")
|
||||
return _decision(found, POSITIVE, text, "a1111")
|
||||
|
||||
|
||||
def _undecided_graph(found: _File) -> Decision:
|
||||
recorded = found.lookup() if found.lookup is not None else None
|
||||
saver = recorded.saver_id if recorded is not None else None
|
||||
inspected = inspection_resolution(
|
||||
found.metadata, found.file_name, saver, name_anchor=False
|
||||
)
|
||||
text = _inspected_text(inspected.resolution)
|
||||
switched = (
|
||||
_side_switch(found.canonical["prompt"], inspected.resolution["positive"])
|
||||
if text is not None
|
||||
else None
|
||||
)
|
||||
if switched is not None:
|
||||
# The inspector follows pass-throughs the #90 graph walk does not
|
||||
return Decision(switched, canonical=found.canonical)
|
||||
if text is not None:
|
||||
source = "recorded-run" if inspected.by_record else "inspector"
|
||||
return _decision(found, POSITIVE, text, source)
|
||||
if found.policy is Policy.MONITOR:
|
||||
if found.lookup is None and found.in_output:
|
||||
return _decision(found, HISTORY, None, "none")
|
||||
if not inspected.malformed and inspected.resolution["state"] == "ambiguous":
|
||||
return _never(found, _unlinked(found))
|
||||
text = graph_prompt_text(found.metadata)
|
||||
switched = _off_text(found.canonical["prompt"], text) if text else None
|
||||
if switched is not None:
|
||||
# The legacy answer is a switched-off node's own text (#90)
|
||||
return Decision(switched, canonical=found.canonical)
|
||||
if text is not None and _joined_text(found.canonical["prompt"], text):
|
||||
# The legacy parser joins prepend + text + append: the text a
|
||||
# PromptManager node stores cannot be told from it (#343).
|
||||
return _never(found, _unlinked(found), try_recorded_run=True)
|
||||
if text is not None:
|
||||
return _decision(found, POSITIVE, text, "legacy-parser")
|
||||
return _never(found, _unlinked(found), try_recorded_run=True)
|
||||
|
||||
|
||||
def _sampler_less(found: _File, graph: Any) -> Decision:
|
||||
# No node takes a positive or negative input (a guider or wrapper graph):
|
||||
# the one wired text, never a guess (#252).
|
||||
wired = sampler_less_prompt(graph)
|
||||
if wired["text"] is not None:
|
||||
return _decision(found, POSITIVE, wired["text"], "sampler-less")
|
||||
if isinstance(found.canonical.get("parameters"), str):
|
||||
# A metadata saver also wrote A1111 text: its positive prompt.
|
||||
text = _a1111_positive(found.metadata)
|
||||
if text is not None:
|
||||
return _decision(found, POSITIVE, text, "a1111")
|
||||
status = "unresolved" if wired["state"] == "ambiguous" else "found"
|
||||
return _never(found, status, try_recorded_run=True)
|
||||
|
||||
|
||||
_UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser")
|
||||
|
||||
|
||||
def _switch_kind(settings: Any) -> Optional[str]:
|
||||
"""``not_saved`` when a setting is off, ``not_saved_wired`` when one is
|
||||
unknowable (wired from a node the graph cannot read), else None."""
|
||||
settings = list(settings)
|
||||
if False in settings:
|
||||
return NOT_SAVED
|
||||
if None in settings:
|
||||
return NOT_SAVED_WIRED
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_manager_ids(graph: Any) -> list:
|
||||
if not isinstance(graph, dict):
|
||||
return []
|
||||
return [
|
||||
str(node_id)
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
|
||||
|
||||
def _side_switch(graph: Any, side: Any) -> Optional[str]:
|
||||
"""The switch outcome of the PromptManager nodes on a side's path."""
|
||||
if not isinstance(graph, dict) or not side_opted_out(graph, side):
|
||||
return None
|
||||
provenance = side.get("provenance")
|
||||
path = provenance.get("path") if isinstance(provenance, dict) else None
|
||||
path = path if isinstance(path, list) else []
|
||||
nodes = {str(step.get("node_id")) for step in path if isinstance(step, dict)} & set(
|
||||
_prompt_manager_ids(graph)
|
||||
)
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in nodes)
|
||||
|
||||
|
||||
# Inputs that never carry prompt text or positive conditioning.
|
||||
_NOT_TEXT_INPUTS = frozenset(
|
||||
{
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"latent_image",
|
||||
"samples",
|
||||
"pixels",
|
||||
"image",
|
||||
"images",
|
||||
"mask",
|
||||
"noise",
|
||||
"sigmas",
|
||||
"sampler",
|
||||
"control_net",
|
||||
"upscale_model",
|
||||
}
|
||||
)
|
||||
_MAX_SWITCH_VISITS = 5000
|
||||
|
||||
|
||||
def _negative_name(key: Any) -> bool:
|
||||
"""#90's negative rule, plus any name containing "negative"
|
||||
(``negative2``, ``NegativePrompt``)."""
|
||||
return is_negative_input(key) or "negative" in str(key).lower()
|
||||
|
||||
|
||||
def _switched_off_upstream(graph: Any) -> Optional[str]:
|
||||
"""The switch outcome of every PromptManager node upstream of a positive.
|
||||
|
||||
Follows every input except negative and non-text ones (#90's walk follows
|
||||
only prompt-like names). A graph without a recognisable sampler checks
|
||||
every PromptManager node, as #90 does.
|
||||
"""
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
roots = _positive_roots(graph)
|
||||
if not roots:
|
||||
ids = _prompt_manager_ids(graph)
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
|
||||
seen, found = set(), set()
|
||||
stack = [str(link[0]) for link in roots]
|
||||
while stack and len(seen) < _MAX_SWITCH_VISITS:
|
||||
node_id = stack.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
node = graph.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
||||
found.add(node_id)
|
||||
inputs = node.get("inputs")
|
||||
for key, value in (inputs.items() if isinstance(inputs, dict) else ()):
|
||||
if key in _NOT_TEXT_INPUTS or _negative_name(key):
|
||||
continue
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
if isinstance(value[0], (str, int)) and isinstance(value[1], int):
|
||||
stack.append(str(value[0]))
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in found)
|
||||
|
||||
|
||||
def _off_text(graph: Any, text: str) -> Optional[str]:
|
||||
"""The switch outcome when ``text`` is the text (resolved, else literal)
|
||||
of a PromptManager-family node whose Save to library is not on."""
|
||||
wanted = text.strip()
|
||||
matched = []
|
||||
for node_id in _prompt_manager_ids(graph):
|
||||
node_text = resolve_text(graph, node_id)
|
||||
if node_text is None:
|
||||
inputs = graph[node_id].get("inputs")
|
||||
literal = inputs.get("text") if isinstance(inputs, dict) else None
|
||||
node_text = literal if isinstance(literal, str) else None
|
||||
if node_text is not None and node_text.strip() == wanted:
|
||||
matched.append(save_setting(graph, node_id))
|
||||
return _switch_kind(matched)
|
||||
|
||||
|
||||
def _joined_text(graph: Any, text: str) -> bool:
|
||||
"""Whether ``text`` may hold a PromptManager node's prepend or append.
|
||||
|
||||
True when a PromptManager-family node has a prepend or append part and
|
||||
``text`` is not the literal ``text`` of one such node (the legacy
|
||||
parser's answer when it does not follow a linked part).
|
||||
"""
|
||||
own = _prompt_manager_parts(graph)
|
||||
return own is not None and text.strip() not in own
|
||||
|
||||
|
||||
def _prompt_manager_parts(graph: Any) -> Optional[set]:
|
||||
"""The literal texts of PromptManager-family nodes with a prepend or
|
||||
append part, or None when no such node exists."""
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
found = None
|
||||
for node in graph.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
name, inputs = node.get("class_type"), node.get("inputs")
|
||||
if not isinstance(name, str) or "promptmanager" not in name.lower():
|
||||
continue
|
||||
if not isinstance(inputs, dict) or not _has_part(inputs):
|
||||
continue
|
||||
found = found if found is not None else set()
|
||||
if isinstance(inputs.get("text"), str):
|
||||
found.add(inputs["text"].strip())
|
||||
return found
|
||||
|
||||
|
||||
def _has_part(inputs: dict) -> bool:
|
||||
for key in ("prepend_text", "append_text"):
|
||||
part = inputs.get(key)
|
||||
if part is not None and not (isinstance(part, str) and not part.strip()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _gated(decision: Decision, found: Optional[_File]) -> Decision:
|
||||
"""Junk text is never stored (step 13).
|
||||
|
||||
A junk answer of the undecided-graph step keeps that step's unlinked
|
||||
status, so an ambiguous file is still a Needs review case; any other
|
||||
step reports ``found``.
|
||||
"""
|
||||
if decision.kind not in (PAIR, POSITIVE):
|
||||
return decision
|
||||
junk = junk_text(decision.text)
|
||||
if junk is None:
|
||||
return decision
|
||||
return Decision(
|
||||
NEVER,
|
||||
canonical=decision.canonical,
|
||||
resolution=decision.resolution,
|
||||
source=f"junk:{junk}",
|
||||
review=decision.review,
|
||||
try_recorded_run=True,
|
||||
unlinked_status=(
|
||||
_unlinked(found)
|
||||
if found is not None and decision.source in _UNDECIDED_SOURCES
|
||||
else "found"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _decision(found: _File, kind: str, text: Optional[str], source: str) -> Decision:
|
||||
return Decision(
|
||||
kind,
|
||||
text=text,
|
||||
canonical=found.canonical,
|
||||
resolution=found.resolution,
|
||||
source=source,
|
||||
review=found.review,
|
||||
)
|
||||
|
||||
|
||||
def _never(found: _File, status: str, *, try_recorded_run: bool = False) -> Decision:
|
||||
return Decision(
|
||||
NEVER,
|
||||
canonical=found.canonical,
|
||||
resolution=found.resolution,
|
||||
review=found.review,
|
||||
try_recorded_run=try_recorded_run,
|
||||
unlinked_status=status,
|
||||
)
|
||||
|
||||
|
||||
def _review(resolution: dict) -> Optional[str]:
|
||||
if resolution["state"] != "ambiguous":
|
||||
return None
|
||||
return "ambiguous" if reviewable(resolution) else "untraced"
|
||||
|
||||
|
||||
def _unlinked(found: _File) -> str:
|
||||
"""Ambiguous without a choice is reported unresolved."""
|
||||
if found.review == "untraced":
|
||||
return "unresolved"
|
||||
return found.resolution["state"]
|
||||
|
||||
|
||||
def _inspected_text(resolution: dict) -> Optional[str]:
|
||||
"""The storage text of a positive the inspector shows, else None."""
|
||||
if resolution.get("state") in _UNDECIDED:
|
||||
return None
|
||||
positive = resolution.get("positive")
|
||||
if not isinstance(positive, dict):
|
||||
return None
|
||||
text = positive.get("storage_text", positive.get("text"))
|
||||
return text if isinstance(text, str) and text.strip() else None
|
||||
|
||||
|
||||
def _parsed(metadata: Mapping) -> dict:
|
||||
canonical = dict(metadata)
|
||||
for key in ("prompt", "workflow"):
|
||||
if isinstance(canonical.get(key), str):
|
||||
try:
|
||||
canonical[key] = json.loads(canonical[key])
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
return canonical
|
||||
|
||||
|
||||
def _with_size(canonical: dict, file_size: Any) -> None:
|
||||
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
||||
|
||||
Stored as ``generated_images.file_size``; the "new" scan scope skips a
|
||||
known file only while its size is unchanged.
|
||||
"""
|
||||
info = canonical.get("file_info")
|
||||
info = dict(info) if isinstance(info, dict) else {}
|
||||
if info.get("size") is None:
|
||||
size = file_size() if callable(file_size) else file_size
|
||||
if size is not None:
|
||||
info["size"] = size
|
||||
if info:
|
||||
canonical["file_info"] = info
|
||||
|
||||
|
||||
def _a1111_positive(metadata: Mapping) -> Optional[str]:
|
||||
text = parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
return text.strip() if text and text.strip() else None
|
||||
|
||||
|
||||
_LORA_TAG = re.compile(r"<lora:[^>]*>")
|
||||
_LORA_SEPARATORS = " \t\r\n\f\v,;|"
|
||||
_MODEL_FILE = re.compile(r"\S*\.(?:safetensors|ckpt|pt|pth|bin|gguf|sft)", re.I)
|
||||
|
||||
|
||||
def junk_text(text: Any) -> Optional[str]:
|
||||
"""The #252 junk class of ``text``, or None when it may be a prompt."""
|
||||
stripped = text.strip() if isinstance(text, str) else ""
|
||||
if not stripped:
|
||||
return "blank"
|
||||
if _numeric(stripped):
|
||||
return "number"
|
||||
if not _LORA_TAG.sub("", stripped).strip(_LORA_SEPARATORS):
|
||||
return "lora-only"
|
||||
if _MODEL_FILE.fullmatch(stripped):
|
||||
return "model-file"
|
||||
return None
|
||||
|
||||
|
||||
def graph_prompt_text(metadata: Mapping) -> Optional[str]:
|
||||
"""The positive prompt the browser parser traces from the graphs, or None.
|
||||
|
||||
Records parse as the legacy pages parsed them (NaN reads as null).
|
||||
"""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
|
||||
_COMMON_FIELDS = (
|
||||
"positive",
|
||||
"negative",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"seed",
|
||||
)
|
||||
|
||||
|
||||
def _json_or_raw(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def parse_comfyui_prompt(metadata: Mapping) -> dict:
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata.
|
||||
|
||||
Accepts the file's strings or parsed values: a parsed ``prompt`` or
|
||||
``workflow`` is kept as it is and a non-string ``parameters`` is ignored.
|
||||
"""
|
||||
result = {
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"parameters": {},
|
||||
"positive_prompt": None,
|
||||
"negative_prompt": None,
|
||||
}
|
||||
params = metadata.get("parameters")
|
||||
if isinstance(params, str):
|
||||
_parse_a1111(params, result)
|
||||
if result["positive_prompt"] is None:
|
||||
for key in ("prompt", "workflow"):
|
||||
if key in metadata:
|
||||
result[key] = _json_or_raw(metadata[key])
|
||||
for name in _COMMON_FIELDS:
|
||||
if name in metadata:
|
||||
result["parameters"][name] = _json_or_raw(metadata[name])
|
||||
return result
|
||||
|
||||
|
||||
_SETTINGS_FIELDS = ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed")
|
||||
|
||||
|
||||
def _parse_a1111(params: str, result: dict) -> None:
|
||||
"""A1111/Forge ``parameters``: a possibly multi-line positive, then a
|
||||
"Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
Taking the first line as the positive would truncate a multi-line prompt,
|
||||
so the text is split on the settings line instead.
|
||||
"""
|
||||
body = params
|
||||
settings = ""
|
||||
found = re.search(r"\nSteps:\s", body)
|
||||
if found:
|
||||
settings = body[found.start() + 1 :].strip()
|
||||
body = body[: found.start()]
|
||||
negative = None
|
||||
found = re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if found:
|
||||
negative = body[found.end() :].strip()
|
||||
body = body[: found.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
result["parameters"]["parameters"] = params
|
||||
for name in _SETTINGS_FIELDS if settings else ():
|
||||
found = re.search(
|
||||
r"(?i)(?:^|,\s*)" + re.escape(name) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if found:
|
||||
key = name.lower().replace(" ", "_")
|
||||
result["parameters"][key] = found.group(1).strip()
|
||||
@@ -41,9 +41,14 @@ _TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
|
||||
_NEGATIVE_KEY = re.compile(r"(^|_)neg(ative)?($|_)", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_negative_input(key: Any) -> bool:
|
||||
"""Whether an input name carries a negative prompt (#90's one rule)."""
|
||||
return isinstance(key, str) and bool(_NEGATIVE_KEY.search(key))
|
||||
|
||||
|
||||
def follows_input(key: Any) -> bool:
|
||||
"""Whether an input name carries positive conditioning or prompt text."""
|
||||
if not isinstance(key, str) or _NEGATIVE_KEY.search(key):
|
||||
if not isinstance(key, str) or is_negative_input(key):
|
||||
return False
|
||||
return key == "positive" or bool(
|
||||
_CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key)
|
||||
|
||||
+132
-65
@@ -1,4 +1,10 @@
|
||||
"""Read prompt evidence without inference, indexing, or decoding image pixels."""
|
||||
"""Read prompt evidence without inference, indexing, or decoding image pixels.
|
||||
|
||||
``inspection_resolution`` is the inspector's resolve (inspection profile, then
|
||||
the recorded saver anchor, #386). Ingestion (the output scan, through
|
||||
``utils.file_prompt``) calls it too, only for files the default profile
|
||||
cannot decide (#247), so the inspector and ingestion cannot drift.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
@@ -571,6 +577,102 @@ def _recorded_execution(resolved: dict, texts: Mapping) -> dict | None:
|
||||
return {"prompts": prompts, "resolution": resolution}
|
||||
|
||||
|
||||
_ABSENT_RESOLUTION = {
|
||||
"state": "absent",
|
||||
"sampler_id": None,
|
||||
"reason": "No parsed prompt metadata",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
}
|
||||
_MALFORMED_RESOLUTION = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": MALFORMED_EVIDENCE_REASON,
|
||||
"reason_code": "malformed",
|
||||
"node": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InspectionResult:
|
||||
"""The inspector's resolution of one file's metadata (#386, #247).
|
||||
|
||||
``resolution`` is the result to use. ``anchored`` is the result anchored
|
||||
on the recorded saver, kept or not (None when no anchored resolve ran).
|
||||
``by_record`` says the anchored result was kept: history's saver chose
|
||||
the samplers and the result names the positive. ``malformed`` marks
|
||||
metadata the inspector cannot read as a graph.
|
||||
"""
|
||||
|
||||
resolution: dict
|
||||
anchored: dict | None = None
|
||||
by_record: bool = False
|
||||
malformed: bool = False
|
||||
|
||||
|
||||
def inspection_resolution(
|
||||
metadata: Mapping,
|
||||
file_name: str | None,
|
||||
recorded_saver_id: str | None,
|
||||
*,
|
||||
profile: ResolverProfile = INSPECTION_PROFILE,
|
||||
name_anchor: bool = True,
|
||||
) -> InspectionResult:
|
||||
"""Resolve raw file metadata as the inspector does.
|
||||
|
||||
Keeps only the role records, parses a string ``prompt`` and attaches the
|
||||
parsed ``workflow`` only when the graph may hold a PromptManager node.
|
||||
Resolves with ``profile``; when that leaves the prompt open and
|
||||
``recorded_saver_id`` is given, resolves again anchored on that saver
|
||||
and keeps the anchored result only when it names the positive.
|
||||
|
||||
``name_anchor=False`` (ingestion, #247) resolves the unanchored pass
|
||||
without ``file_name``, so the saver its file name names anchors nothing:
|
||||
such a file goes to Needs review, never linked (#390 ruling). The
|
||||
recorded-saver pass still gets ``file_name``, only to refuse a saver the
|
||||
name contradicts.
|
||||
"""
|
||||
workflow = metadata.get("workflow")
|
||||
canonical = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
if not canonical:
|
||||
return InspectionResult(dict(_ABSENT_RESOLUTION))
|
||||
malformed = InspectionResult(dict(_MALFORMED_RESOLUTION), malformed=True)
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return malformed
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
resolved = resolve_prompt_pair(
|
||||
canonical, profile=profile, file_name=file_name if name_anchor else None
|
||||
)
|
||||
if resolved["state"] not in _UNDECIDED or recorded_saver_id is None:
|
||||
return InspectionResult(resolved)
|
||||
anchored = resolve_prompt_pair(
|
||||
canonical,
|
||||
profile=profile,
|
||||
file_name=file_name,
|
||||
recorded_saver_id=recorded_saver_id,
|
||||
)
|
||||
# Only a result whose samplers history's saver chose is about this file.
|
||||
if (
|
||||
anchored.get("recorded_anchor") is True
|
||||
and anchored["state"] not in _UNDECIDED
|
||||
and isinstance(anchored["positive"], dict)
|
||||
):
|
||||
return InspectionResult(anchored, anchored, True)
|
||||
return InspectionResult(resolved, anchored)
|
||||
|
||||
|
||||
def inspect_prompt_evidence(
|
||||
metadata: dict,
|
||||
saved: dict | None = None,
|
||||
@@ -593,22 +695,13 @@ def inspect_prompt_evidence(
|
||||
``execution``.
|
||||
|
||||
Inspection routes resolve with ``INSPECTION_PROFILE``: bounded work, not
|
||||
graph size. Ingestion (scan, live monitor, admin import) calls the resolver
|
||||
directly with its default profile. ``resolution`` carries the closed
|
||||
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
|
||||
graph size. The resolve itself is ``inspection_resolution``, which
|
||||
ingestion also calls for files its default profile cannot decide (#247).
|
||||
``resolution`` carries the closed ``reason_code`` and, for a blocking
|
||||
node, ``node: {id, class_type}``.
|
||||
"""
|
||||
workflow = metadata.get("workflow")
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": MALFORMED_EVIDENCE_REASON,
|
||||
"reason_code": "malformed",
|
||||
"node": None,
|
||||
}
|
||||
if not metadata:
|
||||
roles = {key for key in metadata if key in _ROLE_METADATA_KEYS}
|
||||
if not roles:
|
||||
result = {role: _side("absent") for role in ("positive", "negative")}
|
||||
if saved:
|
||||
context = saved.get("pair_context")
|
||||
@@ -621,56 +714,30 @@ def inspect_prompt_evidence(
|
||||
"verified against this file."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"prompts": result,
|
||||
"resolution": {
|
||||
"state": "absent",
|
||||
"sampler_id": None,
|
||||
"reason": "No parsed prompt metadata",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
},
|
||||
}
|
||||
canonical = dict(metadata)
|
||||
malformed = {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
}
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return malformed
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
return {"prompts": result, "resolution": dict(_ABSENT_RESOLUTION)}
|
||||
inspected = inspection_resolution(
|
||||
metadata, file_name, recorded_saver_id, profile=profile
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
resolved = resolve_prompt_pair(canonical, profile=profile, file_name=file_name)
|
||||
recorded = False
|
||||
if resolved["state"] in _UNDECIDED and recorded_saver_id is not None:
|
||||
anchored = resolve_prompt_pair(
|
||||
canonical,
|
||||
profile=profile,
|
||||
file_name=file_name,
|
||||
recorded_saver_id=recorded_saver_id,
|
||||
)
|
||||
# Only a result whose samplers history's saver chose is about this file.
|
||||
if anchored.get("recorded_anchor") is True:
|
||||
if anchored["state"] not in _UNDECIDED and isinstance(
|
||||
anchored["positive"], dict
|
||||
):
|
||||
resolved, recorded = anchored, True
|
||||
elif recorded_texts:
|
||||
executed = _recorded_execution(anchored, recorded_texts)
|
||||
if executed is not None:
|
||||
return executed
|
||||
if inspected.malformed:
|
||||
resolution = inspected.resolution
|
||||
return {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
}
|
||||
resolved, recorded = inspected.resolution, inspected.by_record
|
||||
anchored = inspected.anchored
|
||||
if (
|
||||
not recorded
|
||||
and recorded_texts
|
||||
and anchored is not None
|
||||
and anchored.get("recorded_anchor") is True
|
||||
):
|
||||
executed = _recorded_execution(anchored, recorded_texts)
|
||||
if executed is not None:
|
||||
return executed
|
||||
state = resolved["state"]
|
||||
result = {}
|
||||
for role in ("positive", "negative"):
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""The per-install secret behind the workspace's gallery scopes and cursors (#430).
|
||||
|
||||
A page addresses a listed file as ``{root_index, root_scope, path}``. The root
|
||||
scope is an HMAC over the root's index and canonical path, so the server can
|
||||
refuse an action aimed at a root that was renumbered or replaced since the page
|
||||
listed it (``py/api/images.py`` ``_gallery_scope_error``). The listing's keyset
|
||||
cursors are MACed with a second key, so a forged position never reaches the
|
||||
sort code.
|
||||
|
||||
Both keys used to be drawn per process, so every ComfyUI restart made every
|
||||
open workspace tab stale. They now derive from one random secret persisted in
|
||||
``workspace-scope.key`` beside the database (never in ``config.json`` and never
|
||||
in the database, so a backup or an export does not carry it). When the folder
|
||||
cannot hold it, the process falls back to a per-process secret, as before.
|
||||
|
||||
Standard library only: a fresh process can derive the same scope without
|
||||
importing the API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import time
|
||||
from typing import NamedTuple
|
||||
|
||||
logger = logging.getLogger("prompt_manager.api")
|
||||
|
||||
KEY_FILE_NAME = "workspace-scope.key"
|
||||
SECRET_BYTES = 32
|
||||
_HEX = re.compile(r"[0-9a-fA-F]{64}")
|
||||
# Bounded read: a valid file is 64 hex characters and a newline.
|
||||
_MAX_FILE_BYTES = 128
|
||||
_ROOT_SCOPE_PURPOSE = b"prompt-manager/gallery-root-scope/v1"
|
||||
_CURSOR_PURPOSE = b"prompt-manager/workspace-cursor/v1"
|
||||
_SCOPE_TAG = "prompt-manager-gallery-root-v1"
|
||||
_TEMP_PREFIX = f".{KEY_FILE_NAME}."
|
||||
_TEMP_SUFFIX = ".tmp"
|
||||
_STALE_TEMP_SECONDS = 60
|
||||
|
||||
|
||||
class WorkspaceKeys(NamedTuple):
|
||||
"""The derived keys; ``persisted`` is False for a per-process fallback."""
|
||||
|
||||
root_scope: bytes
|
||||
cursor: bytes
|
||||
persisted: bool
|
||||
|
||||
|
||||
def _read(path: str) -> bytes | None:
|
||||
"""The secret in *path*, None when it is missing or not 64 hex characters.
|
||||
|
||||
Raises OSError for anything but a missing file (a folder, no permission).
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
raw = handle.read(_MAX_FILE_BYTES + 1)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("ascii").strip()
|
||||
except UnicodeDecodeError:
|
||||
return b""
|
||||
return bytes.fromhex(text) if _HEX.fullmatch(text) else b""
|
||||
|
||||
|
||||
def _write_exclusive(path: str, secret: bytes) -> None:
|
||||
"""Create *path* (it must not exist) holding *secret*, owner-only, fsynced."""
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write((secret.hex() + "\n").encode("ascii"))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except BaseException:
|
||||
_unlink(path)
|
||||
raise
|
||||
|
||||
|
||||
def _unlink(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _sweep_temps(directory: str) -> None:
|
||||
"""Remove temp files a crashed first start left (older than a minute)."""
|
||||
try:
|
||||
names = os.listdir(directory)
|
||||
except OSError:
|
||||
return
|
||||
cutoff = time.time() - _STALE_TEMP_SECONDS
|
||||
for name in names:
|
||||
if name.startswith(_TEMP_PREFIX) and name.endswith(_TEMP_SUFFIX):
|
||||
path = os.path.join(directory, name)
|
||||
try:
|
||||
if os.stat(path).st_mtime < cutoff:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _tighten(path: str) -> None:
|
||||
"""On POSIX an existing key readable by group or others becomes 0o600."""
|
||||
if os.name != "posix":
|
||||
return # the file inherits the folder's ACL, as the database does
|
||||
try:
|
||||
if stat.S_IMODE(os.stat(path).st_mode) & 0o077:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _store(directory: str, path: str, secret: bytes, *, replace: bool) -> None:
|
||||
"""Publish *secret* at *path*; a new file never overwrites a concurrent winner.
|
||||
|
||||
The secret is written to a temp file first. ``os.link`` then fails when
|
||||
*path* exists, so of two first starts exactly one file wins
|
||||
(FileExistsError for the other). A file system without hard links (FAT,
|
||||
some shares) creates *path* with ``O_EXCL`` instead. *replace* (a corrupt
|
||||
key) swaps the file with ``os.replace``; Windows refuses that while
|
||||
another process holds the file open, which the caller treats as unusable.
|
||||
"""
|
||||
temp = os.path.join(
|
||||
directory, f"{_TEMP_PREFIX}{secrets.token_hex(8)}{_TEMP_SUFFIX}"
|
||||
)
|
||||
_write_exclusive(temp, secret)
|
||||
try:
|
||||
if replace:
|
||||
os.replace(temp, path)
|
||||
return
|
||||
try:
|
||||
os.link(temp, path)
|
||||
except FileExistsError:
|
||||
raise
|
||||
except OSError:
|
||||
# Not atomic: a second first start reading mid-write would see a
|
||||
# corrupt key. Rare (two first starts on FAT or a share at once);
|
||||
# each process still uses the file it reads back.
|
||||
_write_exclusive(path, secret)
|
||||
finally:
|
||||
_unlink(temp)
|
||||
|
||||
|
||||
def load_install_secret(directory: str) -> bytes | None:
|
||||
"""The persisted secret of *directory*, created on first use; None if unusable.
|
||||
|
||||
After any write the file is read back and its content is what counts, so
|
||||
processes racing on a first start end with the one secret on disk.
|
||||
"""
|
||||
path = os.path.join(directory, KEY_FILE_NAME)
|
||||
try:
|
||||
_sweep_temps(directory)
|
||||
current = _read(path)
|
||||
if current:
|
||||
_tighten(path)
|
||||
return current
|
||||
corrupt = current is not None
|
||||
try:
|
||||
_store(directory, path, secrets.token_bytes(SECRET_BYTES), replace=corrupt)
|
||||
except FileExistsError:
|
||||
pass # another process created it first: its file counts
|
||||
stored = _read(path)
|
||||
if not stored:
|
||||
raise OSError(f"{KEY_FILE_NAME} is not readable after it was written")
|
||||
if corrupt:
|
||||
logger.warning(
|
||||
f"{KEY_FILE_NAME} was not a valid key and was replaced; "
|
||||
"open workspace pages must refresh once"
|
||||
)
|
||||
return stored
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not use {KEY_FILE_NAME}: {exc.__class__.__name__}")
|
||||
return None
|
||||
|
||||
|
||||
def _derive(secret: bytes, purpose: bytes) -> bytes:
|
||||
return hmac.new(secret, purpose, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def workspace_keys_for(db_path: str | None) -> WorkspaceKeys:
|
||||
"""The keys for the library whose database is *db_path* (blocking I/O)."""
|
||||
secret = None
|
||||
if isinstance(db_path, str) and db_path.strip() and db_path != ":memory:":
|
||||
secret = load_install_secret(os.path.dirname(os.path.abspath(db_path)))
|
||||
persisted = secret is not None
|
||||
if not persisted:
|
||||
logger.warning(
|
||||
"Workspace scopes use a per-process key: open workspace pages must "
|
||||
"refresh after a restart"
|
||||
)
|
||||
secret = secrets.token_bytes(SECRET_BYTES)
|
||||
return WorkspaceKeys(
|
||||
_derive(secret, _ROOT_SCOPE_PURPOSE),
|
||||
_derive(secret, _CURSOR_PURPOSE),
|
||||
persisted,
|
||||
)
|
||||
|
||||
|
||||
def gallery_root_scope(keys: WorkspaceKeys, canonical_root: str, index: int) -> str:
|
||||
"""The 64-hex scope of the root at *index* (its canonical path string)."""
|
||||
payload = json.dumps(
|
||||
[_SCOPE_TAG, index, canonical_root], separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
return hmac.new(keys.root_scope, payload, hashlib.sha256).hexdigest()
|
||||
@@ -40,7 +40,8 @@
|
||||
videoAutoplay: false,
|
||||
videoMute: true,
|
||||
videoLoop: true,
|
||||
workspaceLayout: "grid",
|
||||
// #451: Images opens in Masonry unless this browser chose another layout.
|
||||
workspaceLayout: "masonry",
|
||||
workspaceDensity: "comfortable",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Collections (#331): named, hand-picked groups of images, persisted through `/prompt_manager/collections*`.
|
||||
*
|
||||
* - D136: the sidebar COLLECTIONS group (the concept's `#collections-nav`, index.html 28–31, app.js 336–339) lists
|
||||
* each collection as `#/collection/<id>` with its listed-member count, without the concept's "PROPOSED" badge.
|
||||
* - D137: a collection page is Images filtered to the collection; its Edit renames or deletes it through the saved
|
||||
* views' Edit dialog (D51) with collection nouns.
|
||||
* - This module holds the client store (`createCollections`), the route hash, the name rule and every user-facing
|
||||
* string of the design (§7.4, §8), so the selection bar, the inspector and the shell say the same things.
|
||||
*/
|
||||
import { noun } from './plural.mjs';
|
||||
import { openEditDialog } from './saved-views.mjs';
|
||||
|
||||
export const COLLECTIONS_URL = '/prompt_manager/collections';
|
||||
export const COLLECTION_ITEMS_URL = `${COLLECTIONS_URL}/items`;
|
||||
export const COLLECTION_MEMBERSHIP_URL = `${COLLECTIONS_URL}/membership`;
|
||||
export const MAX_COLLECTION_NAME_LENGTH = 100;
|
||||
// The routes' cap on one add or remove (the bulk tagging cap, MAX_BULK_IMAGES).
|
||||
export const MAX_COLLECTION_ITEMS = 500;
|
||||
// Counts are re-read once after a run of confirmed changes settles, as the Favorites count (#249 M1).
|
||||
export const COLLECTION_COUNT_DELAY_MS = 300;
|
||||
|
||||
// Strings (design §7.4 and §8).
|
||||
export const ADD_DIALOG_TITLE = 'Add selected images to collection';
|
||||
export const ADD_DIALOG_LABEL = 'Collection name';
|
||||
export const ADD_DIALOG_ACTION = 'Add';
|
||||
export const TOO_MANY_TOAST = `Select at most ${MAX_COLLECTION_ITEMS} images.`;
|
||||
export const FILES_NOT_FOUND_TOAST = 'None of the selected images could be found. Refresh the images and try again.';
|
||||
export const SOURCE_CHANGED_TOAST = 'The sources changed. Refresh the images and try again.';
|
||||
export const ADD_FAILED_TOAST = 'The images could not be added. Try again.';
|
||||
export const REMOVE_FAILED_TOAST = 'The images could not be removed. Try again.';
|
||||
export const COLLECTION_GONE_TOAST = 'That collection no longer exists.';
|
||||
export const RENAMED_TOAST = 'Collection renamed.';
|
||||
export const RENAME_FAILED_TOAST = 'The collection could not be renamed. Try again.';
|
||||
export const DELETED_TOAST = 'Collection deleted.';
|
||||
export const DELETE_FAILED_TOAST = 'The collection could not be deleted. Try again.';
|
||||
export const DELETED_PAGE_TEXT = 'This collection no longer exists.';
|
||||
export const OPEN_IMAGES_LINK = 'Open Images';
|
||||
export const VIEW_COLLECTION_DELETED = 'The collection this view uses was deleted.';
|
||||
export const LIST_FAILED_TEXT = 'Collections could not be loaded. Your saved data has not changed.';
|
||||
export const COLLECTIONS_UPDATED_TOAST = 'Collections updated.';
|
||||
export const UPDATE_FAILED_TEXT = 'The collections could not be updated. Try again.';
|
||||
export const MEMBERSHIP_LOADING_TEXT = 'Loading collections…';
|
||||
export const MEMBERSHIP_FAILED_TEXT = 'Collections could not be loaded.';
|
||||
export const NO_COLLECTIONS_TEXT = 'No collections yet. Name one below.';
|
||||
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/;
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const isId = value => Number.isSafeInteger(value) && value >= 1 && value <= 999_999_999;
|
||||
|
||||
/** The route of one collection. */
|
||||
export function collectionHash(id) {
|
||||
if (!isId(id)) throw new TypeError('Invalid collection id');
|
||||
return `#/collection/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A name as the database stores it (design §1): trimmed, each run of whitespace one space, 1 to 100 characters
|
||||
* without control characters; null otherwise. Tabs and newlines are whitespace, so they collapse.
|
||||
*/
|
||||
export function normalizeCollectionName(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const name = value.trim().replace(/\s+/g, ' ');
|
||||
return name.length >= 1 && name.length <= MAX_COLLECTION_NAME_LENGTH && !CONTROL.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/** One listed collection, or null when the row is not usable. `count` and `missing` are null unless counts. */
|
||||
export function parseCollection(row) {
|
||||
if (!isId(row?.id) || typeof row.name !== 'string' || !row.name.trim()) return null;
|
||||
const counted = isCount(row.count) && isCount(row.missing);
|
||||
return { id: row.id, name: row.name, count: counted ? row.count : null, missing: counted ? row.missing : null };
|
||||
}
|
||||
|
||||
/** The listing's collections in the server's order (name, then id), skipping unusable rows. */
|
||||
export function parseCollections(data) {
|
||||
if (!Array.isArray(data?.collections)) throw new TypeError('Invalid collections');
|
||||
return data.collections.map(parseCollection).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */
|
||||
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;
|
||||
});
|
||||
|
||||
/** "Added N images to “X”." with what was already in it and what could not be found. */
|
||||
export function addedMessage({ collection, added, already, notFound }) {
|
||||
if (added === 0 && notFound === 0) return `All selected images were already in “${collection.name}”.`;
|
||||
let text = `Added ${added} ${noun(added, 'image')} to “${collection.name}”.`;
|
||||
if (already > 0) text += already === 1 ? ' 1 was already in it.' : ` ${already} were already in it.`;
|
||||
if (notFound > 0) text += ` ${notFound} could not be found.`;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function removedMessage(name, removed) {
|
||||
return `Removed ${removed} ${noun(removed, 'image')} from “${name}”.`;
|
||||
}
|
||||
|
||||
export function nameTakenMessage(name) {
|
||||
return `A collection named “${name}” already exists.`;
|
||||
}
|
||||
|
||||
/** The Delete confirmation, with the stored count (listed plus missing) when the counts are known. */
|
||||
export function deleteQuestion({ name, count, missing }) {
|
||||
if (!isCount(count) || !isCount(missing)) {
|
||||
return `Delete “${name}”? Its images stay where they are; only the collection is removed.`;
|
||||
}
|
||||
const stored = count + missing;
|
||||
return stored === 1
|
||||
? `Delete “${name}”? Its 1 image stays where it is; only the collection is removed.`
|
||||
: `Delete “${name}”? Its ${stored} images stay where they are; only the collection is removed.`;
|
||||
}
|
||||
|
||||
/** The collection page's note about members its sources do not list now (MC3); empty when there are none. */
|
||||
export function missingNote(missing) {
|
||||
if (!isCount(missing) || missing === 0) return '';
|
||||
return missing === 1
|
||||
? '1 image in this collection is not in your current sources.'
|
||||
: `${missing} images in this collection are not in your current sources.`;
|
||||
}
|
||||
|
||||
export const isCollectionGone = error => error?.code === 'collection-not-found';
|
||||
|
||||
/** A failed add or remove's toast: the routes' error codes, else `fallback`. */
|
||||
export function writeFailure(error, fallback) {
|
||||
if (isCollectionGone(error)) return COLLECTION_GONE_TOAST;
|
||||
if (error?.code === 'source-changed') return SOURCE_CHANGED_TOAST;
|
||||
if (error?.code === 'files-not-found') return FILES_NOT_FOUND_TOAST;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** The Edit dialog's mapping of a failed rename of `name` (openEditDialog `renameError`). */
|
||||
export function renameFailure(name) {
|
||||
return error => {
|
||||
if (error?.code === 'name-taken') return { field: nameTakenMessage(name) };
|
||||
if (isCollectionGone(error)) return { toast: COLLECTION_GONE_TOAST, close: true };
|
||||
return { toast: RENAME_FAILED_TOAST };
|
||||
};
|
||||
}
|
||||
|
||||
/** The collection nouns of the shared Edit dialog (D51, D137). */
|
||||
export const COLLECTION_NOUNS = Object.freeze({
|
||||
title: 'Edit collection', label: 'Collection name', deleteTitle: 'Delete collection', deleteText: deleteQuestion,
|
||||
renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST,
|
||||
});
|
||||
|
||||
/**
|
||||
* D137: the open collection's Edit. A taken name stays in the dialog under the field; a failed rename keeps it open;
|
||||
* a collection deleted meanwhile closes it and runs `onGone()` after the toast. `onRename(name)` sends the normalized
|
||||
* name; a delete's 404 counts as deleted (the store's `delete`).
|
||||
*/
|
||||
export function openEditCollectionDialog({ scope, trigger, collection, onRename, onDelete, onGone = () => {} }) {
|
||||
let attempted = '';
|
||||
const mapping = error => {
|
||||
const outcome = renameFailure(attempted)(error);
|
||||
if (outcome.close) queueMicrotask(onGone);
|
||||
return outcome;
|
||||
};
|
||||
openEditDialog({ scope, trigger, item: collection, nouns: COLLECTION_NOUNS, onDelete, renameError: mapping,
|
||||
onRename: name => {
|
||||
attempted = normalizeCollectionName(name) ?? name;
|
||||
return onRename(attempted);
|
||||
} });
|
||||
}
|
||||
|
||||
function fileTargets(items) {
|
||||
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_COLLECTION_ITEMS) {
|
||||
throw new TypeError('A collection change needs 1 to 500 files');
|
||||
}
|
||||
return items.map(item => {
|
||||
if (!Number.isSafeInteger(item?.root_index) || item.root_index < 0 || typeof item.root_scope !== 'string' ||
|
||||
!item.root_scope || typeof item.path !== 'string' || !item.path) {
|
||||
throw new TypeError('Invalid collection file');
|
||||
}
|
||||
return { root_index: item.root_index, root_scope: item.root_scope, path: item.path };
|
||||
});
|
||||
}
|
||||
|
||||
function requireId(id) {
|
||||
if (!isId(id)) throw new TypeError('Invalid collection id');
|
||||
return id;
|
||||
}
|
||||
|
||||
function requireName(name) {
|
||||
const normalized = normalizeCollectionName(name);
|
||||
if (normalized === null) throw new TypeError('Invalid collection name');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** The `{id, name}` a write answered with. */
|
||||
function answered(data) {
|
||||
const collection = parseCollection(data?.collection);
|
||||
if (!collection) throw new TypeError('Invalid collection');
|
||||
return { id: collection.id, name: collection.name };
|
||||
}
|
||||
|
||||
function countOf(value) {
|
||||
if (!isCount(value)) throw new TypeError('Invalid collection count');
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's collection list. `load()` re-reads it (a newer read supersedes an older one), `subscribe(fn)` reports
|
||||
* `{collections, status}` on every change, `settled()` resolves once no read is in flight; `status` is 'loading',
|
||||
* 'ready' or 'error' and a failed read keeps the last list. `add`, `remove`, `rename` and `delete` change the listed
|
||||
* rows at once and re-read the counts once, 300 ms after the last confirmed change; a write to a collection that is
|
||||
* gone (404 `collection-not-found`) re-reads the list at once and rejects. `timers` is a test seam.
|
||||
*/
|
||||
export function createCollections(api, signal, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
||||
let collections = [], status = 'loading', request = null, timer = 0;
|
||||
const listeners = new Set();
|
||||
let waiting = [];
|
||||
const emit = () => {
|
||||
const snapshot = { collections, status };
|
||||
for (const listener of listeners) listener(snapshot);
|
||||
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
|
||||
};
|
||||
const stopTimer = () => { if (timer) clearTimer(timer); timer = 0; };
|
||||
signal?.addEventListener('abort', () => { stopTimer(); request?.abort(); }, { once: true });
|
||||
function load() {
|
||||
if (signal?.aborted) return Promise.resolve();
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
status = 'loading';
|
||||
emit();
|
||||
return api.get(COLLECTIONS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
|
||||
if (request !== controller) return;
|
||||
collections = parseCollections(data);
|
||||
status = 'ready';
|
||||
}).catch(() => {
|
||||
if (request !== controller) return;
|
||||
status = 'error';
|
||||
}).finally(() => {
|
||||
if (request !== controller || signal?.aborted) return;
|
||||
request = null;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
function changed(next) {
|
||||
collections = ordered(next);
|
||||
emit();
|
||||
stopTimer();
|
||||
timer = setTimer(() => { timer = 0; load(); }, COLLECTION_COUNT_DELAY_MS);
|
||||
}
|
||||
/** Send one write; a collection found gone meanwhile re-reads the list before the error reaches the caller. */
|
||||
async function send(write) {
|
||||
try {
|
||||
return await write();
|
||||
} catch (error) {
|
||||
if (isCollectionGone(error)) load();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function add(target, items) {
|
||||
const byId = target?.collectionId !== undefined, byName = target?.name !== undefined;
|
||||
if (byId === byName) throw new TypeError('A collection is named by its id or a name');
|
||||
const body = byId ? { collection_id: requireId(target.collectionId) } : { name: requireName(target.name) };
|
||||
body.items = fileTargets(items);
|
||||
const data = await send(() => api.post(COLLECTION_ITEMS_URL, body, { signal }));
|
||||
const result = { collection: answered(data), created: data.created === true, added: countOf(data.added),
|
||||
already: countOf(data.already), notFound: countOf(data.not_found) };
|
||||
const { id, name } = result.collection;
|
||||
changed(collections.some(item => item.id === id)
|
||||
? collections.map(item => item.id === id ? { ...item, name } : item)
|
||||
: [...collections, { id, name, count: null, missing: null }]);
|
||||
return result;
|
||||
}
|
||||
async function remove(id, items) {
|
||||
const body = { collection_id: requireId(id), items: fileTargets(items) };
|
||||
const data = await send(() => api.del(COLLECTION_ITEMS_URL, { signal, body }));
|
||||
const result = { collection: answered(data), removed: countOf(data.removed) };
|
||||
changed(collections);
|
||||
return result;
|
||||
}
|
||||
async function membership(identity) {
|
||||
const [file] = fileTargets([identity]);
|
||||
const query = new URLSearchParams({ root_index: String(file.root_index), root_scope: file.root_scope, path: file.path });
|
||||
const data = await api.get(`${COLLECTION_MEMBERSHIP_URL}?${query}`, { signal, cache: 'no-store' });
|
||||
const ids = data?.collection_ids;
|
||||
if (!Array.isArray(ids) || !ids.every(isId)) throw new TypeError('Invalid collection membership');
|
||||
return [...ids];
|
||||
}
|
||||
async function rename(id, name) {
|
||||
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
|
||||
const body = { name: requireName(name) };
|
||||
const renamed = answered(await send(() => api.patch(url, body, { signal })));
|
||||
changed(collections.map(item => item.id === renamed.id ? { ...item, name: renamed.name } : item));
|
||||
return renamed;
|
||||
}
|
||||
async function deleteCollection(id) {
|
||||
const url = `${COLLECTIONS_URL}/${requireId(id)}`;
|
||||
try {
|
||||
await api.del(url, { signal });
|
||||
} catch (error) {
|
||||
// Already gone (another tab deleted it): the outcome the user asked for.
|
||||
if (error?.status !== 404) throw error;
|
||||
}
|
||||
changed(collections.filter(item => item.id !== id));
|
||||
return id;
|
||||
}
|
||||
return {
|
||||
load, add, remove, membership, rename, delete: deleteCollection,
|
||||
get collections() { return collections; },
|
||||
get status() { return status; },
|
||||
find: id => collections.find(item => item.id === id) ?? null,
|
||||
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ collections, status }),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -34,9 +34,13 @@ const MAX_SUGGESTIONS = 100;
|
||||
const MAX_INSTRUCTION_CHARS = 4000;
|
||||
const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
|
||||
// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
|
||||
const REOPEN = {'storage-changed': 'storage-changed', 'file-changed': 'file-changed', 'source-changed': 'file-changed', 'capture-expired': 'capture-expired'};
|
||||
const REOBSERVE = new Set(Object.values(REOPEN));
|
||||
const REOBSERVE = new Set(['storage-changed', 'file-changed', 'capture-expired']);
|
||||
// #430: a changed source (the page's root scope no longer names its root) is never retried as is: the page re-scopes
|
||||
// the target (`retarget`) or the dialog offers a refresh.
|
||||
const REOPEN = Object.freeze({...Object.fromEntries([...REOBSERVE].map(code => [code, code])), 'source-changed': 'source-changed'});
|
||||
const RETRYABLE = new Set(['observe-error', 'generate-error', 'unknown-outcome', ...REOBSERVE]);
|
||||
// States whose reason blocks Save (and Generate): the dialog shows it beside its actions (D147).
|
||||
const BLOCKED = new Set(['observe-error', 'source-changed', 'unknown-outcome', ...REOBSERVE]);
|
||||
// Generic failure envelopes whose fixed sentence the dialog words itself.
|
||||
const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed', 'capture-error']);
|
||||
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
||||
@@ -269,6 +273,7 @@ function derive(state) {
|
||||
canSave: editable,
|
||||
canGenerate: editable && state.generateSupported,
|
||||
canRetry: RETRYABLE.has(state.phase),
|
||||
blocked: BLOCKED.has(state.phase),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,7 +296,7 @@ function validatePreset(preset) {
|
||||
*/
|
||||
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) {
|
||||
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
|
||||
const imageTarget = validateTarget(target);
|
||||
let imageTarget = validateTarget(target);
|
||||
requireValue(SOURCES.has(source), 'Unknown tagging source');
|
||||
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
|
||||
const chosen = validatePreset(preset);
|
||||
@@ -364,6 +369,10 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
}
|
||||
} catch (error) {
|
||||
if (closed || serial !== openSerial) return;
|
||||
if (isApiError(error) && error.code === 'source-changed') {
|
||||
set({phase: 'source-changed', statusText: error.message});
|
||||
return;
|
||||
}
|
||||
set({phase: 'observe-error', statusText: messageOf(error, COPY.observeFailed)});
|
||||
return;
|
||||
}
|
||||
@@ -531,6 +540,16 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
return send(lastBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* #430: the page found the file under a fresh `target` (its sources were renumbered): observe it there, keeping the
|
||||
* typed draft. Refused (false) while a save is in flight or after close; a Generate for the old target is dropped.
|
||||
*/
|
||||
function retarget(next) {
|
||||
if (closed || saving) return Promise.resolve(false);
|
||||
imageTarget = validateTarget(next);
|
||||
return open({keepDraft: observedOnce || Boolean(state.manual)}).then(() => true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (closed) return;
|
||||
set({closed: true});
|
||||
@@ -556,7 +575,8 @@ export function createImageTaggingController({api, target, source = 'images', on
|
||||
|
||||
return {
|
||||
get state() {return state;},
|
||||
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
|
||||
get target() {return imageTarget;},
|
||||
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, retarget, cancel, dispose,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ import { showWorkspaceToast } from './toast.mjs';
|
||||
import { cardTitle } from './images-state.mjs';
|
||||
|
||||
const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
|
||||
// #430: while the page looks for a renumbered source's file, and the preview that a refused capture leaves.
|
||||
const FINDING_SOURCE = 'The source of this image changed. Finding it in the refreshed image collection…';
|
||||
const PREVIEW_UNAVAILABLE = 'Preview unavailable';
|
||||
const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
|
||||
// The option inputs per controller key; the classifier keys only exist for WD14 models.
|
||||
const OPTION_IDS = Object.freeze({
|
||||
@@ -194,6 +197,13 @@ function previewOf(image, state) {
|
||||
if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
|
||||
return node;
|
||||
}
|
||||
/** D147: the preview box a failed or refused capture leaves, saying so (the reason is beside the actions). */
|
||||
function unavailablePreview() {
|
||||
const node = el('div', PREVIEW_UNAVAILABLE, 'tag-image-preview tag-preview-empty');
|
||||
node.setAttribute('role', 'img');
|
||||
node.setAttribute('aria-label', PREVIEW_UNAVAILABLE);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
|
||||
@@ -207,7 +217,8 @@ function previewOf(image, state) {
|
||||
* Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop.
|
||||
*/
|
||||
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null,
|
||||
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) {
|
||||
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null, onSourceChanged = null,
|
||||
onRefresh = null}) {
|
||||
const target = imageTarget(image);
|
||||
if (!target) throw new TypeError('The image has no tagging target');
|
||||
const review = mode === 'review';
|
||||
@@ -216,7 +227,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
// Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop.
|
||||
let advancing = false;
|
||||
const saved = ({filename: name, tags, toast}) => {
|
||||
const event = {target, filename: name, tags, source};
|
||||
// A retargeted dialog (#430) reports the file under its current identity.
|
||||
const event = {target: {...controller.target}, filename: name, tags, source};
|
||||
announceSaved(event);
|
||||
if (toast) showWorkspaceToast(toast);
|
||||
onSaved?.(event);
|
||||
@@ -280,6 +292,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
: (last ? 'Save and finish' : 'Save and next'));
|
||||
if (review) actions.append(cancel, skip, primary);
|
||||
else actions.append(cancel, primary);
|
||||
// D147: the reason a state blocks Save (and Generate), on its own row above the buttons; for a changed source the
|
||||
// page is asked to find the file again (`onSourceChanged`), else Refresh is offered.
|
||||
const notice = el('div', undefined, 'tag-action-notice');
|
||||
const noticeText = el('span');
|
||||
noticeText.setAttribute('role', 'status');
|
||||
const refresh = createButton(onRefresh ? 'Refresh image collection' : 'Reload page');
|
||||
notice.append(noticeText);
|
||||
const tried = new Set();
|
||||
let finding = false, current = image;
|
||||
|
||||
const nodes = [heading];
|
||||
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
|
||||
@@ -334,8 +355,45 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
closeWorkspaceDialog();
|
||||
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
|
||||
}
|
||||
/** #430: ask the page once per target where a renumbered source's file is now; a found file is retargeted. */
|
||||
function findSource() {
|
||||
const {root_index: index, root_scope: held} = controller.target;
|
||||
const attempt = `${index}:${held}`;
|
||||
if (!onSourceChanged || tried.has(attempt)) return;
|
||||
tried.add(attempt);
|
||||
finding = true;
|
||||
Promise.resolve().then(() => onSourceChanged({...current, ...controller.target})).catch(() => null).then(fresh => {
|
||||
finding = false;
|
||||
if (!open || completed) return;
|
||||
const next = imageTarget(fresh);
|
||||
if (next && controller.state.phase === 'source-changed') {
|
||||
current = fresh;
|
||||
controller.retarget(next);
|
||||
} else render(controller.state);
|
||||
});
|
||||
}
|
||||
function renderNotice(state) {
|
||||
const changed = state.phase === 'source-changed';
|
||||
const text = !state.blocked ? '' : changed && finding ? FINDING_SOURCE : state.statusText;
|
||||
setText(noticeText, text);
|
||||
const offer = changed && !finding;
|
||||
if (offer !== refresh.isConnected) {
|
||||
if (offer) notice.append(refresh);
|
||||
else refresh.remove();
|
||||
}
|
||||
// Its own first row; Retry still leads the buttons after it (D116).
|
||||
if (text && actions.firstElementChild !== notice) actions.prepend(notice);
|
||||
else if (!text && notice.isConnected) notice.remove();
|
||||
// The empty preview of a refused or failed capture says why it is empty.
|
||||
if (state.blocked && !state.image && preview.tagName !== 'DIV') {
|
||||
const next = unavailablePreview();
|
||||
preview.replaceWith(next);
|
||||
preview = next;
|
||||
}
|
||||
}
|
||||
function render(state) {
|
||||
if (!open || completed) return;
|
||||
if (state.phase === 'source-changed' && !finding) findSource();
|
||||
if (state.image && state.image !== shownImage) renderImage(state.image);
|
||||
// The observation's saved tags, a kept draft, or a conflict's current set ahead of the draft (I2).
|
||||
if (state.draftRevision !== shownDraft) {
|
||||
@@ -344,7 +402,8 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
}
|
||||
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
|
||||
setText(count, state.countText);
|
||||
setText(status, state.statusText);
|
||||
// A blocking reason is shown once, beside the actions (D147).
|
||||
setText(status, state.blocked ? '' : state.statusText);
|
||||
setText(primary, review ? reviewLabel(state) : state.primaryLabel);
|
||||
setDisabled(primary, !state.canSave);
|
||||
// D90: while Approve's save is in flight the session cannot be left, so its count includes that save.
|
||||
@@ -356,10 +415,16 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
|
||||
retry.remove();
|
||||
}
|
||||
}
|
||||
renderNotice(state);
|
||||
if (state.phase === 'saved') complete(state);
|
||||
}
|
||||
|
||||
cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); });
|
||||
refresh.addEventListener('click', () => {
|
||||
closeWorkspaceDialog();
|
||||
if (onRefresh) onRefresh();
|
||||
else window.location.reload();
|
||||
});
|
||||
skip.addEventListener('click', () => {
|
||||
if (!open || advancing || disabled(skip)) return;
|
||||
advancing = true;
|
||||
|
||||
@@ -4,7 +4,8 @@ import { countOf } from './plural.mjs';
|
||||
* The Images selection bar's "Set category" (#332, D140; the concept's bulk-category → simpleInput). Apply sets each
|
||||
* selected image's own category (`PUT /prompt_manager/images/category`); "Use prompt category" removes it after a
|
||||
* confirmation (`DELETE`), so the image shows its prompt's category again. It never writes a prompt: prompt
|
||||
* categories are set in the Prompt library editor and on the legacy admin page's "Set Category".
|
||||
* categories are set in the Prompt library editor, the Prompt library's selection bar (#437, library-selection.mjs)
|
||||
* and on the legacy admin page's "Set Category".
|
||||
*/
|
||||
export const CATEGORY_URL = '/prompt_manager/images/category';
|
||||
export const IMAGE_CATEGORIES_URL = '/prompt_manager/images/categories';
|
||||
@@ -20,11 +21,14 @@ export const CATEGORY_MAX_LENGTH = 100;
|
||||
// One request names at most 500 images (DC2), as "Add tags" does.
|
||||
export const MAX_CATEGORY_IMAGES = 500;
|
||||
|
||||
/** The success toast of a set: unchanged files count as set; files no source lists are named after. */
|
||||
export function setOutcome(result, category) {
|
||||
/**
|
||||
* The success toast of a set: unchanged items count as set; items that could not be found are named after. `noun` is
|
||||
* what was set (the Prompt library's selection bar passes "prompt", #437).
|
||||
*/
|
||||
export function setOutcome(result, category, noun = 'image') {
|
||||
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
|
||||
const missing = Number(result?.not_found) || 0;
|
||||
const done = `Category “${category}” set on ${countOf(updated + unchanged, 'image')}.`;
|
||||
const done = `Category “${category}” set on ${countOf(updated + unchanged, noun)}.`;
|
||||
return missing ? `${done} ${missing.toLocaleString()} could not be found.` : done;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Finding a file again after its source changed (#430).
|
||||
*
|
||||
* A listed file is addressed as `{root_index, root_scope, path}`. When the sources are renumbered every per-file
|
||||
* action on it answers 409 `source-changed`. `rescopeImage` asks the server (POST /prompt_manager/images/rescope)
|
||||
* for the file's current listing descriptor: the server maps the target only when its scope was issued for one of
|
||||
* the current roots, so a removed or replaced source (or a changed key) answers null and the page offers Refresh.
|
||||
*
|
||||
* `createSourceRecovery` is the page's single flight: one rescope per held `(key, scope)` pair, its result shared
|
||||
* by every caller (the inspector, the viewer and the Tag image dialog may all ask for the same file), and a pair
|
||||
* already tried answers its first result, so a refusal can never loop. `apply(image, fresh, started)` is the page's
|
||||
* part (refresh the collection, move the selection); `started` is the page generation when the rescope began, so a
|
||||
* page whose filters changed meanwhile can leave itself alone. `reset()` (a manual Refresh) forgets every pair.
|
||||
*/
|
||||
import { imageKey } from './images-state.mjs';
|
||||
|
||||
export const RESCOPE_URL = '/prompt_manager/images/rescope';
|
||||
// A page holds a handful of stale files at once; older pairs are forgotten first.
|
||||
const MEMO_LIMIT = 64;
|
||||
const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
||||
|
||||
function validTarget(value) {
|
||||
return value !== null && typeof value === 'object' && Number.isSafeInteger(value.root_index) && value.root_index >= 0 &&
|
||||
hex64(value.root_scope) && typeof value.path === 'string' && value.path.length > 0;
|
||||
}
|
||||
|
||||
/** The fresh descriptor of `image` (same path, current root and scope), else null. */
|
||||
export async function rescopeImage(api, image, {signal} = {}) {
|
||||
if (!validTarget(image)) return null;
|
||||
let value;
|
||||
try {
|
||||
value = await api.post(RESCOPE_URL, {target: {root_index: image.root_index, root_scope: image.root_scope, path: image.path}},
|
||||
{signal});
|
||||
} catch (error) {
|
||||
// 409 (the source is gone) or 404 (the file is gone): final, the caller offers Refresh. Anything else (no
|
||||
// answer, a 5xx) is rethrown so the pair may be asked again.
|
||||
if (error?.status === 404 || error?.status === 409) return null;
|
||||
throw error;
|
||||
}
|
||||
const fresh = value?.success === true ? value.image : null;
|
||||
if (!validTarget(fresh) || fresh.path !== image.path) return null;
|
||||
return Object.freeze({...fresh});
|
||||
}
|
||||
|
||||
export function createSourceRecovery({api, signal, apply, generation = () => 0}) {
|
||||
// An embedded gallery's own page adapter may have no `post`: its rescope then simply finds nothing.
|
||||
if (typeof apply !== 'function') throw new TypeError('createSourceRecovery needs apply');
|
||||
const memo = new Map();
|
||||
function recover(image) {
|
||||
if (!validTarget(image)) return Promise.resolve(null);
|
||||
const pair = `${imageKey(image)}|${image.root_scope}`;
|
||||
if (memo.has(pair)) return memo.get(pair);
|
||||
const started = generation();
|
||||
const pending = rescopeImage(api, image, {signal})
|
||||
.then(fresh => (fresh && !signal?.aborted ? apply(image, fresh, started) : null))
|
||||
.then(result => result ?? null, () => {
|
||||
if (memo.get(pair) === pending) memo.delete(pair);
|
||||
return null;
|
||||
});
|
||||
memo.set(pair, pending);
|
||||
if (memo.size > MEMO_LIMIT) memo.delete(memo.keys().next().value);
|
||||
return pending;
|
||||
}
|
||||
return {recover, reset: () => memo.clear()};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Images page's `apply` (#430). Keys are `root_index:path`, so after a renumbering nothing from the old list is
|
||||
* kept by key (a checked or selected key could name a same-named file in another source): the viewer closes, the
|
||||
* checks clear, a Source filter on the old index follows the file, and only the file itself stays selected while the
|
||||
* collection lists again. An unchanged numbering re-reads the loaded range in place. `page` holds the page's hooks.
|
||||
*/
|
||||
export function recoveredSource(page) {
|
||||
return async (image, fresh, started) => {
|
||||
// The held scope still names its root: nothing is stale (a media error had another cause).
|
||||
if (fresh.root_index === image.root_index && fresh.root_scope === image.root_scope) return null;
|
||||
if (!page.current(started)) return fresh;
|
||||
const selected = page.selected(image);
|
||||
if (fresh.root_index === image.root_index) {
|
||||
if (selected) page.inspect(fresh);
|
||||
await page.stream.refresh().catch(() => {});
|
||||
return fresh;
|
||||
}
|
||||
page.closeViewer();
|
||||
page.clearChecks();
|
||||
const {filters} = page;
|
||||
if (filters.root === String(image.root_index)) filters.root = String(fresh.root_index);
|
||||
else if (filters.root !== 'all') { filters.root = 'all'; filters.subfolder = ''; }
|
||||
if (selected) page.inspect(fresh);
|
||||
else page.dropSelection();
|
||||
page.updateScope();
|
||||
page.stream.reset(filters, {load:page.listsNow()});
|
||||
page.rediscover();
|
||||
return fresh;
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,31 @@
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
/** Identity is the source and relative path; content hashes may be shared by files. */
|
||||
/** The layout and size a saved view, an embedded gallery and a page without readable preferences use. */
|
||||
const FIXED_VIEW = Object.freeze({ layout: 'grid', density: 'comfortable' });
|
||||
|
||||
/**
|
||||
* The layout and thumbnail size an Images visit starts with (#249 System S3). The last visit (the current view) keeps
|
||||
* its own; a saved view starts as before, at Grid and Comfortable; any other new visit starts from System ›
|
||||
* Appearance's defaults (`preferences`, preferences.mjs values, or null when unavailable).
|
||||
* The defaults a new Images visit starts from: System › Appearance's (`preferences`, preferences.mjs values; Masonry
|
||||
* when the layout is missing, #451), or the fixed Grid/Comfortable when they are unavailable (null).
|
||||
*/
|
||||
export function visitDefaults(preferences) {
|
||||
if (!preferences) return { ...FIXED_VIEW };
|
||||
return { layout: preferences.workspaceLayout || 'masonry', density: preferences.workspaceDensity || 'comfortable' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout and thumbnail size an Images visit starts with (#249 System S3). A saved view starts as before, at Grid
|
||||
* and Comfortable; any other new visit starts from `visitDefaults(preferences)`. The last visit (`prior`, the current
|
||||
* view) keeps a value its toolbar changed; a value it kept from the defaults it started from (`prior.defaults`) follows
|
||||
* defaults saved since, in this tab or another (#451). A prior without `defaults` keeps its own values.
|
||||
*/
|
||||
export function initialImagesView({ prior = null, saved = null, preferences = null } = {}) {
|
||||
if (prior) return { layout: prior.layout || 'grid', density: prior.density || 'comfortable' };
|
||||
if (saved || !preferences) return { layout: 'grid', density: 'comfortable' };
|
||||
return { layout: preferences.workspaceLayout || 'grid', density: preferences.workspaceDensity || 'comfortable' };
|
||||
if (!prior) return saved ? { ...FIXED_VIEW } : visitDefaults(preferences);
|
||||
const own = { layout: prior.layout || FIXED_VIEW.layout, density: prior.density || FIXED_VIEW.density };
|
||||
if (!prior.defaults || !preferences) return own;
|
||||
const current = visitDefaults(preferences);
|
||||
const follow = key => own[key] === prior.defaults[key] ? current[key] : own[key];
|
||||
return { layout: follow('layout'), density: follow('density') };
|
||||
}
|
||||
|
||||
export function imageKey(image) {
|
||||
@@ -53,6 +69,14 @@ function queryFor(filters, batchSize) {
|
||||
const favorites = filters.favorites ?? false;
|
||||
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
|
||||
if (favorites) query.set('favorites', '1');
|
||||
// A collection page (#331 D137): the listing keeps only that collection's members; never together with favorites.
|
||||
const collection = filters.collection ?? '';
|
||||
if (collection !== '') {
|
||||
if (favorites || !/^[1-9]\d{0,8}$/.test(typeof collection === 'number' ? String(collection) : collection)) {
|
||||
throw new TypeError('Invalid collection filter');
|
||||
}
|
||||
query.set('collection', String(collection));
|
||||
}
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ export const REFRESH_FAILED = 'Images could not be refreshed after the tag save.
|
||||
|
||||
/** Open Tag image for `image` when `action` is its card's Tag button (without selecting the card, as the concept's
|
||||
* `image-tag` does); false for any other control. */
|
||||
export function openCardTagging({api, scope, image, action, embedded}) {
|
||||
export function openCardTagging({api, scope, image, action, embedded, onSourceChanged = null, onRefresh = null}) {
|
||||
if (!isTagButton(action)) return false;
|
||||
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
|
||||
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action, onSourceChanged, onRefresh});
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user