Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ae6b60ac5 | ||
|
|
505c16a724 | ||
|
|
66e567cc95 | ||
|
|
42bc73433c | ||
|
|
28e2bc0941 | ||
|
|
7714c42558 | ||
|
|
dc1258d0bd | ||
|
|
dff08bc161 | ||
|
|
aad3a37bcf | ||
|
|
d03d0ed1c5 | ||
|
|
02190d03a8 | ||
|
|
40dc0e5417 | ||
|
|
ac378f07d3 | ||
|
|
379f0ad1ac | ||
|
|
432dd219ab | ||
|
|
f318ff2f9b | ||
|
|
264d9c3791 | ||
|
|
dc0df5760f | ||
|
|
982464b7f2 | ||
|
|
cf371094b7 | ||
|
|
02ce5e79bc | ||
|
|
81e1c685f0 | ||
|
|
6d99447d76 | ||
|
|
d1cb0ea437 | ||
|
|
be49944a63 | ||
|
|
0d0ffc4ad8 | ||
|
|
3dfb828ae5 | ||
|
|
b1383adcc3 | ||
|
|
6536cee5cd | ||
|
|
45b9d36aa2 | ||
|
|
982c1efff6 | ||
|
|
b6d614f1ca | ||
|
|
57a7f13b4d | ||
|
|
b107de70f8 | ||
|
|
b313224d46 | ||
|
|
a6d634509b | ||
|
|
e137270f45 | ||
|
|
6a494c7c1f | ||
|
|
4b4fa7f8ff | ||
|
|
4a90a5dd83 | ||
|
|
e3191853c8 | ||
|
|
9bf8362d22 | ||
|
|
a90d251f39 | ||
|
|
a8e56f1812 | ||
|
|
3bba78d0eb | ||
|
|
2d7e138624 | ||
|
|
e34c1e2351 | ||
|
|
68443551b2 |
+331
-30
@@ -731,8 +731,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
|
||||
only the concept content: cover, title, filename, the evidence badge with a category
|
||||
chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image
|
||||
tags first, D88, then the linked tags; image tags and the linked positive's own tags
|
||||
chip naming its origin (D146), the + and − prompt blocks with copy and send, the tag chips in labelled
|
||||
sections after the negative prompt (D154: "Image tags · N" for the file's own image tags, D88, then "Prompt
|
||||
tags · N" or "Recipe tags · N" for the linked tags; image tags and the linked positive's own tags
|
||||
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
|
||||
@@ -1385,8 +1386,8 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
|
||||
- **Toolbar** (`toolbar.mjs`, shared with the Library in PR E): `#search` "Search
|
||||
prompts, tags, filenames…" (debounced 250 ms; filename, linked positive text and
|
||||
Library title, and tag names), `#category-filter` "All categories" plus the
|
||||
Library's categories, `#sort` (Recently added, Name A–Z, Highest rated, then Oldest
|
||||
first and Name Z–A), and `Filters` (opens "Refine this view").
|
||||
Library's categories, `#sort` (Recently added, Name A–Z, then Oldest first and Name
|
||||
Z–A; no Highest rated, D153), and `Filters` (opens "Refine this view").
|
||||
- **Filter row**: Grid / List / Masonry (`.segmented`, group "Image layout"),
|
||||
"Thumbnail size" Comfortable / Large / Compact, the scope chip ("All output folders"
|
||||
or "<source> · <folder>"), and "Group by positive prompt →" (goes to the Library).
|
||||
@@ -1396,7 +1397,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
|
||||
@@ -1412,7 +1413,7 @@ How the earlier workspace Images filters moved (old → new). The legacy `/galle
|
||||
| Source | "Refine this view" → Source (D14); shown in the scope chip |
|
||||
| Folder | "Refine this view" → Folder (D14); shown in the scope chip |
|
||||
| Media (All media / Images / Videos) | "Refine this view" → Media (D14) |
|
||||
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A, plus Highest rated (D13, D14) |
|
||||
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A (D13, D14; no Highest rated, D153) |
|
||||
| (none) | toolbar category (the effective category, D141) and "Refine this view" → Tags, through the linked positive (D13, D14) |
|
||||
| Refresh | Import → "Scan an output folder" → Rescan (D15) |
|
||||
| Grid / List / Masonry, Comfortable / Large / Compact | the same choices in the concept filter row |
|
||||
@@ -1449,9 +1450,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
|
||||
@@ -1523,6 +1524,9 @@ Recorded deviations on this page (spec §8):
|
||||
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
|
||||
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
|
||||
and are filters too; a linked tag already shown (case-insensitive) is left out.
|
||||
Amended by D154 (#449): the image tags and the linked tags each sit in their own
|
||||
labelled section, and a linked tag stays in its section even when an image tag
|
||||
has the same name.
|
||||
Amended by D146 (#332): the category chip also names its origin in visible text
|
||||
after the category (" · this image", " · prompt", " · recipe"), and an image's own
|
||||
category shows for an unlinked or ambiguous file too.
|
||||
@@ -1682,6 +1686,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:
|
||||
@@ -1787,6 +1814,30 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
|
||||
concept image's tags saved as its image tags production still equals the concept.
|
||||
After a Tag save the selected file's inspection is re-read, so the panel redraws
|
||||
the row without reselecting and the viewer, which shares that inspection, shows it.
|
||||
Amended by D154 (#449): the one row is now two labelled sections.
|
||||
- **D154** (#449, beyond the concept): the inspector's tags no longer follow the
|
||||
negative prompt as a bare `.tags` row, where they read as part of it. Every surface of
|
||||
the shared inspector (the Images side panel, the viewer details, History's inline
|
||||
current file, the Library-embedded gallery and Activity) draws them in
|
||||
`section.tag-section` regions in the concept's prompt-section frame (the
|
||||
`.prompt-section` top rule with 15px padding and margin, a `.prompt-heading` with a
|
||||
`.role-label`, the row's own 12px top margin dropped; workspace.css). The label is
|
||||
muted (`--ca-muted`, as `.meta-grid dt`), because tags are neither prompt role. First
|
||||
`section.tag-section.image-tags` "Image tags · N" (the file's current image tags, D88),
|
||||
then `section.tag-section.linked-tags` "Prompt tags · N" for the linked positive's
|
||||
tags or "Recipe tags · N" for a recipe's own (`tags_scope`, named as D146's category
|
||||
chip names its origin). Each region is named without its count and with the prompt
|
||||
blocks' context ("Current file image tags" in History). An empty source draws no
|
||||
section. Repeats are removed within a source only, so a linked tag that is also an
|
||||
image tag shows in both sections and each count is true. D21's filter rules are
|
||||
unchanged. History's current-file read carries no `image_tags` facet (D88), so there
|
||||
only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the
|
||||
concept's row in the same sections at runtime (`withTagSections`, the D154 rule
|
||||
injected) and compares the measured sections (frame, heading, label, row); a
|
||||
mutation of the label color, the row margin or the top rule fails it. Browser tests:
|
||||
"D154: image tags sit in their own labelled section after the negative prompt, in
|
||||
the panel and the viewer" and "the inline current file labels its image tags after
|
||||
its negative prompt".
|
||||
- **D115** (beyond the plan's list): the concept always has a selected image, so
|
||||
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
|
||||
@@ -1872,6 +1923,7 @@ production-versus-concept difference of PR C and its record:
|
||||
| Suggestions count field | D91 (ruling T3-bulk) |
|
||||
| No prototype note, "Suggestions from …", no " in this tab" | D29 |
|
||||
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
|
||||
| Image tags and linked tags in labelled sections after the negative prompt | D154 (#449) |
|
||||
| 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, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
|
||||
@@ -2352,7 +2404,7 @@ The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
`article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art`
|
||||
(at most two previews), `.card-content` (`.card-title` with the title `button` and the
|
||||
category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and
|
||||
`.card-footer` (the rating and last-use line, then `.row-actions` with the positive
|
||||
`.card-footer` (the last-use line, then `.row-actions` with the positive
|
||||
`button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`).
|
||||
`tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and
|
||||
the measured row heights on Chromium and touch.
|
||||
@@ -2362,8 +2414,8 @@ Recorded deviations on this page:
|
||||
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>"
|
||||
("Used <Mon D>, <YYYY>" for a use in another calendar year, so last year's date never
|
||||
reads as this year's), counted in the viewer's local calendar days from `last_used_at` (a time ahead of the
|
||||
browser reads as today), and "Not used yet" without one. Stars are `★` × rating with
|
||||
a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words.
|
||||
browser reads as today), and "Not used yet" without one. There are no rating stars
|
||||
(D153, amending D23): every parent shows only the last-use words.
|
||||
The meta chip reads "− N negative variants" (singular for one). The title and Explore
|
||||
history are buttons that open `#/library/<id>`, as the concept's are; the trade-off
|
||||
against the old links is that they offer no open in a new tab, middle-click or URL on
|
||||
@@ -2429,6 +2481,143 @@ 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.
|
||||
|
||||
### No 1–5 prompt rating in the workspace (#447)
|
||||
|
||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||
a 1–5 rating): the workspace shows and edits no prompt rating. The concept's rating
|
||||
appears in four places, and production draws none of them:
|
||||
|
||||
- Library cards: the concept's footer reads "★★★★★ · Used today"; production reads only
|
||||
the last use (amends D23). `library-concept.spec.mjs` takes the stars and their " · "
|
||||
out of the concept page itself before measuring (`dropConceptRatings`), so the footer
|
||||
facts are compared at production's width; it first checks that every concept card had
|
||||
them.
|
||||
- Sort: the concept's "Highest rated" is not offered on Images or the Library (amends
|
||||
D14); the comparisons drop it from the concept's options. A saved view or a Library
|
||||
visit that still names `sort: "rating"` opens on Recently added; the server keeps such a
|
||||
view as it was stored.
|
||||
- Editor (New prompt, Edit recipe metadata, Create text revision): no `#recipe-rating`
|
||||
"Rating" select; `library-editor-canonical-comparison.mjs` `mapConcept` drops the
|
||||
concept's Rating field. The editor never sends `rating`. An edit sends only changed
|
||||
fields, so a stored rating stays as it is; a new prompt and a text revision create an
|
||||
unrated positive (the revised prompt keeps its own rating; the legacy admin page shows
|
||||
the revision as unrated). A reuse that leaves metadata unapplied compares title,
|
||||
category, tags and notes only. The edit token still covers `rating`, so a rating
|
||||
changed on a legacy page while the editor is open is an ordinary metadata conflict
|
||||
whose review shows no visible difference.
|
||||
- System > Statistics: no "Avg. rating" column and no "Rated images" total (amends D96);
|
||||
`system-concept.spec.mjs` maps the concept's table without that column.
|
||||
|
||||
Data and API are unchanged: the `rating` column (`CHECK` 1–5), `PUT
|
||||
/prompt_manager/prompts/{id}/rating`, `rating` in Library, Images and statistics
|
||||
answers, and the server's `rating` sorts. The legacy pages keep their ratings until the
|
||||
cutover (#334). Favorites (the image star) are unchanged.
|
||||
|
||||
Tests: units `tests/js/workspace-library-editor-state.test.js` (a prompt rated 4 edited
|
||||
and saved keeps 4, no `rating` in any body, no rating field),
|
||||
`workspace-library-state.test.js`, `workspace-images-state.test.js`,
|
||||
`workspace-saved-views.test.js`, `workspace-system-overview.test.js`,
|
||||
`workspace-library-format.test.js`; browser `workspace-library-editor.spec.mjs` (the
|
||||
fixture's positive stored with rating 4 still reads 4 after Edit, a revision, Add
|
||||
negative and a reuse), `workspace-library.spec.mjs`, `workspace-images.spec.mjs`,
|
||||
`workspace-saved-views.spec.mjs`, `workspace-system.spec.mjs` and the concept
|
||||
comparisons above.
|
||||
|
||||
### Prompt history page and recorded deviations (#249 PR F-a)
|
||||
|
||||
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
|
||||
@@ -3049,7 +3238,8 @@ until this browser dismissed the 3.2.0 update notice (`pm_update_notice_dismisse
|
||||
the legacy admin page's key); Later dismisses it without scanning, and Review scan
|
||||
dismisses it as the legacy Scan does. "+ Add source" asks validate-source, then adds
|
||||
the folder to the host draft of `gallery_root_paths`; Remove takes one out; "Watch for
|
||||
new outputs" (`monitoring_enabled`) and Worker threads (`worker_threads`) are host
|
||||
new outputs" (`monitoring_enabled`), "Save prompts from other workflows"
|
||||
(`save_foreign_runs`, D158) and Worker threads (`worker_threads`) are host
|
||||
drafts too, and Save writes them with the revision. A save the server refuses because a
|
||||
scan runs toasts "Another job is running: <title>." with a link to that job (D57) and
|
||||
keeps the drafts; a save refused for one source says the server's message as the status
|
||||
@@ -3298,7 +3488,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 assigned). D94,
|
||||
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 and D158 assigned). D94,
|
||||
the per-section hand-off panel, was retired by S12 (#329): every section is native.
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
@@ -3344,12 +3534,13 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
every check passed, some could not run, or issues need review. Close / Review missing
|
||||
links, which reviews a fresh Missing-file links plan (D100, S7); it never cleans up.
|
||||
Nothing is changed by a check.
|
||||
- **D96** (Statistics): "Usage statistics" without the sample note: the concept's
|
||||
"Runs by category" table (Category / Runs / Avg. rating) with the real runs and
|
||||
average rating of eligible parents per category ("Uncategorized", "Not rated"),
|
||||
then a second table "Library totals" (Total / Count: Prompts, Tags, Images from the
|
||||
legacy statistics, and Rated images: indexed images whose linked prompt is rated).
|
||||
The Overview row says "Runs and ratings by category, with library totals." because
|
||||
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
|
||||
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
|
||||
with the real runs of eligible parents per category ("Uncategorized"), then a second
|
||||
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
|
||||
statistics; no Rated images). The API still reports `average_rating` and
|
||||
`rated_images`; the workspace does not show them.
|
||||
The Overview row says "Runs by category, with library totals." because
|
||||
that is what the dialog shows (the concept's own dialog shows only "Runs by
|
||||
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
|
||||
table of them exists yet. A failed read toasts "Statistics could not
|
||||
@@ -3786,6 +3977,26 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
|
||||
the batch tests of workspace-system-tagging.spec; the mutation check renames each grid
|
||||
label in turn.
|
||||
- **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows
|
||||
without a PromptManager node; the maintainer chose a visible switch over a config key.
|
||||
A `.setting-row` in the concept's toggle markup follows "Watch for new outputs":
|
||||
"Save prompts from other workflows" (`#foreign-runs`, host key `save_foreign_runs`,
|
||||
config.json `gallery.monitoring.save_foreign_runs`, default on), "New outputs of
|
||||
workflows without a PromptManager node add their prompt to the library when it is
|
||||
certain.". It is saved with the revision like the other host rows and applied at once
|
||||
(the monitor reads it for every file), so a save names no restart. While "Watch for
|
||||
new outputs" is drafted off, a `p.small` under the description says "Applies while
|
||||
Watch for new outputs is on." (the switch's `aria-describedby`; the value is kept) and
|
||||
the switch is disabled, except while a source still reads "watching until restart"
|
||||
(D128): the running observer still delivers files then, so the value can be set. The
|
||||
server stores no foreign prompt while `monitoring_enabled` is saved off, whatever the
|
||||
observer does.
|
||||
Pinned by `sources matches the concept` (the mapping inserts the row after the
|
||||
monitoring row), `sources comparison detects mutations` (a missing row and a changed
|
||||
label are caught), `D158: Save prompts from other workflows saves save_foreign_runs
|
||||
live; off while monitoring is off (#247)` (keyboard on desktop, a tap on touch) and
|
||||
`D158: saved off while the monitor still watches until a restart, the foreign row can
|
||||
still be set (D128)` (workspace-system-sources.spec) and the system-sources node test.
|
||||
- **D109** (Logs, S12, #329): the concept's Logs surface on the live server. The toolbar
|
||||
keeps "Level", Refresh and the download (renamed "Download log": the active log,
|
||||
`/logs/download/prompt_manager.log`, through the api client), with all six minimum
|
||||
@@ -5153,7 +5364,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
|
||||
@@ -5171,7 +5382,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
|
||||
@@ -5712,6 +5924,70 @@ Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class),
|
||||
saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and
|
||||
`tests/test_recorded_saver_anchor.py` `TestInspectionResolution`.
|
||||
|
||||
**In the monitor.** An output whose workflow has no positive PromptManager node
|
||||
(`_find_prompt_from_metadata` says `foreign`) used to be logged and skipped. It now
|
||||
goes through the same ladder with the `MONITOR` policy (`utils/foreign_runs.py`, the
|
||||
handler's `_ingest_foreign`, `_recheck_foreign`, `_store_foreign`):
|
||||
|
||||
- **At settle.** The ladder runs without history. Certain (`pair` or `positive`): the
|
||||
prompt is stored and the file linked at once. `history` (an undecided graph inside
|
||||
ComfyUI's output folder): the file is registered in its own waiting set, then the
|
||||
ladder runs again at once with the file's recorded output, because the run may have
|
||||
been sealed before the file settled. When that lookup finds a record, its answer is
|
||||
final: certain is stored, anything else is logged "No certain prompt for <file>;
|
||||
left unlinked" and the file stops waiting (no seal is coming). When it finds none,
|
||||
only the inspector's or the recorded saver's answer is stored; anything else (the
|
||||
legacy floor included) keeps waiting for the re-check. Anything else at the first
|
||||
pass is logged "No certain prompt for <file>; left unlinked".
|
||||
- **After the run ends.** When the run's outputs are sealed, each waiting foreign file
|
||||
gets one re-check (`ForeignRecheck(reference)`) with the sealed output reference,
|
||||
so history is asked with the spelling it recorded (a Windows folder whose case
|
||||
differs from the disk still links). Not certain: "No certain prompt for <file>
|
||||
after its run ended; left unlinked". An ambiguous inspector answer is never given
|
||||
the legacy floor in the monitor (D-Q3); an untraceable one still is.
|
||||
- **Waiting sets.** `_awaiting_foreign` and `_foreign_due` sit beside the keyless sets
|
||||
with the same bounds (1000 files, one hour) but are separate, so a large foreign run
|
||||
never evicts a waiting keyless file. An expired entry is logged "No recorded run
|
||||
for <file>; left unlinked", an evicted one (waiting or due) "Too many files wait for
|
||||
their run; <file> left unlinked", both after the lock is released. A due re-check
|
||||
is dropped only when it was scheduled, never a newer reference for the same file. `awaiting_foreign()` lists them;
|
||||
`shutdown()` and `close()` clear them, and a closed handler re-checks nothing.
|
||||
- **Store.** One write through `survive_switch` (rerun after a Move library): a pair
|
||||
through `ingest_resolved_pair(..., marks=RUN_MARKS)`, a positive through
|
||||
`store_scanned_prompt_or_raise(..., RUN_MARKS)`. A created prompt carries category
|
||||
`scanned`, tag `auto-scanned` and the note `Saved from a workflow run: <file>`; a
|
||||
file linked to another prompt is `conflict` and keeps its link. Logged at info
|
||||
"Foreign run: <status> prompt <id> for <file> (<source>)"; a failure is logged and
|
||||
the file stays unlinked.
|
||||
- **Never.** No tracker FIFO entry is read or consumed, nothing is counted (run count
|
||||
stays 0, no `last_used_at`), and nothing is queued for Needs review.
|
||||
- **The setting.** `GalleryConfig.SAVE_FOREIGN_RUNS` (`gallery.monitoring.
|
||||
save_foreign_runs`, default true, only a bool applies) is read for every file, at
|
||||
settle, at the re-check and again just before the store, together with
|
||||
`MONITORING_ENABLED`: either saved off stops foreign writes at once (the running
|
||||
observer watches until a restart, D128); off logs "No PromptManager prompt in
|
||||
workflow, skipping image" as before. System › Sources & scans "Save prompts from other workflows" (D158)
|
||||
saves it through `POST /prompt_manager/settings` with no restart field; the
|
||||
diagnostics report lists it.
|
||||
|
||||
Tests: `tests/test_run_attribution.py` `TestMonitorForeignRuns` (dataset branches
|
||||
linked after the seal, a seal before settle, a name two runs reported, the setting
|
||||
off at settle and at the re-check, a plain workflow linked at settle with no run
|
||||
count and the tracker queue untouched, the sealed reference's spelling, the separate
|
||||
bound, expiry, a seal while the pending set is full, shutdown, a failed store, no
|
||||
record at settle keeps waiting, a record without an answer ends the wait, monitoring
|
||||
saved off, the setting read before the store, a seal between registering and the
|
||||
settle lookup, a newer due reference kept, evictions logged, one link per file),
|
||||
`tests/test_image_linking.py` `TestForeignWorkflows`, `tests/test_image_monitor_singleton.py`
|
||||
(close drops a foreign re-check), `tests/test_config.py`,
|
||||
`tests/test_settings_sources.py` `TestSaveForeignRunsSetting`, and the real-host
|
||||
`tests/comfyui/image-attribution.spec.mjs` case "an output of a workflow without a
|
||||
PromptManager node creates and links its prompt; a second run reuses it" (ComfyUI's
|
||||
own `CLIPTextEncode`, `KSampler` and `SaveImage`), and "each branch of a
|
||||
shared-prefix workflow without a PromptManager node links to its own prompt after the
|
||||
run" (two branches, one save prefix: the recorded saver names each file's branch).
|
||||
The fixture route's GET also lists `foreign`, the waiting foreign files.
|
||||
|
||||
## Linked prompt text (#343)
|
||||
|
||||
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
|
||||
@@ -5809,8 +6085,26 @@ decide through the same `own_counts`.
|
||||
- A cached node counts at job start like the hook: a job failing later still counts it.
|
||||
A node that executes after its replay in the same job (RAM-pressure freed the entry)
|
||||
skips its own count but still consumes the pending first-use catch-up.
|
||||
- No backfill: earlier cached jobs keep their counts. The history page's "tracked runs"
|
||||
can stay below the Library count for these prompts (#403).
|
||||
- No backfill: earlier cached jobs keep their counts.
|
||||
- History (#403): the receipt also keeps the raw, prepend and append text the node gave
|
||||
the history capture. For a cached node whose graph inference leaves a field unknown,
|
||||
the capture takes it from the receipt matching the cache entry
|
||||
(`CachedRuns.recorded_fields`), as `inferred` / `graph_semantics`: the node did not run
|
||||
in this job, the value follows from the run key (the whole upstream closure) plus the
|
||||
cache proof. The job then joins the prompt's history scope, gets the
|
||||
`positive_prompt_id` (and Pair) hint, and an image of it can be attributed through its
|
||||
run, so the history page's "tracked runs" equals the Library count from the restart on.
|
||||
Fail closed (fields stay unknown, as before): no matching receipt, a receipt that
|
||||
disagrees with a field the graph knows, executions with equal outputs that recorded
|
||||
different fields (a conflict that stays for that run key), a field over 64 KiB or
|
||||
invalid Unicode, more than one run's history (1 MiB) in one receipt, or a full 8 MiB
|
||||
field budget (least recently used receipts give up their fields first; their counting
|
||||
stays). No new evidence vocabulary, so older builds still open the database.
|
||||
- Still different: jobs before the restart (no backfill); list batches (per item in the
|
||||
Library, once per job in history); text with leading or trailing whitespace (the
|
||||
Library stores it stripped, history matches the exact raw text, #438); a job in which the
|
||||
node executes after its replay (RAM-pressure freed the entry: two samples, an
|
||||
ambiguous capture); jobs while the history writer is fenced (restore, library move).
|
||||
- Tests: `tests/test_cached_runs.py` (whole jobs with the real nodes, a real database,
|
||||
fake host caches); `tests/comfyui/execution-text-link.spec.mjs` "#347" cases on the
|
||||
managed host: PreviewAny into each node kind, 3 jobs changing only the sampler seed,
|
||||
@@ -5818,7 +6112,10 @@ decide through the same `own_counts`.
|
||||
modes, A, B, A from the owned `PMCycledText` under LRU, typed text, a downstream
|
||||
failure, a "run selected output" job that does not need the node, and a rewire to the
|
||||
negative input. The RAM-pressure eviction path cannot be forced on a real host and is
|
||||
covered by the Python tests.
|
||||
covered by the Python tests. Since #403 these cases also check the history page's
|
||||
tracked runs equal the Library count, and `tests/test_execution_history_hooks.py`
|
||||
(`CachedRuntimeHistoryTests`) runs the real history hook and writer with a real
|
||||
`CachedRuns`.
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p "test_cached_runs.py"
|
||||
@@ -5917,10 +6214,12 @@ Context cases verify A,A,B as three jobs/two combinations/one positive snapshot
|
||||
when every positive field matches; all five registered PM variants; exact raw
|
||||
whitespace and Unicode; known empty negatives; Text negative roles and crossed
|
||||
pairs; fully cached repeats after trigger configuration changes; successful source
|
||||
text before downstream failure/interruption; unknown cached dynamic raw; unsupported
|
||||
text before downstream failure/interruption; cached dynamic raw from the reused execution (#403); unsupported
|
||||
transforms and duplicate list invocation ambiguity. Fresh values use
|
||||
`runtime_return`, cached effective STRING outputs use `host_cache_output`, and
|
||||
submitted literals use graph inference. SQL NULL means unknown; empty text is
|
||||
submitted literals use graph inference. A cached dynamic raw is unknown unless the
|
||||
#347 receipt of the execution the cache entry comes from proves it; it is then
|
||||
`inferred` / `graph_semantics` (graph identity plus a same-process cache receipt, #403). SQL NULL means unknown; empty text is
|
||||
known. Text is never trimmed or normalized by capture. Cache availability and
|
||||
inferred sampler roles prove neither sampler execution nor consumption, and list
|
||||
indices do not identify generated images. External trigger configuration changes
|
||||
@@ -6274,9 +6573,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
|
||||
|
||||
+6
-1
@@ -275,7 +275,12 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, encoding_text
|
||||
)
|
||||
self._record_cached_item(unique_id, (encoding_text,), storage_text)
|
||||
self._record_cached_item(
|
||||
unique_id,
|
||||
(encoding_text,),
|
||||
storage_text,
|
||||
fields=((text, prepend_text, append_text),),
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
|
||||
+13
-3
@@ -243,14 +243,24 @@ class PromptManagerBase:
|
||||
return False
|
||||
|
||||
def _record_cached_item(
|
||||
self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None
|
||||
self,
|
||||
unique_id: Any,
|
||||
outputs: tuple,
|
||||
text: Any,
|
||||
negative_text: Any = None,
|
||||
fields: Optional[tuple] = None,
|
||||
) -> None:
|
||||
"""Remember this execution's outputs and stored text for cached jobs (#347)."""
|
||||
"""Remember this execution's outputs and stored text for cached jobs (#347).
|
||||
|
||||
``fields`` are the ``(raw, prepend, append)`` texts this execution gave
|
||||
``record_runtime_context`` for each output, so a cached job's history
|
||||
can record them too (#403).
|
||||
"""
|
||||
if unique_id is None:
|
||||
return
|
||||
try:
|
||||
self._cached_runs().record_item(
|
||||
unique_id, outputs, stored_facts(text, negative_text)
|
||||
unique_id, outputs, stored_facts(text, negative_text), fields
|
||||
)
|
||||
except Exception as error:
|
||||
self.logger.debug(f"Could not record the cached-run receipt: {error}")
|
||||
|
||||
@@ -198,7 +198,11 @@ class PromptManagerPair(PromptManagerFocused):
|
||||
type(self).__name__, negative_text, "", "", negative_text, role="negative"
|
||||
)
|
||||
self._record_cached_item(
|
||||
unique_id, (positive, negative_text), text, negative_text
|
||||
unique_id,
|
||||
(positive, negative_text),
|
||||
text,
|
||||
negative_text,
|
||||
fields=((text, prepend_text, append_text), (negative_text, "", "")),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -246,7 +246,12 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
|
||||
record_runtime_context(
|
||||
type(self).__name__, text, prepend_text, append_text, final_text
|
||||
)
|
||||
self._record_cached_item(unique_id, (final_text,), storage_text)
|
||||
self._record_cached_item(
|
||||
unique_id,
|
||||
(final_text,),
|
||||
storage_text,
|
||||
fields=((text, prepend_text, append_text),),
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -213,6 +213,8 @@ def public_config():
|
||||
"max_image_age_days": GalleryConfig.MAX_IMAGE_AGE_DAYS,
|
||||
"max_concurrent_processing": GalleryConfig.MAX_CONCURRENT_PROCESSING,
|
||||
"metadata_extraction_timeout": GalleryConfig.METADATA_EXTRACTION_TIMEOUT,
|
||||
# config.json gallery.monitoring.save_foreign_runs (#247)
|
||||
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
|
||||
},
|
||||
"integrations": {
|
||||
"lora_manager_enabled": bool(IntegrationConfig.LORA_MANAGER_ENABLED),
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ is shared with the LoRA integration's save.
|
||||
GET also reports the image sources and the monitor (``sources.py``). A save
|
||||
that changes the sources runs as a ``source_change`` registry hold, so it is
|
||||
refused while a record-changing job (a scan) runs. ``monitoring_enabled``
|
||||
takes effect at the next restart. ``POST /prompt_manager/settings/validate-source``
|
||||
takes effect at the next restart; ``save_foreign_runs`` (#247) at once, since
|
||||
the monitor reads it for every file. ``POST /prompt_manager/settings/validate-source``
|
||||
checks one path and writes nothing.
|
||||
"""
|
||||
|
||||
@@ -43,6 +44,7 @@ _SETTINGS_FILE_KEYS = {
|
||||
"INFINITE_SCROLL": ("web_ui", "infinite_scroll"),
|
||||
"WORKER_THREADS": ("performance", "worker_threads"),
|
||||
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
|
||||
"SAVE_FOREIGN_RUNS": ("gallery", "monitoring", "save_foreign_runs"),
|
||||
"SAFETY_BACKUP": ("maintenance", "safety_backup"),
|
||||
"THUMBNAIL_FORMAT": ("thumbnails", "format"),
|
||||
"THUMBNAIL_QUALITY": ("thumbnails", "quality"),
|
||||
@@ -58,7 +60,7 @@ _SETTINGS_FILE_KEYS = {
|
||||
# The thumbnail settings a running Convert outdated job fixes (#317).
|
||||
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
|
||||
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
|
||||
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
|
||||
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED", "SAVE_FOREIGN_RUNS"})
|
||||
# Attributes that live on AutotagConfig, named without the AUTOTAG_ prefix.
|
||||
_AUTOTAG_SETTINGS = frozenset(
|
||||
name for name in _SETTINGS_FILE_KEYS if name.startswith("AUTOTAG_")
|
||||
@@ -264,6 +266,7 @@ class SettingsRoutesMixin:
|
||||
"thumbnail_encoders": encoders,
|
||||
"settings": {
|
||||
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
|
||||
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
|
||||
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
|
||||
"webui_display_mode": PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
|
||||
@@ -626,6 +629,10 @@ class SettingsRoutesMixin:
|
||||
if not isinstance(data["monitoring_enabled"], bool):
|
||||
raise ValueError("monitoring_enabled must be true or false")
|
||||
updates["MONITORING_ENABLED"] = data["monitoring_enabled"]
|
||||
if "save_foreign_runs" in data:
|
||||
if not isinstance(data["save_foreign_runs"], bool):
|
||||
raise ValueError("save_foreign_runs must be true or false")
|
||||
updates["SAVE_FOREIGN_RUNS"] = data["save_foreign_runs"]
|
||||
if "safety_backup" in data:
|
||||
if not isinstance(data["safety_backup"], bool):
|
||||
raise ValueError("safety_backup must be true or false")
|
||||
|
||||
@@ -194,6 +194,8 @@ class GalleryConfig:
|
||||
|
||||
Attributes:
|
||||
MONITORING_ENABLED (bool): Enable/disable automatic image monitoring
|
||||
SAVE_FOREIGN_RUNS (bool): The monitor stores the certain prompt of an
|
||||
output whose workflow has no PromptManager node (#247)
|
||||
MONITORING_DIRECTORIES (List[str]): Directories to monitor for new images
|
||||
SUPPORTED_EXTENSIONS (List[str]): Image file extensions to process
|
||||
PROCESSING_DELAY (float): Delay in seconds before processing new files
|
||||
@@ -210,6 +212,8 @@ class GalleryConfig:
|
||||
|
||||
# Image monitoring settings
|
||||
MONITORING_ENABLED = True
|
||||
# Read by the monitor for every file, so a save applies without a restart
|
||||
SAVE_FOREIGN_RUNS = True
|
||||
MONITORING_DIRECTORIES = [] # Auto-detect if empty
|
||||
SUPPORTED_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif"]
|
||||
PROCESSING_DELAY = 2.0 # Seconds to wait before processing new files
|
||||
@@ -251,6 +255,7 @@ class GalleryConfig:
|
||||
return {
|
||||
"monitoring": {
|
||||
"enabled": cls.MONITORING_ENABLED,
|
||||
"save_foreign_runs": cls.SAVE_FOREIGN_RUNS,
|
||||
"directories": cls.MONITORING_DIRECTORIES,
|
||||
"extensions": cls.SUPPORTED_EXTENSIONS,
|
||||
"processing_delay": cls.PROCESSING_DELAY,
|
||||
@@ -303,6 +308,14 @@ class GalleryConfig:
|
||||
config_logger.warning(
|
||||
"Ignoring gallery monitoring enabled: expected true or false"
|
||||
)
|
||||
if "save_foreign_runs" in monitoring:
|
||||
if isinstance(monitoring["save_foreign_runs"], bool):
|
||||
cls.SAVE_FOREIGN_RUNS = monitoring["save_foreign_runs"]
|
||||
else:
|
||||
config_logger.warning(
|
||||
"Ignoring gallery monitoring save_foreign_runs: expected true "
|
||||
"or false"
|
||||
)
|
||||
if "directories" in monitoring:
|
||||
directories = cls._validated_directories(monitoring["directories"])
|
||||
if directories is not None:
|
||||
|
||||
@@ -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();
|
||||
@@ -214,6 +232,41 @@ export async function withChipSource(page,root,origin,category=null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
|
||||
* has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
|
||||
* runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
|
||||
* holding the concept's row and each further one a copy of it. Returns the undo.
|
||||
*/
|
||||
export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
|
||||
.tag-section .role-label { color: var(--ca-muted); }
|
||||
.tag-section > .tags { margin-top: 0; }`;
|
||||
export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
|
||||
export async function withTagSections(page,root,sections=PROMPT_TAGS) {
|
||||
const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
|
||||
const token=await page.evaluate(({root,sections})=>{
|
||||
const container=document.querySelector(root);
|
||||
const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
|
||||
const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
|
||||
if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
|
||||
const anchor=row.nextSibling,parent=row.parentNode,added=[];
|
||||
sections.forEach(([className,label,name],index)=>{
|
||||
const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
|
||||
const heading=document.createElement('div');heading.className='prompt-heading';
|
||||
const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
|
||||
heading.append(text);section.append(heading,index?row.cloneNode(true):row);
|
||||
parent.insertBefore(section,anchor);added.push(section);
|
||||
});
|
||||
const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
|
||||
(window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
|
||||
return token;
|
||||
},{root,sections});
|
||||
return async()=>{
|
||||
await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
|
||||
await restoreRule();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
|
||||
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
|
||||
@@ -662,6 +715,13 @@ export function measureInspector(selector) {
|
||||
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
|
||||
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
|
||||
}
|
||||
function tagSectionOf(section) {
|
||||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
|
||||
return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section),
|
||||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
|
||||
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
|
||||
row:when(row,rowOf)};
|
||||
}
|
||||
function factsOf(list) {
|
||||
const style=getComputedStyle(list);
|
||||
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
|
||||
@@ -695,6 +755,8 @@ export function measureInspector(selector) {
|
||||
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
|
||||
sections:sections.map(sectionOf),
|
||||
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
|
||||
// #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
|
||||
tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
|
||||
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
|
||||
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
|
||||
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
|
||||
|
||||
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
// Linked, workflow inferred: every concept element.
|
||||
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']});
|
||||
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
@@ -139,9 +139,13 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
// #332 MC8: the chip names where its value came from, in visible text after the category.
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
|
||||
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
|
||||
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
|
||||
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
|
||||
// (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
|
||||
const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
|
||||
await expect(recipeTags).toHaveClass('tag-section linked-tags');
|
||||
await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
|
||||
await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(panel.locator('.tag-section button')).toHaveCount(0);
|
||||
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');
|
||||
@@ -149,7 +153,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
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(2);
|
||||
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
|
||||
await expect(panel).not.toContainText(/prompt evidence|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';};
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
|
||||
@@ -168,18 +172,20 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
// Ambiguous saved association: the same honest absence.
|
||||
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
|
||||
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
|
||||
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
|
||||
const tagRow=panel.locator('.detail-inner > .tags').last();
|
||||
const tagRow=panel.locator('.tag-section.linked-tags > .tags');
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
|
||||
await selectFile(page,'complete.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
|
||||
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
|
||||
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
|
||||
await selectFile(page,'workflow-only.png');
|
||||
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
|
||||
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
|
||||
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
|
||||
reads.patch=null;await page.unroute('**/images/inspect/**');
|
||||
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
|
||||
@@ -243,7 +249,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
|
||||
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
|
||||
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
|
||||
await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click();
|
||||
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
|
||||
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
|
||||
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
|
||||
@@ -290,14 +296,14 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
|
||||
// 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) 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']});
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-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');
|
||||
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
|
||||
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
|
||||
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > 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','Collections…']);
|
||||
@@ -311,7 +317,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
|
||||
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
|
||||
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
|
||||
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
|
||||
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
|
||||
// D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
|
||||
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
|
||||
await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
|
||||
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
|
||||
const raw=details.locator('details').last();await raw.locator('summary').click();
|
||||
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
|
||||
@@ -467,7 +475,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
|
||||
|
||||
test('embedded gallery uses the same full inspector',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
|
||||
|
||||
@@ -92,7 +92,8 @@ export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...reference.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
|
||||
// D153 (#447): production offers no "Highest rated".
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Name Z–A']}:item);
|
||||
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
|
||||
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
|
||||
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs';
|
||||
|
||||
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
|
||||
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
|
||||
@@ -51,7 +51,8 @@ function withCategoryScope(inventory) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
|
||||
* tag sections the concept page gets at runtime from `withTagSections`); 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. D139's
|
||||
@@ -108,7 +109,8 @@ async function openProductionPanel(page,theme) {
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
|
||||
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
|
||||
await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
@@ -129,13 +131,14 @@ async function withCollectionsButton(page,selector) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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",
|
||||
* without its action); returns an undo.
|
||||
* The concept's tag chips in the open viewer in D154's labelled sections (`sections`) 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", without its action); returns an undo.
|
||||
*/
|
||||
async function plainViewerChips(page) {
|
||||
async function plainViewerChips(page,sections) {
|
||||
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
||||
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
|
||||
const restoreSections=await withTagSections(page,'#viewer-details',sections);
|
||||
await page.evaluate(()=>{
|
||||
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
|
||||
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
|
||||
@@ -151,7 +154,7 @@ async function plainViewerChips(page) {
|
||||
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();
|
||||
await restoreSections();await restoreSource();await restoreTargets();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,7 +172,7 @@ async function fileControlsOf(page) {
|
||||
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
|
||||
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
|
||||
*/
|
||||
async function viewerOf(page,concept,mutation=null) {
|
||||
async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
|
||||
await settle(page);
|
||||
@@ -178,7 +181,7 @@ async function viewerOf(page,concept,mutation=null) {
|
||||
const {viewerDetails}=await inspectorOf(page);
|
||||
const fileControls=await fileControlsOf(page);
|
||||
let viewerPlain=null;
|
||||
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
|
||||
if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
|
||||
await restoreMutation?.();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
|
||||
@@ -186,15 +189,18 @@ async function viewerOf(page,concept,mutation=null) {
|
||||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
|
||||
return {viewerDetails,viewerPlain,fileControls};
|
||||
}
|
||||
async function inventory(page,concept=false) {
|
||||
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history.
|
||||
async function inventory(page,concept=false,sections=PROMPT_TAGS) {
|
||||
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
|
||||
// labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
|
||||
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
|
||||
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
|
||||
const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
|
||||
if(concept)await settle(page);
|
||||
const {panel}=await inspectorOf(page);
|
||||
await restoreSections?.();
|
||||
await restoreCollections?.();
|
||||
await restoreSource?.();
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
|
||||
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
|
||||
}
|
||||
|
||||
@@ -258,7 +264,8 @@ async function productionSendOf(page,context) {
|
||||
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
|
||||
async function tagOf(page) {
|
||||
const before=await page.locator('#result-count').textContent();
|
||||
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
|
||||
// The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
|
||||
await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
|
||||
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
|
||||
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
|
||||
await settle(page);
|
||||
@@ -323,6 +330,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
|
||||
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
|
||||
|
||||
// D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
|
||||
for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
|
||||
expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
|
||||
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
|
||||
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
|
||||
expect.soft(production.copy,'copy').toEqual(concept.copy);
|
||||
@@ -352,14 +362,17 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
await openProductionPanel(page,'dark');
|
||||
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
|
||||
const rows=panelOf(page).locator('.detail-inner > .tags');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
|
||||
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
const badges=panelOf(page).locator('.detail-inner > .tags');
|
||||
await expect(badges).toHaveCount(1);
|
||||
await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
|
||||
await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
// D146 (#332): the visible origin names where the value came from.
|
||||
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(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
|
||||
// D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
|
||||
const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
|
||||
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
|
||||
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(tags.locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
|
||||
});
|
||||
|
||||
@@ -373,7 +386,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
|
||||
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
|
||||
async route=>{arrived();await held;await route.continue();},{times:1});
|
||||
const count=page.locator('#result-count');
|
||||
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
|
||||
await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
|
||||
await reached;
|
||||
await expect(count).toHaveText('Loading images…');
|
||||
await expect(page.locator(CARDS)).toHaveCount(0);
|
||||
@@ -383,8 +396,9 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
|
||||
});
|
||||
|
||||
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
|
||||
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
|
||||
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
|
||||
// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
|
||||
// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
|
||||
// and equals the concept with its row in those two sections, in both sizes.
|
||||
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(120_000);
|
||||
const viewport={width,height};await page.setViewportSize(viewport);
|
||||
@@ -392,13 +406,14 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
|
||||
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
|
||||
expect(names).toEqual(['cinematic','blue_hour','architecture']);
|
||||
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
|
||||
const reference=await inventory(page,true);
|
||||
const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
|
||||
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
|
||||
expect(await saveImageTags(request,path,names)).toEqual(names);
|
||||
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
|
||||
await openProductionPanel(page,'dark');
|
||||
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
|
||||
await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
|
||||
await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
|
||||
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
|
||||
assertInspectorMatch(reference,await inventory(page),viewport);
|
||||
});
|
||||
@@ -423,6 +438,17 @@ test('inspector comparison detects mutations',async({page})=>{
|
||||
await restoreCss();await settle(page);
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
// D154: the tag section's muted label and its row's spacing are part of the mapped concept.
|
||||
for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
|
||||
['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
|
||||
['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
|
||||
const restore=await mutate(page,{css});
|
||||
const mutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
|
||||
await restore();await settle(page);
|
||||
}
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
const panel=(await inspectorOf(page)).panel;
|
||||
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
|
||||
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
|
||||
|
||||
@@ -21,8 +21,8 @@ export function mapLibraryConcept(reference) {
|
||||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...library.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
|
||||
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
|
||||
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
|
||||
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
|
||||
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
|
||||
return item;
|
||||
@@ -30,17 +30,20 @@ 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`).
|
||||
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
|
||||
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
||||
tags:sortedTags(card.tags),
|
||||
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
||||
// run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs"
|
||||
// and "Used today". The two-line clamp is
|
||||
// D153 (#447): no rating stars; the concept page itself is measured without them (`dropConceptRatings`). The
|
||||
// fixture's run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own
|
||||
// "4 runs" and "Used today". The two-line clamp is
|
||||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||||
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
|
||||
footer:card.footer}));
|
||||
return {...reference,library:{...library,toolbar,
|
||||
// D35: the caption uses the singular for one.
|
||||
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
|
||||
@@ -136,9 +139,20 @@ async function sortConceptTags(page) {
|
||||
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
|
||||
}
|
||||
|
||||
/**
|
||||
* D153 (#447): production draws no 1–5 rating. The concept's card footers read "★★★★★ · Used today"; the stars and
|
||||
* their separator are taken out of the concept page itself, so the footer facts are measured as production draws them.
|
||||
* Returns the concept's original footer texts.
|
||||
*/
|
||||
async function dropConceptRatings(page) {
|
||||
return page.evaluate(()=>[...document.querySelectorAll('.prompt-card .card-footer > span')].map(span=>{
|
||||
const original=span.textContent;span.textContent=original.replace(/^★+ · /,'');return original;}));
|
||||
}
|
||||
async function openConceptLibrary(page,theme,motion) {
|
||||
await openConcept(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
const footers=await dropConceptRatings(page);
|
||||
expect(footers,'the concept rates every card (D153 removes it)').toEqual(Array(CONCEPT_PROMPTS).fill().map((_,index)=>expect.stringMatching(/^★+ · \S/)));
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
async function openProductionLibrary(page,theme,motion) {
|
||||
@@ -292,7 +306,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
|
||||
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
|
||||
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts (D153: without the stars)`).toBe('Used today');
|
||||
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
@@ -311,6 +325,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});
|
||||
@@ -341,7 +405,7 @@ test('a run that crosses midnight still reads the concept fixture\'s day',async(
|
||||
const reference=await conceptInventory(page);
|
||||
await openProductionLibrary(page,'dark','no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
|
||||
expect(actual.library.cards.map(card=>card.footer.facts.text)).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
|
||||
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,9 +25,10 @@ export function inventoryOf(dialog) {
|
||||
demo:Boolean(dialog.querySelector('.demo-note')),
|
||||
};
|
||||
}
|
||||
// Documented differences only: prototype Save label, prototype demo note, and data-dependent values.
|
||||
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
|
||||
// concept's "Rating" select is not drawn.
|
||||
export function mapConcept(reference) {
|
||||
return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
|
||||
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
|
||||
}
|
||||
|
||||
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
|
||||
|
||||
@@ -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});
|
||||
@@ -252,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});
|
||||
|
||||
@@ -162,7 +162,6 @@ async function serverJson(request, url) {
|
||||
}
|
||||
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
|
||||
|
||||
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
||||
function mapOverviewConcept(reference, overview) {
|
||||
@@ -178,8 +177,8 @@ function mapOverviewConcept(reference, overview) {
|
||||
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
|
||||
// D93: the real sources, not samples.
|
||||
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
|
||||
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
|
||||
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
|
||||
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
|
||||
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
|
||||
};
|
||||
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
|
||||
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
|
||||
@@ -288,7 +287,10 @@ test('system comparison detects mutations', async ({ page, request }) => {
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
});
|
||||
|
||||
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
|
||||
/**
|
||||
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
|
||||
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
|
||||
*/
|
||||
function mapStatisticsConcept(reference, statistics) {
|
||||
const [runs] = reference.tables;
|
||||
return {
|
||||
@@ -296,10 +298,11 @@ function mapStatisticsConcept(reference, statistics) {
|
||||
// No sample note (prototype copy); a second table follows the first.
|
||||
body: ['table', 'table'],
|
||||
tables: [
|
||||
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
|
||||
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
|
||||
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
|
||||
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
|
||||
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
|
||||
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
|
||||
['Images', number(statistics.totals.total_images)]] },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -614,10 +617,21 @@ const WATCHED = new Set(['watching', 'stopping']);
|
||||
const REFRESH_FILES = { title: 'Refresh files', description: 'Read every source again to find files added or removed outside ComfyUI.',
|
||||
label: 'Refresh' };
|
||||
|
||||
// D158 (#247): the row production adds directly below "Watch for new outputs", in that row's markup.
|
||||
const FOREIGN_RUNS = { id: 'foreign-runs', label: 'Save prompts from other workflows',
|
||||
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.' };
|
||||
|
||||
/** D158: the concept's "Watch for new outputs" row as the inserted "Save prompts from other workflows" row. */
|
||||
function foreignRunsRow(monitoringRow, settings) {
|
||||
return { ...monitoringRow, text: [['label', FOREIGN_RUNS.label, FOREIGN_RUNS.id], ['p', FOREIGN_RUNS.description, null]],
|
||||
control: { ...monitoringRow.control, id: FOREIGN_RUNS.id, checked: settings.save_foreign_runs } };
|
||||
}
|
||||
|
||||
/** The concept's Sources with the recorded deviations applied, given the server's real settings and sources. */
|
||||
function mapSourcesConcept(reference, { settings, sources }) {
|
||||
const [sample] = reference.sources;
|
||||
const scan = sample.actions[0];
|
||||
const rowAt = reference.surface.tree.indexOf('div.setting-row');
|
||||
return {
|
||||
...reference,
|
||||
// The real sources replace the two samples (without the "sample path" note). D98: each configured one has Remove,
|
||||
@@ -625,13 +639,15 @@ function mapSourcesConcept(reference, { settings, sources }) {
|
||||
sources: sources.effective.map(source => ({ ...sample, title: source.label,
|
||||
description: `${source.path} · ${WATCH_TEXT[source.watch]}`,
|
||||
actions: [{ ...scan, text: WATCHED.has(source.watch) ? 'Scan changes' : 'Scan folder' }, { ...scan, text: 'Remove' }] })),
|
||||
rows: reference.rows.map(row => {
|
||||
if (row.control.id === 'monitoring') return { ...row, control: { ...row.control, checked: settings.monitoring_enabled } };
|
||||
rows: reference.rows.flatMap(row => {
|
||||
if (row.control.id === 'monitoring') return [{ ...row, control: { ...row.control, checked: settings.monitoring_enabled } },
|
||||
foreignRunsRow(row, settings)];
|
||||
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
|
||||
return { ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } };
|
||||
return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }];
|
||||
}),
|
||||
// D98: "Refresh files" closes the surface.
|
||||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.operation'] },
|
||||
// D158: the inserted row after the first setting row; D98: "Refresh files" closes the surface.
|
||||
surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row',
|
||||
...reference.surface.tree.slice(rowAt + 1), 'div.operation'] },
|
||||
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
|
||||
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
|
||||
};
|
||||
@@ -685,10 +701,12 @@ test('sources comparison detects mutations', async ({ page, request }) => {
|
||||
const expected = mapSourcesConcept(concept, server);
|
||||
const production = await productionSources(page, 'dark', { notice: true });
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the concept differs only in the real sources, the D98 operation and the D113 range.
|
||||
// Unmapped, the concept differs only in the real sources, the D98 operation, the D158 row (which moves the D113
|
||||
// range to the third row) and the D113 range.
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||||
.toEqual(['operations.2', 'operations.length', 'rows.1', 'sources.0', 'sources.1', 'surface.tree'].sort());
|
||||
.toEqual(['operations.2', 'operations.length', 'rows.1', 'rows.2', 'rows.length', 'sources.0', 'sources.1',
|
||||
'surface.tree'].sort());
|
||||
const measure = () => page.evaluate(measureSources, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
// A removed notice and a removed source row.
|
||||
@@ -702,6 +720,13 @@ test('sources comparison detects mutations', async ({ page, request }) => {
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for outputs'; });
|
||||
expect(await paths()).toEqual(['rows.0.text.0.1']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="monitoring"]').textContent = 'Watch for new outputs'; });
|
||||
// D158: the inserted row is compared too: a missing one and a changed label are caught.
|
||||
const undoForeign = await mutate(page, { remove: ['#workspace-main .setting-row:has(#foreign-runs)'] });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'surface.tree.length']));
|
||||
await undoForeign();
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save other prompts'; });
|
||||
expect(await paths()).toEqual(['rows.1.text.0.1']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main label[for="foreign-runs"]').textContent = 'Save prompts from other workflows'; });
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = 'Add source'; });
|
||||
expect(await paths()).toEqual(['heading.action.text']);
|
||||
await page.evaluate(() => { document.querySelector('#workspace-main .panel-heading button').textContent = '+ Add source'; });
|
||||
|
||||
@@ -363,14 +363,26 @@ test('a list read superseded by another read still opens a collection made since
|
||||
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.
|
||||
// A confirmed 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. The rename's answer is held until "Fresh"'s read is, so
|
||||
// the timer starts only then: with the answer let through at once, a slow host could fire the timer before the
|
||||
// hash change, and the page would then wait on the timer's read alone (#459).
|
||||
let releaseRename;
|
||||
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
|
||||
await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => {
|
||||
if (route.request().method() !== 'PATCH') return route.fallback();
|
||||
await renameHeld;
|
||||
return route.fallback();
|
||||
});
|
||||
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.poll(() => hold.held.length).toBe(1);
|
||||
releaseRename();
|
||||
// The rename landed (the sidebar row has the new name), so the count read is now due.
|
||||
await expect(row(page, 'Landscape grids')).toHaveCount(1);
|
||||
await expect.poll(() => hold.held.length).toBe(2);
|
||||
await expect(main(page)).not.toContainText('Collections could not be loaded.');
|
||||
await hold.release();
|
||||
await expect(heading(page)).toHaveText('Fresh');
|
||||
|
||||
@@ -495,6 +495,25 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
|
||||
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
|
||||
// given one here to prove the shared render in History.
|
||||
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
|
||||
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
|
||||
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
|
||||
body.image_tags={state:'current',tags:['history_tag']};
|
||||
await route.fulfill({response,json:body});});
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
|
||||
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
|
||||
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
|
||||
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
|
||||
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
|
||||
});
|
||||
|
||||
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
|
||||
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
|
||||
// nothing and shows no chip before.
|
||||
|
||||
@@ -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]');
|
||||
@@ -814,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);
|
||||
@@ -895,7 +897,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
|
||||
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
|
||||
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
|
||||
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
|
||||
// D153 (#447): no "Highest rated", even with a rated prompt in the library.
|
||||
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Oldest first', 'Name Z–A']);
|
||||
const row = main.locator('.filter-row');
|
||||
await expect(row.locator(':scope > *')).toHaveCount(5);
|
||||
await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
|
||||
@@ -914,8 +917,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
await search(page).fill('');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await sortBy(page).selectOption('rating');
|
||||
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await sortBy(page).selectOption('name-desc');
|
||||
await expect(sortBy(page)).toHaveValue('name-desc');
|
||||
await sortBy(page).selectOption('newest');
|
||||
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
|
||||
@@ -9,8 +9,9 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const workspace='/prompt_manager/workspace';
|
||||
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
|
||||
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
|
||||
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
|
||||
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
|
||||
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
|
||||
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
|
||||
const editorDialog=page=>page.locator('dialog.library-editor');
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
@@ -65,9 +66,8 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
|
||||
await expect(dialog.getByRole('region')).toHaveCount(0);
|
||||
await expect(field(dialog,'Category')).toHaveValue('Portraits');
|
||||
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
|
||||
await expect(field(dialog,'Rating')).toHaveValue('4');
|
||||
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
|
||||
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
|
||||
await expect(dialog.getByLabel('Rating')).toHaveCount(0);await expect(dialog.locator('select, #recipe-rating')).toHaveCount(0);
|
||||
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
|
||||
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
|
||||
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
|
||||
@@ -82,19 +82,20 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
const f=page.fixture;await mountEditor(page);
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
|
||||
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
|
||||
await field(dialog,'Tags, separated by commas').fill('studio, moody');
|
||||
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
|
||||
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
|
||||
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],notes:'Edited notes'});
|
||||
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
|
||||
const saved=(await detail(request,f.positive_id)).positive;
|
||||
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
|
||||
// D153: the edit never sends a rating, so the stored 4 stays.
|
||||
expect(f.metadata.rating).toBe(4);expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:4,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
|
||||
|
||||
await page.reload();await mountEditor(page);
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
|
||||
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
||||
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
||||
await button(dialog,'Create text revision').click();
|
||||
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
|
||||
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
|
||||
@@ -106,12 +107,15 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
|
||||
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
|
||||
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
|
||||
parent_metadata:{title:'Edited portrait',category:'Uncategorized',notes:'Edited notes'}});
|
||||
expect(Object.hasOwn(create.body.parent_metadata,'rating'),'D153: a revision sends no rating').toBe(false);
|
||||
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
|
||||
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
|
||||
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
|
||||
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
|
||||
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
|
||||
// D153: the revision is a new, unrated positive; the revised prompt keeps its stored rating.
|
||||
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized',rating:null});
|
||||
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
|
||||
|
||||
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
|
||||
@@ -120,9 +124,9 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
|
||||
expect(page.writes).toHaveLength(before);
|
||||
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
|
||||
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
|
||||
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
|
||||
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],notes:''}});
|
||||
|
||||
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
|
||||
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
|
||||
@@ -134,6 +138,7 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
|
||||
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
|
||||
await expect(page.locator('#editor-trigger')).toBeFocused();
|
||||
expect((await detail(request,f.positive_id)).positive.rating,'D153: adding a negative keeps the stored rating').toBe(4);
|
||||
});
|
||||
|
||||
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
|
||||
@@ -804,20 +809,20 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
await uniqueDotNames(page);
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText(message);
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
|
||||
});
|
||||
|
||||
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
|
||||
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page,request})=>{
|
||||
const f=page.fixture;await page.goto(`${workspace}#/library`);
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
|
||||
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
|
||||
await expect(toast(page)).toHaveText(UNAPPLIED);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
|
||||
@@ -825,6 +830,8 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
// D153: neither the reuse nor the kept draft touches the stored rating.
|
||||
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
|
||||
});
|
||||
|
||||
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
|
||||
@@ -832,7 +839,7 @@ test('reuse with the stored parent metadata entered does not claim it was not ap
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
||||
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
@@ -848,13 +855,13 @@ test('reuse of an uncategorized prompt with the category left blank is not unapp
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
|
||||
await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
||||
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
};
|
||||
await reuse('','blank category negative');
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
||||
await reuse('Landscapes','different category negative');
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
|
||||
await expect(toast(page)).toHaveText(UNAPPLIED);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -152,13 +152,25 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
|
||||
});
|
||||
|
||||
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
|
||||
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
|
||||
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(page.getByRole('combobox',{name:'Sort results',exact:true})).toHaveValue('newest');
|
||||
expect(sorts).toEqual(['newest']);
|
||||
});
|
||||
|
||||
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
|
||||
// D153 (#447): no "Highest rated".
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
@@ -239,7 +251,8 @@ test('a sort change atomically discards old row geometry and renders changed par
|
||||
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
|
||||
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
// D153: a stored rating of 5 still draws no stars.
|
||||
await expect(changed.locator('.card-footer > span')).not.toContainText('★');await expect(changed.locator('.card-footer > span > *')).toHaveCount(0);
|
||||
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -445,8 +458,8 @@ test('Library cards clamp long prompts and keep real content inside the card',as
|
||||
for(const item of data.items) {
|
||||
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
|
||||
const footer=card.locator('.card-footer > span');
|
||||
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
|
||||
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
|
||||
// D153: no stars or "Rated N out of 5", whatever the stored rating.
|
||||
await expect(footer.locator('> *')).toHaveCount(0);
|
||||
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
|
||||
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
|
||||
}
|
||||
@@ -598,7 +611,7 @@ async function patchShelf(page,patch) {
|
||||
await route.fulfill({response,json:body});
|
||||
});
|
||||
}
|
||||
/** The footer line as seen: everything but its visually hidden rating sentence. */
|
||||
/** The footer line as seen: everything but visually hidden text (D153: there is no rating sentence any more). */
|
||||
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
|
||||
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
|
||||
|
||||
@@ -618,7 +631,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']);
|
||||
@@ -633,13 +647,13 @@ test('card matches the concept and never wraps the category mid-word',async({pag
|
||||
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
|
||||
const footer=card.locator('.card-footer');
|
||||
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
|
||||
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
|
||||
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
|
||||
// D153 (#447): the stored rating 5 draws no stars; the footer reads only the last use.
|
||||
await expect(footer.locator('> span > *')).toHaveCount(0);
|
||||
expect(await footer.locator('> span').evaluate(shownText)).toBe('Used today');
|
||||
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
|
||||
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
|
||||
// Unrated and never used: the used label alone; no negatives still reads as a count.
|
||||
// Never used: the used label alone; no negatives still reads as a count.
|
||||
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
|
||||
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
|
||||
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
|
||||
|
||||
@@ -230,6 +230,31 @@ test('a view stored under older rules still lists and opens with its usable valu
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
|
||||
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
|
||||
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
|
||||
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [] } });
|
||||
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
|
||||
const listings = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (['/prompt_manager/images/output', '/prompt_manager/library'].includes(url.pathname)) listings.push(url.searchParams.get('sort'));
|
||||
});
|
||||
await page.goto(`${workspace}#/saved/${images.id}`);
|
||||
await expect(heading(page)).toHaveText('Rated cats');
|
||||
await expect(sortBy(page)).toHaveValue('newest');
|
||||
expect(await sortBy(page).locator('option').allTextContents()).not.toContain('Highest rated');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await savedNav(page).getByRole('link', { name: 'Rated dogs', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Rated dogs');
|
||||
await expect(sortBy(page)).toHaveValue('newest');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
expect(listings.length).toBeGreaterThan(1);
|
||||
expect(listings).not.toContain('rating');
|
||||
});
|
||||
|
||||
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
|
||||
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
|
||||
@@ -268,6 +268,71 @@ test('Watch for new outputs saves monitoring_enabled; a restart applies it', asy
|
||||
await expect(sourceList(page).locator('.operation p')).toHaveText(['output · watching', 'input · watching']);
|
||||
});
|
||||
|
||||
test('D158: Save prompts from other workflows saves save_foreign_runs live; off while monitoring is off (#247)', async ({ page, request }, testInfo) => {
|
||||
try {
|
||||
const layout = await openSources(page);
|
||||
const rows = layout.locator('section.surface > .setting-row');
|
||||
await expect(rows.locator(':scope > div > label')).toHaveText(['Watch for new outputs', 'Save prompts from other workflows', 'Worker threads']);
|
||||
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
|
||||
const foreignRow = rows.filter({ has: page.locator('#foreign-runs') });
|
||||
await expect(foreignRow.locator(':scope > div > p')).toHaveText('New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.');
|
||||
await expect(foreign).toBeChecked();
|
||||
await expect(foreign).toBeEnabled();
|
||||
// Keyboard on desktop, a tap on the touch project.
|
||||
if (testInfo.project.name === 'touch') await foreign.tap();
|
||||
else {
|
||||
await foreign.focus();
|
||||
await page.keyboard.press('Space');
|
||||
}
|
||||
await expect(foreign).not.toBeChecked();
|
||||
await expect(statusOf(page)).toHaveText('Unsaved changes.');
|
||||
const saved = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(SETTINGS_URL));
|
||||
await saveButton(page).click();
|
||||
const response = await saved;
|
||||
expect(response.request().postDataJSON()).toEqual(expect.objectContaining({ save_foreign_runs: false }));
|
||||
expect((await response.json()).restart_required_fields).toEqual([]);
|
||||
// Applied live: the monitor reads it for every file, so no restart is named.
|
||||
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
|
||||
expect((await serverJson(request, SETTINGS_URL)).settings.save_foreign_runs).toBe(false);
|
||||
// With Watch for new outputs drafted off nothing is watched: the row is disabled and its note says why.
|
||||
const monitoring = layout.getByRole('switch', { name: 'Watch for new outputs', exact: true });
|
||||
await monitoring.uncheck();
|
||||
await expect(foreign).toBeDisabled();
|
||||
const note = foreignRow.locator('p.small');
|
||||
await expect(note).toHaveText('Applies while Watch for new outputs is on.');
|
||||
await expect(foreign).toHaveAttribute('aria-describedby', await note.getAttribute('id'));
|
||||
await monitoring.check();
|
||||
await expect(foreign).toBeEnabled();
|
||||
await expect(note).toHaveCount(0);
|
||||
await expect(foreign).not.toHaveAttribute('aria-describedby', /./);
|
||||
await expect(statusOf(page)).toHaveText('No unsaved changes.');
|
||||
} finally {
|
||||
// The server is shared by the spec's tests: put the default back whatever happened.
|
||||
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
|
||||
}
|
||||
});
|
||||
|
||||
test('D158: saved off while the monitor still watches until a restart, the foreign row can still be set (D128)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/monitor', { data: { watching: ['output', 'input'] } })).toBeOK();
|
||||
try {
|
||||
const layout = await openSources(page);
|
||||
await layout.getByRole('switch', { name: 'Watch for new outputs', exact: true }).uncheck();
|
||||
await saveButton(page).click();
|
||||
await expect(sourceList(page).locator('.operation p').first()).toHaveText('output · watching until restart');
|
||||
const foreign = layout.getByRole('switch', { name: 'Save prompts from other workflows', exact: true });
|
||||
await expect(foreign).toBeEnabled();
|
||||
await expect(layout.locator('.setting-row').filter({ has: page.locator('#foreign-runs') }).locator('p.small'))
|
||||
.toHaveText('Applies while Watch for new outputs is on.');
|
||||
await foreign.uncheck();
|
||||
const saved = page.waitForRequest(item => item.method() === 'POST' && item.url().endsWith(SETTINGS_URL));
|
||||
await saveButton(page).click();
|
||||
expect((await saved).postDataJSON()).toEqual({ save_foreign_runs: false, expected_revision: expect.any(String) });
|
||||
await expect(statusOf(page)).toHaveText('Saved to the server configuration.');
|
||||
} finally {
|
||||
await request.post(SETTINGS_URL, { data: { save_foreign_runs: true } });
|
||||
}
|
||||
});
|
||||
|
||||
test('worker range max is cpu_count', async ({ page, request }) => {
|
||||
const { settings } = await serverJson(request, SETTINGS_URL);
|
||||
const layout = await openSources(page);
|
||||
|
||||
@@ -79,7 +79,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
|
||||
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
|
||||
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
|
||||
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
|
||||
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
|
||||
['Statistics', 'Runs by category, with library totals.', 'Explore'],
|
||||
]);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
@@ -373,13 +373,12 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
|
||||
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
|
||||
const tables = dialog.locator('#modal-body > table');
|
||||
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
|
||||
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
|
||||
// D153 (#447): no rating column and no rated-images total.
|
||||
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
|
||||
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
|
||||
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
|
||||
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
|
||||
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
|
||||
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
|
||||
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
|
||||
['Rated images', number(statistics.rated_images)]]);
|
||||
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(explore).toBeFocused();
|
||||
// A statistics read that fails says so; the Overview stays.
|
||||
@@ -481,17 +480,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 +502,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 +882,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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
|
||||
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const actionButtons = page => modal(page).locator('.dialog-actions button');
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
|
||||
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
|
||||
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
|
||||
await expect(bar(page).locator('strong')).toHaveText('2 selected');
|
||||
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
|
||||
// The finished job's files are read again: the selected card's panel shows its new image tags.
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
|
||||
await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
|
||||
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
|
||||
expect(await savedTags(request, UNLINKED)).toEqual([]);
|
||||
});
|
||||
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
|
||||
// The reviewed file's panel shows its saved tags without reselecting.
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
|
||||
await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
|
||||
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -458,27 +458,32 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
|
||||
expect(await count()).toBe(mounted);
|
||||
});
|
||||
|
||||
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
|
||||
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
|
||||
// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
|
||||
// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
|
||||
// Tags filter buttons; the viewer's chips are plain.
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
|
||||
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
|
||||
const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
|
||||
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
|
||||
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
|
||||
|
||||
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
// D21/D39: the linked recipe's own tag, plain.
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
|
||||
// D21/D39: the linked recipe's own tag, plain, and no image tags yet.
|
||||
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
|
||||
await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('dunes_tag, Editorial');
|
||||
const reread = inspected(page, FIRST);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
|
||||
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
// The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
|
||||
await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
|
||||
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
});
|
||||
@@ -490,9 +495,10 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
|
||||
await infoButton(page, UNLINKED).click();
|
||||
await read;
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
|
||||
await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
|
||||
await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
|
||||
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
|
||||
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
|
||||
await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
|
||||
await listing;
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
@@ -502,18 +508,53 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
|
||||
test('viewer details show the same chips', async ({ page, request }) => {
|
||||
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await page.goto(images);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
const details = page.locator('#viewer-dialog[open] #viewer-details');
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
const row = details.locator('.tags').last();
|
||||
const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
|
||||
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await expect(row.locator('span.chip')).toHaveCount(3);
|
||||
await expect(row.locator('button')).toHaveCount(0);
|
||||
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
|
||||
// A file without image tags: the viewer shows only D21's linked tags.
|
||||
await page.getByRole('button', { name: 'Next media', exact: true }).click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
|
||||
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
|
||||
// so they never read as part of it; the same in the side panel and the viewer details.
|
||||
async function tagSectionPlacement(root) {
|
||||
return root.evaluate(node => {
|
||||
const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
|
||||
const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
|
||||
return {
|
||||
after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
|
||||
below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
|
||||
border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
|
||||
label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
|
||||
};
|
||||
});
|
||||
}
|
||||
const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
|
||||
label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
|
||||
|
||||
test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
|
||||
await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
|
||||
await page.goto(images);
|
||||
const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
|
||||
await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
|
||||
await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
|
||||
expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
const details = page.locator('#viewer-dialog[open] #viewer-details');
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
|
||||
await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
|
||||
await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
|
||||
expect(await tagSectionPlacement(details)).toEqual(PLACED);
|
||||
});
|
||||
|
||||
test('search finds an image by its image tag', async ({ page, request }) => {
|
||||
@@ -570,7 +611,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
|
||||
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await page.evaluate(() => {
|
||||
const node = document.querySelector('aside[aria-label="Selected image details"]');
|
||||
window.__loadingSeen = false;
|
||||
@@ -589,12 +630,13 @@ test('the post-save re-read keeps the panel content until the new read lands', a
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
// While the re-read is held, the panel still shows the file's details.
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
|
||||
const landed = inspected(page, FIRST);
|
||||
release();
|
||||
await landed;
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
|
||||
await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
|
||||
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
|
||||
await page.unroute(matcher);
|
||||
});
|
||||
@@ -607,7 +649,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
|
||||
await infoButton(page, SECOND).click();
|
||||
await read;
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// The newest match (SECOND) is selected once the filter applies.
|
||||
@@ -767,7 +809,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
@@ -792,7 +834,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -263,15 +263,18 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
});
|
||||
});
|
||||
|
||||
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
|
||||
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
|
||||
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
|
||||
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
|
||||
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
|
||||
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
|
||||
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
|
||||
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
|
||||
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
|
||||
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
|
||||
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
|
||||
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
|
||||
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
|
||||
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
|
||||
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
|
||||
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
|
||||
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
|
||||
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
|
||||
});
|
||||
|
||||
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
|
||||
@@ -369,7 +372,7 @@ test('successful source capture survives downstream failure and real interruptio
|
||||
}
|
||||
});
|
||||
|
||||
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
|
||||
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
|
||||
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
|
||||
const first = await submit(execution.request, graph);
|
||||
await history(execution.request, first);
|
||||
@@ -377,8 +380,9 @@ test('cached dynamic raw stays unknown while literal affixes and effective cache
|
||||
const second = await submit(execution.request, graph);
|
||||
await history(execution.request, second);
|
||||
const value = (await capture(execution, second)).contexts[0];
|
||||
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
|
||||
expect(value.evidence.positive.raw.provenance).toBe('unknown');
|
||||
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
|
||||
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
|
||||
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
|
||||
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
|
||||
});
|
||||
|
||||
|
||||
@@ -287,6 +287,14 @@ async function capturedContext(execution, id) {
|
||||
return result.captures[result.rows[0].run_id].contexts[0];
|
||||
}
|
||||
|
||||
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
|
||||
async function expectTrackedRuns(execution, promptId, expected) {
|
||||
await expect.poll(async () => {
|
||||
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
|
||||
return response.ok() ? (await response.json()).recorded_run_count : null;
|
||||
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
|
||||
}
|
||||
|
||||
async function firstRun(execution, page, testCase) {
|
||||
const prefix = `textlink/${randomUUID()}`;
|
||||
const built = await build(page, { ...testCase, prefix });
|
||||
@@ -309,6 +317,8 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
|
||||
const counts = await runCounts(execution, first.context);
|
||||
expect(counts.positive).toBe(3);
|
||||
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
|
||||
// #403: the cached jobs are tracked runs of the prompt too
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,6 +339,7 @@ test.describe('cached runs in one renderer (#347)', () => {
|
||||
else expect(again.cached).toContain(first.promptNode);
|
||||
}
|
||||
expect((await runCounts(execution, first.context)).positive).toBe(3);
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
|
||||
} finally { await setCache(execution, 'RAM_PRESSURE'); }
|
||||
});
|
||||
}
|
||||
@@ -352,6 +363,9 @@ test.describe('cached runs in one renderer (#347)', () => {
|
||||
const bContext = await capturedContext(execution, second.id);
|
||||
expect(bContext.positive.raw).toBe(b);
|
||||
expect((await runCounts(execution, bContext)).positive).toBe(1);
|
||||
// #403: the third job reuses A's execution, not the newest (B's)
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
|
||||
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
|
||||
} finally { await setCache(execution, 'RAM_PRESSURE'); }
|
||||
});
|
||||
|
||||
@@ -390,10 +404,14 @@ test.describe('cached runs in one renderer (#347)', () => {
|
||||
} });
|
||||
expect(rewired.cached).toContain(first.promptNode);
|
||||
expect((await runCounts(execution, first.context)).positive).toBe(1);
|
||||
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
|
||||
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
|
||||
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
|
||||
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
|
||||
expect(cachedContext.positive_prompt_id).toBeNull();
|
||||
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
|
||||
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
|
||||
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
|
||||
expect((await runCounts(execution, first.context)).positive).toBe(2);
|
||||
// The rewired job used the prompt as its negative: neither number counts it
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,8 +334,15 @@ async def image_monitor(request: web.Request) -> web.Response:
|
||||
|
||||
@PromptServer.instance.routes.get("/pm_execution_fixture/monitor")
|
||||
async def image_monitor_state(request: web.Request) -> web.Response:
|
||||
"""File names of keyless images waiting for their run's recorded outputs."""
|
||||
"""File names of images waiting for their run's recorded outputs: keyless
|
||||
ones (``awaiting``) and foreign ones (``foreign``, #247)."""
|
||||
monitor = _plugin_module("utils.image_monitor")._monitor_instance
|
||||
handler = getattr(monitor, "handler", None)
|
||||
waiting = handler.awaiting_history() if handler is not None else []
|
||||
return web.json_response({"awaiting": [os.path.basename(p) for p in waiting]})
|
||||
foreign = handler.awaiting_foreign() if handler is not None else []
|
||||
return web.json_response(
|
||||
{
|
||||
"awaiting": [os.path.basename(p) for p in waiting],
|
||||
"foreign": [os.path.basename(p) for p in foreign],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// #342 on the real pinned host: the image monitor links an output only to a prompt
|
||||
// that provably produced it. An unknown node (PMHistoryCached) stands in for the
|
||||
// maintainer's LLM; KSampler is the fixture's deterministic stand-in.
|
||||
// maintainer's LLM; KSampler is the fixture's deterministic stand-in. #247: an output
|
||||
// of a workflow without a PromptManager node gets its own prompt.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
@@ -141,6 +142,68 @@ test('an output saved without metadata links once its recorded run names a store
|
||||
}
|
||||
});
|
||||
|
||||
// #247: no PromptManager node at all, only ComfyUI's own text encoders, sampler and
|
||||
// saver (the fixture's inputs and decoder stand in for the model files).
|
||||
function foreignGraph({ text, seed = 1, prefix }) {
|
||||
return {
|
||||
'10': { class_type: 'PMHistoryInputs', inputs: {} },
|
||||
'20': { class_type: 'CLIPTextEncode', inputs: { text, clip: ['10', 0] } },
|
||||
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
|
||||
'40': { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2], positive: ['20', 0],
|
||||
negative: ['30', 0], seed, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||||
'60': { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: ['40', 0] } },
|
||||
'70': { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: prefix } },
|
||||
};
|
||||
}
|
||||
|
||||
test('an output of a workflow without a PromptManager node creates and links its prompt; a second run reuses it', async ({ execution }, testInfo) => {
|
||||
const text = `foreign lighthouse at dusk ${testInfo.project.name} ${randomUUID()}`;
|
||||
const first = await saved(execution.request, foreignGraph({ text, prefix: 'foreign/run' }));
|
||||
await linkedTo(execution, first, text);
|
||||
// Only the seed changes: a new file, the same text, so the same stored prompt.
|
||||
const second = await saved(execution.request, foreignGraph({ text, seed: 2, prefix: 'foreign/run' }));
|
||||
await linkedTo(execution, second, text);
|
||||
const state = await readLinks(execution.databasePath, [first, second], [text]);
|
||||
expect(state).toEqual({ links: { [first]: text, [second]: text }, stored: { [text]: true } });
|
||||
// Decided from the graph at settle: nothing waits for the run's recorded outputs.
|
||||
expect((await (await execution.request.get('/pm_execution_fixture/monitor')).json()).foreign).toEqual([]);
|
||||
});
|
||||
|
||||
// CUJ 1: two branches saved under one prefix, so neither the graph nor the file name
|
||||
// names a file's branch; the run's recorded saver does, once its outputs are sealed.
|
||||
function sharedPrefixGraph(texts) {
|
||||
const nodes = {
|
||||
'10': { class_type: 'PMHistoryInputs', inputs: {} },
|
||||
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
|
||||
};
|
||||
texts.forEach((text, index) => {
|
||||
const base = (index + 1) * 100;
|
||||
nodes[String(base)] = { class_type: 'CLIPTextEncode', inputs: { text, clip: ['10', 0] } };
|
||||
nodes[String(base + 1)] = { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
|
||||
positive: [String(base), 0], negative: ['30', 0], seed: index + 1, steps: 1, cfg: 1, sampler_name: 'euler',
|
||||
scheduler: 'normal', denoise: 1 } };
|
||||
nodes[String(base + 2)] = { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: [String(base + 1), 0] } };
|
||||
nodes[String(base + 3)] = { class_type: 'SaveImage', inputs: { images: [String(base + 2), 0], filename_prefix: 'foreign/dataset' } };
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
test('each branch of a shared-prefix workflow without a PromptManager node links to its own prompt after the run', async ({ execution }, testInfo) => {
|
||||
const tag = `${testInfo.project.name} ${randomUUID()}`;
|
||||
const texts = [`dataset branch one harbor ${tag}`, `dataset branch two meadow ${tag}`];
|
||||
const id = randomUUID();
|
||||
const response = await execution.request.post('/prompt', { data: { prompt: sharedPrefixGraph(texts), prompt_id: id } });
|
||||
expect(response.ok()).toBe(true);
|
||||
let item;
|
||||
await expect.poll(async () => {
|
||||
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
|
||||
return item?.status?.messages?.some(([kind]) => kind === 'execution_success') || false;
|
||||
}, { timeout: 15000 }).toBe(true);
|
||||
const files = ['103', '203'].map(node => item.outputs[node].images[0].filename);
|
||||
for (const [index, file] of files.entries()) await linkedTo(execution, file, texts[index]);
|
||||
expect((await (await execution.request.get('/pm_execution_fixture/monitor')).json()).foreign).toEqual([]);
|
||||
});
|
||||
|
||||
// #332 on the real host: the migration ran at startup and the image category routes
|
||||
// set, list and remove an own category on a generated output.
|
||||
test('an output gets its own image category and loses it again', async ({ execution }, testInfo) => {
|
||||
|
||||
@@ -192,6 +192,7 @@
|
||||
"settings.monitoring_enabled",
|
||||
"settings.result_timeout",
|
||||
"settings.safety_backup",
|
||||
"settings.save_foreign_runs",
|
||||
"settings.thumbnail_edge",
|
||||
"settings.thumbnail_format",
|
||||
"settings.thumbnail_quality",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -209,16 +209,18 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
|
||||
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
|
||||
return {...record(), imageTags, library};
|
||||
}
|
||||
test('renders current image tags before linked tags without duplicates', async () => {
|
||||
test('renders current image tags before linked tags, each without repeats within its source', async () => {
|
||||
const {inspectorTagChips} = await load();
|
||||
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
|
||||
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
|
||||
// D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
|
||||
[{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
|
||||
{name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
|
||||
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
|
||||
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
|
||||
[{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
|
||||
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
|
||||
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
|
||||
});
|
||||
@@ -236,6 +238,61 @@ test('none and unavailable render no image chips', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
|
||||
// prompt blocks, so per-image tags never read as part of the negative prompt.
|
||||
function renderVariant(record, variant, options = {}) {
|
||||
const f = mountedDom(), scope = createScope();
|
||||
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
|
||||
view.setState({loading:false, error:false, record});
|
||||
return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
|
||||
}
|
||||
const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
|
||||
function sectionSummary(f, section) {
|
||||
const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
|
||||
const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
|
||||
return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
|
||||
}
|
||||
test('chips name their source: the image or the linked prompt', async () => {
|
||||
const {inspectorTagChips, tagSectionLabel} = await load();
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
|
||||
['image', 'image', 'linked', 'linked', 'linked']);
|
||||
assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
|
||||
assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
|
||||
assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
|
||||
});
|
||||
test('image tags and linked tags render in their own labelled sections', async () => {
|
||||
for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
|
||||
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
|
||||
try {
|
||||
const sections = tagSections(f).map(section => sectionSummary(f, section));
|
||||
assert.deepEqual(sections, [
|
||||
{className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
|
||||
{className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
|
||||
], variant);
|
||||
// No bare chip row follows the prompt blocks any more.
|
||||
const inner = f.container.children.find(n => n.tagName === 'div');
|
||||
assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
|
||||
} finally { done(); }
|
||||
}
|
||||
});
|
||||
test('a recipe names its tags and the context names the regions', async () => {
|
||||
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
|
||||
try {
|
||||
assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
|
||||
{className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
|
||||
{className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
|
||||
]);
|
||||
} finally { done(); }
|
||||
});
|
||||
test('an empty tag source draws no section', async () => {
|
||||
let view = renderVariant(tagged(null), 'full');
|
||||
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
|
||||
view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
|
||||
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
|
||||
view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
|
||||
try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
|
||||
});
|
||||
|
||||
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
|
||||
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
|
||||
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -211,11 +211,12 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
|
||||
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
|
||||
});
|
||||
|
||||
test('stream sends category, tags and the rating sort and validates them', async () => {
|
||||
test('stream sends category, tags and the sort and validates them; the rating sort is gone (D153)', async () => {
|
||||
const {createImageStream}=await source;const calls=[];
|
||||
const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}});
|
||||
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'});
|
||||
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating');
|
||||
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'name-desc'});
|
||||
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'name-desc');
|
||||
await assert.rejects(stream.reset({sort:'rating'}),/sort/i);
|
||||
await stream.reset({category:'',tags:[]});
|
||||
assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false);
|
||||
for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i);
|
||||
@@ -277,7 +278,7 @@ test('the caption date names the newest result day under Newest first only', asy
|
||||
assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'');
|
||||
assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13');
|
||||
assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025');
|
||||
for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
|
||||
for(const sort of ['name','name-desc']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
|
||||
assert.equal(captionDate([],'newest',now),'');
|
||||
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
|
||||
});
|
||||
@@ -525,7 +526,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 () => {
|
||||
|
||||
@@ -117,8 +117,9 @@ test('new prompt from Images waits for a fresh options epoch', async () => {
|
||||
assert.equal(sentBody.expected_epoch, EPOCH);
|
||||
assert.deepEqual(sentBody, {
|
||||
expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '',
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' },
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], notes: '' },
|
||||
});
|
||||
assert.equal(Object.hasOwn(sentBody.parent_metadata, 'rating'), false, 'D153: a new prompt sends no rating');
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
assert.equal(committed.length, 1);
|
||||
assert.equal(committed[0].kind, 'create');
|
||||
@@ -230,11 +231,11 @@ test('editor recovery preserves hyphenated ApiError codes', async () => {
|
||||
const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) }));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', 5);
|
||||
controller.setField('notes', 'kept');
|
||||
await controller.submit();
|
||||
phases.push(controller.state.phase);
|
||||
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
|
||||
assert.equal(controller.state.draft.rating, 5, 'draft preserved');
|
||||
assert.equal(controller.state.draft.notes, 'kept', 'draft preserved');
|
||||
assert.equal(controller.state.receipt, null);
|
||||
controller.dispose();
|
||||
}
|
||||
@@ -306,7 +307,7 @@ test('confirmed save refresh retry is read only', async () => {
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
let failReads = 1;
|
||||
const committed = [], reconciled = [];
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) };
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext', edit_token: TOKEN2 }) };
|
||||
const base = library({ write: () => response(200, receipt) });
|
||||
const server = harness(call => {
|
||||
if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline'));
|
||||
@@ -315,7 +316,6 @@ test('confirmed save refresh retry is read only', async () => {
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
||||
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', null);
|
||||
controller.setField('notes', 'keep\ntext');
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'saved-refresh-error');
|
||||
@@ -338,7 +338,7 @@ test('confirmed save refresh retry is read only', async () => {
|
||||
test('legacy title fallback is not an edit', async () => {
|
||||
const { createLibraryEditorController } = await source;
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) };
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext' }) };
|
||||
const server = harness(library({ write: () => response(200, receipt) }));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
@@ -346,7 +346,6 @@ test('legacy title fallback is not an edit', async () => {
|
||||
assert.equal(controller.state.baseline.title, null, 'stored title stays distinct');
|
||||
assert.equal(controller.state.draft.tags, 'soft, warm');
|
||||
assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative');
|
||||
controller.setField('rating', null);
|
||||
controller.setField('notes', 'keep\ntext');
|
||||
controller.setField('tags', 'warm, soft,');
|
||||
controller.setField('category', ' Studio ');
|
||||
@@ -357,7 +356,7 @@ test('legacy title fallback is not an edit', async () => {
|
||||
const sentBody = write.body;
|
||||
assert.equal(sentBody.expected_epoch, EPOCH);
|
||||
assert.equal(sentBody.if_match, TOKEN);
|
||||
assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' });
|
||||
assert.deepEqual(sentBody.metadata, { notes: 'keep\ntext' });
|
||||
|
||||
const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await unchanged.initialize();
|
||||
@@ -421,12 +420,12 @@ test('metadata conflict reload keeps changed fields for review and never replays
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
let owner = positive();
|
||||
const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2
|
||||
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } })
|
||||
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, notes: 'mine' } })
|
||||
: response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', '5');
|
||||
owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 });
|
||||
controller.setField('notes', 'mine');
|
||||
owner = positive({ title: 'Renamed elsewhere', category: 'Elsewhere', edit_token: TOKEN2 });
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'conflict');
|
||||
await controller.submit();
|
||||
@@ -434,20 +433,20 @@ test('metadata conflict reload keeps changed fields for review and never replays
|
||||
await controller.reloadOwner({ keepDraft: true });
|
||||
assert.equal(controller.state.phase, 'ready');
|
||||
assert.equal(controller.state.reviewRequired, true);
|
||||
assert.equal(controller.state.draft.rating, 5, 'changed field kept');
|
||||
assert.equal(controller.state.draft.notes, 'mine', 'changed field kept');
|
||||
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
|
||||
assert.equal(controller.state.draft.notes, 'other writer');
|
||||
assert.equal(controller.state.draft.category, 'Elsewhere');
|
||||
assert.equal(server.writes().length, 1);
|
||||
await controller.submit();
|
||||
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } });
|
||||
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { notes: 'mine' } });
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
controller.dispose();
|
||||
|
||||
const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await discard.initialize();
|
||||
discard.setField('rating', 1);
|
||||
discard.setField('notes', 'dropped');
|
||||
await discard.reloadOwner({ keepDraft: false });
|
||||
assert.equal(discard.state.draft.rating, 3);
|
||||
assert.equal(discard.state.draft.notes, '');
|
||||
assert.equal(discard.state.reviewRequired, false);
|
||||
discard.dispose();
|
||||
});
|
||||
@@ -457,12 +456,12 @@ test('metadata unknown outcome retry reloads the owner instead of replaying', as
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
let owner = positive();
|
||||
const server = harness(call => library({ owner, write: () => {
|
||||
owner = positive({ rating: 5, edit_token: TOKEN2 });
|
||||
owner = positive({ notes: 'applied', edit_token: TOKEN2 });
|
||||
return Promise.reject(new TypeError('Failed to fetch'));
|
||||
} })(call));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', 5);
|
||||
controller.setField('notes', 'applied');
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'unknown-outcome');
|
||||
await controller.retryUnconfirmed();
|
||||
@@ -570,8 +569,10 @@ test('revision seeds text from the selected pair and sends Uncategorized for a b
|
||||
await controller.submit();
|
||||
assert.deepEqual(server.writes()[0].body, {
|
||||
expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry',
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' },
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], notes: 'n' },
|
||||
});
|
||||
// D153: the revision's new positive is unrated; the owner's stored rating (3) is neither copied nor written.
|
||||
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
@@ -589,7 +590,9 @@ test('invalid drafts and server failures keep the draft without a receipt', asyn
|
||||
assert.equal(controller.state.phase, 'ready');
|
||||
assert.match(controller.state.error.message, /Title/);
|
||||
assert.throws(() => controller.setField('unknown', 'x'), TypeError);
|
||||
assert.throws(() => controller.setField('rating', 7), TypeError);
|
||||
// D153: the editor has no rating field.
|
||||
assert.throws(() => controller.setField('rating', 4), /Unknown editor field: rating/);
|
||||
assert.throws(() => controller.setField('notes', 4), TypeError);
|
||||
controller.setField('title', 'T');
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'write-error');
|
||||
@@ -807,3 +810,44 @@ test('a blank category reused against an uncategorized owner is not unapplied me
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// D153 (#447): the workspace no longer edits the 1–5 rating, and a save never clears a stored one.
|
||||
test('editing a prompt rated 4 sends no rating and the saved prompt keeps 4', async () => {
|
||||
const { createLibraryEditorController, EDITOR_FIELDS } = await source;
|
||||
assert.deepEqual(EDITOR_FIELDS, ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
|
||||
let owner = positive({ rating: 4, title: 'Portrait' });
|
||||
const server = harness(call => library({ owner, write: write => {
|
||||
owner = positive({ ...owner, ...write.body.metadata, edit_token: TOKEN2 });
|
||||
return response(200, { success: true, library_epoch: EPOCH, positive: owner });
|
||||
} })(call));
|
||||
const reconciled = [];
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
||||
onReconciled: result => reconciled.push(result) });
|
||||
await controller.initialize();
|
||||
assert.equal(Object.hasOwn(controller.state.draft, 'rating'), false, 'the draft has no rating');
|
||||
assert.equal(Object.hasOwn(controller.state.baseline, 'rating'), false, 'the baseline has no rating');
|
||||
controller.setField('title', 'Portrait, edited');
|
||||
await controller.submit();
|
||||
const [write] = server.writes();
|
||||
assert.deepEqual(write.body.metadata, { title: 'Portrait, edited' });
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
assert.equal(controller.state.receipt.positive.rating, 4);
|
||||
assert.equal(reconciled[0].detail.positive.rating, 4, 'the re-read prompt still has its rating');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('a reused rated positive is not reported as unapplied metadata for its rating', async () => {
|
||||
const { createLibraryEditorController } = await source;
|
||||
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
|
||||
positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: 4, notes: '' }) });
|
||||
const server = harness(created(() => response(200, reused)));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
||||
await controller.initialize();
|
||||
controller.setField('title', 'Portrait');
|
||||
controller.setField('positive_text', 'a portrait');
|
||||
await controller.submit();
|
||||
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
assert.equal(controller.state.outcome.metadataNotApplied, false);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
@@ -46,9 +46,8 @@ test('used label boundaries in UTC and local time', async () => {
|
||||
for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet');
|
||||
});
|
||||
|
||||
test('rating label omits unrated', async () => {
|
||||
const { ratingLabel } = await source;
|
||||
assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' });
|
||||
assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' });
|
||||
for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' });
|
||||
// D153 (#447): cards show no rating, so the layout module has no rating label.
|
||||
test('the layout module offers no rating label', async () => {
|
||||
const layout = await source;
|
||||
assert.equal(Object.hasOwn(layout, 'ratingLabel'), false);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -5,8 +5,8 @@ const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,
|
||||
const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor});
|
||||
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
|
||||
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
|
||||
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
|
||||
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
|
||||
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'name'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
|
||||
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'name');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
|
||||
});
|
||||
test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{
|
||||
const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}];
|
||||
@@ -104,3 +104,19 @@ 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;
|
||||
});
|
||||
test('the shelf no longer sorts by rating (D153)',async()=>{
|
||||
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
|
||||
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
|
||||
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
|
||||
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -72,9 +72,9 @@ 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, collection: '' };
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'name-desc', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
|
||||
const state = imagesViewState(filters);
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
|
||||
assert.deepEqual(imagesFilters(state), filters);
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
|
||||
// #331 D140: the open collection round-trips; a missing one is ''.
|
||||
@@ -215,3 +215,13 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
|
||||
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
|
||||
assert.deepEqual(api.calls, []);
|
||||
});
|
||||
|
||||
test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => {
|
||||
const { normalizeViewState, imagesFilters, libraryFilters } = await source;
|
||||
assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(normalizeViewState('library', { sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
|
||||
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -141,7 +141,7 @@ test('the report summary says when it was checked and whether it is complete', a
|
||||
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
|
||||
});
|
||||
|
||||
test('statistics rows are the real runs and ratings, then the library totals', async () => {
|
||||
test('statistics rows are the real runs, then the library totals, without ratings (D153)', async () => {
|
||||
const { categoryRows, totalsRows } = await overviewModule;
|
||||
const statistics = {
|
||||
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
|
||||
@@ -149,8 +149,8 @@ test('statistics rows are the real runs and ratings, then the library totals', a
|
||||
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
|
||||
rated_images: 20,
|
||||
};
|
||||
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
|
||||
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
|
||||
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
|
||||
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
|
||||
});
|
||||
|
||||
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
|
||||
|
||||
@@ -157,3 +157,20 @@ test('a valid configured source missing from the effective list is another spell
|
||||
assert.deepEqual(sourceRows({ sources, saved, current: saved }).rows.at(-1), { key: 'output/.', title: 'output',
|
||||
description: 'output/. · same folder as another source', scan: null, remove: 'output/.' });
|
||||
});
|
||||
|
||||
test('D158: "Save prompts from other workflows" drafts save_foreign_runs, disabled while monitoring is off (#247)', async () => {
|
||||
const { FOREIGN_RUNS, foreignRunsState } = await sourcesModule;
|
||||
assert.deepEqual(FOREIGN_RUNS, { id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows',
|
||||
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.',
|
||||
off: 'Applies while Watch for new outputs is on.' });
|
||||
assert.deepEqual(foreignRunsState({ value: true, monitoring: true }), { disabled: false, checked: true, note: null });
|
||||
assert.deepEqual(foreignRunsState({ value: false, monitoring: true }), { disabled: false, checked: false, note: null });
|
||||
// Monitoring drafted or saved off: nothing is watched, so the row says why it is disabled and keeps its value.
|
||||
assert.deepEqual(foreignRunsState({ value: true, monitoring: false }), { disabled: true, checked: true, note: FOREIGN_RUNS.off });
|
||||
// Not read yet (or the host read failed): disabled without a value, the host rows' own note applies.
|
||||
assert.deepEqual(foreignRunsState({ value: undefined, monitoring: undefined }), { disabled: true, checked: null, note: null });
|
||||
assert.deepEqual(foreignRunsState({ value: undefined, monitoring: false }), { disabled: true, checked: null, note: null });
|
||||
// Saved off while the running monitor still watches until the restart (D128 `stopping`): it can still be set.
|
||||
assert.deepEqual(foreignRunsState({ value: true, monitoring: false, stopping: true }),
|
||||
{ disabled: false, checked: true, note: FOREIGN_RUNS.off });
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ CONFIG = {
|
||||
"max_image_age_days",
|
||||
"max_concurrent_processing",
|
||||
"metadata_extraction_timeout",
|
||||
"save_foreign_runs",
|
||||
},
|
||||
"integrations": {"lora_manager_enabled", "has_civitai_api_key"},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
+193
-1
@@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase):
|
||||
return counts
|
||||
|
||||
|
||||
class TestReceipts(unittest.TestCase):
|
||||
class ReceiptCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.context = ("j1", None)
|
||||
self.runs = CachedRuns(
|
||||
@@ -686,6 +686,8 @@ class TestReceipts(unittest.TestCase):
|
||||
def keys(self):
|
||||
return list(self.runs._receipts)
|
||||
|
||||
|
||||
class TestReceipts(ReceiptCase):
|
||||
def test_least_recently_used_run_keys_are_dropped(self):
|
||||
self.begin(1)
|
||||
for name in ("a", "b", "c"):
|
||||
@@ -726,6 +728,196 @@ class TestReceipts(unittest.TestCase):
|
||||
self.assertEqual(list(self.runs._attempt.nodes), ["b"])
|
||||
|
||||
|
||||
class TestRecordedFields(ReceiptCase):
|
||||
"""The texts a receipt keeps for the history capture of a cached job (#403)."""
|
||||
|
||||
F = (("raw a", " pre ", ""),)
|
||||
|
||||
def record(self, item_id, name="a", output="x", fields=F, facts=None):
|
||||
self.begin(item_id)
|
||||
self.runs.record_item(name, (output,), facts or Facts("h"), fields)
|
||||
self.runs.end_attempt(item_id)
|
||||
|
||||
def lookup(self, name="a", values=(("x",),), prompt_id="j9", item_id=9):
|
||||
self.begin(item_id, prompt_id)
|
||||
return self.runs.recorded_fields(prompt_id, name, values)
|
||||
|
||||
def test_a_cached_job_reads_the_fields_of_the_execution_it_reuses(self):
|
||||
self.record(1)
|
||||
self.assertEqual(self.lookup(), [self.F])
|
||||
|
||||
def test_list_items_return_their_own_fields_in_order(self):
|
||||
self.begin(1)
|
||||
for index, text in enumerate(("x", "y")):
|
||||
self.context = ("j1", index)
|
||||
self.runs.record_item("a", (text,), Facts("h"), ((text, "", ""),))
|
||||
self.runs.end_attempt(1)
|
||||
self.assertEqual(
|
||||
self.lookup(values=(("x", "y"),)), [(("x", "", ""),), (("y", "", ""),)]
|
||||
)
|
||||
|
||||
def test_fails_closed(self):
|
||||
self.record(1)
|
||||
for case, kwargs in (
|
||||
("other output", dict(values=(("changed",),))),
|
||||
("other node", dict(name="b")),
|
||||
("unknown node", dict(name="zzz")),
|
||||
("no values", dict(values=())),
|
||||
("malformed values", dict(values="x")),
|
||||
):
|
||||
with self.subTest(case=case):
|
||||
self.assertIsNone(self.lookup(**kwargs))
|
||||
with self.subTest(case="another job"):
|
||||
self.begin(9, "j9")
|
||||
self.assertIsNone(self.runs.recorded_fields("j8", "a", (("x",),)))
|
||||
with self.subTest(case="no job"):
|
||||
self.runs.end_attempt(9)
|
||||
self.assertIsNone(self.runs.recorded_fields("j9", "a", (("x",),)))
|
||||
|
||||
def test_an_execution_without_fields_gives_none(self):
|
||||
for fields in (None, (("a", "b"),), ((1, "", ""),), (), "abc"):
|
||||
with self.subTest(fields=fields):
|
||||
self.runs = CachedRuns(context=lambda: self.context)
|
||||
self.record(1, fields=fields)
|
||||
self.assertIsNone(self.lookup())
|
||||
|
||||
def test_a_lookup_never_counts_or_marks_the_node_replayed(self):
|
||||
self.record(1)
|
||||
self.lookup()
|
||||
self.assertFalse(self.runs.replayed("a"))
|
||||
|
||||
def test_equal_outputs_with_other_raw_text_conflict_and_still_count(self):
|
||||
key = prompt_node_key(self.g, "a")
|
||||
self.record(1, fields=(("x ", "", ""),))
|
||||
self.record(2, fields=(("x", "", ""),))
|
||||
# Counting dedupes by outputs as before; the fields cannot name either
|
||||
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
|
||||
self.assertIsNone(self.lookup())
|
||||
self.assertIsNotNone(self.runs._match(key, (("x",),)))
|
||||
# The conflict stays: a later execution cannot tell which text it was
|
||||
self.record(3, fields=(("x", "", ""),))
|
||||
self.assertIsNone(self.lookup())
|
||||
|
||||
def test_equal_outputs_with_other_facts_still_count_the_newest(self):
|
||||
key = prompt_node_key(self.g, "a")
|
||||
self.record(1, facts=Facts("old"), fields=(("a x", "", ""),))
|
||||
self.record(2, facts=Facts("new"), fields=(("x", "a ", ""),))
|
||||
items = self.runs._match(key, (("x",),))
|
||||
self.assertEqual([item.facts for item in items], [Facts("new")])
|
||||
|
||||
def test_equal_outputs_and_fields_still_replace(self):
|
||||
key = prompt_node_key(self.g, "a")
|
||||
self.record(1)
|
||||
self.record(2)
|
||||
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
|
||||
self.assertEqual(self.lookup(), [self.F])
|
||||
|
||||
def test_fields_dropped_by_the_budget_do_not_conflict(self):
|
||||
self.runs = CachedRuns(context=lambda: self.context, max_field_bytes=0)
|
||||
self.record(1)
|
||||
self.runs._max_field_bytes = 100
|
||||
self.record(2)
|
||||
self.assertEqual(self.lookup(), [self.F])
|
||||
|
||||
def test_oversized_or_invalid_fields_are_not_kept(self):
|
||||
for raw in ("x" * (module.MAX_FIELD_BYTES + 1), "\ud800"):
|
||||
with self.subTest(size=len(raw)):
|
||||
self.runs = CachedRuns(context=lambda: self.context)
|
||||
self.record(1, fields=((raw, "", ""),))
|
||||
self.assertIsNone(self.lookup())
|
||||
self.assertEqual(self.runs._field_bytes, 0)
|
||||
|
||||
def test_old_receipts_give_up_their_fields_to_new_ones(self):
|
||||
self.runs = CachedRuns(
|
||||
context=lambda: self.context, max_keys=3, max_field_bytes=10
|
||||
)
|
||||
self.record(1, "a", fields=(("123456", "", ""),))
|
||||
self.record(2, "b", fields=(("123456", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 6)
|
||||
self.assertIsNone(self.lookup("a"))
|
||||
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
|
||||
# "a" still counts
|
||||
self.assertIsNotNone(self.runs._match(prompt_node_key(self.g, "a"), (("x",),)))
|
||||
# Larger than the whole budget: not kept, nothing else given up
|
||||
self.record(3, "c", fields=(("12345678901", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 6)
|
||||
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
|
||||
|
||||
def test_one_receipt_holds_at_most_one_runs_history(self):
|
||||
with mock.patch.object(module, "MAX_RECEIPT_FIELD_BYTES", 8):
|
||||
self.begin(1)
|
||||
for index, raw in enumerate(("12345", "12345")):
|
||||
self.context = ("j1", index)
|
||||
self.runs.record_item("a", (raw,), Facts("h"), ((raw, "", ""),))
|
||||
self.runs.end_attempt(1)
|
||||
self.assertEqual(self.runs._field_bytes, 5)
|
||||
self.assertIsNone(self.lookup(values=(("12345", "12345"),)))
|
||||
|
||||
def test_the_byte_count_follows_replacement_and_eviction(self):
|
||||
self.begin(1)
|
||||
self.runs.record_item("a", ("x",), Facts("h"), (("12345", "", ""),))
|
||||
self.runs.record_item("a", ("x",), Facts("h"), (("123", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 3)
|
||||
self.runs.end_attempt(1)
|
||||
for item_id, raw in ((2, "1"), (3, "22"), (4, "333")):
|
||||
self.record(item_id, output=raw, fields=((raw, "", ""),))
|
||||
# max_receipts=2: only the receipts of jobs 4 and 3 remain
|
||||
self.assertEqual(self.runs._field_bytes, 5)
|
||||
for item_id, name in ((5, "b"), (6, "c")): # max_keys=2: "a" is dropped
|
||||
self.record(item_id, name, fields=(("1", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 2)
|
||||
self.assertEqual(
|
||||
self.runs._field_bytes,
|
||||
sum(module._bytes(r) for r in self.runs._receipts.values()),
|
||||
)
|
||||
|
||||
|
||||
class TestCachedJobsRecordFields(JobCase):
|
||||
"""The real nodes keep the texts they gave the history capture (#403)."""
|
||||
|
||||
def lookup(self, g, node_id="pm", prompt_id="job-x"):
|
||||
self.item_id += 1
|
||||
self.runs.begin_attempt(
|
||||
((self.item_id, prompt_id, g, {}, ["save"], {}), self.item_id)
|
||||
)
|
||||
values = cached_string_outputs(self.executor, node_id, g[node_id]["class_type"])
|
||||
found = self.runs.recorded_fields(prompt_id, node_id, values)
|
||||
self.runs.end_attempt(self.item_id)
|
||||
return found
|
||||
|
||||
def test_each_node_kind_keeps_its_runtime_fields(self):
|
||||
for kind in (
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerSplit",
|
||||
"PromptManagerText",
|
||||
):
|
||||
with self.subTest(kind=kind):
|
||||
g = graph(kind)
|
||||
self.first(g, text=f" {LLM} ")
|
||||
self.assertEqual(
|
||||
self.lookup(graph(kind, seed=2)), [((f" {LLM} ", "", ""),)]
|
||||
)
|
||||
|
||||
def test_pair_keeps_positive_and_negative_fields(self):
|
||||
g = graph("PromptManagerPair", negative_text="typed negative")
|
||||
self.first(g, negative="typed negative")
|
||||
self.assertEqual(
|
||||
self.lookup(
|
||||
graph("PromptManagerPair", negative_text="typed negative", seed=2)
|
||||
),
|
||||
[((LLM, "", ""), ("typed negative", "", ""))],
|
||||
)
|
||||
|
||||
def test_a_disabled_node_keeps_no_fields(self):
|
||||
g = graph()
|
||||
g["pm"]["inputs"]["save_to_library"] = False
|
||||
self.first(g)
|
||||
g2 = graph(seed=2)
|
||||
g2["pm"]["inputs"]["save_to_library"] = False
|
||||
self.assertIsNone(self.lookup(g2))
|
||||
|
||||
|
||||
class TestCacheReader(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patcher = mock.patch.dict(sys.modules, host_modules())
|
||||
|
||||
@@ -59,6 +59,28 @@ class TestGalleryConfig(unittest.TestCase):
|
||||
self.assertFalse(GalleryConfig.MONITORING_ENABLED)
|
||||
self.assertEqual(GalleryConfig.PROCESSING_DELAY, 5.0)
|
||||
|
||||
def test_save_foreign_runs_defaults_on_and_only_booleans_apply(self):
|
||||
"""#247: gallery.monitoring.save_foreign_runs, default true."""
|
||||
self.assertIs(
|
||||
GalleryConfig.get_config()["monitoring"]["save_foreign_runs"], True
|
||||
)
|
||||
GalleryConfig.update_config({"monitoring": {"save_foreign_runs": False}})
|
||||
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
|
||||
for junk in ("true", 1, None):
|
||||
with self.subTest(value=junk):
|
||||
with self.assertLogs("prompt_manager.config", "WARNING") as logs:
|
||||
GalleryConfig.update_config(
|
||||
{"monitoring": {"save_foreign_runs": junk}}
|
||||
)
|
||||
self.assertIn(
|
||||
"Ignoring gallery monitoring save_foreign_runs: expected true "
|
||||
"or false",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
|
||||
GalleryConfig.update_config({"monitoring": {"save_foreign_runs": True}})
|
||||
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, True)
|
||||
|
||||
def test_update_tracking(self):
|
||||
GalleryConfig.update_config({"tracking": {"prompt_timeout": 300}})
|
||||
self.assertEqual(GalleryConfig.PROMPT_TIMEOUT, 300)
|
||||
|
||||
@@ -100,14 +100,19 @@ class HostTests(unittest.TestCase):
|
||||
self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache())
|
||||
|
||||
def begin(
|
||||
self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post "
|
||||
self,
|
||||
kind="PromptManager",
|
||||
raw=" raw ",
|
||||
prepend=" pre ",
|
||||
append=" post ",
|
||||
negative="negative",
|
||||
):
|
||||
g = {
|
||||
"p": {
|
||||
"class_type": kind,
|
||||
"inputs": {
|
||||
"text": raw,
|
||||
"negative_text": "negative",
|
||||
"negative_text": negative,
|
||||
"prepend_text": prepend,
|
||||
"append_text": append,
|
||||
},
|
||||
@@ -125,7 +130,7 @@ class HostTests(unittest.TestCase):
|
||||
},
|
||||
},
|
||||
}
|
||||
if type(raw) is list or type(prepend) is list:
|
||||
if any(type(value) is list for value in (raw, prepend, negative)):
|
||||
g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}}
|
||||
g["stateful"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
@@ -445,3 +450,138 @@ class HostTests(unittest.TestCase):
|
||||
ref = install_and_remove()
|
||||
gc.collect()
|
||||
self.assertIsNone(ref())
|
||||
|
||||
# -- #403: a cached runtime text takes its fields from the reused execution
|
||||
|
||||
class Receipts:
|
||||
"""A fake ``CachedRuns.recorded_fields``: fixed fields, calls recorded."""
|
||||
|
||||
def __init__(self, fields):
|
||||
self.fields, self.calls = fields, []
|
||||
|
||||
def recorded_fields(self, prompt_id, node_id, values):
|
||||
self.calls.append((prompt_id, node_id, values))
|
||||
return self.fields
|
||||
|
||||
def observe_with(self, receipts, outputs, **begin):
|
||||
self.begin(**begin)
|
||||
self.executor.caches.outputs.entries["p"] = Entry(None, outputs)
|
||||
self.observe("execution_start")
|
||||
host.observe_cached_context(
|
||||
self.executor,
|
||||
"execution_cached",
|
||||
{"prompt_id": JOB, "nodes": ["p"]},
|
||||
receipts=receipts,
|
||||
)
|
||||
return self.seal()
|
||||
|
||||
def test_cached_runtime_raw_comes_from_the_matching_receipt(self):
|
||||
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
|
||||
result = self.observe_with(
|
||||
receipts, [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertEqual(receipts.calls, [(JOB, "p", (("cached",),))])
|
||||
self.assertEqual(result.raw.value, "LLM raw")
|
||||
self.assertEqual(
|
||||
(result.raw.provenance, result.raw.source), ("inferred", "graph_semantics")
|
||||
)
|
||||
self.assertEqual(result.effective.value, "cached")
|
||||
self.assertEqual(result.effective.source, "host_cache_output")
|
||||
# Fields the graph already knows are never replaced
|
||||
self.assertEqual(result.prepend.value, " pre ")
|
||||
self.assertEqual(result.prepend.source, "graph_literal")
|
||||
self.assertEqual(result.append.value, " post ")
|
||||
|
||||
def test_cached_runtime_prepend_comes_from_the_matching_receipt(self):
|
||||
receipts = self.Receipts([((" raw ", "LLM pre", " post "),)])
|
||||
result = self.observe_with(
|
||||
receipts, [object(), ["cached"]], prepend=["dynamic", 0]
|
||||
).positive
|
||||
self.assertEqual(result.prepend.value, "LLM pre")
|
||||
self.assertEqual(result.prepend.source, "graph_semantics")
|
||||
self.assertEqual(result.raw.value, " raw ")
|
||||
self.assertEqual(result.raw.source, "graph_literal")
|
||||
|
||||
def test_a_receipt_disagreeing_with_a_known_field_fills_nothing(self):
|
||||
receipts = self.Receipts([(("LLM raw", "other pre", " post "),)])
|
||||
result = self.observe_with(
|
||||
receipts, [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertIsNone(result.raw.value)
|
||||
self.assertEqual(result.prepend.value, " pre ")
|
||||
|
||||
def test_pair_runtime_negative_takes_the_negative_fields(self):
|
||||
receipts = self.Receipts(
|
||||
[((" raw ", " pre ", " post "), ("LLM negative", "", ""))]
|
||||
)
|
||||
result = self.observe_with(
|
||||
receipts,
|
||||
[object(), object(), ["cached positive"], ["LLM negative"]],
|
||||
kind="PromptManagerPair",
|
||||
negative=["dynamic", 0],
|
||||
)
|
||||
self.assertEqual(result.negative.raw.value, "LLM negative")
|
||||
self.assertEqual(result.negative.raw.source, "graph_semantics")
|
||||
self.assertEqual(result.positive.raw.value, " raw ")
|
||||
self.assertEqual(result.positive.raw.source, "graph_literal")
|
||||
|
||||
def test_known_typed_fields_are_never_replaced_by_a_receipt(self):
|
||||
receipts = self.Receipts([(("other", "other", "other"),)])
|
||||
result = self.observe_with(receipts, [object(), ["cached"]]).positive
|
||||
self.assertEqual(
|
||||
(result.raw.value, result.prepend.value, result.append.value),
|
||||
(" raw ", " pre ", " post "),
|
||||
)
|
||||
self.assertEqual(result.raw.source, "graph_literal")
|
||||
|
||||
def test_no_matching_receipt_keeps_the_cached_raw_unknown(self):
|
||||
for fields in (None, [None], [], "bad", [(("a", "b"),)], [((1, "", ""),)]):
|
||||
with self.subTest(fields=fields):
|
||||
result = self.observe_with(
|
||||
self.Receipts(fields), [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertIsNone(result.raw.value)
|
||||
self.assertEqual(result.effective.value, "cached")
|
||||
|
||||
def test_a_failing_receipt_lookup_keeps_the_capture(self):
|
||||
class Broken:
|
||||
def recorded_fields(self, *args):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
result = self.observe_with(
|
||||
Broken(), [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertIsNone(result.raw.value)
|
||||
self.assertEqual(result.effective.value, "cached")
|
||||
|
||||
def test_pair_slots_take_their_own_receipt_fields(self):
|
||||
receipts = self.Receipts(
|
||||
[(("LLM positive", " pre ", " post "), ("LLM negative", "", ""))]
|
||||
)
|
||||
result = self.observe_with(
|
||||
receipts,
|
||||
[object(), object(), ["cached positive"], ["cached negative"]],
|
||||
kind="PromptManagerPair",
|
||||
raw=["dynamic", 0],
|
||||
)
|
||||
self.assertEqual(
|
||||
receipts.calls[0], (JOB, "p", (("cached positive",), ("cached negative",)))
|
||||
)
|
||||
self.assertEqual(result.positive.raw.value, "LLM positive")
|
||||
self.assertEqual(result.positive.effective.value, "cached positive")
|
||||
# The negative editor is typed: the graph's value stays
|
||||
self.assertEqual(result.negative.raw.value, "negative")
|
||||
self.assertEqual(result.negative.raw.source, "graph_literal")
|
||||
|
||||
def test_default_receipts_are_the_process_cached_runs(self):
|
||||
from utils import cached_runs
|
||||
|
||||
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
|
||||
with patch.object(cached_runs, "CACHED_RUNS", receipts):
|
||||
self.begin(raw=["dynamic", 0])
|
||||
self.executor.caches.outputs.entries["p"] = Entry(
|
||||
None, [object(), ["cached"]]
|
||||
)
|
||||
self.observe("execution_start")
|
||||
self.observe("execution_cached", nodes=["p"])
|
||||
self.assertEqual(self.seal().positive.raw.value, "LLM raw")
|
||||
|
||||
@@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB):
|
||||
return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})])
|
||||
|
||||
|
||||
class HistoryHooksTests(unittest.TestCase):
|
||||
class HooksCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
@@ -122,6 +122,8 @@ class HistoryHooksTests(unittest.TestCase):
|
||||
def drain(self, registration):
|
||||
drain_writer(registration.writer, clock=time.monotonic)
|
||||
|
||||
|
||||
class HistoryHooksTests(HooksCase):
|
||||
def test_package_history_initializes_when_monitor_and_usage_setup_fail(self):
|
||||
try:
|
||||
from tests.test_init_package import (
|
||||
@@ -767,3 +769,150 @@ class HistoryHooksTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
calls, ["execution_start", "execution_cached", "execution_cached"]
|
||||
)
|
||||
|
||||
|
||||
class CachedRuntimeHistoryTests(HooksCase):
|
||||
"""A cached runtime-linked prompt is a tracked run, like an executed one (#403).
|
||||
|
||||
The real history hook and writer, a real ``CachedRuns`` and the real host
|
||||
adapter over fakes of ComfyUI's executor, cache and executing context.
|
||||
"""
|
||||
|
||||
TEXT = "a lighthouse the LLM wrote"
|
||||
GRAPH = {
|
||||
"str": {"class_type": "PrimitiveString", "inputs": {"value": "seed"}},
|
||||
"llm": {"class_type": "PreviewAny", "inputs": {"source": ["str", 0]}},
|
||||
"p": {"class_type": "PromptManager", "inputs": {"text": ["llm", 0]}},
|
||||
"s": {"class_type": "KSampler", "inputs": {"positive": ["p", 0]}},
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from utils import cached_runs
|
||||
from utils.cached_runs import CachedRuns
|
||||
|
||||
self.context = None
|
||||
self.runs = CachedRuns(context=lambda: self.context or (None, None))
|
||||
self.cache_entries = {}
|
||||
entries = self.cache_entries
|
||||
execution = types.ModuleType("execution")
|
||||
caching = types.ModuleType("comfy_execution.caching")
|
||||
package = types.ModuleType("comfy_execution")
|
||||
utils_module = types.ModuleType("comfy_execution.utils")
|
||||
entry_type = namedtuple("CacheEntry", "ui outputs")
|
||||
|
||||
class PromptExecutor:
|
||||
def add_message(self, event, data, broadcast):
|
||||
return None
|
||||
|
||||
class HierarchicalCache:
|
||||
def get_local(self, node_id):
|
||||
return entries.get(node_id)
|
||||
|
||||
execution.PromptExecutor, execution.CacheEntry = PromptExecutor, entry_type
|
||||
caching.HierarchicalCache = HierarchicalCache
|
||||
package.caching, package.utils = caching, utils_module
|
||||
utils_module.get_executing_context = lambda: (
|
||||
SimpleNamespace(prompt_id=self.context[0], node_id="p", list_index=0)
|
||||
if self.context
|
||||
else None
|
||||
)
|
||||
self.entry_type = entry_type
|
||||
modules = patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"execution": execution,
|
||||
"comfy_execution": package,
|
||||
"comfy_execution.caching": caching,
|
||||
"comfy_execution.utils": utils_module,
|
||||
},
|
||||
)
|
||||
modules.start()
|
||||
self.addCleanup(modules.stop)
|
||||
swap = patch.object(cached_runs, "CACHED_RUNS", self.runs)
|
||||
swap.start()
|
||||
self.addCleanup(swap.stop)
|
||||
self.executor = PromptExecutor()
|
||||
self.executor.server = self.server
|
||||
self.executor.caches = SimpleNamespace(outputs=HierarchicalCache())
|
||||
self.prompt_id = self.db.save_prompt(text=self.TEXT)
|
||||
self.item_id = 0
|
||||
|
||||
def job(self, cached):
|
||||
"""One job; the node executes unless ``cached`` (ComfyUI's cache serves it)."""
|
||||
from uuid import uuid4
|
||||
|
||||
from utils.cached_runs import stored_facts
|
||||
from utils.execution_context_capture import record_runtime_context
|
||||
|
||||
self.item_id += 1
|
||||
job = str(uuid4())
|
||||
result = ((0, job, self.GRAPH, {}, ["s"]), self.item_id)
|
||||
self.queue.results.append(result)
|
||||
self.queue.get()
|
||||
self.runs.begin_attempt(result)
|
||||
self.context = (job, 0)
|
||||
self.executor.add_message("execution_start", {"prompt_id": job}, False)
|
||||
if cached:
|
||||
self.executor.add_message(
|
||||
"execution_cached", {"prompt_id": job, "nodes": ["p"]}, False
|
||||
)
|
||||
else:
|
||||
# What PromptManager.encode_prompt records for this text
|
||||
record_runtime_context("PromptManager", self.TEXT, "", "", self.TEXT)
|
||||
self.runs.record_item(
|
||||
"p", (self.TEXT,), stored_facts(self.TEXT), ((self.TEXT, "", ""),)
|
||||
)
|
||||
self.cache_entries["p"] = self.entry_type(None, [object(), [self.TEXT]])
|
||||
self.context = None
|
||||
self.queue.task_done(self.item_id, {}, status(job=job))
|
||||
self.runs.end_attempt(self.item_id)
|
||||
|
||||
def tracked_runs(self):
|
||||
with self.db.recorded_history_snapshot():
|
||||
return self.db.list_history_window(self.prompt_id)["recorded_run_count"]
|
||||
|
||||
def test_three_jobs_of_runtime_text_are_three_tracked_runs(self):
|
||||
registration = self.register()
|
||||
for cached in (False, True, True):
|
||||
self.job(cached)
|
||||
self.drain(registration)
|
||||
self.assertEqual(self.tracked_runs(), 3)
|
||||
rows = self.rows()
|
||||
for row in rows:
|
||||
contexts = self.db.get_execution_contexts(row["run_id"])["contexts"]
|
||||
self.assertEqual(contexts[0]["positive"]["raw"], self.TEXT)
|
||||
self.assertEqual(contexts[0]["positive_prompt_id"], self.prompt_id)
|
||||
cached = self.db.get_execution_contexts(rows[1]["run_id"])["contexts"][0]
|
||||
self.assertEqual(
|
||||
cached["evidence"]["positive"]["raw"],
|
||||
{"provenance": "inferred", "source": "graph_semantics", "reason": None},
|
||||
)
|
||||
self.assertEqual(
|
||||
cached["evidence"]["positive"]["effective"]["source"], "host_cache_output"
|
||||
)
|
||||
# The stored history passes the whole-database check older builds run too
|
||||
from database.execution_history_schema import history_schema_error
|
||||
|
||||
self.assertIsNone(history_schema_error(self.db.model.get_connection()))
|
||||
# An image of a cached job can now be attributed through its run (#342)
|
||||
from database.run_attribution import named_prompt
|
||||
|
||||
self.assertEqual(
|
||||
[named_prompt(self.db, row["run_id"]) for row in rows],
|
||||
[self.prompt_id] * 3,
|
||||
)
|
||||
|
||||
def test_without_a_receipt_a_cached_job_stays_untracked(self):
|
||||
registration = self.register()
|
||||
self.job(False)
|
||||
self.runs._receipts.clear() # as after a restart with a provider entry
|
||||
self.job(True)
|
||||
self.drain(registration)
|
||||
self.assertEqual(self.tracked_runs(), 1)
|
||||
cached = self.db.get_execution_contexts(self.rows()[1]["run_id"])["contexts"]
|
||||
self.assertIsNone(cached[0]["positive"]["raw"])
|
||||
self.assertIsNone(cached[0]["positive_prompt_id"])
|
||||
from database.run_attribution import named_prompt
|
||||
|
||||
self.assertIsNone(named_prompt(self.db, self.rows()[1]["run_id"]))
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""#247 PR 2 x #90: the monitor's foreign path never stores switched-off text.
|
||||
|
||||
A file whose run's prompt has Save to library off is the opted-out path's,
|
||||
not the foreign path's; a foreign graph whose ladder decision is
|
||||
``not_saved`` (a switched-off node the #90 walk does not reach) stores
|
||||
nothing at settle, at the re-check after the seal, or from recorded history,
|
||||
and leaves no wait behind.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
from tests import test_run_attribution as attribution
|
||||
from tests import test_save_to_library as save_tests
|
||||
from tests.test_pair_scan import sampled_graph
|
||||
from utils.foreign_runs import ForeignRecheck
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from py.config import GalleryConfig # after tests: it stubs the host
|
||||
|
||||
LOGGER = "prompt_manager.image_monitor"
|
||||
OFF_TEXT = "SECRET OFF TEXT"
|
||||
SKIPPED = "Save to library is off for this run's prompt, skipping image"
|
||||
|
||||
# The class dict, not the class: a TestCase bound here would run again
|
||||
Fixture = vars(attribution.TestMonitorForeignRuns)
|
||||
|
||||
|
||||
def off_behind(middle="MyNode", key="value"):
|
||||
"""A switched-off PromptManagerText reaching a core CLIPTextEncode through
|
||||
``middle``'s ``key`` input: the #90 walk does not follow ``key``."""
|
||||
graph = save_tests.run_graph()
|
||||
graph["1"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": 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
|
||||
|
||||
|
||||
def off_text_unwired():
|
||||
"""Only history can decide; the legacy answer is a dangling switched-off
|
||||
node's own text (the ladder says ``not_saved`` once it may ask history)."""
|
||||
graph = sampled_graph("unused", "blurry")
|
||||
graph["1"]["inputs"]["text"] = ["30", 0]
|
||||
graph["30"] = {"class_type": "VendorPromptMaker", "inputs": {"prompt": OFF_TEXT}}
|
||||
graph["40"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": OFF_TEXT, "save_to_library": False},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
class TestForeignPathKeepsSaveToLibrary(attribution.RecordedRuns, unittest.TestCase):
|
||||
setUp = Fixture["setUp"]
|
||||
close_databases = Fixture["close_databases"]
|
||||
wait_for = Fixture["wait_for"]
|
||||
image = Fixture["image"]
|
||||
linked_text = Fixture["linked_text"]
|
||||
key = Fixture["key"]
|
||||
prompt_count = Fixture["prompt_count"]
|
||||
settled = Fixture["settled"]
|
||||
|
||||
def reference(self, path):
|
||||
return {
|
||||
"folder_type": "output",
|
||||
"subfolder": "dataset",
|
||||
"filename": os.path.basename(path),
|
||||
}
|
||||
|
||||
def seal(self, path, positives=(), nodes=("9",)):
|
||||
"""Record one run reporting ``path`` (a new run id for every call)."""
|
||||
self.runs = getattr(self, "runs", -1) + 1
|
||||
self.record_run(
|
||||
self.db,
|
||||
self.output,
|
||||
[("dataset", os.path.basename(path))],
|
||||
list(positives),
|
||||
item=self.runs,
|
||||
nodes=list(nodes),
|
||||
)
|
||||
|
||||
def recheck(self, path):
|
||||
"""The re-check the seal schedules, run through the monitor's entry."""
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(path, ForeignRecheck(self.reference(path)))
|
||||
return "\n".join(logs.output)
|
||||
|
||||
def assert_nothing_stored(self, path):
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
self.assertEqual(self.prompt_count(), 0)
|
||||
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(OFF_TEXT)))
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
self.assertEqual(list(self.handler._foreign_due), [])
|
||||
|
||||
def test_q1_the_only_prompt_manager_node_off_is_opted_out_not_foreign(self):
|
||||
graph = save_tests.run_graph(save=False)
|
||||
# The switched-off positive is the only PromptManager node
|
||||
graph["2"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "blurry"}}
|
||||
sampler_less = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": OFF_TEXT, "save_to_library": False},
|
||||
}
|
||||
}
|
||||
for name, graph in (("sampled", graph), ("sampler-less", sampler_less)):
|
||||
with self.subTest(name=name):
|
||||
self.metadata = {"prompt": graph}
|
||||
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
|
||||
self.assertEqual(state, "opted_out")
|
||||
path = self.image(f"IMG_GEN_{name}_00001_.png")
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIn(SKIPPED, "\n".join(logs.output))
|
||||
self.seal(path)
|
||||
self.assertTrue(self.settled())
|
||||
self.assertIn(SKIPPED, self.recheck(path))
|
||||
self.assert_nothing_stored(path)
|
||||
|
||||
def test_q2_an_off_text_node_behind_a_pass_through_stores_nothing(self):
|
||||
for middle, key in (("MyNode", "value"), ("easy showAnything", "anything")):
|
||||
with self.subTest(middle=middle):
|
||||
self.metadata = {"prompt": off_behind(middle, key)}
|
||||
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
|
||||
self.assertEqual(state, "foreign")
|
||||
path = self.image(f"IMG_GEN_{key}_00001_.png")
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIn(SKIPPED, "\n".join(logs.output))
|
||||
self.assert_nothing_stored(path)
|
||||
self.seal(path)
|
||||
self.assertTrue(self.settled())
|
||||
self.assertIn(SKIPPED, self.recheck(path))
|
||||
self.assert_nothing_stored(path)
|
||||
|
||||
def test_q2_a_wired_switch_behind_a_pass_through_stores_nothing(self):
|
||||
graph = off_behind()
|
||||
graph["1"]["inputs"]["save_to_library"] = ["20", 0]
|
||||
graph["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
self.metadata = {"prompt": graph}
|
||||
path = self.image("IMG_GEN_wired_00001_.png")
|
||||
wired = "Save to library is wired from a node the image cannot show"
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIn(wired, "\n".join(logs.output))
|
||||
self.seal(path)
|
||||
self.assertTrue(self.settled())
|
||||
self.assertIn(wired, self.recheck(path))
|
||||
self.assert_nothing_stored(path)
|
||||
|
||||
def test_q3_an_undecided_not_saved_waits_for_the_record_then_skips(self):
|
||||
"""No record yet: a not_saved of the undecided step is not final (a
|
||||
recorded saver may resolve another branch), so the file waits for its
|
||||
run; the decision with the record stores nothing and logs the skip."""
|
||||
wired = off_text_unwired()
|
||||
wired["40"]["inputs"]["save_to_library"] = ["20", 0]
|
||||
wired["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
cases = (
|
||||
("off", off_text_unwired(), SKIPPED),
|
||||
("wired", wired, "Save to library is wired from a node"),
|
||||
)
|
||||
for name, graph, line in cases:
|
||||
with self.subTest(name=name):
|
||||
self.metadata = {"prompt": graph}
|
||||
path = self.image(f"vendor_{name}_00001_.png")
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.seal(path)
|
||||
self.assertTrue(
|
||||
self.wait_for(lambda: not self.handler.awaiting_foreign())
|
||||
)
|
||||
self.assertTrue(self.settled())
|
||||
self.assertIn(line, "\n".join(logs.output))
|
||||
self.assert_nothing_stored(path)
|
||||
|
||||
def test_q3_an_early_not_saved_never_waits_even_with_a_saver_branch(self):
|
||||
"""Review: a switched-off node feeding one branch's positive is the
|
||||
ladder's early, final not_saved (PR 1's #90 rule covers every
|
||||
sampler's positive), so the file never waits and nothing is stored,
|
||||
not even the plain branch the recorded saver names."""
|
||||
graph = shapes.shared_prefix()
|
||||
graph["20"]["inputs"]["text"] = ["50", 0]
|
||||
graph["50"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": OFF_TEXT, "save_to_library": False},
|
||||
}
|
||||
self.metadata = {"prompt": graph}
|
||||
path = self.image("IMG_GEN_00001_.png")
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIn(SKIPPED, "\n".join(logs.output))
|
||||
self.assert_nothing_stored(path)
|
||||
self.seal(path, nodes=("13",))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertIn(SKIPPED, self.recheck(path))
|
||||
self.assert_nothing_stored(path)
|
||||
self.assertIsNone(
|
||||
self.db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[0]))
|
||||
)
|
||||
|
||||
def test_q3_an_off_node_in_the_recorded_saver_branch_is_never_foreign(self):
|
||||
"""PR 3's parity case: a switched-off node feeds the very branch the
|
||||
recorded saver names (saver "23" is branch 2). A literal switch is the
|
||||
opted-out path's; a wired one is the node's own run's (#90), never the
|
||||
foreign path's. Nothing is stored at settle or after the run, and no
|
||||
file waits for a foreign re-check."""
|
||||
cases = (
|
||||
("off", False, "opted_out", SKIPPED),
|
||||
("wired", ["52", 0], "unknown", "No certain prompt for"),
|
||||
)
|
||||
for name, save, state, line in cases:
|
||||
with self.subTest(name=name):
|
||||
graph = shapes.shared_prefix()
|
||||
graph["50"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": OFF_TEXT, "save_to_library": save},
|
||||
}
|
||||
graph["20"]["inputs"]["text"] = ["50", 0]
|
||||
if isinstance(save, list):
|
||||
graph["52"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
self.metadata = {"prompt": graph}
|
||||
found, _ = self.handler._find_prompt_from_metadata(self.metadata)
|
||||
self.assertEqual(found, state)
|
||||
path = self.image(f"IMG_GEN_{name}_00001_.png")
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIn(line, "\n".join(logs.output))
|
||||
self.assert_nothing_stored(path)
|
||||
self.seal(path, positives=[OFF_TEXT], nodes=("23",))
|
||||
self.assertTrue(self.settled())
|
||||
self.handler.process_new_image(path)
|
||||
self.assert_nothing_stored(path)
|
||||
self.assertIsNone(
|
||||
self.db.get_prompt_by_hash(
|
||||
generate_prompt_hash(shapes.BRANCH_TEXTS[1])
|
||||
)
|
||||
)
|
||||
|
||||
def test_q4_an_off_node_on_the_negative_only_still_links_the_positive(self):
|
||||
positive = "a lighthouse on a cliff at dusk"
|
||||
plain = sampled_graph(positive, "unused")
|
||||
plain["2"] = {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {"text": OFF_TEXT, "save_to_library": False},
|
||||
}
|
||||
behind = sampled_graph(positive, "unused")
|
||||
behind["2"]["inputs"]["text"] = ["7", 0]
|
||||
behind["7"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": OFF_TEXT, "save_to_library": False},
|
||||
}
|
||||
for name, graph in (("direct", plain), ("behind an encoder", behind)):
|
||||
with self.subTest(name=name):
|
||||
self.metadata = {"prompt": graph}
|
||||
state, _ = self.handler._find_prompt_from_metadata(self.metadata)
|
||||
self.assertEqual(state, "foreign")
|
||||
path = self.image(f"plain_{len(name)}_00001_.png")
|
||||
self.handler.process_new_image(path)
|
||||
self.assertEqual(self.linked_text(path), positive)
|
||||
self.assertIsNone(
|
||||
self.db.get_prompt_by_hash(generate_prompt_hash(OFF_TEXT))
|
||||
)
|
||||
pairs = self.db.list_prompt_pairs()["pairs"]
|
||||
self.assertEqual(pairs, [])
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
|
||||
def test_q5_recorded_history_never_brings_back_switched_off_text(self):
|
||||
"""Even a recorded run whose contexts hold the off text (an older
|
||||
history row) cannot name it, at settle or at the re-check."""
|
||||
cases = {"pass-through": off_behind(), "dangling": off_text_unwired()}
|
||||
for name, graph in cases.items():
|
||||
with self.subTest(name=name):
|
||||
self.metadata = {"prompt": graph}
|
||||
path = self.image(f"IMG_GEN_{len(name)}_00001_.png")
|
||||
self.seal(path, positives=[OFF_TEXT], nodes=("9",))
|
||||
self.handler.process_new_image(path)
|
||||
self.assert_nothing_stored(path)
|
||||
self.assertIn(SKIPPED, self.recheck(path))
|
||||
self.assert_nothing_stored(path)
|
||||
|
||||
def test_q6_with_foreign_saving_off_save_to_library_is_unchanged(self):
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS = False
|
||||
opted_out = save_tests.run_graph(save=False)
|
||||
self.metadata = {"prompt": opted_out}
|
||||
skipped = self.image("IMG_GEN_off_00001_.png")
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(skipped)
|
||||
self.assertIn(SKIPPED, "\n".join(logs.output))
|
||||
self.metadata = {"prompt": off_behind()}
|
||||
foreign = self.image("IMG_GEN_foreign_00001_.png")
|
||||
with self.assertLogs(LOGGER, "INFO") as logs:
|
||||
self.handler.process_new_image(foreign)
|
||||
self.assertIn("No PromptManager prompt in workflow", "\n".join(logs.output))
|
||||
for path in (skipped, foreign):
|
||||
self.assert_nothing_stored(path)
|
||||
# A switched-on PromptManager positive still links by its metadata
|
||||
stored = self.db.save_prompt(
|
||||
text=save_tests.POSITIVE,
|
||||
prompt_hash=generate_prompt_hash(save_tests.POSITIVE),
|
||||
)
|
||||
self.metadata = {"prompt": save_tests.run_graph(save=True)}
|
||||
on = self.image("IMG_GEN_on_00001_.png")
|
||||
self.handler.process_new_image(on)
|
||||
self.assertEqual(self.linked_text(on), save_tests.POSITIVE)
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_hash(generate_prompt_hash(save_tests.POSITIVE))["id"],
|
||||
stored,
|
||||
)
|
||||
|
||||
def test_the_shared_prefix_dataset_still_links_after_the_seal(self):
|
||||
"""Regression guard (passes before the fix): the wait still works."""
|
||||
self.metadata = {"prompt": shapes.shared_prefix()}
|
||||
path = self.image("IMG_GEN_00001_.png")
|
||||
self.handler.process_new_image(path)
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
|
||||
self.seal(path, nodes=("13",))
|
||||
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -172,17 +172,40 @@ class TestForeignWorkflows(LinkingTestCase):
|
||||
self.tracker.set_current_prompt(text, {"prompt_id": prompt_id})
|
||||
return prompt_id
|
||||
|
||||
def _linked_text(self):
|
||||
row = (
|
||||
self.db.model.get_connection()
|
||||
.execute(
|
||||
"SELECT p.text FROM generated_images g "
|
||||
"JOIN prompts p ON p.id = g.prompt_id WHERE g.image_path = ?",
|
||||
(self.image,),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
def test_foreign_image_leaves_the_batch_queue_aligned(self):
|
||||
"""#247: it links to its own prompt and never takes a queue entry."""
|
||||
first = self._queue_batch_item("item one")
|
||||
self.assertIsNone(self._process({"prompt": foreign_graph()}))
|
||||
self.assertEqual(self.linked, [])
|
||||
self.assertEqual(self.tracker.pop_next_prompt()["id"], first)
|
||||
self.assertIsNone(self.tracker.pop_next_prompt())
|
||||
self.assertEqual(self._linked_text(), "a cat")
|
||||
prompt = self.db.get_prompt_by_hash(generate_prompt_hash("a cat"))
|
||||
self.assertEqual(prompt["run_count"] or 0, 0)
|
||||
|
||||
def test_foreign_image_is_not_linked_by_the_later_fallbacks_either(self):
|
||||
def test_a_foreign_image_is_linked_only_by_the_ladder(self):
|
||||
self._queue_batch_item("item one") # live tracker and recent prompt exist
|
||||
untraceable = foreign_graph()
|
||||
untraceable["9"]["inputs"]["text"] = ["30", 0]
|
||||
untraceable["30"] = {"class_type": "VendorPromptMaker", "inputs": {}}
|
||||
self.assertIsNone(self._process({"prompt": untraceable}))
|
||||
self.assertEqual(self.linked, [])
|
||||
self.assertIsNone(self._linked_text())
|
||||
self.assertIsNone(self._process({"prompt": foreign_graph()}))
|
||||
self.assertEqual(self.linked, [])
|
||||
self.assertEqual(self._linked_text(), "a cat")
|
||||
|
||||
def test_negative_only_workflow_is_foreign(self):
|
||||
self._save("blurry")
|
||||
|
||||
@@ -597,6 +597,11 @@ class TestHandlerScheduling(unittest.TestCase):
|
||||
self.handler._schedule_recheck(path)
|
||||
self.assertIsNone(self.handler._worker)
|
||||
self.assertEqual(list(self.handler._history_due), [])
|
||||
# #247: a foreign file's sealed re-check is dropped the same way
|
||||
self.handler._await_foreign(path)
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
self.handler._schedule_recheck(path, {"filename": "late.png"})
|
||||
self.assertEqual(list(self.handler._foreign_due), [])
|
||||
self.assertEqual(self._live_workers() - before, set())
|
||||
|
||||
def test_a_sealed_run_starts_a_worker_on_an_open_handler(self):
|
||||
|
||||
@@ -99,7 +99,10 @@ class TestPairUsageTracking(unittest.TestCase):
|
||||
unique_id="1",
|
||||
)
|
||||
node.cached_runs.record_item.assert_called_once_with(
|
||||
"1", ("cat", ""), Facts(usage_tracking.generate_prompt_hash("cat"), None)
|
||||
"1",
|
||||
("cat", ""),
|
||||
Facts(usage_tracking.generate_prompt_hash("cat"), None),
|
||||
(("cat", "", ""), ("", "", "")),
|
||||
)
|
||||
|
||||
def test_validation_rejection_never_counts_or_saves(self):
|
||||
|
||||
@@ -308,7 +308,11 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
|
||||
"typed text", prepend_text="pre", prompt=self.GRAPH, unique_id="1"
|
||||
)
|
||||
node.cached_runs.record_item.assert_called_once_with(
|
||||
"1", ("pre typed text",), Facts(generate_prompt_hash("typed text"))
|
||||
"1",
|
||||
("pre typed text",),
|
||||
Facts(generate_prompt_hash("typed text")),
|
||||
# #403: the texts the history capture got, for a cached job's history
|
||||
(("typed text", "pre", ""),),
|
||||
)
|
||||
# Hook-visible text: the node counts nothing itself
|
||||
row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text"))
|
||||
@@ -319,7 +323,9 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
|
||||
|
||||
node = self._node()
|
||||
node.process_text(" ", prompt=self.GRAPH, unique_id="1")
|
||||
node.cached_runs.record_item.assert_called_once_with("1", (" ",), Facts(None))
|
||||
node.cached_runs.record_item.assert_called_once_with(
|
||||
"1", (" ",), Facts(None), ((" ", "", ""),)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -33,6 +33,7 @@ from utils.execution_outputs import (
|
||||
snapshot_output_roots,
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from py.config import GalleryConfig
|
||||
from utils.image_monitor import ImageGenerationHandler
|
||||
|
||||
STYLE = "windows" if os.name == "nt" else "posix"
|
||||
@@ -771,6 +772,470 @@ class TestMonitorHistoryRecheck(RecordedRuns, unittest.TestCase):
|
||||
run_attribution.notify_outputs_sealed("run", []) # no dead callback raises
|
||||
|
||||
|
||||
class TestMonitorForeignRuns(RecordedRuns, unittest.TestCase):
|
||||
"""#247: outputs of workflows without a PromptManager node, in the monitor."""
|
||||
|
||||
def setUp(self):
|
||||
history_tests.HistoryDatabaseTests.setUp(self)
|
||||
self.db = history_tests.HistoryDatabaseTests.database(self)
|
||||
self.output = str(Path(self.temp.name) / "output")
|
||||
os.makedirs(os.path.join(self.output, "dataset"))
|
||||
self.tracker = make_tracker(self.db)
|
||||
self.handler = ImageGenerationHandler(self.db, self.tracker)
|
||||
self.handler.processing_delay = 0
|
||||
self.handler.settle_interval = 0.01
|
||||
self.addCleanup(close_handler, self, self.handler)
|
||||
self.metadata = {"prompt": shapes.shared_prefix()}
|
||||
saved = GalleryConfig.SAVE_FOREIGN_RUNS
|
||||
self.addCleanup(setattr, GalleryConfig, "SAVE_FOREIGN_RUNS", saved)
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS = True
|
||||
patches = (
|
||||
mock.patch(
|
||||
"utils.image_monitor.host_output_directory", return_value=self.output
|
||||
),
|
||||
mock.patch(
|
||||
"database.run_attribution.host_output_directory",
|
||||
return_value=self.output,
|
||||
),
|
||||
mock.patch.object(
|
||||
self.handler.metadata_extractor,
|
||||
"extract_metadata",
|
||||
side_effect=lambda path: self.metadata,
|
||||
),
|
||||
)
|
||||
for patch in patches:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
close_databases = history_tests.HistoryDatabaseTests.close_databases
|
||||
wait_for = TestMonitorHistoryRecheck.wait_for
|
||||
|
||||
def image(self, name, subfolder="dataset"):
|
||||
path = os.path.join(self.output, subfolder, name)
|
||||
Path(path).write_bytes(b"png")
|
||||
return path
|
||||
|
||||
def linked_text(self, path):
|
||||
row = (
|
||||
self.db.model.get_connection()
|
||||
.execute(
|
||||
"SELECT p.text FROM generated_images g "
|
||||
"JOIN prompts p ON p.id = g.prompt_id WHERE g.image_path = ?",
|
||||
(str(path),),
|
||||
)
|
||||
.fetchone()
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
def key(self, path):
|
||||
return self.handler._awaiting_key(path)
|
||||
|
||||
def prompt_count(self):
|
||||
connection = self.db.model.get_connection()
|
||||
return connection.execute("SELECT COUNT(*) FROM prompts").fetchone()[0]
|
||||
|
||||
def settled(self):
|
||||
return self.wait_for(lambda: self.handler.pending_count() == 0)
|
||||
|
||||
def test_each_dataset_file_links_to_its_branch_once_the_run_ends(self):
|
||||
"""CUJ 1: unlinked while the run is open, its own branch after the seal."""
|
||||
paths = [self.image(f"IMG_GEN_0000{n}_.png") for n in (1, 2)]
|
||||
for path in paths:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertEqual([self.linked_text(path) for path in paths], [None, None])
|
||||
self.assertEqual(
|
||||
self.handler.awaiting_foreign(), [self.key(path) for path in paths]
|
||||
)
|
||||
self.assertEqual(self.handler.awaiting_history(), [])
|
||||
self.record_run(
|
||||
self.db,
|
||||
self.output,
|
||||
[("dataset", os.path.basename(path)) for path in paths],
|
||||
[],
|
||||
nodes=["13", "23"],
|
||||
)
|
||||
self.assertTrue(self.wait_for(lambda: all(map(self.linked_text, paths))))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertEqual(
|
||||
[self.linked_text(path) for path in paths], list(shapes.BRANCH_TEXTS[:2])
|
||||
)
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
prompt = self.db.get_prompt_by_hash(
|
||||
generate_prompt_hash(shapes.BRANCH_TEXTS[0])
|
||||
)
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
self.assertIn("Saved from a workflow run: IMG_GEN_00001_.png", prompt["notes"])
|
||||
|
||||
def test_a_run_sealed_before_the_file_settles_links_it_at_once(self):
|
||||
path = self.image("IMG_GEN_00003_.png")
|
||||
self.record_run(
|
||||
self.db, self.output, [("dataset", "IMG_GEN_00003_.png")], [], nodes=["33"]
|
||||
)
|
||||
self.handler.process_new_image(path)
|
||||
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[2])
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
|
||||
def test_a_name_two_runs_reported_stays_unlinked(self):
|
||||
"""CUJ 3: the file was overwritten; no run proves its saver."""
|
||||
path = self.image("IMG_GEN_00004_.png")
|
||||
reported = [("dataset", "IMG_GEN_00004_.png")]
|
||||
self.record_run(self.db, self.output, reported, [], nodes=["13"])
|
||||
self.record_run(self.db, self.output, reported, [], item=1, nodes=["23"])
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
reference = {"folder_type": "output", "subfolder": "dataset"}
|
||||
reference["filename"] = "IMG_GEN_00004_.png"
|
||||
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
|
||||
self.handler._recheck_foreign(path, reference)
|
||||
self.assertIn(
|
||||
"No certain prompt for IMG_GEN_00004_.png after its run ended; "
|
||||
"left unlinked",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
self.assertEqual(self.prompt_count(), 0)
|
||||
|
||||
def test_the_setting_off_stores_nothing_at_settle_or_after_the_run(self):
|
||||
"""CUJ 4: read for every file, so it applies without a restart."""
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS = False
|
||||
skipped = self.image("IMG_GEN_00005_.png")
|
||||
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
|
||||
self.handler.process_new_image(skipped)
|
||||
self.assertIn(
|
||||
"No PromptManager prompt in workflow, skipping image",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS = True
|
||||
waiting = self.image("IMG_GEN_00006_.png")
|
||||
self.handler.process_new_image(waiting)
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [self.key(waiting)])
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS = False
|
||||
self.record_run(
|
||||
self.db, self.output, [("dataset", "IMG_GEN_00006_.png")], [], nodes=["13"]
|
||||
)
|
||||
self.assertTrue(self.wait_for(lambda: not self.handler.awaiting_foreign()))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertEqual(
|
||||
[self.linked_text(p) for p in (skipped, waiting)], [None, None]
|
||||
)
|
||||
self.assertEqual(self.prompt_count(), 0)
|
||||
|
||||
def test_a_plain_foreign_workflow_links_at_settle_and_counts_no_run(self):
|
||||
"""CUJ 2: the default profile decides; the tracker queue is untouched."""
|
||||
text = "a lighthouse on a cliff at dusk"
|
||||
self.metadata = {"prompt": sampled_graph(text, "blurry")}
|
||||
queued = self.db.save_prompt(
|
||||
text="queued item", prompt_hash=generate_prompt_hash("queued item")
|
||||
)
|
||||
self.tracker.set_current_prompt("queued item", {"prompt_id": queued})
|
||||
first, second = self.image("plain_00001_.png"), self.image("plain_00002_.png")
|
||||
self.handler.process_new_image(first)
|
||||
self.handler.process_new_image(second)
|
||||
self.assertEqual(
|
||||
[self.linked_text(first), self.linked_text(second)], [text, text]
|
||||
)
|
||||
prompt = self.db.get_prompt_by_hash(generate_prompt_hash(text))
|
||||
self.assertEqual(prompt["run_count"] or 0, 0)
|
||||
self.assertIsNone(prompt.get("last_used_at"))
|
||||
self.assertEqual(self.tracker.pop_next_prompt()["id"], queued)
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
|
||||
def test_the_sealed_reference_spells_the_file_as_history_recorded_it(self):
|
||||
"""Windows: the watcher's spelling differs in case from the run's report."""
|
||||
Path(self.output, "Dataset").mkdir()
|
||||
recorded = self.image("IMG_GEN_00007_.png", subfolder="Dataset")
|
||||
seen = os.path.join(self.output, "dataset", "img_gen_00007_.png")
|
||||
Path(seen).write_bytes(b"png")
|
||||
folded = staticmethod(lambda path: os.path.realpath(path).lower())
|
||||
with mock.patch.object(ImageGenerationHandler, "_awaiting_key", folded):
|
||||
self.handler.process_new_image(seen)
|
||||
self.assertIsNone(self.linked_text(seen))
|
||||
self.record_run(
|
||||
self.db,
|
||||
self.output,
|
||||
[("Dataset", os.path.basename(recorded))],
|
||||
[],
|
||||
nodes=["43"],
|
||||
)
|
||||
self.assertTrue(self.wait_for(lambda: self.linked_text(seen)))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertEqual(self.linked_text(seen), shapes.BRANCH_TEXTS[3])
|
||||
|
||||
def test_a_full_foreign_set_never_evicts_a_waiting_keyless_file(self):
|
||||
from utils import image_monitor
|
||||
|
||||
keyless = self.image("bare_00001_.png", subfolder="")
|
||||
with mock.patch.object(
|
||||
self.handler.metadata_extractor, "extract_metadata", return_value=None
|
||||
):
|
||||
self.handler.process_new_image(keyless)
|
||||
paths = [self.image(f"IMG_GEN_1000{n}_.png") for n in range(3)]
|
||||
with (
|
||||
mock.patch.object(image_monitor, "MAX_AWAITING_HISTORY", 2),
|
||||
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
|
||||
):
|
||||
for path in paths:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertEqual(
|
||||
self.handler.awaiting_foreign(), [self.key(p) for p in paths[1:]]
|
||||
)
|
||||
self.assertEqual(self.handler.awaiting_history(), [self.key(keyless)])
|
||||
self.assertIn(
|
||||
"Too many files wait for their run; IMG_GEN_10000_.png left unlinked",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
|
||||
def test_an_expired_foreign_entry_is_logged_and_left_unlinked(self):
|
||||
from utils import image_monitor
|
||||
|
||||
path = self.image("IMG_GEN_00008_.png")
|
||||
self.handler.process_new_image(path)
|
||||
later = time.monotonic() + image_monitor.AWAIT_HISTORY_SECONDS + 1
|
||||
with (
|
||||
mock.patch.object(image_monitor.time, "monotonic", return_value=later),
|
||||
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
|
||||
):
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
self.assertIn(
|
||||
"No recorded run for IMG_GEN_00008_.png; left unlinked",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
|
||||
def test_a_seal_while_the_pending_set_is_full_is_rechecked_later(self):
|
||||
from utils import image_monitor
|
||||
|
||||
path = self.image("IMG_GEN_00009_.png")
|
||||
self.handler.process_new_image(path)
|
||||
blocker = self.image("blocker.png", subfolder="")
|
||||
release = threading.Event()
|
||||
original = self.handler.process_new_image
|
||||
|
||||
def slow(image_path, prompt_snapshot=None):
|
||||
if image_path == blocker:
|
||||
release.wait(5)
|
||||
return None
|
||||
return original(image_path, prompt_snapshot=prompt_snapshot)
|
||||
|
||||
self.handler.process_new_image = slow
|
||||
with mock.patch.object(image_monitor, "MAX_PENDING_IMAGES", 1):
|
||||
self.assertTrue(self.handler.schedule(blocker))
|
||||
self.assertTrue(self.wait_for(lambda: self.handler.pending_count() == 1))
|
||||
self.record_run(
|
||||
self.db,
|
||||
self.output,
|
||||
[("dataset", "IMG_GEN_00009_.png")],
|
||||
[],
|
||||
nodes=["13"],
|
||||
)
|
||||
self.assertEqual(list(self.handler._foreign_due), [path])
|
||||
release.set()
|
||||
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
|
||||
|
||||
def test_shutdown_forgets_waiting_and_due_foreign_files(self):
|
||||
path = self.image("IMG_GEN_00010_.png")
|
||||
self.handler.process_new_image(path)
|
||||
self.handler._foreign_due[path] = {"filename": "IMG_GEN_00010_.png"}
|
||||
self.handler.shutdown()
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
self.assertEqual(list(self.handler._foreign_due), [])
|
||||
|
||||
def link_count(self, path):
|
||||
connection = self.db.model.get_connection()
|
||||
return connection.execute(
|
||||
"SELECT COUNT(*) FROM generated_images WHERE image_path = ?", (str(path),)
|
||||
).fetchone()[0]
|
||||
|
||||
def test_a_file_linked_to_another_prompt_never_gets_a_second_link(self):
|
||||
"""One prompt per file: the pair path and the positive path both refuse."""
|
||||
other = self.db.save_prompt(
|
||||
"an earlier prompt", generate_prompt_hash("an earlier prompt")
|
||||
)
|
||||
plain = self.image("plain_00004_.png")
|
||||
self.db.link_image_to_prompt(other, plain)
|
||||
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
|
||||
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
|
||||
self.handler.process_new_image(plain)
|
||||
self.assertIn("Foreign run: conflict", "\n".join(logs.output))
|
||||
dataset = self.image("IMG_GEN_00011_.png")
|
||||
self.db.link_image_to_prompt(other, dataset)
|
||||
self.metadata = {"prompt": shapes.shared_prefix()}
|
||||
self.record_run(
|
||||
self.db, self.output, [("dataset", "IMG_GEN_00011_.png")], [], nodes=["13"]
|
||||
)
|
||||
self.handler.process_new_image(dataset)
|
||||
for path in (plain, dataset):
|
||||
self.assertEqual(self.linked_text(path), "an earlier prompt")
|
||||
self.assertEqual(self.link_count(path), 1)
|
||||
for text in ("a quiet harbor", shapes.BRANCH_TEXTS[0]):
|
||||
self.assertIsNone(self.db.get_prompt_by_hash(generate_prompt_hash(text)))
|
||||
|
||||
def test_the_file_size_is_read_only_when_the_ladder_needs_it(self):
|
||||
"""PR 1's lazy size: a foreign file decided without a store reads none."""
|
||||
# Foreign (a core encoder only) and no API graph: never, at step 3
|
||||
encoder = {"type": "CLIPTextEncode", "inputs": [], "widgets_values": ["x"]}
|
||||
self.metadata = {"text_encoder_nodes": [encoder]}
|
||||
path = self.image("bare_00002_.png", subfolder="")
|
||||
with mock.patch("utils.foreign_runs.os.path.getsize") as size:
|
||||
self.handler.process_new_image(path)
|
||||
size.assert_not_called()
|
||||
|
||||
def untraceable(self):
|
||||
"""Unresolved and only the legacy parser names a text (an unknown node)."""
|
||||
graph = sampled_graph("unused", "blurry")
|
||||
graph["1"]["inputs"]["text"] = ["30", 0]
|
||||
graph["30"] = {
|
||||
"class_type": "VendorPromptMaker",
|
||||
"inputs": {"prompt": "a misty valley at dawn"},
|
||||
}
|
||||
return {"prompt": graph}
|
||||
|
||||
def test_no_record_at_settle_keeps_the_file_waiting_for_the_legacy_floor(self):
|
||||
"""Review: a lookup that found no record is not the run's answer yet."""
|
||||
self.metadata = self.untraceable()
|
||||
path = self.image("vendor_00001_.png")
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [self.key(path)])
|
||||
self.record_run(self.db, self.output, [("dataset", "vendor_00001_.png")], [])
|
||||
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertEqual(self.linked_text(path), "a misty valley at dawn")
|
||||
|
||||
def test_a_record_found_at_settle_without_a_certain_prompt_ends_the_wait(self):
|
||||
"""Review: sealed before settle, still ambiguous: no seal is coming."""
|
||||
path = self.image("IMG_GEN_00012_.png")
|
||||
# One run reported it from two savers: a record, but no saver
|
||||
reported = [("dataset", "IMG_GEN_00012_.png")] * 2
|
||||
self.record_run(self.db, self.output, reported, [], nodes=["13", "23"])
|
||||
with self.assertLogs("prompt_manager.image_monitor", "INFO") as logs:
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIn(
|
||||
"No certain prompt for IMG_GEN_00012_.png; left unlinked",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
self.assertEqual(self.handler.awaiting_foreign(), [])
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
|
||||
def test_monitoring_saved_off_stops_foreign_writes_at_settle_and_recheck(self):
|
||||
"""Review: the observer runs until a restart; nothing is written."""
|
||||
enabled = GalleryConfig.MONITORING_ENABLED
|
||||
self.addCleanup(setattr, GalleryConfig, "MONITORING_ENABLED", enabled)
|
||||
GalleryConfig.MONITORING_ENABLED = False
|
||||
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
|
||||
plain = self.image("plain_00005_.png")
|
||||
self.handler.process_new_image(plain)
|
||||
self.assertIsNone(self.linked_text(plain))
|
||||
GalleryConfig.MONITORING_ENABLED = True
|
||||
self.metadata = {"prompt": shapes.shared_prefix()}
|
||||
waiting = self.image("IMG_GEN_00013_.png")
|
||||
self.handler.process_new_image(waiting)
|
||||
GalleryConfig.MONITORING_ENABLED = False
|
||||
reference = {"folder_type": "output", "subfolder": "dataset"}
|
||||
reference["filename"] = "IMG_GEN_00013_.png"
|
||||
self.record_run(
|
||||
self.db, self.output, [("dataset", reference["filename"])], [], nodes=["13"]
|
||||
)
|
||||
self.assertTrue(self.wait_for(lambda: not self.handler.awaiting_foreign()))
|
||||
self.assertTrue(self.settled())
|
||||
self.handler._recheck_foreign(waiting, reference)
|
||||
self.assertIsNone(self.linked_text(waiting))
|
||||
self.assertEqual(self.prompt_count(), 0)
|
||||
|
||||
def test_the_setting_is_read_again_just_before_the_store(self):
|
||||
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
|
||||
path = self.image("plain_00006_.png")
|
||||
real = __import__("utils.foreign_runs", fromlist=["x"]).decide_foreign
|
||||
|
||||
def decide_then_switch_off(*args, **kwargs):
|
||||
decision = real(*args, **kwargs)
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS = False
|
||||
return decision
|
||||
|
||||
with mock.patch(
|
||||
"utils.image_monitor.decide_foreign", side_effect=decide_then_switch_off
|
||||
):
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
self.assertEqual(self.prompt_count(), 0)
|
||||
|
||||
def test_a_seal_between_registering_and_the_settle_lookup_links_once(self):
|
||||
"""Regression guard (passes before the review fixes): one link only."""
|
||||
path = self.image("IMG_GEN_00014_.png")
|
||||
real = __import__("utils.foreign_runs", fromlist=["x"]).history_lookup
|
||||
|
||||
def sealed_first(db, image_path, reference=None):
|
||||
lookup = real(db, image_path, reference)
|
||||
|
||||
def run_then_look():
|
||||
if reference is None: # the settle lookup: the run seals now
|
||||
self.record_run(
|
||||
self.db,
|
||||
self.output,
|
||||
[("dataset", "IMG_GEN_00014_.png")],
|
||||
[],
|
||||
nodes=["13"],
|
||||
)
|
||||
return lookup()
|
||||
|
||||
return run_then_look
|
||||
|
||||
with mock.patch("utils.image_monitor.history_lookup", side_effect=sealed_first):
|
||||
self.assertTrue(self.handler.schedule(path))
|
||||
self.assertTrue(self.wait_for(lambda: self.linked_text(path)))
|
||||
self.assertTrue(self.settled())
|
||||
self.assertEqual(self.linked_text(path), shapes.BRANCH_TEXTS[0])
|
||||
self.assertEqual(self.link_count(path), 1)
|
||||
self.assertEqual(list(self.handler._foreign_due), [])
|
||||
|
||||
def test_a_drained_recheck_never_drops_a_newer_reference(self):
|
||||
path = self.image("IMG_GEN_00015_.png")
|
||||
older, newer = {"filename": "old"}, {"filename": "new"}
|
||||
self.handler._foreign_due[path] = older
|
||||
real = self.handler.schedule
|
||||
|
||||
def schedule(image_path, snapshot=None):
|
||||
# The run is sealed again while the older one is scheduled
|
||||
self.handler._foreign_due[image_path] = newer
|
||||
return real(image_path, snapshot)
|
||||
|
||||
self.handler.schedule = schedule
|
||||
with mock.patch.object(self.handler, "process_new_image"):
|
||||
self.handler._drain_history_due()
|
||||
self.assertIs(self.handler._foreign_due[path], newer)
|
||||
|
||||
def test_an_evicted_due_recheck_is_logged(self):
|
||||
from utils import image_monitor
|
||||
|
||||
self.handler.schedule = lambda *args, **kwargs: False
|
||||
with (
|
||||
mock.patch.object(image_monitor, "MAX_AWAITING_HISTORY", 1),
|
||||
self.assertLogs("prompt_manager.image_monitor", "INFO") as logs,
|
||||
):
|
||||
for name in ("a_00001_.png", "b_00001_.png"):
|
||||
self.handler._schedule_recheck(self.image(name), {"filename": name})
|
||||
self.assertIn(
|
||||
"Too many files wait for their run; a_00001_.png left unlinked",
|
||||
"\n".join(logs.output),
|
||||
)
|
||||
|
||||
def test_a_failed_store_is_logged_and_leaves_the_file_unlinked(self):
|
||||
self.metadata = {"prompt": sampled_graph("a quiet harbor", "blurry")}
|
||||
path = self.image("plain_00003_.png")
|
||||
with (
|
||||
mock.patch(
|
||||
"utils.foreign_runs.ingest_resolved_pair",
|
||||
side_effect=sqlite3.OperationalError("locked"),
|
||||
),
|
||||
self.assertLogs("prompt_manager.image_monitor", "ERROR"),
|
||||
):
|
||||
self.handler.process_new_image(path)
|
||||
self.assertIsNone(self.linked_text(path))
|
||||
|
||||
|
||||
class TestBoundedLookup(RecordedRuns, unittest.TestCase):
|
||||
"""M5: history lookups are batched, and the history schema is unchanged."""
|
||||
|
||||
|
||||
@@ -323,6 +323,43 @@ class TestMonitoringSetting(SourcesTestCase):
|
||||
self.assertIs(GalleryConfig.MONITORING_ENABLED, False)
|
||||
|
||||
|
||||
class TestSaveForeignRunsSetting(SourcesTestCase):
|
||||
"""#247 D-Q4: "Save prompts from other workflows", applied live."""
|
||||
|
||||
async def asyncSetUp(self):
|
||||
await super().asyncSetUp()
|
||||
saved = GalleryConfig.SAVE_FOREIGN_RUNS
|
||||
self.addCleanup(setattr, GalleryConfig, "SAVE_FOREIGN_RUNS", saved)
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS = True
|
||||
|
||||
async def test_read_and_write_published_live_without_a_restart(self):
|
||||
self.assertIs((await self._get())["settings"]["save_foreign_runs"], True)
|
||||
self._install_monitor(FakeMonitor([self.output_dir]))
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"save_foreign_runs": False}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertTrue(data["persisted"])
|
||||
self.assertEqual(data["restart_required_fields"], [])
|
||||
saved = self._read_config()
|
||||
self.assertIs(saved["gallery"]["monitoring"]["save_foreign_runs"], False)
|
||||
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, False)
|
||||
self.assertIs((await self._get())["settings"]["save_foreign_runs"], False)
|
||||
|
||||
async def test_only_true_or_false_is_accepted(self):
|
||||
self._write_config({"custom": {"keep": True}})
|
||||
before = self._config_bytes()
|
||||
for value in ("false", 0, None, []):
|
||||
with self.subTest(value=value):
|
||||
status, data = await self._post(
|
||||
"/prompt_manager/settings", {"save_foreign_runs": value}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertIn("save_foreign_runs must be true or false", data["error"])
|
||||
self.assertEqual(self._config_bytes(), before)
|
||||
self.assertIs(GalleryConfig.SAVE_FOREIGN_RUNS, True)
|
||||
|
||||
|
||||
class TestSourceChangeHold(SourcesTestCase):
|
||||
async def test_root_save_refused_while_scan_job_runs(self):
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.a)]
|
||||
|
||||
Vendored
+3
-3
@@ -36,14 +36,14 @@ test('execution script discovers only the new matrix and preserves all 78 origin
|
||||
// + the fresh-host library location case, + #342 image attribution, + #343 linked text,
|
||||
// + #332 image category routes on a generated output (image-attribution.spec.mjs, 2 projects),
|
||||
// + #347 cached runtime-linked runs: five node kinds and eight one-renderer cases (execution-text-link.spec.mjs, 2 projects),
|
||||
// + #90 Save to library off (five node types)
|
||||
assert.match(executions, /Total: 128 tests in 5 files/);
|
||||
// + #90 Save to library off (five node types), + #247 foreign workflows (image-attribution.spec.mjs, 2 tests x 2 projects)
|
||||
assert.match(executions, /Total: 132 tests in 5 files/);
|
||||
for (const project of ['classic-start', 'nodes2-start']) {
|
||||
const cases = executions.split('\n').filter(line => line.includes(`[${project}]`));
|
||||
assert.equal(cases.filter(line => line.includes('execution-history.spec.mjs')).length, 15);
|
||||
assert.equal(cases.filter(line => line.includes('execution-outputs.spec.mjs')).length, 8);
|
||||
assert.equal(cases.filter(line => line.includes('library-location.spec.mjs')).length, 1);
|
||||
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 3);
|
||||
assert.equal(cases.filter(line => line.includes('image-attribution.spec.mjs')).length, 5);
|
||||
assert.equal(cases.filter(line => line.includes('execution-text-link.spec.mjs')).length, 37);
|
||||
}
|
||||
assert.doesNotMatch(executions, /prompt-manager.spec/);
|
||||
|
||||
@@ -389,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,
|
||||
|
||||
+201
-28
@@ -15,9 +15,14 @@ role (positive, negative, which Pair outputs are used) and what the hook
|
||||
already saw are recomputed from that job's own graph, and both paths count
|
||||
through the same ``own_counts`` so they cannot drift.
|
||||
|
||||
The receipt also keeps the raw, prepend and append text the node gave the
|
||||
execution history (#403), so the history capture of a cached job can record
|
||||
the raw text the graph cannot show (``recorded_fields``) and the job becomes a
|
||||
tracked run of its prompt, as it is a counted one.
|
||||
|
||||
Fail closed: when the cached text cannot be matched to a receipt recorded in
|
||||
this process, nothing is counted (the behavior before #347). In memory only and
|
||||
bounded; starts no threads.
|
||||
this process, nothing is counted (the behavior before #347) and the history
|
||||
fields stay unknown. In memory only and bounded; starts no threads.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -39,6 +44,7 @@ try:
|
||||
from .queue_observers import subscribe_dequeue, subscribe_task_done
|
||||
from .run_keys import prompt_node_key, upstream_nodes
|
||||
from .usage_tracking import find_pair_id_by_hashes, survive_switch
|
||||
from .execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.logging_config import get_logger
|
||||
@@ -53,6 +59,7 @@ except ImportError:
|
||||
from utils.queue_observers import subscribe_dequeue, subscribe_task_done
|
||||
from utils.run_keys import prompt_node_key, upstream_nodes
|
||||
from utils.usage_tracking import find_pair_id_by_hashes, survive_switch
|
||||
from utils.execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
|
||||
|
||||
logger = get_logger("prompt_manager.cached_runs")
|
||||
|
||||
@@ -61,9 +68,15 @@ MAX_KEYS = 256 # run keys remembered (the same bound as MAX_KEYED_RUNS)
|
||||
MAX_RECEIPTS = 4 # receipts per run key: a recurring upstream value (A, B, A)
|
||||
MAX_ITEMS = 64 # list items per receipt (the cache reader's bound)
|
||||
MAX_CACHED_NODES = 500 # the execution_cached guard the history capture uses
|
||||
MAX_FIELD_TEXT_BYTES = 8 * 1024 * 1024 # history fields all receipts keep
|
||||
MAX_RECEIPT_FIELD_BYTES = MAX_ATTEMPT_BYTES # one receipt: what one run's history holds
|
||||
_EMPTY_HASH = generate_prompt_hash("")
|
||||
|
||||
Count = Tuple[Any, ...] # ("prompt", hash) or ("pair", positive, negative or None)
|
||||
# (raw, prepend, append) per STRING slot, as given to record_runtime_context
|
||||
Fields = Tuple[Tuple[str, str, str], ...]
|
||||
# Executions with equal outputs recorded different fields: none can be named
|
||||
_CONFLICT: Fields = ()
|
||||
|
||||
|
||||
class Facts(NamedTuple):
|
||||
@@ -96,6 +109,8 @@ class Seen(NamedTuple):
|
||||
class _Item(NamedTuple):
|
||||
outputs: Tuple[str, ...]
|
||||
facts: Facts
|
||||
fields: Optional[Fields] = None
|
||||
field_bytes: int = 0
|
||||
|
||||
|
||||
class _Receipt:
|
||||
@@ -108,6 +123,18 @@ class _Receipt:
|
||||
def outputs(self) -> Dict[int, Tuple[str, ...]]:
|
||||
return {index: item.outputs for index, item in self.items.items()}
|
||||
|
||||
def field_bytes(self) -> int:
|
||||
return sum(item.field_bytes for item in self.items.values())
|
||||
|
||||
def strip_fields(self) -> int:
|
||||
"""Drop the history fields (counting is unchanged); the bytes freed."""
|
||||
freed = 0
|
||||
for index, item in list(self.items.items()):
|
||||
if item.field_bytes:
|
||||
freed += item.field_bytes
|
||||
self.items[index] = item._replace(fields=None, field_bytes=0)
|
||||
return freed
|
||||
|
||||
|
||||
class _Attempt(NamedTuple):
|
||||
item_id: int
|
||||
@@ -117,6 +144,22 @@ class _Attempt(NamedTuple):
|
||||
replayed: set
|
||||
|
||||
|
||||
def _field_size(fields: Any, slots: int) -> Optional[int]:
|
||||
"""UTF-8 size of well-formed fields for ``slots`` outputs, else None."""
|
||||
if type(fields) is not tuple or len(fields) != slots:
|
||||
return None
|
||||
size = 0
|
||||
for slot in fields:
|
||||
if type(slot) is not tuple or len(slot) != 3:
|
||||
return None
|
||||
for value in slot:
|
||||
try:
|
||||
size += text_size(value, MAX_FIELD_BYTES) # the capture's own check
|
||||
except ValueError:
|
||||
return None
|
||||
return size
|
||||
|
||||
|
||||
def text_digest(value: str) -> str:
|
||||
"""Digest of one returned STRING value, exactly as returned."""
|
||||
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
|
||||
@@ -245,11 +288,14 @@ class CachedRuns:
|
||||
max_keys: int = MAX_KEYS,
|
||||
max_receipts: int = MAX_RECEIPTS,
|
||||
max_items: int = MAX_ITEMS,
|
||||
max_field_bytes: int = MAX_FIELD_TEXT_BYTES,
|
||||
context: Callable[[], Tuple[Optional[str], Optional[int]]] = _host_context,
|
||||
) -> None:
|
||||
self._max_keys = max_keys
|
||||
self._max_receipts = max_receipts
|
||||
self._max_items = max_items
|
||||
self._max_field_bytes = max_field_bytes
|
||||
self._field_bytes = 0 # UTF-8 bytes of the fields all receipts keep
|
||||
self._context = context
|
||||
self._receipts: "OrderedDict[str, List[_Receipt]]" = OrderedDict()
|
||||
self._attempt: Optional[_Attempt] = None
|
||||
@@ -284,7 +330,10 @@ class CachedRuns:
|
||||
self._attempt = None
|
||||
for run_key, receipts in self._receipts.items():
|
||||
if receipts and receipts[0].item_id == item_id:
|
||||
self._receipts[run_key] = _deduplicated(receipts)
|
||||
before = _bytes(receipts)
|
||||
deduplicated = _deduplicated(receipts)
|
||||
self._field_bytes += _bytes(deduplicated) - before
|
||||
self._receipts[run_key] = deduplicated
|
||||
|
||||
def _current(self) -> Tuple[Optional[_Attempt], Optional[int]]:
|
||||
"""The attempt the executing node belongs to, and its list index."""
|
||||
@@ -305,8 +354,19 @@ class CachedRuns:
|
||||
with self._lock:
|
||||
return str(node_id) in attempt.replayed
|
||||
|
||||
def record_item(self, node_id: Any, outputs: Tuple[Any, ...], facts: Facts) -> None:
|
||||
"""Remember what this execution returned and was entitled to count."""
|
||||
def record_item(
|
||||
self,
|
||||
node_id: Any,
|
||||
outputs: Tuple[Any, ...],
|
||||
facts: Facts,
|
||||
fields: Optional[Fields] = None,
|
||||
) -> None:
|
||||
"""Remember what this execution returned and was entitled to count.
|
||||
|
||||
``fields`` are the texts the node gave the execution history, one
|
||||
``(raw, prepend, append)`` per output; kept only when well formed,
|
||||
each within the history's field limit and the store within its budget.
|
||||
"""
|
||||
try:
|
||||
attempt, index = self._current()
|
||||
node = attempt.nodes.get(str(node_id)) if attempt else None
|
||||
@@ -317,14 +377,20 @@ class CachedRuns:
|
||||
return
|
||||
if any(not isinstance(value, str) for value in outputs):
|
||||
return
|
||||
item = _Item(tuple(text_digest(value) for value in outputs), facts)
|
||||
size = _field_size(fields, len(outputs)) if fields is not None else None
|
||||
item = _Item(
|
||||
tuple(text_digest(value) for value in outputs),
|
||||
facts,
|
||||
fields if size is not None else None,
|
||||
size or 0,
|
||||
)
|
||||
self._store(node[1], attempt.item_id, index, item)
|
||||
except Exception as error:
|
||||
logger.debug(f"Could not record a cached-run receipt: {error}")
|
||||
|
||||
def _store(self, run_key: str, item_id: int, index: int, item: _Item) -> None:
|
||||
with self._lock:
|
||||
receipts = self._receipts.pop(run_key, [])
|
||||
receipts = self._take(run_key)
|
||||
if receipts and receipts[0].item_id == item_id:
|
||||
receipt = receipts[0]
|
||||
else:
|
||||
@@ -334,9 +400,44 @@ class CachedRuns:
|
||||
receipt = _Receipt(item_id)
|
||||
receipts.insert(0, receipt)
|
||||
receipt.items[index] = item
|
||||
self._receipts[run_key] = receipts[: self._max_receipts]
|
||||
receipts = receipts[: self._max_receipts]
|
||||
if item.field_bytes and (
|
||||
receipt.field_bytes() > MAX_RECEIPT_FIELD_BYTES
|
||||
or not self._make_room(_bytes(receipts))
|
||||
):
|
||||
# Counting is as before; this job's history fields stay unknown
|
||||
receipt.items[index] = item._replace(fields=None, field_bytes=0)
|
||||
self._put(run_key, receipts)
|
||||
while len(self._receipts) > self._max_keys:
|
||||
self._receipts.popitem(last=False)
|
||||
_, dropped = self._receipts.popitem(last=False)
|
||||
self._field_bytes -= _bytes(dropped)
|
||||
|
||||
def _make_room(self, needed: int) -> bool:
|
||||
"""Free the fields of the least recently used receipts (under the lock).
|
||||
|
||||
Old receipts of workflows never run again must not hold the budget:
|
||||
their counting stays, only their history fields go. False when
|
||||
``needed`` does not fit even then.
|
||||
"""
|
||||
if needed > self._max_field_bytes:
|
||||
return False
|
||||
for receipts in self._receipts.values(): # least recently used first
|
||||
if self._field_bytes + needed <= self._max_field_bytes:
|
||||
break
|
||||
for receipt in receipts:
|
||||
self._field_bytes -= receipt.strip_fields()
|
||||
return self._field_bytes + needed <= self._max_field_bytes
|
||||
|
||||
def _take(self, run_key: str) -> List[_Receipt]:
|
||||
"""Remove a key's receipts and their bytes from the store (under the lock)."""
|
||||
receipts = self._receipts.pop(run_key, [])
|
||||
self._field_bytes -= _bytes(receipts)
|
||||
return list(receipts)
|
||||
|
||||
def _put(self, run_key: str, receipts: List[_Receipt]) -> None:
|
||||
"""Store a key's receipts as the most recently used (under the lock)."""
|
||||
self._receipts[run_key] = receipts
|
||||
self._field_bytes += _bytes(receipts)
|
||||
|
||||
# -- cached nodes -------------------------------------------------------
|
||||
|
||||
@@ -393,25 +494,11 @@ class CachedRuns:
|
||||
|
||||
def _match(self, run_key: str, values: Any) -> Optional[List[_Item]]:
|
||||
"""The items of the one receipt whose outputs equal ``values``, in order."""
|
||||
if not values:
|
||||
return None
|
||||
length = len(values[0])
|
||||
if length == 0 or length > self._max_items:
|
||||
return None
|
||||
if any(len(slot) != length for slot in values):
|
||||
return None
|
||||
wanted = {
|
||||
index: tuple(text_digest(slot[index]) for slot in values)
|
||||
for index in range(length)
|
||||
}
|
||||
with self._lock:
|
||||
receipts = self._receipts.get(run_key)
|
||||
if not receipts:
|
||||
return None
|
||||
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
|
||||
matches = self._matching(run_key, values)
|
||||
if not matches:
|
||||
return None
|
||||
self._receipts.move_to_end(run_key)
|
||||
length = len(values[0])
|
||||
facts = {
|
||||
tuple(receipt.items[index].facts for index in range(length))
|
||||
for receipt in matches
|
||||
@@ -420,13 +507,99 @@ class CachedRuns:
|
||||
return None
|
||||
return [matches[0].items[index] for index in range(length)]
|
||||
|
||||
def _matching(self, run_key: str, values: Any) -> List[_Receipt]:
|
||||
"""Receipts of ``run_key`` whose outputs equal ``values`` (under the lock)."""
|
||||
if type(values) not in (list, tuple) or not values:
|
||||
return []
|
||||
if any(type(slot) not in (list, tuple) for slot in values):
|
||||
return []
|
||||
length = len(values[0])
|
||||
if length == 0 or length > self._max_items:
|
||||
return []
|
||||
if any(len(slot) != length for slot in values):
|
||||
return []
|
||||
if any(type(value) is not str for slot in values for value in slot):
|
||||
return []
|
||||
wanted = {
|
||||
index: tuple(text_digest(slot[index]) for slot in values)
|
||||
for index in range(length)
|
||||
}
|
||||
receipts = self._receipts.get(run_key)
|
||||
if not receipts:
|
||||
return []
|
||||
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
|
||||
if matches:
|
||||
self._receipts.move_to_end(run_key)
|
||||
return matches
|
||||
|
||||
# -- the history capture (#403) -------------------------------------------
|
||||
|
||||
def recorded_fields(
|
||||
self, prompt_id: Any, node_id: Any, values: Any
|
||||
) -> Optional[List[Fields]]:
|
||||
"""The history fields of the execution a cached node reuses, per list item.
|
||||
|
||||
``values`` are the node's cached STRING outputs (``cached_string_outputs``).
|
||||
Only for a node of the job currently executing, and only when every
|
||||
receipt matching ``values`` kept the same fields; None otherwise. Never
|
||||
counts and never marks the node replayed.
|
||||
"""
|
||||
try:
|
||||
with self._lock:
|
||||
attempt = self._attempt
|
||||
if (
|
||||
attempt is None
|
||||
or type(prompt_id) is not str
|
||||
or prompt_id != attempt.prompt_id
|
||||
or type(node_id) is not str
|
||||
or node_id not in attempt.nodes
|
||||
):
|
||||
return None
|
||||
matches = self._matching(attempt.nodes[node_id][1], values)
|
||||
if not matches:
|
||||
return None
|
||||
found = []
|
||||
for index in range(len(values[0])):
|
||||
fields = {receipt.items[index].fields for receipt in matches}
|
||||
if len(fields) != 1 or None in fields or _CONFLICT in fields:
|
||||
return None
|
||||
found.append(fields.pop())
|
||||
return found
|
||||
except Exception as error:
|
||||
logger.debug(f"Could not read a cached run's history fields: {error}")
|
||||
return None
|
||||
|
||||
|
||||
def _deduplicated(receipts: List[_Receipt]) -> List[_Receipt]:
|
||||
"""The newest receipt replaces older ones with the same outputs."""
|
||||
"""The newest receipt replaces older ones with the same outputs.
|
||||
|
||||
Counting needs only the outputs. When a replaced receipt recorded other
|
||||
history fields for equal outputs (a raw text the outputs do not show),
|
||||
the newest item's fields become a conflict, so no cached job of it is
|
||||
given either text (#403). A conflict stays until the run key is dropped.
|
||||
"""
|
||||
if not receipts:
|
||||
return receipts
|
||||
outputs = receipts[0].outputs()
|
||||
return [receipts[0]] + [r for r in receipts[1:] if r.outputs() != outputs]
|
||||
newest, outputs = receipts[0], receipts[0].outputs()
|
||||
kept = [newest]
|
||||
for receipt in receipts[1:]:
|
||||
if receipt.outputs() != outputs:
|
||||
kept.append(receipt)
|
||||
continue
|
||||
for index, item in list(newest.items.items()):
|
||||
older = receipt.items[index].fields
|
||||
if older == _CONFLICT or (
|
||||
older is not None and item.fields is not None and older != item.fields
|
||||
):
|
||||
newest.items[index] = item._replace(fields=_CONFLICT, field_bytes=0)
|
||||
return kept
|
||||
|
||||
|
||||
def _bytes(receipts: List[_Receipt]) -> int:
|
||||
"""UTF-8 bytes of the history fields ``receipts`` keep."""
|
||||
return sum(
|
||||
item.field_bytes for receipt in receipts for item in receipt.items.values()
|
||||
)
|
||||
|
||||
|
||||
def _job_nodes(graph: Dict[str, Any], outputs: Any) -> Dict[str, Tuple[str, str]]:
|
||||
|
||||
@@ -151,10 +151,77 @@ def cached_string_outputs(
|
||||
return None
|
||||
|
||||
|
||||
def _receipt_fields(
|
||||
receipts: object, executor: object, job_id: str, source: object
|
||||
) -> list | None:
|
||||
"""Per list index, the ``(raw, prepend, append)`` of the execution reused (#403).
|
||||
|
||||
From the #347 receipt whose digests equal every STRING slot of the cache
|
||||
entry; None when there is none or the answer is malformed.
|
||||
"""
|
||||
try:
|
||||
if receipts is None:
|
||||
try:
|
||||
from .cached_runs import CACHED_RUNS as receipts
|
||||
except ImportError:
|
||||
from utils.cached_runs import CACHED_RUNS as receipts
|
||||
values = cached_string_outputs(executor, source.node_id, source.node_type)
|
||||
if values is None:
|
||||
return None
|
||||
found = receipts.recorded_fields(job_id, source.node_id, values)
|
||||
if type(found) is not list or len(found) != len(values[0]):
|
||||
return None
|
||||
position = (
|
||||
(1 if source.output_slot in (1, 3) else 0)
|
||||
if source.node_type == "PromptManagerPair"
|
||||
else 0
|
||||
)
|
||||
fields = []
|
||||
for slots in found:
|
||||
slot = slots[position] if type(slots) is tuple else None
|
||||
if not (
|
||||
type(slot) is tuple
|
||||
and len(slot) == 3
|
||||
and all(type(value) is str for value in slot)
|
||||
):
|
||||
return None
|
||||
fields.append(slot)
|
||||
return fields
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _agrees(inferred: PromptSnapshot, fields: tuple) -> bool:
|
||||
"""Every field the graph knows equals the receipt's; else fill nothing."""
|
||||
known = (inferred.raw, inferred.prepend, inferred.append)
|
||||
return all(
|
||||
field.value is None or field.value == value
|
||||
for field, value in zip(known, fields)
|
||||
)
|
||||
|
||||
|
||||
def _filled(inferred: TextEvidence, value: str) -> TextEvidence:
|
||||
"""A field the graph cannot show, taken from the reused execution (#403)."""
|
||||
if inferred.value is not None:
|
||||
return inferred
|
||||
return TextEvidence(value, "inferred", "graph_semantics", None)
|
||||
|
||||
|
||||
def observe_cached_context(
|
||||
executor: object, message_type: object, message_data: object
|
||||
executor: object,
|
||||
message_type: object,
|
||||
message_data: object,
|
||||
*,
|
||||
receipts: object = None,
|
||||
) -> None:
|
||||
"""Copy bounded local strings without cache/provider execution or retention."""
|
||||
"""Copy bounded local strings without cache/provider execution or retention.
|
||||
|
||||
Raw, prepend and append come from graph inference; a field it leaves
|
||||
unknown (runtime-only text) is taken from the receipt of the execution the
|
||||
cache entry is proven to come from (``CachedRuns.recorded_fields``), so the
|
||||
cached job is a tracked run of its prompt (#403). ``receipts`` defaults to
|
||||
the process ``CACHED_RUNS``.
|
||||
"""
|
||||
try:
|
||||
binding = active_capture()
|
||||
if binding is None or type(message_type) is not str:
|
||||
@@ -198,18 +265,29 @@ def observe_cached_context(
|
||||
inferred = manager.source_snapshot(cap, source)
|
||||
if inferred is None:
|
||||
continue
|
||||
fields = None
|
||||
if any(
|
||||
field.value is None
|
||||
for field in (inferred.raw, inferred.prepend, inferred.append)
|
||||
):
|
||||
fields = _receipt_fields(receipts, executor, cap.job_id, source)
|
||||
for index, value in enumerate(values):
|
||||
if type(value) is not str:
|
||||
continue
|
||||
raw, prepend, append = inferred.raw, inferred.prepend, inferred.append
|
||||
if fields is not None and _agrees(inferred, fields[index]):
|
||||
raw = _filled(raw, fields[index][0])
|
||||
prepend = _filled(prepend, fields[index][1])
|
||||
append = _filled(append, fields[index][2])
|
||||
manager.observe(
|
||||
cap,
|
||||
source,
|
||||
index,
|
||||
PromptSnapshot(
|
||||
inferred.raw,
|
||||
raw,
|
||||
TextEvidence(value, "recorded", "host_cache_output", None),
|
||||
inferred.prepend,
|
||||
inferred.append,
|
||||
prepend,
|
||||
append,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""The monitor's path for outputs of workflows without a PromptManager node (#247).
|
||||
|
||||
A foreign output (no positive PromptManager node in its graph) is decided by
|
||||
the ladder the output scan uses (``utils.file_prompt.decide_file_prompt``)
|
||||
with the ``MONITOR`` policy: certain files are stored at once, files only
|
||||
recorded history can decide wait for their run's outputs to be sealed, and
|
||||
everything else stays unlinked. This module holds the parts that do not
|
||||
need the monitor's waiting sets: the setting, the decision call and the
|
||||
store. The waiting and the re-check stay on ``ImageGenerationHandler``.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Optional, Tuple
|
||||
|
||||
from .file_prompt import (
|
||||
NOT_SAVED,
|
||||
NOT_SAVED_WIRED,
|
||||
PAIR,
|
||||
POSITIVE,
|
||||
Decision,
|
||||
Policy,
|
||||
decide_file_prompt,
|
||||
)
|
||||
from .hashing import generate_prompt_hash
|
||||
from .usage_tracking import survive_switch
|
||||
|
||||
try:
|
||||
from ..database.pair_ingestion import RUN_MARKS, ingest_resolved_pair
|
||||
from ..database.run_attribution import recorded_output_lookup
|
||||
from ..database.scanned_prompts import store_scanned_prompt_or_raise
|
||||
except ImportError:
|
||||
from database.pair_ingestion import RUN_MARKS, ingest_resolved_pair
|
||||
from database.run_attribution import recorded_output_lookup
|
||||
from database.scanned_prompts import store_scanned_prompt_or_raise
|
||||
|
||||
# Decisions that name a prompt to store
|
||||
CERTAIN = (PAIR, POSITIVE)
|
||||
# Decisions of a run whose prompt has Save to library off, or wired from a
|
||||
# node the file cannot show (#90): final, nothing is stored, nothing waits
|
||||
NOT_SAVED_KINDS = (NOT_SAVED, NOT_SAVED_WIRED)
|
||||
# Sources certain without a recorded run: the graph alone, or its recorded saver
|
||||
ANCHORED_SOURCES = ("inspector", "recorded-run")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForeignRecheck:
|
||||
"""``schedule`` snapshot: the one re-check of a foreign output after its
|
||||
run's outputs were sealed. ``reference`` is the sealed output reference,
|
||||
the spelling history recorded (``recorded_output_lookup(reference=)``)."""
|
||||
|
||||
reference: Mapping[str, Any]
|
||||
|
||||
|
||||
def save_foreign_runs() -> bool:
|
||||
"""Whether a foreign output may be stored now, read for every file.
|
||||
|
||||
``GalleryConfig.SAVE_FOREIGN_RUNS`` and ``MONITORING_ENABLED``: a
|
||||
monitoring switch saved off stops foreign writes at once, although the
|
||||
running observer watches until a restart. True when the configuration
|
||||
cannot be imported (the defaults).
|
||||
"""
|
||||
try:
|
||||
from ..py.config import GalleryConfig
|
||||
except ImportError:
|
||||
try:
|
||||
from py.config import GalleryConfig
|
||||
except ImportError:
|
||||
return True
|
||||
return (
|
||||
GalleryConfig.SAVE_FOREIGN_RUNS is not False
|
||||
and GalleryConfig.MONITORING_ENABLED is not False
|
||||
)
|
||||
|
||||
|
||||
def history_lookup(
|
||||
db: Any, image_path: str, reference: Optional[Mapping[str, Any]] = None
|
||||
) -> Callable[[], Any]:
|
||||
"""The ladder's ``lookup`` for one file: its ``RecordedOutput`` or None.
|
||||
|
||||
With ``reference`` the file is spelled as history recorded it. Failures
|
||||
are logged by ``recorded_output_lookup`` and name nothing.
|
||||
"""
|
||||
lookup = recorded_output_lookup(
|
||||
db, reference=dict(reference) if reference is not None else None
|
||||
)
|
||||
return lambda: lookup(image_path)
|
||||
|
||||
|
||||
def decide_foreign(
|
||||
image_path: str,
|
||||
metadata: Any,
|
||||
*,
|
||||
in_output: bool,
|
||||
lookup: Optional[Callable[[], Any]],
|
||||
) -> Decision:
|
||||
"""The ``MONITOR`` ladder for one foreign output (the size is read only
|
||||
when the ladder needs it)."""
|
||||
|
||||
def file_size() -> Optional[int]:
|
||||
try:
|
||||
return os.path.getsize(image_path)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
return decide_file_prompt(
|
||||
metadata,
|
||||
file_name=os.path.basename(image_path),
|
||||
file_size=file_size,
|
||||
in_output=in_output,
|
||||
lookup=lookup,
|
||||
policy=Policy.MONITOR,
|
||||
)
|
||||
|
||||
|
||||
def store_foreign(
|
||||
db: Any, image_path: str, decision: Decision
|
||||
) -> Tuple[str, Optional[int]]:
|
||||
"""Store a certain decision: ``(status, prompt id)``.
|
||||
|
||||
One write through ``survive_switch``, so it runs again on the moved
|
||||
library when a Move library switched while it waited (#249 S6b). Created
|
||||
prompts carry ``RUN_MARKS``. A file linked to another prompt is a
|
||||
``conflict`` and nothing is written. Database errors are raised.
|
||||
"""
|
||||
|
||||
def store():
|
||||
if decision.kind == PAIR:
|
||||
result = ingest_resolved_pair(
|
||||
db, image_path, decision.canonical, decision.resolution, marks=RUN_MARKS
|
||||
)
|
||||
return result["status"], result.get("image_id")
|
||||
return store_scanned_prompt_or_raise(
|
||||
db, image_path, decision.canonical, decision.text, RUN_MARKS
|
||||
)
|
||||
|
||||
status, linked = survive_switch(db, store)
|
||||
if decision.kind != PAIR or linked is None:
|
||||
return status, linked
|
||||
# The pair ingester reports the image row; its prompt is the positive's.
|
||||
prompt = db.get_prompt_by_hash(generate_prompt_hash(decision.text))
|
||||
return status, prompt["id"] if prompt else None
|
||||
+201
-41
@@ -33,6 +33,17 @@ from typing import Callable, Optional, Dict, Any, List, Tuple
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
|
||||
from .file_prompt import HISTORY, NOT_SAVED, NOT_SAVED_WIRED
|
||||
from .foreign_runs import (
|
||||
ANCHORED_SOURCES,
|
||||
CERTAIN,
|
||||
NOT_SAVED_KINDS,
|
||||
ForeignRecheck,
|
||||
decide_foreign,
|
||||
history_lookup,
|
||||
save_foreign_runs,
|
||||
store_foreign,
|
||||
)
|
||||
from .hashing import generate_prompt_hash
|
||||
from .metadata_extractor import ComfyUIMetadataExtractor
|
||||
from .parallel import note_refusal, refused_at_shutdown
|
||||
@@ -153,6 +164,11 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
# Sealed re-checks ``schedule`` refused (the path still pending, or the
|
||||
# pending set full): retried each time a pending image finishes.
|
||||
self._history_due: "OrderedDict[str, None]" = OrderedDict()
|
||||
# The same two sets for foreign outputs (#247), apart so a large
|
||||
# foreign run never evicts a waiting keyless file; the due set keeps
|
||||
# each file's sealed output reference.
|
||||
self._awaiting_foreign: "OrderedDict[str, Tuple[str, float]]" = OrderedDict()
|
||||
self._foreign_due: "OrderedDict[str, dict]" = OrderedDict()
|
||||
self._awaiting_lock = threading.Lock()
|
||||
self._sealed_subscribed = False
|
||||
# Set by ``close``: no new work, no new worker, ever (#302).
|
||||
@@ -253,6 +269,8 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
with self._awaiting_lock:
|
||||
self._awaiting.clear()
|
||||
self._history_due.clear()
|
||||
self._awaiting_foreign.clear()
|
||||
self._foreign_due.clear()
|
||||
with self._pending_lock:
|
||||
self._pending.clear()
|
||||
worker, self._worker = self._worker, None
|
||||
@@ -438,6 +456,9 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
if prompt_snapshot is HISTORY_ONLY:
|
||||
self._recheck_history(image_path)
|
||||
return
|
||||
if isinstance(prompt_snapshot, ForeignRecheck):
|
||||
self._recheck_foreign(image_path, prompt_snapshot.reference)
|
||||
return
|
||||
self.logger.info(f"Processing image: {image_path}")
|
||||
|
||||
if not os.path.exists(image_path):
|
||||
@@ -457,17 +478,12 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
# negative-prompt PromptManager nodes in the workflow.
|
||||
state, current_prompt = self._find_prompt_from_metadata(metadata)
|
||||
if state == "opted_out":
|
||||
self.logger.info(
|
||||
"Save to library is off for this run's prompt, skipping image: "
|
||||
f"{image_path}"
|
||||
)
|
||||
self._log_not_saved(image_path)
|
||||
return
|
||||
if state == "foreign":
|
||||
# Another workflow saved this image: it belongs to no prompt of
|
||||
# ours, and must not consume a batch queue entry.
|
||||
self.logger.info(
|
||||
f"No PromptManager prompt in workflow, skipping image: {image_path}"
|
||||
)
|
||||
# Another workflow saved this image: never a batch queue entry
|
||||
# of ours; its own graph may name its prompt (#247).
|
||||
self._ingest_foreign(image_path, metadata)
|
||||
return
|
||||
if current_prompt:
|
||||
self.logger.info(
|
||||
@@ -589,36 +605,55 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
resolved = os.path.abspath(image_path)
|
||||
return os.path.normcase(resolved)
|
||||
|
||||
def _prune_awaiting(self) -> None:
|
||||
"""Drop expired and surplus entries (``_awaiting_lock`` held)."""
|
||||
def _prune_awaiting(self, store=None) -> List[Tuple[str, bool]]:
|
||||
"""Drop expired and surplus entries of ``store`` (the keyless set by
|
||||
default; ``_awaiting_lock`` held). Returns the dropped foreign files
|
||||
as ``(path, evicted)`` for ``_log_dropped`` after the lock."""
|
||||
store = self._awaiting if store is None else store
|
||||
now = time.monotonic()
|
||||
for key in [k for k, (_p, due) in self._awaiting.items() if due <= now]:
|
||||
del self._awaiting[key]
|
||||
while len(self._awaiting) > MAX_AWAITING_HISTORY:
|
||||
self._awaiting.popitem(last=False)
|
||||
expired = [k for k, (_p, due) in store.items() if due <= now]
|
||||
dropped = [(store.pop(key)[0], False) for key in expired]
|
||||
while len(store) > MAX_AWAITING_HISTORY:
|
||||
dropped.append((store.popitem(last=False)[1][0], True))
|
||||
return dropped if store is self._awaiting_foreign else []
|
||||
|
||||
def _await_history(self, image_path: str) -> None:
|
||||
def _log_dropped(self, dropped: List[Tuple[str, bool]]) -> None:
|
||||
"""Log foreign files that stopped waiting (outside ``_awaiting_lock``)."""
|
||||
for image_path, evicted in dropped:
|
||||
name = os.path.basename(image_path)
|
||||
if evicted:
|
||||
self.logger.info(
|
||||
f"Too many files wait for their run; {name} left unlinked"
|
||||
)
|
||||
else:
|
||||
self.logger.info(f"No recorded run for {name}; left unlinked")
|
||||
|
||||
def _await_history(self, image_path: str, store=None) -> None:
|
||||
"""Re-check ``image_path`` once, history only, when its run is sealed.
|
||||
|
||||
A closed handler re-checks nothing.
|
||||
``store`` is the waiting set (``_awaiting_foreign`` for a foreign
|
||||
output). A closed handler re-checks nothing.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
if not self._sealed_subscribed:
|
||||
subscribe_outputs_sealed(self._on_outputs_sealed)
|
||||
self._sealed_subscribed = True
|
||||
store = self._awaiting if store is None else store
|
||||
key = self._awaiting_key(image_path)
|
||||
with self._awaiting_lock:
|
||||
self._awaiting.pop(key, None)
|
||||
self._awaiting[key] = (
|
||||
image_path,
|
||||
time.monotonic() + AWAIT_HISTORY_SECONDS,
|
||||
)
|
||||
self._prune_awaiting()
|
||||
store.pop(key, None)
|
||||
store[key] = (image_path, time.monotonic() + AWAIT_HISTORY_SECONDS)
|
||||
dropped = self._prune_awaiting(store)
|
||||
self._log_dropped(dropped)
|
||||
|
||||
def _stop_awaiting(self, image_path: str) -> None:
|
||||
def _await_foreign(self, image_path: str) -> None:
|
||||
self._await_history(image_path, self._awaiting_foreign)
|
||||
|
||||
def _stop_awaiting(self, image_path: str, store=None) -> None:
|
||||
store = self._awaiting if store is None else store
|
||||
with self._awaiting_lock:
|
||||
self._awaiting.pop(self._awaiting_key(image_path), None)
|
||||
store.pop(self._awaiting_key(image_path), None)
|
||||
|
||||
def awaiting_history(self) -> List[str]:
|
||||
"""Normalized paths still waiting for their run's recorded outputs."""
|
||||
@@ -626,6 +661,14 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
self._prune_awaiting()
|
||||
return list(self._awaiting)
|
||||
|
||||
def awaiting_foreign(self) -> List[str]:
|
||||
"""Normalized paths of foreign outputs waiting for their run (#247)."""
|
||||
with self._awaiting_lock:
|
||||
dropped = self._prune_awaiting(self._awaiting_foreign)
|
||||
waiting = list(self._awaiting_foreign)
|
||||
self._log_dropped(dropped)
|
||||
return waiting
|
||||
|
||||
def _on_outputs_sealed(self, run_id: str, references: List[dict]) -> None:
|
||||
"""A run's outputs were recorded: re-check its waiting images once.
|
||||
|
||||
@@ -637,6 +680,7 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
due = []
|
||||
with self._awaiting_lock:
|
||||
self._prune_awaiting()
|
||||
dropped = self._prune_awaiting(self._awaiting_foreign)
|
||||
for reference in references:
|
||||
if reference.get("folder_type") != "output":
|
||||
continue
|
||||
@@ -647,39 +691,53 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
)
|
||||
waiting = self._awaiting.pop(key, None)
|
||||
if waiting is not None:
|
||||
due.append(waiting[0])
|
||||
for image_path in due:
|
||||
self._schedule_recheck(image_path)
|
||||
due.append((waiting[0], None))
|
||||
waiting = self._awaiting_foreign.pop(key, None)
|
||||
if waiting is not None:
|
||||
due.append((waiting[0], dict(reference)))
|
||||
self._log_dropped(dropped)
|
||||
for image_path, reference in due:
|
||||
self._schedule_recheck(image_path, reference)
|
||||
|
||||
def _schedule_recheck(self, image_path: str) -> None:
|
||||
"""Schedule the history-only re-check, or keep it due for later.
|
||||
@staticmethod
|
||||
def _recheck_snapshot(reference):
|
||||
return HISTORY_ONLY if reference is None else ForeignRecheck(reference)
|
||||
|
||||
def _schedule_recheck(self, image_path: str, reference=None) -> None:
|
||||
"""Schedule the history-only re-check (with the sealed ``reference``:
|
||||
a foreign output's), or keep it due for later.
|
||||
|
||||
A closed handler drops it.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
if self.schedule(image_path, HISTORY_ONLY):
|
||||
if self.schedule(image_path, self._recheck_snapshot(reference)):
|
||||
return
|
||||
with self._awaiting_lock:
|
||||
# ``close`` sets the flag before ``shutdown`` clears this set, so
|
||||
# a close that raced the refusal above is seen here.
|
||||
if self._closed:
|
||||
return
|
||||
self._history_due.pop(image_path, None)
|
||||
self._history_due[image_path] = None
|
||||
while len(self._history_due) > MAX_AWAITING_HISTORY:
|
||||
self._history_due.popitem(last=False)
|
||||
due = self._history_due if reference is None else self._foreign_due
|
||||
due.pop(image_path, None)
|
||||
due[image_path] = reference
|
||||
evicted = []
|
||||
while len(due) > MAX_AWAITING_HISTORY:
|
||||
evicted.append(due.popitem(last=False)[0])
|
||||
if reference is not None:
|
||||
self._log_dropped([(path, True) for path in evicted])
|
||||
|
||||
def _drain_history_due(self) -> None:
|
||||
"""Schedule due re-checks now that a pending slot or path is free."""
|
||||
with self._awaiting_lock:
|
||||
if not self._history_due:
|
||||
return
|
||||
due = list(self._history_due)
|
||||
for image_path in due:
|
||||
if self.schedule(image_path, HISTORY_ONLY):
|
||||
due = [(self._history_due, p, None) for p in self._history_due]
|
||||
due += [(self._foreign_due, *item) for item in self._foreign_due.items()]
|
||||
for store, image_path, reference in due:
|
||||
if self.schedule(image_path, self._recheck_snapshot(reference)):
|
||||
with self._awaiting_lock:
|
||||
self._history_due.pop(image_path, None)
|
||||
# A newer seal may have replaced it meanwhile: keep that one
|
||||
if image_path in store and store[image_path] is reference:
|
||||
del store[image_path]
|
||||
|
||||
def _recheck_history(self, image_path: str) -> None:
|
||||
"""The one history-only re-check of a keyless image (#342)."""
|
||||
@@ -703,6 +761,108 @@ class ImageGenerationHandler(FileSystemEventHandler):
|
||||
metadata or {"file_info": self.get_basic_file_info(image_path)},
|
||||
)
|
||||
|
||||
def _ingest_foreign(self, image_path: str, metadata) -> None:
|
||||
"""A foreign output at settle: store its certain prompt, or wait for
|
||||
its run's recorded outputs when only history can decide (#247)."""
|
||||
name = os.path.basename(image_path)
|
||||
if not save_foreign_runs():
|
||||
self.logger.info(
|
||||
f"No PromptManager prompt in workflow, skipping image: {image_path}"
|
||||
)
|
||||
return
|
||||
in_output = in_output_folder(image_path, host_output_directory())
|
||||
decision = decide_foreign(
|
||||
image_path, metadata, in_output=in_output, lookup=None
|
||||
)
|
||||
if decision.kind == HISTORY:
|
||||
decision = self._settle_lookup(image_path, metadata)
|
||||
if decision is None:
|
||||
self.logger.debug(f"{name} waits for its run to end")
|
||||
return
|
||||
self._stop_awaiting(image_path, self._awaiting_foreign)
|
||||
if decision.kind in NOT_SAVED_KINDS:
|
||||
self._log_not_saved(image_path, decision.kind)
|
||||
elif decision.kind in CERTAIN:
|
||||
self._store_foreign(image_path, decision)
|
||||
else:
|
||||
self.logger.info(f"No certain prompt for {name}; left unlinked")
|
||||
|
||||
def _log_not_saved(self, image_path: str, kind: str = NOT_SAVED) -> None:
|
||||
"""The log line of a file whose run's prompt does not save (#90)."""
|
||||
if kind == NOT_SAVED_WIRED:
|
||||
reason = "is wired from a node the image cannot show"
|
||||
else:
|
||||
reason = "is off"
|
||||
self.logger.info(
|
||||
f"Save to library {reason} for this run's prompt, skipping image: "
|
||||
f"{image_path}"
|
||||
)
|
||||
|
||||
def _settle_lookup(self, image_path: str, metadata):
|
||||
"""The settle-time history lookup of a file only history can decide.
|
||||
|
||||
Registered first, so a run sealed before this lookup is not missed.
|
||||
Returns None to keep waiting: no record yet, and the answer is not
|
||||
the inspector's or the recorded saver's (the legacy floor waits for
|
||||
the re-check). With a record, any decision ends the wait. A
|
||||
``not_saved`` here is the undecided step's (an early one never asks
|
||||
history, so it never reaches this lookup): a recorded saver may still
|
||||
resolve another branch, so without a record it waits too (#90).
|
||||
"""
|
||||
self._await_foreign(image_path)
|
||||
found = []
|
||||
lookup = history_lookup(self.db_manager, image_path)
|
||||
|
||||
def recorded():
|
||||
found.append(lookup())
|
||||
return found[-1]
|
||||
|
||||
decision = decide_foreign(image_path, metadata, in_output=True, lookup=recorded)
|
||||
if any(record is not None for record in found):
|
||||
return decision
|
||||
if decision.kind in CERTAIN and decision.source in ANCHORED_SOURCES:
|
||||
return decision
|
||||
return None
|
||||
|
||||
def _recheck_foreign(self, image_path: str, reference) -> None:
|
||||
"""The one re-check of a foreign output after its run ended (#247)."""
|
||||
name = os.path.basename(image_path)
|
||||
if not os.path.exists(image_path):
|
||||
return
|
||||
if not save_foreign_runs():
|
||||
self.logger.info(f"Saving prompts from other workflows is off: {name}")
|
||||
return
|
||||
try:
|
||||
metadata = self.metadata_extractor.extract_metadata(image_path)
|
||||
except Exception as error:
|
||||
self.logger.warning(f"Metadata of {name} could not be read: {error}")
|
||||
return
|
||||
lookup = history_lookup(self.db_manager, image_path, reference)
|
||||
decision = decide_foreign(image_path, metadata, in_output=True, lookup=lookup)
|
||||
if decision.kind in NOT_SAVED_KINDS:
|
||||
self._log_not_saved(image_path, decision.kind)
|
||||
return
|
||||
if decision.kind in CERTAIN:
|
||||
self._store_foreign(image_path, decision)
|
||||
return
|
||||
self.logger.info(
|
||||
f"No certain prompt for {name} after its run ended; left unlinked"
|
||||
)
|
||||
|
||||
def _store_foreign(self, image_path: str, decision) -> None:
|
||||
name = os.path.basename(image_path)
|
||||
if not save_foreign_runs(): # switched off while this file was decided
|
||||
self.logger.info(f"Saving prompts from other workflows is off: {name}")
|
||||
return
|
||||
try:
|
||||
status, prompt_id = store_foreign(self.db_manager, image_path, decision)
|
||||
except Exception as error:
|
||||
self.logger.error(f"Failed to store the prompt of {name}: {error}")
|
||||
return
|
||||
self.logger.info(
|
||||
f"Foreign run: {status} prompt {prompt_id} for {name} ({decision.source})"
|
||||
)
|
||||
|
||||
def _history_prompt(self, image_path: str) -> Optional[int]:
|
||||
"""The stored prompt recorded history attributes to this file, if one."""
|
||||
try:
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -176,24 +176,32 @@ function extensionOf(record) {
|
||||
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
|
||||
}
|
||||
/**
|
||||
* The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
|
||||
* shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
|
||||
* a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
|
||||
* The tag chips after the prompt blocks (D88, D154): the file's current image tags (`source: 'image'`), then D21's linked
|
||||
* tags (`source: 'linked'`), each without case-insensitive repeats within its own source (D154: a linked tag that is also
|
||||
* an image tag stays in both labelled sections). With `filterable` (a Tags filter exists, as in the Images panel) every
|
||||
* image tag sets it, and a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip
|
||||
* is plain.
|
||||
*/
|
||||
export function inspectorTagChips(record, {filterable}) {
|
||||
const shown = new Set(), chips = [];
|
||||
const add = (names, filter) => {
|
||||
const chips = [];
|
||||
const add = (names, filter, source) => {
|
||||
const shown = new Set();
|
||||
for (const name of names) {
|
||||
const key = name.toLowerCase();
|
||||
if (shown.has(key)) continue;
|
||||
shown.add(key); chips.push({name, filter});
|
||||
shown.add(key); chips.push({name, filter, source});
|
||||
}
|
||||
};
|
||||
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
|
||||
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable, 'image');
|
||||
const {library} = record;
|
||||
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
|
||||
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive', 'linked');
|
||||
return chips;
|
||||
}
|
||||
/** D154 (#449): a tag section's visible heading; linked tags name their owner as D146's category chip does. */
|
||||
export function tagSectionLabel(source, library) {
|
||||
if (source === 'image') return 'Image tags';
|
||||
return library?.tags_scope === 'pair' ? 'Recipe tags' : 'Prompt tags';
|
||||
}
|
||||
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
|
||||
/**
|
||||
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
|
||||
@@ -295,14 +303,11 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
|
||||
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
|
||||
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
|
||||
// D154 (#449): each source gets its own labelled section, so image tags never read as part of the negative prompt.
|
||||
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
|
||||
if (chips.length) {
|
||||
const tags = el('div', undefined, 'tags');
|
||||
for (const {name, filter} of chips) {
|
||||
if (!filter) {tags.append(el('span', name, 'chip')); continue;}
|
||||
const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
|
||||
}
|
||||
inner.append(tags);
|
||||
for (const source of ['image', 'linked']) {
|
||||
const own = chips.filter(chip => chip.source === source);
|
||||
if (own.length) inner.append(tagSection(source, tagSectionLabel(source, library), own));
|
||||
}
|
||||
inner.append(...generationFacts(record, 'meta-grid', 'small'));
|
||||
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
|
||||
@@ -336,6 +341,22 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
}
|
||||
if (!full && footer.childNodes.length) container.append(footer);
|
||||
}
|
||||
/**
|
||||
* D154: `section.tag-section` in the prompt sections' frame, its `.role-label` heading naming the source and count.
|
||||
* The region's name leaves the count out and carries the prompt blocks' context ("Current file image tags").
|
||||
*/
|
||||
function tagSection(source, label, chips) {
|
||||
const section = el('section', undefined, `tag-section ${source}-tags`), heading = el('div', undefined, 'prompt-heading');
|
||||
section.setAttribute('aria-label', promptContext ? `${promptContext} ${label.toLowerCase()}` : label);
|
||||
heading.append(el('span', `${label} · ${chips.length}`, 'role-label'));
|
||||
const tags = el('div', undefined, 'tags');
|
||||
for (const {name: text, filter} of chips) {
|
||||
if (!filter) {tags.append(el('span', text, 'chip')); continue;}
|
||||
const chip = createButton(text, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(text)); tags.append(chip);
|
||||
}
|
||||
section.append(heading, tags);
|
||||
return section;
|
||||
}
|
||||
/**
|
||||
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
|
||||
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
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) {
|
||||
return `${image.root_index}:${image.path}`;
|
||||
}
|
||||
|
||||
/** The workspace listing's sorts: the concept's Recently added, Name A–Z and Highest rated, plus Oldest and Z–A (D14). */
|
||||
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']);
|
||||
/** The workspace listing's sorts: the concept's Recently added and Name A–Z, plus Oldest and Z–A (D14; no rating, D153). */
|
||||
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc']);
|
||||
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
// The largest page a refresh asks for (the listing clamps at 500).
|
||||
@@ -299,7 +315,7 @@ const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.ge
|
||||
|
||||
/**
|
||||
* D17: the caption's right-hand date, "<Today|Yesterday|weekday> · <Month D>" of the newest result under Newest first,
|
||||
* with ", YYYY" outside the current year; '' under Oldest first and the name or rating sorts.
|
||||
* with ", YYYY" outside the current year; '' under Oldest first and the name sorts.
|
||||
*/
|
||||
export function captionDate(items, sort, now = new Date()) {
|
||||
if (sort !== 'newest') return '';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, listingFilter } from './images-state.mjs';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, initialImagesView, visitDefaults, listingFilter } from './images-state.mjs';
|
||||
import { createImageCard } from './image-card.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
@@ -61,8 +61,9 @@ function selectOf(id, options, value) {
|
||||
return select;
|
||||
}
|
||||
|
||||
// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3).
|
||||
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
|
||||
// The concept's sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3). D153 (#447)
|
||||
// drops the concept's "Highest rated".
|
||||
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'],
|
||||
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
|
||||
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
|
||||
const RETRY_RESCAN = 'Rescan from Import to retry.';
|
||||
@@ -156,6 +157,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
|
||||
// toolbar to change them, so it keeps its fixed Grid/Comfortable. The metadata and card defaults apply everywhere.
|
||||
const start = initialImagesView({prior, saved, preferences:embedded ? null : preferences});
|
||||
// The defaults this visit started from: a return follows defaults saved since unless the toolbar changed a value (#451).
|
||||
const startDefaults = visitDefaults(embedded ? null : preferences);
|
||||
let density = start.density;
|
||||
let metrics = {...INITIAL_METRICS};
|
||||
let layout = start.layout, selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
@@ -182,7 +185,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
|
||||
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
|
||||
// Images return state (#249 D51, and M2 for saved views).
|
||||
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, defaults: startDefaults, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
cancelAnimationFrame(restoreFrame);
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
*/
|
||||
const MODES = new Set(['new', 'metadata', 'revision', 'negative']);
|
||||
const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']);
|
||||
const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating'];
|
||||
// D153 (#447): the workspace edits no 1–5 rating; a stored one is left exactly as it is.
|
||||
export const EDITOR_FIELDS = Object.freeze(['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
|
||||
const FIELDS = EDITOR_FIELDS;
|
||||
const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'};
|
||||
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
||||
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
|
||||
@@ -106,27 +108,20 @@ export function fallbackTitle(text) {return text.split('\n')[0].trim();}
|
||||
export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];}
|
||||
const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length;
|
||||
const stored = value => (value ?? '').trim();
|
||||
function normalizeRating(value) {
|
||||
if (value === null || value === '') return null;
|
||||
const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value;
|
||||
if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null');
|
||||
return number;
|
||||
}
|
||||
|
||||
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null});
|
||||
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: ''});
|
||||
function seedDraft(mode, {owner, pair}) {
|
||||
if (!owner) return blankDraft();
|
||||
if (mode === 'negative') return {...blankDraft(), positive_text: owner.text};
|
||||
return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text,
|
||||
negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '),
|
||||
notes: owner.notes ?? '', rating: owner.rating};
|
||||
notes: owner.notes ?? ''};
|
||||
}
|
||||
/** Keep fields the user changed from the previous seed; the rest follow the new seed. */
|
||||
function overlay(next, draft, previous) {
|
||||
return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]]));
|
||||
}
|
||||
function baselineOf(owner) {
|
||||
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes};
|
||||
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], notes: owner.notes};
|
||||
}
|
||||
|
||||
/** Only changed fields; an untouched fallback title is not a title edit. */
|
||||
@@ -136,19 +131,18 @@ function metadataChanges(draft, seed, baseline) {
|
||||
if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title;
|
||||
if (category !== stored(baseline.category)) changes.category = category;
|
||||
if (!sameTags(tags, baseline.tags)) changes.tags = tags;
|
||||
if (draft.rating !== baseline.rating) changes.rating = draft.rating;
|
||||
if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes;
|
||||
return changes;
|
||||
}
|
||||
function createMetadata(draft) {
|
||||
return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags),
|
||||
rating: draft.rating, notes: draft.notes};
|
||||
notes: draft.notes};
|
||||
}
|
||||
// A blank category is stored as NULL; "Uncategorized" is only the UI's label for it.
|
||||
const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value));
|
||||
function sameMetadata(sent, owner) {
|
||||
return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) &&
|
||||
sent.rating === owner.rating && sent.notes === (owner.notes ?? '');
|
||||
sent.notes === (owner.notes ?? '');
|
||||
}
|
||||
function draftProblem(mode, draft) {
|
||||
if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'});
|
||||
@@ -328,9 +322,8 @@ export function createLibraryEditorController({api, mode, positiveId = null, pai
|
||||
},
|
||||
setField(name, value) {
|
||||
if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`);
|
||||
const next = name === 'rating' ? normalizeRating(value) : value;
|
||||
if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`);
|
||||
emit({draft: {...state.draft, [name]: next}});
|
||||
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
|
||||
emit({draft: {...state.draft, [name]: value}});
|
||||
},
|
||||
async submit() {
|
||||
if (disposed || !SUBMITTABLE.has(state.phase)) return;
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
* module only renders `controller.state` and forwards user actions.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createLibraryEditorController } from './library-editor-state.mjs';
|
||||
import { createLibraryEditorController, EDITOR_FIELDS } from './library-editor-state.mjs';
|
||||
|
||||
const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'};
|
||||
const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']];
|
||||
const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category',
|
||||
tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'};
|
||||
tags: 'recipe-tags', notes: 'recipe-notes'};
|
||||
const SUBMITTABLE = new Set(['ready', 'write-error']);
|
||||
const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.';
|
||||
const NO_PAIR = 'Archive needs a selected saved negative variant.';
|
||||
@@ -218,9 +217,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
category.setAttribute('list', 'categories');
|
||||
tagInput.setAttribute('list', 'tag-options');
|
||||
columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags));
|
||||
const rating = controls.rating = el('select');
|
||||
for (const [value, text] of RATINGS) rating.add(new Option(text, value));
|
||||
form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes));
|
||||
// D153 (#447): no Rating field; the concept's `#recipe-rating` select is not drawn.
|
||||
form.append(columns, categories, tags, field('Notes', textarea('notes'), FIELD_IDS.notes));
|
||||
} else form.append(el('p', NEGATIVE_HELP, 'small'));
|
||||
const line = statusLine(), recovery = new Map();
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
@@ -230,9 +228,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
form.append(line.note, actions);
|
||||
|
||||
for (const [name, node] of Object.entries(controls)) {
|
||||
node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => {
|
||||
controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value);
|
||||
}, {signal: views.signal});
|
||||
node.addEventListener('input', () => controller.setField(name, node.value), {signal: views.signal});
|
||||
}
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
@@ -246,7 +242,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
let categoryKey = null, refocus = null;
|
||||
function render(state) {
|
||||
for (const [name, node] of Object.entries(controls)) {
|
||||
const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name];
|
||||
const value = state.draft[name];
|
||||
if (node.value !== value) node.value = value;
|
||||
}
|
||||
const key = state.categories.join('\n');
|
||||
@@ -274,7 +270,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
|
||||
body.replaceChildren(form);
|
||||
return {controller, views, render, start() {
|
||||
if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value);
|
||||
// A kept draft carries only the editor's own fields (D153: never a rating).
|
||||
if (draft) for (const [name, value] of Object.entries(draft)) if (EDITOR_FIELDS.includes(name)) controller.setField(name, value);
|
||||
render(controller.state);
|
||||
controller.initialize();
|
||||
}};
|
||||
|
||||
@@ -60,9 +60,3 @@ export function usedLabel(lastUsedAt, now = new Date()) {
|
||||
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
|
||||
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
|
||||
}
|
||||
|
||||
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
|
||||
export function ratingLabel(rating) {
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
|
||||
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { selectionLabel, toggleSelection } from './images-state.mjs';
|
||||
import { applyKeys, rangeKeys } from './images-multiselect.mjs';
|
||||
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, setOutcome } from './images-category.mjs';
|
||||
import { NO_TAGS, parseBulkTags } from './images-tagging-bulk.mjs';
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
/**
|
||||
* The Prompt library's selection and its bulk actions (D148, D151, beyond the concept): "Add tags" (#442, D151) and "Set
|
||||
* category" (#437), the legacy admin page's bulk Add Tags and Set Category in the workspace. Built from the concept's
|
||||
* Images parts only: each card's `.image-overlays` checkbox and the `.bulkbar` row ("N selected", the actions in the
|
||||
* Images bar's order, Clear). The selection is a set of
|
||||
* prompt ids over the shelf's loaded items, so windowed-out cards keep their state; Shift+click on a checkbox applies
|
||||
* the anchor's range (the Images helpers, D135). The dialog, the range helpers, the count and the toast text are the
|
||||
* Images bar's own; one bar core shared with images-selection.mjs is a follow-up (D148).
|
||||
*
|
||||
* Apply posts `{prompt_ids, category|tags, expected_epoch}` to `POST /prompt_manager/bulk/category` or `/bulk/tags`:
|
||||
* the epoch the shelf was read under, so a restored storage answers 409 `storage-changed` and nothing is written.
|
||||
* Add tags writes prompt tags only, never image tags or auto-tags.
|
||||
*/
|
||||
export const CATEGORY_URL = '/prompt_manager/bulk/category';
|
||||
export const PROMPT_CATEGORY_DIALOG = Object.freeze({ ...CATEGORY_DIALOG, title: 'Categorize selected prompts' });
|
||||
// The route's cap (`BULK_CATEGORY_MAX_PROMPTS`): more opens nothing.
|
||||
export const MAX_CATEGORY_PROMPTS = 1000;
|
||||
export const TOO_MANY_PROMPTS = 'Select at most 1,000 prompts.';
|
||||
export const STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No category was changed.';
|
||||
export const UNCONFIRMED = 'The category change could not be confirmed. The Library was reloaded.';
|
||||
export const NONE_FOUND = 'None of the selected prompts could be found. The Library was reloaded.';
|
||||
export const CONTROL_CHARACTERS = 'A category cannot contain control characters.';
|
||||
export { CATEGORY_FAILED };
|
||||
export const TAGS_URL = '/prompt_manager/bulk/tags';
|
||||
export const TAG_OPTIONS_URL = '/prompt_manager/tags';
|
||||
export const PROMPT_TAGS_DIALOG = Object.freeze({ title: 'Tag selected prompts', label: 'Tags, separated by commas', action: 'Apply' });
|
||||
// What the editor's save accepts for one prompt (`validate_tags`): the route refuses more.
|
||||
export const MAX_PROMPT_TAGS = 20;
|
||||
export const MAX_TAG_LENGTH = 50;
|
||||
export const TAG_PROBLEMS = Object.freeze({ none: NO_TAGS, many: 'Enter at most 20 tags.',
|
||||
long: 'A tag can have at most 50 characters.', control: 'A tag cannot contain control characters.' });
|
||||
export const TAGS_FAILED = 'Tags could not be added. Try again.';
|
||||
export const TAGS_STORAGE_CHANGED = 'Prompt storage was restored since this page loaded. No tags were added.';
|
||||
export const TAGS_UNCONFIRMED = 'Adding the tags could not be confirmed. The Library was reloaded.';
|
||||
// Answers that may follow a committed write (the Library editor's unknown outcome): no status, or a gateway's.
|
||||
const UNCONFIRMED_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
const CHECKBOX = '.image-overlays input[type="checkbox"]';
|
||||
const CARD = 'article[data-prompt-id]';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a settled write means for the page: `toast` to show, whether the write may have `committed` (the page then
|
||||
* invalidates its cached Library views) and whether the shelf must `reload` (which clears the selection).
|
||||
*/
|
||||
export function writeOutcome(result, failure, kind = 'category') {
|
||||
const copy = WRITES[kind];
|
||||
if (failure === null && result?.success) {
|
||||
const found = ['updated', 'unchanged', 'over_limit'].reduce((sum, key) => sum + (Number(result[key]) || 0), 0);
|
||||
if (!found) return { toast: NONE_FOUND, committed: false, reload: true };
|
||||
return { toast: copy.success(result), committed: (Number(result.updated) || 0) > 0, reload: true };
|
||||
}
|
||||
if (failure?.code === 'storage-changed') return { toast: copy.storage, committed: false, reload: true };
|
||||
const status = failure?.status;
|
||||
if (!Number.isInteger(status) || UNCONFIRMED_STATUSES.has(status)) return { toast: copy.unconfirmed, committed: true, reload: true };
|
||||
return { toast: copy.failed, committed: false, reload: false };
|
||||
}
|
||||
|
||||
/** The Add tags toast (#442): what was added, then what already had them, was skipped or could not be found. */
|
||||
export function tagsOutcome(result) {
|
||||
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
|
||||
const skipped = Number(result?.over_limit) || 0, missing = Number(result?.not_found) || 0;
|
||||
const names = Array.isArray(result?.tags) ? result.tags.join(', ') : '';
|
||||
const parts = [];
|
||||
if (updated) parts.push(`Added “${names}” to ${countOf(updated, 'prompt')}.`);
|
||||
if (unchanged) parts.push(`${countOf(unchanged, 'prompt')} already had ${updated ? 'them' : `“${names}”`}.`);
|
||||
if (skipped) parts.push(`${countOf(skipped, 'prompt')} would pass ${MAX_PROMPT_TAGS} tags and ${skipped === 1 ? 'was' : 'were'} skipped.`);
|
||||
if (missing) parts.push(`${missing.toLocaleString('en-US')} could not be found.`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
const WRITES = Object.freeze({
|
||||
category: { success: result => setOutcome(result, result.category, 'prompt'), storage: STORAGE_CHANGED,
|
||||
unconfirmed: UNCONFIRMED, failed: CATEGORY_FAILED },
|
||||
tags: { success: tagsOutcome, storage: TAGS_STORAGE_CHANGED, unconfirmed: TAGS_UNCONFIRMED, failed: TAGS_FAILED },
|
||||
});
|
||||
|
||||
/**
|
||||
* "Select all N loaded" (#444 ruling, D151; the legacy page's select-all checkbox): offered while some loaded prompts
|
||||
* are unchecked and no more are loaded than an action accepts. It sits where D135's "Select all N matching" sits on
|
||||
* Images, but that offer appears once every loaded image is checked; this one reaches only the loaded prompts.
|
||||
*/
|
||||
export function offersLoaded(selected, keys) {
|
||||
return selected.size > 0 && keys.length <= MAX_CATEGORY_PROMPTS && keys.some(key => !selected.has(key));
|
||||
}
|
||||
export const loadedOffer = count => `Select all ${count.toLocaleString('en-US')} loaded`;
|
||||
|
||||
/** The Add tags field's check before Apply: the parsed names, as `validate_tags` would judge them. */
|
||||
export function tagsProblem(text) {
|
||||
const tags = parseBulkTags(text);
|
||||
if (!tags.length) return TAG_PROBLEMS.none;
|
||||
if (tags.length > MAX_PROMPT_TAGS) return TAG_PROBLEMS.many;
|
||||
if (tags.some(tag => tag.length > MAX_TAG_LENGTH)) return TAG_PROBLEMS.long;
|
||||
if (tags.some(tag => /[\x00-\x1f]/.test(tag))) return TAG_PROBLEMS.control;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The field's check before Apply: what `validate_category` refuses beyond the length the field already limits. */
|
||||
export function categoryProblem(value) {
|
||||
return /[\x00-\x1f]/.test(value) ? CONTROL_CHARACTERS : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the selection on the shelf `grid`. `getItems()` is the shelf's loaded items in order, `getEpoch()` the
|
||||
* storage epoch they were read under, `getCategories()` the Library's category list (the field's suggestions).
|
||||
* `onCommitted(kind, receipt)` (`category` or `tags`) runs after a write that may have changed prompts, even once the page is gone (the page
|
||||
* invalidates its cached Library views); `onReload()` then reloads the mounted shelf (which calls `clear`). Focus moves
|
||||
* to `focusFallback` when it was on the bar the reload hides.
|
||||
*/
|
||||
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
|
||||
onReload }) {
|
||||
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
|
||||
const element = el('div');
|
||||
element.id = 'library-bulkbar';
|
||||
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
|
||||
const addTags = createButton('Add tags');
|
||||
const setCategory = createButton('Set category');
|
||||
const clearButton = createButton('Clear');
|
||||
const selectLoaded = createButton('');
|
||||
const actions = [addTags, setCategory, selectLoaded];
|
||||
bulk.append(count, addTags, setCategory, clearButton);
|
||||
scope.listen(selectLoaded, 'click', () => {
|
||||
if (writing) return;
|
||||
selectAll();
|
||||
// The pressed offer left the bar: focus moves to its first action, as after D135's offer.
|
||||
addTags.focus({ preventScroll: true });
|
||||
});
|
||||
scope.listen(addTags, 'click', () => openTags());
|
||||
scope.listen(setCategory, 'click', () => openCategory());
|
||||
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
|
||||
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
|
||||
scope.listen(element, 'keydown', event => {
|
||||
if (event.key !== 'Escape' || !checked.size) return;
|
||||
event.preventDefault();
|
||||
clear();
|
||||
focusFallback.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
const ids = () => getItems().map(item => item.id);
|
||||
function render() {
|
||||
if (!checked.size) { bulk.remove(); return; }
|
||||
const label = selectionLabel(checked.size);
|
||||
if (count.textContent !== label) count.textContent = label;
|
||||
const keys = ids();
|
||||
if (offersLoaded(checked, keys)) {
|
||||
const offer = loadedOffer(keys.length);
|
||||
if (selectLoaded.textContent !== offer) selectLoaded.textContent = offer;
|
||||
if (count.nextElementSibling !== selectLoaded) count.after(selectLoaded);
|
||||
} else selectLoaded.remove();
|
||||
if (bulk.parentNode !== element) element.append(bulk);
|
||||
}
|
||||
function paint() {
|
||||
for (const box of grid.querySelectorAll(CHECKBOX)) {
|
||||
const on = checked.has(Number(box.closest(CARD).dataset.promptId));
|
||||
if (box.checked !== on) box.checked = on;
|
||||
}
|
||||
}
|
||||
function update(next) { checked = next; paint(); render(); }
|
||||
function clear() {
|
||||
anchor = null;
|
||||
if (checked.size) update(Object.freeze(new Set()));
|
||||
}
|
||||
/** Every loaded prompt, windowed-out cards included; the anchor stays. */
|
||||
function selectAll() {
|
||||
update(applyKeys(checked, ids(), true));
|
||||
}
|
||||
// The checkbox toggles natively; a Shift+click applies the anchor's range with the box's new state.
|
||||
scope.listen(grid, 'click', event => {
|
||||
const box = event.target.closest('.image-overlays > label');
|
||||
if (box && event.shiftKey) shiftBox = Number(box.closest(CARD).dataset.promptId);
|
||||
});
|
||||
scope.listen(grid, 'change', event => {
|
||||
if (!event.target.matches(CHECKBOX)) return;
|
||||
const id = Number(event.target.closest(CARD).dataset.promptId), on = event.target.checked;
|
||||
if (shiftBox === id) {
|
||||
const keys = rangeKeys(ids(), anchor, id);
|
||||
if (anchor === null || keys.length === 1) anchor = id;
|
||||
update(applyKeys(checked, keys, on));
|
||||
} else {
|
||||
anchor = id;
|
||||
update(toggleSelection(checked, id, on));
|
||||
}
|
||||
shiftBox = null;
|
||||
});
|
||||
|
||||
/** The card's overlay: the concept's `.image-overlays > label > input`, checked from the selection. */
|
||||
function overlayFor(item, name) {
|
||||
const overlays = el('div', undefined, 'image-overlays'), label = el('label'), box = el('input');
|
||||
box.type = 'checkbox';
|
||||
box.checked = checked.has(item.id);
|
||||
box.setAttribute('aria-label', `Select ${name}`);
|
||||
label.append(box);
|
||||
overlays.append(label);
|
||||
return overlays;
|
||||
}
|
||||
|
||||
/** The selected loaded prompts and the shelf epoch, or null after a toast says why nothing can be sent. */
|
||||
function target(failed) {
|
||||
if (writing) return null;
|
||||
const loaded = new Set(ids()), selected = [...checked].filter(id => loaded.has(id));
|
||||
if (selected.length > MAX_CATEGORY_PROMPTS) { showWorkspaceToast(TOO_MANY_PROMPTS); return null; }
|
||||
const epoch = getEpoch();
|
||||
if (!selected.length || typeof epoch !== 'string') { showWorkspaceToast(failed); return null; }
|
||||
return { prompt_ids: selected, expected_epoch: epoch };
|
||||
}
|
||||
function openCategory() {
|
||||
const chosen = target(CATEGORY_FAILED);
|
||||
if (!chosen) return;
|
||||
openInputDialog({ scope, trigger: setCategory, title: PROMPT_CATEGORY_DIALOG.title, label: PROMPT_CATEGORY_DIALOG.label,
|
||||
action: PROMPT_CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(),
|
||||
validate: categoryProblem, onApply: category => write(CATEGORY_URL, { ...chosen, category }, 'category') });
|
||||
}
|
||||
/** Add tags (#442): the tag vocabulary as suggestions, the editor's list; a failed read opens without them. */
|
||||
async function openTags() {
|
||||
const chosen = target(TAGS_FAILED);
|
||||
if (!chosen) return;
|
||||
let suggestions = null;
|
||||
try {
|
||||
const result = await api.get(TAG_OPTIONS_URL, { signal: scope.signal, cache: 'no-store' });
|
||||
if (Array.isArray(result?.tags)) suggestions = result.tags.filter(tag => typeof tag === 'string');
|
||||
} catch {
|
||||
// Suggestions are auxiliary; the field works without them.
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
openInputDialog({ scope, trigger: addTags, ...PROMPT_TAGS_DIALOG, suggestions, validate: tagsProblem,
|
||||
onApply: text => write(TAGS_URL, { ...chosen, tags: parseBulkTags(text) }, 'tags') });
|
||||
}
|
||||
/** The write carries no page signal: once sent it completes even if the page is left. A failure keeps the selection. */
|
||||
async function write(url, body, kind) {
|
||||
writing = true;
|
||||
for (const button of actions) button.setAttribute('aria-disabled', 'true');
|
||||
let result = null, failure = null;
|
||||
try {
|
||||
result = await api.post(url, body);
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
writing = false;
|
||||
for (const button of actions) button.removeAttribute('aria-disabled');
|
||||
}
|
||||
const outcome = writeOutcome(result, failure, kind);
|
||||
// A left page still invalidates what it cached, so a return never shows the old categories.
|
||||
if (outcome.committed) onCommitted(kind, result?.success ? result : { phase: 'unconfirmed' });
|
||||
if (scope.signal.aborted) return;
|
||||
const focused = element.contains(document.activeElement);
|
||||
showWorkspaceToast(outcome.toast);
|
||||
if (!outcome.reload) return;
|
||||
onReload();
|
||||
if (focused) focusFallback.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
render();
|
||||
return {
|
||||
element,
|
||||
overlayFor,
|
||||
isChecked: id => checked.has(id),
|
||||
hasSelection: () => checked.size > 0,
|
||||
clear,
|
||||
selectAll,
|
||||
/** A loaded batch: the count and the offer follow it. */
|
||||
listingChanged: () => { if (checked.size) render(); },
|
||||
/** Whether a Set category write is still in flight. */
|
||||
writing: () => writing,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ function parameters(filters,limit) {
|
||||
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
|
||||
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
|
||||
const sort=filters.sort || 'newest';
|
||||
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
query.set('sort',sort);return query;
|
||||
}
|
||||
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
|
||||
@@ -19,7 +19,7 @@ function validatePage(page,cursor) {
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
}
|
||||
export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});
|
||||
let query=parameters({},40),request,disposed=false,seen=new Set();
|
||||
if(restore) {
|
||||
query=parameters(restore.filters,40);const saved=restore.state;
|
||||
@@ -27,7 +27,7 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
const firstPagePending=saved?.variantTotal===null && Array.isArray(saved.items) && !saved.items.length && saved.total===0 && saved.cursor===null && saved.hasMore===true;
|
||||
if(!firstPagePending) validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
|
||||
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
|
||||
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
|
||||
state=Object.freeze({...saved,epoch:saved.epoch ?? null,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
|
||||
}
|
||||
const emit=patch=>{state=Object.freeze({...state,...patch});onChange(state);};
|
||||
async function loadMore() {
|
||||
@@ -37,14 +37,19 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
try {
|
||||
const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal});
|
||||
if(disposed || current!==request) return 0;
|
||||
validatePage(page,cursor);const incoming=[];const added=new Set();
|
||||
validatePage(page,cursor);
|
||||
// #437: the storage epoch the shelf's items were read under, which a bulk write sends as its fence; a later
|
||||
// page from a restored storage is refused (its items would belong to another database).
|
||||
const epoch=typeof page.library_epoch==='string'?page.library_epoch:null;
|
||||
if(state.epoch!==null && epoch!==state.epoch) throw new TypeError('Prompt storage changed while the library was loading');
|
||||
const incoming=[];const added=new Set();
|
||||
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
|
||||
for(const value of added) seen.add(value);
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,epoch:state.epoch ?? epoch,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
|
||||
}
|
||||
return {get state(){return state;},loadMore,async reset(filters={}) {
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null,epoch:null});return loadMore();
|
||||
},dispose(){disposed=true;request?.abort();request=null;}};
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr
|
||||
import {promptRoles} from './prompt-blocks.mjs';
|
||||
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
|
||||
import {createLibraryEditorController} from './library-editor-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows,usedLabel} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
@@ -15,6 +15,7 @@ import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
import {mountLibrarySelection} from './library-selection.mjs';
|
||||
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
@@ -28,8 +29,10 @@ const selectedPairs=new Map();
|
||||
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
|
||||
const metadataDrafts=new Map();
|
||||
const archivedVisible=new Map();
|
||||
// The concept's three sorts first, then the Library's appended Oldest first (D14).
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
|
||||
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
|
||||
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
|
||||
const CARD_CONTROLS='button, input[type="checkbox"]';
|
||||
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
|
||||
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
|
||||
function el(tag,text,className) {
|
||||
@@ -108,7 +111,7 @@ function outcomeMessage(result,outcome) {
|
||||
if(kind!=='create' && kind!=='add-negative') return null;
|
||||
if(receipt.positive_created) return 'Saved.';
|
||||
if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.'
|
||||
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'
|
||||
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.'
|
||||
:'Saved as a new negative variant of an existing prompt.';
|
||||
if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
|
||||
return 'This prompt pair already exists. Showing the saved version.';
|
||||
@@ -119,7 +122,7 @@ function takeSavedSelection(positiveId) {
|
||||
}
|
||||
function rememberUnappliedMetadata(result,{outcome,draft}={}) {
|
||||
if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return;
|
||||
const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes});
|
||||
const {title:name,category,tags,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,notes});
|
||||
}
|
||||
/** New prompt, from Library or Images: a saved prompt always opens its own history, as in the concept. */
|
||||
function createNewPromptEditor(api,scope,main) {
|
||||
@@ -156,6 +159,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
|
||||
// D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added.
|
||||
if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest';
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
@@ -171,6 +176,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
|
||||
captionCount.setAttribute('role','status');
|
||||
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
|
||||
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
|
||||
let categoryNames=[];
|
||||
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
|
||||
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},
|
||||
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
|
||||
const message=el('p','','library-status');message.setAttribute('role','status');
|
||||
const continuation=el('div',undefined,'library-continuation');
|
||||
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
|
||||
@@ -201,7 +211,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
scope.listen(exportButton,'click',exportLibrary);
|
||||
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
|
||||
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
|
||||
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section);
|
||||
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,selection.element,caption,grid,continuation);main.append(section);
|
||||
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
|
||||
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
|
||||
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
|
||||
@@ -212,7 +222,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
|
||||
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
|
||||
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
|
||||
/** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */
|
||||
/** The concept's prompt card (app.js renderLibrary a586) with real counts and last use (#249 D23; no rating, D153). */
|
||||
function cardFor(item,index) {
|
||||
const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
|
||||
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
|
||||
@@ -238,15 +248,13 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip'));
|
||||
content.append(heading,prompts,meta,vocabulary);
|
||||
const footer=el('div',undefined,'card-footer');
|
||||
const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at);
|
||||
const facts=el('span');
|
||||
if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);}
|
||||
else facts.textContent=used;
|
||||
// D153 (#447): the footer reads only the last use; the concept's rating stars are not drawn.
|
||||
const facts=el('span',usedLabel(item.last_used_at));
|
||||
const actions=el('div',undefined,'row-actions');
|
||||
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
|
||||
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
|
||||
actions.append(send,explore);footer.append(facts,actions);
|
||||
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
|
||||
article.append(selection.overlayFor(item,name),art,content,footer);article.dataset.index=index;return {node:article,blocks};
|
||||
}
|
||||
function captureAnchor() {
|
||||
// Mounted rows can already differ from estimates before their observer batch.
|
||||
@@ -340,10 +348,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
if(changed) {pendingAnchor ||= anchor;schedule();}
|
||||
}):null;
|
||||
measurementObserver?.observe(grid);
|
||||
function focusCard(index,last=false) {
|
||||
/** Focus a card's first or `last` control (Tab across the window edge), or its title (`title`: Home/End/arrows). */
|
||||
function focusCard(index,last=false,title=false) {
|
||||
if(index<0 || index>=state.items.length) return;
|
||||
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
|
||||
const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
|
||||
const controls=card.querySelectorAll(CARD_CONTROLS);(title?card.querySelector('.card-title > button'):last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
|
||||
}
|
||||
// The title and Explore history open the prompt's history; the footer send sends the full positive (D23).
|
||||
scope.listen(grid,'click',event=>{
|
||||
@@ -355,15 +364,19 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
scope.listen(grid,'keydown',event=>{
|
||||
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
|
||||
const index=Number(card.dataset.index);
|
||||
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
|
||||
// D151 (#444 ruling): Ctrl/Cmd+A selects every loaded prompt and Escape clears, as on Images (focus stays).
|
||||
if((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase()==='a') {event.preventDefault();selection.selectAll();return;}
|
||||
if(event.key==='Escape' && selection.hasSelection()) {event.preventDefault();selection.clear();return;}
|
||||
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1),false,true);}
|
||||
else if(event.key==='Tab') {
|
||||
const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1);
|
||||
const controls=card.querySelectorAll(CARD_CONTROLS);const next=index+(event.shiftKey?-1:1);
|
||||
const edge=event.shiftKey?controls[0]:controls[controls.length-1];
|
||||
if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);}
|
||||
}
|
||||
});
|
||||
function render(next) {
|
||||
if(items!==next.items) {
|
||||
const batch=items!==next.items;
|
||||
if(batch) {
|
||||
pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true;
|
||||
if(!items.length) {
|
||||
anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0};
|
||||
@@ -372,6 +385,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
|
||||
}
|
||||
state=next;
|
||||
// A loaded batch: the selection bar's count and Select all N loaded follow it (D151).
|
||||
if(batch) selection.listingChanged();
|
||||
if(next.variantTotal!==null) {
|
||||
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
|
||||
@@ -392,10 +407,10 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
optionsRequest?.abort();const request=optionsRequest=new AbortController();
|
||||
try {
|
||||
const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return;
|
||||
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
|
||||
categoryNames=[...data.categories];toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
|
||||
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
|
||||
}
|
||||
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
|
||||
const reset=()=>{restoreScrollY=null;selection.clear();toolbar.setActiveFilters(refinements());return stream.reset(filters);};
|
||||
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
|
||||
function applyToolbar(value) {
|
||||
const next={text:value.q.trim(),category:value.category,sort:value.sort};
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
|
||||
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
|
||||
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
|
||||
* accepts is dropped to that key's default, so an opened view applies what it can.
|
||||
* accepts is dropped to that key's default, so an opened view applies what it can (a view saved under the retired
|
||||
* "Highest rated" sort opens on Recently added, D153).
|
||||
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
|
||||
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
|
||||
*/
|
||||
@@ -36,7 +37,7 @@ const tags = { fallback: [], accepts: value => Array.isArray(value) && value.len
|
||||
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
|
||||
const FIELDS = Object.freeze({
|
||||
images: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc'], 'newest'),
|
||||
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
|
||||
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
|
||||
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
|
||||
@@ -45,7 +46,7 @@ const FIELDS = Object.freeze({
|
||||
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
|
||||
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
|
||||
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
|
||||
* - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals".
|
||||
* - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or
|
||||
* rated-images total).
|
||||
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
|
||||
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
|
||||
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
|
||||
@@ -168,21 +169,16 @@ export function healthSummary(report) {
|
||||
return `${time}Nothing was changed; review the checks with issues.`;
|
||||
}
|
||||
|
||||
/** D96: the concept's "Runs by category" rows `[category, runs, average rating]`. */
|
||||
/** D96: the concept's "Runs by category" rows `[category, runs]`; D153 drops its average rating column. */
|
||||
export function categoryRows(statistics) {
|
||||
return statistics.by_category.map(row => [
|
||||
row.category ?? 'Uncategorized',
|
||||
number(row.runs),
|
||||
row.average_rating === null || row.average_rating === undefined ? 'Not rated'
|
||||
: `${Number(row.average_rating).toLocaleString(undefined, { maximumFractionDigits: 2 })} / 5`,
|
||||
]);
|
||||
return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]);
|
||||
}
|
||||
|
||||
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics and the rated images). */
|
||||
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */
|
||||
export function totalsRows(statistics) {
|
||||
const { totals } = statistics;
|
||||
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images],
|
||||
['Rated images', statistics.rated_images]].map(([label, value]) => [label, number(value)]);
|
||||
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]]
|
||||
.map(([label, value]) => [label, number(value)]);
|
||||
}
|
||||
|
||||
function table(caption, head, rows, empty) {
|
||||
@@ -215,7 +211,7 @@ function table(caption, head, rows, empty) {
|
||||
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
|
||||
export function statisticsNodes(statistics) {
|
||||
return [
|
||||
table('Runs by category', ['Category', 'Runs', 'Avg. rating'], categoryRows(statistics), 'No prompts in the library yet.'),
|
||||
table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'),
|
||||
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
|
||||
];
|
||||
}
|
||||
@@ -378,7 +374,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
|
||||
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
|
||||
label: 'Appearance', onClick: go('appearance') }),
|
||||
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
|
||||
operationRow({ scope, title: 'Statistics', description: 'Runs and ratings by category, with library totals.', label: 'Explore',
|
||||
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore',
|
||||
onClick: openStatistics }),
|
||||
]);
|
||||
const columns = el('div', undefined, 'two-col');
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* it to the host draft of `gallery_root_paths`; Remove takes one out. Both wait for the one Save row.
|
||||
* - "Watch for new outputs" (`monitoring_enabled`) and "Worker threads" (`worker_threads`, a range up to the server's
|
||||
* `cpu_count`, D113) are host drafts too. "Enrich existing metadata" reviews a scan of every source.
|
||||
* - D158 (#247): "Save prompts from other workflows" (`save_foreign_runs`), a host draft below "Watch for new outputs",
|
||||
* applied by the server at once (the monitor reads it for every file); disabled, saying why, while monitoring is off,
|
||||
* unless the running monitor still watches until a restart (D128 `stopping`): then it can still be set.
|
||||
* - "Thumbnail generation → Review options" opens "Thumbnail maintenance" (thumbnail-review.mjs, S5 Task 14), which
|
||||
* starts the thumbnail Activity job.
|
||||
*
|
||||
@@ -63,6 +66,21 @@ export const LOAD_FAILED_TEXT = 'Sources could not be loaded. Your saved data ha
|
||||
export const DUPLICATE_TEXT = 'This folder is already a source.';
|
||||
export const VALIDATE_FAILED_TEXT = 'The folder could not be checked. Try again.';
|
||||
export const REFRESH_FAILED_TEXT = 'Files could not be refreshed. Try again.';
|
||||
/** D158 (#247): the row the concept does not draw, below "Watch for new outputs". */
|
||||
export const FOREIGN_RUNS = Object.freeze({ id: 'foreign-runs', key: 'save_foreign_runs', label: 'Save prompts from other workflows',
|
||||
description: 'New outputs of workflows without a PromptManager node add their prompt to the library when it is certain.',
|
||||
off: 'Applies while Watch for new outputs is on.' });
|
||||
|
||||
/**
|
||||
* D158: the row for the drafted `save_foreign_runs` (`value`) and `monitoring_enabled` (`monitoring`): disabled until
|
||||
* read, and while monitoring is off, when `note` says why (the value is kept and saved as it is). `stopping`: a source
|
||||
* is still watched until the restart (D128), so the value can still be set; the note stays.
|
||||
*/
|
||||
export function foreignRunsState({ value, monitoring, stopping = false }) {
|
||||
if (value === undefined) return { disabled: true, checked: null, note: null };
|
||||
const off = monitoring === false;
|
||||
return { disabled: off && !stopping, checked: value === true, note: off ? FOREIGN_RUNS.off : null };
|
||||
}
|
||||
|
||||
// Labels of the sources added in this page session (validate-source answers them), for the rows not saved yet.
|
||||
const addedLabels = new Map();
|
||||
@@ -267,13 +285,16 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
|
||||
const monitoring = settingRow({ id: 'monitoring', label: 'Watch for new outputs',
|
||||
description: 'New files enter the same library. Existing scans do not count as new runs.', control: { type: 'toggle' },
|
||||
scope, onChange: value => drafts.set('host', 'monitoring_enabled', value) });
|
||||
const foreign = settingRow({ id: FOREIGN_RUNS.id, label: FOREIGN_RUNS.label, description: FOREIGN_RUNS.description,
|
||||
control: { type: 'toggle' }, scope, onChange: value => drafts.set('host', FOREIGN_RUNS.key, value) });
|
||||
const workers = settingRow({ id: 'workers', label: 'Worker threads',
|
||||
description: 'Balance background scans and thumbnail work against generation.', control: { type: 'range', value: 4 },
|
||||
scope, onChange: value => drafts.set('host', 'worker_threads', value) });
|
||||
const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [workers, 'workers', 'worker_threads']]
|
||||
const hostRows = [[monitoring, 'monitoring', 'monitoring_enabled'], [foreign, FOREIGN_RUNS.id, FOREIGN_RUNS.key],
|
||||
[workers, 'workers', 'worker_threads']]
|
||||
.map(([row, id, key]) => ({ input: controlOf(row, id), key, text: row.children[0], note: null }));
|
||||
for (const row of hostRows) row.input.dataset.key = row.key;
|
||||
surface.append(monitoring, workers,
|
||||
surface.append(monitoring, foreign, workers,
|
||||
operationRow({ scope, title: 'Enrich existing metadata', description: 'Resolve both prompts, deduplicate recipes, preserve uncertain links.',
|
||||
label: 'Review scan', onClick: scanAll }),
|
||||
operationRow({ scope, title: 'Thumbnail generation', description: 'Generate missing previews; rebuilding all is a separate explicit option.',
|
||||
@@ -348,6 +369,10 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
|
||||
for (const row of hostRows) {
|
||||
const value = drafts.get('host', row.key);
|
||||
row.input.disabled = value === undefined;
|
||||
if (row.key === FOREIGN_RUNS.key) {
|
||||
paintForeign(row, value, unavailable);
|
||||
continue;
|
||||
}
|
||||
if (row.key === 'worker_threads') {
|
||||
// D113: the range ends at the server's core count (the concept's fixed 16).
|
||||
const cpus = drafts.get('host', 'cpu_count');
|
||||
@@ -360,6 +385,24 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
|
||||
}
|
||||
}
|
||||
|
||||
// D158: disabled while monitoring is drafted off, with the reason under the description.
|
||||
function paintForeign(row, value, unavailable) {
|
||||
const stopping = sources?.effective?.some(source => source.watch === 'stopping') === true;
|
||||
const state = foreignRunsState({ value, monitoring: drafts.get('host', 'monitoring_enabled'), stopping });
|
||||
row.input.disabled = state.disabled;
|
||||
if (state.checked !== null) row.input.checked = state.checked;
|
||||
const note = unavailable && value === undefined ? HOST_UNAVAILABLE : state.note;
|
||||
if (note && row.note?.textContent !== note) {
|
||||
row.note?.remove();
|
||||
row.note = el('p', note, 'small');
|
||||
row.note.id = `${FOREIGN_RUNS.id}-note`;
|
||||
row.text.append(row.note);
|
||||
} else if (!note && row.note) { row.note.remove(); row.note = null; }
|
||||
// The note is the switch's description, so a screen reader says why it is disabled.
|
||||
if (row.note) row.input.setAttribute('aria-describedby', row.note.id);
|
||||
else row.input.removeAttribute('aria-describedby');
|
||||
}
|
||||
|
||||
// The sources changed since this page read them (a refused per-source scan): read them and the saved values again.
|
||||
function reread() {
|
||||
load();
|
||||
@@ -378,6 +421,7 @@ export function mountSources(container, { api, activity, scope, drafts, notify =
|
||||
if (answer === STALE || scope.signal.aborted) return;
|
||||
sources = answer?.sources ?? null;
|
||||
paintList();
|
||||
paintControls();
|
||||
}
|
||||
|
||||
// A save (or a reread after a conflict) changed the saved sources or monitoring: read their state again. The first
|
||||
|
||||
@@ -13,6 +13,7 @@ export const SEARCH_DELAY = 250;
|
||||
|
||||
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
|
||||
// other label is one of the page's appended options.
|
||||
// No page passes "Highest rated" since D153 (#447); it stays here only to keep the concept's order.
|
||||
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
|
||||
|
||||
const isText = value => typeof value === 'string';
|
||||
|
||||
@@ -169,6 +169,13 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; }
|
||||
.list-grid .image-open video { height: 95px; aspect-ratio: auto; }
|
||||
|
||||
/* #451 D157: a cover crop of a generated image keeps its top (heads, faces) instead of its middle. The vertical 20%
|
||||
acts only when the image is taller than its box; a wider image overflows sideways and stays centred. Masonry shows
|
||||
the whole image (contain) and keeps the centre. */
|
||||
.image-grid:not(.masonry-grid) .image-open :is(img, video), .card-art img, .detail-cover, .tag-image-preview {
|
||||
object-position: 50% 20%; /* D157 */
|
||||
}
|
||||
|
||||
/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media,
|
||||
titles and states that the concept's one demo image never shows. */
|
||||
/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the
|
||||
@@ -230,6 +237,11 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
details, and an unavailable download stays visibly inert next to its reason. */
|
||||
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
|
||||
/* #449 D154: the image's own tags and the linked prompt's or recipe's tags sit in the prompt sections' frame (concept
|
||||
.prompt-section), under a muted .role-label; the heading's 8px spaces the chips, as it spaces a prompt's text. */
|
||||
.file-inspector .tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
|
||||
.file-inspector .tag-section .role-label { color: var(--ca-muted); }
|
||||
.file-inspector .tag-section > .tags { margin-top: 0; }
|
||||
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
|
||||
where the toolkit's `nowrap` would push them past its edge. */
|
||||
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }
|
||||
@@ -264,6 +276,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
/* #249 D23: a parent with one preview, or none, fills the art row instead of leaving the concept's second cell empty. */
|
||||
.prompt-card .card-art > :only-child { grid-column: 1 / -1; /* D23 */ }
|
||||
.prompt-card .card-art > .small { display: grid; place-content: center; min-width: 0; color: var(--ca-muted); /* D23 */ }
|
||||
/* #437 D148: the card's selection checkbox is the concept's Images `.image-overlays`, placed over the art as on an image
|
||||
card; `.media-art` carries the concept's `position: relative` there, the prompt card carries it here. */
|
||||
.prompt-card { position: relative; /* D148 */ }
|
||||
|
||||
/* Prompt library shelf (#249 PR E). #249 D3: rows stay windowed, as the Images grid's do; the concept's `.library-grid`
|
||||
tracks and breakpoints lay them out, each row spans them, and appended rows never anchor the scroll. The row's
|
||||
|
||||
Reference in New Issue
Block a user