Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b57def35fe | ||
|
|
89d94cdb2b | ||
|
|
b6e0439421 | ||
|
|
b5ca2eda94 | ||
|
|
8870496ea3 | ||
|
|
83199518df | ||
|
|
5e00ee479f | ||
|
|
2c54daf2d8 | ||
|
|
1ce2577733 | ||
|
|
2941a9b748 | ||
|
|
505c16a724 | ||
|
|
66e567cc95 | ||
|
|
dff08bc161 | ||
|
|
aad3a37bcf | ||
|
|
d03d0ed1c5 |
@@ -36,6 +36,11 @@ concurrency:
|
||||
# 12m53s, node units and the viewer suite before 11.8m of workflows), so coverage is the tight one
|
||||
# in its 18 minutes. This holds only when the npm cache hits: a cold cache adds up to 5m to npm ci,
|
||||
# and then the job limit can still end the job before the step limit does.
|
||||
# #473: firefox/webkit shard 1/2 reached its 18-minute step limit (queue run 40866252975: webkit 622 tests
|
||||
# passed in 17m56s, 48.5 worker-minutes vs 28.0 in shard 2/2; recent shard 1/2 jobs 13.7m-18.9m, shard 2/2
|
||||
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
|
||||
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
|
||||
# re-measured after three green main runs.
|
||||
jobs:
|
||||
frontend-coverage:
|
||||
name: frontend coverage (shard ${{ matrix.shard }}/3)
|
||||
@@ -175,7 +180,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
browser-workflows:
|
||||
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/2)
|
||||
name: page workflows (${{ matrix.browser }}, shard ${{ matrix.shard }}/3)
|
||||
# Merge queue, main and manual runs only (#420); pull requests run the Chromium/touch coverage shards.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -194,7 +199,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [firefox, webkit]
|
||||
shard: [1, 2]
|
||||
shard: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
@@ -221,9 +226,9 @@ jobs:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
|
||||
- run: npm ci --prefix tools/vendor --ignore-scripts
|
||||
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/2
|
||||
- name: Page workflows in ${{ matrix.browser }}, shard ${{ matrix.shard }}/3
|
||||
timeout-minutes: 18
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/2
|
||||
run: npm run --prefix tools/vendor test:workflows -- --project ${{ matrix.browser }} --shard ${{ matrix.shard }}/3
|
||||
# Traces and the blob report (test-results/blob/) of a failed, timed-out or cancelled step.
|
||||
- name: Upload browser traces
|
||||
if: always()
|
||||
|
||||
+303
-28
@@ -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).
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -1810,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
|
||||
@@ -1895,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 |
|
||||
@@ -2375,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.
|
||||
@@ -2385,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
|
||||
@@ -2547,6 +2576,48 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no
|
||||
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
|
||||
@@ -2740,10 +2811,12 @@ mapped differences"):
|
||||
- **D60** (heading action, LI-3): "Open file metadata" (`ca-primary`) replaces the
|
||||
concept's "Open sample metadata". It is `aria-disabled="true"` and
|
||||
`aria-describedby="local-file-note"` (the drop note) until a classified selection is
|
||||
ready, then opens the shared viewer on that file.
|
||||
ready, then opens the shared viewer on that file. Amended by D155: choosing the file
|
||||
already opens it; the action reopens it.
|
||||
- **D61** (copy, LI-3): the surface `p` reads "Read embedded metadata in this browser.
|
||||
The file is not uploaded or added to your library."; the drop note "Your file stays in
|
||||
this browser. Its metadata opens with “Open file metadata”."; the chooser label "Choose
|
||||
this browser. Its metadata opens with “Open file metadata”." (D155: "Your file stays in
|
||||
this browser. Choosing it opens its metadata."); the chooser label "Choose
|
||||
an image or video for a local preview"; the toast "Local preview only. No file
|
||||
uploaded; metadata is available above.". The h2 "Inspect without importing" is the
|
||||
concept's, verbatim.
|
||||
@@ -2797,6 +2870,7 @@ mapped differences"):
|
||||
by the next selection attempt or after the concept's toast interval (6.5 s). The toast
|
||||
"Local preview only…; metadata is available above." shows only for a selection whose
|
||||
status is empty or the AVI/WMV note, never beside an unreadable or failed preview.
|
||||
Amended by D155: only the AVI/WMV note keeps the toast; an empty status opens the viewer.
|
||||
- **D64** (drop, LI-6): `dragover`/`drop` on `.file-drop` accept exactly one File, read
|
||||
only `dataTransfer.files`/`items` (never its strings) and change nothing visually.
|
||||
While the page is mounted, `window` `dragover`/`drop` call `preventDefault`, so a missed
|
||||
@@ -2809,6 +2883,36 @@ mapped differences"):
|
||||
Modified with no Path row and no Show in folder, and ends with "Local file; no saved
|
||||
library association was checked." and Download original. There is no Prompt history
|
||||
action and no category or tag chips.
|
||||
- **D155** (#448, maintainer choice 2026-10-28, beyond the concept): choosing or dropping
|
||||
a file opens the shared viewer on it at once, with `#viewer-details` showing, in one
|
||||
step; the concept previews it and waits for its heading action. The viewer opens once
|
||||
per selection, after the session has selected the record (a microtask after `ready`),
|
||||
only while that selection is current and no other dialog is open. A new selection
|
||||
that starts while the viewer shows the previous file closes it first, so no element
|
||||
holds the old `blob:` URL when the session revokes it (`URLs and media end with their
|
||||
owning session`); the new file's viewer opens once it is ready. A video opens the
|
||||
viewer only once its inline preview has settled (metadata, an error, or D62's 15 s):
|
||||
replacing a video while both the inline and the viewer's video were still starting
|
||||
deadlocked WebKit (`a video replaced while still starting is reset once it settles`
|
||||
hung 2 of 2 runs before, passes 5 of 5 after). It opens for a selection with no status and for one
|
||||
without a preview URL ("The preview could not be displayed. Metadata and the original
|
||||
remain available in the viewer."); an unreadable file stays on the page with its
|
||||
alert, and AVI/WMV keeps its note and the toast (the viewer could not play it and
|
||||
reads no metadata); "Open file metadata" opens either. An image or video that fails to
|
||||
decode after the viewer opened shows the viewer's own media error; the page's alert
|
||||
is there on close. The toast no longer shows for an opened file (the viewer is the
|
||||
confirmation), and the drop note reads "Your file stays in this browser. Choosing it
|
||||
opens its metadata.". On this page the viewer, by choice or by the heading action,
|
||||
opens with the details showing even when "Collapse viewer details by default" is on
|
||||
(D97); the toggle is still drawn then and starts as "Hide details" (`aria-expanded`
|
||||
true). Closing returns focus to the chooser (the heading action when it opened the
|
||||
viewer), with the preview, the chooser naming the file and the heading action in
|
||||
place; the next file chosen or dropped replaces it and opens again. A drop is still
|
||||
ignored while the viewer is open (D64). Nothing else changes: the file is read in this
|
||||
browser only. Browser tests: "D155: choosing a file opens the viewer with its details
|
||||
in one click", "D155: Inspect shows the details even when the viewer collapses them by
|
||||
default (amends D97)", "D155: an unreadable file stays on the page with its alert" and
|
||||
"D155: at 390px the opened details are reachable below the media".
|
||||
- **D117** (Copy settings summary, LI-4): the legacy metadata
|
||||
page's COPY ALL, consolidated into the shared full inspector (the viewer's
|
||||
`#viewer-details`, so Images, the recorded history's current file and Inspect all have
|
||||
@@ -3167,7 +3271,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
|
||||
@@ -3416,7 +3521,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
|
||||
@@ -3462,12 +3567,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
|
||||
@@ -3486,10 +3592,41 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
card's title and file line, Info and Tag stay at the end of the row), "Collapse viewer
|
||||
details by default" (`sidebarCollapsedByDefault`; the viewer opens with
|
||||
`#viewer-details` hidden and a "Show details"/"Hide details" toggle with
|
||||
`aria-expanded`, the stage taking the whole width), and the operation "Reset display
|
||||
`aria-expanded`, the stage taking the whole width; amended by D155: on Inspect a file
|
||||
the viewer opens with the details showing and the toggle reading "Hide details"; amended by D156: the row moved
|
||||
to the "Viewer" surface), and the operation "Reset display
|
||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
|
||||
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
|
||||
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
|
||||
the concept's `.dialog-header > button` and D34's coarse 40px rule cover it; 8px from
|
||||
✕). It names the view it switches to in its accessible name and `title` ("Full screen
|
||||
view" / "Centered view") with a `maximize` / `minimize` outline icon, and keeps focus
|
||||
after a switch. Full screen (`#viewer-dialog.viewer-fullscreen`) fills the browser
|
||||
window edge to edge: `inset: 0`, no width or height cap, no margin, border or radius
|
||||
(`.ca-tools #viewer-dialog.viewer-fullscreen[open]`, beating the concept's box, D34's
|
||||
max-height and the ≤700px height); the layout inside is the concept's. Centered is the
|
||||
concept's framed dialog, unchanged. In full screen the details stay toggleable: the
|
||||
D97 "Show details"/"Hide details" toggle is in the toolbar whenever the view is full
|
||||
screen or "Collapse viewer details by default" is on; going back to Centered without
|
||||
that setting removes the toggle and shows the details. The view is not the browser's
|
||||
Fullscreen API (Esc keeps one meaning, closing the viewer; WebKit has no element
|
||||
fullscreen for a modal dialog; the workspace also runs in ComfyUI popups). The viewer
|
||||
opens in the view System › Appearance › **Viewer** › "Open viewer in" (Centered,
|
||||
Full screen) chooses: a browser display preference, `viewerOpenMode` (`centered` by
|
||||
default, normalized in `gallery-settings.js`) in the shared `gallerySettings`; no
|
||||
server setting. The button changes only the current view (navigation in the viewer
|
||||
keeps it; the next open uses the setting). "Viewer" is its own surface after
|
||||
"Browsing & display" ("How the image viewer opens; the button beside ✕ switches the
|
||||
current view."), holding "Open viewer in" and the moved "Collapse viewer details by
|
||||
default"; "Reset display defaults" still restores both. At ≤700px (the concept's
|
||||
breakpoint, where the viewer is already 96vw × 94dvh) the button is hidden
|
||||
(`display: none !important` against the concept's `.icon-btn` rule), popup windows
|
||||
that narrow included; the stored view still applies there. Browser tests:
|
||||
`tests/browser/workspace-viewer-mode.spec.mjs`; the concept comparisons add the
|
||||
button to the concept's header at runtime and map the Viewer surface.
|
||||
- **D98** (Sources): each configured source's row has a plain Remove after its scan
|
||||
button; nothing changes until Save. A source added this session reads "<path> · not
|
||||
saved yet" with Remove only until Save; a configured source the server cannot use
|
||||
@@ -3904,6 +4041,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
|
||||
@@ -5831,6 +5988,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,
|
||||
@@ -5928,8 +6149,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,
|
||||
@@ -5937,7 +6176,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"
|
||||
@@ -6036,10 +6278,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
|
||||
@@ -6392,15 +6636,16 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
|
||||
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
|
||||
longer hands off to the legacy tag manager. That page stays reachable from Overview's
|
||||
"Open existing tag manager" card for tag search and sort, the untagged entry and
|
||||
multi-tag AND/OR browsing; bulk prompt delete stays on the legacy admin page (bulk tags:
|
||||
the Prompt library's selection bar, D151). A
|
||||
"Open existing tag manager" card for multi-tag AND/OR browsing (tag search and sort
|
||||
and the untagged entry moved here with D149, #444); 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.
|
||||
above and adds no route, so PR B needed no ComfyUI restart; D149 (#444) adds read
|
||||
parameters to `GET tags` and `GET /prompt_manager/library`, which do.
|
||||
|
||||
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
|
||||
Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and
|
||||
@@ -6452,6 +6697,36 @@ Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or m
|
||||
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
|
||||
fixture-only tags after the concept's sample tags.
|
||||
|
||||
- **D149** (#444, cutover #334; the concept's renderTags has neither): the Tag vocabulary
|
||||
surface shows, after its paragraph, an "Untagged prompts" `.operation` row ("N positive
|
||||
prompts", Browse) and then the tag search and sort, drawn as the concept page toolbar's
|
||||
`search.toolbar` > `label.search-field` (search icon, `input#tag-search` "Search tags…",
|
||||
maxlength 200) and a `select#tag-sort` (Oldest first, the default and the old order;
|
||||
Name A–Z; Name Z–A; Most prompts; Fewest prompts). Search is a literal, case-insensitive
|
||||
substring on the server (`GET /prompt_manager/vocabulary/tags?search=&sort=`; ASCII-only
|
||||
case folding, like SQLite `LIKE`/`NOCASE`), debounced 250 ms; it resets only the tag list
|
||||
to page one, keeps through command re-reads, reads "No tags match this search." when
|
||||
empty and is announced in a visually hidden status ("N tags"). Every sort breaks ties by
|
||||
ascending id; a cursor belongs to one search and sort (scope-hashed; another is 400 with
|
||||
"refresh"); a count sort's counts can move between pages only through a write, which
|
||||
re-reads from page one. Each tags page reports `untagged_count` (eligible Library parents
|
||||
with no tag and no active pair carrying one, the complement of every tag's Browse; legacy
|
||||
counted every prompt without `prompt_tags` and hid the entry at 0; this row always
|
||||
shows) and echoes `search` and `sort`. Browse opens the Library with `tag_mode=untagged`
|
||||
(`GET /prompt_manager/library`, echoed as `tag_mode`; with a `tag` it is 400): "Filters ·
|
||||
1", and Refine this view adds **Tag match** after Tags ("All of these tags" / "Prompts
|
||||
without tags", the latter disables Tags and drops them on Apply). A client whose request
|
||||
the server did not echo (an un-restarted older server) shows the read error with Retry,
|
||||
never an unfiltered list. Saved Library views store `tag_mode` (`all|untagged`, default
|
||||
`all`); untagged with tags is refused on write and reads back as `all`; an older build
|
||||
opens an untagged view as the whole Library. The concept comparison holds the row to the
|
||||
concept's first category row, its top to the concept table's offset, the table to that
|
||||
offset plus the additions' height (`d149`) and the toolbar to the concept Library
|
||||
toolbar (`toolbarInventoryOf`), with a mutation check. Tests:
|
||||
`tests/test_vocabulary.py` (`TestVocabularyTagSearchAndSort`, `TestLibraryTagMode`),
|
||||
`test_api_vocabulary.py`, `test_api_library.py`, `test_saved_views.py`, the
|
||||
vocabulary/library/saved-views JS units and `tests/browser/workspace-vocabulary-search.spec.mjs`
|
||||
(behavior plus artifact captures).
|
||||
- **D143** (#332): category rows count the images whose own category is the value
|
||||
(`image_count`, the stored rows, missing files included): "N positive prompts · M
|
||||
images" beside prompts, "M images" for an image-only value, which follows every
|
||||
|
||||
@@ -7,6 +7,11 @@ from typing import Callable, ContextManager, Iterator
|
||||
|
||||
try:
|
||||
from ..utils.execution_history_events import RunFinish, encode_command
|
||||
from ..utils.hashing import (
|
||||
generate_prompt_hash,
|
||||
stored_prompt_text,
|
||||
stored_prompt_text_sql,
|
||||
)
|
||||
from ..utils.execution_contexts import (
|
||||
CAPTURE_REASONS,
|
||||
ContextBundle,
|
||||
@@ -15,6 +20,11 @@ try:
|
||||
)
|
||||
except ImportError:
|
||||
from utils.execution_history_events import RunFinish, encode_command
|
||||
from utils.hashing import (
|
||||
generate_prompt_hash,
|
||||
stored_prompt_text,
|
||||
stored_prompt_text_sql,
|
||||
)
|
||||
from utils.execution_contexts import (
|
||||
CAPTURE_REASONS,
|
||||
ContextBundle,
|
||||
@@ -134,7 +144,8 @@ def _insert_exact(
|
||||
|
||||
def _role_hints(role: str) -> str:
|
||||
return (
|
||||
f"SELECT r.{role}_prompt_id, tx.text_value "
|
||||
f"SELECT r.{role}_prompt_id, tx.text_value, "
|
||||
f"{stored_prompt_text_sql('tx.text_value')} "
|
||||
"FROM execution_run_contexts r "
|
||||
"JOIN execution_contexts c ON c.context_id = r.context_id "
|
||||
f"JOIN execution_prompt_snapshots s ON s.snapshot_id = c.{role}_snapshot_id "
|
||||
@@ -146,57 +157,78 @@ def _role_hints(role: str) -> str:
|
||||
|
||||
# Set form of the read-time prompt-hint rule in get_execution_contexts():
|
||||
# a stored role hint is live only in the current storage epoch, and only when
|
||||
# its exact raw snapshot text matches exactly one prompt, which is the hinted
|
||||
# one (_prompt_hint). Evaluated once per statement, never per prompt row.
|
||||
# the hinted prompt is the one match of _prompt_hint: by text (the raw
|
||||
# snapshot text or its stored form, outer whitespace stripped, #438), or, only
|
||||
# when no prompt has either text, by dedup hash (case folded too, #460).
|
||||
# The hash is a fallback, so a hint #438 kept is never made ambiguous.
|
||||
# Needs register_prompt_functions (pm_prompt_hash). The hint rows are the
|
||||
# outer loop (CROSS JOIN), so each prompts lookup is by index.
|
||||
# Defines the CTE ``live_hint_prompts(prompt_id)``; prefix it with WITH.
|
||||
LIVE_HINT_CTES = (
|
||||
"stored_hints(prompt_id, raw) AS ("
|
||||
"stored_hints(prompt_id, raw, stored) AS ("
|
||||
+ _role_hints("positive")
|
||||
+ " UNION "
|
||||
+ _role_hints("negative")
|
||||
+ "), unique_hint_texts(prompt_id, raw) AS ("
|
||||
"SELECT MIN(id), text COLLATE BINARY FROM prompts "
|
||||
"WHERE text COLLATE BINARY IN (SELECT raw FROM stored_hints) "
|
||||
"GROUP BY text COLLATE BINARY HAVING COUNT(*) = 1"
|
||||
+ "), hint_matches(prompt_id, raw, stored, match_id) AS ("
|
||||
"SELECT h.prompt_id, h.raw, h.stored, p.id FROM stored_hints h "
|
||||
"CROSS JOIN prompts p ON p.text COLLATE BINARY IN (h.raw, h.stored) "
|
||||
"UNION ALL "
|
||||
"SELECT h.prompt_id, h.raw, h.stored, p.id FROM stored_hints h "
|
||||
"CROSS JOIN prompts p ON p.hash = pm_prompt_hash(h.raw) "
|
||||
"WHERE NOT EXISTS (SELECT 1 FROM prompts q "
|
||||
"WHERE q.text COLLATE BINARY IN (h.raw, h.stored))"
|
||||
"), live_hint_prompts(prompt_id) AS ("
|
||||
"SELECT h.prompt_id FROM stored_hints h JOIN unique_hint_texts u "
|
||||
"ON u.prompt_id = h.prompt_id AND u.raw = h.raw COLLATE BINARY)"
|
||||
"SELECT prompt_id FROM hint_matches GROUP BY prompt_id, raw, stored "
|
||||
"HAVING COUNT(*) = 1 AND MIN(match_id) = prompt_id)"
|
||||
)
|
||||
LIVE_HINT_PROMPT_IDS_SQL = (
|
||||
"WITH " + LIVE_HINT_CTES + " SELECT DISTINCT prompt_id FROM live_hint_prompts"
|
||||
)
|
||||
|
||||
_BY_TEXT = "SELECT id FROM prompts WHERE text COLLATE BINARY IN (?,?) LIMIT 2"
|
||||
|
||||
|
||||
def _text_matches(conn: sqlite3.Connection, raw: str) -> list:
|
||||
return [row[0] for row in conn.execute(_BY_TEXT, (raw, stored_prompt_text(raw)))]
|
||||
|
||||
|
||||
def _prompt_hint(conn: sqlite3.Connection, raw: str | None) -> int | None:
|
||||
"""The one prompt that stores ``raw`` (#438), else the one it counts toward.
|
||||
|
||||
By text first: ``raw`` or ``raw.strip()`` (the exact form keeps hints of
|
||||
older rows that kept outer whitespace). Only when no prompt has either
|
||||
text, by the dedup hash, which also folds case (#460).
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM prompts WHERE text=? COLLATE BINARY LIMIT 2", (raw,)
|
||||
).fetchall()
|
||||
return rows[0][0] if len(rows) == 1 else None
|
||||
matches = _text_matches(conn, raw)
|
||||
if not matches:
|
||||
matches = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT id FROM prompts WHERE hash=? LIMIT 2",
|
||||
(generate_prompt_hash(raw),),
|
||||
)
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _hints(
|
||||
conn: sqlite3.Connection, positive: str | None, negative: str | None
|
||||
) -> tuple:
|
||||
pos_id, neg_id = _prompt_hint(conn, positive), _prompt_hint(conn, negative)
|
||||
stored = None if negative is None else stored_prompt_text(negative)
|
||||
pair_id = None
|
||||
# A known negative that names no prompt because several share its text
|
||||
negative_ambiguous = (
|
||||
negative is not None
|
||||
and neg_id is None
|
||||
and conn.execute(
|
||||
"SELECT 1 FROM prompts WHERE text=? COLLATE BINARY LIMIT 1", (negative,)
|
||||
).fetchone()
|
||||
is not None
|
||||
stored is not None and neg_id is None and bool(_text_matches(conn, negative))
|
||||
)
|
||||
if pos_id is not None and negative is not None and not negative_ambiguous:
|
||||
if pos_id is not None and stored is not None and not negative_ambiguous:
|
||||
rows = conn.execute(
|
||||
"SELECT pp.id FROM prompt_pairs pp LEFT JOIN prompts n ON "
|
||||
"n.id=pp.negative_prompt_id "
|
||||
"SELECT pp.id FROM prompt_pairs pp "
|
||||
"WHERE pp.positive_prompt_id=? AND ((pp.negative_prompt_id IS NULL AND "
|
||||
"?='') "
|
||||
"OR (pp.negative_prompt_id=? AND n.text=? COLLATE BINARY)) LIMIT 2",
|
||||
(pos_id, negative, neg_id, negative),
|
||||
"?='') OR pp.negative_prompt_id=?) LIMIT 2",
|
||||
(pos_id, stored, neg_id),
|
||||
).fetchall()
|
||||
if len(rows) == 1:
|
||||
pair_id = rows[0][0]
|
||||
|
||||
+3
-7
@@ -23,10 +23,10 @@ from pathlib import PurePosixPath, PureWindowsPath
|
||||
from urllib.parse import quote
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
from ..utils.hashing import register_prompt_functions
|
||||
from .models import normalize_image_path
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.hashing import register_prompt_functions
|
||||
from database.models import normalize_image_path
|
||||
|
||||
logger = logging.getLogger("prompt_manager.database.health")
|
||||
@@ -125,12 +125,8 @@ def _foreign_keys(conn, _roots):
|
||||
)
|
||||
|
||||
|
||||
def _prompt_hash(text):
|
||||
return generate_prompt_hash(text) if isinstance(text, str) else None
|
||||
|
||||
|
||||
def _canonical_hashes(conn, _roots):
|
||||
conn.create_function("pm_prompt_hash", 1, _prompt_hash, deterministic=True)
|
||||
register_prompt_functions(conn) # pm_prompt_hash, shared with history (#460)
|
||||
return _counted(
|
||||
f"Prompt {row[0]}: the stored hash does not match its text"
|
||||
for row in conn.execute(
|
||||
|
||||
+66
-28
@@ -15,6 +15,11 @@ from .execution_history import HistoryEpochChanged
|
||||
from .execution_outputs import _capture, _REFERENCE_COLUMNS
|
||||
from .library import _ELIGIBLE
|
||||
|
||||
try:
|
||||
from ..utils.hashing import prompt_key, stored_prompt_text
|
||||
except ImportError:
|
||||
from utils.hashing import prompt_key, stored_prompt_text
|
||||
|
||||
RunKey = tuple[int, str, int, str]
|
||||
|
||||
|
||||
@@ -100,14 +105,43 @@ _ORDER = (
|
||||
)
|
||||
# Stale-hint rows a newest-pair lookup inspects before it reports none.
|
||||
_PAIR_RUN_CANDIDATES = 16
|
||||
# The recorded texts of one scope, as a JSON array parameter (_scope_text_ids).
|
||||
_IN_SCOPE = "IN (SELECT value FROM json_each(?))"
|
||||
_MATCHING = (
|
||||
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
|
||||
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
|
||||
"JOIN execution_run_contexts rc ON rc.context_id=c.context_id "
|
||||
"WHERE s.raw_text_id=?"
|
||||
f"WHERE s.raw_text_id {_IN_SCOPE}"
|
||||
)
|
||||
|
||||
|
||||
def _scope_text_ids(conn: sqlite3.Connection, key: str) -> str:
|
||||
"""Every recorded raw text whose prompt key is ``key``, as a JSON array.
|
||||
|
||||
A run keeps its exact raw text, while the Library saves and counts it as
|
||||
the prompt with its key (outer whitespace #438, case #460). The values are
|
||||
compared, never only their digests; ``pm_prompt_key`` is Python's key.
|
||||
"""
|
||||
return json.dumps(
|
||||
[
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
# Only snapshot raw texts: effective and affix texts are skipped
|
||||
"SELECT t.text_id FROM (SELECT DISTINCT raw_text_id AS text_id "
|
||||
"FROM execution_prompt_snapshots) s CROSS JOIN "
|
||||
"execution_context_texts t WHERE t.text_id=s.text_id AND "
|
||||
"pm_prompt_key(t.text_value)=? COLLATE BINARY",
|
||||
(key,),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _scope_text_id(raw: str | None) -> str | None:
|
||||
"""The scope digest of a raw text: that of its prompt key."""
|
||||
return None if raw is None else content_digest("text", (prompt_key(raw),))
|
||||
|
||||
|
||||
def _limit(limit: int) -> None:
|
||||
if type(limit) is not int or not 1 <= limit <= 100:
|
||||
raise ValueError("History limit must be an integer from 1 to 100")
|
||||
@@ -255,8 +289,15 @@ class HistoryReadOperations:
|
||||
if saved is None:
|
||||
return None
|
||||
text = saved["text"]
|
||||
raw_id = None if text is None else content_digest("text", (text,))
|
||||
if after is not None and after.raw_text_id != raw_id:
|
||||
raw_id = _scope_text_id(text)
|
||||
# A position from before #460 names the scope by the stripped text
|
||||
# alone; it is the same scope, so a page open across the upgrade resumes.
|
||||
legacy_id = (
|
||||
None
|
||||
if text is None
|
||||
else content_digest("text", (stored_prompt_text(text),))
|
||||
)
|
||||
if after is not None and after.raw_text_id not in (raw_id, legacy_id):
|
||||
raise ValueError("History position text scope changed")
|
||||
group = {
|
||||
"positive_id": positive_id,
|
||||
@@ -273,18 +314,11 @@ class HistoryReadOperations:
|
||||
next_position=None,
|
||||
resume_position=None,
|
||||
)
|
||||
if (
|
||||
raw_id is None
|
||||
or conn.execute(
|
||||
"SELECT 1 FROM execution_context_texts WHERE text_id=? "
|
||||
"AND text_value=? COLLATE BINARY",
|
||||
(raw_id, text),
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
if raw_id is None:
|
||||
return result
|
||||
scope = _scope_text_ids(conn, prompt_key(text))
|
||||
where = f"r.run_id IN ({_MATCHING})"
|
||||
params = [raw_id]
|
||||
params = [scope]
|
||||
if after is not None:
|
||||
where += f" AND {_KEY}<=(?,?,?,?)"
|
||||
params.extend(after.upper)
|
||||
@@ -338,9 +372,9 @@ class HistoryReadOperations:
|
||||
"LEFT JOIN execution_prompt_snapshots n "
|
||||
"ON n.snapshot_id=c.negative_snapshot_id "
|
||||
"LEFT JOIN execution_context_texts t ON t.text_id=n.raw_text_id "
|
||||
f"WHERE rc.run_id IN ({placeholders}) AND p.raw_text_id=? "
|
||||
f"WHERE rc.run_id IN ({placeholders}) AND p.raw_text_id {_IN_SCOPE} "
|
||||
"ORDER BY rc.anchor_id COLLATE BINARY,rc.run_id COLLATE BINARY",
|
||||
(*by_run, raw_id),
|
||||
(*by_run, scope),
|
||||
)
|
||||
summaries = {}
|
||||
evidence_by_context = {}
|
||||
@@ -438,6 +472,7 @@ class HistoryReadOperations:
|
||||
if saved["text"] is None:
|
||||
return {"run": None}
|
||||
epoch = self.get_execution_epoch()
|
||||
key = prompt_key(saved["text"])
|
||||
candidates = conn.execute(
|
||||
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
|
||||
"c.positive_snapshot_id,c.negative_snapshot_id "
|
||||
@@ -445,10 +480,11 @@ class HistoryReadOperations:
|
||||
"ON rc.run_id=r.run_id JOIN execution_contexts c "
|
||||
"ON c.context_id=rc.context_id JOIN execution_prompt_snapshots s "
|
||||
"ON s.snapshot_id=c.positive_snapshot_id "
|
||||
"WHERE s.raw_text_id=? AND rc.pair_id=? AND rc.reference_epoch=? "
|
||||
f"WHERE s.raw_text_id {_IN_SCOPE} AND rc.pair_id=? "
|
||||
"AND rc.reference_epoch=? "
|
||||
f"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
|
||||
(
|
||||
content_digest("text", (saved["text"],)),
|
||||
_scope_text_ids(conn, key),
|
||||
pair_id,
|
||||
epoch,
|
||||
_PAIR_RUN_CANDIDATES,
|
||||
@@ -457,7 +493,11 @@ class HistoryReadOperations:
|
||||
for row in candidates:
|
||||
positive = _values(conn, row["positive_snapshot_id"])
|
||||
negative = _values(conn, row["negative_snapshot_id"])
|
||||
if positive is None or positive["raw"] != saved["text"]:
|
||||
if (
|
||||
positive is None
|
||||
or positive["raw"] is None
|
||||
or prompt_key(positive["raw"]) != key
|
||||
):
|
||||
continue
|
||||
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
|
||||
if hints[2] == pair_id:
|
||||
@@ -477,11 +517,9 @@ class HistoryReadOperations:
|
||||
snapshots = {
|
||||
row["anchor_id"]: row
|
||||
for row in conn.execute(
|
||||
"SELECT rc.anchor_id,c.positive_snapshot_id,c.negative_snapshot_id,"
|
||||
"p.raw_text_id AS positive_raw_text_id "
|
||||
"SELECT rc.anchor_id,c.positive_snapshot_id,c.negative_snapshot_id "
|
||||
"FROM execution_run_contexts rc JOIN execution_contexts c "
|
||||
"ON c.context_id=rc.context_id LEFT JOIN execution_prompt_snapshots p "
|
||||
"ON p.snapshot_id=c.positive_snapshot_id WHERE rc.run_id=?",
|
||||
"ON c.context_id=rc.context_id WHERE rc.run_id=?",
|
||||
(run_id,),
|
||||
)
|
||||
}
|
||||
@@ -491,12 +529,12 @@ class HistoryReadOperations:
|
||||
context.update(
|
||||
{
|
||||
key: row[key]
|
||||
for key in (
|
||||
"positive_snapshot_id",
|
||||
"negative_snapshot_id",
|
||||
"positive_raw_text_id",
|
||||
)
|
||||
}
|
||||
for key in ("positive_snapshot_id", "negative_snapshot_id")
|
||||
},
|
||||
# The history scope this context belongs to (#438)
|
||||
positive_raw_text_id=_scope_text_id(
|
||||
(context["positive"] or {}).get("raw")
|
||||
),
|
||||
)
|
||||
pair = context["pair_id"]
|
||||
context["navigation"] = {
|
||||
|
||||
+33
-2
@@ -121,7 +121,28 @@ def _archive(value: str) -> str:
|
||||
return ARCHIVE_SCOPES[value]
|
||||
|
||||
|
||||
def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]:
|
||||
# A parent that no tag's Browse returns (#444 "Untagged prompts"): no tag of its
|
||||
# own and no active pair carrying one. The vocabulary's untagged count uses it too.
|
||||
UNTAGGED = (
|
||||
"NOT EXISTS(SELECT 1 FROM prompt_tags ut WHERE ut.prompt_id=p.id) "
|
||||
"AND NOT EXISTS(SELECT 1 FROM prompt_pairs up JOIN prompt_pair_tags upt "
|
||||
"ON upt.pair_id=up.id WHERE up.positive_prompt_id=p.id AND up.archived=0)"
|
||||
)
|
||||
# How the Tags filter matches: every tag (AND), or parents without any tag.
|
||||
TAG_MODES = ("all", "untagged")
|
||||
|
||||
|
||||
def _tag_mode(tag_mode: object, tags: list[str]) -> str:
|
||||
if not isinstance(tag_mode, str) or tag_mode not in TAG_MODES:
|
||||
raise ValueError("tag_mode must be one of " + ", ".join(TAG_MODES))
|
||||
if tag_mode == "untagged" and tags:
|
||||
raise ValueError("Untagged prompts cannot also filter by tags")
|
||||
return tag_mode
|
||||
|
||||
|
||||
def _filter(
|
||||
text: str, category: str | None, tags: list[str], tag_mode: str = "all"
|
||||
) -> tuple[str, list]:
|
||||
if not isinstance(text, str) or len(text) > 500:
|
||||
raise ValueError("Search text must be a string of at most 500 characters")
|
||||
if category is not None and (not isinstance(category, str) or len(category) > 200):
|
||||
@@ -132,6 +153,7 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list
|
||||
or any(not isinstance(tag, str) or not tag or len(tag) > 200 for tag in tags)
|
||||
):
|
||||
raise ValueError("Invalid library tags")
|
||||
untagged = _tag_mode(tag_mode, tags) == "untagged"
|
||||
parent, pair, parent_params, pair_params = [], ["pp.archived=0"], [], []
|
||||
if text:
|
||||
parent.append("(p.text LIKE ? ESCAPE '\\' OR ls.title LIKE ? ESCAPE '\\')")
|
||||
@@ -168,6 +190,10 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list
|
||||
+ " AND ".join(pair)
|
||||
+ ")"
|
||||
)
|
||||
if untagged:
|
||||
# A whole-card condition: inside the parent-or-pair OR an untagged pair
|
||||
# would let a tagged parent through.
|
||||
condition = f"({condition}) AND {UNTAGGED}"
|
||||
return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params]
|
||||
|
||||
|
||||
@@ -251,6 +277,7 @@ class LibraryOperations:
|
||||
text: str = "",
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tag_mode: str = "all",
|
||||
sort: str = "newest",
|
||||
limit: int = 40,
|
||||
after: dict | None = None,
|
||||
@@ -258,7 +285,7 @@ class LibraryOperations:
|
||||
_limit(limit)
|
||||
if sort not in _ORDERS:
|
||||
raise ValueError("Unknown library sort order")
|
||||
where, params = _filter(text, category, [] if tags is None else tags)
|
||||
where, params = _filter(text, category, [] if tags is None else tags, tag_mode)
|
||||
if after is not None:
|
||||
if (
|
||||
not isinstance(after, dict)
|
||||
@@ -311,6 +338,8 @@ class LibraryOperations:
|
||||
"items": rows,
|
||||
"total": total,
|
||||
"variant_total": variant_total,
|
||||
# Echoed so a client can tell this read applied its tag match.
|
||||
"tag_mode": tag_mode,
|
||||
"has_more": has_more,
|
||||
"next_key": next_key,
|
||||
}
|
||||
@@ -321,6 +350,7 @@ class LibraryOperations:
|
||||
text: str = "",
|
||||
category: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tag_mode: str = "all",
|
||||
sort: str = "newest",
|
||||
limit: int = 40,
|
||||
after: dict | None = None,
|
||||
@@ -333,6 +363,7 @@ class LibraryOperations:
|
||||
text=text,
|
||||
category=category,
|
||||
tags=tags,
|
||||
tag_mode=tag_mode,
|
||||
sort=sort,
|
||||
limit=limit,
|
||||
after=after,
|
||||
|
||||
@@ -44,6 +44,11 @@ from .health import BATCH_ROWS, link_states, readable_roots, readonly_uri
|
||||
from .pairs import PairDependencyError
|
||||
from .workflow_documents import documents_of_rows, release_documents
|
||||
|
||||
try:
|
||||
from ..utils.hashing import register_prompt_functions
|
||||
except ImportError:
|
||||
from utils.hashing import register_prompt_functions
|
||||
|
||||
MAINTENANCE_OPERATIONS = (
|
||||
"duplicate_prompts",
|
||||
"optimize",
|
||||
@@ -289,6 +294,7 @@ def _read_snapshot(db_path, read):
|
||||
conn = sqlite3.connect(readonly_uri(db_path), uri=True)
|
||||
try:
|
||||
conn.row_factory = sqlite3.Row
|
||||
register_prompt_functions(conn) # LIVE_HINT_CTES calls them (#460)
|
||||
conn.execute("BEGIN") # one read snapshot for the whole plan
|
||||
return read(conn)
|
||||
finally:
|
||||
|
||||
@@ -38,6 +38,11 @@ from .review_schema import (
|
||||
review_without_annotations_error,
|
||||
)
|
||||
from .saved_views import migrate_saved_views, saved_views_schema_error
|
||||
|
||||
try:
|
||||
from ..utils.hashing import register_prompt_functions
|
||||
except ImportError:
|
||||
from utils.hashing import register_prompt_functions
|
||||
from .image_categories import image_categories_schema_error, migrate_image_categories
|
||||
from .workflow_documents import (
|
||||
DOCUMENT_ID_COLUMNS,
|
||||
@@ -153,12 +158,15 @@ def configure_connection(conn: sqlite3.Connection) -> None:
|
||||
(common on Windows) let other writers' polling busy handlers starve past
|
||||
BUSY_TIMEOUT_SECONDS and fail with "database is locked" (#242). Without
|
||||
WAL (for example an in-memory database) SQLite's FULL default is kept.
|
||||
It also registers the prompt-key SQL functions (register_prompt_functions).
|
||||
"""
|
||||
journal_mode = conn.execute("PRAGMA journal_mode = WAL").fetchone()[0]
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_SECONDS * 1000}")
|
||||
if str(journal_mode).lower() == "wal":
|
||||
conn.execute("PRAGMA synchronous = NORMAL")
|
||||
# History reads and the live-hint rule call Python's prompt key (#460)
|
||||
register_prompt_functions(conn)
|
||||
|
||||
|
||||
class PromptModel:
|
||||
|
||||
@@ -33,6 +33,8 @@ MAX_TAG_LENGTH = 50
|
||||
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_MEDIA = ("all", "image", "video")
|
||||
# How a Library view's Tags match (#444): every tag, or parents without tags.
|
||||
_TAG_MODES = ("all", "untagged")
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
|
||||
# A collection id as the listing's ``collection`` query takes it (#331): none
|
||||
@@ -67,6 +69,8 @@ _STATE_FIELDS = {
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _LIBRARY_SORTS, "newest"),
|
||||
"tags": ("tags", None, ()),
|
||||
# #444: "untagged" never goes with tags (the listing refuses both).
|
||||
"tag_mode": ("choice", _TAG_MODES, "all"),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -152,6 +156,8 @@ def encode_state(page: object, state: object) -> str:
|
||||
_validate_value(key, kind, limit, state[key])
|
||||
if state.get("favorites") is True and state.get("collection"):
|
||||
raise SavedViewInvalid("A view cannot filter by favorites and a collection")
|
||||
if state.get("tag_mode") == "untagged" and state.get("tags"):
|
||||
raise SavedViewInvalid("A view cannot list untagged prompts and filter tags")
|
||||
encoded = json.dumps(
|
||||
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
@@ -195,6 +201,9 @@ def normalize_state(page: str, raw: object) -> dict:
|
||||
# as the listing does for a URL with both (#331 D140).
|
||||
if state.get("favorites") is True and state.get("collection"):
|
||||
state["favorites"] = False
|
||||
# Never written by this build either: the tags win.
|
||||
if state.get("tag_mode") == "untagged" and state.get("tags"):
|
||||
state["tag_mode"] = "all"
|
||||
return state
|
||||
|
||||
|
||||
|
||||
+133
-10
@@ -20,7 +20,7 @@ counted, hashed into the token and renamed with the prompts and pairs.
|
||||
|
||||
import sqlite3
|
||||
|
||||
from .library import _ELIGIBLE, _limit
|
||||
from .library import _ELIGIBLE, UNTAGGED, _escape, _limit
|
||||
from .library_commands import _TOKEN, LibraryCommandError, _validate_epoch
|
||||
from .library_tokens import canonical_token
|
||||
from .models import utc_now_iso
|
||||
@@ -38,7 +38,7 @@ _IMAGE_FILES = (
|
||||
)
|
||||
|
||||
|
||||
def _tag_rows(page: str) -> str:
|
||||
def _tag_rows(page: str, order: str = "page.id") -> str:
|
||||
"""Count rows for the registry rows selected by ``page`` (id, name)."""
|
||||
# Eligible parent components carrying a tag in the page, plus the
|
||||
# positive parent of every active pair carrying it (always eligible).
|
||||
@@ -57,11 +57,45 @@ def _tag_rows(page: str) -> str:
|
||||
"COALESCE(MAX(images.n),0) AS image_count "
|
||||
"FROM page LEFT JOIN owners ON owners.tag_id=page.id "
|
||||
"LEFT JOIN images ON images.tag_id=page.id "
|
||||
"GROUP BY page.id ORDER BY page.id"
|
||||
"GROUP BY page.id ORDER BY " + order
|
||||
)
|
||||
|
||||
|
||||
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
|
||||
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
|
||||
# "created" follow the id, so every order is total and keyset pages are exact.
|
||||
TAG_SORTS = {
|
||||
"created": (None, "ASC"),
|
||||
"name": ("name COLLATE NOCASE", "ASC"),
|
||||
"name-desc": ("name COLLATE NOCASE", "DESC"),
|
||||
"prompts": ("prompt_count", "DESC"),
|
||||
"prompts-asc": ("prompt_count", "ASC"),
|
||||
}
|
||||
_MAX_SEARCH = 200
|
||||
_TAG_SEARCH = "name LIKE ? ESCAPE '\\'"
|
||||
# Every matching tag ranked by the prompt count its row shows (the owners rule
|
||||
# of ``_tag_rows``), then one keyset page of it with its image counts.
|
||||
_RANKED_TAG_PAGE = (
|
||||
"WITH owners(tag_id,pid) AS ("
|
||||
"SELECT pt.tag_id,p.id FROM prompts p "
|
||||
"JOIN prompt_tags pt ON pt.prompt_id=p.id WHERE " + _ELIGIBLE + " "
|
||||
"UNION SELECT ppt.tag_id,pp.positive_prompt_id FROM prompt_pairs pp "
|
||||
"JOIN prompt_pair_tags ppt ON ppt.pair_id=pp.id WHERE pp.archived=0),"
|
||||
"counted AS (SELECT t.id,t.name,COUNT(DISTINCT o.pid) AS prompt_count "
|
||||
"FROM tags t LEFT JOIN owners o ON o.tag_id=t.id WHERE {search} "
|
||||
"GROUP BY t.id),"
|
||||
"page AS (SELECT id,name,prompt_count FROM counted WHERE {after} "
|
||||
"ORDER BY prompt_count {direction},id LIMIT ?),"
|
||||
"images(tag_id,n) AS (" + _IMAGE_FILES + " "
|
||||
"WHERE mit.tag_id IN (SELECT id FROM page) GROUP BY mit.tag_id) "
|
||||
"SELECT page.id,page.name,page.prompt_count,"
|
||||
"COALESCE(images.n,0) AS image_count FROM page "
|
||||
"LEFT JOIN images ON images.tag_id=page.id "
|
||||
"ORDER BY page.prompt_count {direction},page.id"
|
||||
)
|
||||
_UNTAGGED_COUNT = (
|
||||
"SELECT COUNT(*) FROM prompts p WHERE (" + _ELIGIBLE + ") AND " + UNTAGGED
|
||||
)
|
||||
_TAG_ONE = _tag_rows("SELECT id,name FROM tags WHERE id=?")
|
||||
# Every owner kind a tag can sit on, for the delete confirmation.
|
||||
_TAG_IMPACT = (
|
||||
@@ -210,6 +244,28 @@ def _tag_after(after: object) -> int:
|
||||
return after
|
||||
|
||||
|
||||
def _tag_search(search: object) -> str:
|
||||
if not isinstance(search, str) or len(search) > _MAX_SEARCH:
|
||||
raise ValueError(f"Tag search must be text of at most {_MAX_SEARCH} characters")
|
||||
return search.strip()
|
||||
|
||||
|
||||
def _sorted_tag_after(after: object, sort: str) -> tuple | None:
|
||||
"""The ``[key, id]`` cursor of a name or prompt-count sort."""
|
||||
if after is None:
|
||||
return None
|
||||
key_type = str if sort.startswith("name") else int
|
||||
if (
|
||||
not isinstance(after, list)
|
||||
or len(after) != 2
|
||||
or type(after[0]) is not key_type
|
||||
or type(after[1]) is not int
|
||||
or after[1] <= 0
|
||||
):
|
||||
raise ValueError("Invalid vocabulary cursor")
|
||||
return after[0], after[1]
|
||||
|
||||
|
||||
def _category_after(after: object) -> tuple[int, str] | None:
|
||||
if after is None:
|
||||
return None
|
||||
@@ -431,22 +487,80 @@ class VocabularyOperations:
|
||||
epoch: str,
|
||||
*,
|
||||
limit: int = 100,
|
||||
after: int | None = None,
|
||||
after: object = None,
|
||||
search: str = "",
|
||||
sort: str = "created",
|
||||
) -> dict:
|
||||
"""One page of tags matching ``search`` (a literal, case-insensitive
|
||||
substring) in ``sort`` order, with the untagged parents' count."""
|
||||
_limit(limit)
|
||||
start = _tag_after(after)
|
||||
total = conn.execute("SELECT COUNT(*) FROM tags").fetchone()[0]
|
||||
rows = [dict(row) for row in conn.execute(_TAG_PAGE, (start, limit + 1))]
|
||||
search = _tag_search(search)
|
||||
if not isinstance(sort, str) or sort not in TAG_SORTS:
|
||||
raise ValueError("sort must be one of " + ", ".join(TAG_SORTS))
|
||||
where, params = "1=1", []
|
||||
if search:
|
||||
where, params = _TAG_SEARCH, [f"%{_escape(search)}%"]
|
||||
total, untagged = conn.execute(
|
||||
f"SELECT (SELECT COUNT(*) FROM tags WHERE {where}),({_UNTAGGED_COUNT})",
|
||||
params,
|
||||
).fetchone()
|
||||
rows = [
|
||||
dict(row) for row in self._tag_page(conn, where, params, sort, after, limit)
|
||||
]
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
key, _direction = TAG_SORTS[sort]
|
||||
next_key = None
|
||||
if has_more:
|
||||
last = rows[-1]
|
||||
next_key = (
|
||||
last["id"]
|
||||
if key is None
|
||||
else [
|
||||
last["prompt_count" if key == "prompt_count" else "name"],
|
||||
last["id"],
|
||||
]
|
||||
)
|
||||
return {
|
||||
"library_epoch": epoch,
|
||||
"tags": rows,
|
||||
"total": total,
|
||||
"untagged_count": untagged,
|
||||
# Echoed so a client can tell this read applied its search and sort.
|
||||
"search": search,
|
||||
"sort": sort,
|
||||
"has_more": has_more,
|
||||
"next_key": rows[-1]["id"] if has_more else None,
|
||||
"next_key": next_key,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _tag_page(conn, where, params, sort, after, limit):
|
||||
key, direction = TAG_SORTS[sort]
|
||||
if key is None:
|
||||
page = (
|
||||
f"SELECT id,name FROM tags WHERE {where} AND id>? ORDER BY id LIMIT ?"
|
||||
)
|
||||
return conn.execute(
|
||||
_tag_rows(page), [*params, _tag_after(after), limit + 1]
|
||||
)
|
||||
cursor = _sorted_tag_after(after, sort)
|
||||
operator = ">" if direction == "ASC" else "<"
|
||||
keyset, keys = "1=1", []
|
||||
if cursor is not None:
|
||||
keyset = f"({key}{operator}? OR ({key}=? AND id>?))"
|
||||
keys = [cursor[0], cursor[0], cursor[1]]
|
||||
if key == "prompt_count":
|
||||
sql = _RANKED_TAG_PAGE.format(
|
||||
search=where, after=keyset, direction=direction
|
||||
)
|
||||
return conn.execute(sql, [*params, *keys, limit + 1])
|
||||
page = (
|
||||
f"SELECT id,name FROM tags WHERE {where} AND {keyset} "
|
||||
f"ORDER BY {key} {direction},id LIMIT ?"
|
||||
)
|
||||
order = f"page.{key} {direction},page.id"
|
||||
return conn.execute(_tag_rows(page, order), [*params, *keys, limit + 1])
|
||||
|
||||
def _vocabulary_categories(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
@@ -490,9 +604,18 @@ class VocabularyOperations:
|
||||
),
|
||||
}
|
||||
|
||||
def list_vocabulary_tags(self, *, limit: int = 100, after: int | None = None):
|
||||
def list_vocabulary_tags(
|
||||
self,
|
||||
*,
|
||||
limit: int = 100,
|
||||
after: object = None,
|
||||
search: str = "",
|
||||
sort: str = "created",
|
||||
):
|
||||
with self.library_snapshot() as (conn, epoch, _ceiling):
|
||||
return self._vocabulary_tags(conn, epoch, limit=limit, after=after)
|
||||
return self._vocabulary_tags(
|
||||
conn, epoch, limit=limit, after=after, search=search, sort=sort
|
||||
)
|
||||
|
||||
def list_vocabulary_categories(
|
||||
self, *, limit: int = 100, after: list | None = None
|
||||
|
||||
+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),
|
||||
|
||||
@@ -294,6 +294,7 @@ class LibraryRoutesMixin:
|
||||
"text": request.query.get("text", ""),
|
||||
"category": request.query.get("category"),
|
||||
"tags": sorted(set(request.query.getall("tag", []))),
|
||||
"tag_mode": request.query.get("tag_mode", "all"),
|
||||
"sort": request.query.get("sort", "newest"),
|
||||
}
|
||||
limit = _page_limit(request)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+18
-5
@@ -61,18 +61,25 @@ class VocabularyRoutesMixin:
|
||||
)
|
||||
|
||||
async def _vocabulary_page(
|
||||
self, request: web.Request, kind: str, reader_name: str, name: bool
|
||||
self,
|
||||
request: web.Request,
|
||||
kind: str,
|
||||
reader_name: str,
|
||||
name: bool,
|
||||
options: dict | None = None,
|
||||
) -> web.Response:
|
||||
limit = _vocabulary_limit(request)
|
||||
cursor = request.query.get("cursor")
|
||||
options = options or {}
|
||||
|
||||
def read() -> dict:
|
||||
# The snapshot stays open while the cursor is decoded and encoded,
|
||||
# so its epoch scope is the epoch the page was read from.
|
||||
# so its epoch scope is the epoch the page was read from. A tag
|
||||
# search or sort joins the scope: its cursor fits no other order.
|
||||
with self.db.library_snapshot() as (conn, epoch, _ceiling):
|
||||
scope = _scope({"kind": kind, "epoch": epoch})
|
||||
scope = _scope({"kind": kind, "epoch": epoch, **options})
|
||||
result = getattr(self.db, reader_name)(
|
||||
conn, epoch, limit=limit, after=_decode(cursor, scope)
|
||||
conn, epoch, limit=limit, after=_decode(cursor, scope), **options
|
||||
)
|
||||
result["next_cursor"] = _encode(
|
||||
result.pop("next_key"), scope, name=name
|
||||
@@ -84,8 +91,14 @@ class VocabularyRoutesMixin:
|
||||
|
||||
@pair_response
|
||||
async def list_vocabulary_tags(self, request: web.Request) -> web.Response:
|
||||
options = {
|
||||
"search": request.query.get("search", "").strip(),
|
||||
"sort": request.query.get("sort", "created"),
|
||||
}
|
||||
# Name keys are text: kept server side so the cursor stays small.
|
||||
name = options["sort"].startswith("name")
|
||||
return await self._vocabulary_page(
|
||||
request, "vocabulary-tags", "_vocabulary_tags", False
|
||||
request, "vocabulary-tags", "_vocabulary_tags", name, options
|
||||
)
|
||||
|
||||
@pair_response
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -232,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)
|
||||
@@ -680,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),
|
||||
@@ -713,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})=>{
|
||||
@@ -550,7 +558,8 @@ test.describe('full inspector coarse pointer',()=>{
|
||||
}
|
||||
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').all()) {
|
||||
// #450 D156: the view switch is hidden at 700px and below; a shown one is measured like ×.
|
||||
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button:visible').all()) {
|
||||
const box=await target.evaluate(node=>{const {width,height}=node.getBoundingClientRect();return {width,height,name:node.getAttribute('aria-label')};});
|
||||
expect(box.width,box.name).toBeGreaterThanOrEqual(80);expect(box.height,box.name).toBeGreaterThanOrEqual(80);
|
||||
}
|
||||
@@ -613,6 +622,11 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
|
||||
const production=await page.evaluate(VIEWER_SHELL);
|
||||
// C8 rebuilds the details content; until then the shared file inspector marks the aside it renders into.
|
||||
production.paths=production.paths.map(path=>path.replace('aside#viewer-details.file-inspector','aside#viewer-details'));
|
||||
// #450 D156: production's view switch sits right before × (hidden at 700px and below); the concept has none.
|
||||
const modePaths=production.paths.filter(path=>path.includes('button#viewer-mode'));
|
||||
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
|
||||
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
|
||||
production.paths=production.paths.filter(path=>!modePaths.includes(path));
|
||||
// #249 D34: after Next → production appends its own status lines (position, then the transform and slideshow
|
||||
// reasons), and Download stays the original-file link; every other control keeps the concept's text and order.
|
||||
const appended=production.toolbar.slice(9);
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { parityPng } from './local-inspect-files.mjs';
|
||||
// `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722,
|
||||
// 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the
|
||||
// same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded
|
||||
// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
|
||||
// differences D60–D66 and D155 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
|
||||
// is measured, so the browser lays the concept out with production's words. After that the diff must be empty.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
@@ -22,7 +22,8 @@ const COPY = {
|
||||
'Read embedded metadata in this browser. The file is not uploaded or added to your library.'],
|
||||
chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'],
|
||||
note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.',
|
||||
'Your file stays in this browser. Its metadata opens with “Open file metadata”.'],
|
||||
// D155 (#448): choosing the file opens its metadata.
|
||||
'Your file stays in this browser. Choosing it opens its metadata.'],
|
||||
toast: ['Local preview only. No file uploaded; sample metadata is available above.',
|
||||
'Local preview only. No file uploaded; metadata is available above.'],
|
||||
};
|
||||
@@ -212,23 +213,29 @@ async function productionStates(page, theme, testInfo, tag) {
|
||||
await settle(page);
|
||||
const initial = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-initial-${tag}`);
|
||||
// D155 (#448): choosing the file opens the viewer with its details at once (the concept waits for its heading
|
||||
// action), so the viewer state is measured first; the selected page is measured after Escape, with the focus the
|
||||
// close returned to the chooser removed (the concept's page has no focused control there).
|
||||
await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE));
|
||||
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]);
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'false');
|
||||
// D63: a readable PNG shows no status line.
|
||||
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
|
||||
await action.click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await settle(page);
|
||||
const viewer = await page.evaluate(measureViewer);
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
// D155: no toast; the opened viewer was the confirmation.
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'false');
|
||||
// D63: a readable PNG shows no status line.
|
||||
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
|
||||
return { initial, selected, viewer };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -39,11 +39,11 @@ export function mapLibraryConcept(reference) {
|
||||
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)}))},
|
||||
@@ -139,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) {
|
||||
@@ -295,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([]);
|
||||
@@ -394,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.
|
||||
|
||||
@@ -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)]] },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -472,9 +475,9 @@ function measureAppearance(mainSelector) {
|
||||
}
|
||||
|
||||
// D97: the rows Appearance adds for the legacy gallery settings, after the concept's own, as production renders them.
|
||||
// D156 (#450) moved "Collapse viewer details by default" to the Viewer surface (workspace-viewer-mode.spec.mjs).
|
||||
const ADDED_APPEARANCE_ROWS = [
|
||||
['card-details', 'Show image details on cards', 'Show each card’s title and file details under the preview.', true],
|
||||
['viewer-details', 'Collapse viewer details by default', 'Open the image viewer with its details panel hidden.', false],
|
||||
];
|
||||
|
||||
/** The concept's Appearance with the recorded deviations applied, given the server's real settings. */
|
||||
@@ -485,10 +488,11 @@ function mapAppearanceConcept(reference, settings) {
|
||||
control: { ...toggleRow.control, id, checked } }));
|
||||
return {
|
||||
...reference,
|
||||
// #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance).
|
||||
layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)],
|
||||
// D97: two added toggles and the "Reset display defaults" operation close the surface.
|
||||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
|
||||
// D156: the Viewer surface, then (#317) the server's thumbnail settings in their own surface ("Thumbnails",
|
||||
// README: retained in Appearance).
|
||||
layout: [reference.layout[0], 'section.surface', 'section.surface', ...reference.layout.slice(1)],
|
||||
// D97: one added toggle (D156 moved the other) and the "Reset display defaults" operation close the surface.
|
||||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.operation'] },
|
||||
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
|
||||
// The node search timeout is the server's real `result_timeout`, not the concept's sample 30.
|
||||
? { ...row, control: { ...row.control, value: String(settings.result_timeout) } } : row), ...added],
|
||||
@@ -535,7 +539,7 @@ test('appearance comparison detects mutations', async ({ page, request }) => {
|
||||
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||||
.toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13',
|
||||
.toEqual(['layout.1', 'layout.2', 'layout.3', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12',
|
||||
'rows.length', 'surface.tree'].sort());
|
||||
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
@@ -614,10 +618,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 +640,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 +702,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 +721,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'; });
|
||||
|
||||
@@ -29,6 +29,19 @@ export function pageInventoryOf(main) {
|
||||
const rows=[...table.querySelectorAll('tbody tr')];
|
||||
const firstCell=rows[0]?.querySelector('td');
|
||||
const operations=[...categorySurface.querySelectorAll('.operation')].filter(visible);
|
||||
const operationOf=row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
|
||||
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
|
||||
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
|
||||
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};};
|
||||
// D149 (#444): production adds the Untagged prompts row and the tag search and sort above the table; the concept has
|
||||
// neither. The toolbar is compared on its own (toolbarInventoryOf) with the concept's page toolbar as donor.
|
||||
const untaggedRow=tagSurface.querySelector(':scope > .operation');
|
||||
const d149=untaggedRow?{untagged:operationOf(untaggedRow),
|
||||
// It starts where the concept's table starts: right after the paragraph.
|
||||
offset:px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top),
|
||||
// The vertical space the two additions take: the table starts this much lower than after the paragraph alone.
|
||||
// (Both offsets rounded first, so offset + shift is exactly the table's rounded offset.)
|
||||
shift:px(wrap.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)-px(untaggedRow.getBoundingClientRect().top-tagSurface.getBoundingClientRect().top)}:null;
|
||||
return {
|
||||
heading:{...css(heading,'display','flexDirection','justifyContent','alignItems','columnGap','rowGap','marginBottom'),
|
||||
h1:{text:h1.textContent,tabIndex:h1.tabIndex,...font(h1)},
|
||||
@@ -54,14 +67,26 @@ export function pageInventoryOf(main) {
|
||||
rows:rows.map(row=>{const cells=[...row.querySelectorAll('td')];return {cells:cells.length,height:px(row.getBoundingClientRect().height),tagLines:lines(cells[0]),tag:cells[0].textContent,prompts:cells[1].textContent,
|
||||
images:shownText(cells[2]),imagesHidden:cells[2].querySelector('.ca-visually-hidden')?.textContent??null,
|
||||
actions:[...cells[3].querySelectorAll('.row-actions button')].map(node=>node.textContent.trim())};})},
|
||||
operations:operations.map(row=>{const h3=row.querySelector('h3'),p=row.querySelector('p'),rowActions=row.querySelector('.row-actions');return {
|
||||
...css(row,'display','flexDirection','justifyContent','alignItems','columnGap','paddingTop','paddingBottom','borderTopWidth','borderTopStyle','borderTopColor'),
|
||||
height:px(row.getBoundingClientRect().height),h3:{text:h3.textContent,...font(h3)},p:{text:p.textContent,...font(p)},
|
||||
actions:{...css(rowActions,'flexShrink','flexWrap','columnGap'),buttons:[...rowActions.querySelectorAll('button')].map(buttonOf)}};}),
|
||||
operations:operations.map(operationOf),
|
||||
d149,
|
||||
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
|
||||
};
|
||||
}
|
||||
|
||||
/** A `search.toolbar`'s tokens (D149): runs in the page on the concept's page toolbar and on the tag search and sort. */
|
||||
export function toolbarInventoryOf(root) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value);
|
||||
const field=root.querySelector('.search-field'),input=field.querySelector('input'),select=root.querySelector('select');
|
||||
return {...css(root,'display','columnGap','rowGap','flexWrap','alignItems','marginBottom'),
|
||||
field:{...css(field,'display','alignItems','columnGap','flexGrow','minWidth','paddingLeft','paddingRight','borderTopWidth','borderTopStyle',
|
||||
'borderTopColor','borderTopLeftRadius','backgroundColor'),icon:Boolean(field.querySelector(':scope > svg')),height:px(field.getBoundingClientRect().height)},
|
||||
input:{type:input.type,...css(input,'fontSize','minHeight','backgroundColor','borderTopWidth','color')},
|
||||
select:{...css(select,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft'),
|
||||
height:px(select.getBoundingClientRect().height)}};
|
||||
}
|
||||
|
||||
|
||||
const countOf=(count,noun)=>`${count} ${noun}${count===1?'':'s'}`;
|
||||
|
||||
/**
|
||||
@@ -81,12 +106,17 @@ export function withImageCounts(operations,{images={},only=[]}={}) {
|
||||
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
// `imageCategories` (#332 D143) is the fixture's own image categories, none by default.
|
||||
export function mapVocabularyConcept(reference,imageCategories={}) {
|
||||
export function mapVocabularyConcept(reference,imageCategories={},{untaggedText=null,shift=0}={}) {
|
||||
const operations=reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}}));
|
||||
return {...reference,
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// D149 (#444): the Untagged prompts row is drawn as the concept's first category row with its own text and a Browse
|
||||
// action, and the table moves down by the space it and the tag search and sort take.
|
||||
const first=reference.operations[0];
|
||||
const d149=untaggedText===null?null:{untagged:{...first,h3:{...first.h3,text:'Untagged prompts'},p:{...first.p,text:untaggedText}},
|
||||
offset:reference.table.wrap.offset,shift};
|
||||
return {...reference,d149,
|
||||
table:{...reference.table,wrap:{...reference.table.wrap,offset:reference.table.wrap.offset+shift},rows:reference.table.rows.map(row=>({...row,
|
||||
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
|
||||
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
|
||||
images:'0'}))},
|
||||
@@ -94,9 +124,14 @@ export function mapVocabularyConcept(reference,imageCategories={}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The D149 mapping inputs: the fixture's Untagged prompts count text (none by default) and production's table shift. */
|
||||
export function d149Of(actual,untaggedText='0 positive prompts') {
|
||||
return actual.d149?{untaggedText,shift:actual.d149.shift}:{};
|
||||
}
|
||||
|
||||
/** The page comparison: concept rows are the concept's sample tags; production appends fixture-only tags (D4). */
|
||||
export function compareVocabularyPage(reference,actual,imageCategories={}) {
|
||||
const expected=mapVocabularyConcept(reference,imageCategories);
|
||||
const expected=mapVocabularyConcept(reference,imageCategories,d149Of(actual));
|
||||
const shared=expected.table.rows.length;
|
||||
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
|
||||
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -897,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']);
|
||||
@@ -916,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);
|
||||
|
||||
@@ -8,10 +8,11 @@ import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised,
|
||||
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
|
||||
const NO_TOAST = '';
|
||||
const ACTION = 'Open file metadata';
|
||||
const COPY = {
|
||||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||||
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
|
||||
note: 'Your file stays in this browser. Choosing it opens its metadata.',
|
||||
chooser: 'Choose an image or video for a local preview',
|
||||
multiple: 'Drop one file at a time.',
|
||||
directory: 'Folders cannot be inspected. Choose one image or video file.',
|
||||
@@ -53,13 +54,26 @@ async function choose(page, file) {
|
||||
await chooser(page).setInputFiles(file);
|
||||
}
|
||||
|
||||
/** D155: choosing a file opens the viewer on it with its details; waits for that. */
|
||||
async function openViewer(page) {
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await action(page).click();
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(rail(page).locator('.panel-heading')).toBeVisible();
|
||||
}
|
||||
|
||||
/** After the viewer was closed, the heading action (D60) opens it again on the current selection. */
|
||||
async function reopenViewer(page) {
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await action(page).click();
|
||||
await openViewer(page);
|
||||
}
|
||||
|
||||
/** Waits for the viewer a selection opened (D155) and closes it, back on the page. */
|
||||
async function closeOpened(page) {
|
||||
await openViewer(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
|
||||
function drop(page, selector, { files = [], strings = [] } = {}) {
|
||||
return page.evaluate(async ({ selector, files, strings }) => {
|
||||
@@ -152,7 +166,8 @@ test('chooser opens the shared full inspector without a second read', async ({ p
|
||||
const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
|
||||
expect(box.height).toBeLessThanOrEqual(400);
|
||||
expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
// D155: the open viewer is the confirmation, so no toast.
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
|
||||
|
||||
@@ -195,18 +210,120 @@ test('chooser opens the shared full inspector without a second read', async ({ p
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(action(page)).toBeFocused();
|
||||
// D155: focus returns to the chooser that opened it.
|
||||
await expect(chooser(page)).toBeFocused();
|
||||
// Opening the viewer again shows the same record without reading the File a second time.
|
||||
await page.evaluate(() => {
|
||||
window.__slices = 0;
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
|
||||
});
|
||||
await openViewer(page);
|
||||
await reopenViewer(page);
|
||||
expect(await railContent(rail(page))).toEqual(served);
|
||||
expect(await page.evaluate(() => window.__slices)).toBe(0);
|
||||
});
|
||||
|
||||
// #448 (D155): choosing or dropping a file opens the shared viewer with its details showing, in one step. Closing it
|
||||
// returns to the page with the file still chosen and focus on the chooser, ready for the next file.
|
||||
test('D155: choosing a file opens the viewer with its details in one click', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(rail(page)).toBeVisible();
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||||
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
|
||||
// The open viewer is the confirmation: no toast says the metadata is "available above".
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(chooser(page)).toBeFocused();
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
// A dropped second file replaces the first and opens at once.
|
||||
await drop(page, '.file-drop', { files: [sendablePng()] });
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('send-me.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(chooser(page)).toBeFocused();
|
||||
// The same file again is a new selection and opens again; the heading action reopens the current one.
|
||||
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await action(page).click();
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(rail(page)).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(action(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test('D155: Inspect shows the details even when the viewer collapses them by default (amends D97)', async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(rail(page)).toBeVisible();
|
||||
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await toggle.click();
|
||||
await expect(rail(page)).toBeHidden();
|
||||
await page.keyboard.press('Escape');
|
||||
await action(page).click();
|
||||
await expect(rail(page)).toBeVisible();
|
||||
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('D155: an unreadable file stays on the page with its alert', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
|
||||
return slice.apply(this, args);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png', 'gone.png'));
|
||||
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('D155: an AVI keeps its note and toast and does not open the viewer', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
// AVI and WMV metadata is never read and the browser cannot play them, so the page keeps the selection's note.
|
||||
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
|
||||
await choose(page, avi);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await reopenViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('clip.avi');
|
||||
});
|
||||
|
||||
test('D155: a selection that lands while another dialog is open leaves the viewer closed', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
await page.evaluate(() => { const other = document.createElement('dialog'); other.id = 'other-dialog'; other.textContent = 'Other'; document.body.append(other); other.showModal(); });
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await page.evaluate(() => document.querySelector('#other-dialog').close());
|
||||
await reopenViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
});
|
||||
|
||||
test('D155: at 390px the opened details are reachable below the media', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
const facts = rail(page).locator('.meta-grid').first();
|
||||
await facts.scrollIntoViewIfNeeded();
|
||||
await expect(facts).toBeInViewport();
|
||||
expect(await viewer(page).evaluate(node => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the
|
||||
// prompts, their status lines and the facts Images shows for the same bytes in the output folder.
|
||||
const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12',
|
||||
@@ -260,7 +377,9 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
|
||||
await expect(preview(page)).toBeEmpty();
|
||||
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
// D155: the drop opened the viewer; drops are ignored while it is open (D64), so close it first.
|
||||
await closeOpened(page);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
|
||||
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
|
||||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
@@ -275,7 +394,7 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
}
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await openViewer(page);
|
||||
await reopenViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(page).toHaveURL(/#\/inspect$/);
|
||||
});
|
||||
@@ -285,6 +404,7 @@ test('cancel and same-file reselect', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await closeOpened(page);
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
// The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
|
||||
expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
|
||||
@@ -308,6 +428,7 @@ test('cancel and same-file reselect', async ({ page }) => {
|
||||
const state = await urls(page);
|
||||
expect(state.created).toHaveLength(2);
|
||||
expect(state.revoked).toEqual([first]);
|
||||
// The new selection opened the viewer again (D155).
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
|
||||
});
|
||||
@@ -337,6 +458,7 @@ test('preview does not imply metadata support', async ({ page }) => {
|
||||
// Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
|
||||
const before = (await urls(page)).created.length;
|
||||
for (const file of disguised()) {
|
||||
// Unsupported: nothing opens (D155).
|
||||
await choose(page, file);
|
||||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
@@ -360,6 +482,7 @@ test('local video plays inline and in the viewer with real tags', async ({ page,
|
||||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||||
await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await choose(page, tinyWebm());
|
||||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||||
await openViewer(page);
|
||||
@@ -386,6 +509,9 @@ test('the toast and a rejected drop never contradict the status line', async ({
|
||||
// concept's toast interval.
|
||||
await choose(page, undecodableMp4());
|
||||
const failed = status(page).filter({ hasText: COPY.previewFailed });
|
||||
// D155: the selection opened the viewer; its media failed there too, and the page's alert waits under it.
|
||||
await expect(failed).toHaveCount(1);
|
||||
await closeOpened(page);
|
||||
await expect(failed).toHaveCount(1);
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
const rejected = status(page).filter({ hasText: COPY.multiple });
|
||||
@@ -442,7 +568,8 @@ test('URLs and media end with their owning session', async ({ page }) => {
|
||||
let state = await urls(page);
|
||||
expect(state.revoked).toEqual([state.created[0]]);
|
||||
expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
|
||||
// Route change.
|
||||
// Route change, from the page (D155: the selection opened the viewer, which makes the sidebar inert).
|
||||
await closeOpened(page);
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
state = await urls(page);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,26 @@ test('library groups reusable negatives under one positive without exposing nega
|
||||
for(const item of rows.items) await expect(page.locator(`article[data-prompt-id="${item.id}"] .card-meta > span`).nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
|
||||
});
|
||||
|
||||
// #464: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for seconds;
|
||||
// a loaded page of prompts is shown without waiting for one.
|
||||
test('a held animation frame does not hide a loaded page of prompts, the first or a later one',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:45}})).toBeOK();
|
||||
const {total}=await(await request.get('/prompt_manager/library?limit=100')).json();
|
||||
await page.addInitScript(()=>{
|
||||
const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
|
||||
const held=new Map(),real=new Map();let holding=true,next=0;
|
||||
window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;};
|
||||
window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);};
|
||||
window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();};
|
||||
});
|
||||
await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
await expect(more).toHaveAttribute('aria-disabled','false');await expect(cards(page)).toHaveCount(40);
|
||||
await more.evaluate(node=>node.click());await expect(page.locator('.library-status')).toHaveText('You’re all caught up.');
|
||||
await expect(cards(page)).toHaveCount(total);
|
||||
await page.evaluate(()=>window.__releaseAnimationFrames());await nextFrame(page);
|
||||
await expect(cards(page)).toHaveCount(total);await expect(page.locator('.library-row-spacer')).toHaveCount(0);
|
||||
});
|
||||
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
@@ -152,13 +172,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);
|
||||
@@ -168,7 +200,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
|
||||
// D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted.
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await expect(refine(page).locator('.field label')).toHaveText(['Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3);
|
||||
@@ -239,7 +271,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 +478,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 +631,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');
|
||||
|
||||
@@ -634,13 +667,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);
|
||||
|
||||
@@ -208,7 +208,7 @@ test('a view stored under older rules still lists and opens with its usable valu
|
||||
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
|
||||
expect(await listed(request)).toEqual([
|
||||
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }),
|
||||
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all' } }),
|
||||
]);
|
||||
// A state that is no JSON object is never listed, and its link behaves like a missing view.
|
||||
const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' });
|
||||
@@ -230,9 +230,34 @@ 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: [], tag_mode: 'all' } });
|
||||
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: [] } });
|
||||
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
@@ -275,7 +300,7 @@ test('a toolbar change while sources load keeps the saved Source and Folder (#24
|
||||
});
|
||||
|
||||
test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
let releaseList = () => {};
|
||||
const holdList = async () => {
|
||||
const held = new Promise(resolve => { releaseList = resolve; });
|
||||
@@ -323,7 +348,7 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images');
|
||||
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
let failing = true;
|
||||
await page.route(`**${API}`, route => failing && route.request().method() === 'GET'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' })
|
||||
@@ -514,7 +539,7 @@ test('Edit renames an open view, and deletes it only after confirming inside the
|
||||
});
|
||||
|
||||
test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all' } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -287,7 +287,8 @@ test('Appearance › Thumbnails saves the server thumbnail settings and never co
|
||||
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK();
|
||||
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK();
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1);
|
||||
// #450 D156: the Viewer surface sits between Browsing & display and Thumbnails.
|
||||
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(2);
|
||||
await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails');
|
||||
await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']);
|
||||
const format = surface.locator('#thumbnail-format');
|
||||
@@ -373,13 +374,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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// #450 (TESTING.md D156): every viewer has a full-screen toggle beside ✕. Full screen fills the browser window edge to
|
||||
// edge, Centered is the concept's framed dialog; the button names the view it switches to. System › Appearance ›
|
||||
// Viewer › "Open viewer in" chooses how the viewer opens; the button changes the current view only.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openProduction } from './concept-inventory.mjs';
|
||||
import { parityPng } from './local-inspect-files.mjs';
|
||||
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
|
||||
const CARDS = '.gallery-body > .image-grid > .media-card';
|
||||
const viewer = page => page.locator('dialog#viewer-dialog[open]');
|
||||
const modeButton = page => viewer(page).locator('.dialog-header > button#viewer-mode');
|
||||
const details = page => viewer(page).locator('#viewer-details');
|
||||
|
||||
test.beforeEach(async ({ request }, testInfo) => { testInfo.manifest = await seedConcept(request); });
|
||||
|
||||
async function openImages(page, { width = 1600, height = 1100, theme = 'dark' } = {}) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await openProduction(page, 'images', theme);
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
|
||||
}
|
||||
|
||||
/** The dialog's box and edges against the window. */
|
||||
const frame = page => viewer(page).evaluate(node => {
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
return { left: Math.round(box.left), top: Math.round(box.top), right: Math.round(innerWidth - box.right),
|
||||
bottom: Math.round(document.documentElement.clientHeight - box.bottom), radius: style.borderTopLeftRadius,
|
||||
border: style.borderTopWidth, fills: Math.abs(box.width - document.documentElement.clientWidth) <= 1 };
|
||||
});
|
||||
|
||||
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
|
||||
await openImages(page);
|
||||
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
|
||||
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
|
||||
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
|
||||
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
|
||||
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
|
||||
const centered = await frame(page);
|
||||
expect(centered.left).toBeGreaterThan(0);
|
||||
expect(centered.fills).toBe(false);
|
||||
// Keyboard: the button is reachable and keeps focus after a switch.
|
||||
await viewer(page).locator('#viewer-close').focus();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(modeButton(page)).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
|
||||
await expect(modeButton(page)).toBeFocused();
|
||||
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
|
||||
expect(await frame(page)).toEqual({ left: 0, top: 0, right: 0, bottom: 0, radius: '0px', border: '0px', fills: true });
|
||||
// The details stay toggleable in full screen.
|
||||
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await toggle.click();
|
||||
await expect(details(page)).toBeHidden();
|
||||
await viewer(page).getByRole('button', { name: 'Show details', exact: true }).click();
|
||||
await expect(details(page)).toBeVisible();
|
||||
// Back to Centered: today's dialog, with no toggle while the collapse setting is off.
|
||||
await modeButton(page).click();
|
||||
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
|
||||
expect(await frame(page)).toEqual(centered);
|
||||
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
|
||||
// Navigation keeps the current view; Esc closes from full screen; the next open is Centered again.
|
||||
await modeButton(page).click();
|
||||
await viewer(page).getByRole('button', { name: 'Next media', exact: true }).click();
|
||||
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
|
||||
await viewer(page).getByRole('button', { name: 'Hide details', exact: true }).focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
|
||||
});
|
||||
|
||||
test('switching back to Centered keeps the collapse setting\'s toggle and hidden details (D97)', async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(details(page)).toBeHidden();
|
||||
await modeButton(page).click();
|
||||
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toBeVisible();
|
||||
await modeButton(page).click();
|
||||
await expect(details(page)).toBeHidden();
|
||||
await expect(viewer(page).getByRole('button', { name: 'Show details', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('System › Appearance › Viewer chooses how the viewer opens; the button changes only the current view', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openProduction(page, 'system/appearance', 'dark');
|
||||
const surface = page.locator('#workspace-main section.surface').filter({ has: page.locator('h2', { hasText: /^Viewer$/ }) });
|
||||
await expect(surface.locator('.setting-row > div > :is(label, h3)')).toHaveText(['Open viewer in', 'Collapse viewer details by default']);
|
||||
const select = surface.getByRole('combobox', { name: 'Open viewer in', exact: true });
|
||||
await expect(select).toHaveValue('centered');
|
||||
await select.selectOption('fullscreen');
|
||||
await page.getByRole('button', { name: 'Save preferences', exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings') || '{}').viewerOpenMode)).toBe('fullscreen');
|
||||
await page.evaluate(() => { location.hash = '#/images'; });
|
||||
await page.locator(CARDS).first().locator('.image-open').click();
|
||||
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
|
||||
await expect(modeButton(page)).toHaveAccessibleName('Centered view');
|
||||
await modeButton(page).click();
|
||||
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
|
||||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings')).viewerOpenMode)).toBe('fullscreen');
|
||||
});
|
||||
|
||||
test('Inspect opens full screen with its details when that is the default (D155)', async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ viewerOpenMode: 'fullscreen', sidebarCollapsedByDefault: true })));
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openProduction(page, 'inspect', 'dark');
|
||||
await page.locator('#local-file').setInputFiles(parityPng('f02.png'));
|
||||
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
|
||||
await expect(details(page)).toBeVisible();
|
||||
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('the Library gallery viewer has the button too', async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openProduction(page, `library/${testInfo.manifest.prompts[0].positive_id}`, 'dark');
|
||||
await page.locator('.media-card .image-open').first().click();
|
||||
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
|
||||
});
|
||||
|
||||
test('at 390px the viewer is already full-bleed and the button hides', async ({ page }) => {
|
||||
await openImages(page, { width: 390, height: 844 });
|
||||
await expect(viewer(page).locator('#viewer-mode')).toBeHidden();
|
||||
await expect(viewer(page).locator('#viewer-close')).toBeVisible();
|
||||
});
|
||||
|
||||
test('full screen holds at 200% zoom and coarse pointers get a 40px target', async ({ page, browserName }) => {
|
||||
await openImages(page);
|
||||
await modeButton(page).click();
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
const zoomed = await frame(page);
|
||||
expect(zoomed).toMatchObject({ left: 0, top: 0, right: 0, bottom: 0 });
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '1'; });
|
||||
if (await page.evaluate(() => matchMedia('(pointer: coarse)').matches)) {
|
||||
const size = await modeButton(page).evaluate(node => [node.getBoundingClientRect().width, node.getBoundingClientRect().height]);
|
||||
expect(Math.min(...size)).toBeGreaterThanOrEqual(40);
|
||||
}
|
||||
test.info().annotations.push({ type: 'engine', description: browserName });
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// #444 (D149): Tags & categories tag search, tag sort and the Untagged prompts row, against the real server.
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
|
||||
const route='/prompt_manager/workspace#/tags';
|
||||
const main=page=>page.locator('#workspace-main');
|
||||
const tagSurface=page=>main(page).locator('.two-col > section.surface').first();
|
||||
const table=page=>main(page).getByRole('table',{name:'Reusable tags',exact:true});
|
||||
const rows=page=>table(page).locator('tbody tr');
|
||||
const names=page=>rows(page).locator('td:first-child');
|
||||
const tagRow=(page,name)=>rows(page).filter({has:page.locator('td:first-child').getByText(name,{exact:true})});
|
||||
const search=page=>page.getByRole('searchbox',{name:'Search tags',exact:true});
|
||||
const sort=page=>page.getByRole('combobox',{name:'Sort tags',exact:true});
|
||||
const untagged=page=>tagSurface(page).locator('.operation').filter({has:page.getByRole('heading',{level:3,name:'Untagged prompts',exact:true})});
|
||||
const status=page=>tagSurface(page).locator('search.vocabulary-tools [role="status"]');
|
||||
const shelf=page=>page.locator('article[data-prompt-id]');
|
||||
const filtersButton=(page,name)=>page.locator('#workspace-main search.toolbar').getByRole('button',{name,exact:true});
|
||||
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
|
||||
async function seed(request,data={}) {
|
||||
const response=await request.post('/__test__/vocabulary/reset',{data});await expect(response).toBeOK();return response.json();
|
||||
}
|
||||
let manifest;
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
await startCoverage(page,browserName);await armUnseenRestores(page);
|
||||
// Two minimal recipes without tags (the concept recipes all carry tags).
|
||||
manifest=await seed(request,{extra_categories:2});
|
||||
page.vocabularyErrors=[];page.on('pageerror',error=>page.vocabularyErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.vocabularyErrors).toEqual([]);});
|
||||
|
||||
async function open(page) {
|
||||
await page.goto(route);await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||||
}
|
||||
|
||||
test('the tag vocabulary surface orders its paragraph, Untagged prompts, the search and sort, then the table',async({page})=>{
|
||||
await open(page);
|
||||
const order=await tagSurface(page).evaluate(node=>[...node.children].map(child=>child.matches('.operation')?'operation':
|
||||
child.matches('search.toolbar')?'toolbar':child.matches('.table-wrap')?'table':child.tagName.toLowerCase()).slice(0,5));
|
||||
expect(order).toEqual(['h2','p','operation','toolbar','table']);
|
||||
await expect(untagged(page).locator('p')).toHaveText('2 positive prompts');
|
||||
await expect(untagged(page).getByRole('button')).toHaveText(['Browse']);
|
||||
await expect(search(page)).toHaveAttribute('placeholder','Search tags…');
|
||||
await expect(search(page)).toHaveAttribute('maxlength','200');
|
||||
await expect(sort(page).locator('option')).toHaveText(['Oldest first','Name A–Z','Name Z–A','Most prompts','Fewest prompts']);
|
||||
await expect(sort(page)).toHaveValue('created');
|
||||
await expect(tagSurface(page).locator('label.search-field svg, label.search-field .ca-icon').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('search narrows the tags on the server, says when nothing matches and keeps the categories',async({page})=>{
|
||||
await open(page);
|
||||
const categories=await main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3').allTextContents();
|
||||
const reads=[];page.on('request',request=>{if(request.url().includes('/vocabulary/tags?'))reads.push(new URL(request.url()).searchParams);});
|
||||
await search(page).fill('LIGHT');
|
||||
await expect(names(page)).toHaveText(['warm_light','soft_light']);
|
||||
await expect(status(page)).toHaveText('2 tags');
|
||||
expect(reads.at(-1).get('search')).toBe('LIGHT');
|
||||
// Literal: an underscore is not a wildcard.
|
||||
await search(page).fill('a_c');await expect(rows(page)).toHaveCount(0);
|
||||
await expect(tagSurface(page).locator('p.empty')).toHaveText('No tags match this search.');
|
||||
await expect(status(page)).toHaveText('No tags match this search.');
|
||||
await expect(main(page).locator('.two-col > section.surface').nth(1).locator('.operation h3')).toHaveText(categories);
|
||||
await search(page).fill('');await expect(rows(page)).toHaveCount(manifest.tags.length);
|
||||
await expect(status(page)).toHaveText(`${manifest.tags.length} tags`);
|
||||
});
|
||||
|
||||
test('sort orders every tag by name or by prompts, ties in creation order',async({page})=>{
|
||||
await open(page);
|
||||
const created=await names(page).allTextContents();
|
||||
await sort(page).selectOption('name');
|
||||
const byName=[...created].sort((a,b)=>a.toLowerCase()<b.toLowerCase()?-1:a.toLowerCase()>b.toLowerCase()?1:0);
|
||||
await expect(names(page)).toHaveText(byName);
|
||||
await sort(page).selectOption('name-desc');await expect(names(page)).toHaveText([...byName].reverse());
|
||||
await sort(page).selectOption('prompts');
|
||||
await expect(rows(page).first().locator('td:first-child')).toHaveText('cinematic');
|
||||
const counts=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number);
|
||||
expect(counts).toEqual([...counts].sort((a,b)=>b-a));
|
||||
await sort(page).selectOption('prompts-asc');
|
||||
const ascending=(await rows(page).locator('td:nth-child(2)').allTextContents()).map(Number);
|
||||
expect(ascending).toEqual([...ascending].sort((a,b)=>a-b));
|
||||
await sort(page).selectOption('created');await expect(names(page)).toHaveText(created);
|
||||
});
|
||||
|
||||
test('a rename while searching re-reads the same search and sort',async({page})=>{
|
||||
await open(page);
|
||||
await sort(page).selectOption('name');await search(page).fill('light');
|
||||
await expect(names(page)).toHaveText(['soft_light','warm_light']);
|
||||
await tagRow(page,'warm_light').getByRole('button',{name:'Rename / merge',exact:true}).click();
|
||||
const box=page.locator('dialog.vocabulary-dialog');
|
||||
await box.getByLabel('New name; existing names merge',{exact:true}).fill('amber_light');
|
||||
await box.getByRole('button',{name:'Apply',exact:true}).click();await expect(box).toHaveCount(0);
|
||||
await expect(names(page)).toHaveText(['amber_light','soft_light']);
|
||||
await expect(tagRow(page,'amber_light').getByRole('button',{name:'Rename / merge',exact:true})).toBeFocused();
|
||||
await expect(search(page)).toHaveValue('light');await expect(sort(page)).toHaveValue('name');
|
||||
});
|
||||
|
||||
test('Untagged prompts Browse opens the Library on exactly the prompts without tags',async({page})=>{
|
||||
await open(page);
|
||||
await untagged(page).getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
await expect(shelf(page)).toHaveCount(2);
|
||||
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(/^2 positive prompts · /);
|
||||
for(const card of await shelf(page).all())await expect(card.locator('.tags .chip')).toHaveCount(0);
|
||||
await filtersButton(page,'Filters · 1').click();
|
||||
await expect(refine(page).getByRole('combobox',{name:'Tag match',exact:true})).toHaveValue('untagged');
|
||||
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeDisabled();
|
||||
// Changing only the Tag match applies it: the whole shelf returns.
|
||||
await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('all');
|
||||
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toBeEnabled();
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await expect(filtersButton(page,'Filters')).toBeVisible();
|
||||
await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test('Refine › Prompts without tags on the shelf drops typed tags and Clear restores All',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');await expect(shelf(page).first()).toBeVisible();
|
||||
await filtersButton(page,'Filters').click();
|
||||
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill('cinematic');
|
||||
await refine(page).getByRole('combobox',{name:'Tag match',exact:true}).selectOption('untagged');
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await expect(shelf(page)).toHaveCount(2);
|
||||
await filtersButton(page,'Filters · 1').click();
|
||||
await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('');
|
||||
await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();
|
||||
await expect(filtersButton(page,'Filters')).toBeVisible();await expect.poll(()=>shelf(page).count()).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test('an older server that ignores the search shows an error with Retry, never an unfiltered list',async({page})=>{
|
||||
await open(page);
|
||||
// An un-restarted server answers without the echo.
|
||||
await page.route('**/prompt_manager/vocabulary/tags?*',async route=>{
|
||||
const response=await route.fetch();const body=await response.json();delete body.search;delete body.sort;
|
||||
await route.fulfill({response,json:body});
|
||||
});
|
||||
await search(page).fill('light');
|
||||
await expect(tagSurface(page).locator('.vocabulary-error')).toBeVisible();
|
||||
await expect(rows(page)).toHaveCount(0);
|
||||
await expect(tagSurface(page).getByRole('button',{name:'Retry',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('the search and sort fit 390px without a page scroll',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await open(page);
|
||||
const layout=await tagSurface(page).evaluate(node=>{
|
||||
const tools=node.querySelector('search.vocabulary-tools'),field=tools.querySelector('.search-field'),select=tools.querySelector('select');
|
||||
const box=node.getBoundingClientRect(),fieldBox=field.getBoundingClientRect(),selectBox=select.getBoundingClientRect();
|
||||
return {inside:fieldBox.left>=box.left&&selectBox.right<=box.right+0.5,fullField:Math.round(fieldBox.width)===Math.round(tools.getBoundingClientRect().width)};
|
||||
});
|
||||
expect(layout).toEqual({inside:true,fullField:true});
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Artifact-only captures for the maintainer's quick check (no pixel assertions): a search with a sort, the Library on
|
||||
// Untagged prompts and its Refine dialog.
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`D149 captures ${width} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});await open(page);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await sort(page).selectOption('prompts');await search(page).fill('i');await expect(status(page)).toHaveText(/^\d+ tags$/);
|
||||
await page.mouse.move(0,0);
|
||||
await captureCanonicalState(page,testInfo,`production-tags-search-${width}-${theme}`,'#workspace-main');
|
||||
await untagged(page).getByRole('button',{name:'Browse',exact:true}).click();await expect(shelf(page)).toHaveCount(2);
|
||||
await captureCanonicalState(page,testInfo,`production-library-untagged-${width}-${theme}`,'#workspace-main');
|
||||
await filtersButton(page,'Filters · 1').click();await expect(refine(page)).toBeVisible();
|
||||
await captureCanonicalState(page,testInfo,`production-refine-untagged-${width}-${theme}`,'dialog[open]');
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog} from './vocabulary-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
const route='/prompt_manager/workspace#/tags';
|
||||
// Prompts per concept tag (app.js:71-165): first appearance order, Library Browse counts.
|
||||
@@ -786,6 +786,15 @@ async function openProductionTags(page,theme,motion) {
|
||||
await expect(rows(page)).toHaveCount(manifest.tags.length);await expect(categoryRow(page,'Landscapes')).toHaveCount(1);
|
||||
}
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||
/** D149 donor: the concept's own page toolbar (Prompt library), whose search field and select the tag search reuses. */
|
||||
async function conceptToolbar(page,theme) {
|
||||
await page.goto('about:blank');await page.goto(`${concept}#library`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const inventory=await page.locator('#main search.toolbar').evaluate(toolbarInventoryOf);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
return inventory;
|
||||
}
|
||||
const tagToolbar=page=>page.locator('#workspace-main .two-col > section.surface:first-child > search.toolbar').evaluate(toolbarInventoryOf);
|
||||
/** Breadcrumb, document title (D7: "Tags", only the concept suffix differs) and the keyboard focus ring on a row action. */
|
||||
async function chromeOf(page,production) {
|
||||
const scope=page.locator(production?'#workspace-main':'#main');
|
||||
@@ -805,7 +814,7 @@ async function chromeOf(page,production) {
|
||||
}
|
||||
/** Concept rows are the concept's own sample tags; production appends the fixture-only tags after them (D4 creation order). */
|
||||
function comparePage(reference,actual) {
|
||||
const expected=mapVocabularyConcept(reference);
|
||||
const expected=mapVocabularyConcept(reference,{},d149Of(actual));
|
||||
const shared=expected.table.rows.length;
|
||||
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
|
||||
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
|
||||
@@ -818,13 +827,18 @@ for(const [width,height] of SIZES)for(const theme of ['dark','light'])test(`tags
|
||||
await openConceptTags(page,theme,motion);const reference=await pageOf(page,false);
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-tags-${width}-${theme}`,'#main');
|
||||
const referenceChrome=await chromeOf(page,false);
|
||||
const referenceTools=await conceptToolbar(page,theme);
|
||||
await openProductionTags(page,theme,motion);const actual=await pageOf(page,true);
|
||||
const actualTools=await tagToolbar(page);
|
||||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-tags-${width}-${theme}`,'#workspace-main');
|
||||
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true)};
|
||||
result[motion]={reference,actual,...comparePage(reference,actual),referenceChrome,actualChrome:await chromeOf(page,true),
|
||||
toolbarDiff:diffInventory(referenceTools,actualTools)};
|
||||
}
|
||||
const path=testInfo.outputPath('tags-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('tags-geometry',{path,contentType:'application/json'});
|
||||
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome}] of Object.entries(result)) {
|
||||
for(const [motion,{reference,diff,extra,referenceChrome,actualChrome,toolbarDiff}] of Object.entries(result)) {
|
||||
// D149 (#444): the tag search and sort carry the concept page toolbar's tokens.
|
||||
expect.soft(toolbarDiff,`${motion} tag search and sort against the concept toolbar`).toEqual([]);
|
||||
expect.soft(referenceChrome,`${motion} concept chrome`).toMatchObject({breadcrumb:'Tags',title:'Tags',ring:{visible:true},linkRing:{outlineOffset:'3px'}});
|
||||
expect.soft(actualChrome,`${motion} breadcrumb, title and focus ring`).toEqual(referenceChrome);
|
||||
expect.soft(reference.heading.actions.buttons.map(item=>item.text),`${motion} concept heading actions`).toEqual(['+ Create tag','Auto-tag images']);
|
||||
@@ -894,6 +908,13 @@ test('mutation check: the comparison reports a changed surface padding and a cha
|
||||
expect(paths).toContain('surfaces.1.paddingLeft');
|
||||
expect(paths).toContain('table.rows.0.actions.0');
|
||||
expect(paths).toContain('table.rowActions.buttons.0.text');
|
||||
// D149: a moved Untagged prompts row and a restyled tag search are reported too.
|
||||
await page.addStyleTag({content:'.vocabulary-main .surface > .operation{margin-top:6px!important}.vocabulary-tools .search-field{border-radius:9px!important}'});
|
||||
const moved=comparePage(reference,await pageOf(page,true)).diff.map(item=>item.path);
|
||||
expect(moved).toContain('d149.offset');
|
||||
const tools=await conceptToolbar(page,'dark');await openProductionTags(page,'dark','no-preference');
|
||||
await page.addStyleTag({content:'.vocabulary-tools .search-field{border-radius:9px!important}'});
|
||||
expect(diffInventory(tools,await tagToolbar(page)).map(item=>item.path)).toContain('field.borderTopLeftRadius');
|
||||
// The dialog comparison is not vacuous either: a removed Cancel and a recolored field are reported.
|
||||
const donors=await conceptDonors(page,'dark');
|
||||
await openConceptTags(page,'dark','no-preference');await conceptDialogs.create(page);
|
||||
|
||||
@@ -472,7 +472,7 @@ test('System is native: the concept shell, section navigation, sections and the
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'section.surface', 'div.save-row'] /* D156: Browsing & display, Viewer, Thumbnails */,
|
||||
sources: ['div.notice', 'section.surface', 'div.save-row'],
|
||||
integrations: ['section.surface', 'section.surface', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -306,6 +309,21 @@ test('fresh registered PM variants preserve exact raw whitespace and known empty
|
||||
expect(value.negative.raw).toBe('');
|
||||
expect(value.negative.effective).toBe('');
|
||||
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'recorded', source: 'runtime_return' });
|
||||
// #438: the Library stores the stripped text; the run is in that prompt's history
|
||||
expect(value.positive_prompt_id, 'the run names the prompt the Library saved').toEqual(expect.any(Number));
|
||||
const runId = (await readExecutionRuns(execution.databasePath, id)).rows[0].run_id;
|
||||
const response = await execution.request.get(`/prompt_manager/library/${value.positive_prompt_id}/history`);expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).items.map(item => item.run_id)).toContain(runId);
|
||||
if (kind !== 'PromptManager') continue;
|
||||
// #460: an upper-case re-queue counts toward the same prompt and joins its history
|
||||
const upper = await submit(execution.request, recipeGraph({ kind, text: `${kind.toUpperCase()} E\u0301` }));
|
||||
await history(execution.request, upper);
|
||||
const variant = (await capture(execution, upper)).contexts[0];
|
||||
expect(variant.positive.raw).toBe(`${kind.toUpperCase()} E\u0301`);
|
||||
expect(variant.positive_prompt_id, 'the case variant names the same prompt').toBe(value.positive_prompt_id);
|
||||
const variantRun = (await readExecutionRuns(execution.databasePath, upper)).rows[0].run_id;
|
||||
const scoped = await execution.request.get(`/prompt_manager/library/${value.positive_prompt_id}/history`);
|
||||
expect((await scoped.json()).items.map(item => item.run_id)).toContain(variantRun);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -369,7 +387,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 +395,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) => {
|
||||
|
||||
@@ -36,11 +36,11 @@ async function watchHost(page,id,reset=true) {
|
||||
}
|
||||
const restore=page=>page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.inspectOriginals);});
|
||||
|
||||
/** Chooses `file` on the Inspect page and opens the shared viewer on its local record. */
|
||||
/** Chooses `file` on the Inspect page, which opens the shared viewer on its local record (D155, #448). */
|
||||
async function inspect(workspace,{file,positive,shown}) {
|
||||
await workspace.locator('#local-file').setInputFiles(file());
|
||||
const open=workspace.locator('#workspace-main .heading-actions').getByRole('button',{name:'Open file metadata',exact:true});
|
||||
await expect(open).toHaveAttribute('aria-disabled','false');await open.click();
|
||||
await expect(open).toHaveAttribute('aria-disabled','false');
|
||||
const rail=workspace.locator('dialog#viewer-dialog[open] #viewer-details');
|
||||
await expect(rail.locator('.prompt-section.positive p.prompt-text')).toHaveText(positive);
|
||||
await expect(rail.locator('.prompt-section.negative p.prompt-text')).toHaveText(shown);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -79,6 +79,15 @@ test("the workspace keys have their own defaults and validation", () => {
|
||||
assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid");
|
||||
});
|
||||
|
||||
// #450 (D156): the viewer's default view, Centered unless Full screen was chosen.
|
||||
test("the viewer open mode defaults to centered and accepts only its two values", () => {
|
||||
const { VIEWER_OPEN_MODES } = require("../../web/js/gallery-settings.js");
|
||||
assert.deepEqual(VIEWER_OPEN_MODES, ["centered", "fullscreen"]);
|
||||
assert.equal(DEFAULT_SETTINGS.viewerOpenMode, "centered");
|
||||
assert.equal(normalizeGallerySettings({ viewerOpenMode: "fullscreen" }).viewerOpenMode, "fullscreen");
|
||||
for (const value of ["full", true, 1, null]) assert.equal(normalizeGallerySettings({ viewerOpenMode: value }).viewerOpenMode, "centered");
|
||||
});
|
||||
|
||||
// #330 S13: the legacy gallery's Page options edit only the page-only keys (PAGE_OPTION_KEYS); System › Appearance owns the rest, so a Save
|
||||
// or a Reset from the legacy page keeps every other stored key.
|
||||
function legacyGallery(stored, controls, extra = {}) {
|
||||
@@ -112,7 +121,8 @@ const PAGE_OPTION_CONTROLS = () => ({
|
||||
|
||||
test("a legacy gallery Save keeps the workspace keys and every other stored key", () => {
|
||||
const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1,
|
||||
imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list" },
|
||||
imageQuality: "high", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig", defaultViewMode: "list",
|
||||
viewerOpenMode: "fullscreen" },
|
||||
PAGE_OPTION_CONTROLS());
|
||||
manager.saveSettings();
|
||||
const result = saved();
|
||||
@@ -123,6 +133,7 @@ test("a legacy gallery Save keeps the workspace keys and every other stored key"
|
||||
assert.equal(result.showImageInfo, false);
|
||||
assert.equal(result.videoMute, false);
|
||||
assert.equal(result.thumbnailPromptDismissed, "sig");
|
||||
assert.equal(result.viewerOpenMode, "fullscreen");
|
||||
// Page options no longer edit the default view (S13 G5), so a stored one stays.
|
||||
assert.equal(result.defaultViewMode, "list");
|
||||
// The page-only keys come from the dialog.
|
||||
|
||||
@@ -10,6 +10,7 @@ export function mountedDom() {
|
||||
// As in the DOM, a string argument becomes a text node.
|
||||
static wrap(node){if(typeof node!=='string')return node;const text=new Node('#text');text.textContent=node;return text;}
|
||||
append(...nodes){for(const node of nodes.map(Node.wrap)){node.parent=this;this.children.push(node);}}
|
||||
insertBefore(node,before){node.parent=this;const index=this.children.indexOf(before);this.children.splice(index<0?this.children.length:index,0,node);return node;}
|
||||
prepend(...nodes){nodes=nodes.map(Node.wrap);for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);}
|
||||
remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),'');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,6 +104,17 @@ 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('tag mode untagged is sent, echoed and never combined with tags (#444)',async()=>{
|
||||
const {createLibraryStream}=await source;const calls=[];const pending=[{...page([item(1)]),tag_mode:'untagged'},page([item(2)]),page([item(3)])];
|
||||
const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
|
||||
assert.equal(await s.reset({tagMode:'untagged'}),1);assert.equal(new URL(calls[0],'https://x').searchParams.get('tag_mode'),'untagged');
|
||||
// An older server that ignored tag_mode answers without the echo: an error, never the whole Library.
|
||||
assert.equal(await s.reset({tagMode:'untagged'}),0);assert.ok(s.state.error instanceof TypeError);assert.deepEqual(s.state.items,[]);
|
||||
// The default sends no tag_mode, so it needs no echo.
|
||||
assert.equal(await s.reset({tagMode:'all'}),1);assert.equal(new URL(calls[2],'https://x').searchParams.has('tag_mode'),false);
|
||||
await assert.rejects(s.reset({tagMode:'untagged',tags:['a']}),TypeError);await assert.rejects(s.reset({tagMode:'none'}),TypeError);
|
||||
assert.equal(calls.length,3);s.dispose();
|
||||
});
|
||||
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}];
|
||||
@@ -114,3 +125,9 @@ test('the stream keeps the first page\'s storage epoch and refuses a later page
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -188,6 +188,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
const left0 = dialog => dialog.children[1].children[0];
|
||||
test('viewer renders the concept shell and toolbar order', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
|
||||
@@ -199,7 +200,14 @@ test('viewer renders the concept shell and toolbar order', async () => {
|
||||
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
|
||||
const [header,layout] = dialog.children;
|
||||
assert.equal(header.className,'dialog-header');
|
||||
const [heading,close] = header.children, [title,subtitle] = heading.children;
|
||||
const [heading,mode,close] = header.children, [title,subtitle] = heading.children;
|
||||
// #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default).
|
||||
assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']);
|
||||
mode.fire('click');
|
||||
assert.equal(mode.attributes['aria-label'],'Centered view');
|
||||
assert.ok(f.walk(left0(dialog),n => n.textContent === 'Hide details').length === 1,'full screen adds the details toggle');
|
||||
mode.fire('click');
|
||||
assert.equal(mode.attributes['aria-label'],'Full screen view');
|
||||
assert.deepEqual([heading.tagName,title.tagName,title.id,title.textContent],['div','h2','viewer-title','a.png']);
|
||||
assert.deepEqual([subtitle.tagName,subtitle.className,subtitle.textContent],['p','muted small','One viewer · image, prompts, generation data, workflow']);
|
||||
assert.deepEqual([close.tagName,close.id,close.textContent,close.attributes['aria-label']],['button','viewer-close','×','Close image viewer']);
|
||||
|
||||
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/saved-views.mjs');
|
||||
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all' };
|
||||
|
||||
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
|
||||
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
|
||||
@@ -32,7 +32,7 @@ test('a saved state always holds every page key and keeps only values its page a
|
||||
{ ...full, q: '', folder: '', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
|
||||
// Library has no source, folder or media, and no Name Z–A.
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] });
|
||||
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all' });
|
||||
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
|
||||
// The returned tags are a copy.
|
||||
const tags = ['one'];
|
||||
@@ -62,7 +62,7 @@ test('the listing keeps usable views in order and normalizes their state', async
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } },
|
||||
] });
|
||||
assert.deepEqual(views, [
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all' } },
|
||||
{ id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
|
||||
]);
|
||||
assert.equal(parseSavedView(null), null);
|
||||
@@ -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 ''.
|
||||
@@ -84,8 +84,8 @@ test('page filters and saved states convert both ways', async () => {
|
||||
assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, '');
|
||||
assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7');
|
||||
assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false);
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] });
|
||||
const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'all' };
|
||||
assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'all' });
|
||||
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
|
||||
});
|
||||
|
||||
@@ -215,3 +215,23 @@ 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 Library view keeps its tag match, and untagged never travels with tags (#444)', async () => {
|
||||
const { normalizeViewState, libraryFilters, libraryViewState } = await source;
|
||||
assert.equal(normalizeViewState('library', { tag_mode: 'untagged' }).tag_mode, 'untagged');
|
||||
assert.equal(normalizeViewState('library', { tag_mode: 'any' }).tag_mode, 'all');
|
||||
assert.deepEqual(normalizeViewState('library', { tags: ['a'], tag_mode: 'untagged' }), { ...LIBRARY_DEFAULTS, tags: ['a'] });
|
||||
const shelf = { text: '', category: '', sort: 'newest', tags: [], tagMode: 'untagged' };
|
||||
assert.equal(libraryViewState(shelf).tag_mode, 'untagged');
|
||||
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ function fakeDOM() {
|
||||
|
||||
const BROWSER = { workspaceLayout: 'grid', workspaceDensity: 'comfortable', autoLoadMetadata: true, cacheMetadata: true,
|
||||
imageQuality: 'medium', videoAutoplay: false, videoMute: true, videoLoop: true, showFilePaths: true, showImageInfo: true,
|
||||
sidebarCollapsedByDefault: false };
|
||||
sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' };
|
||||
|
||||
async function mounted({ host = { webui_display_mode: 'newtab', result_timeout: 5 }, browser = BROWSER, hostFails = false } = {}) {
|
||||
const { mountAppearance } = await appearanceModule;
|
||||
@@ -80,9 +80,9 @@ async function mounted({ host = { webui_display_mode: 'newtab', result_timeout:
|
||||
});
|
||||
await drafts.load('browser');
|
||||
const scope = createScope();
|
||||
const [surface] = mountAppearance(null, { scope, drafts });
|
||||
const byId = id => dom.find(surface, node => node.dataset?.setting === id);
|
||||
return { dom, drafts, scope, surface, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
|
||||
const [surface, viewer] = mountAppearance(null, { scope, drafts });
|
||||
const byId = id => dom.find(surface, node => node.dataset?.setting === id) ?? dom.find(viewer, node => node.dataset?.setting === id);
|
||||
return { dom, drafts, scope, surface, viewer, byId, loadHost: async () => { hostValues = host; await drafts.load('host'); },
|
||||
fixHost: () => { hostFails = false; } };
|
||||
}
|
||||
|
||||
@@ -98,13 +98,26 @@ test('the surface is the concept section with the concept rows in order, then th
|
||||
assert.deepEqual(titles, ['Default layout', 'Thumbnail size', 'Continuous browsing', 'Load metadata on selection',
|
||||
'Cache metadata in this browser', 'Image loading quality', 'Autoplay videos', 'Mute videos by default', 'Loop videos',
|
||||
'Show file paths', 'Open from ComfyUI', 'Node search auto-hide (seconds)', 'Show image details on cards',
|
||||
'Collapse viewer details by default', 'Reset display defaults']);
|
||||
'Reset display defaults']);
|
||||
const continuous = rows[2];
|
||||
assert.deepEqual(dom.tags(continuous.children[0]), ['h3', 'p']);
|
||||
assert.equal(continuous.children[1].className, 'chip blue');
|
||||
assert.equal(continuous.children[1].textContent, 'Infinite scroll');
|
||||
assert.equal(rows.at(-1).className, 'operation');
|
||||
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 13);
|
||||
assert.equal(APPEARANCE_ROWS.filter(Array.isArray).length, 12);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
// #450 (D156, amends D97): a "Viewer" surface after "Browsing & display" holds the viewer's two defaults.
|
||||
test('the Viewer surface holds Open viewer in and the moved collapse row', async () => {
|
||||
const { VIEWER_ROWS } = await appearanceModule;
|
||||
const { dom, viewer, scope } = await mounted();
|
||||
try {
|
||||
assert.equal(viewer.className, 'surface');
|
||||
assert.equal(viewer.children[0].textContent, 'Viewer');
|
||||
assert.equal(viewer.children[1].textContent, 'How the image viewer opens; the button beside ✕ switches the current view.');
|
||||
assert.deepEqual(viewer.children.slice(2).map(row => row.children[0].children[0].textContent), ['Open viewer in', 'Collapse viewer details by default']);
|
||||
assert.deepEqual(VIEWER_ROWS.map(row => row[0]), ['viewer-mode', 'viewer-details']);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
@@ -127,9 +140,11 @@ test('added rows reach their consumers: each control edits its stored key with t
|
||||
byId(id).checked = false; byId(id).dispatch('change');
|
||||
}
|
||||
for (const id of ['video-auto', 'viewer-details']) { byId(id).checked = true; byId(id).dispatch('change'); }
|
||||
assert.deepEqual(byId('viewer-mode').children.map(option => [option.value, option.textContent]), [['centered', 'Centered'], ['fullscreen', 'Full screen']]);
|
||||
byId('viewer-mode').value = 'fullscreen'; byId('viewer-mode').dispatch('change');
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', workspaceDensity: 'large', imageQuality: 'low',
|
||||
autoLoadMetadata: false, cacheMetadata: false, videoMute: false, videoLoop: false, showFilePaths: false, showImageInfo: false,
|
||||
videoAutoplay: true, sidebarCollapsedByDefault: true });
|
||||
videoAutoplay: true, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' });
|
||||
assert.deepEqual(drafts.changes('host'), { webui_display_mode: 'popup' });
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
@@ -154,7 +169,7 @@ test('the auto-hide timeout takes whole seconds from 0 to 300 only', async () =>
|
||||
|
||||
test('reset restores defaults without touching other keys', async () => {
|
||||
const { DISPLAY_KEYS } = await appearanceModule;
|
||||
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true };
|
||||
const changed = { ...BROWSER, workspaceLayout: 'list', videoMute: false, showImageInfo: false, sidebarCollapsedByDefault: true, viewerOpenMode: 'fullscreen' };
|
||||
const { dom, drafts, scope, surface, loadHost } = await mounted({ browser: changed });
|
||||
try {
|
||||
await loadHost();
|
||||
@@ -163,7 +178,7 @@ test('reset restores defaults without touching other keys', async () => {
|
||||
assert.equal(reset.textContent, 'Reset');
|
||||
reset.click();
|
||||
// #451: the default layout is Masonry.
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false });
|
||||
assert.deepEqual(drafts.changes('browser'), { workspaceLayout: 'masonry', videoMute: true, showImageInfo: true, sidebarCollapsedByDefault: false, viewerOpenMode: 'centered' });
|
||||
// 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 });
|
||||
});
|
||||
|
||||
@@ -461,6 +461,57 @@ test('after dispose an unconfirmed write invalidates and a failed write does not
|
||||
}
|
||||
});
|
||||
|
||||
test('a tag stream sends its search and sort, replaces them on reset and needs the server to echo them (#444)', async () => {
|
||||
const {createVocabularyStream, TAG_SORTS} = await source;
|
||||
assert.deepEqual(TAG_SORTS.map(([value]) => value), ['created', 'name', 'name-desc', 'prompts', 'prompts-asc']);
|
||||
assert.deepEqual(TAG_SORTS.map(([, label]) => label), ['Oldest first', 'Name A–Z', 'Name Z–A', 'Most prompts', 'Fewest prompts']);
|
||||
const echo = (page, search, sort) => ({...page, search, sort, untagged_count: 4});
|
||||
const {calls, api} = queued([
|
||||
echo(tagPage([tag(1)], 'next'), 'cat', 'name'), echo(tagPage([tag(2)]), 'cat', 'name'),
|
||||
echo(tagPage([tag(3)]), '', 'created'), tagPage([tag(4)]), echo(tagPage([tag(5)]), 'dog', 'created'),
|
||||
]);
|
||||
const stream = createVocabularyStream({api, kind: 'tags', query: {search: ' cat ', sort: 'name'}});
|
||||
await stream.loadMore();
|
||||
assert.equal(params(calls[0].url).get('search'), 'cat');
|
||||
assert.equal(params(calls[0].url).get('sort'), 'name');
|
||||
assert.equal(stream.state.untaggedCount, 4);
|
||||
await stream.loadMore();
|
||||
assert.equal(params(calls[1].url).get('search'), 'cat');
|
||||
assert.equal(params(calls[1].url).get('cursor'), 'next');
|
||||
assert.deepEqual(stream.state.query, {search: 'cat', sort: 'name'});
|
||||
// The default query sends neither, so an older server answers it unchanged.
|
||||
await stream.reset({search: '', sort: 'created'});
|
||||
assert.equal(params(calls[2].url).has('search'), false);
|
||||
assert.equal(params(calls[2].url).has('sort'), false);
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [3]);
|
||||
// A server that ignored the search (no echo) is an error, never a wrong list.
|
||||
await stream.reset({search: 'dog', sort: 'created'});
|
||||
assert.deepEqual(stream.state.items, []);
|
||||
assert.ok(stream.state.error instanceof TypeError);
|
||||
assert.equal(stream.state.untaggedCount, 4, 'the last known untagged count survives a reset');
|
||||
await stream.loadMore();
|
||||
assert.deepEqual(stream.state.items.map(row => row.id), [5]);
|
||||
assert.throws(() => stream.reset({search: 'x', sort: 'count'}), TypeError);
|
||||
assert.throws(() => createVocabularyStream({api, kind: 'categories', query: {search: 'x'}}), TypeError);
|
||||
assert.throws(() => createVocabularyStream({api, kind: 'tags', query: {search: 3}}), TypeError);
|
||||
stream.dispose();
|
||||
});
|
||||
|
||||
test('an invalid untagged_count rejects the page', async () => {
|
||||
const {createVocabularyStream} = await source;
|
||||
for (const untagged_count of [-1, 1.5, '2']) {
|
||||
const stream = createVocabularyStream({api: queued([{...tagPage([tag(1)]), untagged_count}]).api, kind: 'tags'});
|
||||
await stream.loadMore();
|
||||
assert.ok(stream.state.error instanceof TypeError, String(untagged_count));
|
||||
assert.equal(stream.state.untaggedCount, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('untaggedBrowseFilters opens the Library on prompts without tags (#444)', async () => {
|
||||
const {untaggedBrowseFilters} = await source;
|
||||
assert.deepEqual(untaggedBrowseFilters(), {text: '', category: '', tags: [], sort: 'newest', tagMode: 'untagged'});
|
||||
});
|
||||
|
||||
test('browseFilters hands Library one exact constraint', async () => {
|
||||
const {browseFilters} = await source;
|
||||
assert.deepEqual(browseFilters('tags', 'a'), {text: '', category: '', tags: ['a'], sort: 'newest'});
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ from unittest.mock import patch
|
||||
|
||||
from aiohttp import FormData, web
|
||||
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase
|
||||
except ImportError:
|
||||
@@ -191,7 +193,11 @@ class HistoryAPITests(ImageAPITestCase):
|
||||
context["evidence"]["positive"]["raw"]["provenance"], "recorded"
|
||||
)
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute("UPDATE prompts SET text='edited' WHERE id=?", (positive,))
|
||||
# An edit changes the text and its dedup hash together (#460)
|
||||
conn.execute(
|
||||
"UPDATE prompts SET text='edited',hash=? WHERE id=?",
|
||||
(generate_prompt_hash("edited"), positive),
|
||||
)
|
||||
_, changed = await self.detail(start.run_id)
|
||||
self.assertEqual(changed["contexts"][0]["positive"], context["positive"])
|
||||
self.assertFalse(changed["contexts"][0]["navigation"]["positive_available"])
|
||||
|
||||
@@ -60,6 +60,32 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(result))
|
||||
|
||||
async def test_untagged_tag_mode_lists_parents_without_tags(self):
|
||||
self._save_prompt("plain one")
|
||||
self._save_prompt("plain two")
|
||||
self._save_prompt("tagged", tags=["t"])
|
||||
self.api.db.save_prompt_pair("pair tagged", "noise", tags=["p"])
|
||||
_, first = await self.get(limit=1, tag_mode="untagged")
|
||||
self.assertEqual(first["total"], 2)
|
||||
self.assertEqual(first["tag_mode"], "untagged")
|
||||
_, second = await self.get(
|
||||
limit=1, tag_mode="untagged", cursor=first["next_cursor"]
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted([first["items"][0]["text"], second["items"][0]["text"]]),
|
||||
["plain one", "plain two"],
|
||||
)
|
||||
_, default = await self.get(tag_mode="all")
|
||||
self.assertEqual(default["total"], 4)
|
||||
for params in (
|
||||
{"tag_mode": "untagged", "tag": "t"},
|
||||
{"tag_mode": "sql"},
|
||||
{"cursor": first["next_cursor"]},
|
||||
):
|
||||
response, result = await self.get(limit=1, **params)
|
||||
self.assertEqual(response.status, 400, params)
|
||||
self.assertEqual(result["success"], False)
|
||||
|
||||
async def test_outside_missing_and_unsupported_media_do_not_leak_or_scan(self):
|
||||
positive = self._save_prompt("Cat")
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
|
||||
@@ -35,7 +35,17 @@ class TestVocabularyAPI(ImageAPITestCase):
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
set(first),
|
||||
{"success", "library_epoch", "tags", "total", "has_more", "next_cursor"},
|
||||
{
|
||||
"success",
|
||||
"library_epoch",
|
||||
"tags",
|
||||
"total",
|
||||
"untagged_count",
|
||||
"search",
|
||||
"sort",
|
||||
"has_more",
|
||||
"next_cursor",
|
||||
},
|
||||
)
|
||||
self.assertEqual(first["library_epoch"], epoch)
|
||||
self.assertEqual(first["total"], 3)
|
||||
@@ -111,6 +121,41 @@ class TestVocabularyAPI(ImageAPITestCase):
|
||||
response, _ = await self.get("tags", limit=1, cursor=fresh["next_cursor"])
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
async def test_tag_search_and_sort_page_with_scoped_cursors(self):
|
||||
self._save_prompt("One", tags=["beta", "alpha"])
|
||||
self._save_prompt("Two", tags=["beta"])
|
||||
self._save_prompt("Plain")
|
||||
self.add_unused("gamma")
|
||||
_, first = await self.get("tags", limit=1, sort="name", search="A")
|
||||
self.assertEqual([row["name"] for row in first["tags"]], ["alpha"])
|
||||
self.assertEqual(first["total"], 3)
|
||||
self.assertEqual(first["untagged_count"], 1)
|
||||
self.assertEqual((first["search"], first["sort"]), ("A", "name"))
|
||||
_, second = await self.get(
|
||||
"tags", limit=1, sort="name", search="A", cursor=first["next_cursor"]
|
||||
)
|
||||
self.assertEqual([row["name"] for row in second["tags"]], ["beta"])
|
||||
_, ranked = await self.get("tags", sort="prompts")
|
||||
self.assertEqual(
|
||||
[(row["name"], row["prompt_count"]) for row in ranked["tags"]],
|
||||
[("beta", 2), ("alpha", 1), ("gamma", 0)],
|
||||
)
|
||||
# A cursor belongs to one search and one sort.
|
||||
for params in (
|
||||
{"sort": "name-desc", "search": "A"},
|
||||
{"sort": "name", "search": "B"},
|
||||
{"sort": "created", "search": "A"},
|
||||
):
|
||||
response, result = await self.get(
|
||||
"tags", limit=1, cursor=first["next_cursor"], **params
|
||||
)
|
||||
self.assertEqual(response.status, 400, params)
|
||||
self.assertIn("refresh", result["error"])
|
||||
for params in ({"sort": "count"}, {"search": "x" * 201}):
|
||||
response, result = await self.get("tags", **params)
|
||||
self.assertEqual(response.status, 400, params)
|
||||
self.assertEqual(result["success"], False)
|
||||
|
||||
async def test_read_failures_keep_the_read_envelope(self):
|
||||
with patch.object(
|
||||
self.api.db,
|
||||
|
||||
+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)
|
||||
|
||||
@@ -15,6 +15,7 @@ from database import execution_contexts
|
||||
from database.execution_history import HistoryEpochChanged
|
||||
from database.execution_context_schema import context_schema_error
|
||||
from database.execution_contexts import LIVE_HINT_PROMPT_IDS_SQL
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
@@ -466,7 +467,11 @@ class ContextDatabaseTests(unittest.TestCase):
|
||||
with conn:
|
||||
conn.execute("DELETE FROM prompts WHERE id=3")
|
||||
self.assertEqual(assert_same(), {1, 2, 4})
|
||||
# A case-only edit keeps the prompt's hash, so the run still counts
|
||||
# toward it (#460); another text drops the hint.
|
||||
db.update_prompt_text(4, "Other")
|
||||
self.assertEqual(assert_same(), {1, 2, 4})
|
||||
db.update_prompt_text(4, "another")
|
||||
self.assertEqual(assert_same(), {1, 2})
|
||||
with conn:
|
||||
conn.execute("INSERT INTO prompts(id,text) VALUES(5,'none')")
|
||||
@@ -474,6 +479,131 @@ class ContextDatabaseTests(unittest.TestCase):
|
||||
rotate_history_epoch(conn)
|
||||
self.assertEqual(assert_same(), set())
|
||||
|
||||
def test_hints_resolve_the_stored_form_of_outer_whitespace_raw_text(self):
|
||||
# #438: the Library stores text.strip(); the raw run text keeps its spaces.
|
||||
db = self.database()
|
||||
conn = db.model.get_connection()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(id,text) VALUES(1,'positive'),(2,'negative')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(7,1,2)"
|
||||
)
|
||||
start = self.capture(db, 0, bundle(" negative\n", positive="positive\n\t"))
|
||||
context = db.get_execution_contexts(start.run_id)["contexts"][0]
|
||||
self.assertEqual(context["positive"]["raw"], "positive\n\t")
|
||||
self.assertEqual(
|
||||
(
|
||||
context["positive_prompt_id"],
|
||||
context["negative_prompt_id"],
|
||||
context["pair_id"],
|
||||
),
|
||||
(1, 2, 7),
|
||||
)
|
||||
live = {row[0] for row in conn.execute(LIVE_HINT_PROMPT_IDS_SQL)}
|
||||
self.assertEqual(live, {1, 2})
|
||||
stored = conn.execute(
|
||||
"SELECT positive_prompt_id FROM execution_run_contexts WHERE run_id=?",
|
||||
(start.run_id,),
|
||||
).fetchone()[0]
|
||||
self.assertEqual(stored, 1)
|
||||
# A legacy row that kept outer whitespace keeps its exact-text hint and
|
||||
# with it the recorded-run protection from pruning.
|
||||
with conn:
|
||||
conn.execute("INSERT INTO prompts(id,text) VALUES(3,' legacy ')")
|
||||
legacy = self.capture(db, 1, bundle("negative", positive=" legacy "))
|
||||
hinted = db.get_execution_contexts(legacy.run_id)["contexts"][0]
|
||||
self.assertEqual(hinted["positive_prompt_id"], 3)
|
||||
live = {row[0] for row in conn.execute(LIVE_HINT_PROMPT_IDS_SQL)}
|
||||
self.assertEqual(live, {1, 2, 3})
|
||||
self.assertEqual(db.prune_orphaned_prompts(), 0)
|
||||
# Both forms present make the hint ambiguous, in both rule forms.
|
||||
with conn:
|
||||
conn.execute("INSERT INTO prompts(id,text) VALUES(4,'legacy')")
|
||||
hinted = db.get_execution_contexts(legacy.run_id)["contexts"][0]
|
||||
self.assertIsNone(hinted["positive_prompt_id"])
|
||||
live = {row[0] for row in conn.execute(LIVE_HINT_PROMPT_IDS_SQL)}
|
||||
self.assertEqual(live, {1, 2})
|
||||
|
||||
def test_hints_follow_the_hash_for_case_variant_raw_text(self):
|
||||
# #460: the run names the prompt the Library counted it toward.
|
||||
db = self.database()
|
||||
first = db.save_prompt(
|
||||
"\u00e9t\u00e9 positive",
|
||||
prompt_hash=generate_prompt_hash("\u00e9t\u00e9 positive"),
|
||||
)
|
||||
second = db.save_prompt(
|
||||
"negative \u03c3", prompt_hash=generate_prompt_hash("negative \u03c3")
|
||||
)
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_pairs(id,positive_prompt_id,negative_prompt_id) "
|
||||
"VALUES(7,?,?)",
|
||||
(first, second),
|
||||
)
|
||||
start = self.capture(
|
||||
db, 0, bundle("NEGATIVE \u03a3", positive="\u00c9T\u00c9 Positive")
|
||||
)
|
||||
context = db.get_execution_contexts(start.run_id)["contexts"][0]
|
||||
self.assertEqual(
|
||||
(
|
||||
context["positive_prompt_id"],
|
||||
context["negative_prompt_id"],
|
||||
context["pair_id"],
|
||||
),
|
||||
(first, second, 7),
|
||||
)
|
||||
conn = db.model.get_connection()
|
||||
live = {row[0] for row in conn.execute(LIVE_HINT_PROMPT_IDS_SQL)}
|
||||
self.assertEqual(live, {first, second})
|
||||
# A prompt that only shares SQLite's ASCII lower() is not a match
|
||||
other = db.save_prompt("other", prompt_hash=generate_prompt_hash("other"))
|
||||
start = self.capture(db, 1, bundle("x", positive="\u00d6THER"))
|
||||
context = db.get_execution_contexts(start.run_id)["contexts"][0]
|
||||
self.assertIsNone(context["positive_prompt_id"])
|
||||
self.assertNotIn(
|
||||
other, {row[0] for row in conn.execute(LIVE_HINT_PROMPT_IDS_SQL)}
|
||||
)
|
||||
|
||||
def test_hash_is_only_a_fallback_so_a_text_hint_stays_unambiguous(self):
|
||||
# #460 review: a hash match must never make a #438 text hint ambiguous,
|
||||
# or its prompt would lose the recorded-run prune protection.
|
||||
db = self.database()
|
||||
conn = db.model.get_connection()
|
||||
with conn:
|
||||
# A legacy row without a hash, and a case variant with the hash
|
||||
conn.execute("INSERT INTO prompts(id,text) VALUES(1,'a lighthouse')")
|
||||
conn.execute(
|
||||
"INSERT INTO prompts(id,text,hash) VALUES(2,'A Lighthouse',?)",
|
||||
(generate_prompt_hash("a lighthouse"),),
|
||||
)
|
||||
exact = self.capture(db, 0, bundle("x", positive="a lighthouse"))
|
||||
variant = self.capture(db, 1, bundle("x", positive="A LIGHTHOUSE"))
|
||||
hints = [
|
||||
db.get_execution_contexts(start.run_id)["contexts"][0]["positive_prompt_id"]
|
||||
for start in (exact, variant)
|
||||
]
|
||||
self.assertEqual(hints, [1, 2])
|
||||
live = {row[0] for row in conn.execute(LIVE_HINT_PROMPT_IDS_SQL)}
|
||||
self.assertEqual(live, {1, 2})
|
||||
self.assertEqual(db.prune_orphaned_prompts(), 0)
|
||||
|
||||
def test_live_hint_prompt_lookups_use_indexes(self):
|
||||
db = self.database()
|
||||
conn = db.model.get_connection()
|
||||
plan = [
|
||||
row[3]
|
||||
for row in conn.execute("EXPLAIN QUERY PLAN " + LIVE_HINT_PROMPT_IDS_SQL)
|
||||
]
|
||||
prompt_steps = [
|
||||
step for step in plan if " p " in f" {step} " or "TABLE p" in step
|
||||
]
|
||||
self.assertTrue(prompt_steps, plan)
|
||||
self.assertEqual([step for step in plan if step.startswith("SCAN p")], [])
|
||||
self.assertEqual([step for step in plan if step.startswith("SCAN q")], [])
|
||||
|
||||
def test_hints_require_exact_unique_raw_text_and_never_refresh(self):
|
||||
db = self.database()
|
||||
conn = db.model.get_connection()
|
||||
@@ -504,12 +634,13 @@ class ContextDatabaseTests(unittest.TestCase):
|
||||
db.get_execution_contexts(fresh.run_id)["contexts"][0]["positive_prompt_id"]
|
||||
)
|
||||
|
||||
def test_null_negative_pair_only_matches_known_exact_empty(self):
|
||||
def test_null_negative_pair_only_matches_a_known_stored_empty(self):
|
||||
db = self.database()
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(id,text) VALUES(1,'positive')")
|
||||
conn.execute("INSERT INTO prompt_pairs(id,positive_prompt_id) VALUES(7,1)")
|
||||
for item, raw, expected in ((0, "", 7), (1, " ", None), (2, None, None)):
|
||||
# A whitespace-only negative is the stored empty one (#438)
|
||||
for item, raw, expected in ((0, "", 7), (1, " \n", 7), (2, None, None)):
|
||||
candidate = bundle(negative=raw or "")
|
||||
if raw is None:
|
||||
ctx = candidate.contexts[0]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -19,6 +19,7 @@ from unittest.mock import patch
|
||||
from tests.history_waits import drain_writer
|
||||
from database import PromptDatabase
|
||||
from utils import usage_tracking
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from utils.execution_history_writer import HistoryWriter
|
||||
|
||||
try:
|
||||
@@ -54,7 +55,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 +123,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 +770,189 @@ 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"]))
|
||||
|
||||
|
||||
class CachedRuntimeCaseHistoryTests(CachedRuntimeHistoryTests):
|
||||
"""Raw text that differs only in case joins the saved prompt's history (#460).
|
||||
|
||||
The hash folds case, so the run counts toward the lower-case prompt saved
|
||||
first; the run keeps its exact raw spelling.
|
||||
"""
|
||||
|
||||
TEXT = "\u00c9 LIGHTHOUSE the LLM wrote"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.db.delete_prompt(self.prompt_id)
|
||||
lower = self.TEXT.lower()
|
||||
self.prompt_id = self.db.save_prompt(
|
||||
text=lower, prompt_hash=generate_prompt_hash(lower)
|
||||
)
|
||||
|
||||
def test_the_library_counts_the_case_variant_as_this_prompt(self):
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_hash(generate_prompt_hash(self.TEXT))["id"],
|
||||
self.prompt_id,
|
||||
)
|
||||
|
||||
|
||||
class CachedRuntimeWhitespaceHistoryTests(CachedRuntimeHistoryTests):
|
||||
"""Raw text with outer whitespace joins the history of the prompt it saves (#438).
|
||||
|
||||
The Library stores ``"a lighthouse the LLM wrote"``; the run keeps the exact
|
||||
frozen raw text, newline included, yet is a tracked run of that prompt.
|
||||
"""
|
||||
|
||||
TEXT = "\u3000 a lighthouse the LLM wrote\n"
|
||||
|
||||
def test_the_library_stores_the_stripped_text(self):
|
||||
self.assertEqual(
|
||||
self.db.get_prompt_by_id(self.prompt_id)["text"], self.TEXT.strip()
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -16,6 +16,11 @@ from tests.background_stops import stop_tracker # noqa: E402
|
||||
from utils.hashing import (
|
||||
generate_prompt_hash,
|
||||
is_duplicate_prompt,
|
||||
STRIPPED_CODE_POINTS,
|
||||
prompt_key,
|
||||
register_prompt_functions,
|
||||
stored_prompt_text,
|
||||
stored_prompt_text_sql,
|
||||
)
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -115,5 +120,96 @@ class TestGeneratePromptHashEdgeCases(unittest.TestCase):
|
||||
self.assertEqual(len(h), 64)
|
||||
|
||||
|
||||
class TestStoredPromptText(unittest.TestCase):
|
||||
"""SQLite strips exactly the characters Python's str.strip() does (#438)."""
|
||||
|
||||
WHITESPACE = "".join(c for c in map(chr, range(0x110000)) if c.isspace())
|
||||
|
||||
def sql_strip(self, value):
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
return conn.execute(
|
||||
f"SELECT {stored_prompt_text_sql('?')}", (value,)
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_code_points_are_exactly_python_whitespace(self):
|
||||
self.assertEqual("".join(map(chr, STRIPPED_CODE_POINTS)), self.WHITESPACE)
|
||||
|
||||
def test_python_rule_is_the_hash_strip(self):
|
||||
self.assertEqual(stored_prompt_text(" a lighthouse\n"), "a lighthouse")
|
||||
self.assertEqual(
|
||||
generate_prompt_hash(stored_prompt_text(" A b\u3000")),
|
||||
generate_prompt_hash(" A b\u3000"),
|
||||
)
|
||||
|
||||
def test_sql_strips_every_python_whitespace_character(self):
|
||||
for char in self.WHITESPACE:
|
||||
with self.subTest(code=hex(ord(char))):
|
||||
value = f"{char}a{char}b{char}"
|
||||
self.assertEqual(self.sql_strip(value), stored_prompt_text(value))
|
||||
mixed = self.WHITESPACE + "a lighthouse é" + self.WHITESPACE
|
||||
self.assertEqual(self.sql_strip(mixed), "a lighthouse é")
|
||||
|
||||
def test_sql_keeps_what_python_keeps(self):
|
||||
for value in ("\u200ba\u200b", "\ufeffa", "a\u0301", "", "x"):
|
||||
with self.subTest(value=repr(value)):
|
||||
self.assertEqual(self.sql_strip(value), stored_prompt_text(value))
|
||||
|
||||
|
||||
class TestPromptKey(unittest.TestCase):
|
||||
"""History compares the hash's own key; SQL calls Python for it (#460)."""
|
||||
|
||||
CASES = (
|
||||
"A Lighthouse",
|
||||
" \u00c9t\u00c9\n",
|
||||
"\u03a3\u03a3",
|
||||
"\u0130stanbul",
|
||||
"\u1e9e",
|
||||
"\u212a",
|
||||
"ok",
|
||||
)
|
||||
|
||||
def test_key_is_the_hash_normalization(self):
|
||||
import hashlib
|
||||
|
||||
for value in self.CASES:
|
||||
with self.subTest(value=repr(value)):
|
||||
self.assertEqual(
|
||||
generate_prompt_hash(value),
|
||||
hashlib.sha256(prompt_key(value).encode("utf-8")).hexdigest(),
|
||||
)
|
||||
self.assertEqual(prompt_key(" A Lighthouse\n"), "a lighthouse")
|
||||
|
||||
def test_sql_functions_give_pythons_result_where_sql_lower_does_not(self):
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
register_prompt_functions(conn)
|
||||
for value in self.CASES:
|
||||
with self.subTest(value=repr(value)):
|
||||
key, digest = conn.execute(
|
||||
"SELECT pm_prompt_key(?), pm_prompt_hash(?)", (value, value)
|
||||
).fetchone()
|
||||
self.assertEqual(key, prompt_key(value))
|
||||
self.assertEqual(digest, generate_prompt_hash(value))
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT pm_prompt_key(1), pm_prompt_hash(NULL)"
|
||||
).fetchone(),
|
||||
(None, None),
|
||||
)
|
||||
# SQLite's own lower() folds ASCII only
|
||||
self.assertNotEqual(
|
||||
conn.execute("SELECT lower(?)", ("\u00c9",)).fetchone()[0], "\u00e9"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+135
-5
@@ -9,9 +9,15 @@ from unittest.mock import patch
|
||||
|
||||
from database.execution_history import HistoryEpochChanged
|
||||
from database.execution_history_schema import history_schema_error
|
||||
from utils.execution_contexts import ContextBundle, RecipeContext, TextEvidence
|
||||
from utils.execution_contexts import (
|
||||
ContextBundle,
|
||||
RecipeContext,
|
||||
TextEvidence,
|
||||
content_digest,
|
||||
)
|
||||
from utils.execution_outputs import OutputBundle
|
||||
from tests.test_execution_context_database import bundle, snapshot
|
||||
from utils.hashing import generate_prompt_hash
|
||||
from tests.test_execution_output_database import bundle as outputs
|
||||
from tests import test_execution_history_database as history_tests
|
||||
|
||||
@@ -28,6 +34,10 @@ class HistoryReadTests(unittest.TestCase):
|
||||
conn.execute("INSERT INTO prompts(id,text) VALUES(?,?)", (identity, text))
|
||||
return identity
|
||||
|
||||
def hashed(self, db, text):
|
||||
"""A prompt saved as the nodes save it: with its dedup hash."""
|
||||
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text))
|
||||
|
||||
def capture(self, db, item=0, context=None, output=None, time=100):
|
||||
start = self.start(db, item, time)
|
||||
db.write_execution_start(start)
|
||||
@@ -146,10 +156,11 @@ class HistoryReadTests(unittest.TestCase):
|
||||
)
|
||||
self.require_reads(db)
|
||||
with db.recorded_history_snapshot():
|
||||
for i in range(1, len(values) + 1):
|
||||
self.assertEqual(
|
||||
db.list_history_window(i)["recorded_run_count"], 2 if i == 1 else 1
|
||||
)
|
||||
# Outer whitespace (#438) and case (#460) join one scope;
|
||||
# Unicode normalization stays distinct.
|
||||
expected = (5, 5, 5, 5, 1, 1, 2, 2)
|
||||
for i, count in enumerate(expected, 1):
|
||||
self.assertEqual(db.list_history_window(i)["recorded_run_count"], count)
|
||||
self.assertIsNone(db.list_history_window(100))
|
||||
self.assertIsNone(db.list_history_window(101))
|
||||
page = db.list_history_window(99)
|
||||
@@ -989,3 +1000,122 @@ class HistoryReadTests(unittest.TestCase):
|
||||
db.delete_prompt(saved["positive_prompt_id"])
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertIsNone(db.get_newest_pair_run(saved["positive_prompt_id"], pair))
|
||||
|
||||
def test_outer_whitespace_raw_text_joins_the_stored_prompt_scope(self):
|
||||
# #438: the Library stores text.strip(); the run keeps its raw text.
|
||||
db = self.database()
|
||||
positive = db.save_prompt("a lighthouse")
|
||||
raws = ("a lighthouse\n", "a lighthouse", " a lighthouse \t")
|
||||
starts = [
|
||||
self.capture(db, i, bundle(positive=raw), time=100 + i)
|
||||
for i, raw in enumerate(raws)
|
||||
]
|
||||
self.saved(db, "a lighthouse at dusk", 50)
|
||||
self.capture(db, 9, bundle(positive="a lighthouse at dusk\n"), time=200)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(positive)
|
||||
first = db.list_history_window(positive, limit=2)
|
||||
rest = db.list_history_window(
|
||||
positive, limit=2, after=first["next_position"]
|
||||
)
|
||||
details = [db.get_history_run(start.run_id) for start in starts]
|
||||
self.assertEqual(page["recorded_run_count"], 3)
|
||||
self.assertEqual(page["group"]["scope_status"], "matched")
|
||||
self.assertEqual(
|
||||
page["group"]["raw_text_id"], content_digest("text", ("a lighthouse",))
|
||||
)
|
||||
self.assertEqual(
|
||||
[r["run_id"] for r in page["items"]], [s.run_id for s in starts]
|
||||
)
|
||||
self.assertTrue(all(r["matching_anchors"] for r in page["items"]))
|
||||
self.assertEqual(len(page["context_summaries"]), 3)
|
||||
self.assertEqual(
|
||||
[r["run_id"] for r in first["items"] + rest["items"]],
|
||||
[s.run_id for s in starts[1:]] + [starts[0].run_id],
|
||||
)
|
||||
for raw, detail in zip(raws, details):
|
||||
context = detail["contexts"][0]
|
||||
self.assertEqual(context["positive"]["raw"], raw)
|
||||
self.assertEqual(
|
||||
context["positive_raw_text_id"], page["group"]["raw_text_id"]
|
||||
)
|
||||
self.assertEqual(context["positive_prompt_id"], positive)
|
||||
self.assertTrue(context["navigation"]["positive_available"])
|
||||
|
||||
def test_newest_pair_run_follows_outer_whitespace_raw_text(self):
|
||||
db = self.database()
|
||||
pair = db.save_prompt_pair("a lighthouse", "fog")
|
||||
positive = db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
newest = self.capture(
|
||||
db, 0, bundle(" fog\n", positive="a lighthouse\n"), time=100
|
||||
)
|
||||
with db.recorded_history_snapshot():
|
||||
found = db.get_newest_pair_run(positive, pair)
|
||||
self.assertEqual(found["run"]["run_id"], newest.run_id)
|
||||
|
||||
def test_case_variant_raw_text_joins_the_saved_prompt_scope(self):
|
||||
# #460: the hash folds case, so these runs count toward this prompt.
|
||||
db = self.database()
|
||||
positive = self.hashed(db, "\u00e9t\u00e9 lighthouse \u03c3")
|
||||
raws = (
|
||||
"\u00c9T\u00c9 Lighthouse \u03a3\n",
|
||||
"\u00e9t\u00e9 lighthouse \u03c3",
|
||||
" \u00c9t\u00e9 LIGHTHOUSE \u03a3",
|
||||
)
|
||||
starts = [
|
||||
self.capture(db, i, bundle(positive=raw), time=100 + i)
|
||||
for i, raw in enumerate(raws)
|
||||
]
|
||||
# Unicode normalization and inner text stay distinct
|
||||
self.capture(db, 7, bundle(positive="e\u0301te\u0301 lighthouse \u03c3"))
|
||||
self.capture(db, 8, bundle(positive="\u00e9t\u00e9 lighthouse \u03c3"))
|
||||
dotted = self.hashed(db, "\u0130stanbul")
|
||||
dotted_run = self.capture(db, 9, bundle(positive="\u0130STANBUL"), time=300)
|
||||
with db.recorded_history_snapshot():
|
||||
page = db.list_history_window(positive)
|
||||
details = [db.get_history_run(start.run_id) for start in starts]
|
||||
turkish = db.list_history_window(dotted)
|
||||
self.assertEqual(page["recorded_run_count"], 3)
|
||||
self.assertEqual(
|
||||
[r["run_id"] for r in page["items"]], [s.run_id for s in starts]
|
||||
)
|
||||
for raw, detail in zip(raws, details):
|
||||
context = detail["contexts"][0]
|
||||
self.assertEqual(context["positive"]["raw"], raw)
|
||||
self.assertEqual(
|
||||
context["positive_raw_text_id"], page["group"]["raw_text_id"]
|
||||
)
|
||||
self.assertEqual(context["positive_prompt_id"], positive)
|
||||
self.assertEqual([r["run_id"] for r in turkish["items"]], [dotted_run.run_id])
|
||||
|
||||
def test_newest_pair_run_follows_case_variant_raw_text(self):
|
||||
db = self.database()
|
||||
pair = db.save_prompt_pair("a lighthouse", "fog")
|
||||
positive = db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
newest = self.capture(db, 0, bundle("FOG", positive="A Lighthouse"), time=100)
|
||||
with db.recorded_history_snapshot():
|
||||
found = db.get_newest_pair_run(positive, pair)
|
||||
self.assertEqual(found["run"]["run_id"], newest.run_id)
|
||||
|
||||
def test_a_position_from_before_460_resumes_the_same_scope(self):
|
||||
db = self.database()
|
||||
positive = self.hashed(db, "A Lighthouse")
|
||||
for i in range(3):
|
||||
self.capture(db, i, bundle(positive="a lighthouse"), time=100 + i)
|
||||
with db.recorded_history_snapshot():
|
||||
first = db.list_history_window(positive, limit=2)
|
||||
position = first["next_position"]
|
||||
legacy = replace(
|
||||
position, raw_text_id=content_digest("text", ("A Lighthouse",))
|
||||
)
|
||||
rest = db.list_history_window(positive, limit=2, after=legacy)
|
||||
with self.assertRaises(ValueError):
|
||||
db.list_history_window(
|
||||
positive,
|
||||
after=replace(position, raw_text_id=content_digest("text", ("x",))),
|
||||
)
|
||||
self.assertEqual(first["recorded_run_count"], 3)
|
||||
self.assertEqual(len(rest["items"]), 1)
|
||||
self.assertEqual(
|
||||
rest["group"]["raw_text_id"], content_digest("text", ("a lighthouse",))
|
||||
)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
"""tools/repair_foreign_links.py: the reviewed repair of the #247 dataset links.
|
||||
|
||||
The fixture is a library whose foreign (no PromptManager node) dataset run
|
||||
wrote shared-prefix files that the old scan linked to the wrong branch text.
|
||||
Recorded history names each file's saver, so the ladder (``recorded-run``)
|
||||
names the right branch.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
from tests.test_run_attribution import RecordedRuns
|
||||
from tools import repair_foreign_links as tool
|
||||
from utils.file_prompt import Decision
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
TEXTS = shapes.BRANCH_TEXTS
|
||||
NAME = "IMG_GEN_{:05}_.png"
|
||||
|
||||
|
||||
def png(path, graph):
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
Image.new("RGB", (4, 4)).save(path, pnginfo=info)
|
||||
|
||||
|
||||
def sha(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def file_sha(path):
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def dump(path):
|
||||
"""The database's logical content (independent of WAL checkpoints)."""
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
return list(conn.iterdump())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def not_running(_comfyui_dir, _port):
|
||||
return []
|
||||
|
||||
|
||||
def no_handles(_database):
|
||||
return []
|
||||
|
||||
|
||||
class RepairFixture(RecordedRuns, unittest.TestCase):
|
||||
"""Rows: A relink to a new prompt, B relink to a stored prompt (with a
|
||||
pair link), C keep, D file gone, E two savers (skip), F the UNIQUE case
|
||||
(a wrong row beside a correct row), H relink from a rated prompt, G a
|
||||
PromptManager run (not a candidate)."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.addCleanup(assert_closed, self, self.tmp)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.output = os.path.join(self.tmp, "output")
|
||||
os.makedirs(os.path.join(self.output, "dataset"))
|
||||
self.db_path = os.path.join(self.tmp, "prompts.db")
|
||||
self.plan_path = os.path.join(self.tmp, "plan.json")
|
||||
self.backups = os.path.join(self.tmp, "backups")
|
||||
os.makedirs(self.backups)
|
||||
self.comfy = os.path.join(self.tmp, "comfy")
|
||||
os.makedirs(self.comfy)
|
||||
Path(self.comfy, "main.py").write_text("# ComfyUI\n", encoding="utf-8")
|
||||
self.files = {}
|
||||
for key, number in zip("ABCDEFHG", range(1, 9)):
|
||||
path = os.path.join(self.output, "dataset", NAME.format(number))
|
||||
png(path, shapes.shared_prefix())
|
||||
self.files[key] = path
|
||||
db = PromptDatabase(self.db_path)
|
||||
try:
|
||||
self.build(db)
|
||||
finally:
|
||||
db.close_all()
|
||||
os.remove(self.files["D"])
|
||||
|
||||
def build(self, db):
|
||||
save = lambda text, **kw: db.save_prompt( # noqa: E731
|
||||
text=text, prompt_hash=generate_prompt_hash(text), **kw
|
||||
)
|
||||
self.p0 = save(TEXTS[0], category="scanned", tags=["auto-scanned"])
|
||||
self.p2 = save(TEXTS[2], category="scanned", tags=["auto-scanned"])
|
||||
self.orphan = save(
|
||||
"an old guess",
|
||||
category="scanned",
|
||||
tags=["auto-scanned"],
|
||||
notes="Auto-scanned from IMG_GEN_00001_.png",
|
||||
)
|
||||
self.rated = save("a rated guess", rating=5)
|
||||
link = lambda prompt, key: db.link_image_to_prompt( # noqa: E731
|
||||
prompt, self.files[key], metadata={}
|
||||
)
|
||||
self.rows = {
|
||||
"A": link(self.orphan, "A"),
|
||||
"B": link(self.p0, "B"),
|
||||
"C": link(self.p0, "C"),
|
||||
"D": link(self.p0, "D"),
|
||||
"E": link(self.p0, "E"),
|
||||
"F": link(self.p0, "F"),
|
||||
"F_ok": link(self.p2, "F"),
|
||||
"H": link(self.rated, "H"),
|
||||
"G": link(self.p0, "G"),
|
||||
}
|
||||
conn = db.model.get_connection()
|
||||
with conn:
|
||||
pair = conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id) VALUES(?)", (self.p0,)
|
||||
).lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(image_id, pair_id, provenance) "
|
||||
"VALUES(?,?,'{}')",
|
||||
(self.rows["B"], pair),
|
||||
)
|
||||
name = lambda key: ("dataset", os.path.basename(self.files[key])) # noqa
|
||||
# Savers of the shared-prefix graph: "13", "23", "33", "43" (branches 1-4)
|
||||
self.record_run(
|
||||
db,
|
||||
self.output,
|
||||
[name(k) for k in "CDEAEBFH"],
|
||||
[],
|
||||
nodes=["13", "13", "13", "23", "23", "33", "33", "43"],
|
||||
)
|
||||
self.record_run(db, self.output, [name("G")], [TEXTS[0]], item=1, nodes=["23"])
|
||||
|
||||
def dry_run(self, *args):
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--output-dir",
|
||||
self.output,
|
||||
"--plan",
|
||||
self.plan_path,
|
||||
*args,
|
||||
],
|
||||
out=out,
|
||||
)
|
||||
return code, out.getvalue()
|
||||
|
||||
def apply(self, *args, probe=not_running, handles=no_handles):
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--apply",
|
||||
self.plan_path,
|
||||
"--comfyui-dir",
|
||||
os.path.join(self.tmp, "comfy"),
|
||||
"--backup-dir",
|
||||
self.backups,
|
||||
*args,
|
||||
],
|
||||
out=out,
|
||||
probe=probe,
|
||||
handles=handles,
|
||||
)
|
||||
return code, out.getvalue()
|
||||
|
||||
def plan(self):
|
||||
with open(self.plan_path, encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
def write_plan(self, plan):
|
||||
with open(self.plan_path, "w", encoding="utf-8") as stream:
|
||||
json.dump(plan, stream)
|
||||
|
||||
def query(self, sql, args=()):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
return conn.execute(sql, args).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def query_write(self, sql, args):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(sql, args)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def links(self):
|
||||
return dict(self.query("SELECT id, prompt_id FROM generated_images"))
|
||||
|
||||
|
||||
class DryRunTests(RepairFixture):
|
||||
def test_plan_names_relink_keep_and_skip_rows(self):
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
items = {item["row_id"]: item for item in self.plan()["rows"]}
|
||||
rows = self.rows
|
||||
self.assertNotIn(rows["G"], items, "a PromptManager run is not foreign")
|
||||
actions = {key: items[rows[key]]["action"] for key in rows if key != "G"}
|
||||
self.assertEqual(
|
||||
actions,
|
||||
{
|
||||
"A": "relink",
|
||||
"B": "relink",
|
||||
"C": "keep",
|
||||
"D": "skip",
|
||||
"E": "skip",
|
||||
"F": "relink",
|
||||
"F_ok": "keep",
|
||||
"H": "relink",
|
||||
},
|
||||
)
|
||||
a = items[rows["A"]]
|
||||
self.assertEqual(a["current_prompt_id"], self.orphan)
|
||||
self.assertEqual(a["current_text_sha256"], sha("an old guess"))
|
||||
self.assertEqual(a["target_text"], TEXTS[1])
|
||||
self.assertEqual(a["target_hash"], generate_prompt_hash(TEXTS[1]))
|
||||
self.assertEqual(a["target_prompt_id"], "new")
|
||||
self.assertEqual(a["file"], self.files["A"])
|
||||
self.assertEqual(items[rows["B"]]["target_prompt_id"], self.p2)
|
||||
self.assertEqual(items[rows["H"]]["target_prompt_id"], "new")
|
||||
self.assertEqual(items[rows["D"]]["reason"], "file gone")
|
||||
self.assertEqual(
|
||||
items[rows["E"]]["reason"], "not decided by the recorded saver"
|
||||
)
|
||||
self.assertIn("relink: 4", out)
|
||||
self.assertIn("keep: 2", out)
|
||||
self.assertIn("skip: 2", out)
|
||||
|
||||
def test_orphan_audit_lists_the_prompts_losing_every_link(self):
|
||||
self.dry_run()
|
||||
plan = self.plan()
|
||||
orphans = {o["id"]: o for o in plan["orphans"]}
|
||||
self.assertEqual(set(orphans), {self.orphan, self.rated})
|
||||
self.assertEqual(orphans[self.orphan]["blockers"], [])
|
||||
self.assertTrue(orphans[self.orphan]["safe"])
|
||||
self.assertEqual(orphans[self.rated]["blockers"], ["usage or rating"])
|
||||
self.assertFalse(orphans[self.rated]["safe"])
|
||||
self.assertFalse(plan["delete_orphans"], "off by default")
|
||||
self.dry_run("--delete-orphans")
|
||||
self.assertTrue(self.plan()["delete_orphans"])
|
||||
|
||||
def test_dry_run_writes_nothing_and_writes_the_markdown_list(self):
|
||||
before = file_sha(self.db_path)
|
||||
listing = sorted(os.listdir(self.tmp))
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(file_sha(self.db_path), before)
|
||||
self.assertIn("Dry run: nothing was changed.", out)
|
||||
# A read-only reader of a WAL library may leave its empty side files
|
||||
sidecars = {"prompts.db-shm", "prompts.db-wal"}
|
||||
self.assertEqual(
|
||||
sorted(set(os.listdir(self.tmp)) - sidecars),
|
||||
sorted(set(listing + ["plan.json", "plan.list.md"]) - sidecars),
|
||||
)
|
||||
wal = Path(self.tmp, "prompts.db-wal")
|
||||
self.assertEqual(wal.stat().st_size if wal.exists() else 0, 0)
|
||||
markdown = Path(self.tmp, "plan.list.md").read_text(encoding="utf-8")
|
||||
self.assertIn(f"| {self.rows['A']} |", markdown)
|
||||
long_path = os.path.join("dataset", NAME.format(1))
|
||||
self.assertIn(f"| {long_path} |", markdown, "file paths are never cut")
|
||||
self.assertIn(generate_prompt_hash(TEXTS[1])[:12], markdown)
|
||||
self.assertIn("new", markdown)
|
||||
self.assertIn("would lose every link", markdown)
|
||||
|
||||
|
||||
class DryRunReviewTests(RepairFixture):
|
||||
def test_plan_records_its_library_and_fingerprint(self):
|
||||
self.dry_run()
|
||||
plan = self.plan()
|
||||
self.assertEqual(
|
||||
plan["source_db"], os.path.normcase(os.path.realpath(self.db_path))
|
||||
)
|
||||
self.assertEqual(plan["max_row_id"], max(self.links()))
|
||||
|
||||
def test_a_markdown_plan_path_is_refused(self):
|
||||
out = io.StringIO()
|
||||
with (
|
||||
self.assertRaises(SystemExit),
|
||||
contextlib.redirect_stderr(io.StringIO()),
|
||||
):
|
||||
tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--output-dir",
|
||||
self.output,
|
||||
"--plan",
|
||||
os.path.join(self.tmp, "plan.md"),
|
||||
],
|
||||
out=out,
|
||||
)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.tmp, "plan.md")))
|
||||
|
||||
def test_keep_compares_the_linked_text_not_the_stored_hash(self):
|
||||
self.query_write("UPDATE prompts SET hash='stale' WHERE id=?", (self.p0,))
|
||||
self.dry_run()
|
||||
items = {item["row_id"]: item for item in self.plan()["rows"]}
|
||||
self.assertEqual(items[self.rows["C"]]["action"], "keep")
|
||||
|
||||
def test_delete_orphans_is_rehearsed_with_the_deletes(self):
|
||||
code, out = self.dry_run("--delete-orphans")
|
||||
self.assertEqual(code, 0, out)
|
||||
simulated = self.plan()["simulated"]
|
||||
self.assertEqual(simulated["result"]["orphans_deleted"], 1)
|
||||
self.assertEqual(
|
||||
simulated["after"]["prompts"], simulated["before"]["prompts"] + 2 - 1
|
||||
)
|
||||
|
||||
def test_a_file_on_another_drive_is_listed_by_its_full_path(self):
|
||||
self.dry_run()
|
||||
plan = self.plan()
|
||||
with unittest.mock.patch.object(
|
||||
tool.os.path, "relpath", side_effect=ValueError("other drive")
|
||||
):
|
||||
listing = tool.markdown(plan, {})
|
||||
self.assertIn(self.files["A"], listing)
|
||||
|
||||
|
||||
class ApplyTests(RepairFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def test_apply_relinks_creates_targets_and_drops_pair_links(self):
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
links = self.links()
|
||||
new = dict(self.query("SELECT hash, id FROM prompts"))
|
||||
target_a = new[generate_prompt_hash(TEXTS[1])]
|
||||
target_h = new[generate_prompt_hash(TEXTS[3])]
|
||||
self.assertEqual(links[self.rows["A"]], target_a)
|
||||
self.assertEqual(links[self.rows["B"]], self.p2)
|
||||
self.assertEqual(links[self.rows["H"]], target_h)
|
||||
self.assertEqual(links[self.rows["C"]], self.p0, "keep")
|
||||
self.assertEqual(links[self.rows["E"]], self.p0, "skip")
|
||||
self.assertEqual(links[self.rows["G"]], self.p0, "not foreign")
|
||||
self.assertEqual(
|
||||
self.query(
|
||||
"SELECT COUNT(*) FROM image_prompt_pairs WHERE image_id=?",
|
||||
(self.rows["B"],),
|
||||
),
|
||||
[(0,)],
|
||||
)
|
||||
category, notes = self.query(
|
||||
"SELECT category, notes FROM prompts WHERE id=?", (target_a,)
|
||||
)[0]
|
||||
self.assertEqual(category, "scanned")
|
||||
self.assertEqual(
|
||||
notes, f"Auto-scanned from {os.path.basename(self.files['A'])}"
|
||||
)
|
||||
tags = self.query(
|
||||
"SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.prompt_id=?",
|
||||
(target_a,),
|
||||
)
|
||||
self.assertEqual(tags, [("auto-scanned",)])
|
||||
self.assertIn("integrity_check", out)
|
||||
self.assertIn("foreign_key_check", out)
|
||||
self.assertRegex(out, r"integrity_check\s+ok\s+ok")
|
||||
# Orphans are kept unless the plan chose to delete them
|
||||
self.assertTrue(self.query("SELECT 1 FROM prompts WHERE id=?", (self.orphan,)))
|
||||
|
||||
def test_unique_case_deletes_the_wrong_row(self):
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
links = self.links()
|
||||
self.assertNotIn(self.rows["F"], links)
|
||||
self.assertEqual(links[self.rows["F_ok"]], self.p2)
|
||||
rows = self.query(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?",
|
||||
(self.files["F"],),
|
||||
)
|
||||
self.assertEqual(rows, [(self.p2,)])
|
||||
|
||||
def test_backup_is_taken_before_writing(self):
|
||||
before = self.links()
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
names = os.listdir(self.backups)
|
||||
self.assertEqual(len(names), 1)
|
||||
self.assertRegex(names[0], r"^prompts\.db\.pre-247-repair_\d{8}_\d{6}$")
|
||||
backup = os.path.join(self.backups, names[0])
|
||||
conn = sqlite3.connect(backup)
|
||||
try:
|
||||
saved = dict(conn.execute("SELECT id, prompt_id FROM generated_images"))
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(saved, before)
|
||||
self.assertIn(f"Backup: {backup}", out)
|
||||
|
||||
def test_backup_beside_the_database_by_default(self):
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--apply",
|
||||
self.plan_path,
|
||||
"--comfyui-dir",
|
||||
os.path.join(self.tmp, "comfy"),
|
||||
],
|
||||
out=out,
|
||||
probe=not_running,
|
||||
handles=no_handles,
|
||||
)
|
||||
self.assertEqual(code, 0, out.getvalue())
|
||||
self.assertTrue(
|
||||
any(
|
||||
n.startswith("prompts.db.pre-247-repair_") for n in os.listdir(self.tmp)
|
||||
)
|
||||
)
|
||||
|
||||
def test_refuses_while_comfyui_runs(self):
|
||||
before = file_sha(self.db_path)
|
||||
code, out = self.apply(probe=lambda _d, _p: ["process 42 (main.py)"])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("REFUSED", out)
|
||||
self.assertIn("process 42", out)
|
||||
self.assertEqual(os.listdir(self.backups), [], "no backup either")
|
||||
self.assertEqual(file_sha(self.db_path), before)
|
||||
|
||||
def test_rehearsal_skips_the_running_check_on_a_copy_only(self):
|
||||
code, out = self.apply("--rehearsal", probe=lambda _d, _p: ["port 8188"])
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn("rehearsal", out)
|
||||
# The live library of that ComfyUI folder: --rehearsal is refused
|
||||
live_dir = os.path.join(self.tmp, "comfy", "user", "__promptmanager")
|
||||
os.makedirs(live_dir)
|
||||
live = os.path.join(live_dir, "prompts.db")
|
||||
shutil.copy(self.db_path, live)
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
live,
|
||||
"--apply",
|
||||
self.plan_path,
|
||||
"--comfyui-dir",
|
||||
os.path.join(self.tmp, "comfy"),
|
||||
"--rehearsal",
|
||||
],
|
||||
out=out,
|
||||
probe=lambda _d, _p: [],
|
||||
handles=no_handles,
|
||||
)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("--rehearsal", out.getvalue())
|
||||
|
||||
def test_drift_refuses_and_changes_nothing(self):
|
||||
cases = {
|
||||
"row moved": lambda: self.query_write(
|
||||
"UPDATE generated_images SET prompt_id=? WHERE id=?",
|
||||
(self.p2, self.rows["H"]),
|
||||
),
|
||||
"text changed": lambda: self.query_write(
|
||||
"UPDATE prompts SET text='an older guess' WHERE id=?", (self.orphan,)
|
||||
),
|
||||
"file changed": lambda: png(self.files["B"], shapes.branch_bench()),
|
||||
"target stored since": lambda: self.query_write(
|
||||
"INSERT INTO prompts(text, hash) VALUES(?,?)",
|
||||
(TEXTS[1], generate_prompt_hash(TEXTS[1])),
|
||||
),
|
||||
}
|
||||
pristine = os.path.join(self.tmp, "pristine.db")
|
||||
shutil.copy(self.db_path, pristine)
|
||||
image = Path(self.files["B"]).read_bytes()
|
||||
for name, drift in cases.items():
|
||||
with self.subTest(name):
|
||||
shutil.copy(pristine, self.db_path)
|
||||
Path(self.files["B"]).write_bytes(image)
|
||||
shutil.rmtree(self.backups)
|
||||
os.makedirs(self.backups)
|
||||
drift()
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertIn("REFUSED (rolled back, nothing changed)", out)
|
||||
self.assertEqual(dump(self.db_path), before)
|
||||
|
||||
def test_late_failure_rolls_back_every_write(self):
|
||||
plan = self.plan()
|
||||
plan["delete_orphans"] = True
|
||||
self.write_plan(plan)
|
||||
# The safe orphan gained a user tag after the dry run
|
||||
self.query_write("INSERT INTO tags(name) VALUES('mine')", ())
|
||||
self.query_write(
|
||||
"INSERT INTO prompt_tags(prompt_id, tag_id) "
|
||||
"SELECT ?, id FROM tags WHERE name='mine'",
|
||||
(self.orphan,),
|
||||
)
|
||||
before = self.links()
|
||||
prompts = self.query("SELECT COUNT(*) FROM prompts")
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertIn(f"prompt {self.orphan}", out)
|
||||
self.assertIn("user tags", out)
|
||||
self.assertEqual(self.links(), before, "the relinks were rolled back")
|
||||
self.assertEqual(self.query("SELECT COUNT(*) FROM prompts"), prompts)
|
||||
|
||||
def test_orphans_are_deleted_only_when_chosen_and_unreferenced(self):
|
||||
plan = self.plan()
|
||||
plan["delete_orphans"] = True
|
||||
self.write_plan(plan)
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertFalse(self.query("SELECT 1 FROM prompts WHERE id=?", (self.orphan,)))
|
||||
self.assertTrue(
|
||||
self.query("SELECT 1 FROM prompts WHERE id=?", (self.rated,)),
|
||||
"a prompt with a rating is never deleted",
|
||||
)
|
||||
self.assertIn("orphans_deleted", out)
|
||||
|
||||
def test_an_applied_plan_refuses_a_second_time(self):
|
||||
self.assertEqual(self.apply()[0], 0)
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("REFUSED", out)
|
||||
self.assertEqual(dump(self.db_path), before)
|
||||
|
||||
|
||||
class ApplySafetyTests(RepairFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def assert_unchanged(self, before, code, out):
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertEqual(dump(self.db_path), before)
|
||||
|
||||
def test_refuses_when_another_process_has_the_library_open(self):
|
||||
before = dump(self.db_path)
|
||||
held = lambda _db: ["process 7 has prompts.db-wal open"] # noqa: E731
|
||||
for args in ((), ("--rehearsal",)):
|
||||
with self.subTest(args):
|
||||
code, out = self.apply(*args, handles=held)
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("process 7", out)
|
||||
self.assertEqual(os.listdir(self.backups), [])
|
||||
|
||||
def test_the_running_check_runs_again_just_before_writing(self):
|
||||
calls = []
|
||||
|
||||
def late(_d, _p):
|
||||
calls.append(1)
|
||||
return [] if len(calls) == 1 else ["something listens on port 8188"]
|
||||
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply(probe=late)
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertIn("rolled back", out)
|
||||
self.assertIn("Backup:", out)
|
||||
|
||||
def test_without_proc_only_an_explicit_stop_lets_it_through(self):
|
||||
blind = lambda _db: [tool.NO_PROC] # noqa: E731
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply(handles=blind)
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("--i-stopped-comfyui", out)
|
||||
code, out = self.apply("--i-stopped-comfyui", handles=blind)
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def test_a_folder_without_main_py_is_refused(self):
|
||||
os.remove(os.path.join(self.comfy, "main.py"))
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("main.py", out)
|
||||
|
||||
def test_a_plan_for_another_library_is_refused(self):
|
||||
other = os.path.join(self.tmp, "other.db")
|
||||
shutil.copy(self.db_path, other)
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[other, "--apply", self.plan_path, "--comfyui-dir", self.comfy],
|
||||
out=out,
|
||||
probe=not_running,
|
||||
handles=no_handles,
|
||||
)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("made for", out.getvalue())
|
||||
|
||||
def test_opening_the_library_must_not_change_its_schema(self):
|
||||
self.query_write("DROP INDEX idx_prompt_pairs_positive_only", ())
|
||||
PromptModel.reset_schema_cache()
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertIn("schema", out)
|
||||
self.assertIn("live code version", out)
|
||||
|
||||
def test_an_existing_backup_file_is_refused(self):
|
||||
stamp = "20261028_000000"
|
||||
Path(self.backups, f"prompts.db.pre-247-repair_{stamp}").write_bytes(b"x")
|
||||
before = dump(self.db_path)
|
||||
with unittest.mock.patch.object(tool, "_stamp", return_value=stamp):
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("already exists", out)
|
||||
|
||||
def test_a_backup_that_does_not_match_is_refused(self):
|
||||
real = tool.backup_database
|
||||
|
||||
def short(source, target):
|
||||
real(source, target)
|
||||
conn = sqlite3.connect(target)
|
||||
try:
|
||||
with conn:
|
||||
conn.execute("DELETE FROM image_prompt_pairs")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
before = dump(self.db_path)
|
||||
with unittest.mock.patch.object(tool, "backup_database", short):
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("backup", out)
|
||||
|
||||
def test_a_changed_target_text_is_refused(self):
|
||||
plan = self.plan()
|
||||
for item in plan["rows"]:
|
||||
if item["row_id"] == self.rows["A"]:
|
||||
item["target_text"] = "something else"
|
||||
self.write_plan(plan)
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("target_text", out)
|
||||
|
||||
def test_an_orphan_gone_or_changed_at_apply_is_refused(self):
|
||||
plan = self.plan()
|
||||
plan["delete_orphans"] = True
|
||||
plan["orphans"][0]["text_sha256"] = "0" * 64
|
||||
self.write_plan(plan)
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("gone or text changed", out)
|
||||
|
||||
def test_any_error_rolls_back_and_names_the_backup(self):
|
||||
before = dump(self.db_path)
|
||||
with unittest.mock.patch.object(
|
||||
tool, "_relink", side_effect=RuntimeError("disk on fire")
|
||||
):
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("rolled back", out)
|
||||
self.assertIn("disk on fire", out)
|
||||
self.assertIn(self.backups, out)
|
||||
|
||||
def test_a_failed_integrity_check_after_writing_exits_1(self):
|
||||
real = tool.counts
|
||||
seen = []
|
||||
|
||||
def counts(path, readonly=True):
|
||||
found = real(path, readonly)
|
||||
if path == self.db_path:
|
||||
seen.append(path)
|
||||
if len(seen) > 1:
|
||||
return {**found, "integrity_check": "corrupt"}
|
||||
return found
|
||||
|
||||
with unittest.mock.patch.object(tool, "counts", counts):
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 1, out)
|
||||
self.assertIn("restore", out)
|
||||
|
||||
|
||||
class BlockerTests(RepairFixture):
|
||||
"""Each reference that keeps a prompt from being deleted."""
|
||||
|
||||
def test_every_blocker(self):
|
||||
pid = self.orphan
|
||||
cases = {
|
||||
"linked images": None,
|
||||
"saved pairs": "INSERT INTO prompt_pairs(positive_prompt_id) VALUES(:id)",
|
||||
"recorded runs": "UPDATE execution_run_contexts "
|
||||
"SET positive_prompt_id=:id",
|
||||
"library state": "INSERT INTO prompt_library_state(prompt_id) "
|
||||
"VALUES(:id)",
|
||||
"user tags": "INSERT INTO prompt_tags(prompt_id, tag_id) "
|
||||
"SELECT :id, id FROM tags WHERE name='mine'",
|
||||
"usage or rating": "UPDATE prompts SET run_count=3 WHERE id=:id",
|
||||
"category": "UPDATE prompts SET category='portraits' WHERE id=:id",
|
||||
"notes": "UPDATE prompts SET notes='mine' WHERE id=:id",
|
||||
}
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute("INSERT INTO tags(name) VALUES('mine')")
|
||||
conn.commit()
|
||||
for name, sql in cases.items():
|
||||
with self.subTest(name):
|
||||
conn.execute("SAVEPOINT blocker")
|
||||
if sql is None:
|
||||
unused = {pid}
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM generated_images WHERE prompt_id=?", (pid,)
|
||||
)
|
||||
conn.execute(sql, {"id": pid})
|
||||
unused = {pid}
|
||||
found = tool.blockers(conn, pid, unused)
|
||||
self.assertTrue(any(item.startswith(name) for item in found), found)
|
||||
conn.execute("ROLLBACK TO blocker")
|
||||
conn.execute("RELEASE blocker")
|
||||
conn.execute("DELETE FROM generated_images WHERE prompt_id=?", (pid,))
|
||||
self.assertEqual(tool.blockers(conn, pid, {pid}), [])
|
||||
self.assertEqual(
|
||||
tool.blockers(conn, pid, set()), ["not unused by the app's rule"]
|
||||
)
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _off_text_node(text, save=False):
|
||||
return {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": text, "save_to_library": save},
|
||||
}
|
||||
|
||||
|
||||
def _encode_from(source):
|
||||
return {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": source, "clip": ["1", 1]},
|
||||
}
|
||||
|
||||
|
||||
def off_behind_pass_through(branch=2):
|
||||
"""The dataset graph with ``branch``'s text coming from a switched-off
|
||||
PromptManagerText through a pass-through the #90 walk does not follow."""
|
||||
graph = shapes.shared_prefix()
|
||||
graph["50"] = _off_text_node(TEXTS[branch - 1])
|
||||
graph["51"] = {"class_type": "MyNode", "inputs": {"value": ["50", 0]}}
|
||||
graph[str(branch * 10)] = _encode_from(["51", 0])
|
||||
return graph
|
||||
|
||||
|
||||
def off_target(branch=2, save=False):
|
||||
"""``branch``'s text straight from a PromptManagerText whose switch is
|
||||
``save`` (False, or a link to a node the graph cannot read)."""
|
||||
graph = shapes.shared_prefix()
|
||||
graph["50"] = _off_text_node(TEXTS[branch - 1], save)
|
||||
graph[str(branch * 10)] = _encode_from(["50", 0])
|
||||
if isinstance(save, list):
|
||||
graph["52"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
return graph
|
||||
|
||||
|
||||
def off_negative_only():
|
||||
"""Every branch's negative from a switched-off PromptManagerText."""
|
||||
graph = shapes.shared_prefix()
|
||||
graph["50"] = _off_text_node(shapes.NEGATIVE)
|
||||
graph["3"] = _encode_from(["50", 0])
|
||||
return graph
|
||||
|
||||
|
||||
class SaveToLibraryTests(RecordedRuns, unittest.TestCase):
|
||||
"""#90: the repair never relinks a file to a switched-off node's text.
|
||||
|
||||
The plan decides each file through ``decide_file_prompt``; every answer
|
||||
but a positive from the recorded saver or the inspector is a skip, so a
|
||||
``not_saved`` or ``not_saved_wired`` file is never written. Each file is
|
||||
reported by a run without contexts (as a cached or unrecorded run is),
|
||||
so only the ladder can stop it. Saver "23" is branch 2.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.addCleanup(assert_closed, self, self.tmp)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.output = os.path.join(self.tmp, "output")
|
||||
os.makedirs(os.path.join(self.output, "dataset"))
|
||||
self.db_path = os.path.join(self.tmp, "prompts.db")
|
||||
|
||||
def plan_for(self, graph):
|
||||
path = os.path.join(self.output, "dataset", NAME.format(1))
|
||||
png(path, graph)
|
||||
db = PromptDatabase(self.db_path)
|
||||
try:
|
||||
wrong = db.save_prompt(
|
||||
text=TEXTS[0], prompt_hash=generate_prompt_hash(TEXTS[0])
|
||||
)
|
||||
db.link_image_to_prompt(wrong, path, metadata={})
|
||||
self.record_run(
|
||||
db, self.output, [("dataset", NAME.format(1))], [], nodes=["23"]
|
||||
)
|
||||
plan = tool.build_plan(db, self.output, self.db_path)
|
||||
finally:
|
||||
db.close_all()
|
||||
(item,) = plan["rows"]
|
||||
return item
|
||||
|
||||
def test_an_off_node_behind_a_pass_through_is_skipped(self):
|
||||
item = self.plan_for(off_behind_pass_through())
|
||||
self.assertEqual(item["action"], "skip")
|
||||
self.assertEqual(item["decision"], "not_saved/none")
|
||||
self.assertNotIn("target_text", item)
|
||||
|
||||
def test_an_off_node_in_the_recorded_run_target_is_skipped(self):
|
||||
cases = {
|
||||
"off": (False, "not_saved/none"),
|
||||
"wired": (["52", 0], "not_saved_wired/none"),
|
||||
}
|
||||
for name, (save, decision) in cases.items():
|
||||
with self.subTest(name):
|
||||
if os.path.exists(self.db_path):
|
||||
os.remove(self.db_path)
|
||||
item = self.plan_for(off_target(save=save))
|
||||
self.assertEqual(item["action"], "skip")
|
||||
self.assertEqual(item["decision"], decision)
|
||||
|
||||
def test_an_off_node_on_another_branch_still_skips_the_file(self):
|
||||
"""The ladder judges the file's whole graph: any switched-off text
|
||||
upstream of a positive makes the file not saved."""
|
||||
item = self.plan_for(off_target(branch=3))
|
||||
self.assertEqual(item["action"], "skip")
|
||||
self.assertEqual(item["decision"], "not_saved/none")
|
||||
|
||||
def test_an_off_node_wired_only_to_the_negative_still_repairs(self):
|
||||
item = self.plan_for(off_negative_only())
|
||||
self.assertEqual(item["action"], "relink")
|
||||
self.assertEqual(item["source"], "recorded-run")
|
||||
self.assertEqual(item["target_text"], TEXTS[1])
|
||||
|
||||
def test_a_switched_on_node_in_the_target_still_repairs(self):
|
||||
"""Regression guard: a switch that is on is no reason to skip."""
|
||||
item = self.plan_for(off_target(save=True))
|
||||
self.assertEqual(item["action"], "relink")
|
||||
self.assertEqual(item["target_text"], TEXTS[1])
|
||||
|
||||
|
||||
class ActionTests(unittest.TestCase):
|
||||
"""Only the recorded saver relinks; a certain step naming the linked
|
||||
prompt keeps it; the legacy floor and the rest are skipped."""
|
||||
|
||||
def test_action_by_decision(self):
|
||||
linked = generate_prompt_hash("linked")
|
||||
cases = [
|
||||
(Decision("positive", "other", source="recorded-run"), "relink"),
|
||||
(Decision("positive", "linked", source="recorded-run"), "keep"),
|
||||
(Decision("positive", "linked", source="inspector"), "keep"),
|
||||
(Decision("positive", "other", source="inspector"), "skip"),
|
||||
(Decision("positive", "linked", source="legacy-parser"), "skip"),
|
||||
(Decision("pair", "linked", source="resolver"), "skip"),
|
||||
(Decision("never"), "skip"),
|
||||
(Decision("not_saved"), "skip"),
|
||||
(Decision("not_saved_wired"), "skip"),
|
||||
(Decision("positive", "other", source="opted-out-negative"), "skip"),
|
||||
]
|
||||
for decision, action in cases:
|
||||
with self.subTest(decision.source, text=decision.text):
|
||||
self.assertEqual(tool.action_of(decision, linked), action)
|
||||
|
||||
|
||||
class CommandLineTests(unittest.TestCase):
|
||||
def test_running_probe_reports_a_listening_port(self):
|
||||
import socket
|
||||
|
||||
with socket.socket() as server:
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
port = server.getsockname()[1]
|
||||
found = tool.comfyui_running("/nonexistent/comfy", port)
|
||||
self.assertTrue(any(str(port) in item for item in found))
|
||||
|
||||
def test_running_probe_is_quiet_when_nothing_runs(self):
|
||||
import socket
|
||||
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
self.assertEqual(tool.comfyui_running("/nonexistent/comfy", port), [])
|
||||
|
||||
def fake_proc(self, tmp):
|
||||
proc = Path(tmp, "proc")
|
||||
db = Path(tmp, "prompts.db")
|
||||
db.write_bytes(b"")
|
||||
Path(tmp, "prompts.db-wal").write_bytes(b"")
|
||||
comfy = Path(tmp, "comfy")
|
||||
comfy.mkdir()
|
||||
holder = proc / "125" / "fd"
|
||||
holder.mkdir(parents=True)
|
||||
os.symlink(Path(tmp, "prompts.db-wal"), holder / "3")
|
||||
(proc / "125" / "cmdline").write_bytes(b"python\0main.py\0")
|
||||
os.symlink(comfy, proc / "125" / "cwd")
|
||||
(proc / "123").mkdir() # unreadable: no fd, cmdline or cwd
|
||||
(proc / "self").mkdir() # not a pid
|
||||
return proc, db, comfy
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows")
|
||||
def test_proc_scan_finds_holders_and_skips_unreadable_entries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proc, db, comfy = self.fake_proc(tmp)
|
||||
found = tool.open_by_others(str(db), proc=str(proc), own_pid=1)
|
||||
self.assertEqual(len(found), 1)
|
||||
self.assertIn("125", found[0])
|
||||
self.assertEqual(
|
||||
tool.open_by_others(str(db), proc=str(proc), own_pid=125), []
|
||||
)
|
||||
running = tool.comfyui_running(str(comfy), 1, proc=str(proc))
|
||||
self.assertTrue(any("125" in item for item in running), running)
|
||||
|
||||
def test_without_proc_the_scan_says_it_cannot_tell(self):
|
||||
self.assertEqual(
|
||||
tool.open_by_others("/x/prompts.db", proc="/nonexistent/proc"),
|
||||
[tool.NO_PROC],
|
||||
)
|
||||
|
||||
def test_missing_database_is_an_argument_error(self):
|
||||
with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()):
|
||||
tool.main(
|
||||
["/nonexistent/prompts.db", "--output-dir", "/tmp"], out=io.StringIO()
|
||||
)
|
||||
|
||||
def test_apply_needs_the_comfyui_folder(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "prompts.db")
|
||||
Path(db).write_bytes(b"")
|
||||
with (
|
||||
self.assertRaises(SystemExit),
|
||||
contextlib.redirect_stderr(io.StringIO()),
|
||||
):
|
||||
tool.main([db, "--apply", "plan.json"], out=io.StringIO())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.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,487 @@ class TestMonitorHistoryRecheck(RecordedRuns, unittest.TestCase):
|
||||
run_attribution.notify_outputs_sealed("run", []) # no dead callback raises
|
||||
|
||||
|
||||
def folder_is_case_sensitive(folder):
|
||||
"""True when ``folder``'s volume tells names apart by case: the existing
|
||||
folder spelled with swapped case is not found (#476)."""
|
||||
head, name = os.path.split(folder)
|
||||
return not os.path.exists(os.path.join(head, name.swapcase()))
|
||||
|
||||
|
||||
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.
|
||||
|
||||
On a case-sensitive folder the two spellings are two files and the
|
||||
folded key joins them; on a case-insensitive one (Windows, default
|
||||
macOS) they already name one file, so only history's spelling and
|
||||
the watcher's differ (#476)."""
|
||||
seen = os.path.join(self.output, "dataset", "img_gen_00007_.png")
|
||||
Path(seen).write_bytes(b"png")
|
||||
recorded = os.path.join(self.output, "Dataset", "IMG_GEN_00007_.png")
|
||||
self.assertNotEqual(os.path.basename(recorded), os.path.basename(seen))
|
||||
if folder_is_case_sensitive(os.path.join(self.output, "dataset")):
|
||||
Path(self.output, "Dataset").mkdir()
|
||||
self.image("IMG_GEN_00007_.png", subfolder="Dataset")
|
||||
else:
|
||||
self.assertTrue(os.path.exists(recorded))
|
||||
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."""
|
||||
|
||||
|
||||
@@ -42,7 +42,13 @@ IMAGES_STATE = {
|
||||
"favorites": False,
|
||||
"collection": "",
|
||||
}
|
||||
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
|
||||
LIBRARY_STATE = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
"sort": "rating",
|
||||
"tags": [],
|
||||
"tag_mode": "untagged",
|
||||
}
|
||||
IMAGES_DEFAULTS = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
@@ -54,7 +60,13 @@ IMAGES_DEFAULTS = {
|
||||
"favorites": False,
|
||||
"collection": "",
|
||||
}
|
||||
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
|
||||
LIBRARY_DEFAULTS = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
"sort": "newest",
|
||||
"tags": [],
|
||||
"tag_mode": "all",
|
||||
}
|
||||
# A row saved by an older build: no favorites key, a retired key, a sort
|
||||
# that is no longer offered and an over-long folder.
|
||||
OLD_IMAGES_STATE = json.dumps(
|
||||
@@ -228,9 +240,16 @@ class TestSavedViewsApi(ImageAPITestCase):
|
||||
favorite = await self.create(state={"favorites": True})
|
||||
self.assertIs(favorite["state"]["favorites"], True)
|
||||
for sort in ("newest", "oldest", "name", "rating"):
|
||||
await self.create(page="library", state={"sort": sort, "tags": ["a b"]})
|
||||
await self.create(
|
||||
page="library",
|
||||
state={"sort": sort, "tags": ["a b"], "tag_mode": "all"},
|
||||
)
|
||||
untagged = await self.create(
|
||||
page="library", state={"tags": [], "tag_mode": "untagged"}
|
||||
)
|
||||
self.assertEqual(untagged["state"]["tag_mode"], "untagged")
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(len(listed["views"]), 20)
|
||||
self.assertEqual(len(listed["views"]), 21)
|
||||
|
||||
async def test_write_requires_the_collection_key(self):
|
||||
# #331 D140: a view saved on a collection page keeps the collection id.
|
||||
@@ -675,6 +694,23 @@ class TestSavedViewsSchema(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(normalize_state("history", {}), {})
|
||||
|
||||
def test_library_tag_mode_is_kept_and_never_paired_with_tags(self):
|
||||
# A row from before #444 has no tag_mode: it opens matching every tag.
|
||||
old = {"q": "", "category": "", "sort": "rating", "tags": ["a"]}
|
||||
self.assertEqual(normalize_state("library", old), {**LIBRARY_DEFAULTS, **old})
|
||||
self.assertEqual(
|
||||
normalize_state("library", {"tag_mode": "any"}), LIBRARY_DEFAULTS
|
||||
)
|
||||
# Never written by this build: the tags win, as the listing refuses both.
|
||||
self.assertEqual(
|
||||
normalize_state("library", {"tags": ["a"], "tag_mode": "untagged"}),
|
||||
{**LIBRARY_DEFAULTS, "tags": ["a"]},
|
||||
)
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
encode_state("library", {**LIBRARY_STATE, "tags": ["a"]})
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
encode_state("library", {**LIBRARY_STATE, "tag_mode": "none"})
|
||||
|
||||
def test_validation_errors_are_saved_view_invalid(self):
|
||||
for call in (
|
||||
lambda: validate_name(""),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -364,6 +364,162 @@ class TestVocabularyReads(unittest.TestCase):
|
||||
self.assertTrue(conn.in_transaction)
|
||||
|
||||
|
||||
class _ReadFixture(unittest.TestCase):
|
||||
"""TestVocabularyReads' database and helpers, without re-running its tests."""
|
||||
|
||||
setUp = TestVocabularyReads.setUp
|
||||
tearDown = TestVocabularyReads.tearDown
|
||||
save = TestVocabularyReads.save
|
||||
read = TestVocabularyReads.read
|
||||
add_unused = TestVocabularyReads.add_unused
|
||||
all_tags = TestVocabularyReads.all_tags
|
||||
|
||||
|
||||
class TestVocabularyTagSearchAndSort(_ReadFixture):
|
||||
"""#444: tag search, tag sort and the untagged prompts count."""
|
||||
|
||||
def names(self, **options):
|
||||
rows, after = [], None
|
||||
while True:
|
||||
page = self.read("list_vocabulary_tags", limit=2, after=after, **options)
|
||||
rows.extend(row["name"] for row in page["tags"])
|
||||
if not page["has_more"]:
|
||||
return rows
|
||||
after = page["next_key"]
|
||||
|
||||
def seed_counts(self):
|
||||
self.save("One", tags=["beta", "Alpha"])
|
||||
self.save("Two", tags=["beta", "gamma"])
|
||||
self.save("Three", tags=["beta"])
|
||||
self.add_unused("delta", "100%_off", "100x-off")
|
||||
|
||||
def test_search_is_a_case_insensitive_literal_substring(self):
|
||||
self.seed_counts()
|
||||
self.assertEqual(self.names(search="ALP"), ["Alpha"])
|
||||
self.assertEqual(self.names(search="%"), ["100%_off"])
|
||||
self.assertEqual(self.names(search="_"), ["100%_off"])
|
||||
self.assertEqual(
|
||||
self.names(search=" a "), ["beta", "Alpha", "gamma", "delta"]
|
||||
)
|
||||
page = self.read("list_vocabulary_tags", search="a")
|
||||
self.assertEqual(page["total"], 4)
|
||||
self.assertEqual(self.read("list_vocabulary_tags", search="")["total"], 6)
|
||||
|
||||
def test_each_sort_orders_rows_and_continues_without_gaps(self):
|
||||
self.seed_counts()
|
||||
self.assertEqual(
|
||||
self.names(), ["beta", "Alpha", "gamma", "delta", "100%_off", "100x-off"]
|
||||
)
|
||||
self.assertEqual(self.names(sort="created"), self.names())
|
||||
self.assertEqual(
|
||||
self.names(sort="name"),
|
||||
["100%_off", "100x-off", "Alpha", "beta", "delta", "gamma"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.names(sort="name-desc"),
|
||||
["gamma", "delta", "beta", "Alpha", "100x-off", "100%_off"],
|
||||
)
|
||||
# Ties keep creation order: Alpha and gamma (1), then the unused tags (0).
|
||||
self.assertEqual(
|
||||
self.names(sort="prompts"),
|
||||
["beta", "Alpha", "gamma", "delta", "100%_off", "100x-off"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.names(sort="prompts-asc"),
|
||||
["delta", "100%_off", "100x-off", "Alpha", "gamma", "beta"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.names(sort="name", search="a"), ["Alpha", "beta", "delta", "gamma"]
|
||||
)
|
||||
|
||||
def test_count_sort_uses_the_row_counts(self):
|
||||
self.save("Parent", tags=["direct"])
|
||||
self.db.save_prompt_pair("Owner", "Noise", tags=["pair"])
|
||||
self.db.save_prompt_pair("Owner two", "Noise two", tags=["pair"])
|
||||
archived = self.db.save_prompt_pair("Gone", "Gone noise", tags=["archived"])
|
||||
self.db.archive_prompt_pair(archived)
|
||||
page = self.read("list_vocabulary_tags", sort="prompts")
|
||||
self.assertEqual(
|
||||
[(row["name"], row["prompt_count"]) for row in page["tags"]],
|
||||
[("pair", 2), ("direct", 1), ("archived", 0)],
|
||||
)
|
||||
|
||||
def test_invalid_search_sort_and_keys_are_refused(self):
|
||||
self.seed_counts()
|
||||
for options in (
|
||||
{"sort": "count"},
|
||||
{"sort": None},
|
||||
{"search": "x" * 201},
|
||||
{"search": 3},
|
||||
{"sort": "name", "after": 3},
|
||||
{"sort": "name", "after": ["a"]},
|
||||
{"sort": "prompts", "after": ["a", 1]},
|
||||
{"sort": "created", "after": ["a", 1]},
|
||||
):
|
||||
with self.assertRaises(ValueError, msg=options):
|
||||
self.read("list_vocabulary_tags", **options)
|
||||
|
||||
def test_untagged_count_is_every_parent_no_tag_browse_returns(self):
|
||||
self.save("Plain")
|
||||
self.save("Tagged", tags=["t"])
|
||||
self.db.save_prompt_pair("Pair tagged", "Noise", tags=["p"])
|
||||
self.db.save_prompt_pair("Pair plain", "Noise plain")
|
||||
archived = self.db.save_prompt_pair("Archived tag", "Old", tags=["old"])
|
||||
self.db.archive_prompt_pair(archived)
|
||||
self.add_unused("unused")
|
||||
page = self.read("list_vocabulary_tags", search="zzz")
|
||||
untagged = self.db.list_library(tag_mode="untagged")
|
||||
self.assertEqual(
|
||||
sorted(item["text"] for item in untagged["items"]),
|
||||
["Archived tag", "Pair plain", "Plain"],
|
||||
)
|
||||
self.assertEqual(page["untagged_count"], untagged["total"])
|
||||
self.assertEqual(page["untagged_count"], 3)
|
||||
tagged = set()
|
||||
for row in self.all_tags():
|
||||
tagged |= {
|
||||
item["id"] for item in self.db.list_library(tags=[row["name"]])["items"]
|
||||
}
|
||||
everyone = {item["id"] for item in self.db.list_library()["items"]}
|
||||
self.assertEqual({item["id"] for item in untagged["items"]}, everyone - tagged)
|
||||
|
||||
|
||||
class TestLibraryTagMode(_ReadFixture):
|
||||
def test_untagged_mode_combines_with_text_and_category(self):
|
||||
self.save("Red plain", category="Colors")
|
||||
self.save("Blue plain")
|
||||
self.save("Red tagged", category="Colors", tags=["red"])
|
||||
self.assertEqual(
|
||||
[
|
||||
i["text"]
|
||||
for i in self.db.list_library(text="red", tag_mode="untagged")["items"]
|
||||
],
|
||||
["Red plain"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
i["text"]
|
||||
for i in self.db.list_library(category="Colors", tag_mode="untagged")[
|
||||
"items"
|
||||
]
|
||||
],
|
||||
["Red plain"],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.list_library(tag_mode="all")["total"],
|
||||
self.db.list_library()["total"],
|
||||
)
|
||||
|
||||
def test_untagged_mode_refuses_tags_and_unknown_modes(self):
|
||||
for options in (
|
||||
{"tag_mode": "untagged", "tags": ["x"]},
|
||||
{"tag_mode": "none"},
|
||||
{"tag_mode": None},
|
||||
):
|
||||
with self.assertRaises(ValueError, msg=options):
|
||||
self.db.list_library(**options)
|
||||
|
||||
|
||||
_TAG_TABLES = ('"tags"', '"prompt_tags"', '"prompt_pair_tags"', '"sqlite_sequence"')
|
||||
|
||||
|
||||
|
||||
Vendored
+7
-3
@@ -212,11 +212,15 @@ test('every browser job sets PM_TEST_WORKERS and runs in the pinned image', () =
|
||||
assert.match(mergeJob(), PINNED_IMAGE, 'the merge sees the same workspace paths as the shards');
|
||||
});
|
||||
|
||||
test('page workflows run each browser in two shards', () => {
|
||||
// #473: two shards split by test count put the slow concept specs in shard 1, which reached the 18-minute step limit.
|
||||
test('page workflows run each browser in three shards', () => {
|
||||
const workflows = allJobs.get('browser-workflows');
|
||||
assert.deepEqual(matrixList(workflows, 'browser'), ['firefox', 'webkit']);
|
||||
assert.deepEqual(matrixList(workflows, 'shard'), ['1', '2']);
|
||||
assert.match(workflows, /test:workflows -- --project \$\{\{ matrix\.browser \}\} --shard \$\{\{ matrix\.shard \}\}\/2/);
|
||||
assert.deepEqual(matrixList(workflows, 'shard'), ['1', '2', '3']);
|
||||
assert.match(workflows, /test:workflows -- --project \$\{\{ matrix\.browser \}\} --shard \$\{\{ matrix\.shard \}\}\/3/);
|
||||
assert.match(workflows, /^ {4}name: page workflows \(\$\{\{ matrix\.browser \}\}, shard \$\{\{ matrix\.shard \}\}\/3\)$/m);
|
||||
assert.match(workflows, /name: Page workflows in \$\{\{ matrix\.browser \}\}, shard \$\{\{ matrix\.shard \}\}\/3$/m);
|
||||
assert.doesNotMatch(workflows, /\/2\b/, 'no step still names two shards');
|
||||
assert.match(workflows, /^ {6}fail-fast: false$/m);
|
||||
assert.match(workflows, /name: browser-traces-\$\{\{ matrix\.browser \}\}-shard-\$\{\{ matrix\.shard \}\}/);
|
||||
});
|
||||
|
||||
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/);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Is ComfyUI stopped? The checks a library repair runs before writing (#247).
|
||||
|
||||
``comfyui_running`` looks for ``main.py`` processes of one ComfyUI folder
|
||||
and a listener on its port. ``open_by_others`` lists the processes that hold
|
||||
the library file, its ``-wal`` or its ``-shm`` open. Both read ``/proc``
|
||||
(Linux); without it ``open_by_others`` answers ``[NO_PROC]``: it cannot
|
||||
tell, and the caller refuses unless the user says ComfyUI is stopped.
|
||||
Entries that cannot be read (another user's process, one that just exited)
|
||||
are skipped.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
NO_PROC = (
|
||||
"cannot list the processes that hold the library open (no /proc): stop "
|
||||
"ComfyUI and pass --i-stopped-comfyui"
|
||||
)
|
||||
|
||||
|
||||
def _same(path):
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def _pids(proc, own_pid=None):
|
||||
try:
|
||||
names = os.listdir(proc)
|
||||
except OSError:
|
||||
return []
|
||||
return [name for name in names if name.isdigit() and name != str(own_pid)]
|
||||
|
||||
|
||||
def comfyui_running(comfyui_dir, port, proc="/proc"):
|
||||
"""``main.py`` processes running from ``comfyui_dir`` and a listener on
|
||||
``port`` (127.0.0.1), each as one line; empty when none is found."""
|
||||
found = []
|
||||
folder = _same(comfyui_dir)
|
||||
for pid in _pids(proc):
|
||||
try:
|
||||
with open(os.path.join(proc, pid, "cmdline"), "rb") as stream:
|
||||
parts = stream.read().split(b"\0")
|
||||
cwd = _same(os.readlink(os.path.join(proc, pid, "cwd")))
|
||||
except OSError:
|
||||
continue
|
||||
if any(part.endswith(b"main.py") for part in parts) and (
|
||||
cwd == folder or any(folder.encode() in part for part in parts)
|
||||
):
|
||||
found.append(f"process {pid} (main.py in {cwd})")
|
||||
with socket.socket() as probe:
|
||||
probe.settimeout(1)
|
||||
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
||||
found.append(f"something listens on port {port}")
|
||||
return found
|
||||
|
||||
|
||||
def open_by_others(database, proc="/proc", own_pid=None):
|
||||
"""Processes other than ``own_pid`` (default: this one) holding
|
||||
``database``, its ``-wal`` or ``-shm`` open; ``[NO_PROC]`` without /proc."""
|
||||
if not os.path.isdir(proc):
|
||||
return [NO_PROC]
|
||||
own_pid = os.getpid() if own_pid is None else own_pid
|
||||
targets = {_same(database + suffix): suffix for suffix in ("", "-wal", "-shm")}
|
||||
found = []
|
||||
for pid in _pids(proc, own_pid):
|
||||
fd_dir = os.path.join(proc, pid, "fd")
|
||||
try:
|
||||
fds = os.listdir(fd_dir)
|
||||
except OSError:
|
||||
continue
|
||||
held = set()
|
||||
for fd in fds:
|
||||
try:
|
||||
target = os.readlink(os.path.join(fd_dir, fd))
|
||||
except OSError:
|
||||
continue
|
||||
target = target.replace(" (deleted)", "")
|
||||
if os.path.isabs(target) and _same(target) in targets:
|
||||
held.add(os.path.basename(database) + targets[_same(target)])
|
||||
if held:
|
||||
found.append(f"process {pid} has {', '.join(sorted(held))} open")
|
||||
return found
|
||||
@@ -0,0 +1,776 @@
|
||||
"""Repair the links the old scan gave foreign dataset files (#247), on review.
|
||||
|
||||
Before #247 the output scan linked each file of a foreign run (a workflow
|
||||
with no PromptManager node) through the legacy parser, so every file of a
|
||||
shared-prefix dataset run got the first branch's text. Recorded history
|
||||
knows which save node wrote each file; the shared ladder
|
||||
(``utils.file_prompt.decide_file_prompt``, scan policy) uses it
|
||||
(``recorded-run``) to name the right branch. This tool judges every
|
||||
``generated_images`` row of a file whose recording runs all have no
|
||||
PromptManager context:
|
||||
|
||||
``relink`` the ladder names a positive by the recorded saver, and its text
|
||||
is not the linked prompt's
|
||||
``keep`` the recorded saver or the inspector names the linked prompt
|
||||
``skip`` anything else (file gone, no single recorded saver, ambiguous,
|
||||
the legacy floor or another step decided)
|
||||
|
||||
Dry run (the default) judges a copy of the database (SQLite backup from a
|
||||
read-only connection) and writes a JSON plan and a Markdown list beside it
|
||||
(``<plan stem>.list.md``). It also rehearses the apply on a second copy, to
|
||||
list the prompts that would lose every link with their remaining
|
||||
references. Deleting those is OFF unless the dry run is given
|
||||
``--delete-orphans``; the choice is recorded in the plan and rehearsed on a
|
||||
third copy. The library is never written.
|
||||
|
||||
``--apply <plan.json>`` (ComfyUI stopped), only on the library the plan was
|
||||
made from (its real path, recorded in the plan):
|
||||
|
||||
1. refuses unless ``--comfyui-dir`` holds ``main.py``; refuses while a
|
||||
``main.py`` runs from it, anything listens on ``--port``, or any other
|
||||
process holds the library, its ``-wal`` or ``-shm`` open
|
||||
(``tools/comfyui_stopped.py``; without /proc only with
|
||||
``--i-stopped-comfyui``). ``--rehearsal`` skips the process and port
|
||||
check, never the open-file check, and never for that folder's library;
|
||||
2. saves ``<db>.pre-247-repair_<stamp>`` (or into ``--backup-dir``), never
|
||||
over an existing file, and refuses unless the backup passes
|
||||
``integrity_check`` with the library's counts. Opening the library must
|
||||
not change its schema (a newer checkout's migration): run the tool from a
|
||||
checkout at the live code version. The running check runs once more just
|
||||
before writing;
|
||||
3. in ONE ``BEGIN IMMEDIATE`` transaction: re-judges every planned row and
|
||||
refuses on any drift (row moved, prompt text changed, file changed, target
|
||||
stored since), creates the target prompts that do not exist (category
|
||||
``scanned``, tag ``auto-scanned``, note ``Auto-scanned from <first file>``),
|
||||
points each row at its target and drops its pair links (they named the old
|
||||
positive). A file that already has a row for the target keeps that row and
|
||||
loses the wrong one (UNIQUE(prompt_id, file_path)). Orphans are deleted only
|
||||
when the plan chose it and each is still unreferenced.
|
||||
|
||||
Any failed check rolls everything back and exits 2. Before/after table
|
||||
counts, ``integrity_check`` and ``foreign_key_check`` are printed.
|
||||
|
||||
python3 tools/repair_foreign_links.py <db> --output-dir <ComfyUI output> \\
|
||||
--plan repair_plan.json
|
||||
python3 tools/repair_foreign_links.py <db> --apply repair_plan.json \\
|
||||
--comfyui-dir <ComfyUI folder>
|
||||
|
||||
Run from a checkout at the live code version (after the merge: main) with
|
||||
its venv.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from database.maintenance import ORPHANED_PROMPTS_SQL, _delete_ids # noqa: E402
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from database.pair_ingestion import ( # noqa: E402
|
||||
SCAN_MARKS,
|
||||
file_link_rows,
|
||||
mark_created,
|
||||
)
|
||||
from database.run_attribution import recorded_output # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from tools.comfyui_stopped import ( # noqa: E402
|
||||
NO_PROC,
|
||||
comfyui_running,
|
||||
open_by_others,
|
||||
)
|
||||
from tools.review_image_links import backup_database # noqa: E402
|
||||
from utils.execution_outputs import snapshot_output_roots # noqa: E402
|
||||
from utils.file_prompt import POSITIVE, Policy, decide_file_prompt # noqa: E402
|
||||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||||
|
||||
PLAN_VERSION = 1
|
||||
COMFY_PORT = 8188
|
||||
LIVE_LIBRARY = ("user", "__promptmanager", "prompts.db")
|
||||
EXCERPT = 80
|
||||
COUNTED = (
|
||||
"prompts",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"image_prompt_pairs",
|
||||
"workflow_documents",
|
||||
"prompt_pairs",
|
||||
"prompt_library_state",
|
||||
"execution_run_contexts",
|
||||
"tags",
|
||||
)
|
||||
# The ladder steps whose answer is certain enough to keep a link.
|
||||
KEEP_SOURCES = ("recorded-run", "inspector")
|
||||
# What must be unchanged since the dry run for a row to be written.
|
||||
DRIFT_KEYS = (
|
||||
"action",
|
||||
"file",
|
||||
"current_prompt_id",
|
||||
"current_text_sha256",
|
||||
"file_size",
|
||||
"file_mtime_ns",
|
||||
"target_text",
|
||||
"target_hash",
|
||||
"target_prompt_id",
|
||||
)
|
||||
# Locations reported only by runs without any PromptManager context.
|
||||
_FOREIGN_LOCATIONS = (
|
||||
"SELECT o.subfolder, o.filename FROM execution_run_outputs o "
|
||||
"WHERE o.folder_type = 'output' AND o.root_key = ? AND o.path_style = ? "
|
||||
"GROUP BY o.subfolder, o.filename "
|
||||
"HAVING SUM(EXISTS (SELECT 1 FROM execution_run_contexts c "
|
||||
"WHERE c.run_id = o.run_id)) = 0 "
|
||||
"ORDER BY o.subfolder, o.filename"
|
||||
)
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""A check failed: nothing is written."""
|
||||
|
||||
|
||||
def sha256(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def metadata_reader():
|
||||
"""The scan's metadata extraction (PNG text chunks, ffprobe for videos)."""
|
||||
api = SimpleNamespace(logger=logging.getLogger("prompt_manager.repair_247"))
|
||||
return lambda path: PromptManagerAPI._extract_comfyui_metadata(api, path) or {}
|
||||
|
||||
|
||||
def foreign_rows(db, output_dir):
|
||||
"""The link rows of every file only foreign runs reported, by id."""
|
||||
style = "windows" if os.name == "nt" else "posix"
|
||||
root_key = snapshot_output_roots(style, output_dir, output_dir).output_key
|
||||
conn = db.model.get_connection()
|
||||
rows = {}
|
||||
for subfolder, filename in conn.execute(_FOREIGN_LOCATIONS, (root_key, style)):
|
||||
path = os.path.join(output_dir, subfolder, filename)
|
||||
rows.update((row["id"], row) for row in file_link_rows(conn, path))
|
||||
return [rows[key] for key in sorted(rows)]
|
||||
|
||||
|
||||
class Judge:
|
||||
"""One file's decision per path, for all of its rows."""
|
||||
|
||||
def __init__(self, db, output_dir, read=None):
|
||||
self.db = db
|
||||
self.output_dir = output_dir
|
||||
self.read = read or metadata_reader()
|
||||
self.decisions = {}
|
||||
|
||||
def decide(self, path, size):
|
||||
if path not in self.decisions:
|
||||
self.decisions[path] = decide_file_prompt(
|
||||
self.read(path),
|
||||
file_name=os.path.basename(path),
|
||||
file_size=size,
|
||||
in_output=True,
|
||||
lookup=lambda: recorded_output(self.db, path, self.output_dir),
|
||||
policy=Policy.SCAN,
|
||||
)
|
||||
return self.decisions[path]
|
||||
|
||||
def row(self, row):
|
||||
"""The plan entry of one ``generated_images`` row."""
|
||||
conn = self.db.model.get_connection()
|
||||
path = row["image_path"]
|
||||
(text,) = conn.execute(
|
||||
"SELECT text FROM prompts WHERE id = ?", (row["prompt_id"],)
|
||||
).fetchone()
|
||||
prompt_hash = generate_prompt_hash(text)
|
||||
item = {
|
||||
"row_id": row["id"],
|
||||
"file": path,
|
||||
"current_prompt_id": row["prompt_id"],
|
||||
"current_text_sha256": sha256(text),
|
||||
}
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
return {**item, "action": "skip", "reason": "file gone"}
|
||||
item.update(file_size=stat.st_size, file_mtime_ns=stat.st_mtime_ns)
|
||||
decision = self.decide(path, stat.st_size)
|
||||
action = action_of(decision, prompt_hash)
|
||||
if action == "skip":
|
||||
return {
|
||||
**item,
|
||||
"action": "skip",
|
||||
"reason": "not decided by the recorded saver",
|
||||
"decision": f"{decision.kind}/{decision.source}",
|
||||
}
|
||||
target_hash = generate_prompt_hash(decision.text)
|
||||
stored = conn.execute(
|
||||
"SELECT id FROM prompts WHERE hash = ?", (target_hash,)
|
||||
).fetchone()
|
||||
item.update(
|
||||
target_text=decision.text,
|
||||
target_hash=target_hash,
|
||||
target_prompt_id=stored[0] if stored else "new",
|
||||
action=action,
|
||||
source=decision.source,
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def action_of(decision, linked_hash):
|
||||
"""``relink``, ``keep`` or ``skip`` for a row linked to ``linked_hash``.
|
||||
|
||||
Only the recorded saver (``recorded-run``) names a new target. A certain
|
||||
step (the recorded saver or the inspector) naming the linked prompt keeps
|
||||
it; the legacy floor and every other answer are skipped.
|
||||
"""
|
||||
if decision.kind != POSITIVE or decision.source not in KEEP_SOURCES:
|
||||
return "skip"
|
||||
if generate_prompt_hash(decision.text) == linked_hash:
|
||||
return "keep"
|
||||
return "relink" if decision.source == "recorded-run" else "skip"
|
||||
|
||||
|
||||
def library_identity(path):
|
||||
"""How a plan names its library: the case-normalized real path."""
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def build_plan(db, output_dir, source_db, read=None):
|
||||
"""The plan for ``db`` (a copy of ``source_db``)."""
|
||||
judge = Judge(db, output_dir, read)
|
||||
conn = db.model.get_connection()
|
||||
(max_row_id,) = conn.execute("SELECT MAX(id) FROM generated_images").fetchone()
|
||||
return {
|
||||
"version": PLAN_VERSION,
|
||||
"created": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
"source_db": library_identity(source_db),
|
||||
"max_row_id": max_row_id,
|
||||
"output_dir": output_dir,
|
||||
"rows": [judge.row(row) for row in foreign_rows(db, output_dir)],
|
||||
"orphans": [],
|
||||
"delete_orphans": False,
|
||||
}
|
||||
|
||||
|
||||
def blockers(conn, prompt_id, unused):
|
||||
"""Why ``prompt_id`` must not be deleted (empty: nothing references it)."""
|
||||
found = []
|
||||
values = {"id": prompt_id, "tag": SCAN_MARKS.tags[0]}
|
||||
checks = {
|
||||
"linked images": "SELECT COUNT(*) FROM generated_images WHERE prompt_id=:id",
|
||||
"saved pairs": "SELECT COUNT(*) FROM prompt_pairs "
|
||||
"WHERE positive_prompt_id=:id OR negative_prompt_id=:id",
|
||||
"recorded runs": "SELECT COUNT(*) FROM execution_run_contexts "
|
||||
"WHERE positive_prompt_id=:id OR negative_prompt_id=:id",
|
||||
"library state": "SELECT COUNT(*) FROM prompt_library_state "
|
||||
"WHERE prompt_id=:id",
|
||||
"user tags": "SELECT COUNT(*) FROM prompt_tags pt JOIN tags t "
|
||||
"ON t.id=pt.tag_id WHERE pt.prompt_id=:id AND t.name != :tag",
|
||||
}
|
||||
for name, sql in checks.items():
|
||||
count = conn.execute(sql, values).fetchone()[0]
|
||||
if count:
|
||||
found.append(f"{name}: {count}")
|
||||
run_count, last_used, rating, category, notes = conn.execute(
|
||||
"SELECT run_count, last_used_at, rating, category, notes FROM prompts "
|
||||
"WHERE id=?",
|
||||
(prompt_id,),
|
||||
).fetchone()
|
||||
if run_count or last_used or rating:
|
||||
found.append("usage or rating")
|
||||
if category not in (None, SCAN_MARKS.category):
|
||||
found.append(f"category {category}")
|
||||
if notes and not notes.startswith("Auto-scanned from "):
|
||||
found.append("notes")
|
||||
if prompt_id not in unused:
|
||||
found.append("not unused by the app's rule")
|
||||
return found
|
||||
|
||||
|
||||
def _rejudge(conn, judge, plan):
|
||||
for item in plan["rows"]:
|
||||
if item["action"] == "skip":
|
||||
continue
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generated_images WHERE id = ?", (item["row_id"],)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise Refused(f"row {item['row_id']}: gone")
|
||||
if generate_prompt_hash(item.get("target_text") or "") != item.get(
|
||||
"target_hash"
|
||||
):
|
||||
raise Refused(f"row {item['row_id']}: target_text does not match its hash")
|
||||
fresh = judge.row(row)
|
||||
for key in DRIFT_KEYS:
|
||||
if fresh.get(key) != item.get(key):
|
||||
raise Refused(
|
||||
f"row {item['row_id']}: {key} changed "
|
||||
f"({item.get(key)!r} -> {fresh.get(key)!r})"
|
||||
)
|
||||
|
||||
|
||||
def _targets(conn, db, relinks):
|
||||
"""``{target hash: prompt id}``, creating each "new" target once."""
|
||||
targets = {}
|
||||
for item in relinks:
|
||||
digest = item["target_hash"]
|
||||
if digest in targets:
|
||||
continue
|
||||
if item["target_prompt_id"] != "new":
|
||||
targets[digest] = item["target_prompt_id"]
|
||||
continue
|
||||
prompt_id, created = db._pair_component_outcome(conn, item["target_text"])
|
||||
if not created:
|
||||
raise Refused(f"row {item['row_id']}: target prompt already stored")
|
||||
mark_created(conn, db, prompt_id, SCAN_MARKS, os.path.basename(item["file"]))
|
||||
targets[digest] = prompt_id
|
||||
return targets
|
||||
|
||||
|
||||
def _relink(conn, item, target):
|
||||
"""Point the row at ``target``; True when the wrong row was deleted instead."""
|
||||
row_id = item["row_id"]
|
||||
correct = [
|
||||
row
|
||||
for row in file_link_rows(conn, item["file"])
|
||||
if row["prompt_id"] == target and row["id"] != row_id
|
||||
]
|
||||
if correct: # UNIQUE(prompt_id, file_path): the correct row stays alone
|
||||
if _delete_ids(conn, "generated_images", [row_id]) != 1:
|
||||
raise Refused(f"row {row_id}: delete touched no row")
|
||||
return True
|
||||
# A pair link records the old positive: it goes with the old link
|
||||
conn.execute("DELETE FROM image_prompt_pairs WHERE image_id = ?", (row_id,))
|
||||
cursor = conn.execute(
|
||||
"UPDATE generated_images SET prompt_id = ? WHERE id = ? AND prompt_id = ?",
|
||||
(target, row_id, item["current_prompt_id"]),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise Refused(f"row {row_id}: relink touched {cursor.rowcount} rows")
|
||||
return False
|
||||
|
||||
|
||||
def _audit(conn, relinks):
|
||||
"""The prompts the relinks leave without any link, with their blockers."""
|
||||
sources = {}
|
||||
for item in relinks:
|
||||
sources.setdefault(item["current_prompt_id"], []).append(item)
|
||||
unused = {row[0] for row in conn.execute(ORPHANED_PROMPTS_SQL)}
|
||||
audit = []
|
||||
for prompt_id in sorted(sources):
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM generated_images WHERE prompt_id = ?", (prompt_id,)
|
||||
).fetchone():
|
||||
continue
|
||||
(text,) = conn.execute(
|
||||
"SELECT text FROM prompts WHERE id = ?", (prompt_id,)
|
||||
).fetchone()
|
||||
found = blockers(conn, prompt_id, unused)
|
||||
audit.append(
|
||||
{
|
||||
"id": prompt_id,
|
||||
"text_excerpt": " ".join(text.split())[:EXCERPT],
|
||||
"text_sha256": sha256(text),
|
||||
"images_before": len(sources[prompt_id]),
|
||||
"blockers": found,
|
||||
"safe": not found,
|
||||
}
|
||||
)
|
||||
return audit
|
||||
|
||||
|
||||
def _delete_orphans(conn, plan):
|
||||
chosen = [orphan for orphan in plan["orphans"] if orphan["safe"]]
|
||||
unused = {row[0] for row in conn.execute(ORPHANED_PROMPTS_SQL)}
|
||||
for orphan in chosen:
|
||||
row = conn.execute(
|
||||
"SELECT text FROM prompts WHERE id = ?", (orphan["id"],)
|
||||
).fetchone()
|
||||
if row is None or sha256(row[0]) != orphan["text_sha256"]:
|
||||
raise Refused(f"prompt {orphan['id']}: gone or text changed")
|
||||
found = blockers(conn, orphan["id"], unused)
|
||||
if found:
|
||||
raise Refused(f"prompt {orphan['id']}: {', '.join(found)}")
|
||||
ids = [orphan["id"] for orphan in chosen]
|
||||
if _delete_ids(conn, "prompts", ids) != len(ids):
|
||||
raise Refused("an orphan prompt was not deleted")
|
||||
return len(ids)
|
||||
|
||||
|
||||
def apply_plan(db, plan, read=None, before_write=None):
|
||||
"""Write ``plan`` in one transaction; returns ``(result, orphan audit)``.
|
||||
|
||||
``before_write()`` runs just before ``BEGIN IMMEDIATE`` (the running
|
||||
check). Raises ``Refused`` (and rolls back) on any drift or failed check.
|
||||
"""
|
||||
judge = Judge(db, plan["output_dir"], read)
|
||||
relinks = [item for item in plan["rows"] if item["action"] == "relink"]
|
||||
if before_write is not None:
|
||||
before_write()
|
||||
with db._pair_transaction() as conn:
|
||||
_rejudge(conn, judge, plan)
|
||||
targets = _targets(conn, db, relinks)
|
||||
dropped = sum(
|
||||
_relink(conn, item, targets[item["target_hash"]]) for item in relinks
|
||||
)
|
||||
audit = _audit(conn, relinks)
|
||||
deleted = _delete_orphans(conn, plan) if plan.get("delete_orphans") else 0
|
||||
created = sum(1 for item in relinks if item["target_prompt_id"] == "new")
|
||||
result = {
|
||||
"relinked": len(relinks) - dropped,
|
||||
"duplicates_dropped": dropped,
|
||||
"prompts_created": len(
|
||||
{i["target_hash"] for i in relinks if i["target_prompt_id"] == "new"}
|
||||
),
|
||||
"rows_to_new_prompts": created,
|
||||
"kept": sum(1 for item in plan["rows"] if item["action"] == "keep"),
|
||||
"orphans_deleted": deleted,
|
||||
}
|
||||
return result, audit
|
||||
|
||||
|
||||
def counts(path, readonly=True):
|
||||
"""Table counts plus the integrity and foreign-key checks.
|
||||
|
||||
Read-only for a library; the tool's own backup is opened read-write so
|
||||
a WAL file leaves no side files behind once it closes.
|
||||
"""
|
||||
uri = Path(os.path.abspath(path)).as_uri() + ("?mode=ro" if readonly else "")
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
found = {
|
||||
table: conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
||||
for table in COUNTED # nosec B608 - fixed table names
|
||||
}
|
||||
found["integrity_check"] = conn.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
found["foreign_key_check"] = len(
|
||||
conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
)
|
||||
return found
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _print_counts(before, after, out):
|
||||
out.write(f"{'':24}{'before':>12}{'after':>12}\n")
|
||||
for key in before:
|
||||
out.write(f"{key:24}{before[key]!s:>12}{after[key]!s:>12}\n")
|
||||
|
||||
|
||||
def _summary(plan):
|
||||
actions = [item["action"] for item in plan["rows"]]
|
||||
return " ".join(
|
||||
f"{name}: {actions.count(name)}" for name in ("relink", "keep", "skip")
|
||||
)
|
||||
|
||||
|
||||
def _cell(text, limit=EXCERPT):
|
||||
"""One table cell: whitespace folded, cut to ``limit`` (None: whole)."""
|
||||
folded = " ".join(str(text).split())
|
||||
return (folded if limit is None else folded[:limit]).replace("|", "\\|")
|
||||
|
||||
|
||||
def _relative(path, root):
|
||||
try:
|
||||
return os.path.relpath(path, root)
|
||||
except ValueError: # another drive on Windows
|
||||
return path
|
||||
|
||||
|
||||
def markdown(plan, prompt_texts):
|
||||
"""The maintainer's list of the plan."""
|
||||
root = plan["output_dir"]
|
||||
rel = lambda path: _relative(path, root) # noqa: E731
|
||||
rows = plan["rows"]
|
||||
lines = [
|
||||
"# #247 repair plan (dry run)",
|
||||
"",
|
||||
f"Created {plan['created']}; output folder `{root}`. {_summary(plan)}.",
|
||||
"",
|
||||
f"## Relink ({sum(i['action'] == 'relink' for i in rows)})",
|
||||
"",
|
||||
"| row | file | linked prompt | linked text | target | target hash "
|
||||
"| target text |",
|
||||
"|---:|---|---:|---|---|---|---|",
|
||||
]
|
||||
for item in (i for i in rows if i["action"] == "relink"):
|
||||
lines.append(
|
||||
f"| {item['row_id']} | {_cell(rel(item['file']), None)} | "
|
||||
f"{item['current_prompt_id']} | "
|
||||
f"{_cell(prompt_texts.get(item['current_prompt_id'], ''))} | "
|
||||
f"{item['target_prompt_id']} | `{item['target_hash'][:12]}` | "
|
||||
f"{_cell(item['target_text'])} |"
|
||||
)
|
||||
lines += ["", f"## Keep ({sum(i['action'] == 'keep' for i in rows)})", ""]
|
||||
lines += ["| row | file | prompt |", "|---:|---|---:|"]
|
||||
for item in (i for i in rows if i["action"] == "keep"):
|
||||
lines.append(
|
||||
f"| {item['row_id']} | {_cell(rel(item['file']), None)} | "
|
||||
f"{item['current_prompt_id']} |"
|
||||
)
|
||||
lines += ["", f"## Skip ({sum(i['action'] == 'skip' for i in rows)})", ""]
|
||||
lines += ["| row | file | prompt | reason |", "|---:|---|---:|---|"]
|
||||
for item in (i for i in rows if i["action"] == "skip"):
|
||||
reason = item["reason"] + (
|
||||
f" ({item['decision']})" if item.get("decision") else ""
|
||||
)
|
||||
lines.append(
|
||||
f"| {item['row_id']} | {_cell(rel(item['file']), None)} | "
|
||||
f"{item['current_prompt_id']} | {reason} |"
|
||||
)
|
||||
choice = "ON (--delete-orphans)" if plan["delete_orphans"] else "OFF"
|
||||
lines += [
|
||||
"",
|
||||
f"## Prompts that would lose every link ({len(plan['orphans'])})",
|
||||
"",
|
||||
f"Deleting the safe ones: {choice}.",
|
||||
"",
|
||||
"| id | text | images before | blockers | safe to delete |",
|
||||
"|---:|---|---:|---|---|",
|
||||
]
|
||||
for orphan in plan["orphans"]:
|
||||
lines.append(
|
||||
f"| {orphan['id']} | {_cell(orphan['text_excerpt'])} | "
|
||||
f"{orphan['images_before']} | {', '.join(orphan['blockers']) or 'none'} | "
|
||||
f"{'yes' if orphan['safe'] else 'no'} |"
|
||||
)
|
||||
simulated = plan.get("simulated")
|
||||
if simulated:
|
||||
lines += ["", "## Rehearsed on the dry run's copy", ""]
|
||||
lines += ["| table | before | after |", "|---|---:|---:|"]
|
||||
for key, value in simulated["before"].items():
|
||||
lines.append(f"| {key} | {value} | {simulated['after'][key]} |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _texts(db):
|
||||
return dict(
|
||||
db.model.get_connection().execute(
|
||||
"SELECT id, text FROM prompts WHERE id IN (SELECT prompt_id "
|
||||
"FROM generated_images)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _rehearse(source, scratch, name, plan):
|
||||
"""``apply_plan`` on a fresh backup of ``source``: (counts, result, audit)."""
|
||||
copy = os.path.join(scratch, name)
|
||||
backup_database(source, copy)
|
||||
db = PromptDatabase(copy)
|
||||
try:
|
||||
before = counts(copy)
|
||||
result, audit = apply_plan(db, plan)
|
||||
finally:
|
||||
db.close_all()
|
||||
return {"before": before, "after": counts(copy), "result": result}, audit
|
||||
|
||||
|
||||
def dry_run(args, out):
|
||||
with tempfile.TemporaryDirectory() as scratch:
|
||||
copy = os.path.join(scratch, "judge.db")
|
||||
backup_database(args.database, copy)
|
||||
db = PromptDatabase(copy)
|
||||
try:
|
||||
plan = build_plan(db, os.path.abspath(args.output_dir), args.database)
|
||||
texts = _texts(db)
|
||||
finally:
|
||||
db.close_all()
|
||||
simulated, audit = _rehearse(args.database, scratch, "relinks.db", plan)
|
||||
plan.update(orphans=audit, delete_orphans=bool(args.delete_orphans))
|
||||
if plan["delete_orphans"]:
|
||||
# The plan as the apply will write it, deletes included
|
||||
simulated, _audit = _rehearse(args.database, scratch, "deletes.db", plan)
|
||||
plan["simulated"] = simulated
|
||||
plan_path = Path(args.plan)
|
||||
plan_path.write_text(
|
||||
json.dumps(plan, indent=1, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
list_path = plan_path.with_name(plan_path.stem + ".list.md")
|
||||
list_path.write_text(markdown(plan, texts), encoding="utf-8")
|
||||
out.write(f"{_summary(plan)}\n")
|
||||
out.write(
|
||||
f"Prompts that would lose every link: {len(audit)} "
|
||||
f"({sum(o['safe'] for o in audit)} safe to delete; deleting "
|
||||
f"{'ON' if plan['delete_orphans'] else 'OFF'})\n"
|
||||
)
|
||||
out.write(f"Plan: {plan_path}\nList: {list_path}\n")
|
||||
out.write("Dry run: nothing was changed.\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _is_live(database, comfyui_dir):
|
||||
live = os.path.join(comfyui_dir, *LIVE_LIBRARY)
|
||||
return library_identity(database) == library_identity(live)
|
||||
|
||||
|
||||
def _stamp():
|
||||
return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def schema_of(path):
|
||||
"""``sqlite_master`` and ``user_version`` from a read-only connection."""
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
master = conn.execute(
|
||||
"SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name"
|
||||
).fetchall()
|
||||
return master, conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def running_reasons(args, probe, handles):
|
||||
"""Why writing must not start now (empty: go ahead).
|
||||
|
||||
Another process holding the library open always counts, also in a
|
||||
rehearsal; ComfyUI's processes and port count unless rehearsing.
|
||||
"""
|
||||
held = [
|
||||
reason
|
||||
for reason in handles(args.database)
|
||||
if not (reason == NO_PROC and args.i_stopped_comfyui)
|
||||
]
|
||||
return held + ([] if args.rehearsal else probe(args.comfyui_dir, args.port))
|
||||
|
||||
|
||||
def _preflight(args, plan, probe, handles):
|
||||
"""The refusals before any backup or write, as a message (None: go on)."""
|
||||
if plan.get("version") != PLAN_VERSION:
|
||||
return "not a plan of this tool"
|
||||
if args.rehearsal and _is_live(args.database, args.comfyui_dir):
|
||||
return f"--rehearsal is for a copy; this is the library of {args.comfyui_dir}"
|
||||
if plan.get("source_db") != library_identity(args.database):
|
||||
return f"this plan was made for {plan.get('source_db')}"
|
||||
if not os.path.isfile(os.path.join(args.comfyui_dir, "main.py")):
|
||||
return f"no main.py in {args.comfyui_dir}: not a ComfyUI folder"
|
||||
reasons = running_reasons(args, probe, handles)
|
||||
if reasons:
|
||||
return "ComfyUI may be running: " + "; ".join(reasons)
|
||||
return None
|
||||
|
||||
|
||||
def _backup(args, before):
|
||||
"""The verified backup's path; raises ``Refused``."""
|
||||
folder = args.backup_dir or os.path.dirname(os.path.abspath(args.database))
|
||||
name = f"{os.path.basename(args.database)}.pre-247-repair_{_stamp()}"
|
||||
backup = os.path.join(folder, name)
|
||||
if os.path.exists(backup):
|
||||
raise Refused(f"the backup {backup} already exists")
|
||||
backup_database(args.database, backup)
|
||||
saved = counts(backup, readonly=False)
|
||||
if saved != before or saved["integrity_check"] != "ok":
|
||||
raise Refused(f"the backup {backup} does not match the library")
|
||||
return backup
|
||||
|
||||
|
||||
def _open_unchanged(path):
|
||||
"""``PromptDatabase(path)``; raises ``Refused`` when opening it changed
|
||||
its schema (a migration of a newer checkout, committed on open)."""
|
||||
schema = schema_of(path)
|
||||
db = PromptDatabase(path)
|
||||
db.model.get_connection()
|
||||
if schema_of(path) != schema:
|
||||
db.close_all()
|
||||
raise Refused(
|
||||
"opening the library with this checkout changed its schema (that "
|
||||
"change is committed); run the tool from a checkout at the live "
|
||||
"code version"
|
||||
)
|
||||
return db
|
||||
|
||||
|
||||
def apply(args, out, probe, handles):
|
||||
with open(args.apply, encoding="utf-8") as stream:
|
||||
plan = json.load(stream)
|
||||
refusal = _preflight(args, plan, probe, handles)
|
||||
if refusal:
|
||||
out.write(f"REFUSED: {refusal}\n")
|
||||
return 2
|
||||
if args.rehearsal:
|
||||
out.write("(rehearsal on a copy: ComfyUI's process and port not checked)\n")
|
||||
before = counts(args.database)
|
||||
try:
|
||||
backup = _backup(args, before)
|
||||
except Refused as error:
|
||||
out.write(f"REFUSED: {error}\n")
|
||||
return 2
|
||||
out.write(f"Backup: {backup}\n")
|
||||
|
||||
def still_stopped():
|
||||
reasons = running_reasons(args, probe, handles)
|
||||
if reasons:
|
||||
raise Refused("ComfyUI may be running: " + "; ".join(reasons))
|
||||
|
||||
try:
|
||||
db = _open_unchanged(args.database)
|
||||
except Refused as error:
|
||||
out.write(f"REFUSED: {error}. Backup: {backup}\n")
|
||||
return 2
|
||||
try:
|
||||
result, _audit = apply_plan(db, plan, before_write=still_stopped)
|
||||
except Exception as error:
|
||||
out.write(
|
||||
f"REFUSED (rolled back, nothing changed): {error}. Backup: {backup}\n"
|
||||
)
|
||||
return 2
|
||||
finally:
|
||||
db.close_all()
|
||||
after = counts(args.database)
|
||||
_print_counts(before, after, out)
|
||||
out.write(f"Applied: {json.dumps(result, sort_keys=True)}\n")
|
||||
if after["integrity_check"] != "ok" or after["foreign_key_check"]:
|
||||
out.write(f"WARNING: integrity or foreign-key check failed; restore {backup}\n")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None, out=sys.stdout, probe=comfyui_running, handles=open_by_others):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
parser.add_argument("database", help="the library database (prompts.db)")
|
||||
parser.add_argument("--output-dir", help="ComfyUI's output folder (dry run)")
|
||||
parser.add_argument(
|
||||
"--plan", default="repair_foreign_links_plan.json", help="plan to write"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete-orphans",
|
||||
action="store_true",
|
||||
help="record in the plan that the safe orphan prompts are deleted",
|
||||
)
|
||||
parser.add_argument("--apply", metavar="PLAN", help="write this reviewed plan")
|
||||
parser.add_argument("--comfyui-dir", help="the ComfyUI folder (apply)")
|
||||
parser.add_argument("--backup-dir", help="where the backup goes (apply)")
|
||||
parser.add_argument("--port", type=int, default=COMFY_PORT)
|
||||
parser.add_argument(
|
||||
"--rehearsal",
|
||||
action="store_true",
|
||||
help="apply to a copy while ComfyUI runs (never its own library)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--i-stopped-comfyui",
|
||||
action="store_true",
|
||||
help="without /proc: say ComfyUI is stopped (the port is still checked)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if not os.path.isfile(args.database):
|
||||
parser.error(f"no database at {args.database}")
|
||||
if args.apply:
|
||||
if not args.comfyui_dir:
|
||||
parser.error("--apply needs --comfyui-dir (the running check)")
|
||||
return apply(args, out, probe, handles)
|
||||
if not args.output_dir:
|
||||
parser.error("the dry run needs --output-dir")
|
||||
if Path(args.plan).suffix.lower() == ".md":
|
||||
parser.error("--plan is the JSON plan; the list is written beside it")
|
||||
return dry_run(args, out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+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
|
||||
+65
-4
@@ -58,11 +58,72 @@ def generate_prompt_hash(text: str) -> str:
|
||||
if not isinstance(text, str):
|
||||
raise TypeError("Text must be a string")
|
||||
|
||||
# Normalize the text by stripping whitespace and converting to lowercase
|
||||
# for consistent hashing regardless of minor formatting differences
|
||||
normalized_text = text.strip().lower()
|
||||
return hashlib.sha256(prompt_key(text).encode("utf-8")).hexdigest()
|
||||
|
||||
return hashlib.sha256(normalized_text.encode("utf-8")).hexdigest()
|
||||
|
||||
def stored_prompt_text(text: str) -> str:
|
||||
"""The text the Library stores for raw node text: outer whitespace removed.
|
||||
|
||||
Every prompt writer stores ``text.strip()`` and the hash above strips too,
|
||||
so this is the form recorded raw run text is compared in (#438). Case and
|
||||
inner whitespace stay significant.
|
||||
"""
|
||||
return text.strip()
|
||||
|
||||
|
||||
# The code points str.strip() removes (every str.isspace() character), for
|
||||
# SQLite's char(); tests/test_hashing.py pins it to the running Python.
|
||||
STRIPPED_CODE_POINTS = (
|
||||
*range(0x09, 0x0E),
|
||||
*range(0x1C, 0x21),
|
||||
0x85,
|
||||
0xA0,
|
||||
0x1680,
|
||||
*range(0x2000, 0x200B),
|
||||
0x2028,
|
||||
0x2029,
|
||||
0x202F,
|
||||
0x205F,
|
||||
0x3000,
|
||||
)
|
||||
_STRIP_CODES = ",".join(map(str, STRIPPED_CODE_POINTS))
|
||||
|
||||
|
||||
def prompt_key(text: str) -> str:
|
||||
"""The dedup key: the stored text with case folded by ``str.lower()``.
|
||||
|
||||
Two texts with one key are one prompt (the hash is sha256 of the key), so
|
||||
a run belongs to the history of the prompt with its key (#460).
|
||||
"""
|
||||
return stored_prompt_text(text).lower()
|
||||
|
||||
|
||||
def _sql_key(value):
|
||||
return prompt_key(value) if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _sql_hash(value):
|
||||
return generate_prompt_hash(value) if isinstance(value, str) else None
|
||||
|
||||
|
||||
def register_prompt_functions(conn) -> None:
|
||||
"""Give ``conn`` ``pm_prompt_key(x)`` and ``pm_prompt_hash(x)`` (#460).
|
||||
|
||||
SQLite's lower() folds ASCII only; these call Python, so SQL gets exactly
|
||||
``prompt_key`` / ``generate_prompt_hash`` (NULL for a non-text value).
|
||||
Never used in schema objects, so the file stays readable without them.
|
||||
"""
|
||||
conn.create_function("pm_prompt_key", 1, _sql_key, deterministic=True)
|
||||
conn.create_function("pm_prompt_hash", 1, _sql_hash, deterministic=True)
|
||||
|
||||
|
||||
def stored_prompt_text_sql(expression: str) -> str:
|
||||
"""``stored_prompt_text`` as an SQLite expression over ``expression``.
|
||||
|
||||
SQLite's one-argument trim() removes spaces only; trim(X, Y) removes every
|
||||
character of Y, multi-byte ones included, so it strips what Python does.
|
||||
"""
|
||||
return f"trim({expression}, char({_STRIP_CODES}))"
|
||||
|
||||
|
||||
def is_duplicate_prompt(text1: str, text2: str, threshold: float = 0.95) -> bool:
|
||||
|
||||
+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:
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
// The workspace Images defaults; the legacy gallery's defaultViewMode has no masonry.
|
||||
const WORKSPACE_LAYOUTS = ["grid", "list", "masonry"];
|
||||
const WORKSPACE_DENSITIES = ["comfortable", "large", "compact"];
|
||||
const VIEWER_OPEN_MODES = ["centered", "fullscreen"];
|
||||
const LIMIT_OPTIONS = [50, 100, 200, 500];
|
||||
const MIN_COLUMNS = 2;
|
||||
const MAX_COLUMNS = 12;
|
||||
@@ -43,6 +44,8 @@
|
||||
// #451: Images opens in Masonry unless this browser chose another layout.
|
||||
workspaceLayout: "masonry",
|
||||
workspaceDensity: "comfortable",
|
||||
// #450 (D156): how the workspace viewer opens.
|
||||
viewerOpenMode: "centered",
|
||||
});
|
||||
|
||||
function toInt(value) {
|
||||
@@ -83,6 +86,7 @@
|
||||
videoLoop: bool(merged.videoLoop, DEFAULT_SETTINGS.videoLoop),
|
||||
workspaceLayout: WORKSPACE_LAYOUTS.includes(merged.workspaceLayout) ? merged.workspaceLayout : DEFAULT_SETTINGS.workspaceLayout,
|
||||
workspaceDensity: WORKSPACE_DENSITIES.includes(merged.workspaceDensity) ? merged.workspaceDensity : DEFAULT_SETTINGS.workspaceDensity,
|
||||
viewerOpenMode: VIEWER_OPEN_MODES.includes(merged.viewerOpenMode) ? merged.viewerOpenMode : DEFAULT_SETTINGS.viewerOpenMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,7 +96,7 @@
|
||||
return `repeat(${n}, minmax(0, 1fr))`;
|
||||
}
|
||||
|
||||
const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, normalizeGallerySettings, gridColumnsStyle };
|
||||
const api = { DEFAULT_SETTINGS, LIMIT_OPTIONS, VIEW_MODES, WORKSPACE_LAYOUTS, WORKSPACE_DENSITIES, VIEWER_OPEN_MODES, normalizeGallerySettings, gridColumnsStyle };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.GallerySettings = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -173,7 +173,7 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
|
||||
}
|
||||
count.textContent = window.group ? `${countOf(window.recorded_run_count, 'distinct recorded run')} as of this read · ${window.items.length} loaded positions` : '';
|
||||
const group = window.group;
|
||||
scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by exact frozen positive raw text, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.';
|
||||
scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by frozen positive raw text that is this saved text apart from outer whitespace, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.';
|
||||
status.setAttribute('role', window.error || next.run.error ? 'alert' : 'status');
|
||||
status.textContent = next.validating ? 'Revalidating recorded history…' : window.loading ? 'Loading recorded history…' : window.error ? 'Recorded history could not be loaded.' : next.refreshRequired ? 'Recorded evidence changed. Reload before using these actions.' : !items.length ? 'No matching recorded executions.' : window.has_more ? 'Newest bounded window · displayed in chronological order.' : 'All matching recorded runs in this window are loaded.';
|
||||
if (next.run.error && !next.selection.run_id && !next.refreshRequired) status.textContent += ' The previous recorded selection could not be restored.';
|
||||
|
||||
@@ -21,7 +21,10 @@ const paths = Object.freeze({
|
||||
"tagImage": "M3 4h8l9 9-7 7L3 10V4ZM7 8h.01M18 2v5M15.5 4.5h5",
|
||||
"folder": "M3 5h7l2 3h9v12H3z",
|
||||
"history": "M3 11a9 9 0 1 1 2 7M3 4v7h7M12 7v6l4 2",
|
||||
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5"
|
||||
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5",
|
||||
// #450 (D156): the viewer's Full screen / Centered switch.
|
||||
"maximize": "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5",
|
||||
"minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"
|
||||
});
|
||||
|
||||
/** Create an approved decorative outline icon without injecting markup. */
|
||||
|
||||
@@ -32,8 +32,8 @@ 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).
|
||||
@@ -315,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 '';
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The concept's "Inspect a file" page (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729):
|
||||
// one local file, chosen or dropped, previews inline; the heading's one action opens the shared viewer with the full
|
||||
// inspector on a record read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its
|
||||
// one local file, chosen or dropped, previews inline and (D155, #448) opens the shared viewer with its details showing
|
||||
// at once; the heading's one action opens it again. The record is read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its
|
||||
// blob: URL and its record belong to one local session (local-session.mjs) that ends with this page.
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
@@ -17,7 +17,8 @@ export const INSPECT_COPY = Object.freeze({
|
||||
heading: 'Inspect without importing',
|
||||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||||
chooser: 'Choose an image or video for a local preview',
|
||||
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
|
||||
// D155 (#448): choosing a file opens its metadata (D61's note named the heading action).
|
||||
note: 'Your file stays in this browser. Choosing it opens its metadata.',
|
||||
toast: 'Local preview only. No file uploaded; metadata is available above.',
|
||||
});
|
||||
// D62: the concept's image list plus the videos the local readers support (AVI/WMV preview only).
|
||||
@@ -38,8 +39,11 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available".
|
||||
const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]);
|
||||
// D155 (#448): a selection with no status, or one without a preview URL (its copy points at the viewer), opens the viewer
|
||||
// at once. AVI/WMV's note keeps the toast instead (the viewer could not play it and reads no metadata), and an
|
||||
// unreadable file stays on the page with its alert; the heading action opens either.
|
||||
const AUTO_OPEN = new Set(['', LOCAL_SESSION_MESSAGES.previewFailed]);
|
||||
const TOASTABLE = new Set([LOCAL_SESSION_MESSAGES.containerOnly]);
|
||||
// How long a replaced video still starting may stay in place before it is released anyway.
|
||||
const STARTING_VIDEO_MS = 15000;
|
||||
|
||||
@@ -131,9 +135,10 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
};
|
||||
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL });
|
||||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
|
||||
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } });
|
||||
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); },
|
||||
onClose: () => { viewerKey = null; } });
|
||||
|
||||
let media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null;
|
||||
let viewerKey = null, media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null;
|
||||
function statusElement(message) {
|
||||
// /* D63 */ one status line for the states the mock never shows.
|
||||
const line = element('p', 'small spaced-16', message);
|
||||
@@ -202,14 +207,25 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
else preview.prepend(node);
|
||||
}
|
||||
scope.cleanup(session.subscribe(state => {
|
||||
// D155: a new selection can start while the viewer shows the previous one; it closes first, so no element holds
|
||||
// the old URL when the session revokes it (the new file's viewer opens once it is ready).
|
||||
if (viewerKey !== null && (state.phase !== 'ready' || session.entry?.key !== viewerKey)) { viewer.close(); viewerKey = null; }
|
||||
showMedia(state);
|
||||
showStatus(state.message);
|
||||
const entry = session.entry;
|
||||
setAvailable(state.phase === 'ready' && !!entry);
|
||||
if (state.phase === 'ready' && entry && announced !== entry.key) {
|
||||
announced = entry.key;
|
||||
// The session selects the record right after it publishes `ready`, so the viewer opens after that, and only
|
||||
// while this selection is still the current one and no other dialog is open (the viewer is replaced). D62:
|
||||
// a video opens it once the inline preview has settled, so a replacement while it starts never tears down a
|
||||
// second starting video in the viewer (that deadlocked WebKit).
|
||||
if (AUTO_OPEN.has(state.message)) whenSettled(media, () => {
|
||||
if (scope.signal.aborted || session.entry !== entry || session.state.phase !== 'ready' || document.querySelector('dialog[open]:not(#viewer-dialog)')) return;
|
||||
openViewer(input);
|
||||
});
|
||||
// An unreadable or failed preview says so in its alert; the toast never claims metadata is available.
|
||||
if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast);
|
||||
else if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast);
|
||||
}
|
||||
}));
|
||||
// A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the
|
||||
@@ -261,10 +277,25 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
});
|
||||
// A drop that misses the area never navigates away from the page while it is mounted.
|
||||
for (const type of ['dragover', 'drop']) scope.listen(window, type, event => event.preventDefault());
|
||||
scope.listen(open, 'click', () => {
|
||||
/** Runs `run` after this task, or once `preview`'s video stops starting (metadata, an error, or the D62 limit). */
|
||||
function whenSettled(preview, run) {
|
||||
if (!preview || !isStarting(preview.node)) { queueMicrotask(run); return; }
|
||||
// `run` checks that the page and the selection are still current, so the page's end may call `done` too.
|
||||
const settled = new AbortController();
|
||||
const done = () => { settled.abort(); clearTimeout(timer); run(); };
|
||||
const timer = setTimeout(done, STARTING_VIDEO_MS);
|
||||
for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, done, { once: true, signal: settled.signal });
|
||||
scope.cleanup(done);
|
||||
}
|
||||
/** The viewer on the current selection, details showing (D155 amends D97); focus returns to `trigger` on close. */
|
||||
function openViewer(trigger) {
|
||||
const entry = session.entry;
|
||||
if (open.getAttribute('aria-disabled') === 'true' || !entry || session.state.phase !== 'ready') return;
|
||||
viewer.open({ items: [entry], selectedKey: entry.key, trigger: open });
|
||||
if (!entry || session.state.phase !== 'ready') return;
|
||||
viewer.open({ items: [entry], selectedKey: entry.key, trigger, details: 'shown' });
|
||||
viewerKey = entry.key;
|
||||
}
|
||||
scope.listen(open, 'click', () => {
|
||||
if (open.getAttribute('aria-disabled') !== 'true') openViewer(open);
|
||||
});
|
||||
/** Settles when no video on the page is still starting; `null` when none is. */
|
||||
function leaveGuard() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user