Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d02b636867 | ||
|
|
3bdd895bec | ||
|
|
11f95e4989 | ||
|
|
8fff373902 | ||
|
|
042d6678e2 | ||
|
|
42332ce234 | ||
|
|
762669758b | ||
|
|
daa41ff6cc | ||
|
|
1ff2ddce05 | ||
|
|
10a4847a6b | ||
|
|
3476f8ede9 | ||
|
|
0a7b0238ca | ||
|
|
00bda8f442 | ||
|
|
728db36ac3 | ||
|
|
5ded3cfd39 | ||
|
|
2b8126b7be | ||
|
|
86d82186ee | ||
|
|
6c82098207 |
@@ -26,7 +26,12 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
options: --user 1001
|
||||
timeout-minutes: 25
|
||||
# This job runs every page workflow twice (chromium, then touch) on one worker with V8 coverage on, so it
|
||||
# takes about twice a single-browser job. Measured 2026-10-20: whole job 18m26s-24m57s (main 5ded3cf
|
||||
# 22m15s; PR head 6359999 was cancelled at the old 25-minute limit while still passing), coverage step
|
||||
# alone 20m55s-22m17s+, against ~12-15 minutes for the firefox/webkit jobs. 40 minutes leaves headroom
|
||||
# for suite growth while still stopping a hung run; re-measure if a run passes 30 minutes.
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
|
||||
|
||||
+582
-25
@@ -225,9 +225,27 @@ managed host with both renderer startup configurations.
|
||||
|
||||
The shared inline and collection inspector reads the current rooted file's supported
|
||||
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, the + and − prompt blocks with copy and send, tag chips (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
|
||||
content without the cover, then "File & provenance" (the honest file facts, the
|
||||
`Prompt evidence` line, Download original and, for a listed file, D38's local-only
|
||||
"Show in folder" after the Path) and "Workflow & raw metadata" (raw records and their
|
||||
two downloads), then the pair send and Prompt history buttons. D22 words an empty
|
||||
positive explicitly and makes confirmed prompt text focusable.
|
||||
`tests/browser/inspector-concept.spec.mjs` compares both surfaces with the concept
|
||||
(see the D20–D39 notes under Images).
|
||||
`tests/browser/full-inspector.spec.mjs` covers original/metadata/workflow downloads,
|
||||
response limits, source changes, nested sending, matched dark/light and mobile
|
||||
geometry, and the loaded-collection viewer. The viewer never fetches another page
|
||||
geometry, and the loaded-collection viewer. `viewer matches the concept shell and
|
||||
toolbar order` measures the concept's `#viewer-dialog` shell, header copy, toolbar
|
||||
order and frame geometry in the concept and in production at 1600 and 390 pixels;
|
||||
its #249 D34 mapping keeps production's Download link, the appended position and
|
||||
reason lines, coarse 40px targets and the toolbar inside `.viewer-left` (Viewer.js's
|
||||
own `.viewer-toolbar` rule lifts the concept's across the dialog). The viewer never fetches another page
|
||||
or preloads all originals. Its explicit slideshow waits 3.5 seconds after image
|
||||
readiness, pauses at native video without advancing on `ended`, and resumes on a
|
||||
manually selected image only while prior intent remains. Pause/Stop, inspector
|
||||
@@ -343,8 +361,8 @@ of the approved concept's `docs/concepts/workspace/style.css`. Never edit the co
|
||||
it is only rewritten from the concept with `npm run --prefix tools/vendor sync:concept`.
|
||||
`npm run --prefix tools/vendor check` runs `sync-concept-css.mjs --check`, which fails
|
||||
on a missing copy or names the first differing byte. `workspace.html` loads, in order,
|
||||
the toolkit controls, Viewer.js, `concept.css`, the page stylesheets that their own
|
||||
PRs retire, and the production overlay `workspace.css` last.
|
||||
the toolkit controls, Viewer.js, `concept.css` and the production overlay `workspace.css`
|
||||
last; every page stylesheet that waited for its page PR is retired.
|
||||
`tests/vendor/concept-css.test.mjs` asserts that order, the drift check, and that no
|
||||
other page or canvas script loads workspace CSS.
|
||||
|
||||
@@ -355,16 +373,28 @@ D-number must be one of this spec's (#249): a comment that cites any other issue
|
||||
as `D5 (#245)`, is that issue's ruling and is not a marker, so write `#245 decision 5`
|
||||
for those. The lint test fails if `workspace.css` is missing. The match is by exact
|
||||
selector, so a narrower selector over a concept rule (for example
|
||||
`.ca-tools dialog.prompt-sender`) passes the lint. Such rules, and the page-stylesheet
|
||||
leftovers such as `library.css` `.row-actions`, name the PR that retires them, by
|
||||
convention.
|
||||
`.ca-tools dialog.prompt-sender`) passes the lint; by convention such rules carry a
|
||||
marker too.
|
||||
|
||||
The lint covers `workspace.css` only. The page stylesheets that load before it
|
||||
(`images.css`, `library.css`, `inspector.css`) are not linted yet, and `images.css`
|
||||
still has unmarked concept-selector overrides (`.image-grid`, `.prompt-text`,
|
||||
`.prompt-actions`, `.ca-tools .prompt-actions button`). PRs C and D retire those
|
||||
sheets: each one runs `lintOverlay` over every page sheet it keeps, and adds that
|
||||
sheet to the real-file lint test.
|
||||
PR D deleted `images.css` and `inspector.css`, and PR F-b deleted `library.css`, the
|
||||
last page stylesheet (test `the retired shell, vocabulary, editor, Images, inspector and
|
||||
library stylesheets stay deleted`). `the overlay keeps no retired Images page or
|
||||
inspector rules` keeps the replaced card, filter, toolbar, preview, facts and inspector
|
||||
rules out, and `the overlay keeps no rule waiting for a page rebuild` keeps out the
|
||||
interim guards and the history page's private role colors. Every rule that stayed has
|
||||
a live consumer and a marker: the Library rows windowed (D3) and the shelf's states
|
||||
(D40), the history panel's wrap, archived-lane and coarse-target rules (D45), and under
|
||||
**D48** the production surfaces the concept never shows, drawn in the concept's tokens
|
||||
(`--green`, `--red`, `--ca-*`): recorded executions and the Recorded run dialog (#209,
|
||||
D26), `pre.prompt-text` for stored text in the frozen field provenance and the Archive
|
||||
negative confirmation, whose `section.prompt-block` (#235) keeps its rules too, and the
|
||||
acknowledged prompt sender (spec §2.2). Rules that repeated the concept
|
||||
(the `.library-grid` tracks and breakpoints, the history `.row-actions` flex row and
|
||||
button size, `summary` cursors, `h2` margins, the sender and Recorded run dialogs'
|
||||
colors and border) or had no effect (`min-inline-size` on block children, a
|
||||
`.recorded-run-dialog pre` limit, a 700px padding that a later rule overrode) were
|
||||
deleted; the workspace browser specs on Chromium and the concept specs on touch prove
|
||||
the pages unchanged.
|
||||
|
||||
**Comparison method.** Production must equal the concept except for the recorded
|
||||
deviations in the #249 design (spec §8, D-numbers). It is never compared against
|
||||
@@ -401,13 +431,63 @@ Recorded shell deviations:
|
||||
- **D4**: a "Theme" select with Dark, Light and System. System follows the ComfyUI
|
||||
palette, then the OS.
|
||||
- **D5**: no Activity button or Activity row until an activity center exists.
|
||||
- **D28**: no SAVED VIEWS or COLLECTIONS, and no Design review. The bottom nav is
|
||||
- **D28**: no COLLECTIONS, and no Design review. The bottom nav is
|
||||
System plus the production Overview hub (its own outline home glyph, not the concept's
|
||||
`activity` pulse, and no count).
|
||||
The connection reads "Your library, on your server" with a neutral muted dot.
|
||||
- **D49** (saved views, #249 D16): the SAVED VIEWS group label has no "PROPOSED"
|
||||
badge, because saved views are persisted (`/prompt_manager/saved-views`). Its rows are
|
||||
the concept's `links()` rows: the search icon, the view name and no count, linking
|
||||
`#/saved/<id>`. Opening one shows its page with the saved toolbar value and Refine
|
||||
filters, and the view's name as the h1, breadcrumb and title. A value its page no
|
||||
longer accepts (a removed Source or Folder) stays default; an unknown id opens Images.
|
||||
A toolbar change made while the sources are still loading keeps the saved Source and
|
||||
Folder until discovery settles. A saved view, like Favorites, never replaces the plain
|
||||
Images or Library return state. Writes (POST, PATCH) validate strictly; a state over
|
||||
4 KB is refused in the browser with "The view is too large to save. Shorten its
|
||||
search, folder or tags and try again." Startup and restore check only the table's
|
||||
structure and that each row's state is a JSON object of at most 4 KB, never the value
|
||||
rules, so a row saved under older rules (a missing or retired key, a sort no longer
|
||||
offered) never stops the database from opening: reads fill missing keys with their
|
||||
defaults, drop unknown keys, default invalid values and make the name displayable
|
||||
(`tests/test_saved_views.py`). A live row whose state is not a JSON object never
|
||||
blocks startup either: its id is logged as a warning, the row is left in place, the
|
||||
listing and rename skip it, and its `#/saved/<id>` link behaves like a missing view.
|
||||
Restore stays strict and rejects such a backup ("Saved views contains invalid values"). Only request validation is a 400; any other failure is
|
||||
a logged 500 with a generic message. The Favorites count re-read after starring is
|
||||
coalesced to one read 300 ms after the last toggle.
|
||||
`a saved view row matches the concept's` saves one view in both documents and
|
||||
compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving,
|
||||
reopening, the defaults that replace rejected values, and failed reads and saves.
|
||||
- **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer
|
||||
ruling, 2026-10-21). The comparison maps only that row out.
|
||||
- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with
|
||||
the concept's Favorites row (star icon, name, count). Its count is the Favorites
|
||||
listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows
|
||||
the nav-count rules (hidden until loaded, absent when its read fails, never a
|
||||
placeholder) and is re-read after every confirmed star or unstar, on any page.
|
||||
`#/favorites` is Images filtered to favorites, named "Favorites" (concept
|
||||
`renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root
|
||||
from `image_favorites` and matched against the scan with the toggle's case folding,
|
||||
and a failed read is an error, never an unfiltered page. Unlike the concept, an
|
||||
unstarred card stays on Favorites (showing ☆) until the next load, so the star can be
|
||||
undone in place. Favorites neither restores nor replaces the Images return state.
|
||||
Save view there saves `favorites: true`, so the view reopens the favorites. An open
|
||||
saved view's heading leads with **Edit**: "Edit saved view" with "View name" and
|
||||
Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and
|
||||
toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the
|
||||
same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`; its Cancel
|
||||
closes the dialog without deleting rather than returning to Edit); Delete
|
||||
removes the view, replaces the dead route with the view's plain page and toasts
|
||||
"Saved view deleted.". Failures toast "The view could not be renamed. Try again." or
|
||||
"The view could not be deleted. Try again."; a view another tab already deleted counts
|
||||
as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count,
|
||||
route, star updates, Save view on Favorites, Edit, and the failure paths;
|
||||
`tests/test_image_favorites.py` covers `favorites=1`.
|
||||
- **D29**: no prototype copy. There is no "Interactive concept" badge and no
|
||||
"What changes & why" button. The footer reads "ComfyAssets · Local tools. Your
|
||||
library.", and the title has no " concept" suffix.
|
||||
library.", and the title has no " concept" suffix. Save view's dialog action reads
|
||||
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
@@ -423,6 +503,463 @@ npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
`tests/browser/images-concept.spec.mjs` compares the Images page (#249 C7): heading,
|
||||
toolbar, filter row, caption, grid, the first six cards, the continuation and the panel
|
||||
box, in Grid, List, Masonry and the Large and Compact sizes, at 1600x1100 and 390x844
|
||||
in both themes and motion settings; grid, cards and panel also at 1200x900 (2 columns,
|
||||
275px panel) and 1800x1100 (4 columns, 310px panel). The concept is compared once its
|
||||
own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, production
|
||||
40); the Load more row is compared before the press in both documents. Behaviors: hover
|
||||
zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape returns focus
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
sorted alphabetically). Removed toolbar and filter-row items flow again from the
|
||||
concept's own boxes (`reflow`), checked against the concept page with those items
|
||||
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
|
||||
unit per card above in the column (D3). `one footer height for the collection (D3)`
|
||||
seeds two seedless files (`seedless_images` on `/__test__/concept/reset`) and asserts
|
||||
that every row reserves the tallest footer.
|
||||
|
||||
### Images page controls and recorded deviations (#249 PR C)
|
||||
|
||||
The Images page renders the concept's DOM with real data. Its controls, top to bottom:
|
||||
|
||||
- **Heading**: "Images" / "Your generations, their prompts, and the next idea.",
|
||||
`Import` (opens "Import & inspect") and the shell's `New prompt`.
|
||||
- **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").
|
||||
- **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).
|
||||
- **Caption**: "N of M images · K positive prompts" (`#result-count`) and, under
|
||||
Recently added only, the newest result's day.
|
||||
- **Grid** (`#image-grid`): concept media cards. The image opens the viewer
|
||||
(click, Enter, Space); Info selects the card into the side panel; the star toggles
|
||||
the favorite; arrows, Home and End move between cards. Bounded rendering keeps the
|
||||
DOM small, and returning to Images restores filters, layout, size, selection and
|
||||
scroll.
|
||||
- **Continuation** (`#gallery-continuation`): scrolling near it loads the next page;
|
||||
`Load more images` and `Retry` do the same by hand, with a toast per batch.
|
||||
- **Viewer** (`dialog#viewer-dialog`): "<title> · <filename>", the concept toolbar
|
||||
(a "Media controls" group), the shared file inspector beside the stage, and Escape
|
||||
returns focus to the opening image.
|
||||
|
||||
How the earlier workspace Images filters moved (old → new). The legacy `/gallery`,
|
||||
`/admin` and `/metadata` pages are unchanged.
|
||||
|
||||
| Before PR C | Now |
|
||||
| --- | --- |
|
||||
| "Search filenames" (filename or folder) | toolbar search, which also matches linked prompt text, title and tags (D12) |
|
||||
| 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) |
|
||||
| (none) | toolbar category 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 |
|
||||
| Card image → side panel; preview icon → viewer | image → viewer; Info → side panel (D19) |
|
||||
| "All images" heading and count | the concept caption and count line (D17, D35) |
|
||||
|
||||
Recorded deviations on this page (spec §8):
|
||||
- **D3**: bounded rendering stays. Every grid row reserves the tallest measured card
|
||||
footer, masonry tops may differ by sub-pixel snapping (at most one layout unit per
|
||||
card above in the column), and a List row's info takes its natural height, centred
|
||||
beside the 160×95 art.
|
||||
- **D5**, **D29**: no Activity button and no prototype copy (shell).
|
||||
- **D7**: the star is a real persisted favorite with "Added to favorites." /
|
||||
"Removed from favorites." and a rollback toast on failure.
|
||||
- **D8**: every Images card has the concept's select checkbox before the star; the
|
||||
`#bulkbar` shows "N selected" and only the real actions in the concept's order (Set
|
||||
category, Export metadata, Clear). Add tags and Add to collection wait for image tags
|
||||
and collections; "Remove links…" is dropped. The selection survives Load more and
|
||||
bounded re-mounting and is cleared whenever the collection is replaced (search,
|
||||
category, sort, Filters, source change, rescan, reload). A return to Images restores
|
||||
the saved collection unreplaced and keeps its selection; a full page reload starts
|
||||
empty. The Library-embedded gallery has no checkbox and no bar. Export metadata reads
|
||||
each checked image's inspection (at most 4 at a time) and downloads one
|
||||
`prompt-manager-images-metadata.json` (`format`, `version: 1`, `exported_at`, and the
|
||||
inspector's own "Download metadata JSON" envelopes in listing order); the button is
|
||||
`aria-disabled` and ignores a second press while it runs, the selection is kept, and
|
||||
the toast names the unread images (none read: no download); leaving Images aborts the
|
||||
reads with no download and no toast. Set category opens the
|
||||
concept's `simpleInput` dialog ("Categorize selected images", `input#action-input`
|
||||
labelled Category, Cancel / Apply) and posts the selection's distinct linked positive
|
||||
ids to `/prompt_manager/bulk/category` (images have no category; the filter matches
|
||||
the linked positive's). With no linked image checked it posts nothing and opens no
|
||||
dialog; an empty value keeps the dialog open; the field stops at the server's 100
|
||||
characters; success reloads the categories and the listing together (clearing the
|
||||
selection, focus to the heading, and, as any replaced collection does, the inspector
|
||||
moves to the first listed image); failure keeps the selection. The write carries no
|
||||
page signal: leaving Images while it runs lets it finish without a toast, and the return
|
||||
loads the collection afresh instead of the saved visit. Three deviations from the
|
||||
concept's `simpleInput`: the field starts empty (the concept pre-fills its demo
|
||||
"Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the
|
||||
nothing-linked check runs on the Set category press, so no dialog opens. The route
|
||||
rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range
|
||||
(deduplicated) and a category that fails `validate_category`. `images selection bar matches
|
||||
the concept` compares the bar and the checked card with one card checked; the omitted
|
||||
actions flow again from the concept's own boxes, checked against the concept page with
|
||||
them removed at runtime.
|
||||
- **D9**: no Tag button until the image-annotations slice.
|
||||
- **D10**: the card title is the linked positive's display title, else the filename;
|
||||
the `h3` keeps the relative path in `title`.
|
||||
- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
|
||||
- **D14**: Source, Folder, Media and Tags live in "Refine this view" (no "Prompt
|
||||
evidence" field, no scope note); the primary action reads "Apply filters"; extra
|
||||
sorts come after the concept's three; long folder names wrap inside the scope chip.
|
||||
Folder lists only the chosen Source's folders (each workspace root lists its own),
|
||||
and an unchanged Apply or Clear reloads nothing.
|
||||
- **D15**: Import offers "Inspect a local image" (`#/inspect`) and "Scan an output
|
||||
folder" (Rescan, `/gallery/rescan`); no "Import portable recipes". The concept has
|
||||
no rescan copy: toasts say "Rescanning output folders…", then "Rescan finished. N
|
||||
files in M output folders." (the scan's `total` and `roots`) or "Rescan could not
|
||||
finish. Try again."; while it runs, a reopened Import shows an aria-disabled
|
||||
"Rescanning…".
|
||||
- **D16 (retired)**: Save view is the concept's own toolbar button, between sort and
|
||||
Filters, on Images and the Prompt library (see D49). It saves the visible toolbar value
|
||||
with the Refine filters; the comparisons no longer map it.
|
||||
- **D17**: the caption date is the newest result's day under Recently added (", YYYY"
|
||||
outside the current year), hidden under every other sort.
|
||||
- **D18**: the concept hover zoom, with no sticky zoom on hover-less (touch) devices.
|
||||
- **D32**: the layout control may wrap, so the filter and continuation rows never
|
||||
overflow at 390px and 200% zoom.
|
||||
- **D33**: after Load more or Retry, focus moves to the first new image without
|
||||
scrolling, and only while focus is still on the pressed control or `body`.
|
||||
- **D34**: the viewer keeps its toolbar under the stage in `.viewer-left`, a flex
|
||||
column above 700px, `max-height: min(94dvh, 94%)`, the "Download original" link,
|
||||
the appended position and reason lines, full-stage video and failure overlay,
|
||||
40×40 coarse targets, production's control names and "Media controls" group, and
|
||||
`overscroll-behavior: contain`.
|
||||
- **D35**: counts pluralize ("1 positive prompt", "1 image", "1 more image loaded").
|
||||
- **D36**: the category list is the Library's, sorted alphabetically (case-insensitive)
|
||||
by the page, whatever order the server sends.
|
||||
- **D37**: production loads 40 images per page (the concept 12), so the continuation
|
||||
and toast use 40; after Escape production returns focus to the opening image.
|
||||
- **D44** (#249 PR E): Filters reads "Filters · N" while N of Source, Folder, Media and
|
||||
Tags are set in "Refine this view" (see the Prompt library deviations).
|
||||
- **D20**: the side panel shows only the concept content; "File & provenance",
|
||||
"Workflow & raw metadata" and the downloads are only in the viewer's details
|
||||
(`#viewer-details`: the panel content without the cover in a class-less wrapper, the
|
||||
two disclosures, then the pair send and Prompt history buttons). "File & provenance"
|
||||
keeps the honest facts (current-file note, Source, Path, Size, Dimensions, Format,
|
||||
Media type, Modified, the reported-location occurrence, the saved-association
|
||||
sentence, Download original) and the concept's `Prompt evidence: <badge>` line in the
|
||||
D21 badge's words in lower case, left out when there is no badge. Raw records keep
|
||||
their names, representations and the two downloads with their unavailable reasons;
|
||||
the viewer's pair send and history buttons may wrap at 200% zoom. Tag chips in the
|
||||
viewer are plain (the concept's go to the Library). The details' disclosure
|
||||
summaries and "Download original" keep 40px coarse targets (D34). Prompt history
|
||||
closes the viewer and, when its target is the route already shown, re-renders it
|
||||
(the concept's `go()`).
|
||||
- **D20/D21 side panel**: the sticky panel scrolls inside the window
|
||||
(`max-block-size` with `overflow: auto`, not the concept's `overflow: hidden`), so
|
||||
long real prompts keep the footer reachable; an empty badge or tag `.tags` row is
|
||||
left out (the concept always renders both); the footer, its pair send and Prompt
|
||||
history appear only with resolved prompt roles or a saved link; a video file keeps
|
||||
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
|
||||
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
|
||||
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
|
||||
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
|
||||
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
|
||||
- **D38 Show in folder** (maintainer decision 2026-10-20, beyond the mock): in the
|
||||
viewer's File & provenance the Path value stays text, followed in its `dd` by a
|
||||
"Show in folder" button (`Show <filename> in folder`), only for a listed file
|
||||
(`root_index`, `root_scope`, `path`), so never in the side panel or a recorded run.
|
||||
It POSTs that identity as JSON to `/prompt_manager/images/reveal` and toasts:
|
||||
|
||||
| Result | Toast |
|
||||
|---|---|
|
||||
| Success | "Opened the folder on this computer." |
|
||||
| 403 `not-local` | "Show in folder works only in a browser on the computer running ComfyUI." |
|
||||
| 501 `unavailable` | "No file manager is available on this computer." |
|
||||
| 429 `busy` | "The folder is already opening." |
|
||||
| Anything else (415, 404, 409, 500, offline) | "The folder could not be opened. Try again." |
|
||||
|
||||
The route is local-only. It answers 403 `not-local` unless all of these hold: no
|
||||
forwarding-style header (`Forwarded`, `Via`, `X-Real-IP`, `CF-Connecting-IP`,
|
||||
`True-Client-IP`, `X-Client-IP`, `X-Cluster-Client-IP`, `Fastly-Client-IP`, and any
|
||||
`X-Forwarded-*`, `X-Original-*` or `*-Client-IP`); a loopback `request.remote`; a
|
||||
loopback `Host` (`localhost`, `*.localhost` or a loopback address, against DNS
|
||||
rebinding); an `Origin` equal to the request's own scheme, host and port (absent,
|
||||
`null`, another port or another scheme is refused; browsers send `Origin` on every
|
||||
fetch POST); and, when sent, `Sec-Fetch-Site: same-origin`. A body that is not
|
||||
`application/json` is 415 `unsupported-media-type`, so any cross-origin request
|
||||
needs a CORS preflight. The body is strict (an int root, a 64-hex scope, a bounded
|
||||
relative path; absolute, drive and `..` paths are 400), the scope goes through
|
||||
`_gallery_scope_error` (409 `source-changed`), and the path resolves only inside
|
||||
that root to a regular media file (403/404; a resolution `OSError` such as
|
||||
`ENAMETOOLONG` is the same JSON 404, and the client's path is never logged at error
|
||||
level). One reveal runs at a time per process with a 1 s cooldown after it
|
||||
(`RevealGate`, 429 `busy`), on its own single worker (`reveal_executor`), never
|
||||
ComfyUI's default executor. Just before launching, `utils/file_reveal.py` re-checks
|
||||
that the real path is still a regular file inside the root (a swapped parent is
|
||||
404). Windows starts no process: `ILCreateFromPathW` and
|
||||
`SHOpenFolderAndSelectItems` select the file through the shell API (Explorer's own
|
||||
command line splits on commas, so a path like `output\a,\Windows\...` must never
|
||||
reach it), falling back to `os.startfile(folder, "explore")`. macOS runs
|
||||
`/usr/bin/open -R`; Linux asks FileManager1 `ShowItems` with `dbus-send
|
||||
--print-reply --reply-timeout=2000` (a real reply or a fallback) and else runs
|
||||
`xdg-open` on the folder. Those openers are argv lists resolved only on a fixed
|
||||
system PATH, with no shell, DEVNULL streams, `close_fds`, their own session, a
|
||||
minimal environment (no venv PATH, PYTHONPATH or API keys) and a daemon-thread
|
||||
reaper. `tests/test_image_reveal.py` mocks `Popen`, `sys.platform`, `shutil.which`,
|
||||
the shell API and `os.startfile`; the browser server's opener is a stub
|
||||
(`POST`/`GET /__test__/reveal`) with no cooldown. The inspector comparison reads
|
||||
closed disclosures as hidden, so the button needs no mapping there.
|
||||
|
||||
Residual risk, accepted: a reverse proxy or tunnel that adds no forwarding header
|
||||
(a bare nginx `proxy_pass` to 127.0.0.1, `ssh -R`, `socat`) cannot be told apart
|
||||
from a local client, and a non-browser client behind it can forge a matching
|
||||
`Host` and `Origin`. Such a client can open a folder of a listed file on the host's
|
||||
screen, at most once a second, and learn whether that file exists (which the
|
||||
listing routes already tell it). Operators who expose ComfyUI that way accept this.
|
||||
- **D39** (ruled 2026-10-21: fallback): a recipe-linked image shows its recipe's own
|
||||
category when set, otherwise the linked positive prompt's, and its recipe's own tags
|
||||
when it has any, otherwise the positive's; each field falls back on its own and the
|
||||
two tag sets are never merged. Set category (#259) writes the positive's category and
|
||||
the Images category and Tags filters match the positive's, so the panel now agrees
|
||||
with them whenever the recipe adds nothing. The inspection response names each
|
||||
field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or
|
||||
null); the category chip's visually hidden prefix (D21) says "Recipe category: " or
|
||||
"Positive prompt category: " from it, and tag chips are Tags filter buttons only when
|
||||
they are the positive's (#260 D2). History's media inspector uses the same rule. The
|
||||
concept shows the prompt's category and tags for every image and the concept
|
||||
fixture's recipes have neither, so the inspector comparison needs no mapping for it.
|
||||
- **D42**: the concept never shows a toast and a page alert together. Production keeps
|
||||
the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below)
|
||||
and, when an Import → Rescan toast leaves the Images discovery alert
|
||||
(`.options-status`), scrolls that alert into view only as far as needed, with
|
||||
`scroll-margin-block-end: 96px`, so it is never under the toast.
|
||||
`Rescan toast does not cover the discovery alert it leaves` ends the window inside
|
||||
the toast's band at 1600 and 390 pixels and measures both boxes.
|
||||
|
||||
`tests/browser/inspector-concept.spec.mjs` compares the side panel and the viewer's
|
||||
full details (#249 D4) for alpine_00001.png, at 1600x1100 and 390x844 in both themes:
|
||||
structure, cover, heading, filename, badges, prompt blocks (role labels, actions,
|
||||
text), tag chips, facts, footer, the viewer's disclosures and pair actions, and every
|
||||
offered control's name and minimum height. Each role's copy and send are named after
|
||||
the prompt, as the concept's are ("Copy positive prompt: <title>"). The concept loads
|
||||
all 24 fixture images first (D37). Behaviors: copy shows "Prompt copied." (or the
|
||||
same "Copy prompt" dialog in both); the pair send previews both roles in the concept
|
||||
and is applied and acknowledged by a test ComfyUI tab in production; the `cinematic`
|
||||
chip sets the Images Tags filter (D21: the concept searches the word, which also
|
||||
matches the untagged forest prompt's text); Prompt history goes to
|
||||
`#/library/<positive id>` (the concept's `#prompt/<id>`). `mapInspectorConcept`
|
||||
applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
|
||||
viewer's plain tag chips, laid out by the concept page itself with its chips made
|
||||
plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
|
||||
`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
|
||||
prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
|
||||
changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
|
||||
|
||||
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
|
||||
click it (the viewer reads "<title> · <filename>"; Escape closes it); press a card's
|
||||
Info (the side panel changes); search a word from a prompt (matching images remain);
|
||||
open Filters (Source, Folder and Media are there); Import → Rescan picks up a newly
|
||||
saved file.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
|
||||
workspace-images.spec.mjs full-inspector.spec.mjs inspector-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Prompt library cards and recorded deviations (#249 PR E)
|
||||
|
||||
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
|
||||
`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.
|
||||
|
||||
Recorded deviations on this page:
|
||||
- **D23**: real card facts. "N runs" is the parent's `run_count`. The footer reads
|
||||
"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.
|
||||
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
|
||||
hover. The positive is clamped to two lines
|
||||
(`.prompt-card .prompt-text`); copy and send use the full text. A long title wraps
|
||||
inside the title row, and the category chip keeps whole words ("Uncategorized" for
|
||||
none). A parent with one preview, or none, fills the art row; no preview reads "No
|
||||
available image previews" (`.small`). Each card's actions add its title to their names
|
||||
("Copy positive prompt: <title>", as the concept's do).
|
||||
- **D40**: production-only shelf copy the concept never shows. A failed page reads
|
||||
"Prompt library could not be loaded. Retry." beside "Retry prompt library" (no Refresh,
|
||||
D15). A failed category list reads "Categories could not be loaded. Open the Prompt
|
||||
library again to retry." (Retry re-reads it only when the shelf failed too). The
|
||||
continuation keeps "Loading prompt library…", "No matching prompts. Try changing the
|
||||
filters." (the concept: "No matching prompts."), "You’re all caught up.", "The saved
|
||||
prompt is hidden by the current filters." and "Load more prompts".
|
||||
- **D41**: the search keeps the concept's shared placeholder "Search prompts, tags,
|
||||
filenames…" for parity with Images, although Library search matches positive text and
|
||||
titles and saved negative text and variant titles, not filenames or tags (Tags live in
|
||||
"Refine this view", D14).
|
||||
- **D43**: a card's tag chips come in the store's alphabetical order (`ORDER BY t.name`,
|
||||
as everywhere tags are read); the concept lists each sample's tags in authored order.
|
||||
- **D44**: the shared toolbar's Filters button reads "Filters · N" while N "Refine this
|
||||
view" filters are active, and plain "Filters" otherwise; the text is its accessible
|
||||
name, so the count is spoken. The concept has no indicator, and without one a Tags
|
||||
filter set by Tags & categories Browse was invisible on the shelf. The Library counts
|
||||
Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is
|
||||
not a Refine filter and is not counted. The caption's count is a `role="status"`
|
||||
region, written only when it changes and kept shown while the shelf reloads, so
|
||||
Load more and scrolling never re-announce it.
|
||||
|
||||
`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the
|
||||
concept-shaped fixture, which creates the concept's prompts last to first so Recently
|
||||
added lists them in the concept's order: heading, toolbar, caption, grid and the six
|
||||
cards (art, title row, prompt block, meta, tags, footer) at 1600x1100 and 390x844 in
|
||||
both themes and motion settings, and the grid and cards at 1800x1100 (3 columns).
|
||||
Behaviors, in both documents: the title and Explore history open the prompt's history
|
||||
(its `h1` is the title), the footer send carries the positive only (a bridge host page
|
||||
records the roles production's preview asks for), copy writes the full positive with
|
||||
"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards
|
||||
and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D23 (the
|
||||
title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the
|
||||
singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the
|
||||
concept's samples exceed at 390px, checked against the concept with the clamp rule
|
||||
applied), D35 (singular captions), D36 (sorted categories), D40 (the continuation after
|
||||
the grid) and D43 (sorted chips, checked against the concept with its chips sorted).
|
||||
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
|
||||
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
|
||||
"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open
|
||||
existing prompt library") and the sidebar still link `/prompt_manager/admin`
|
||||
(`tests/browser/workspace.spec.mjs`). `library comparison detects
|
||||
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
|
||||
|
||||
Manual check (quick), after a browser refresh on Prompt library: each card shows the
|
||||
title with its category chip on one line, two lines of the positive with copy and send,
|
||||
"− N negative variants · N runs · N images" and the tags; the footer reads "★★★ · Used
|
||||
today" (or the real date) with send and "Explore history"; Filters → Tags still narrows
|
||||
the shelf, and the button then reads "Filters · 1" (D44), also after a Browse from Tags &
|
||||
categories.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \
|
||||
workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Prompt history page and recorded deviations (#249 PR F-a)
|
||||
|
||||
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
|
||||
variants panel and comparison (app.js `renderHistory` a591–616, the dialogs a1282–1304,
|
||||
style.css 1059–1179). The summary is `article.surface` with the category chip, the
|
||||
positive and selected negative prompt blocks and `.card-meta` "N tracked runs · N images
|
||||
· N negative variants" (tracked runs from the recorded window's `recorded_run_count`).
|
||||
The panel is `section.surface.timeline-panel` (region "Saved negative variants") with
|
||||
Compare negatives, + Add negative and Show archived, a `.timeline` of `.variant-lane`
|
||||
rows (`--lane-offset` 38 + 47i, the "−" `.variant-dot`, "<name> · N runs" and a
|
||||
`.run-dot` for a pair counted more than once) and `.timeline-lower` with the legend and
|
||||
All images for this positive. Recorded executions, frozen fields, send and archived
|
||||
lanes keep their contracts. `tests/browser/workspace-history.spec.mjs` and
|
||||
`workspace-library-editor.spec.mjs` cover them on Chromium and touch.
|
||||
|
||||
Spec decisions this page applies: **D24** (Send to ComfyUI is disabled only while the
|
||||
selected pair's roles load), **D25** (Compare negatives compares a baseline with the
|
||||
selected red dot, and lists the selected side's added comma terms) and **D26** (All
|
||||
images for this positive is a dialog with its own bounded gallery; the run dot opens
|
||||
Recorded run for the pair's newest recorded execution, else a Repeat run dialog with the
|
||||
pair's newest linked image, reading the archived gallery for an archived pair).
|
||||
|
||||
Recorded deviations on this page:
|
||||
- **D45**: a variant dot is named "Select negative <title>" when no other loaded lane
|
||||
shares that title (compared trimmed), else "Select negative variant N"; a title that
|
||||
itself reads "variant N" always uses the lane's own number, so no two names repeat.
|
||||
The bounded "N of M saved negative variants" count stays a visually hidden status,
|
||||
and production's "Saved combinations, not an execution timeline." is removed.
|
||||
Archived lanes (#235) are concept lanes marked `.library-variant-archived` at the .6
|
||||
marker emphasis (their text stays at full contrast) with their Restore button. Under
|
||||
`pointer: coarse` the variant dot keeps a 47px target and the 12px run dot a 44px one
|
||||
(`::after` overlays). While a run dot shows, the lane text wraps within 139px before
|
||||
it. The panel heading, the legend row and All images wrap at 390px and 200% zoom.
|
||||
- **D46**: lanes list newest first (`ORDER BY pp.id DESC`), so the comparison's
|
||||
baseline is the oldest (lowest-id) active variant, not the first lane; while the
|
||||
variant page has more, the oldest is not loaded and the section reads "Load more
|
||||
negative variants to reach the baseline." instead of guessing. The two blocks are
|
||||
named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats.
|
||||
Compare negatives is disabled while the positive has no active variant. The page
|
||||
opens on the newest lane (existing behavior), while the concept opens on
|
||||
`negatives[0]`, its baseline: the first comparison reads "Baseline compared with
|
||||
<newest>" where the concept compares the baseline with itself. The history concept
|
||||
comparison (PR F-b, F5) maps this.
|
||||
- **D47** (#249 PR F-b): the shared gallery is the concept's `section.history-gallery`
|
||||
(region "Shared gallery") with `.panel-heading` (h2 "Shared gallery", `span.small`
|
||||
"<name> · same positive prompt") after the variants panel (and Compare), and
|
||||
recorded executions (#209) follow it, as the concept has no recorded section. The
|
||||
name is the lane's own name ("<title>", else "Negative variant N"), with " · Archived"
|
||||
for an archived pair, and follows a rename. Its cards are the Images media cards: the
|
||||
star wherever the read returns `favorite`, no checkbox or selection bar (Images only,
|
||||
#259), the image opens the Viewer. A saved variant with no images shows the concept's
|
||||
`.empty` "Saved variant · no runs yet." beside the grid (the concept puts it inside
|
||||
`.image-grid`, which only holds positioned cards here). The concept's "Shared gallery"
|
||||
heading is the gallery's only heading and names its region: the embedded collection's
|
||||
"Linked images" heading and "N loaded · N linked images" count are removed, since they
|
||||
repeated it (maintainer ruling, 2026-10-21). Two production pieces stay, which the
|
||||
concept's gallery does not show: the layout row (Grid/List/Masonry and Thumbnail size),
|
||||
because the legacy gallery let people change layout and density and nothing else on the
|
||||
page offers it, and the selected-image side panel, the one shared inspector the Images
|
||||
page uses (spec §3.3), so an image's details stay reachable without opening the viewer.
|
||||
The same ruling covers the page's two media dialogs, All images for this positive and
|
||||
Repeat run: as in the concept (app.js a1282-1304), each is named by its own dialog
|
||||
title, with no "Linked images" heading or "N loaded · N linked images" count, and a
|
||||
stranded focus returns to the dialog's close button (maintainer ruling, 2026-10-21).
|
||||
The Images page keeps its caption. With no saved variant the page shows every image
|
||||
of the positive under "All images for this positive, including unassigned images. …"
|
||||
and no empty copy (a state the concept never reaches).
|
||||
- **D48** (#249 PR F-b): production surfaces the concept never shows keep their own
|
||||
overlay rules, drawn in the concept's tokens: recorded executions and the Recorded run
|
||||
dialog (#209, D26), the Archive negative confirmation (#235) and the acknowledged
|
||||
prompt sender (spec §2.2). With `library.css` retired, these rules live in
|
||||
`workspace.css` (see "Concept shell and comparison method").
|
||||
|
||||
The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest`
|
||||
(see "Chronological Library history"), and each lane's "N runs" is the pair's
|
||||
`run_count` (see "Workspace data fields").
|
||||
|
||||
`tests/browser/history-concept.spec.mjs` (#249 PR F-b, F5) compares this page with the
|
||||
immutable concept's `#prompt/alpine` on the concept fixture, at 1600×1100 and 390×844 in
|
||||
both themes and motion settings, on Chromium and touch: the heading, the summary, the
|
||||
variants panel, the comparison (Compare negatives pressed) and the shared gallery's
|
||||
place and heading, all measured by `inventoryOf`'s `history` section with the concept's
|
||||
selectors in both documents, with "Clean architecture" selected (the middle lane in
|
||||
either order). The fixture links and records its images oldest first, so ids grow with
|
||||
time as real runs do, and counts one pair use per image, which gives the concept's
|
||||
"Quality baseline · 2 runs" and its run dot. `mapHistoryConcept` applies D24 (the four
|
||||
recorded runs give the concept's own "4 tracked runs", asserted on both), D45 (the
|
||||
visually hidden "3 of 3 saved negative variants" status, and its wrap rules applied to
|
||||
the concept at runtime), D35 and D46 (the concept's lanes reordered newest first with
|
||||
"1 run", laid out by the browser), D46's Baseline/Selected action names, #235's Show
|
||||
archived button (added to the concept at runtime) and D47 (the gallery's body is the
|
||||
Images collection, so its height is not compared). D25: each compared block mounts in a
|
||||
`display: contents` child of the concept's `.compare-item`, so the item carries no
|
||||
production class or margin. Behaviors are compared in both documents: a red dot changes
|
||||
the negative block and the gallery, Compare toggles `aria-pressed`, All images opens a
|
||||
dialog with the positive's four images, the run dot opens a dialog (D26: Recorded run
|
||||
where the concept shows Repeat run), neither dialog has a collection caption (D47), the
|
||||
heading send previews the selected pair, and
|
||||
← Prompt library navigates. `history comparison detects mutations` injects
|
||||
`.ca-tools .variant-dot { width: 20px }` and removes Compare negatives. The older
|
||||
`history-canonical-comparison.mjs` stays for `workspace-visual.spec.mjs`'s recorded
|
||||
executions (#209) checks; its production selectors are now the concept's.
|
||||
|
||||
```sh
|
||||
npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
|
||||
workspace-history.spec.mjs --project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
## Workspace data fields for the concept pages (#249)
|
||||
|
||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||
@@ -440,9 +977,9 @@ and skip the parse.
|
||||
`unavailable` when the database read failed (logged, no error text). Treat
|
||||
anything but `linked` as the filename fallback. In pair scope (the image has
|
||||
exactly one distinct pair), `positive_id` is the pair's positive, and the title
|
||||
comes from the pair title, then the positive's Library title, then the text's
|
||||
first line. In positive scope it comes from the Library title, then the first
|
||||
line. A first line longer than 80 code points is cut to 80 plus `…`, like
|
||||
comes from that positive's Library title, then its text's first line, never
|
||||
from the pair's own title, which names the negative variant (#249 D10/D20). In
|
||||
positive scope it comes from the Library title, then the first line. A first line longer than 80 code points is cut to 80 plus `…`, like
|
||||
`library.mjs` `title()`. `title_source` is `stored`, `text` or null. `category`
|
||||
and `rating` come from the linked positive (D13), not from the pair. The title
|
||||
gate is shared with the inspector (`_verified_title`): a `stored` title always
|
||||
@@ -484,9 +1021,11 @@ and skip the parse.
|
||||
Filters (`mode=workspace`; anything invalid is 400 "Invalid gallery filters"):
|
||||
|
||||
- `q` (at most 500 characters). An entry passes when the case-folded relative path
|
||||
contains it, or when its linked positive's text, Library title, a tag name, or
|
||||
its pair title contains it (SQLite `LIKE` with escaping, ASCII case folding
|
||||
only). Search does not apply the card title gate, and does not search pair tags.
|
||||
contains it, or when its linked positive's text, Library title or a tag name
|
||||
contains it (SQLite `LIKE` with escaping, ASCII case folding only). A pair's own
|
||||
title names its negative variant, so it is not searched (#249 D12): a variant
|
||||
name alone matches nothing. Search does not apply the card title gate, and does
|
||||
not search pair tags.
|
||||
- `category` (exact and case-sensitive, at most 200 characters, stripped; blank
|
||||
means none) and `tags` (a comma list, blanks dropped, order-free, AND, at most 30
|
||||
tags of at most 200 characters) admit linked images only, through the linked
|
||||
@@ -519,7 +1058,9 @@ null. The edit token ignores both. `variant_total` has two meanings. On the list
|
||||
response it counts the active variants over all filtered parents (the same
|
||||
snapshot as `total`, the same on every cursor page). On the detail response it
|
||||
counts one positive's variants in the chosen archive scope. The Library caption
|
||||
reads it from the list state.
|
||||
reads it from the list state. Each detail variant (active and archived) also carries
|
||||
its pair's `run_count` (`prompt_pairs.run_count`, 0 when null), which the history lanes
|
||||
show as "N runs" (#249 F2).
|
||||
|
||||
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
|
||||
`{root_index, path}`. Both are idempotent and answer
|
||||
@@ -527,7 +1068,11 @@ Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` wit
|
||||
root or a malformed body, and 403 for traversal, an absolute or drive path, NUL, a
|
||||
non-media name, or a key over the stored bound. PUT needs a listed file (404
|
||||
otherwise). DELETE also clears a path that is no longer listed. Send the DELETE
|
||||
body as `application/json`.
|
||||
body as `application/json` (`api.del(url, {body})` in the shared API client).
|
||||
The Images card star (#249 C5) toggles optimistically, allows one request per file
|
||||
at a time, and on any failure restores the previous state and announces it in the
|
||||
shared toast. The browser fixtures clear `image_favorites` on reset, and the
|
||||
concept fixture stars the concept's favorites (each other prompt's first image).
|
||||
|
||||
- The table is `image_favorites(root_key, relative_path, created_at)`. `root_key`
|
||||
is the root's real path (`os.path.realpath`), case-normalized with `/`
|
||||
@@ -740,10 +1285,20 @@ The versioned public reads are:
|
||||
| Route | Meaning |
|
||||
| --- | --- |
|
||||
| `GET /prompt_manager/library/{positive_id}/history` | Exact frozen raw-positive group; compact chronological windows |
|
||||
| `GET /prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest` | The pair's newest recorded run for the history run dot (#249 D26): `{run: {run_id, anchor_id, context_id}}` or `{run: null}` |
|
||||
| `GET /prompt_manager/history/runs/{run_id}` | Frozen fields, per-field provenance, capture and outcome for a known UUID |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/outputs` | Ordered host-reported occurrences, including repeated locations |
|
||||
| `GET /prompt_manager/history/runs/{run_id}/output/current/{media,thumbnail,inspection}` | Authorized current file at that reported location, using the returned epoch/scope URL |
|
||||
|
||||
The newest-pair read takes no query. It answers 404 when the positive is not an
|
||||
eligible saved prompt or the pair is not one of its pairs. It walks the positive's exact
|
||||
raw-text scope newest first and follows only a row whose `reference_epoch` is the
|
||||
current epoch and whose saved-pair hint still resolves to that pair (the
|
||||
`get_history_run` hint rule). It inspects at most 16 candidates, so 16 newer stale hints
|
||||
report `{run: null}` (the dot then opens Repeat run). Like the other reads it runs inside
|
||||
the fenced history snapshot: a restore or epoch change is 409 `history-changed`, a
|
||||
storage failure 503 `storage-unavailable`.
|
||||
|
||||
Run and output reads accept the returned `epoch`; current-file routes require
|
||||
the returned epoch, current scope and occurrence coordinates. Run integers are
|
||||
decimal strings, preserving SQLite's full signed 64-bit values. Windows and
|
||||
@@ -835,9 +1390,11 @@ page lifecycle recovery retain their separate behavioral tests.
|
||||
Initial production acceptance requires maintainer inspection of paired PNGs and
|
||||
geometry; never update snapshots automatically or mask content to bless a gap.
|
||||
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
|
||||
distinctions are documented in the comparison helper. Broader Images card
|
||||
activation/Info/tag/favorite/select parity and shared Viewer stage/toolbar polish
|
||||
and shared role-block spacing/padding/glyph polish remain outside #209. The
|
||||
distinctions are documented in the comparison helper. The Images card tag
|
||||
button (#249 D9) remains outside #209; every prompt block now renders the concept's
|
||||
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
|
||||
gap is closed, and only the production-only surfaces above keep their own rules
|
||||
(D48). The
|
||||
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
|
||||
mobile toolbar collision. These are explicit remaining visual differences, not
|
||||
a whole-application or full-gallery/Viewer pixel-parity claim.
|
||||
|
||||
+24
-11
@@ -4,9 +4,17 @@ import os
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
from ..utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from ..utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
from utils.inspection_response import MAX_LIBRARY_BYTES, _library_projection
|
||||
from utils.inspection_response import (
|
||||
MAX_LIBRARY_BYTES,
|
||||
_library_projection,
|
||||
library_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _display_bytes(value: bytes | None) -> str | None:
|
||||
@@ -52,15 +60,21 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
return saved, _library_projection({"status": "limited"})
|
||||
pair = pairs[0] if pairs else None
|
||||
title = pair["title"] if pair is not None else None
|
||||
category = pair["category"] if pair is not None else row["category"]
|
||||
tags, limited = _tags(
|
||||
conn,
|
||||
pair["id"] if pair is not None else row["prompt_id"],
|
||||
pair=pair is not None,
|
||||
recipe_tags, limited = (
|
||||
_tags(conn, pair["id"], pair=True) if pair is not None else ([], False)
|
||||
)
|
||||
positive_tags = []
|
||||
if not recipe_tags and not limited:
|
||||
# D39: only read the positive's tags when they are what the panel shows.
|
||||
positive_tags, limited = _tags(conn, row["prompt_id"], pair=False)
|
||||
metadata = library_metadata(
|
||||
row["category"],
|
||||
positive_tags,
|
||||
(pair["category"], recipe_tags) if pair is not None else None,
|
||||
)
|
||||
limited = limited or any(
|
||||
value is not None and len(value) > MAX_LIBRARY_BYTES
|
||||
for value in (title, category)
|
||||
for value in (title, metadata["category"])
|
||||
)
|
||||
if pair is not None:
|
||||
saved["pair"] = {
|
||||
@@ -73,9 +87,8 @@ def _saved_projection(conn: sqlite3.Connection, row: sqlite3.Row) -> tuple[dict,
|
||||
"positive_id": row["prompt_id"],
|
||||
"pair_id": pair["id"] if pair is not None else None,
|
||||
"title": _display_bytes(title),
|
||||
"category": _display_bytes(category),
|
||||
"tags": tags,
|
||||
"metadata_scope": "pair" if pair is not None else "positive",
|
||||
**metadata,
|
||||
"category": _display_bytes(metadata["category"]),
|
||||
}
|
||||
)
|
||||
if limited:
|
||||
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
import sqlite3
|
||||
from typing import Iterator
|
||||
|
||||
from .execution_contexts import content_digest
|
||||
from .execution_contexts import _hints, _values, content_digest
|
||||
from .execution_history import HistoryEpochChanged
|
||||
from .execution_outputs import _capture, _REFERENCE_COLUMNS
|
||||
from .library import _ELIGIBLE
|
||||
@@ -98,6 +98,8 @@ _ORDER = (
|
||||
"r.observed_dequeue_at DESC,r.session_id COLLATE BINARY DESC,"
|
||||
"r.queue_item_id DESC,r.run_id COLLATE BINARY DESC"
|
||||
)
|
||||
# Stale-hint rows a newest-pair lookup inspects before it reports none.
|
||||
_PAIR_RUN_CANDIDATES = 16
|
||||
_MATCHING = (
|
||||
"SELECT DISTINCT rc.run_id FROM execution_prompt_snapshots s "
|
||||
"JOIN execution_contexts c ON c.positive_snapshot_id=s.snapshot_id "
|
||||
@@ -410,6 +412,62 @@ class HistoryReadOperations:
|
||||
result["context_summaries"] = list(ordered.values())
|
||||
return result
|
||||
|
||||
def get_newest_pair_run(self, positive_id: int, pair_id: int) -> dict | None:
|
||||
"""The newest matching run whose current saved-pair hint names `pair_id`.
|
||||
|
||||
None when the positive is not an eligible saved prompt or the pair is
|
||||
not one of its pairs; ``{"run": None}`` when no recorded run names it.
|
||||
Only a hint captured in this epoch and still resolving to the same pair
|
||||
counts, as in ``get_history_run``.
|
||||
"""
|
||||
conn = self._history_connection()
|
||||
saved = conn.execute(
|
||||
"SELECT CASE WHEN typeof(p.text)='text' AND "
|
||||
"length(CAST(p.text AS BLOB))<=65536 THEN p.text ELSE NULL END AS text "
|
||||
f"FROM prompts p WHERE p.id=? AND ({_ELIGIBLE})",
|
||||
(positive_id,),
|
||||
).fetchone()
|
||||
if saved is None or (
|
||||
conn.execute(
|
||||
"SELECT 1 FROM prompt_pairs WHERE id=? AND positive_prompt_id=?",
|
||||
(pair_id, positive_id),
|
||||
).fetchone()
|
||||
is None
|
||||
):
|
||||
return None
|
||||
if saved["text"] is None:
|
||||
return {"run": None}
|
||||
epoch = self.get_execution_epoch()
|
||||
candidates = conn.execute(
|
||||
"SELECT r.run_id,rc.anchor_id,rc.context_id,"
|
||||
"c.positive_snapshot_id,c.negative_snapshot_id "
|
||||
"FROM execution_runs r JOIN execution_run_contexts rc "
|
||||
"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"ORDER BY {_ORDER},rc.anchor_id COLLATE BINARY LIMIT ?",
|
||||
(
|
||||
content_digest("text", (saved["text"],)),
|
||||
pair_id,
|
||||
epoch,
|
||||
_PAIR_RUN_CANDIDATES,
|
||||
),
|
||||
).fetchall()
|
||||
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"]:
|
||||
continue
|
||||
hints = _hints(conn, positive["raw"], negative["raw"] if negative else None)
|
||||
if hints[2] == pair_id:
|
||||
return {
|
||||
"run": {
|
||||
key: row[key] for key in ("run_id", "anchor_id", "context_id")
|
||||
}
|
||||
}
|
||||
return {"run": None}
|
||||
|
||||
def get_history_run(self, run_id: str) -> dict | None:
|
||||
conn = self._history_connection()
|
||||
run = self.get_execution_run(run_id)
|
||||
|
||||
@@ -137,3 +137,16 @@ class ImageFavoriteOperations:
|
||||
)
|
||||
found.update(path for (path,) in rows)
|
||||
return found
|
||||
|
||||
def get_root_favorites(self, root_key: str) -> set[str]:
|
||||
"""Every relative key favorited under *root_key* (a primary-key range).
|
||||
|
||||
The Favorites listing (``favorites=1``) intersects these with the
|
||||
root's scan, so its size follows the root's favorites, not its files.
|
||||
"""
|
||||
conn = self.model.get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT relative_path FROM image_favorites WHERE root_key=?",
|
||||
(root_key,),
|
||||
)
|
||||
return {path for (path,) in rows}
|
||||
|
||||
+3
-1
@@ -388,7 +388,7 @@ class LibraryOperations:
|
||||
for row in conn.execute(
|
||||
"SELECT pp.id,COALESCE(n.text,'') AS negative_text,"
|
||||
"pp.category,pp.title,pp.archived,"
|
||||
"pp.negative_prompt_id IS NULL AS explicit_empty,"
|
||||
"pp.negative_prompt_id IS NULL AS explicit_empty,pp.run_count,"
|
||||
"(SELECT COUNT(DISTINCT image_id) FROM image_prompt_pairs ip "
|
||||
"WHERE ip.pair_id=pp.id) AS image_count "
|
||||
"FROM prompt_pairs pp "
|
||||
@@ -404,6 +404,8 @@ class LibraryOperations:
|
||||
row["tags"] = []
|
||||
row["archived"] = bool(row["archived"])
|
||||
row["explicit_empty"] = bool(row["explicit_empty"])
|
||||
# The history lane's "N runs" (#249): queue-hook pair uses, 0 when unset.
|
||||
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
||||
if by_id:
|
||||
marks = ",".join("?" for _ in by_id)
|
||||
for row in conn.execute(
|
||||
|
||||
+10
-2
@@ -17,6 +17,7 @@ from typing import Optional, Set, Tuple
|
||||
|
||||
from .library_schema import library_schema_error, migrate_library_state
|
||||
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
|
||||
from .saved_views import migrate_saved_views, saved_views_schema_error
|
||||
from .execution_history_schema import (
|
||||
history_schema_error,
|
||||
migrate_execution_history,
|
||||
@@ -300,6 +301,7 @@ class PromptModel:
|
||||
migrate_execution_history(conn)
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
migrate_saved_views(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
@@ -1019,8 +1021,9 @@ class PromptModel:
|
||||
Args:
|
||||
path: File to inspect
|
||||
require_library_state: Also require the tables every current
|
||||
database has (Library state and image favorites), as a
|
||||
migrated restore stage must; old backups may lack them.
|
||||
database has (Library state, image favorites and saved
|
||||
views), as a migrated restore stage must; old backups may
|
||||
lack them.
|
||||
|
||||
Returns:
|
||||
(ok, reason): reason is "ok" when the file is acceptable, otherwise
|
||||
@@ -1080,6 +1083,11 @@ class PromptModel:
|
||||
)
|
||||
if favorites_error:
|
||||
return False, favorites_error
|
||||
views_error = saved_views_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if views_error:
|
||||
return False, views_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
|
||||
+16
-10
@@ -12,6 +12,7 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .saved_views import SavedViewOperations
|
||||
from .library_commands import LibraryCommandOperations
|
||||
from .vocabulary import VocabularyOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
@@ -184,10 +185,12 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
|
||||
# parameters per path stays under SQLite's historical 999-variable limit.
|
||||
LINK_SUMMARY_BATCH = 200
|
||||
# A pair link counts only when an image has exactly one distinct pair, as in
|
||||
# get_image_prompt_info; its positive, title and metadata then describe the card.
|
||||
# get_image_prompt_info; its positive (text, Library title, category, rating)
|
||||
# then describes the card. The pair's own title names its negative variant, so
|
||||
# it is not read (D10).
|
||||
_LINK_SUMMARY_SQL = """SELECT gi.image_path, gi.file_path, gi.prompt_id,
|
||||
p.text, p.category, p.rating, ls.title,
|
||||
pp.positive_prompt_id, pp.title, pt.text, pt.category, pt.rating, pls.title
|
||||
pp.positive_prompt_id, pt.text, pt.category, pt.rating, pls.title
|
||||
FROM generated_images gi
|
||||
JOIN prompts p ON gi.prompt_id = p.id
|
||||
LEFT JOIN prompt_library_state ls ON ls.prompt_id = p.id
|
||||
@@ -213,15 +216,15 @@ JOIN prompts p ON p.id = COALESCE(pp.positive_prompt_id, gi.prompt_id)
|
||||
WHERE gi.file_path IS NOT NULL AND CASE WHEN {admit} THEN NOT EXISTS (
|
||||
SELECT 1 FROM generated_images other
|
||||
WHERE other.file_path = gi.file_path AND other.id <> gi.id) ELSE 0 END"""
|
||||
# The positive's text, its Library title or one of its tag names, or the pair
|
||||
# title, contains :pattern (LIKE, so ASCII case is ignored as in Library search).
|
||||
_LINK_FILTER_HIT = """(p.id IN (
|
||||
# The positive's text, its Library title or one of its tag names contains
|
||||
# :pattern (LIKE, so ASCII case is ignored as in Library search). A pair's own
|
||||
# title names its negative variant and is never searched (#249 D12).
|
||||
_LINK_FILTER_HIT = """p.id IN (
|
||||
SELECT hp.id FROM prompts hp
|
||||
LEFT JOIN prompt_library_state hls ON hls.prompt_id = hp.id
|
||||
WHERE hp.text LIKE :pattern ESCAPE '\\' OR hls.title LIKE :pattern ESCAPE '\\'
|
||||
OR hp.id IN (SELECT ht.prompt_id FROM prompt_tags ht JOIN tags hn
|
||||
ON hn.id = ht.tag_id WHERE hn.name LIKE :pattern ESCAPE '\\'))
|
||||
OR pp.title LIKE :pattern ESCAPE '\\')"""
|
||||
ON hn.id = ht.tag_id WHERE hn.name LIKE :pattern ESCAPE '\\'))"""
|
||||
_LINK_FILTER_TAG = (
|
||||
"p.id IN (SELECT ft.prompt_id FROM prompt_tags ft JOIN tags fn "
|
||||
"ON fn.id = ft.tag_id WHERE fn.name = :tag{index})"
|
||||
@@ -277,8 +280,9 @@ def _empty_link_summary(status: str) -> Dict[str, Any]:
|
||||
|
||||
def _link_summary(row: tuple) -> Dict[str, Any]:
|
||||
if row[7] is not None:
|
||||
positive, text, category, rating = row[7], row[9], row[10], row[11]
|
||||
titles = (row[8], row[12])
|
||||
positive, text, category, rating = row[7], row[8], row[9], row[10]
|
||||
# The linked positive's Library title (D10/D20).
|
||||
titles = (row[11],)
|
||||
else:
|
||||
positive, text, category, rating = row[2], row[3], row[4], row[5]
|
||||
titles = (row[6],)
|
||||
@@ -303,6 +307,7 @@ class PromptDatabase(
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
SavedViewOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
@@ -2338,7 +2343,8 @@ class PromptDatabase(
|
||||
|
||||
Rows are limited to images whose linked positive has *category* and
|
||||
every tag in *tags* (exact names). ``hit`` says whether *q* occurs in
|
||||
the positive's text, Library title or a tag name, or in the pair title.
|
||||
the positive's text, Library title or a tag name; a pair's own title
|
||||
names its negative variant and is not searched.
|
||||
Without category or tags only hits are returned, unless *every_link*
|
||||
asks for every linked image (the rating sort and the positive count).
|
||||
The positive id is the card's linked positive. One SELECT whose rows
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Persisted saved views (D16): a named Images or Library toolbar state.
|
||||
|
||||
A view's state holds exactly its page's toolbar values and its Refine-this-view
|
||||
refinements, whitelisted per page and stored as compact JSON of at most 4 KB.
|
||||
|
||||
Writes (the API's POST and PATCH) validate strictly. Startup and restore check
|
||||
only the structure (the table, its columns and each row's types, sizes and JSON
|
||||
object), never the value rules: a row saved under older rules must not stop the
|
||||
database from opening. Startup is more tolerant still: a row whose state is not
|
||||
a JSON object is logged by id, left in place and skipped by every read, while
|
||||
restore rejects such a backup. Reads normalize instead, so a stale row opens with its
|
||||
usable values and defaults for the rest. Optional in old backups, required once
|
||||
a database is current (see ``library_schema.py``).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
# The models logger's parent ("prompt_manager.database.models" logs there too).
|
||||
_LOGGER = logging.getLogger("prompt_manager.database.saved_views")
|
||||
|
||||
TABLE = "saved_views"
|
||||
PAGES = ("images", "library")
|
||||
# The schema check's literal SQL repeats the name and state caps (names in
|
||||
# bytes, at most 4 per character); keep them in step.
|
||||
MAX_NAME_LENGTH = 100
|
||||
MAX_STATE_BYTES = 4096
|
||||
MAX_TAGS = 20
|
||||
MAX_TAG_LENGTH = 50
|
||||
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
|
||||
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
|
||||
_MEDIA = ("all", "image", "video")
|
||||
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}")
|
||||
FALLBACK_NAME = "Saved view"
|
||||
|
||||
|
||||
class SavedViewInvalid(ValueError):
|
||||
"""A request's page, name or state breaks the write rules (a 400)."""
|
||||
|
||||
|
||||
# (kind, limit-or-choices, default) per state key; "text" values are
|
||||
# length-capped. The defaults match the client's (saved-views.mjs FIELDS).
|
||||
_STATE_FIELDS = {
|
||||
"images": {
|
||||
"q": ("text", 500, ""),
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _IMAGE_SORTS, "newest"),
|
||||
"source": ("source", None, "all"),
|
||||
"folder": ("text", 1024, ""),
|
||||
"media": ("choice", _MEDIA, "all"),
|
||||
"tags": ("tags", None, ()),
|
||||
# The Favorites page's filter (#249 D51): a view saved there reopens it.
|
||||
"favorites": ("bool", None, False),
|
||||
},
|
||||
"library": {
|
||||
"q": ("text", 500, ""),
|
||||
"category": ("text", 100, ""),
|
||||
"sort": ("choice", _LIBRARY_SORTS, "newest"),
|
||||
"tags": ("tags", None, ()),
|
||||
},
|
||||
}
|
||||
|
||||
_DDL = (
|
||||
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"page TEXT NOT NULL CHECK(page IN ('images','library')), "
|
||||
"name TEXT NOT NULL, state TEXT NOT NULL, position INTEGER NOT NULL, "
|
||||
"created_at TEXT NOT NULL, updated_at TEXT NOT NULL)"
|
||||
)
|
||||
_COLUMNS = (
|
||||
("id", "INTEGER", 0, 1),
|
||||
("page", "TEXT", 1, 0),
|
||||
("name", "TEXT", 1, 0),
|
||||
("state", "TEXT", 1, 0),
|
||||
("position", "INTEGER", 1, 0),
|
||||
("created_at", "TEXT", 1, 0),
|
||||
("updated_at", "TEXT", 1, 0),
|
||||
)
|
||||
_SELECT = "SELECT id, page, name, state FROM saved_views"
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def validate_name(name: object) -> str:
|
||||
"""The trimmed name, 1 to 100 characters without control characters."""
|
||||
if not isinstance(name, str):
|
||||
raise SavedViewInvalid("Name must be a string")
|
||||
trimmed = name.strip()
|
||||
if not 1 <= len(trimmed) <= MAX_NAME_LENGTH:
|
||||
raise SavedViewInvalid(f"Name must be 1 to {MAX_NAME_LENGTH} characters")
|
||||
if _CONTROL.search(trimmed):
|
||||
raise SavedViewInvalid("Name contains control characters")
|
||||
return trimmed
|
||||
|
||||
|
||||
def _validate_value(key: str, kind: str, limit, value: object) -> None:
|
||||
"""Raise ``SavedViewInvalid`` when *value* breaks *key*'s write rule."""
|
||||
if kind == "tags":
|
||||
if not isinstance(value, list) or len(value) > MAX_TAGS:
|
||||
raise SavedViewInvalid(
|
||||
f"State '{key}' must be a list of at most {MAX_TAGS}"
|
||||
)
|
||||
for tag in value:
|
||||
if (
|
||||
not isinstance(tag, str)
|
||||
or not tag.strip()
|
||||
or len(tag) > MAX_TAG_LENGTH
|
||||
or "," in tag
|
||||
or _CONTROL.search(tag)
|
||||
):
|
||||
raise SavedViewInvalid(f"State '{key}' holds an invalid tag")
|
||||
return
|
||||
if kind == "bool":
|
||||
if not isinstance(value, bool):
|
||||
raise SavedViewInvalid(f"State '{key}' must be true or false")
|
||||
return
|
||||
if not isinstance(value, str):
|
||||
raise SavedViewInvalid(f"State '{key}' must be a string")
|
||||
if kind == "choice" and value not in limit:
|
||||
raise SavedViewInvalid(f"State '{key}' is not a supported value")
|
||||
if kind == "source" and not _SOURCE.fullmatch(value):
|
||||
raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index")
|
||||
if kind == "text" and (len(value) > limit or _CONTROL.search(value)):
|
||||
raise SavedViewInvalid(f"State '{key}' is too long or has control characters")
|
||||
|
||||
|
||||
def encode_state(page: object, state: object) -> str:
|
||||
"""Validate *state* for *page* and return its compact JSON (at most 4 KB)."""
|
||||
if page not in PAGES:
|
||||
raise SavedViewInvalid("Page must be 'images' or 'library'")
|
||||
fields = _STATE_FIELDS[page]
|
||||
if not isinstance(state, dict):
|
||||
raise SavedViewInvalid("State must be an object")
|
||||
if set(state) != set(fields):
|
||||
raise SavedViewInvalid(
|
||||
f"State for {page} must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
for key, (kind, limit, _default) in fields.items():
|
||||
_validate_value(key, kind, limit, state[key])
|
||||
encoded = json.dumps(
|
||||
{key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
if len(encoded.encode("utf-8")) > MAX_STATE_BYTES:
|
||||
raise SavedViewInvalid("State must encode to at most 4 KB")
|
||||
return encoded
|
||||
|
||||
|
||||
def _decode_object(raw: object) -> Optional[dict]:
|
||||
"""The JSON object stored in *raw*, or None when it holds no object."""
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
decoded = json.loads(raw)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
return decoded if isinstance(decoded, dict) else None
|
||||
|
||||
|
||||
def normalize_state(page: str, raw: object) -> dict:
|
||||
"""The page's full state from a stored row, never raising.
|
||||
|
||||
Every key of the page's current whitelist is present: a stored value that
|
||||
passes today's write rule is kept, anything else (missing, invalid, or a
|
||||
row that is not a JSON object) takes the key's default, and keys the
|
||||
whitelist no longer has are dropped. An unknown page has no keys.
|
||||
"""
|
||||
fields = _STATE_FIELDS.get(page, {})
|
||||
stored = _decode_object(raw) or {}
|
||||
state = {}
|
||||
for key, (kind, limit, default) in fields.items():
|
||||
value = stored.get(key, default)
|
||||
try:
|
||||
_validate_value(key, kind, limit, value)
|
||||
except SavedViewInvalid:
|
||||
value = default
|
||||
state[key] = list(value) if kind == "tags" else value
|
||||
return state
|
||||
|
||||
|
||||
def normalize_name(name: object) -> str:
|
||||
"""A stored name made displayable: trimmed, capped, never empty."""
|
||||
text = _CONTROL.sub("", name) if isinstance(name, str) else ""
|
||||
trimmed = text.strip()[:MAX_NAME_LENGTH].strip()
|
||||
return trimmed or FALLBACK_NAME
|
||||
|
||||
|
||||
def saved_views_schema_error(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
allow_missing: bool = True,
|
||||
check_states: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Reject unsupported saved-views schemas and structurally invalid rows.
|
||||
|
||||
Reads only. Checks the table, its columns, each row's types and sizes and
|
||||
that each state is a JSON object; never the value rules, which change
|
||||
between versions and are normalized on read instead. A missing table is
|
||||
acceptable for backups that predate it unless ``allow_missing`` is false.
|
||||
``check_states=False`` (live startup) skips the JSON-object check; such
|
||||
rows are reported by ``unusable_view_ids`` and skipped on read.
|
||||
"""
|
||||
objects = conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master WHERE name=? "
|
||||
"OR (type IN ('index','trigger') AND tbl_name=?)",
|
||||
(TABLE, TABLE),
|
||||
).fetchall()
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing saved views schema"
|
||||
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
||||
if len(tables) != 1 or len(objects) != 1:
|
||||
return "Saved views name is reserved by an unsupported schema object"
|
||||
if _compact(tables[0] or "") != _compact(_DDL):
|
||||
return "Saved views has an invalid table definition"
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({TABLE})")
|
||||
)
|
||||
if columns != _COLUMNS:
|
||||
return "Saved views has invalid columns"
|
||||
# Literal SQL: the table name and limits are constants of this module.
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM saved_views WHERE typeof(page) != 'text' "
|
||||
"OR page NOT IN ('images','library') "
|
||||
"OR typeof(name) != 'text' OR typeof(state) != 'text' "
|
||||
"OR typeof(position) != 'integer' OR typeof(created_at) != 'text' "
|
||||
"OR typeof(updated_at) != 'text' "
|
||||
"OR length(CAST(name AS BLOB)) > 400 "
|
||||
"OR length(CAST(state AS BLOB)) > 4096 "
|
||||
"OR length(CAST(created_at AS BLOB)) > 64 "
|
||||
"OR length(CAST(updated_at AS BLOB)) > 64 LIMIT 1"
|
||||
).fetchone():
|
||||
return "Saved views contains invalid values"
|
||||
if check_states and unusable_view_ids(conn):
|
||||
return "Saved views contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def unusable_view_ids(conn: sqlite3.Connection) -> list[int]:
|
||||
"""Ids of rows whose state is not a JSON object (never listed)."""
|
||||
return [
|
||||
view_id
|
||||
for view_id, state in conn.execute("SELECT id, state FROM saved_views")
|
||||
if _decode_object(state) is None
|
||||
]
|
||||
|
||||
|
||||
def migrate_saved_views(conn: sqlite3.Connection) -> None:
|
||||
"""Install the table atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT saved_views_schema")
|
||||
try:
|
||||
error = saved_views_schema_error(conn, check_states=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
error = saved_views_schema_error(conn, allow_missing=False, check_states=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
# A corrupt state never stops the live database from opening: the row
|
||||
# stays (no data is destroyed) and reads skip it. Only its id is logged.
|
||||
for view_id in unusable_view_ids(conn):
|
||||
_LOGGER.warning(
|
||||
"Saved view id %d has an unreadable state and is hidden", view_id
|
||||
)
|
||||
conn.execute("RELEASE saved_views_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO saved_views_schema")
|
||||
conn.execute("RELEASE saved_views_schema")
|
||||
raise
|
||||
|
||||
|
||||
def _view(row) -> dict:
|
||||
view_id, page, name, state = row
|
||||
return {
|
||||
"id": view_id,
|
||||
"page": page,
|
||||
"name": normalize_name(name),
|
||||
"state": normalize_state(page, state),
|
||||
}
|
||||
|
||||
|
||||
class SavedViewOperations:
|
||||
"""PromptDatabase mixin: list, create, rename and delete saved views."""
|
||||
|
||||
def list_saved_views(self) -> list[dict]:
|
||||
"""Every view, in list order (position, then creation)."""
|
||||
rows = self.model.get_connection().execute(_SELECT + " ORDER BY position, id")
|
||||
# A row whose state is no JSON object (logged at startup) is never listed.
|
||||
return [_view(row) for row in rows if _decode_object(row[3]) is not None]
|
||||
|
||||
def create_saved_view(self, page: object, name: object, state: object) -> dict:
|
||||
"""Validate and append one view at the end of the list."""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
encoded = encode_state(page, state)
|
||||
trimmed = validate_name(name)
|
||||
now = utc_now_iso()
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO saved_views (page, name, state, position, "
|
||||
"created_at, updated_at) SELECT ?, ?, ?, "
|
||||
"COALESCE(MAX(position), -1) + 1, ?, ? FROM saved_views",
|
||||
(page, trimmed, encoded, now, now),
|
||||
)
|
||||
row = conn.execute(
|
||||
_SELECT + " WHERE id = ?", (cursor.lastrowid,)
|
||||
).fetchone()
|
||||
return _view(row)
|
||||
|
||||
def rename_saved_view(self, view_id: int, name: object) -> Optional[dict]:
|
||||
"""The renamed view, or None when no view has *view_id*."""
|
||||
from .models import utc_now_iso
|
||||
|
||||
trimmed = validate_name(name)
|
||||
with self.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ?",
|
||||
(trimmed, utc_now_iso(), view_id),
|
||||
)
|
||||
row = conn.execute(_SELECT + " WHERE id = ?", (view_id,)).fetchone()
|
||||
# A hidden (unreadable) view is not found, as in the listing.
|
||||
return _view(row) if row and _decode_object(row[3]) is not None else None
|
||||
|
||||
def delete_saved_view(self, view_id: int) -> bool:
|
||||
"""True when a view was removed."""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
|
||||
return cursor.rowcount > 0
|
||||
@@ -25,6 +25,7 @@ from .prompts import PromptRoutesMixin
|
||||
from .pairs import PairRoutesMixin
|
||||
from .library import LibraryRoutesMixin
|
||||
from .vocabulary import VocabularyRoutesMixin
|
||||
from .saved_views import SavedViewRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .admin import AdminRoutesMixin
|
||||
@@ -248,6 +249,7 @@ class PromptManagerAPI(
|
||||
PairRoutesMixin,
|
||||
LibraryRoutesMixin,
|
||||
VocabularyRoutesMixin,
|
||||
SavedViewRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
@@ -432,6 +434,7 @@ class PromptManagerAPI(
|
||||
self._register_pair_routes(routes)
|
||||
self._register_library_routes(routes)
|
||||
self._register_vocabulary_routes(routes)
|
||||
self._register_saved_view_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
|
||||
@@ -388,6 +388,9 @@ class HistoryRoutesMixin:
|
||||
routes.get("/prompt_manager/library/{positive_id}/history")(
|
||||
self.get_library_history
|
||||
)
|
||||
routes.get(
|
||||
"/prompt_manager/library/{positive_id}/history/pairs/{pair_id}/newest"
|
||||
)(self.get_newest_pair_run)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}")(self.get_history_run)
|
||||
routes.get("/prompt_manager/history/runs/{run_id}/outputs")(
|
||||
self.get_history_outputs
|
||||
@@ -473,6 +476,40 @@ class HistoryRoutesMixin:
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
@_history_errors
|
||||
async def get_newest_pair_run(self, request: web.Request) -> web.Response:
|
||||
"""The pair's newest recorded run for the history run dot (#249 D26)."""
|
||||
_query(request)
|
||||
if request.query:
|
||||
raise ValueError("Invalid newest pair run query")
|
||||
positive_id = _decimal(request.match_info["positive_id"], minimum=1)
|
||||
pair_id = _decimal(request.match_info["pair_id"], minimum=1)
|
||||
fence = self._history_fence()
|
||||
db = self.db
|
||||
|
||||
def project() -> bytes:
|
||||
generation = fence.begin()
|
||||
try:
|
||||
with db.recorded_history_snapshot() as epoch:
|
||||
result = db.get_newest_pair_run(positive_id, pair_id)
|
||||
finally:
|
||||
db.model.close()
|
||||
if result is None:
|
||||
fence.validate(epoch, generation)
|
||||
raise HistoryReadError(404, "not-found")
|
||||
encoded = _serialize_history(
|
||||
{
|
||||
"success": True,
|
||||
"history_version": 1,
|
||||
"history_epoch": epoch,
|
||||
**result,
|
||||
}
|
||||
)
|
||||
fence.validate(epoch, generation)
|
||||
return encoded
|
||||
|
||||
return _success_response(await self._run_in_executor(project))
|
||||
|
||||
@_history_errors
|
||||
async def get_history_run(self, request: web.Request) -> web.Response:
|
||||
_query(request)
|
||||
|
||||
+270
-52
@@ -36,16 +36,38 @@ try:
|
||||
from ...database.models import normalize_image_path
|
||||
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from ...utils.parallel import map_parallel
|
||||
from ...utils.inspection_response import encode_inspection_response
|
||||
from ...utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
)
|
||||
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
from ...utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
except ImportError:
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
from database.library import display_title
|
||||
from database.models import normalize_image_path
|
||||
from utils.card_seed import card_seed, header_params, parsed_positive
|
||||
from utils.parallel import map_parallel
|
||||
from utils.inspection_response import encode_inspection_response
|
||||
from utils.inspection_response import (
|
||||
encode_inspection_response,
|
||||
library_metadata,
|
||||
)
|
||||
from utils.prompt_inspector import MetadataReadError, read_inspection_file
|
||||
from utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
RevealGate,
|
||||
RevealTargetChanged,
|
||||
is_local_request,
|
||||
reveal_executor,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
@@ -522,6 +544,51 @@ def _favorite_target(data: Mapping, roots: list[Path]):
|
||||
return (index, relative), None
|
||||
|
||||
|
||||
def _reveal_target(data: Mapping):
|
||||
"""``((index, scope, relative), None)`` for a reveal body, else an error.
|
||||
|
||||
Only the listing identity is accepted: an int root index, the root's
|
||||
64-hex scope and a bounded root-relative path, never an absolute,
|
||||
drive-qualified or ``..`` path.
|
||||
"""
|
||||
index, scope, relative = (
|
||||
data.get("root_index"),
|
||||
data.get("root_scope"),
|
||||
data.get("path"),
|
||||
)
|
||||
if type(index) is not int or index < 0:
|
||||
return None, bad_request("Invalid gallery root")
|
||||
if not isinstance(scope, str) or re.fullmatch(r"[0-9a-f]{64}", scope) is None:
|
||||
return None, bad_request("Invalid gallery root scope")
|
||||
if (
|
||||
not isinstance(relative, str)
|
||||
or not relative
|
||||
or len(relative) > MAX_FAVORITE_PATH
|
||||
or "\0" in relative
|
||||
or _has_traversal(relative)
|
||||
or re.match(r"^[a-zA-Z]:", relative)
|
||||
):
|
||||
return None, bad_request("path must be a relative media path")
|
||||
return (index, scope, relative), None
|
||||
|
||||
|
||||
def _reveal_not_found():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Image file not found"}, status=404
|
||||
)
|
||||
|
||||
|
||||
def _not_local():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "not-local",
|
||||
"error": "Show in folder works only on the computer running ComfyUI.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
|
||||
# The path module whose case rules key favorites (tests swap in ntpath).
|
||||
_path_flavor = os.path
|
||||
|
||||
@@ -629,7 +696,9 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
``q`` (at most 500 characters) matches the relative path, case-folded, and
|
||||
the linked positive's text, title and tag names. ``category`` is exact,
|
||||
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
|
||||
the linked positive. Raises ValueError for any unknown or oversized value.
|
||||
the linked positive, and ``favorites=1`` keeps only favorites (the
|
||||
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
|
||||
value.
|
||||
"""
|
||||
text = query.get("q", "").strip()
|
||||
folded = text.casefold()
|
||||
@@ -640,8 +709,10 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
tags = sorted(
|
||||
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
||||
)
|
||||
favorites = query.get("favorites", "0")
|
||||
if (
|
||||
len(text) > 500
|
||||
favorites not in ("0", "1")
|
||||
or len(text) > 500
|
||||
or len(folded) > 500
|
||||
or media not in ("all", "image", "video")
|
||||
or order not in WORKSPACE_ORDERS
|
||||
@@ -660,6 +731,7 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
"folder": folder,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"favorites": favorites == "1",
|
||||
}
|
||||
|
||||
|
||||
@@ -669,7 +741,12 @@ _COUNT_OMITTED = object()
|
||||
|
||||
|
||||
def _filtered_page(
|
||||
entries: list, start: int, size: int, filters: dict, links: dict | None
|
||||
entries: list,
|
||||
start: int,
|
||||
size: int,
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
) -> tuple[int, list, int | None]:
|
||||
"""Count the *entries* the filters admit and take *size* from *start* on.
|
||||
|
||||
@@ -678,6 +755,7 @@ def _filtered_page(
|
||||
category or tag filter admits only those, and ``q`` admits a filename match
|
||||
or a linked-positive hit. Returns the total, the page and the number of
|
||||
distinct linked positives among all admitted entries (None without links).
|
||||
*favorites*, when given, admits only those listed paths (``favorites=1``).
|
||||
"""
|
||||
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
||||
members_only = filters["category"] is not None or bool(filters["tags"])
|
||||
@@ -691,6 +769,8 @@ def _filtered_page(
|
||||
continue
|
||||
if media != "all" and (media == "video") != entry.video:
|
||||
continue
|
||||
if favorites is not None and entry.path not in favorites:
|
||||
continue
|
||||
match = None if links is None else links.get(entry.normalized)
|
||||
if members_only and match is None:
|
||||
continue
|
||||
@@ -828,6 +908,11 @@ class ImageRoutesMixin:
|
||||
async def clear_image_favorite_route(request):
|
||||
return await self.update_image_favorite(request, favorite=False)
|
||||
|
||||
# POST, local-only: opens this computer's file manager (D38).
|
||||
@routes.post("/prompt_manager/images/reveal")
|
||||
async def reveal_image_route(request):
|
||||
return await self.reveal_image(request)
|
||||
|
||||
# POST: drops the server-side listing cache and rewalks the output roots.
|
||||
@routes.post("/prompt_manager/gallery/rescan")
|
||||
async def rescan_gallery_route(request):
|
||||
@@ -1074,6 +1159,7 @@ class ImageRoutesMixin:
|
||||
[str(root) for root in output_dirs],
|
||||
filters["category"],
|
||||
filters["tags"],
|
||||
filters["favorites"],
|
||||
]
|
||||
).encode()
|
||||
).hexdigest()
|
||||
@@ -1261,8 +1347,11 @@ class ImageRoutesMixin:
|
||||
start = len(entries) - bisect_left(ascending, after, key=key)
|
||||
else:
|
||||
start = bisect_right(entries, after, key=key)
|
||||
favorites = (
|
||||
self._workspace_favorite_paths(listings) if filters["favorites"] else None
|
||||
)
|
||||
total, page, positive_count = _filtered_page(
|
||||
entries, start, limit + 1, filters, links
|
||||
entries, start, limit + 1, filters, links, favorites
|
||||
)
|
||||
if not needed and after is not None:
|
||||
positive_count = _COUNT_OMITTED
|
||||
@@ -1422,45 +1511,56 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": "Failed to rescan the gallery"}, status=500
|
||||
)
|
||||
|
||||
async def _root_subfolders(self, output_path, include_ancestors):
|
||||
"""The distinct folders holding listed files under one output root."""
|
||||
subfolders = set()
|
||||
for f, _mtime in await self._get_gallery_files(output_path):
|
||||
rel_dir = str(f.relative_to(output_path).parent)
|
||||
if rel_dir and rel_dir != ".":
|
||||
subfolders.add(rel_dir)
|
||||
if include_ancestors:
|
||||
ancestors = set()
|
||||
for folder in subfolders:
|
||||
parts = folder.replace("\\", "/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
ancestors.add("/".join(parts[:i]))
|
||||
subfolders.update(ancestors)
|
||||
return subfolders
|
||||
|
||||
async def get_gallery_subfolders(self, request):
|
||||
"""Get distinct subfolders from gallery output directories."""
|
||||
"""Get distinct subfolders from gallery output directories.
|
||||
|
||||
In workspace mode every root also lists its own folders, so the
|
||||
Refine dialog can offer only the chosen Source's folders.
|
||||
"""
|
||||
try:
|
||||
output_dirs = list(self._get_all_output_dirs())
|
||||
workspace = request.query.get("mode") == "workspace"
|
||||
roots = None
|
||||
include_ancestors = (
|
||||
request.query.get("include_ancestors", "").lower() == "true"
|
||||
)
|
||||
selected = None
|
||||
if workspace:
|
||||
try:
|
||||
selected = _workspace_root(request.query, output_dirs)
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
roots = [
|
||||
{"index": index, "label": _public_path(root)}
|
||||
for index, root in enumerate(output_dirs)
|
||||
]
|
||||
if selected is not None:
|
||||
output_dirs = [output_dirs[selected]]
|
||||
subfolders = set()
|
||||
for output_path in output_dirs:
|
||||
files = await self._get_gallery_files(output_path)
|
||||
for f, _mtime in files:
|
||||
rel_dir = str(f.relative_to(output_path).parent)
|
||||
if rel_dir and rel_dir != ".":
|
||||
subfolders.add(rel_dir)
|
||||
|
||||
include_ancestors = (
|
||||
request.query.get("include_ancestors", "").lower() == "true"
|
||||
)
|
||||
if include_ancestors:
|
||||
ancestors = set()
|
||||
for folder in subfolders:
|
||||
parts = folder.replace("\\", "/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
ancestors.add("/".join(parts[:i]))
|
||||
subfolders.update(ancestors)
|
||||
|
||||
walked = [
|
||||
await self._root_subfolders(output_path, include_ancestors)
|
||||
for output_path in output_dirs
|
||||
]
|
||||
chosen = walked if selected is None else [walked[selected]]
|
||||
subfolders = set().union(*chosen)
|
||||
payload = {"success": True, "subfolders": sorted(subfolders)}
|
||||
if workspace:
|
||||
payload["roots"] = roots
|
||||
payload["roots"] = [
|
||||
{
|
||||
"index": index,
|
||||
"label": _public_path(root),
|
||||
"subfolders": sorted(walked[index]),
|
||||
}
|
||||
for index, root in enumerate(output_dirs)
|
||||
]
|
||||
return web.json_response(payload)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Subfolders error: {e}")
|
||||
@@ -2145,6 +2245,108 @@ class ImageRoutesMixin:
|
||||
}
|
||||
)
|
||||
|
||||
# Swapped by tests and the disposable browser server; never a shell.
|
||||
_reveal_opener = staticmethod(reveal_in_file_manager)
|
||||
# One per process (a class attribute): single flight plus a cooldown.
|
||||
_reveal_gate = RevealGate()
|
||||
|
||||
@staticmethod
|
||||
def _reveal_path(relative: str, root: Path) -> Path | None:
|
||||
"""The real path of a regular media file under *root*, else None."""
|
||||
resolved = ImageRoutesMixin._resolve_client_media_path(relative, [root])
|
||||
if resolved is None or _media_access_error(resolved, [root]) is not None:
|
||||
return None
|
||||
real = Path(os.path.realpath(resolved))
|
||||
return real if real.is_file() and _is_within(real, root) else None
|
||||
|
||||
async def reveal_image(self, request):
|
||||
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
|
||||
|
||||
Local-only (a same-origin browser request from a loopback client, no
|
||||
forwarding header) and JSON-only: the browser must run on the
|
||||
computer whose file manager opens. One reveal at a time (429 busy).
|
||||
"""
|
||||
if not is_local_request(request.remote, request.headers, request.scheme):
|
||||
return _not_local()
|
||||
if request.content_type != "application/json":
|
||||
# A non-JSON POST is a CORS "simple" request with no preflight.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "unsupported-media-type",
|
||||
"error": "Send the request as application/json.",
|
||||
},
|
||||
status=415,
|
||||
)
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
return error
|
||||
target, error = _reveal_target(data)
|
||||
if error is not None:
|
||||
return error
|
||||
index, scope, relative = target
|
||||
roots = list(self._get_all_output_dirs())
|
||||
error = self._gallery_scope_error(
|
||||
{"root_scope": scope, "root": str(index)}, roots
|
||||
)
|
||||
if error is not None:
|
||||
return error
|
||||
root = roots[index]
|
||||
if Path(relative).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _forbidden("Only media files can be shown")
|
||||
gate = self._reveal_gate
|
||||
if not gate.try_enter():
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": "The folder is already opening.",
|
||||
},
|
||||
status=429,
|
||||
)
|
||||
try:
|
||||
return await self._reveal(relative, root)
|
||||
finally:
|
||||
gate.leave()
|
||||
|
||||
async def _reveal(self, relative: str, root: Path):
|
||||
"""Resolve and open, inside the reveal gate."""
|
||||
try:
|
||||
path = await self._run_in_executor(self._reveal_path, relative, root)
|
||||
except (OSError, ValueError) as exc:
|
||||
# ENAMETOOLONG and the like: the client's path is not logged.
|
||||
self.logger.debug(
|
||||
"Show in folder could not resolve: %s", type(exc).__name__
|
||||
)
|
||||
return _reveal_not_found()
|
||||
if path is None:
|
||||
return _reveal_not_found()
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
reveal_executor(), self._reveal_opener, path, root
|
||||
)
|
||||
except RevealTargetChanged:
|
||||
return _reveal_not_found()
|
||||
except OpenerUnavailable:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "unavailable",
|
||||
"error": "No file manager is available on this computer.",
|
||||
},
|
||||
status=501,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The exception text can hold the path: only its type at error
|
||||
# level, the traceback at debug.
|
||||
self.logger.error("Show in folder failed: %s", type(exc).__name__)
|
||||
self.logger.debug("Show in folder failure", exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": "The folder could not be opened."},
|
||||
status=500,
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
def _favorite_listing(self, root: Path, files: list) -> dict[str, str]:
|
||||
"""``{relative key: listing POSIX spelling}`` for one root's scan.
|
||||
|
||||
@@ -2162,6 +2364,25 @@ class ImageRoutesMixin:
|
||||
self._favorite_listing_cache[cache_key] = (files, listing)
|
||||
return listing
|
||||
|
||||
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
||||
"""The listed paths of every favorite in the selected roots (blocking).
|
||||
|
||||
One primary-key read per root (get_root_favorites), matched against the
|
||||
root's cached ``{relative key: listing spelling}``, so the work follows
|
||||
the favorites rather than the files, and a key folds case exactly as
|
||||
the toggle stored it (Windows). A favorite whose file is gone matches
|
||||
nothing. A failed read raises: an unfiltered page must never pass for
|
||||
the favorites.
|
||||
"""
|
||||
paths = set()
|
||||
for _index, root, files in listings:
|
||||
listing = self._favorite_listing(root, files)
|
||||
for key in self.db.get_root_favorites(_favorite_root_key(root)):
|
||||
listed = listing.get(key)
|
||||
if listed is not None:
|
||||
paths.add(root / listed)
|
||||
return frozenset(paths)
|
||||
|
||||
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
|
||||
"""``(root index, listing path)`` of the favorites among *page* entries."""
|
||||
by_root: dict[tuple[int, Path], dict[str, str]] = {}
|
||||
@@ -2285,7 +2506,8 @@ class ImageRoutesMixin:
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
if not isinstance(saved, dict) or not saved:
|
||||
return result
|
||||
@@ -2293,23 +2515,19 @@ class ImageRoutesMixin:
|
||||
result["status"] = "ambiguous"
|
||||
return result
|
||||
pair = saved.get("pair")
|
||||
if isinstance(pair, dict):
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=pair.get("positive_prompt_id"),
|
||||
pair_id=pair.get("id"),
|
||||
category=pair.get("category"),
|
||||
tags=pair.get("tags"),
|
||||
metadata_scope="pair",
|
||||
)
|
||||
else:
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=saved.get("prompt_id"),
|
||||
category=saved.get("category"),
|
||||
tags=saved.get("tags"),
|
||||
metadata_scope="positive",
|
||||
)
|
||||
recipe = isinstance(pair, dict)
|
||||
result.update(
|
||||
status="linked",
|
||||
positive_id=(
|
||||
pair.get("positive_prompt_id") if recipe else saved.get("prompt_id")
|
||||
),
|
||||
pair_id=pair.get("id") if recipe else None,
|
||||
**library_metadata(
|
||||
saved.get("category"),
|
||||
saved.get("tags"),
|
||||
(pair.get("category"), pair.get("tags")) if recipe else None,
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_image_prompt(self, request):
|
||||
|
||||
+43
-5
@@ -22,6 +22,29 @@ MAX_PAGE_OFFSET = 10_000_000
|
||||
# for one giant result set (and silently truncating large libraries).
|
||||
EXPORT_PAGE_SIZE = 1000
|
||||
|
||||
# The most prompts one bulk category request may change.
|
||||
BULK_CATEGORY_MAX_PROMPTS = 1000
|
||||
|
||||
# The largest value SQLite's INTEGER can bind; a larger id raises OverflowError.
|
||||
MAX_SQLITE_INT = 2**63 - 1
|
||||
|
||||
|
||||
def parse_bulk_prompt_ids(value, cap=BULK_CATEGORY_MAX_PROMPTS):
|
||||
"""A bulk request's ``prompt_ids``: distinct positive ints, first-seen order.
|
||||
|
||||
Raises ``ValueError`` unless ``value`` is a non-empty list of at most
|
||||
``cap`` distinct positive integers no larger than SQLite's INTEGER
|
||||
(booleans are not integers here).
|
||||
"""
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("prompt_ids must be a non-empty list")
|
||||
if any(type(pid) is not int or not 1 <= pid <= MAX_SQLITE_INT for pid in value):
|
||||
raise ValueError("prompt_ids must be positive integers")
|
||||
ids = list(dict.fromkeys(value))
|
||||
if len(ids) > cap:
|
||||
raise ValueError(f"At most {cap} prompts can be changed at once")
|
||||
return ids
|
||||
|
||||
|
||||
def parse_page_params(query, default_limit=50, max_limit=MAX_PAGE_LIMIT):
|
||||
"""Return ``(limit, offset)`` from a query mapping, clamped to safe bounds.
|
||||
@@ -1145,14 +1168,29 @@ class PromptRoutesMixin:
|
||||
async def bulk_set_category(self, request):
|
||||
"""Bulk set category for prompts."""
|
||||
try:
|
||||
data = await request.json()
|
||||
prompt_ids = data.get("prompt_ids", [])
|
||||
category = (data.get("category") or "").strip()
|
||||
|
||||
if not prompt_ids:
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Expected a JSON object"}, status=400
|
||||
)
|
||||
if not data.get("prompt_ids"):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "No prompt IDs provided"}, status=400
|
||||
)
|
||||
category = data.get("category")
|
||||
try:
|
||||
prompt_ids = parse_bulk_prompt_ids(data["prompt_ids"])
|
||||
validate_category(category)
|
||||
except ValueError as ve:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(ve)}, status=400
|
||||
)
|
||||
category = (category or "").strip()
|
||||
|
||||
updated_count = await self._run_in_executor(
|
||||
self.db.bulk_set_category, prompt_ids, category
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Saved views (D16): list, create, rename and delete named toolbar states.
|
||||
|
||||
The page, the name and the whitelisted per-page state are validated here
|
||||
(through ``database/saved_views.py``) before anything is written. Only that
|
||||
request validation (``SavedViewInvalid``) is a 400 carrying its message; any
|
||||
other failure, including a ``ValueError`` raised by the database layer, is a
|
||||
logged 500 with a generic message.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ...database.saved_views import SavedViewInvalid, encode_state, validate_name
|
||||
except ImportError:
|
||||
from database.saved_views import SavedViewInvalid, encode_state, validate_name
|
||||
|
||||
_CREATE_FIELDS = frozenset({"page", "name", "state"})
|
||||
_RENAME_FIELDS = frozenset({"name"})
|
||||
_MAX_ID = 2**63 - 1
|
||||
|
||||
|
||||
class SavedViewNotFound(LookupError):
|
||||
pass
|
||||
|
||||
|
||||
def saved_view_response(handler: Callable) -> Callable:
|
||||
"""``{success:false,error}`` with 400, 404 or 500 on every failure."""
|
||||
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except SavedViewNotFound:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Saved view not found"}, status=404
|
||||
)
|
||||
except SavedViewInvalid as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception:
|
||||
self.logger.exception("Saved view operation failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Saved view operation failed"}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
async def _body(request: web.Request, fields: frozenset) -> dict:
|
||||
"""A JSON object whose keys are exactly ``fields``."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError): # JSONDecodeError is a ValueError
|
||||
raise SavedViewInvalid("Expected a JSON object") from None
|
||||
if not isinstance(body, dict):
|
||||
raise SavedViewInvalid("Expected a JSON object")
|
||||
if set(body) != fields:
|
||||
raise SavedViewInvalid(
|
||||
"Request must contain exactly " + ", ".join(sorted(fields))
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
def _view_id(request: web.Request) -> int:
|
||||
raw = request.match_info["view_id"]
|
||||
if not raw.isascii() or not raw.isdigit():
|
||||
raise SavedViewInvalid("Invalid saved view ID")
|
||||
value = int(raw)
|
||||
if not 1 <= value <= _MAX_ID:
|
||||
raise SavedViewInvalid("Invalid saved view ID")
|
||||
return value
|
||||
|
||||
|
||||
class SavedViewRoutesMixin:
|
||||
def _register_saved_view_routes(self, routes: web.RouteTableDef) -> None:
|
||||
routes.get("/prompt_manager/saved-views")(self.list_saved_views)
|
||||
routes.post("/prompt_manager/saved-views")(self.create_saved_view)
|
||||
routes.patch("/prompt_manager/saved-views/{view_id}")(self.rename_saved_view)
|
||||
routes.delete("/prompt_manager/saved-views/{view_id}")(self.delete_saved_view)
|
||||
|
||||
@saved_view_response
|
||||
async def list_saved_views(self, request: web.Request) -> web.Response:
|
||||
views = await self._run_in_executor(self.db.list_saved_views)
|
||||
return web.json_response({"success": True, "views": views})
|
||||
|
||||
@saved_view_response
|
||||
async def create_saved_view(self, request: web.Request) -> web.Response:
|
||||
body = await _body(request, _CREATE_FIELDS)
|
||||
encode_state(body["page"], body["state"])
|
||||
validate_name(body["name"])
|
||||
view = await self._run_in_executor(
|
||||
self.db.create_saved_view, body["page"], body["name"], body["state"]
|
||||
)
|
||||
return web.json_response({"success": True, "view": view}, status=201)
|
||||
|
||||
@saved_view_response
|
||||
async def rename_saved_view(self, request: web.Request) -> web.Response:
|
||||
view_id = _view_id(request)
|
||||
body = await _body(request, _RENAME_FIELDS)
|
||||
validate_name(body["name"])
|
||||
view = await self._run_in_executor(
|
||||
self.db.rename_saved_view, view_id, body["name"]
|
||||
)
|
||||
if view is None:
|
||||
raise SavedViewNotFound
|
||||
return web.json_response({"success": True, "view": view})
|
||||
|
||||
@saved_view_response
|
||||
async def delete_saved_view(self, request: web.Request) -> web.Response:
|
||||
view_id = _view_id(request)
|
||||
if not await self._run_in_executor(self.db.delete_saved_view, view_id):
|
||||
raise SavedViewNotFound
|
||||
return web.json_response({"success": True, "id": view_id})
|
||||
@@ -11,6 +11,66 @@ export const SIZES=Object.freeze([[1600,1100],[390,844]]);
|
||||
export const THEMES=Object.freeze(['dark','light']);
|
||||
export const MOTIONS=Object.freeze(['no-preference','reduce']);
|
||||
|
||||
/** D35: production uses the singular for exactly one; the concept's plurals are hard-coded (app.js 447, 489, 517-521). */
|
||||
export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt')
|
||||
.replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image');
|
||||
|
||||
/**
|
||||
* The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its
|
||||
* hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits
|
||||
* after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack
|
||||
* from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first.
|
||||
*/
|
||||
export function reflow(row,items) {
|
||||
const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0;
|
||||
// The unrounded boxes (`exact`); rounding happens once, on the result.
|
||||
const rowWidth=row.exact?.width??row.width;
|
||||
const exact=item=>item.exact??{left:item.left,width:item.width};
|
||||
const grows=item=>parseFloat(item.flexGrow)>0;
|
||||
const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width;
|
||||
const lines=[];
|
||||
const order=row.items.filter(entry=>entry.shown);
|
||||
for(const item of items.filter(entry=>entry.shown)) {
|
||||
const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0;
|
||||
// The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed.
|
||||
if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]);
|
||||
}
|
||||
const placed=new Map();let top=0;
|
||||
const sameLine=(a,b)=>a.top<b.top+b.height&&b.top<a.top+a.height;
|
||||
const freeOf=line=>rowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1);
|
||||
for(const line of lines) {
|
||||
const free=freeOf(line);
|
||||
const growing=line.filter(grows);
|
||||
const height=Math.max(...line.map(item=>item.height));
|
||||
let x=0,previous=null;
|
||||
for(const item of line) {
|
||||
// A growing item keeps its concept width plus the space its line gained.
|
||||
const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width;
|
||||
// An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing,
|
||||
// moved by however much that neighbour grew; otherwise it follows the previous box and the gap.
|
||||
const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous);
|
||||
const before=previous&&placed.get(previous);
|
||||
const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x;
|
||||
placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)});
|
||||
x=left+width+gap;previous=item;
|
||||
}
|
||||
top+=height+rowGap;
|
||||
}
|
||||
const moved=item=>{
|
||||
const box=placed.get(item);
|
||||
if(!box)return item;
|
||||
const grown=box.width-exact(item).width;
|
||||
return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width),
|
||||
input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}};
|
||||
};
|
||||
return {...row,height:Math.round(top-rowGap),items:items.map(moved)};
|
||||
}
|
||||
|
||||
/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */
|
||||
export function withoutItems(row,isRemoved) {
|
||||
return reflow(row,row.items.filter(item=>!isRemoved(item)));
|
||||
}
|
||||
|
||||
/** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */
|
||||
export async function seedConcept(request,data={}) {
|
||||
const response=await request.post('/__test__/concept/reset',{data});
|
||||
@@ -37,6 +97,8 @@ export async function openProduction(page,route,theme,motion='no-preference') {
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#workspace-main #page-title')).toBeVisible();
|
||||
await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3);
|
||||
// SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture.
|
||||
await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */
|
||||
@@ -88,14 +150,24 @@ export async function mutate(page,{css=null,remove=[]}={}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, panel, library, history,
|
||||
* noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet
|
||||
* (toolbar … history, PRs C–F) are null until that PR adds its measurer here.
|
||||
* 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)
|
||||
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
|
||||
* anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar,
|
||||
* caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary,
|
||||
* negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading.
|
||||
*/
|
||||
export function inventoryOf(page,scope='main') {
|
||||
return page.evaluate(measureInventory,scope);
|
||||
}
|
||||
|
||||
/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */
|
||||
export function stripExact(value) {
|
||||
if(Array.isArray(value))return value.map(stripExact);
|
||||
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)]));
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Runs in the page; self-contained. `scope` selects the page's main element. */
|
||||
export function measureInventory(scope) {
|
||||
const px=value=>Math.round(value);
|
||||
@@ -234,6 +306,335 @@ export function measureInventory(scope) {
|
||||
buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'),
|
||||
...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),toolbar:null,filterRow:null,caption:null,grid:null,cards:null,panel:null,
|
||||
library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
// ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ----
|
||||
const classes=node=>[...node.classList].sort().join(' ');
|
||||
const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)});
|
||||
/** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */
|
||||
function controlOf(node,origin) {
|
||||
const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)};
|
||||
if(!base.shown)return base;
|
||||
const style=getComputedStyle(node),input=node.querySelector('input');
|
||||
// A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either.
|
||||
const buttons=[...node.querySelectorAll(':scope > button')];
|
||||
return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'),
|
||||
role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null,
|
||||
primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin),
|
||||
...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis,
|
||||
icon:iconOf(node),
|
||||
options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null,
|
||||
input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'),
|
||||
...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})),
|
||||
// Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff.
|
||||
exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))};
|
||||
}
|
||||
const gallery=main?.querySelector(':scope > .gallery-layout');
|
||||
const body=gallery?.querySelector(':scope > .gallery-body');
|
||||
const grid=body?.querySelector(':scope > .image-grid');
|
||||
function captionOf(caption) {
|
||||
return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body),
|
||||
// A span's width follows its text, so only its line box is recorded (the D17 date's text differs).
|
||||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span),
|
||||
top:relative(span,caption).top,height:relative(span,caption).height}))};
|
||||
}
|
||||
function gridOf() {
|
||||
const style=getComputedStyle(grid);
|
||||
return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length,
|
||||
...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length};
|
||||
}
|
||||
function cardOf(card) {
|
||||
const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img');
|
||||
const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info');
|
||||
const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions');
|
||||
const h3=copy?.querySelector('h3'),p=copy?.querySelector('p');
|
||||
return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid),
|
||||
exact:{left:rect(card).left-rect(grid).left,top:rect(card).top-rect(grid).top,height:rect(card).height},
|
||||
...edges(card),...css(card,'boxShadow','overflow','display'),
|
||||
art:when(art,()=>({...relative(art,card),...css(art,'position')})),
|
||||
open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})),
|
||||
img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img),
|
||||
...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})),
|
||||
// An overlay item's horizontal margin may be `auto`, whose used value is the free space beside it: it is kept
|
||||
// to the pixel, and the item's place is also recorded as its inset from the overlay's right edge.
|
||||
overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays),
|
||||
...css(overlays,'display','pointerEvents'),...relative(overlays,card),
|
||||
items:[...overlays.children].map(node=>{const item=controlOf(node,card);
|
||||
return item.shown?{...item,marginLeft:`${px(parseFloat(item.marginLeft))}px`,marginRight:`${px(parseFloat(item.marginRight))}px`,
|
||||
rightInset:px(rect(overlays).right-rect(node).right)}:item;})})),
|
||||
info:when(info,()=>({...layout(info),...edges(info),...relative(info,card),
|
||||
// The text and the actions are placed in the card (what is seen), so a centred list row compares by place.
|
||||
// The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines
|
||||
// of 16.5px and 13.5px add up exactly.
|
||||
copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})),
|
||||
h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3),
|
||||
...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})),
|
||||
// `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can
|
||||
// be shown to fit it on one line.
|
||||
p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{
|
||||
const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content';
|
||||
p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'),
|
||||
buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||||
}
|
||||
function continuationOf(continuation) {
|
||||
return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body),
|
||||
items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))};
|
||||
}
|
||||
function panelOf() {
|
||||
const panel=gallery.children[1];
|
||||
// The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here.
|
||||
const {height,...place}=relative(gallery,main);
|
||||
return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place,
|
||||
box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
|
||||
}
|
||||
const continuation=body?.querySelector(':scope > .infinite-continuation');
|
||||
function bulkbarOf(bar) {
|
||||
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
|
||||
}
|
||||
// ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps
|
||||
// them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"),
|
||||
// so every Library box is found by descendant and placed relative to `main` or its own card.
|
||||
// A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point).
|
||||
const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]);
|
||||
function libraryCardOf(card,origin) {
|
||||
const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer');
|
||||
const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags');
|
||||
const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading');
|
||||
const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions');
|
||||
const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight));
|
||||
const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)});
|
||||
return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'),
|
||||
children:[...card.children].map(classes),
|
||||
art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'),
|
||||
items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art),
|
||||
...css(node,'objectFit')}))})),
|
||||
content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})),
|
||||
title:when(title,()=>({...layout(title),...edges(title),...relative(title,content),
|
||||
items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})),
|
||||
section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section),
|
||||
label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})),
|
||||
actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section),
|
||||
buttons:[...group.children].map(node=>controlOf(node,group))})),
|
||||
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})),
|
||||
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})),
|
||||
// A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it).
|
||||
tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width},
|
||||
items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card),
|
||||
// What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out).
|
||||
facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))};
|
||||
}
|
||||
function libraryOf() {
|
||||
const grid=main?.querySelector('.library-grid');
|
||||
if(!grid)return null;
|
||||
const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption');
|
||||
const cards=[...grid.querySelectorAll('.prompt-card')];
|
||||
// Whatever the page shows after the grid, in document order (the concept: nothing).
|
||||
const trailing=[];
|
||||
for(let node=grid;node&&node!==main;node=node.parentElement)
|
||||
for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()});
|
||||
const style=getComputedStyle(grid);
|
||||
return {toolbar:when(toolbar,rowOf),
|
||||
caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main),
|
||||
spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span),
|
||||
top:relative(span,caption).top,height:relative(span,caption).height}))})),
|
||||
grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length},
|
||||
cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing};
|
||||
}
|
||||
// ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds
|
||||
// the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so
|
||||
// each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section.
|
||||
const spoken=node=>node.classList.contains('ca-visually-hidden');
|
||||
const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset');
|
||||
const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height',
|
||||
'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));};
|
||||
// What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`).
|
||||
const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`),
|
||||
spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))});
|
||||
function promptSectionOf(section,origin) {
|
||||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
|
||||
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
|
||||
return {className:classes(section),...relative(section,origin),...edges(section),
|
||||
label:when(label,()=>({text:label.textContent,...font(label)})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})),
|
||||
text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),
|
||||
lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
|
||||
}
|
||||
function panelHeadingOf(heading,origin) {
|
||||
const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions');
|
||||
return {...layout(heading),...edges(heading),...relative(heading,origin),
|
||||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})),
|
||||
note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})),
|
||||
actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))};
|
||||
}
|
||||
function laneOf(lane,timeline) {
|
||||
const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot');
|
||||
return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'),
|
||||
before:pseudo(lane,'::before'),
|
||||
dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})),
|
||||
label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})),
|
||||
run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null};
|
||||
}
|
||||
function timelineOf(timeline,origin) {
|
||||
const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span');
|
||||
return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin),
|
||||
positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'),
|
||||
span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})),
|
||||
lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)),
|
||||
structure:structureOf(timeline)};
|
||||
}
|
||||
function variantsOf(panel) {
|
||||
const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower');
|
||||
return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel),
|
||||
heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)),
|
||||
lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel),
|
||||
items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))};
|
||||
}
|
||||
function compareOf(section) {
|
||||
const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid');
|
||||
return {...edges(section),...relative(section,main),structure:structureOf(section),
|
||||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})),
|
||||
intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})),
|
||||
grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section),
|
||||
items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid),
|
||||
sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})),
|
||||
notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})),
|
||||
added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))};
|
||||
}
|
||||
function historyOf() {
|
||||
const panel=main?.querySelector('section.timeline-panel');
|
||||
if(!panel)return null;
|
||||
const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery');
|
||||
const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown);
|
||||
const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta');
|
||||
const galleryHeading=gallery?.querySelector(':scope > .panel-heading');
|
||||
return {
|
||||
summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary),
|
||||
chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})),
|
||||
sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)),
|
||||
meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary),
|
||||
items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})),
|
||||
variants:variantsOf(panel),
|
||||
compare:when(compare,compareOf),
|
||||
gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main),
|
||||
heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})),
|
||||
};
|
||||
}
|
||||
return {chrome:chromeOf(),heading:headingOf(),
|
||||
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
|
||||
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
|
||||
bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf),
|
||||
caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
|
||||
grid:when(grid,gridOf),
|
||||
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
|
||||
continuation:when(continuation,continuationOf),
|
||||
panel:when(gallery,panelOf),
|
||||
library:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspector (#249 D4): `panel` is the Images side panel's content (`aside#image-inspector`, app.js imageDetails
|
||||
* a400–414) and `viewerDetails` the viewer's full details (`aside#viewer-details`, imageDetails(i, true)), each null
|
||||
* when absent. Both are measured by one in-page function with the concept's selectors in either document.
|
||||
*/
|
||||
export async function inspectorOf(page) {
|
||||
return {panel:await page.evaluate(measureInspector,'#image-inspector'),
|
||||
viewerDetails:await page.evaluate(measureInspector,'#viewer-details')};
|
||||
}
|
||||
|
||||
/** Runs in the page; self-contained. Every box is relative to the measured root. */
|
||||
export function measureInspector(selector) {
|
||||
const root=document.querySelector(selector);
|
||||
if(!root)return null;
|
||||
const px=value=>Math.round(value);
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const shown=node=>Boolean(node)&&node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden';
|
||||
const rect=node=>node.getBoundingClientRect();
|
||||
const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderBottomWidth','borderTopLeftRadius','backgroundColor');
|
||||
const layout=node=>css(node,'display','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap');
|
||||
const box=node=>{const a=rect(node),b=rect(root);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};};
|
||||
const classes=node=>[...node.classList].sort().join(' ');
|
||||
const when=(node,measure)=>node?measure(node):null;
|
||||
// Visible text: without visually hidden prefixes and aria-hidden glyphs, whitespace collapsed.
|
||||
const textOf=(node,skip='.ca-visually-hidden, [aria-hidden="true"]')=>{const copy=node.cloneNode(true);copy.querySelectorAll(skip).forEach(item=>item.remove());
|
||||
return copy.textContent.replace(/\s+/g,' ').trim();};
|
||||
const hiddenOf=node=>[...node.querySelectorAll('.ca-visually-hidden')].map(item=>item.textContent).join('')||null;
|
||||
const nameOf=node=>node.getAttribute('aria-label')??textOf(node,'[aria-hidden="true"]');
|
||||
function iconOf(node) {
|
||||
const svg=node.querySelector(':scope > svg.icon');
|
||||
if(!svg)return null;
|
||||
return {width:px(rect(svg).width),height:px(rect(svg).height),viewBox:svg.getAttribute('viewBox'),hidden:svg.getAttribute('aria-hidden'),
|
||||
d:svg.querySelector('path')?.getAttribute('d')??null,...css(svg,'strokeWidth','color')};
|
||||
}
|
||||
function controlOf(node) {
|
||||
const style=getComputedStyle(node);
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||||
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
|
||||
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
|
||||
}
|
||||
function chipOf(node) {
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
|
||||
minHeight:getComputedStyle(node).minHeight};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
|
||||
}
|
||||
function sectionOf(section) {
|
||||
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label');
|
||||
const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text');
|
||||
return {className:classes(section),...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)})),
|
||||
actions:when(actions,()=>({...layout(actions),...box(actions),buttons:[...actions.children].map(controlOf)})),
|
||||
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 factsOf(list) {
|
||||
const style=getComputedStyle(list);
|
||||
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
|
||||
items:[...list.children].map(item=>{const dt=item.querySelector('dt'),dd=item.querySelector('dd');
|
||||
return {...box(item),dt:when(dt,()=>({text:dt.textContent,...font(dt),...edges(dt)})),dd:when(dd,()=>({text:dd.textContent,...font(dd),...edges(dd)}))};})};
|
||||
}
|
||||
const describe=item=>`${item.localName}${item.classList.length?'.'+[...item.classList].join('.'):''}`;
|
||||
const panel=root.classList.contains('detail-panel');
|
||||
// The panel's `.detail-inner`, the viewer's class-less wrapper (D20). A prompt-block mount point that is
|
||||
// `display: contents` is transparent: its sections count as the wrapper's own children.
|
||||
const inner=root.querySelector(panel?':scope > .detail-inner':':scope > div');
|
||||
const parts=inner?[...inner.children].flatMap(item=>getComputedStyle(item).display==='contents'?[...item.children]:[item]):[];
|
||||
const sections=parts.filter(item=>item.classList.contains('prompt-section'));
|
||||
const firstSection=parts.indexOf(sections[0]);
|
||||
const rows=parts.filter(item=>item.classList.contains('tags'));
|
||||
const heading=parts.find(item=>item.classList.contains('panel-heading')),h2=heading?.querySelector('h2'),format=heading?.querySelector('.chip');
|
||||
const filename=parts.find(item=>item.matches('p.small'));
|
||||
const cover=root.querySelector(':scope > .detail-cover'),footer=root.querySelector(':scope > .detail-footer');
|
||||
// What a closed disclosure holds is not offered (only its summary is), whatever boxes the engine keeps for it.
|
||||
const offered=node=>{const closed=node.closest('details:not([open])');return !closed||node.parentElement===closed&&node.localName==='summary';};
|
||||
const controls=[...root.querySelectorAll('button, a[href], [tabindex]:not([tabindex="-1"]), summary')].filter(node=>shown(node)&&offered(node));
|
||||
return {
|
||||
className:classes(root),label:root.getAttribute('aria-label'),
|
||||
...css(root,'position','top','overflowX','overflowY','maxHeight'),...edges(root),width:px(rect(root).width),height:px(rect(root).height),
|
||||
structure:{outer:[...root.children].map(describe),inner:parts.map(describe)},
|
||||
cover:when(cover,()=>({tag:cover.tagName.toLowerCase(),alt:cover.getAttribute('alt'),...box(cover),...css(cover,'objectFit','display')})),
|
||||
inner:when(inner,()=>({...edges(inner),...box(inner)})),
|
||||
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading),
|
||||
h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...box(h2)})),format:when(format,chipOf)})),
|
||||
filename:when(filename,()=>({text:filename.textContent,...font(filename),...edges(filename),...box(filename)})),
|
||||
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
|
||||
sections:sections.map(sectionOf),
|
||||
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
|
||||
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');
|
||||
return {open:item.open,...edges(item),...box(item),summary:when(summary,()=>({text:summary.textContent,...font(summary),...box(summary)}))};}),
|
||||
footer:when(footer,()=>({...layout(footer),...edges(footer),...box(footer),buttons:[...footer.children].map(controlOf)})),
|
||||
// The viewer's pair send and history follow the wrapper directly (imageDetails(i, true) has no footer).
|
||||
actions:panel?null:[...root.children].filter(item=>item.localName==='button').map(controlOf),
|
||||
controls:controls.map(node=>({tag:node.tagName.toLowerCase(),text:textOf(node),name:nameOf(node),pressed:node.getAttribute('aria-pressed'),
|
||||
primary:node.classList.contains('ca-primary'),minHeight:getComputedStyle(node).minHeight})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import { seedConcept, openConcept, openProduction, SIZES } from './concept-inventory.mjs';
|
||||
// D10/D20: the heading is the linked positive's title (its confirmed first line here), never the pair's
|
||||
// "Inspector recipe", which names the negative variant.
|
||||
const POSITIVE_TITLE='Inspector portrait 猫 😀';
|
||||
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
test.beforeEach(async({page,browserName})=>{await startCoverage(page,browserName);page.inspectorErrors=[];page.on('pageerror',error=>page.inspectorErrors.push(error.message));});
|
||||
@@ -71,7 +75,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
expect(result.metadata.prompt_graph.value['3'].inputs.cfg).toBe('6.5');
|
||||
expect(result.metadata.record_formats).toEqual({ prompt: 'original-text', workflow: 'original-text' });
|
||||
expect(result.file).toMatchObject({ observation: 'current-file', format: 'PNG', width: 720, height: 480 });
|
||||
expect(result.library).toMatchObject({ status: 'linked', metadata_scope: 'pair', title: 'Inspector recipe', category: 'Inspection', tags: ['fixture', 'geometric'] });
|
||||
expect(result.library).toMatchObject({ status: 'linked', category_scope: 'pair', tags_scope: 'pair', title: POSITIVE_TITLE, category: 'Inspection', tags: ['fixture', 'geometric'] });
|
||||
expect((await inspect(collision)).prompts.positive.text).toBe('Input collision');
|
||||
const gallery = await (await request.get(`/prompt_manager/library/${result.library.positive_id}/images?pair_id=${result.library.pair_id}`)).json();
|
||||
expect(gallery.images[0].root_scope).toBe(complete.root_scope);
|
||||
@@ -85,8 +89,10 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
});
|
||||
|
||||
const inspector=page=>page.locator('.image-details');
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
|
||||
const inspector=page=>page.locator('.detail-panel');
|
||||
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
|
||||
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
|
||||
async function fullFixture(page,request) {
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
|
||||
@@ -95,47 +101,257 @@ async function downloadText(page,action) {
|
||||
const [download]=await Promise.all([page.waitForEvent('download'),action.click()]);
|
||||
const fs=await import('node:fs/promises');return {name:download.suggestedFilename(),text:await fs.readFile(await download.path(),'utf8')};
|
||||
}
|
||||
/** The viewer's full details for one file (D19: the card image opens the viewer; D20: only it shows provenance and raw). */
|
||||
async function viewerDetails(page,name,root=0) {
|
||||
await page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-open`).click();
|
||||
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();return details;
|
||||
}
|
||||
/** Rewrites the next inspection reads; `null` passes them through. */
|
||||
async function patchInspection(page) {
|
||||
const state={patch:null};
|
||||
await page.route('**/images/inspect/**',async route=>{
|
||||
if (!state.patch) return route.continue();
|
||||
const response=await route.fetch();const data=await response.json();state.patch(data);await route.fulfill({response,json:data});
|
||||
});
|
||||
return state;
|
||||
}
|
||||
/** The side panel's concept structure (app.js imageDetails a400–414): element, class and visible text, in order. */
|
||||
const panelStructure=panel=>panel.evaluate(node=>{
|
||||
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
|
||||
const inner=node.querySelector(':scope > .detail-inner');
|
||||
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
|
||||
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
|
||||
});
|
||||
|
||||
test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(panel).toHaveAttribute('id','image-inspector');await expect(panel).toHaveClass('detail-panel file-inspector');
|
||||
// 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']});
|
||||
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');
|
||||
const badges=panel.locator('.detail-inner > .tags').first();
|
||||
await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection');
|
||||
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);
|
||||
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
const footer=panel.locator('.detail-footer');
|
||||
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
|
||||
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
|
||||
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
|
||||
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Execution recorded');
|
||||
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
|
||||
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
|
||||
// Unlinked: no badge, category, tags, pair or history.
|
||||
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
|
||||
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
// Ambiguous saved association: the same honest absence.
|
||||
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.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();
|
||||
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']);
|
||||
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);
|
||||
// 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'}}));
|
||||
await selectFile(page,'malformed.png');
|
||||
await expect(panel.locator('.detail-inner > p.small[role="alert"]')).toHaveText('Could not read prompts from this file.');
|
||||
await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']);
|
||||
});
|
||||
|
||||
test('tag chip filters Images by that tag',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 expect(await request.post('/prompt_manager/prompts/tags',{data:{prompt_id:id,tags:['geometric']}})).toBeOK();
|
||||
// workflow-only.png is linked to the positive alone, so its chips are the positive's tags, which the filter matches.
|
||||
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();
|
||||
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();
|
||||
await expect(page.locator('article[data-image-key="0:inspector/empty.png"]')).toHaveCount(0);
|
||||
// #249 D44: the chip's Tags filter shows on the toolbar's Filters button.
|
||||
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
await expect(page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('geometric');
|
||||
});
|
||||
|
||||
test('pair send and history appear only with real roles and link',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await panel.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'explicit-empty.png');
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
await expect(panel.locator('details,summary,pre,a')).toHaveCount(0);
|
||||
await expect(panel.getByRole('button',{name:/Download|Open full preview/})).toHaveCount(0);
|
||||
await expect(panel).not.toContainText(/File & provenance|Workflow & raw metadata|Download/);
|
||||
// The viewer keeps them until its own full details (D3).
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await details.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(details.getByRole('button',{name:'Download metadata JSON',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
/** The viewer's full details (app.js imageDetails(i, true)): its children, and the class-less wrapper's in order. */
|
||||
const viewerStructure=details=>details.evaluate(node=>{
|
||||
const describe=item=>`${item.localName}${item.className?'.'+item.className.trim().split(/\s+/).join('.'):''}`;
|
||||
const inner=node.querySelector(':scope > div');
|
||||
const flatten=item=>item.classList.contains('prompt-inspector')?[...item.children].map(describe):[describe(item)];
|
||||
return {outer:[...node.children].map(describe),inner:inner?[...inner.children].flatMap(flatten):null};
|
||||
});
|
||||
|
||||
test('viewer details hold provenance, raw records and downloads',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
|
||||
// The panel content without the cover, then the two disclosures, then the pair send and history buttons (D20).
|
||||
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','details','details']});
|
||||
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.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
|
||||
const provenance=details.locator('details').first();await provenance.locator('summary').click();
|
||||
await expect(provenance.locator('p.small').first()).toHaveText('Current file at this source; historical bytes are not verified.');
|
||||
await expect(provenance.locator('dl.meta-grid dt')).toHaveText(['Source','Path','Size','Dimensions','Format','Media type','Modified']);
|
||||
// D38: the Path value stays text, with "Show in folder" right after it.
|
||||
await expect(provenance.locator('dl.meta-grid dd').nth(1)).toHaveText('inspector/complete.png Show in folder');
|
||||
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/);
|
||||
// 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();
|
||||
for (const name of ['Download metadata JSON','Download workflow JSON']) await expect(raw.getByRole('button',{name,exact:true})).toBeVisible();
|
||||
// D21 wording for execution and embedded parameters; an unverified format names its extension without a dot.
|
||||
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'explicit-empty.png');await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details.getByText('Prompt evidence: execution recorded',{exact:true})).toBeVisible();
|
||||
reads.patch=data=>{data.prompts={positive:{status:'resolved',text:'embedded positive',source:'embedded-parameters'},negative:{status:'resolved',text:'embedded negative',source:'embedded-parameters'}};data.file.format=null;};
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'ambiguous.png');await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
|
||||
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
|
||||
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
|
||||
// No badge, no line; no resolved pair or link, no buttons after the details.
|
||||
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
|
||||
expect((await viewerStructure(details)).outer).toEqual(['div']);
|
||||
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
|
||||
// The history button leaves the viewer for the prompt's history.
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
|
||||
await details.getByRole('button',{name:'Prompt history',exact:true}).click();await expect(page).toHaveURL(/#\/library\/\d+$/);
|
||||
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// D38 Show in folder: a listed file's Path offers it; the stubbed opener records what the route resolved.
|
||||
test('Show in folder asks the server to open the listed file and announces each outcome',async({page,request})=>{
|
||||
await fullFixture(page,request);
|
||||
await expect(inspector(page).getByRole('button',{name:/in folder$/})).toHaveCount(0);
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
const provenance=details.locator('details').first();await provenance.locator('summary').click();
|
||||
const path=provenance.locator('dl.meta-grid > div').nth(1);await expect(path.locator('dt')).toHaveText('Path');
|
||||
const show=path.locator('dd > button');
|
||||
await expect(show).toHaveText('Show in folder');await expect(show).toHaveAccessibleName('Show complete.png in folder');
|
||||
const toast=page.locator('#workspace-toast');
|
||||
const [sent]=await Promise.all([page.waitForRequest(entry=>entry.url().endsWith('/prompt_manager/images/reveal')),show.click()]);
|
||||
expect(sent.method()).toBe('POST');
|
||||
// The route takes only a same-origin JSON request, as a real browser sends it.
|
||||
const sentHeaders=await sent.allHeaders();
|
||||
expect(sentHeaders.origin).toBe(new URL(page.url()).origin);expect(sentHeaders['content-type']).toBe('application/json');
|
||||
const body=sent.postDataJSON();
|
||||
expect(Object.keys(body).sort()).toEqual(['path','root_index','root_scope']);
|
||||
expect(body).toMatchObject({root_index:0,path:'inspector/complete.png'});expect(body.root_scope).toMatch(/^[0-9a-f]{64}$/);
|
||||
await expect(toast).toHaveText('Opened the folder on this computer.');
|
||||
const opened=async()=>(await (await request.get('/__test__/reveal')).json()).opened;
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
for (const [outcome,message] of [['unavailable','No file manager is available on this computer.'],['fail','The folder could not be opened. Try again.']]) {
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome}})).toBeOK();
|
||||
await show.click();await expect(toast).toHaveText(message);
|
||||
}
|
||||
// A reveal already in progress answers 429 busy.
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.fulfill({status:429,contentType:'application/json',body:JSON.stringify({success:false,code:'busy',error:'The folder is already opening.'})}));
|
||||
await show.click();await expect(toast).toHaveText('The folder is already opening.');
|
||||
await page.unroute('**/prompt_manager/images/reveal');
|
||||
// A proxied request counts as remote, so the real route answers 403 not-local.
|
||||
await expect(await request.post('/__test__/reveal',{data:{outcome:'opened'}})).toBeOK();
|
||||
await page.route('**/prompt_manager/images/reveal',route=>route.continue({headers:{...route.request().headers(),'x-forwarded-for':'203.0.113.7'}}));
|
||||
await show.click();await expect(toast).toHaveText('Show in folder works only in a browser on the computer running ComfyUI.');
|
||||
expect(await opened()).toEqual(['output/inspector/complete.png']);
|
||||
});
|
||||
test('full inspector shows six facts and scoped provenance',async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);const panel=inspector(page);
|
||||
await expect(panel.getByRole('heading',{name:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.locator('.file-name')).toHaveText('complete.png');
|
||||
await expect(panel.locator('.file-evidence')).toHaveText('Workflow prompt evidence');
|
||||
await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
|
||||
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
|
||||
expect(await panel.locator('.detail-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
|
||||
if (!await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
const copy=await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).boundingBox();expect(copy.width).toBe(30);expect(copy.height).toBe(26);
|
||||
expect((await panel.getByRole('button',{name:'Send prompt pair',exact:true}).boundingBox()).height).toBe(30);
|
||||
// The concept's prompt actions (`.prompt-actions button.icon-btn`) measure 31×31 in the mock.
|
||||
const copy=await panel.getByRole('button',{name:/^Copy positive prompt: /}).boundingBox();expect(copy.width).toBe(31);expect(copy.height).toBe(31);
|
||||
}
|
||||
await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel).toContainText('Recipe tags');await expect(panel).toContainText('Recipe category');
|
||||
await expect(panel.locator('.file-vocabulary button')).toHaveCount(0);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(panel).toContainText('Current file at this source; historical bytes are not verified.');await expect(panel).toContainText('Saved association; not verified against current file');
|
||||
await expect(panel).toContainText('Configuration evidence');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveAttribute('href',/^#\/library\/\d+$/);
|
||||
await expect(panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true})).toBeVisible();
|
||||
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']);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.prompt-section.negative p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(1);
|
||||
// Provenance lives in the viewer's full details (D20): one "Prompt evidence" line replaces the per-fact source lines.
|
||||
const details=await viewerDetails(page,'complete.png');
|
||||
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');await expect(details).toContainText('Recipe category: Inspection');
|
||||
await details.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(details).toContainText('Current file at this source; historical bytes are not verified.');await expect(details).toContainText('Saved association; not verified against current file');
|
||||
await expect(details.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
|
||||
await expect(details).not.toContainText(/Configuration evidence|Embedded API configuration|Saved workflow configuration/);
|
||||
await expect(details.getByRole('button',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
if (testInfo.project.name === 'chromium') {
|
||||
await panel.getByText('Seed: Configuration evidence · Embedded API configuration',{exact:true}).scrollIntoViewIfNeeded();
|
||||
const path=testInfo.outputPath('configuration-api-source.png');await page.screenshot({path});await testInfo.attach('configuration-api-source',{path,contentType:'image/png'});
|
||||
}
|
||||
await expect(panel).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.file-format')).toHaveText('PNG');await expect(panel.getByRole('heading',{name:'empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);await expect(panel).toContainText('Not recorded in this file.');await expect(panel.getByRole('link',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'explicit-empty.png');await expect(panel).toContainText('Empty negative prompt');await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(1);
|
||||
await selectFile(page,'workflow-only.png');await panel.getByText('File & provenance',{exact:true}).click();
|
||||
await expect(panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true})).toBeVisible();
|
||||
if (testInfo.project.name === 'chromium') {
|
||||
await panel.getByText('Seed: Configuration evidence · Saved workflow configuration',{exact:true}).scrollIntoViewIfNeeded();
|
||||
const path=testInfo.outputPath('configuration-workflow-source.png');await page.screenshot({path});await testInfo.attach('configuration-workflow-source',{path,contentType:'image/png'});
|
||||
await details.getByText('Prompt evidence: workflow inferred',{exact:true}).scrollIntoViewIfNeeded();
|
||||
const path=testInfo.outputPath('prompt-evidence.png');await page.screenshot({path});await testInfo.attach('prompt-evidence',{path,contentType:'image/png'});
|
||||
}
|
||||
await expect(details).not.toContainText(/Run:|Original file unchanged|Execution recorded/);
|
||||
});
|
||||
|
||||
test('full inspector preserves long role text and exact sends',async({page,request,context})=>{
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');const target={id:'pair',label:'Exact pair',type:'PromptManagerPair',roles:['positive','negative']};window.sent=[];window.bridge=createBridgeHost({origin:location.origin,namespace:'/',label:'Exact host',adapter:{listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:'',after}))},snapshot:roles}),apply:roles=>{window.sent.push(roles);return{targetId:target.id,values:roles};}}});});
|
||||
try {
|
||||
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
||||
await panel.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await fullFixture(page,request);const panel=inspector(page);const positive=panel.locator('.prompt-section.positive .prompt-text');await expect(positive).toContainText('猫 😀');const text=await positive.textContent();expect(text.length).toBeGreaterThan(1000);
|
||||
await panel.locator('.detail-footer button.send').click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await expect(sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).locator('option')).toContainText(['Exact host']);
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Exact host'});await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Exact pair'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
@@ -144,14 +360,14 @@ test('full inspector preserves long role text and exact sends',async({page,reque
|
||||
});
|
||||
|
||||
test('full inspector downloads actual bounded evidence',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=inspector(page);await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await fullFixture(page,request);let panel=await viewerDetails(page,'complete.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
const list=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector')).json();const image=list.images.find(image=>image.root_index===0&&image.path==='inspector/complete.png');
|
||||
const result=await(await request.get(`/prompt_manager/images/inspect/inspector/complete.png?root=0&root_scope=${image.root_scope}`)).json();
|
||||
await expect(panel).toContainText('Original embedded text');const metadata=await downloadText(page,panel.getByRole('button',{name:'Download metadata JSON',exact:true}));expect(metadata.name).toBe('complete.metadata.json');expect(JSON.parse(metadata.text).metadata.raw).toEqual(result.metadata.raw);expect(JSON.parse(metadata.text).identity.root_scope).toBe(image.root_scope);
|
||||
const workflow=await downloadText(page,panel.getByRole('button',{name:'Download workflow JSON',exact:true}));expect(workflow.name).toBe('complete.workflow.json');expect(workflow.text).toBe(result.metadata.raw.workflow);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',image.url);
|
||||
await selectFile(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await selectFile(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
|
||||
await page.keyboard.press('Escape');panel=await viewerDetails(page,'malformed.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:'{broken'})).toHaveText('{broken');await expect(panel.getByRole('button',{name:'Download workflow JSON',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await page.keyboard.press('Escape');panel=await viewerDetails(page,'control-heavy.png');await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel).toContainText('Raw metadata exceeds the response limit. Download the original file to inspect it externally.');
|
||||
let downloads=0;page.on('download',()=>downloads++);for(const name of ['Download metadata JSON','Download workflow JSON']) {const button=panel.getByRole('button',{name,exact:true});await expect(button).toHaveAttribute('aria-disabled','true');await button.focus();await page.keyboard.press('Enter');}expect(downloads).toBe(0);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();await expect(panel.getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=0&root_scope=[a-f0-9]{64}/);
|
||||
});
|
||||
@@ -159,9 +375,9 @@ test('full inspector downloads actual bounded evidence',async({page,request})=>{
|
||||
test('full inspector labels a limited workflow record without hiding prompt facts',async({page,request})=>{
|
||||
// The server marks a workflow over its 4 MiB read budget `limited`: no raw text, a limited graph.
|
||||
await page.route('**/images/inspect/inspector/complete.png?*',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.record_formats.workflow='limited';delete data.metadata.raw.workflow;data.metadata.workflow_graph={status:'limited',value:null};await route.fulfill({response,json:data});});
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await expect(panel.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
|
||||
await fullFixture(page,request);const panel=await viewerDetails(page,'complete.png');
|
||||
await expect(panel.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(panel.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeVisible();
|
||||
await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(panel.getByText('Record exceeds inspection limits; text is unavailable',{exact:true})).toBeVisible();await expect(panel.locator('pre code')).toHaveCount(1);
|
||||
const workflow=panel.getByRole('button',{name:'Download workflow JSON',exact:true});await expect(workflow).toHaveAttribute('aria-disabled','true');
|
||||
@@ -173,8 +389,8 @@ test('full inspector labels a limited workflow record without hiding prompt fact
|
||||
|
||||
test('full inspector rejects stale shared action state',async({page,request})=>{
|
||||
let reads=0,stale=false;await page.route('**/images/inspect/**',route=>{reads++;return stale?route.fulfill({status:409,json:{success:false,code:'source-changed',error:'Source changed'}}):route.continue();});
|
||||
await fullFixture(page,request);const panel=inspector(page);await expect(panel.getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();const before=reads;
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(preview.locator('.generation-facts dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.detail-footer button.send')).toBeVisible();const before=reads;
|
||||
await openSelected(page);const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.meta-grid dd').first()).toHaveText('9007199254740993');expect(reads).toBe(before);await preview.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
stale=true;await selectFile(page,'complete.png',1);await expect(panel).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
await expect(panel.getByRole('button',{name:/Retry/})).toHaveCount(0);await expect(panel.locator('[data-copy-role],[data-send-role],a[download]')).toHaveCount(0);await expect(panel.getByRole('button',{name:/Download/})).toHaveCount(0);
|
||||
stale=false;const refreshed=page.waitForResponse(response=>response.url().includes('/images/output?'));await panel.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await selectFile(page,'complete.png',1);await expect(panel).toContainText('Input collision');
|
||||
@@ -186,15 +402,32 @@ test('full inspector keeps long literal content reachable on mobile',async({page
|
||||
const literal='<svg onload="window.executed=true"><image href="https://invalid.test/never"/></svg></pre>'+'verylongmodelpath/'.repeat(30);
|
||||
let external=0;await page.route('https://invalid.test/**',route=>{external++;return route.abort();});
|
||||
await page.route('**/images/inspect/**',async route=>{const response=await route.fetch();const data=await response.json();data.metadata.raw.parameters=literal;data.metadata.record_formats.parameters='original-text';data.metadata.parameters=literal;data.metadata.prompt_graph.value['4'].inputs.ckpt_name=literal;await route.fulfill({response,json:data});});
|
||||
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-positive pre')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(panel).toContainText('Copy unavailable. Select the prompt text to copy it.');
|
||||
await panel.getByText('Workflow & raw metadata',{exact:true}).click();await expect(panel.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);await expect(panel.locator('.generation-facts dd').last()).toHaveText(literal);
|
||||
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.getByRole('button',{name:'Send prompt pair',exact:true});await footer.focus();await expect(footer).toBeFocused();const summary=panel.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(panel).toContainText('historical bytes are not verified');}
|
||||
await page.setViewportSize({width:390,height:844});await fullFixture(page,request);const panel=inspector(page);await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('fine geometry and light.');await panel.getByRole('button',{name:/^Copy positive prompt: /}).click();const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback.getByRole('textbox')).toHaveValue(/^Inspector portrait 猫 😀\nFull prompt context, fine geometry and light\./);await page.keyboard.press('Escape');await expect(fallback).toBeHidden();
|
||||
await expect(panel.locator('.meta-grid dd').last()).toHaveText(literal);
|
||||
for(const zoom of [1,2]) {await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);const footer=panel.locator('.detail-footer button.send');await footer.focus();await expect(footer).toBeFocused();}
|
||||
await page.evaluate(()=>{document.documentElement.style.zoom='1';});
|
||||
const details=await viewerDetails(page,'complete.png');await details.getByText('Workflow & raw metadata',{exact:true}).click();await expect(details.locator('pre code').filter({hasText:literal}).last()).toHaveText(literal);
|
||||
const summary=details.getByText('File & provenance',{exact:true});await summary.focus();await page.keyboard.press('Enter');await expect(details).toContainText('historical bytes are not verified');
|
||||
expect(await page.evaluate(()=>window.executed)).toBeUndefined();expect(external).toBe(0);
|
||||
});
|
||||
|
||||
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:'Inspector recipe',exact:true})).toBeVisible();await expect(panel.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel).toContainText('Recipe tags');await panel.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('.image-preview .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.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);
|
||||
});
|
||||
|
||||
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',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');await expect(inspector(page).getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();
|
||||
await openSelected(page);const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
const heading=page.locator('#workspace-main h1');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
|
||||
await viewer.locator('#viewer-details').getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);await expect(page).toHaveURL(new RegExp(`#/library/${id}$`));
|
||||
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
|
||||
// The side panel's Prompt history does the same.
|
||||
await selectFile(page,'complete.png');await heading.evaluate(node=>{node.dataset.beforeHistory='true';});
|
||||
await inspector(page).getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect(page.locator('#workspace-main h1[data-before-history]')).toHaveCount(0);await expect(heading).toBeFocused();
|
||||
});
|
||||
|
||||
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
|
||||
@@ -226,12 +459,12 @@ test('full inspector labels container representations and releases action URLs',
|
||||
|
||||
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-open').click();await expect(panel.locator('.generation-facts dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'control-heavy.png');
|
||||
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
|
||||
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
|
||||
for(const value of await panel.locator('.generation-facts dd').all())await expect(value).toHaveAccessibleDescription(explanation);
|
||||
for(const value of await panel.locator('.meta-grid dd').all())await expect(value).toHaveAccessibleDescription(explanation);
|
||||
await page.evaluate(async()=>{
|
||||
const {mountFileInspector}=await import('/prompt_manager/js/workspace/file-inspector.mjs');
|
||||
const {toInspectionRecord}=await import('/prompt_manager/js/workspace/inspection-state.mjs');
|
||||
@@ -240,7 +473,7 @@ test('full inspector distinguishes missing, limited and legacy unknown generatio
|
||||
window.legacyFactsScope=createScope();window.legacyFactsView=mountFileInspector(container,{parentScope:window.legacyFactsScope});
|
||||
window.legacyFactsView.setState({record:toInspectionRecord({prompts:{}},{root_index:0,path:'legacy.png'})});
|
||||
});
|
||||
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.generation-facts dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.generation-facts dd[aria-describedby]')).toHaveCount(0);
|
||||
const legacy=page.locator('#legacy-facts');await expect(legacy.locator('.meta-grid').first().locator('dd')).toHaveText(Array(6).fill('Unresolved'));await expect(legacy).not.toContainText(explanation);await expect(legacy.locator('.meta-grid dd[aria-describedby]')).toHaveCount(0);
|
||||
await page.evaluate(()=>{window.legacyFactsView.dispose();window.legacyFactsScope.dispose();});
|
||||
});
|
||||
|
||||
@@ -248,49 +481,126 @@ test.describe('full inspector coarse pointer',()=>{
|
||||
test.use({hasTouch:true,viewport:{width:390,height:844}});
|
||||
test('full inspector meets coarse target sizes and keeps long content reachable at zoom',async({page,request})=>{
|
||||
await fullFixture(page,request);expect(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)).toBe(true);const panel=inspector(page);
|
||||
await expect(panel.locator('.prompt-positive pre')).toContainText('Full prompt context, fine geometry and light.');
|
||||
const fullText=await panel.locator('.prompt-positive pre').textContent();expect(fullText.length).toBeGreaterThan(1000);
|
||||
await panel.getByText('File & provenance',{exact:true}).click();await panel.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toContainText('Full prompt context, fine geometry and light.');
|
||||
const fullText=await panel.locator('.prompt-section.positive p.prompt-text').textContent();expect(fullText.length).toBeGreaterThan(1000);
|
||||
const bounds=await panel.locator('button,a,summary').evaluateAll(nodes=>nodes.map(node=>({name:node.getAttribute('aria-label')||node.textContent,role:node.matches('.prompt-actions button'),width:node.getBoundingClientRect().width,height:node.getBoundingClientRect().height})));
|
||||
for(const target of bounds){expect(target.width,target.name).toBeGreaterThanOrEqual(target.role?36:40);expect(target.height,target.name).toBeGreaterThanOrEqual(target.role?36:40);}
|
||||
for(const zoom of [1,2]) {
|
||||
await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom);
|
||||
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
for(const target of [panel.locator('summary').first(),panel.getByRole('button',{name:'Download metadata JSON',exact:true}),panel.getByRole('link',{name:'Prompt history',exact:true})]) {
|
||||
for(const target of [panel.locator('.detail-footer button.send'),panel.getByRole('button',{name:'Prompt history',exact:true})]) {
|
||||
await target.scrollIntoViewIfNeeded();await target.focus();await expect(target).toBeFocused();await expect(target).toBeInViewport();
|
||||
}
|
||||
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
|
||||
await expect(panel.locator('.prompt-section.positive p.prompt-text')).toHaveText(fullText);
|
||||
}
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
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('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) {
|
||||
for(const target of await viewer.locator('#viewer-toolbar > :is(button,a),.dialog-header > button').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);
|
||||
}
|
||||
const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport();
|
||||
// #249 D34: the viewer details' disclosure summaries and "Download original" keep 40px coarse targets as well.
|
||||
const rail=viewer.locator('#viewer-details');await rail.getByText('File & provenance',{exact:true}).click();await expect(rail.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
|
||||
const details=await rail.locator(':is(summary,a)').evaluateAll(nodes=>nodes.map(node=>{const {width,height}=node.getBoundingClientRect();return {name:node.textContent,width,height};}));
|
||||
expect(details.map(target=>target.name)).toEqual(['File & provenance','Download original','Workflow & raw metadata']);
|
||||
for(const target of details){expect(target.width,target.name).toBeGreaterThanOrEqual(40);expect(target.height,target.name).toBeGreaterThanOrEqual(40);}
|
||||
});
|
||||
});
|
||||
|
||||
const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
const mediaDialog=page=>page.locator('dialog#viewer-dialog');
|
||||
const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true});
|
||||
const position=page=>mediaDialog(page).locator('.image-preview-position');
|
||||
const position=page=>mediaDialog(page).locator('.media-position');
|
||||
async function openCollection(page) {
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await openSelected(page);
|
||||
await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
|
||||
// #249 C6: the concept viewer shell (h84–103, a889–926, s1008–1058, ≤700 s1456–1474), measured by one function in the
|
||||
// concept and in production. #viewer-stage and #viewer-details content is not part of the shell (C8 owns the details).
|
||||
const VIEWER_SHELL=()=>{
|
||||
const dialog=document.querySelector('dialog#viewer-dialog');
|
||||
const describe=node=>`${node.tagName.toLowerCase()}${node.id?`#${node.id}`:''}${[...node.classList].sort().map(value=>`.${value}`).join('')}`;
|
||||
const paths=[];
|
||||
const walk=(node,prefix)=>{for(const child of node.children){const path=`${prefix} > ${describe(child)}`;paths.push(path);if(!child.matches('#viewer-stage,#viewer-toolbar,#viewer-details'))walk(child,path);}};
|
||||
walk(dialog,describe(dialog));
|
||||
const box=node=>{const bounds=node.getBoundingClientRect();return [bounds.x,bounds.y,bounds.width,bounds.height].map(Math.round);};
|
||||
const toolbar=dialog.querySelector('#viewer-toolbar');
|
||||
const part=selector=>dialog.querySelector(selector);
|
||||
return {open:dialog.open,labelledBy:dialog.getAttribute('aria-labelledby'),paths,
|
||||
title:part('#viewer-title').textContent.trim(),subtitle:part('.dialog-header > div > p').textContent.trim(),
|
||||
close:{text:part('#viewer-close').textContent.trim(),label:part('#viewer-close').getAttribute('aria-label')},
|
||||
details:part('#viewer-details').getAttribute('aria-label'),
|
||||
toolbar:[...toolbar.children].map(node=>({tag:node.tagName.toLowerCase(),text:node.textContent.trim(),size:box(node).slice(2)})),
|
||||
geometry:Object.fromEntries(['dialog','.dialog-header','#viewer-title','#viewer-close','.viewer-layout','.viewer-left','#viewer-details']
|
||||
.map(selector=>[selector,box(selector==='dialog'?dialog:part(selector))])),
|
||||
toolbarBox:box(toolbar),stageBox:box(part('#viewer-stage'))};
|
||||
};
|
||||
|
||||
test('viewer matches the concept shell and toolbar order',async({page,request})=>{
|
||||
await seedConcept(request);
|
||||
for(const [width,height] of SIZES) {
|
||||
await page.setViewportSize({width,height});
|
||||
await openConcept(page,'images','dark');
|
||||
const card=page.locator('#image-grid > article.media-card').nth(1).locator('.image-open');
|
||||
const filename=(await card.getAttribute('aria-label')).replace(/^Open /,'');
|
||||
await card.click();await expect(page.locator('#viewer-dialog .viewer-canvas img')).toBeVisible();
|
||||
const concept=await page.evaluate(VIEWER_SHELL);
|
||||
expect(concept.toolbar.map(control=>control.text)).toEqual(['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →']);
|
||||
await openProduction(page,'images','dark');
|
||||
const opener=page.locator(`article[data-image-key] .image-open[aria-label="Open ${filename}"]`);await opener.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();await expect(viewer.locator('#viewer-title')).toHaveText(concept.title);
|
||||
await expect(viewer.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
// The dialog is named by its title, as the concept's aria-labelledby does.
|
||||
await expect(page.getByRole('dialog',{name:concept.title,exact:true})).toBeVisible();
|
||||
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'));
|
||||
// #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);
|
||||
expect(appended.map(control=>control.tag)).toEqual(['p','p','p']);
|
||||
await expect(viewer.locator('#viewer-toolbar > p[role="status"]')).toHaveText(new RegExp(`^\\d+ of \\d+ loaded: ${filename.replace(/[\\^$.*+?()[\]{}|]/g,'\\$&')}$`));
|
||||
expect(production.toolbar[7].tag).toBe('a');
|
||||
production.toolbar=production.toolbar.slice(0,9).map((control,index)=>index===7?{...control,tag:'button'}:control);
|
||||
// #249 D34: Viewer.js's own `.viewer-toolbar` rule makes the concept's toolbar absolute across the dialog's
|
||||
// bottom edge, over the details; production keeps it in `.viewer-left` under the stage.
|
||||
const [leftX,leftY,leftWidth,leftHeight]=production.geometry['.viewer-left'],[barX,barY,barWidth,barHeight]=production.toolbarBox;
|
||||
// Inside the left box's 1px border (boxes are rounded to whole pixels).
|
||||
for(const [actual,expected] of [[barX,leftX+1],[barWidth,leftWidth-2],[barY+barHeight,leftY+leftHeight-1],[production.stageBox[1]+production.stageBox[3],barY]])
|
||||
expect(Math.abs(actual-expected),`${width}x${height} toolbar inside .viewer-left`).toBeLessThanOrEqual(1);
|
||||
expect(concept.toolbarBox[2]).toBeGreaterThan(concept.geometry['.viewer-left'][2]);
|
||||
for(const shell of [concept,production]) {
|
||||
delete shell.toolbarBox;delete shell.stageBox;
|
||||
// At 700px and below the details and the stacked layout grow with the details content, which C8 rebuilds.
|
||||
if(width<=700) for(const selector of ['#viewer-details','.viewer-layout']) shell.geometry[selector]=shell.geometry[selector].slice(0,3);
|
||||
}
|
||||
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
// #249 D34: coarse pointers keep production's 40px square targets, wider than the concept's ×, "−" and "+" (the title beside × narrows by the difference).
|
||||
for(const shell of [concept,production]) {shell.toolbar=shell.toolbar.map(({tag,text})=>({tag,text}));delete shell.geometry['#viewer-close'];shell.geometry['#viewer-title'].splice(2,1);}
|
||||
expect(production.close).toEqual(concept.close);
|
||||
}
|
||||
expect(production,`${width}x${height}`).toEqual(concept);
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(opener).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{
|
||||
await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');
|
||||
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0');
|
||||
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
|
||||
await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name');
|
||||
// #249 C2: Source and Folder live in the concept's "Refine this view"; sort is the toolbar's.
|
||||
await page.getByRole('button',{name:'Filters',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByLabel('Source',{exact:true}).selectOption('0');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
|
||||
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('name');
|
||||
const more=page.getByRole('button',{name:'Load more images',exact:true});
|
||||
await expect(page.locator('.image-toolbar')).toContainText('40 of');
|
||||
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);}
|
||||
await expect(page.locator('.section-caption')).toContainText('40 of');
|
||||
for(const n of [80,120,160]) {await more.click();await expect(page.locator('.section-caption')).toContainText(`${n} of`);}
|
||||
const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json();
|
||||
const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160);
|
||||
await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120);
|
||||
@@ -298,32 +608,38 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
// A grid page, not the sidebar's `limit=1` Images count.
|
||||
await page.route(url=>url.pathname.endsWith('/images/output') && url.searchParams.get('limit')!=='1',async route=>{started();await held;await route.continue();},{times:1});
|
||||
await more.click();await hit;await page.evaluate(()=>scrollTo(0,0));
|
||||
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`);
|
||||
await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of');
|
||||
const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-open`);
|
||||
await opener.click();release();await expect(page.locator('.section-caption')).toContainText('200 of');
|
||||
const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
|
||||
let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;});
|
||||
await modal.getByRole('button',{name:'Previous media',exact:true}).click();
|
||||
await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`);
|
||||
await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename);
|
||||
await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
|
||||
await expect(modal.locator('#viewer-details > div > p.small')).toHaveText(loaded[159].filename);
|
||||
await expect(modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url);
|
||||
await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`);
|
||||
await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`);
|
||||
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
expect(pages).toBe(0);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await expect(opener).toBeFocused();
|
||||
await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1');
|
||||
await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector');
|
||||
// #249 D44: Source and Folder are active.
|
||||
await page.getByRole('button',{name:'Filters · 2',exact:true}).click();
|
||||
await refine.getByLabel('Source',{exact:true}).selectOption('1');await refine.getByLabel('Folder',{exact:true}).selectOption('inspector');
|
||||
await refine.getByRole('button',{name:'Apply filters',exact:true}).click();
|
||||
await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png');
|
||||
for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
const onePlay=mediaDialog(page).getByRole('button',{name:'Play slideshow',exact:true});
|
||||
await expect(onePlay).toHaveAccessibleDescription('Slideshow requires at least two loaded media entries.');await onePlay.dispatchEvent('click');await expect(onePlay).toHaveText('Play');
|
||||
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative Inspector recipe',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page);
|
||||
await expect(position(page)).toContainText('of 1 loaded: complete.png');
|
||||
await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await mediaDialog(page).getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:');
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
|
||||
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(position(page)).toContainText('of 2 loaded:');
|
||||
});
|
||||
|
||||
test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{
|
||||
@@ -334,8 +650,8 @@ test('collection viewer uses real public transforms and resets on navigation',as
|
||||
for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};}
|
||||
window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}};
|
||||
});
|
||||
await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true});
|
||||
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
|
||||
await openCollection(page);const modal=mediaDialog(page),controls=modal.locator('#viewer-toolbar');
|
||||
await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator('.viewer-container :is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0);
|
||||
const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData}));
|
||||
await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width);
|
||||
await controls.getByRole('button',{name:'Zoom out',exact:true}).click();
|
||||
@@ -375,10 +691,10 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
|
||||
window.slideshowFixture={scope,controller,view,items,open:()=>view.open({items,selectedKey:'0:inspector/complete.png'})};window.slideshowFixture.open();
|
||||
},preferences);
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await expect(mediaDialog(page).locator('.file-name')).toHaveText('complete.png');
|
||||
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png');
|
||||
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
|
||||
}
|
||||
const slideshowButton=page=>mediaDialog(page).getByRole('group',{name:'Media controls',exact:true}).getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
|
||||
const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
|
||||
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
|
||||
const mediaStep=async(page,direction)=>mediaDialog(page).getByRole('button',{name:`${direction} media`,exact:true}).click();
|
||||
async function readyClockImage(page) {
|
||||
@@ -407,7 +723,7 @@ test('slideshow exposes exact state labels and respects video playback',async({p
|
||||
await button.click();await mediaStep(page,'Previous');await expect(button).toHaveText('Stop');
|
||||
await mediaStep(page,'Previous');await expect(button).toHaveText('Pause');await expect(position(page)).toContainText('Slideshow waiting for image.');await readyClockImage(page);await expect.poll(()=>timerCount(page)).toBe(1);
|
||||
expect(new Set(originals.map(url=>new URL(url).pathname)).size).toBe(3);
|
||||
await button.click();await modal.locator('.image-preview-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
|
||||
await button.click();await modal.locator('#viewer-details').evaluate(node=>{node.scrollTop=node.scrollHeight;});await page.clock.runFor(50);
|
||||
await button.click();await mediaStep(page,'Previous');await readyClockImage(page);await expect(button).toHaveText('Pause');expect(await timerCount(page)).toBe(1);
|
||||
});
|
||||
|
||||
@@ -438,30 +754,32 @@ test('slideshow production query and route disposal cancel the active deadline',
|
||||
await openCollection(page);await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await page.clock.pauseAt(new Date(Date.now()+1000));await slideshowButton(page).click();expect(await timerCount(page)).toBe(1);
|
||||
await page.clock.runFor(3499);
|
||||
const pendingAtReset=await page.evaluate(()=>{const folder=document.getElementById('image-folder');folder.value='inspector';folder.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
|
||||
const pendingAtReset=await page.evaluate(()=>{const sort=document.getElementById('sort');sort.value='name';sort.dispatchEvent(new Event('change'));return window.slideshowTimers.size;});
|
||||
expect(pendingAtReset).toBe(0);await page.clock.runFor(1);
|
||||
await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await selectFile(page,'complete.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
|
||||
await selectFile(page,'complete.png');await openSelected(page);await readyClockImage(page);await expect(slideshowButton(page)).toHaveText('Play');
|
||||
await slideshowButton(page).click();await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('h1')).toHaveText('Overview');await expect(mediaDialog(page)).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await page.clock.runFor(7000);await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('slideshow interaction visibility sender and disposal clear intent',async({page,request})=>{
|
||||
// A working clipboard keeps the copy action on the toast, so no "Copy prompt" fallback covers the viewer.
|
||||
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{}}}));
|
||||
await slideshowFixture(page,request);const button=slideshowButton(page),modal=mediaDialog(page);
|
||||
for(const action of [
|
||||
()=>modal.locator('.prompt-positive pre').click(),
|
||||
()=>modal.locator('.image-preview-details').dispatchEvent('wheel',{deltaY:100}),
|
||||
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).click(),
|
||||
()=>modal.locator('.prompt-section.positive p.prompt-text').click(),
|
||||
()=>modal.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
|
||||
()=>modal.getByRole('button',{name:/^Copy positive prompt: /}).click(),
|
||||
async()=>{await modal.getByText('Workflow & raw metadata',{exact:true}).click();await downloadText(page,modal.getByRole('button',{name:'Download metadata JSON',exact:true}));},
|
||||
async()=>{await page.evaluate(()=>{Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'));Object.defineProperty(document,'hidden',{configurable:true,value:false});document.dispatchEvent(new Event('visibilitychange'));});},
|
||||
async()=>{const trigger=modal.getByRole('button',{name:'Send prompt pair',exact:true});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
|
||||
()=>modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true}).click(),
|
||||
async()=>{const trigger=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await trigger.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(trigger).toBeFocused();},
|
||||
()=>modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true}).click(),
|
||||
()=>page.evaluate(()=>window.slideshowFixture.view.stopSlideshow()),
|
||||
]) {
|
||||
await button.click();expect(await timerCount(page)).toBe(1);await action();await expect(button).toHaveText('Play');expect(await timerCount(page)).toBe(0);await page.clock.runFor(3500);await expect(position(page)).toContainText('1 of 3 loaded');
|
||||
}
|
||||
for(let mount=0;mount<3;mount++) {
|
||||
await button.click();await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await button.click();await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);expect(await timerCount(page)).toBe(0);
|
||||
await page.evaluate(()=>window.slideshowFixture.open());await expect(button).toHaveText('Play');
|
||||
await readyClockImage(page);
|
||||
}
|
||||
@@ -492,13 +810,13 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
async function capture(name,viewer=false) {
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
if(viewer) {
|
||||
const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
|
||||
const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
|
||||
expect(await modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
measurements.push({name,...await modal.evaluate(node=>{
|
||||
const box=element=>{const bounds=element.getBoundingClientRect();return{x:bounds.x,y:bounds.y,width:bounds.width,height:bounds.height};};
|
||||
return{frame:box(node),header:box(node.querySelector('.image-preview-header')),stage:box(node.querySelector('.image-preview-stage')),
|
||||
toolbar:box(node.querySelector('.image-preview-toolbar')),rail:box(node.querySelector('.image-preview-details')),
|
||||
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
|
||||
return{frame:box(node),header:box(node.querySelector('.dialog-header')),stage:box(node.querySelector('#viewer-stage')),
|
||||
toolbar:box(node.querySelector('#viewer-toolbar')),rail:box(node.querySelector('#viewer-details')),
|
||||
pair:node.querySelector('#viewer-details > button.send')?box(node.querySelector('#viewer-details > button.send')):null};
|
||||
})});
|
||||
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
|
||||
else await expect(rail).toHaveCSS('overflow-y','visible');
|
||||
@@ -508,22 +826,22 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
for(const [width,height,theme]of [[1600,1100,'dark'],[1600,1100,'light'],[390,844,'dark'],[390,844,'light']]) {
|
||||
const label=`${theme}-${width}`;await page.setViewportSize({width,height});
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.file-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await selectFile(page,'complete.png');await expect(inspector(page).locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
if(width===1600)await page.evaluate(()=>scrollTo(0,0));else await inspector(page).locator('.detail-cover').evaluate(node=>node.scrollIntoView({block:'start'}));
|
||||
await capture(`inline-${label}`);
|
||||
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('.image-preview-details');
|
||||
await openCollection(page);const modal=mediaDialog(page),rail=modal.locator('#viewer-details');
|
||||
await expect(modal.locator('.viewer-canvas img')).toBeVisible();await expect.poll(()=>modal.locator('.viewer-canvas img').evaluate(node=>node.complete&&node.naturalWidth>0)).toBe(true);
|
||||
await modal.evaluate(node=>{node.scrollTop=0;});await capture(`viewer-${label}`,true);
|
||||
await expect(rail.locator('.prompt-negative')).toContainText('blurred edges, watermark');await rail.locator('.generation-facts').scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
|
||||
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('.file-facts').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
|
||||
await expect(rail.locator('.prompt-section.negative')).toContainText('blurred edges, watermark');await rail.locator('.meta-grid').first().scrollIntoViewIfNeeded();await capture(`facts-${label}`,true);
|
||||
await rail.getByText('File & provenance',{exact:true}).click();await rail.locator('details .meta-grid').scrollIntoViewIfNeeded();await capture(`provenance-${label}`,true);
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await raw.scrollIntoViewIfNeeded();await expect(raw).toBeInViewport();await capture(`raw-${label}`,true);
|
||||
const send=rail.getByRole('button',{name:'Send prompt pair',exact:true});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await selectFile(page,'clip.webm');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
const send=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await send.focus();await expect(send).toBeInViewport();await send.click();await expect(page.getByRole('dialog',{name:'Send to ComfyUI',exact:true})).toBeVisible();await capture(`sender-${label}`);await page.keyboard.press('Escape');await expect(send).toBeFocused();
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await selectFile(page,'clip.webm');await openSelected(page);
|
||||
await expect.poll(()=>modal.locator('video').evaluate(node=>node.readyState)).toBeGreaterThanOrEqual(1);await capture(`video-${label}`,true);await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await selectFile(page,'control-heavy.png');await openSelected(page);
|
||||
await expect(modal).toContainText('Unavailable within inspection limits');await modal.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
const limitedDownload=modal.getByRole('button',{name:'Download metadata JSON',exact:true});await limitedDownload.scrollIntoViewIfNeeded();await expect(limitedDownload).toHaveAttribute('aria-disabled','true');await capture(`limited-${label}`,true);
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
}
|
||||
const fs=await import('node:fs/promises'),geometryPath=testInfo.outputPath('inspector-geometry.json');
|
||||
await fs.writeFile(geometryPath,JSON.stringify(measurements,null,2));await testInfo.attach('inspector-geometry',{path:geometryPath,contentType:'application/json'});
|
||||
@@ -532,14 +850,14 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
test('collection viewer preserves original download and rooted retries',async({page,request})=>{
|
||||
await fullFixture(page,request);let deny=true;const originals=[];
|
||||
await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();});
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page);
|
||||
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.');
|
||||
const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
|
||||
await openSelected(page);const modal=mediaDialog(page);
|
||||
await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
await expect(modal.locator('.media-position')).toContainText('Media could not be loaded.');
|
||||
const download=modal.locator('#viewer-toolbar').getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href');
|
||||
await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404);
|
||||
await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied;
|
||||
await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
|
||||
await expect.poll(()=>mediaStage(page).locator(':scope > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true);
|
||||
await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible();
|
||||
deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(download).toHaveAttribute('href',original);
|
||||
@@ -547,38 +865,39 @@ test('collection viewer preserves original download and rooted retries',async({p
|
||||
expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3);
|
||||
for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);}
|
||||
const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png');
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;});
|
||||
await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1});
|
||||
await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.');
|
||||
await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click();
|
||||
await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-open').click();
|
||||
await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720);
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.');
|
||||
await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click();
|
||||
await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
});
|
||||
|
||||
test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{
|
||||
await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page);
|
||||
const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const send=modal.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
|
||||
await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused();
|
||||
const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
|
||||
const text=modal.locator('.prompt-section.positive p.prompt-text');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before);
|
||||
await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1];
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key);
|
||||
await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';});
|
||||
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0);
|
||||
// D19: focus returns to the opening image; the panel follows the file the viewer moved to.
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('article[data-image-key="0:inspector/complete.png"] .image-open')).toBeFocused();await expect(inspector(page).locator('.detail-inner > p.small')).toHaveText(key);
|
||||
await openSelected(page);await page.evaluate(()=>{location.hash='/overview';});
|
||||
await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page);
|
||||
const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details');
|
||||
const modal=mediaDialog(page),body=modal.locator('.viewer-layout'),rail=modal.locator('#viewer-details');
|
||||
// Absolute gallery cards relayout through ResizeObserver and rAF after resize or CSS zoom.
|
||||
const settleViewport=width=>expect.poll(()=>page.evaluate(()=>({inner:innerWidth,client:document.documentElement.clientWidth,scroll:document.documentElement.scrollWidth})),{message:`Images gallery relayout settles the ${width}px viewport`}).toEqual({inner:width,client:width,scroll:width});
|
||||
await settleViewport(1600);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.evaluate(theme=>{const preference=document.getElementById('theme');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme);
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};});
|
||||
const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('#viewer-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.viewer-layout')).columnGap};});
|
||||
expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'});
|
||||
await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)});
|
||||
}
|
||||
@@ -594,30 +913,30 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(zoom===1?356:322,1);
|
||||
expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true);
|
||||
await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1);
|
||||
await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>{const style=getComputedStyle(node);return [style.display,style.flexDirection];})).toEqual(['flex','column']);
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible();
|
||||
await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport();
|
||||
await rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).focus();await expect(rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeInViewport();
|
||||
await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
}
|
||||
await rail.evaluate(node=>{node.style.fontFamily='"DejaVu Sans", sans-serif';});
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
const sendPair=rail.getByRole('button',{name:'Send prompt pair',exact:true});await sendPair.focus();await expect(sendPair).toBeInViewport();
|
||||
const sendPair=rail.getByRole('button',{name:/^Send prompt pair to ComfyUI: /});await sendPair.focus();await expect(sendPair).toBeInViewport();
|
||||
await rail.getByText('Workflow & raw metadata',{exact:true}).click();await sendPair.focus();await expect(sendPair).toBeFocused();await expect(sendPair).toBeInViewport();
|
||||
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('.file-inspector-footer').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
|
||||
const footer=await sendPair.evaluate(node=>{const box=node.getBoundingClientRect(),parent=node.closest('#viewer-details').getBoundingClientRect();return{width:box.width,right:box.right,available:parent.width,parentRight:parent.right,height:box.height,minimum:matchMedia('(pointer: coarse)').matches?80:60};});
|
||||
expect(footer.width).toBeLessThanOrEqual(footer.available);expect(footer.right).toBeLessThanOrEqual(footer.parentRight);expect(footer.height).toBeGreaterThanOrEqual(footer.minimum);await expect(sendPair.locator('svg')).toHaveCount(1);
|
||||
expect(await modal.evaluate(node=>node.getBoundingClientRect().width)).toBeCloseTo(322,1);await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
await testInfo.attach('wider-font-footer-geometry',{body:JSON.stringify({footer,modal:await modal.evaluate(node=>({width:node.getBoundingClientRect().width,scrollWidth:node.scrollWidth,clientWidth:node.clientWidth}))}),contentType:'application/json'});
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
|
||||
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page);
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
|
||||
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
|
||||
await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport();
|
||||
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
|
||||
});
|
||||
|
||||
async function currentStageFit(page) {
|
||||
return page.evaluate(()=>{
|
||||
const instance=window.fitInstances.at(-1),stage=document.querySelector('.image-preview-stage');
|
||||
const instance=window.fitInstances.at(-1),stage=document.querySelector('#viewer-stage');
|
||||
const bounds=stage.getBoundingClientRect(),image=instance.image.getBoundingClientRect();
|
||||
return {stage:{width:stage.offsetWidth,height:stage.offsetHeight},viewer:{...instance.viewerData},
|
||||
image:{width:image.width,height:image.height},
|
||||
@@ -655,7 +974,7 @@ async function openStageFitFixture(page,request) {
|
||||
window.stageFitFixture={scope,controller,view};view.open({items:[tall,next],selectedKey:`${tall.root_index}:${tall.path}`,trigger:document.activeElement});
|
||||
});
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await expect(mediaDialog(page).locator('.image-preview-reason')).toBeHidden();
|
||||
await expect(mediaDialog(page).locator('.media-reason')).toBeHidden();
|
||||
return release;
|
||||
}
|
||||
async function attachStageFit(page,testInfo,name) {
|
||||
@@ -679,7 +998,7 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
const modal=mediaDialog(page),fit=modal.getByRole('button',{name:'Fit image',exact:true});
|
||||
await page.evaluate(()=>{window.fitImage=document.querySelector('.viewer-canvas img');window.fitControl=document.querySelector('[aria-label="Fit image"]');});
|
||||
await fit.focus();const initial=await attachStageFit(page,testInfo,'before-late-title');release();
|
||||
await expect(modal.locator('.image-preview-header h2')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
|
||||
await expect(modal.locator('#viewer-title')).toHaveText(`${'W'.repeat(220)} · study-02.png`);
|
||||
await expect(fit).toBeFocused();await fit.click();await attachStageFit(page,testInfo,'late-title-fit');
|
||||
await expect.poll(async()=>{const data=await currentStageFit(page);return Math.abs(data.viewer.height-data.stage.height);}).toBeLessThanOrEqual(1);
|
||||
const current=await currentStageFit(page);expect(current.stage.height).toBeLessThan(initial.stage.height);expect(Math.abs(current.center.y)).toBeLessThanOrEqual(1);expect(current.contained).toBe(true);
|
||||
@@ -687,12 +1006,12 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
expect(await page.evaluate(()=>window.fitImage===document.querySelector('.viewer-canvas img')&&window.fitControl===document.querySelector('[aria-label="Fit image"]')&&window.fitInstances.length===1)).toBe(true);
|
||||
await modal.getByRole('button',{name:'Zoom in',exact:true}).click();await modal.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await modal.getByRole('button',{name:'Flip image horizontally',exact:true}).click();
|
||||
const transformed=await page.evaluate(()=>({...window.fitInstances[0].imageData}));await page.evaluate(()=>window.stageFitFixture.controller.retry());
|
||||
await expect(modal.locator('.file-name')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
await expect(modal.locator('#viewer-details > div > p.small')).toHaveText('study-02.png');await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
expect(await page.evaluate(()=>window.fitInstances[0].imageData)).toEqual(transformed);
|
||||
await fit.click();const fitted=await currentStageFit(page);expect(Math.abs(fitted.center.y)).toBeLessThanOrEqual(1);expect(fitted.contained).toBe(true);
|
||||
await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await page.evaluate(()=>{for(const probe of window.fitObservers.filter(probe=>probe.disconnected))probe.callback([],probe.observer);});
|
||||
await modal.getByRole('button',{name:'Close preview',exact:true}).click();await expect(modal).toHaveCount(0);
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await expect(modal).toHaveCount(0);
|
||||
await page.evaluate(async()=>{for(const probe of window.fitObservers)probe.callback([],probe.observer);await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));});
|
||||
expect(await page.evaluate(()=>window.fitObservers.every(probe=>probe.disconnected))).toBe(true);
|
||||
expect(await page.evaluate(()=>window.fitResizeCalls.filter(call=>call.destroyed||!call.connected))).toEqual([]);
|
||||
|
||||
@@ -10,14 +10,14 @@ import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.m
|
||||
export const canonicalDifferences = {
|
||||
data: ['Independent synthetic text, image bytes, IDs, counts and text wrapping'],
|
||||
semantics: [
|
||||
'Saved combinations are distinct from the additional chronological executions',
|
||||
'Negative variants are saved combinations; Recorded runs list the chronological executions',
|
||||
'Recorded run exposes exact frozen evidence; Repeat run is illustrative',
|
||||
'Current-file candidates disclose unknown identity and context membership',
|
||||
],
|
||||
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
|
||||
// exact history comparison is tests/browser/history-concept.spec.mjs.
|
||||
remainingApplicationPolish: [
|
||||
'Gallery thumbnail selects details before Open full preview; direct Viewer/Info/tag/favorite/select parity remains outside #209',
|
||||
'Shared Viewer stage background and toolbar alignment remain outside #209',
|
||||
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
|
||||
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
};
|
||||
@@ -34,29 +34,27 @@ export async function captureCanonicalState(page, testInfo, name, selector) {
|
||||
await testInfo.attach(name, { path, contentType: 'image/png' });
|
||||
}
|
||||
|
||||
const CONCEPT_HISTORY_SELECTORS = Object.freeze({
|
||||
surface: 'article.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
|
||||
label: '.variant-lane > span', dot: '.variant-dot[aria-pressed="true"]', role: 'article.surface .prompt-section',
|
||||
positiveLabel: '.positive-lane span',
|
||||
prompt: 'article.surface .prompt-text', gallery: '.history-gallery',
|
||||
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
|
||||
footer: '.timeline-lower', all: '.timeline-lower button',
|
||||
repeat: '.timeline .run-dot:not([hidden])',
|
||||
});
|
||||
const RECORDED_HISTORY_SELECTORS = Object.freeze({
|
||||
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
|
||||
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
|
||||
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
|
||||
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
|
||||
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
|
||||
});
|
||||
|
||||
export async function historyGeometry(page, production) {
|
||||
const selectors = production ? {
|
||||
surface: '.library-surface', timeline: '.library-branches', positive: '.library-positive-lane',
|
||||
branch: '.library-variant-lane', label: '.library-variant-lane span', dot: '.library-variant-dot[aria-pressed="true"]',
|
||||
positiveLabel: '.library-positive-lane span',
|
||||
role: '[aria-label="Saved prompt texts"] .prompt-block',
|
||||
prompt: '[aria-label="Saved prompt texts"] pre', gallery: '.library-shared-gallery',
|
||||
heading: '.library-variants h2', intro: '.library-variants > p.muted',
|
||||
footer: '.library-variant-footer', all: '.library-variants button:last-child',
|
||||
recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch',
|
||||
recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas',
|
||||
recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label',
|
||||
recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)',
|
||||
recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column',
|
||||
} : {
|
||||
surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane',
|
||||
label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section',
|
||||
positiveLabel: '.positive-lane span',
|
||||
prompt: '.surface .prompt-text', gallery: '.history-gallery',
|
||||
heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small',
|
||||
footer: '.timeline-lower', all: '.timeline-lower button',
|
||||
repeat: '.timeline .run-dot',
|
||||
};
|
||||
// Production renders the concept's history classes (#249 PR F), so both documents use the concept's selectors;
|
||||
// production adds the recorded executions (#209), which the concept does not have.
|
||||
const selectors = production ? { ...CONCEPT_HISTORY_SELECTORS, ...RECORDED_HISTORY_SELECTORS } : CONCEPT_HISTORY_SELECTORS;
|
||||
return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches,
|
||||
...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => {
|
||||
const nodes = [...document.querySelectorAll(selector)];
|
||||
@@ -106,8 +104,8 @@ export async function exerciseCurrentPairSend(page, roles) {
|
||||
apply: roles => { window.canonicalApplied.push(roles);return { targetId: target.id, values: roles }; },
|
||||
} });
|
||||
});
|
||||
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });
|
||||
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });await send.click();
|
||||
const viewer = page.locator('dialog#viewer-dialog');
|
||||
const send = viewer.getByRole('button', { name: /^Send prompt pair to ComfyUI: / });await send.click();
|
||||
const sender = page.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });await expect(sender).toBeVisible();
|
||||
await sender.getByRole('combobox', { name: 'ComfyUI window', exact: true }).selectOption({ label: 'Canonical ComfyUI' });
|
||||
await sender.getByRole('combobox', { name: 'Destination node', exact: true }).selectOption({ label: 'Canonical destination' });
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs';
|
||||
|
||||
// Task F5 of #249: the production Prompt history (#/library/<id>) against the immutable concept's (#prompt/alpine,
|
||||
// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real
|
||||
// fixture: Alpine observatory with its three saved negatives, four images linked to their pairs, four recorded runs and
|
||||
// real pair run counts (2, 1, 1). Heading, summary, the negative variants panel, the comparison and the shared gallery's
|
||||
// heading are compared at 1600x1100 and 390x844 in both themes and motion settings, with the page's behaviors in both
|
||||
// documents. Both documents are measured with "Clean architecture" selected: it is the middle lane in either order
|
||||
// (D46). The topbar and sidebar are the shell spec's, the gallery's cards the Images spec's (D47). Paired captures are
|
||||
// artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const TITLE='Alpine observatory';
|
||||
const SELECTED='Clean architecture';
|
||||
const LANES=3;
|
||||
const SHOW_ARCHIVED='Show archived (0)';
|
||||
const SECTIONS=['heading','history','noPageScroll'];
|
||||
|
||||
/**
|
||||
* Recorded history deviations only (spec §8, TESTING.md D24–D26, D35, D45–D47); everything else must equal production.
|
||||
* `reference.oracle` is the concept page itself, measured once its lanes list newest first with the singular for one
|
||||
* (D46, D35) and once the approved Show archived button (#235) follows + Add negative: the browser lays out what moves.
|
||||
*/
|
||||
export function mapHistoryConcept(reference) {
|
||||
const {history}=reference.oracle;
|
||||
return {...reference,history:{...history,
|
||||
// D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so
|
||||
// the concept's "4 tracked runs" holds by construction (asserted on both documents below).
|
||||
summary:history.summary,
|
||||
variants:{...history.variants,
|
||||
// D45: the bounded "N of M saved negative variants" count stays a visually hidden status.
|
||||
structure:{...history.variants.structure,spoken:[{role:'status',text:`${LANES} of ${LANES} saved negative variants`}]}},
|
||||
compare:history.compare&&{...history.compare,grid:{...history.compare.grid,items:history.compare.grid.items.map((item,index)=>({...item,
|
||||
// D46: the two compared blocks are named Baseline and Selected, so no action name repeats on the page.
|
||||
sections:item.sections.map(section=>({...section,actions:{...section.actions,buttons:section.actions.buttons.map(button=>({...button,
|
||||
label:button.label.replace(/^(Copy|Send) negative/,`$1 ${COMPARED[index]} negative`)}))}}))}))}},
|
||||
// D47: the gallery's body is the Images collection (layout row, cards, side panel), so only its place, frame and
|
||||
// heading are compared here; the cards are the Images comparison's.
|
||||
gallery:history.gallery&&withoutHeight(history.gallery),
|
||||
}};
|
||||
}
|
||||
const COMPARED=['baseline','selected'];
|
||||
const withoutHeight=({height,...gallery})=>gallery;
|
||||
|
||||
function compare(reference,actual) {
|
||||
const only=inventory=>Object.fromEntries(SECTIONS.map(key=>[key,key==='history'&&inventory.history?.gallery
|
||||
?{...inventory.history,gallery:withoutHeight(inventory.history.gallery)}:inventory[key]]));
|
||||
return diffInventory(stripExact(only(mapHistoryConcept(reference))),stripExact(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertHistoryMatch(reference,actual) {
|
||||
const diff=compare(reference,actual);
|
||||
if(diff.length)throw new Error(`the Prompt history differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const decoded=page=>page.evaluate(async()=>{
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('.history-gallery img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
|
||||
});
|
||||
|
||||
const dot=(page,name)=>page.getByRole('button',{name:`Select negative ${name}`,exact:true});
|
||||
const compareButton=page=>page.getByRole('button',{name:'Compare negatives',exact:true});
|
||||
const galleryCards=page=>page.locator('.history-gallery .media-card');
|
||||
|
||||
/** Press a variant dot and wait until the summary and the gallery follow it. */
|
||||
async function selectVariant(page,name) {
|
||||
await dot(page,name).click();
|
||||
await expect(dot(page,name)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(page.locator('.history-gallery .panel-heading .small')).toHaveText(`${name} · same positive prompt`);
|
||||
await expect(galleryCards(page).first()).toBeVisible();
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
async function setCompare(page,pressed) {
|
||||
const button=compareButton(page);
|
||||
if(await button.getAttribute('aria-pressed')!==String(pressed))await button.click();
|
||||
await expect(compareButton(page)).toHaveAttribute('aria-pressed',String(pressed));
|
||||
await page.mouse.move(0,0);await settle(page);
|
||||
}
|
||||
|
||||
async function openConceptHistory(page,theme,motion) {
|
||||
await openConcept(page,'prompt/alpine',theme,motion);
|
||||
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
|
||||
await selectVariant(page,SELECTED);
|
||||
}
|
||||
async function openProductionHistory(page,theme,motion,positiveId) {
|
||||
await openProduction(page,`library/${positiveId}`,theme,motion);
|
||||
await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES);
|
||||
await expect(page.locator('article.surface .card-meta > span').first()).toBeVisible();
|
||||
await selectVariant(page,SELECTED);
|
||||
}
|
||||
|
||||
// D45: the recorded production rules that lay the panel out (workspace.css): the panel heading and the legend row wrap,
|
||||
// All images wraps its label, and a lane's text wraps before a shown repeat-run dot. Applied to the concept so the browser
|
||||
// lays out what follows; at 1600px nothing wraps.
|
||||
const D45_LAYOUT=`.timeline-panel > .panel-heading, .timeline-lower { flex-wrap: wrap; }
|
||||
.variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; }
|
||||
.ca-tools .timeline-lower > button { max-inline-size: 100%; white-space: normal; }`;
|
||||
|
||||
/**
|
||||
* The concept laid out as production lists it: lanes newest first with their offsets (D46), "1 run" in the singular
|
||||
* (D35), the approved Show archived button after + Add negative (#235) and D45's layout rules. Returns the undo.
|
||||
*/
|
||||
async function conceptAsRecorded(page) {
|
||||
const restoreLayout=await mutate(page,{css:D45_LAYOUT});
|
||||
await page.evaluate(label=>{
|
||||
const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')];
|
||||
const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]);
|
||||
lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
||||
for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,(match,before)=>`${before}1 run`);
|
||||
const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative');
|
||||
const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt');
|
||||
archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived);
|
||||
window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived};
|
||||
},SHOW_ARCHIVED);
|
||||
await settle(page);
|
||||
return ()=>page.evaluate(()=>{
|
||||
const {timeline,order,texts,archived}=window.__historyOracle;
|
||||
order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);});
|
||||
for(const [span,text] of texts)span.textContent=text;
|
||||
archived.remove();delete window.__historyOracle;
|
||||
}).then(restoreLayout);
|
||||
}
|
||||
|
||||
/** The concept inventory, with `oracle`: the same page laid out as recorded (`conceptAsRecorded`). */
|
||||
async function conceptInventory(page) {
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const restore=await conceptAsRecorded(page);
|
||||
const oracle=await inventoryOf(page,'#main');
|
||||
await restore();await settle(page);
|
||||
return {...reference,oracle};
|
||||
}
|
||||
|
||||
const negativeText=page=>page.locator('article.surface .prompt-section.negative .prompt-text').textContent();
|
||||
const galleryOf=async page=>({context:await page.locator('.history-gallery .panel-heading .small').textContent(),
|
||||
cards:await galleryCards(page).locator('.image-open').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))});
|
||||
|
||||
/** A dialog the page opens: its accessible name, how many media cards it shows once loaded and its captions; then closed. */
|
||||
async function dialogOf(page,trigger,{name,cards}) {
|
||||
await trigger.click();
|
||||
const dialog=page.getByRole('dialog',{name,exact:true});
|
||||
await expect(dialog).toBeVisible();
|
||||
if(cards)await expect(dialog.locator('.media-card')).toHaveCount(cards);
|
||||
// D47: neither document's media dialog carries a collection caption ("Linked images", "N loaded · …").
|
||||
const captions=await dialog.locator('.section-caption').count();
|
||||
const result=cards?{name,cards:await dialog.locator('.media-card').count(),captions}:{name,captions};
|
||||
await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors Task F5 lists, with identical selectors in both documents. */
|
||||
async function behaviorsOf(page,{sent,runDialog}) {
|
||||
await selectVariant(page,'Quality baseline');
|
||||
const before={negative:await negativeText(page),gallery:await galleryOf(page)};
|
||||
await selectVariant(page,SELECTED);
|
||||
const after={negative:await negativeText(page),gallery:await galleryOf(page)};
|
||||
const pressed=[await compareButton(page).getAttribute('aria-pressed')];
|
||||
await setCompare(page,true);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
|
||||
const compared=await page.locator('section.surface:not(.timeline-panel) > h2').textContent();
|
||||
await setCompare(page,false);pressed.push(await compareButton(page).getAttribute('aria-pressed'));
|
||||
const all=await dialogOf(page,page.getByRole('button',{name:'All images for this positive',exact:true}),{name:'All images for this positive',cards:4});
|
||||
const run=await dialogOf(page,page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}),{name:runDialog});
|
||||
await page.locator('.heading-actions').getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
const send=await sent(page);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('h1#page-title')).toHaveText('Prompt library');
|
||||
return {variant:{before,after},pressed,compared,all,run,send,back:await page.locator('h1#page-title').textContent()};
|
||||
}
|
||||
|
||||
/** The concept's demo sender: the roles its preview lists, with their text. */
|
||||
async function conceptSent(page) {
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
|
||||
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return roles;
|
||||
}
|
||||
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
|
||||
function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
|
||||
return roles;
|
||||
};
|
||||
}
|
||||
async function openHost(context) {
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.previewed=[];
|
||||
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
|
||||
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
|
||||
apply:roles=>({targetId:target.id,values:roles}),
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{
|
||||
testInfo.manifest=await seedConcept(request);
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const positiveId=testInfo.manifest.prompts[0].positive_id;
|
||||
const host=await openHost(context);
|
||||
try {
|
||||
const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
await openConceptHistory(page,theme,motion);
|
||||
const reference=await conceptInventory(page);
|
||||
await setCompare(page,true);
|
||||
const referenceCompared=await conceptInventory(page);
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-history-${width}-${theme}`);
|
||||
const referenceBehaviors=capture?await behaviorsOf(page,{sent:conceptSent,runDialog:'Repeat run'}):null;
|
||||
|
||||
await openProductionHistory(page,theme,motion,positiveId);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await setCompare(page,true);
|
||||
const actualCompared=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-history-${width}-${theme}`);
|
||||
const actualBehaviors=capture?await behaviorsOf(page,{sent:productionSent(host),runDialog:'Recorded run'}):null;
|
||||
result[motion]={reference,actual,diff:compare(reference,actual),referenceCompared,actualCompared,
|
||||
compareDiff:compare(referenceCompared,actualCompared),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
const path=testInfo.outputPath('history-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('history-geometry',{path,contentType:'application/json'});
|
||||
for(const [motion,{diff,compareDiff,actual,referenceCompared}] of Object.entries(result)) {
|
||||
// The concept itself is what this comparison assumes (style.css 709–722, 1059–1179, 1426, 1546).
|
||||
const {summary,variants,compare:compared,gallery}=referenceCompared.history;
|
||||
expect.soft(summary,`${motion} concept surface`).toMatchObject({paddingTop:'22px',paddingLeft:'22px',borderTopLeftRadius:'7px',borderTopWidth:'1px'});
|
||||
// D24: the fixture's four recorded runs give the concept's own "4 tracked runs"; production reads them.
|
||||
expect.soft(summary.meta.items.map(item=>item.text),`${motion} concept counts`).toEqual(['4 tracked runs','4 images','3 negative variants']);
|
||||
expect.soft(actual.history.summary.meta.items.map(item=>item.text),`${motion} tracked runs`).toEqual(['4 tracked runs','4 images','3 negative variants']);
|
||||
expect.soft(variants.timeline,`${motion} concept timeline`).toMatchObject({paddingTop:'18px',paddingLeft:'20px',borderTopLeftRadius:'6px'});
|
||||
variants.timeline.lanes.forEach((lane,index)=>{
|
||||
expect.soft(lane,`${motion} concept lane ${index}`).toMatchObject({offset:`${38+index*47}px`,marginLeft:`${38+index*47}px`,height:57});
|
||||
expect.soft(lane.dot,`${motion} concept dot ${index}`).toMatchObject({width:'29px',height:'29px',text:'−'});
|
||||
});
|
||||
expect.soft(variants.timeline.lanes.map(lane=>lane.run&&{width:lane.run.width,left:lane.run.left}),`${motion} concept run dots`).toEqual([{width:'12px',left:'190px'},null,null]);
|
||||
expect.soft(compared.grid,`${motion} concept compare grid`).toMatchObject({columns:width>700?2:1,columnGap:'15px'});
|
||||
expect.soft(compared.added.map(chip=>chip.text),`${motion} concept added terms`).toEqual(['+ distorted architecture','+ watermark','+ text']);
|
||||
expect.soft(compared.added[0],`${motion} concept diff chip`).toMatchObject({fontSize:'11px',paddingTop:'3px',paddingLeft:'7px',borderTopLeftRadius:'4px',display:'inline-block'});
|
||||
expect.soft(gallery,`${motion} concept gallery`).toMatchObject({marginTop:'24px',heading:{h2:{text:'Shared gallery',fontSize:'14px'},note:{text:`${SELECTED} · same positive prompt`}}});
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(compareDiff,`${motion} differences with Compare negatives pressed`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
}
|
||||
const {referenceBehaviors:concept,actualBehaviors:production}=result['no-preference'];
|
||||
expect.soft(production,'behaviors').toEqual({...concept,run:{...concept.run,name:'Recorded run'}});
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant
|
||||
// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match.
|
||||
test('history comparison detects mutations',async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConceptHistory(page,'dark','no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await openProductionHistory(page,'dark','no-preference',testInfo.manifest.prompts[0].positive_id);
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertHistoryMatch(reference,await measure());
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.ca-tools .variant-dot { width: 20px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertHistoryMatch(reference,cssMutated)).toThrow(/history\.variants\.timeline\.lanes\.0\.dot\.width/);
|
||||
await restoreCss();await settle(page);
|
||||
assertHistoryMatch(reference,await measure());
|
||||
|
||||
await compareButton(page).evaluate(node=>node.remove());
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/);
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs';
|
||||
|
||||
// Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24
|
||||
// images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid,
|
||||
// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the
|
||||
// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
|
||||
// columns, the 275px and 310px panel). The topbar and sidebar (D5, D28, D29) are the shell spec's.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
// D37: the concept loads 12 images per batch (app.js imageBatchSize) and production one listing page (40), so both are
|
||||
// compared once the 24 fixture images are all loaded: the concept after its own Load more press.
|
||||
const FIXTURE_IMAGES=24;
|
||||
const CONCEPT_BATCH=12;
|
||||
const PRODUCTION_BATCH=40;
|
||||
// Load more in production needs more than one listing page: the fixture continues the concept's rounds (two pages).
|
||||
const LOAD_MORE_EXTRA=2*PRODUCTION_BATCH-FIXTURE_IMAGES;
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const WIDE_SIZES=Object.freeze([[1200,900],[1800,1100]]);
|
||||
|
||||
/** D17: "<Today|Yesterday|weekday> · <Month D>" of the newest file (the fixture's own mtime), in the test's UTC zone. */
|
||||
function captionDay(seconds,now=new Date()) {
|
||||
const day=value=>Date.UTC(value.getUTCFullYear(),value.getUTCMonth(),value.getUTCDate());
|
||||
const date=new Date(seconds*1000),days=Math.round((day(now)-day(date))/86400000);
|
||||
const name=days===0?'Today':days===1?'Yesterday':date.toLocaleDateString('en-US',{weekday:'long',timeZone:'UTC'});
|
||||
const options={month:'long',day:'numeric',timeZone:'UTC',...(date.getUTCFullYear()===now.getUTCFullYear()?{}:{year:'numeric'})};
|
||||
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
|
||||
}
|
||||
|
||||
/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */
|
||||
const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
|
||||
/**
|
||||
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
|
||||
* measured from the border box as the inventory records them. The concept's border is one width on every side
|
||||
* (`border: 1px solid`), so the right border stands for the left one.
|
||||
*/
|
||||
function withoutPaddedItems(row,isRemoved) {
|
||||
const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'],
|
||||
['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0));
|
||||
const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y,
|
||||
exact:item.exact&&{...item.exact,left:item.exact.left+x}};
|
||||
const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))};
|
||||
const flowed=withoutItems(inner,isRemoved);
|
||||
return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))};
|
||||
}
|
||||
|
||||
/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */
|
||||
function mapBulkbar(bulkbar,rise) {
|
||||
if(!bulkbar)return {bulkbar,shrink:0};
|
||||
if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0};
|
||||
const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text));
|
||||
const shrink=bulkbar.bar.height-bar.height;
|
||||
return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink};
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
|
||||
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
|
||||
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
|
||||
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
|
||||
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
|
||||
* - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
|
||||
*/
|
||||
function listInfo(card,actionsWidth) {
|
||||
const {info}=card,{copy,p}=info;
|
||||
const width=card.width-2*parseFloat(card.borderTopWidth)-card.art.width;
|
||||
const column=width-parseFloat(info.paddingLeft)-parseFloat(info.paddingRight)-parseFloat(info.columnGap)-actionsWidth;
|
||||
const line=parseFloat(p.lineHeight);
|
||||
const pHeight=p.height>line&&p.maxContent<=column?line:p.height;
|
||||
const copyHeight=copy.height-(p.height-pHeight);
|
||||
const natural=Math.max(copyHeight,info.actions.height)+parseFloat(info.paddingTop)+parseFloat(info.paddingBottom);
|
||||
// The row's inner box is the stretched concept info: its top is the card border, its height the art's.
|
||||
return {info:{width,top:Math.round(info.top+(info.height-natural)/2),height:Math.round(natural)},
|
||||
copy:{height:copyHeight,top:Math.round(info.top+(info.height-copyHeight)/2)},p:{height:pHeight},
|
||||
actionsLeft:info.left+width-parseFloat(info.paddingRight)-actionsWidth};
|
||||
}
|
||||
|
||||
/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
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);
|
||||
// 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.
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
|
||||
?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item);
|
||||
// D29: no "Illustrative previews" sample tag.
|
||||
const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
|
||||
filterRow.items=filterRow.items.map(item=>{
|
||||
// D32: the layout buttons may wrap at extreme zoom (they never do while they fit).
|
||||
if(item.className==='segmented')return {...item,flexWrap:'wrap'};
|
||||
// D14: the scope chip may shrink and wrap a long source or folder name inside itself.
|
||||
if(item.tag==='span'&&item.className==='chip')return {...item,minWidth:'0px'};
|
||||
return item;
|
||||
});
|
||||
// A shorter toolbar or filter row moves what follows it up by as much.
|
||||
const toolbarRise=reference.toolbar.height-toolbar.height;
|
||||
filterRow.top-=toolbarRise;
|
||||
const rise=toolbarRise+reference.filterRow.height-filterRow.height;
|
||||
const list=reference.grid.className.split(' ').includes('list-grid');
|
||||
const cards=reference.cards.map(card=>{
|
||||
const filename=card.open.label.replace(/^Open /,'');
|
||||
const actions=card.info.actions;
|
||||
const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
|
||||
// D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
|
||||
const freed=tag?tag.width+parseFloat(actions.columnGap):0;
|
||||
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
|
||||
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
|
||||
const actionsWidth=actions.width-freed;
|
||||
const listRow=list?listInfo(card,actionsWidth):{};
|
||||
return {...card,
|
||||
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
|
||||
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
|
||||
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
|
||||
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
|
||||
h3:{...card.info.h3,title:paths.get(filename)??null},
|
||||
actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
|
||||
buttons:actions.buttons.filter(button=>button!==tag)}}};
|
||||
});
|
||||
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
|
||||
// D35: the count line uses the singular for one.
|
||||
const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1
|
||||
?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})};
|
||||
const continuation=mapContinuation(reference.continuation);
|
||||
const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise);
|
||||
return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}};
|
||||
}
|
||||
|
||||
/** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */
|
||||
function mapContinuation(continuation,numbers=null) {
|
||||
return {...continuation,flexWrap:'wrap',items:continuation.items.map(item=>item.tag!=='p'?item:{...item,
|
||||
// D37: a row with more to load counts production's own page ("N of M"), not the concept's 12-image batch.
|
||||
text:countCopy(numbers?item.text.replace(/^\d+ of \d+/,`${numbers.loaded} of ${numbers.total}`):item.text)})};
|
||||
}
|
||||
|
||||
/** D37: the Load more row before its press, the concept's at 12 of 24 against production's at its own page. */
|
||||
function compareLoadingRow(reference,actual,count) {
|
||||
const [,loaded,total]=count.match(/^(\d+) of (\d+)/);
|
||||
// The row's place follows the grid above it, whose height is the batch's (D37).
|
||||
const unplaced=({top,...row})=>stripExact(row);
|
||||
return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
|
||||
}
|
||||
|
||||
/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
|
||||
function mapImportConcept(reference) {
|
||||
return {...reference,
|
||||
intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'},
|
||||
rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder'
|
||||
?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
|
||||
const pick=inventory=>stripExact(inventory);
|
||||
|
||||
// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
|
||||
const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
|
||||
|
||||
function compare(reference,actual,manifest,sections=SECTIONS) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
|
||||
return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertImagesMatch(reference,actual,manifest) {
|
||||
const diff=compare(reference,actual,manifest);
|
||||
if(diff.length)throw new Error(`the Images page differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 (masonry sub-pixel snapping): bounded rendering places masonry cards from the layout arithmetic (media width over
|
||||
* the file's ratio), while the concept's flow snaps each box to the browser's layout unit (1/64px in Chromium and
|
||||
* WebKit, 1/60px in Firefox) before measuring the next. So masonry tops and heights are compared unrounded instead,
|
||||
* within one layout unit per card above in the column (the card's own included); the rounded fields are skipped there.
|
||||
*/
|
||||
const MASONRY_ROUNDED=/^cards\.\d+\.(top|height)$/;
|
||||
function masonryDrift(reference,actual) {
|
||||
return reference.cards.flatMap((card,index)=>{
|
||||
const above=reference.cards.slice(0,index).filter(other=>Math.abs(other.exact.left-card.exact.left)<1).length;
|
||||
const bound=(above+1)/60+1e-6,other=actual.cards[index];
|
||||
return ['top','height'].filter(key=>!(Math.abs(card.exact[key]-other.exact[key])<=bound))
|
||||
.map(key=>({path:`cards.${index}.exact.${key}`,reference:card.exact[key],actual:other.exact[key],bound}));
|
||||
});
|
||||
}
|
||||
function compareLayout(layout,reference,mapped,actual) {
|
||||
const diff=diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(actual));
|
||||
return layout==='Masonry'?[...diff.filter(item=>!MASONRY_ROUNDED.test(item.path)),...masonryDrift(reference,actual)]:diff;
|
||||
}
|
||||
|
||||
// Two frames, then every running transition (a card image zooming back after the pointer left it) finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const countText=page=>page.locator('#result-count').textContent();
|
||||
const toastText=page=>page.locator('.toast').textContent();
|
||||
|
||||
/** The concept with every fixture image loaded through its own Load more button; returns what the press did. */
|
||||
async function openConceptLoaded(page,theme,motion) {
|
||||
await openConcept(page,'images',theme,motion);await page.mouse.move(0,0);await settle(page);
|
||||
const before=await page.locator(CARDS).count();
|
||||
const loadingRow=(await inventoryOf(page,'#main')).continuation;
|
||||
// A dispatched press: scrolling the button into view would let the concept's own observer load the batch first.
|
||||
// Where the first batch leaves the row in view (1800px, four columns), that observer has already loaded it.
|
||||
if(before<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
|
||||
const load={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page),loadingRow};
|
||||
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page);
|
||||
return load;
|
||||
}
|
||||
|
||||
async function openProductionLoaded(page,theme,motion,total=FIXTURE_IMAGES) {
|
||||
await openProduction(page,'images',theme,motion);await page.mouse.move(0,0);
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${Math.min(total,PRODUCTION_BATCH)} of ${total} images`));
|
||||
await expect(page.locator(CARDS)).toHaveCount(Math.min(total,PRODUCTION_BATCH));
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/** The Import dialog's intro and operation rows (D15). */
|
||||
async function importDialogOf(page) {
|
||||
await page.locator('.heading-actions').getByRole('button',{name:'Import',exact:true}).click();
|
||||
const dialog=page.locator('dialog#modal[open]');
|
||||
await expect(dialog).toBeVisible();
|
||||
const inventory=await dialog.evaluate(node=>{
|
||||
const css=(element,...keys)=>{const style=getComputedStyle(element);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const font=element=>css(element,'fontSize','fontWeight','lineHeight','color');
|
||||
const intro=node.querySelector('#modal-body > p.small');
|
||||
return {title:node.querySelector('#modal-title').textContent,
|
||||
intro:intro&&{text:intro.textContent,...font(intro)},
|
||||
...css(node.querySelector('.operation-list'),'display','rowGap'),
|
||||
rows:[...node.querySelectorAll('.operation-list > .operation')].map(row=>({title:row.querySelector('h3').textContent,
|
||||
description:row.querySelector('p').textContent,buttons:[...row.querySelectorAll('button')].map(button=>button.textContent.trim()),
|
||||
titleFont:font(row.querySelector('h3')),descriptionFont:font(row.querySelector('p')),...css(row,'display','alignItems')}))};
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
return inventory;
|
||||
}
|
||||
|
||||
/** The hovered first card image after its transition, then the pointer parked again. */
|
||||
async function hoverOf(page) {
|
||||
const open=page.locator(CARDS).first().locator('.image-open');
|
||||
await open.hover();
|
||||
// The concept's 0.2s transition (instant under reduced motion) has started and finished.
|
||||
await settle(page);
|
||||
const state=await open.locator('img').evaluate(img=>({hovered:img.closest('.image-open').matches(':hover'),
|
||||
transform:getComputedStyle(img).transform,transitionDuration:getComputedStyle(img).transitionDuration,
|
||||
transitionProperty:getComputedStyle(img).transitionProperty,hoverNone:matchMedia('(hover: none)').matches}));
|
||||
await page.mouse.move(0,0);
|
||||
return state;
|
||||
}
|
||||
|
||||
const activeOf=page=>page.evaluate(()=>{const node=document.activeElement;
|
||||
return {tag:node?.tagName.toLowerCase()??null,label:node?.getAttribute('aria-label')??null,
|
||||
card:node?.closest('.media-card')?[...node.closest('.image-grid').children].indexOf(node.closest('.media-card')):null};});
|
||||
const selectedOf=page=>page.locator(CARDS).evaluateAll(nodes=>nodes.flatMap((node,index)=>node.classList.contains('selected')?[index]:[]));
|
||||
|
||||
const LAYOUTS=['List','Masonry','Grid','Large','Compact'];
|
||||
/** The card boxes in each layout: Grid → List → Masonry → Grid, then the Large and Compact thumbnail sizes. */
|
||||
async function layoutsOf(page,scope) {
|
||||
const result={};
|
||||
for(const layout of LAYOUTS) {
|
||||
if(['Large','Compact'].includes(layout))await page.locator('#image-size').selectOption(layout.toLowerCase());
|
||||
else {
|
||||
await page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true}).click();
|
||||
await expect(page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
}
|
||||
await page.mouse.move(0,0);await settle(page);await settle(page);
|
||||
const inventory=await inventoryOf(page,scope);
|
||||
result[layout]={grid:inventory.grid,cards:inventory.cards};
|
||||
}
|
||||
await page.locator('#image-size').selectOption('comfortable');await settle(page);
|
||||
return result;
|
||||
}
|
||||
|
||||
const continuationText=page=>page.locator('.infinite-continuation > p').first().textContent();
|
||||
/** A search: the count line, the continuation and the cards it leaves, then the search cleared again. */
|
||||
async function searchOf(page,term) {
|
||||
const before=await countText(page);
|
||||
await page.locator('#search').fill(term);
|
||||
await expect.poll(()=>countText(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(FIXTURE_IMAGES);
|
||||
await settle(page);
|
||||
const result={count:await countText(page),continuation:await continuationText(page),cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
|
||||
await page.locator('#search').fill('');
|
||||
await expect(page.locator('#result-count')).toHaveText(before);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors §7 lists, run with identical selectors in both documents. */
|
||||
async function behaviorsOf(page,scope) {
|
||||
const hover=await hoverOf(page);
|
||||
const second=page.locator(CARDS).nth(1).locator('.image-open');
|
||||
const opened=await second.getAttribute('aria-label');
|
||||
await second.click();
|
||||
const viewer=page.locator('#viewer-dialog[open]');
|
||||
await expect(viewer).toBeVisible();
|
||||
const viewerTitle=await page.locator('#viewer-title').textContent();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer).toHaveCount(0);
|
||||
await settle(page);
|
||||
const focusAfterViewer=await activeOf(page);
|
||||
await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click();
|
||||
await expect.poll(()=>selectedOf(page)).toEqual([2]);
|
||||
const selected=await selectedOf(page);
|
||||
const layouts=await layoutsOf(page,scope);
|
||||
const search=await searchOf(page,'alpine');
|
||||
// Exactly one image (a unique filename stem): both counted nouns in the singular (D35).
|
||||
const single=await searchOf(page,'alpine_00003');
|
||||
return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
|
||||
}
|
||||
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
const conceptLoad=await openConceptLoaded(page,theme,motion);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself without the sample tag.
|
||||
const removed={};
|
||||
for(const [key,selector] of [['filterRow','.filter-row > .sample-tag']]) {
|
||||
const restore=await mutate(page,{remove:[selector]});
|
||||
removed[key]=(await inventoryOf(page,'#main'))[key];
|
||||
await restore();
|
||||
}
|
||||
const referenceImport=await importDialogOf(page);
|
||||
const referenceBehaviors=await behaviorsOf(page,'#main');
|
||||
|
||||
await openProductionLoaded(page,theme,motion);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
|
||||
const actualImport=await importDialogOf(page);
|
||||
const actualBehaviors=await behaviorsOf(page,'#workspace-main');
|
||||
result[motion]={conceptLoad,reference,removed,actual,diff:compare(reference,actual,manifest),referenceImport,actualImport,
|
||||
importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
// Load more in production: a fixture past one listing page appends one batch and shows the concept's toast.
|
||||
testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA});
|
||||
for(const motion of MOTIONS) {
|
||||
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
|
||||
await openProductionLoaded(page,theme,motion,total);
|
||||
const before=await page.locator(CARDS).count();
|
||||
const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
|
||||
// A dispatched press, as on the concept side: a pointer click scrolls the row into view first, and the page's
|
||||
// own observer (rootMargin 250px) can append the final batch and hide the button before the click lands
|
||||
// (WebKit, run 39512528039). The button must still be the enabled one the user would press.
|
||||
const more=page.getByRole('button',{name:'Load more images',exact:true});
|
||||
await expect(more).toBeVisible();await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
await more.dispatchEvent('click');
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)};
|
||||
result[motion].loadingDiff=compareLoadingRow(result[motion].conceptLoad.loadingRow,loadingRow,loadingCount);
|
||||
}
|
||||
const path=testInfo.outputPath('images-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('images-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,entry] of Object.entries(result)) {
|
||||
const {conceptLoad:{loadingRow,...conceptLoad},productionLoad,loadingDiff,reference,removed,actual,diff,referenceImport,importDiff,
|
||||
referenceBehaviors:concept,actualBehaviors:production}=entry;
|
||||
// The concept itself is what this comparison assumes: its first batch, its sample card and its copy.
|
||||
expect.soft(conceptLoad,`${motion} concept Load more`).toEqual({before:CONCEPT_BATCH,after:FIXTURE_IMAGES,
|
||||
toast:`${CONCEPT_BATCH} more images loaded. ${FIXTURE_IMAGES} of ${FIXTURE_IMAGES}.`,count:`${FIXTURE_IMAGES} of ${FIXTURE_IMAGES} images · 6 positive prompts`});
|
||||
// D37: production appends one listing page, with the concept's toast and count copy (D35 for one).
|
||||
expect.soft(productionLoad,`${motion} production Load more`).toEqual({before:PRODUCTION_BATCH,after:2*PRODUCTION_BATCH,
|
||||
toast:countCopy(`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`),
|
||||
count:countCopy(`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`)});
|
||||
// D37: the row with more to load, before its press, in both documents.
|
||||
expect.soft(loadingRow.items.map(item=>item.text),`${motion} concept loading row`).toEqual([`${CONCEPT_BATCH} of ${FIXTURE_IMAGES} images · more load as you scroll`,'Load more images']);
|
||||
expect.soft(loadingDiff,`${motion} loading row differences after D32, D35, D37`).toEqual([]);
|
||||
// D11: the concept's subtitle is the fixture's own "W × H · PNG · Seed N".
|
||||
const first=manifest.images[0];
|
||||
expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`);
|
||||
expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px');
|
||||
expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']);
|
||||
// The row model reproduces the concept's own rows, and the concept page itself once the sample tag (D29) is
|
||||
// removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
|
||||
`${motion} filter row without the sample tag`).toEqual([]);
|
||||
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
expect.soft(importDiff,`${motion} Import dialog differences after D15`).toEqual([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
// Hover (C4): the concept's 1.03 zoom over 0.2s, instant under reduced motion; D18: none where there is no hover.
|
||||
expect.soft(concept.hover,`${motion} concept hover`).toMatchObject({hovered:true,transform:'matrix(1.03, 0, 0, 1.03, 0, 0)',
|
||||
transitionDuration:motion==='reduce'?'0s':'0.2s'});
|
||||
expect.soft(production.hover,`${motion} hover`).toEqual(production.hover.hoverNone?{...concept.hover,transform:'none'}:concept.hover);
|
||||
// The 2nd card's image opens the viewer titled "<title> · <filename>".
|
||||
expect.soft(concept.viewerTitle,`${motion} concept viewer title`).toBe(`${reference.cards[1].info.h3.text} · ${concept.opened.replace(/^Open /,'')}`);
|
||||
expect.soft(production.viewerTitle,`${motion} viewer title`).toBe(concept.viewerTitle);
|
||||
// Escape returns focus to the image that opened the viewer.
|
||||
expect.soft(production.focusAfterViewer,`${motion} focus after the viewer`).toEqual({tag:'button',label:concept.opened,card:1});
|
||||
// The concept re-renders its grid when the viewer closes (app.js 1737-1743), so its focus falls to the body;
|
||||
// §7 and D19 keep production's restore, which is what is asserted above.
|
||||
// Info on the 3rd card moves `.media-card.selected` there.
|
||||
expect.soft(production.selected,`${motion} selection after Info`).toEqual(concept.selected);
|
||||
// List (160×95 art) and Masonry (--media-ratio media) as the concept lays them out.
|
||||
expect.soft(concept.layouts.List.cards[0].img,`${motion} concept list art`).toMatchObject({width:160,height:95});
|
||||
for(const layout of LAYOUTS) {
|
||||
const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{manifest});
|
||||
expect.soft(compareLayout(layout,concept.layouts[layout],mapped,production.layouts[layout]),`${motion} ${layout} layout`).toEqual([]);
|
||||
}
|
||||
for(const card of concept.layouts.Masonry.cards) {
|
||||
const [w,h]=card.ratio.split('/').map(Number);
|
||||
expect.soft(Math.abs(card.img.width/card.img.height-w/h),`${motion} concept masonry ratio ${card.open.label}`).toBeLessThan(0.02);
|
||||
}
|
||||
// Searches leave the same images, count line and continuation in both documents (D35 for one).
|
||||
const mappedSearch=search=>({...search,count:countCopy(search.count),continuation:countCopy(search.continuation)});
|
||||
expect.soft(production.search,`${motion} search alpine`).toEqual(mappedSearch(concept.search));
|
||||
expect.soft(concept.single,`${motion} concept single result`).toMatchObject({count:'1 of 1 images · 1 positive prompts',cards:['Open alpine_00003.png']});
|
||||
expect.soft(production.single,`${motion} search for one image`).toEqual(mappedSearch(concept.single));
|
||||
expect.soft(production.single.count,`${motion} singular count line`).toBe('1 of 1 image · 1 positive prompt');
|
||||
}
|
||||
});
|
||||
|
||||
// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the
|
||||
// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the
|
||||
// oracle for the re-laid-out bar.
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
const check=async()=>{
|
||||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
|
||||
await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page);
|
||||
};
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const empty=await inventoryOf(page,'#main');
|
||||
await check();
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||||
const removed=(await inventoryOf(page,'#main')).bulkbar;
|
||||
await restore();
|
||||
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const emptyActual=await inventoryOf(page,'#workspace-main');
|
||||
await check();
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`);
|
||||
// The concept itself: an empty bar, then "1 selected" and its six actions.
|
||||
expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null});
|
||||
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
|
||||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// The bar comparison notices a change to the production bar.
|
||||
await mutate(page,{css:'.bulkbar { padding: 4px }'});
|
||||
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
|
||||
// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
|
||||
test('images comparison detects mutations',async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const manifest=testInfo.manifest;
|
||||
await openConceptLoaded(page,'dark','no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await openProductionLoaded(page,'dark','no-preference');
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertImagesMatch(reference,await measure(),manifest);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.media-info { padding: 4px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertImagesMatch(reference,cssMutated,manifest)).toThrow(/cards\.0\.info\.paddingTop/);
|
||||
await restoreCss();await settle(page);
|
||||
assertImagesMatch(reference,await measure(),manifest);
|
||||
|
||||
await mutate(page,{remove:['[aria-label^="Inspect"]']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/);
|
||||
});
|
||||
|
||||
// Grid, cards and panel at the other concept breakpoints: 2 columns and the 275px panel at 1200, 4 columns and the
|
||||
// 310px panel at 1800.
|
||||
for(const [width,height] of WIDE_SIZES)for(const theme of THEMES)test(`images grid, cards and panel match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
|
||||
expect(reference.grid.columns,'concept columns').toBe(width<=1250?2:4);
|
||||
expect(reference.panel.box.width,'concept panel').toBe(width<=1250?275:310);
|
||||
expect(compare(reference,actual,manifest,['grid','cards','panel'])).toEqual([]);
|
||||
});
|
||||
|
||||
// D35 for the toast and the continuation: production's Load more appending exactly one image, against the concept's
|
||||
// own toast and row copy (app.js 489, 517-521) with production's numbers (D37).
|
||||
test('one appended image reads in the singular',async({page,request})=>{
|
||||
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const total=PRODUCTION_BATCH+1;
|
||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
||||
await openProductionLoaded(page,'dark','no-preference',total);
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
|
||||
// A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
|
||||
await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
|
||||
expect(countCopy('1 more images loaded. 41 of 41.')).toBe('1 more image loaded. 41 of 41.');
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
|
||||
});
|
||||
|
||||
// The same singular copy when the page's own observer loads the one image: the user only scrolls (no press).
|
||||
test('one image appended by scrolling reads in the singular',async({page,request})=>{
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const total=PRODUCTION_BATCH+1;
|
||||
await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
|
||||
await openProductionLoaded(page,'dark','no-preference',total);
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
|
||||
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
|
||||
await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
|
||||
await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
|
||||
await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
|
||||
});
|
||||
|
||||
// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
|
||||
// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line) form the first row at 390px, where
|
||||
// every concept subtitle wraps: their row is as tall as the wrapped rows, and every concept row moves down by one.
|
||||
test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
// A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await openConceptLoaded(page,'dark','no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
expect(reference.grid.columns).toBe(2);
|
||||
expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
|
||||
const manifest=await seedConcept(request,{seedless_images:2});
|
||||
await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
|
||||
const pitch=reference.cards[2].top-reference.cards[0].top;
|
||||
expect(actual.cards.map(box)).toEqual([box(reference.cards[0]),box(reference.cards[1]),
|
||||
...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
|
||||
const seedless=actual.cards.slice(0,2);
|
||||
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
|
||||
expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
|
||||
// Their own footer is one line shorter than the row it reserves.
|
||||
for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} 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.
|
||||
// Both documents select the first listed image, alpine_00001.png (D19: the first load selects it). The fixture links it
|
||||
// to a recipe without a category or tags, so (D39, ruled 2026-10-21) its panel falls back to the positive's own category
|
||||
// and tags, as the concept's does. The panel's box in the gallery layout is the Images spec's. Paired captures are
|
||||
// artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const TITLE='Alpine observatory';
|
||||
const FIXTURE_IMAGES=24;
|
||||
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
|
||||
const PANEL_INSET=40;
|
||||
// D34: coarse pointers keep 40px targets for the viewer details' disclosure summaries and "Download original" (the
|
||||
// concept's coarse rule covers buttons only). The recorded rule, applied to the concept under the same emulation so the
|
||||
// browser lays out what follows the taller summaries; a fine pointer never matches it.
|
||||
const D34_COARSE_TARGETS=`@media (pointer: coarse) {
|
||||
#viewer-details summary { box-sizing: border-box; min-block-size: 40px; padding-block: 10px; }
|
||||
#viewer-details a { display: inline-flex; align-items: center; min-block-size: 40px; }
|
||||
}`;
|
||||
// What an open "File & provenance" offers beyond the concept's text-only disclosure: D38's Show in folder right after
|
||||
// the Path value, then D20's Download original.
|
||||
const FILE_CONTROLS=[{tag:'button',text:'Show in folder',name:'Show alpine_00001.png in folder'},{tag:'a',text:'Download original',name:'Download original'}];
|
||||
|
||||
/** D22: production's confirmed prompt text is focusable, so it follows its block's actions among the controls. */
|
||||
function withFocusablePrompts(inventory) {
|
||||
const controls=[...inventory.controls];
|
||||
for(const section of inventory.sections) {
|
||||
const send=section.actions.buttons.at(-1);
|
||||
const index=controls.findIndex(control=>control.name===send.name);
|
||||
controls.splice(index+1,0,{tag:'p',text:section.text.text,name:section.text.text,pressed:null,primary:false,minHeight:'0px'});
|
||||
}
|
||||
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
|
||||
}
|
||||
|
||||
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
|
||||
function withCategoryScope(inventory) {
|
||||
const [badge,category,...rest]=inventory.badges.chips;
|
||||
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34 and D38); everything else must equal
|
||||
* production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20) and, under a coarse pointer, D34's targets, both at runtime: the browser lays out
|
||||
* what follows them, as the Images spec's removed toolbar items do.
|
||||
*/
|
||||
export function mapInspectorConcept(reference,{viewport}) {
|
||||
const {panel,viewerPlain,fileControls}=reference;
|
||||
return {
|
||||
panel:withCategoryScope(withFocusablePrompts({...panel,
|
||||
// D3: the production-only inspector rules (`.file-inspector`, workspace.css) hook on this class.
|
||||
className:`${panel.className} file-inspector`,
|
||||
// D20/D21 side panel: the sticky panel scrolls inside the window, so long real prompts keep the footer
|
||||
// reachable; at 700px and below, where the concept unsticks it, it grows instead.
|
||||
overflowX:'auto',overflowY:'auto',maxHeight:viewport.width>700?`${viewport.height-PANEL_INSET}px`:'none'})),
|
||||
// D3 as above; D20: the viewer's tag chips are plain (the concept's go to the Library).
|
||||
viewerDetails:withCategoryScope(withFocusablePrompts({...viewerPlain,className:'file-inspector'})),
|
||||
// D38 and D20: the open "File & provenance" offers Show in folder and Download original.
|
||||
fileControls:[...fileControls,...FILE_CONTROLS],
|
||||
};
|
||||
}
|
||||
|
||||
function compare(reference,actual,viewport) {
|
||||
return diffInventory(mapInspectorConcept(reference,{viewport}),actual);
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertInspectorMatch(reference,actual,viewport) {
|
||||
const diff=compare(reference,actual,viewport);
|
||||
if(diff.length)throw new Error(`the inspector differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
const panelOf=page=>page.locator('#image-inspector');
|
||||
|
||||
/**
|
||||
* The concept with every fixture image loaded through its own Load more (it loads 12 per batch, D37), as production
|
||||
* has them: where the panel follows the grid (390px), reaching it would otherwise load the batch mid-comparison.
|
||||
*/
|
||||
async function openConceptPanel(page,theme) {
|
||||
await openConcept(page,'images',theme);await page.mouse.move(0,0);
|
||||
if(await page.locator(CARDS).count()<FIXTURE_IMAGES)await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
|
||||
await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES);
|
||||
await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
|
||||
await settle(page);
|
||||
}
|
||||
async function openProductionPanel(page,theme) {
|
||||
await openProduction(page,'images',theme);await page.mouse.move(0,0);
|
||||
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 settle(page);
|
||||
}
|
||||
|
||||
/** The concept's tag chips in the open viewer as plain `span.chip`s (D20) and D34's coarse targets; returns an undo. */
|
||||
async function plainViewerChips(page) {
|
||||
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
||||
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;
|
||||
button.replaceWith(span);return [span,button];
|
||||
});
|
||||
});
|
||||
await settle(page);
|
||||
return async()=>{
|
||||
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;});
|
||||
await restoreTargets();
|
||||
};
|
||||
}
|
||||
|
||||
/** The controls an open "File & provenance" offers between its summary and the next disclosure's (m5). */
|
||||
async function fileControlsOf(page) {
|
||||
const disclosure=page.locator('#viewer-details details').first();
|
||||
await disclosure.evaluate(node=>{node.open=true;});await settle(page);
|
||||
const {controls}=(await inspectorOf(page)).viewerDetails;
|
||||
await disclosure.evaluate(node=>{node.open=false;});await settle(page);
|
||||
const names=controls.map(control=>control.name);
|
||||
return controls.slice(names.indexOf('File & provenance')+1,names.indexOf('Workflow & raw metadata')).map(({tag,text,name})=>({tag,text,name}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
const restoreMutation=mutation?await mutate(page,mutation):null;
|
||||
if(mutation)await settle(page);
|
||||
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();}
|
||||
await restoreMutation?.();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
|
||||
// The concept re-renders the Images page on the viewer's `close` event (app.js updateImages): let it land first.
|
||||
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
|
||||
return {viewerDetails,viewerPlain,fileControls};
|
||||
}
|
||||
async function inventory(page,concept=false) {
|
||||
const {panel}=await inspectorOf(page);
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
|
||||
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
|
||||
}
|
||||
|
||||
/** A ComfyUI host in a second tab that acknowledges what it applies (the shipped prompt bridge). */
|
||||
async function fakeHost(context) {
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
window.hostValues={positive:'Existing positive',negative:'Existing negative'};window.applied=[];
|
||||
const target={id:'pair-1',label:'My prompt pair',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[role],after}))},snapshot:roles}),
|
||||
apply:roles=>{Object.assign(window.hostValues,roles);window.applied.push(roles);return {targetId:target.id,values:roles};},
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
/** Copy the positive: the concept's outcome is the toast, or its "Copy prompt" dialog where the clipboard is refused. */
|
||||
async function copyOf(page) {
|
||||
// An earlier toast (the concept's Load more) is not the copy's outcome.
|
||||
await panelOf(page).locator('.prompt-actions').first().scrollIntoViewIfNeeded();await settle(page);
|
||||
await page.evaluate(()=>document.querySelectorAll('.toast').forEach(toast=>{toast.textContent='';}));
|
||||
await panelOf(page).getByRole('button',{name:`Copy positive prompt: ${TITLE}`,exact:true}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'Copy prompt',exact:true});
|
||||
await expect.poll(async()=>(await page.locator('.toast').textContent())||(await dialog.count()?'dialog':'')).not.toBe('');
|
||||
const result={toast:await page.locator('.toast').textContent(),dialog:await dialog.count()>0};
|
||||
if(result.dialog){await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0);}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The concept's send: its dialog previews both roles (app.js openSend). */
|
||||
async function conceptSendOf(page) {
|
||||
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const previews=await modal.locator('.send-preview').evaluateAll(nodes=>nodes.map(node=>[node.querySelector('h3').textContent,node.querySelector('p').textContent]));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return previews;
|
||||
}
|
||||
|
||||
/** Production's send: the acknowledged sender, previewing and applying both roles to a chosen node. */
|
||||
async function productionSendOf(page,context) {
|
||||
const host=await fakeHost(context);
|
||||
try {
|
||||
await panelOf(page).getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible();
|
||||
const windows=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(windows.locator('option')).toContainText(['Test ComfyUI']);await windows.selectOption({label:'Test ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'My prompt pair'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
|
||||
await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
const applied=await host.evaluate(()=>window.applied);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();await expect(sender).toHaveCount(0);
|
||||
return applied;
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
await expect.poll(()=>page.locator('#result-count').textContent()).not.toBe(before);
|
||||
await settle(page);
|
||||
return {count:await page.locator('#result-count').textContent(),
|
||||
cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))};
|
||||
}
|
||||
|
||||
const historyOf=async page=>{
|
||||
await panelOf(page).getByRole('button',{name:'Prompt history',exact:true}).click();
|
||||
await expect.poll(()=>page.evaluate(()=>location.hash)).not.toMatch(/images$/);
|
||||
return page.evaluate(()=>location.hash);
|
||||
};
|
||||
|
||||
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector panel geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page,context,browserName},testInfo)=>{
|
||||
test.setTimeout(180_000);
|
||||
// The concept's copy and production's both use the async clipboard; Chromium asks for the permission.
|
||||
if(browserName==='chromium')await context.grantPermissions(['clipboard-read','clipboard-write']);
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
const viewport={width,height};
|
||||
await openConceptPanel(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`concept-inspector-${width}-${theme}`);
|
||||
const reference=await inventory(page,true);
|
||||
const concept={copy:await copyOf(page),send:await conceptSendOf(page),tag:await tagOf(page)};
|
||||
await openConceptPanel(page,theme);concept.history=await historyOf(page);
|
||||
|
||||
await openProductionPanel(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`production-inspector-${width}-${theme}`);
|
||||
const actual=await inventory(page);
|
||||
const production={copy:await copyOf(page),send:await productionSendOf(page,context)};
|
||||
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.get('tags')==='cinematic');
|
||||
production.tag=await tagOf(page);await listing;
|
||||
// D44: the applied tag is one active refinement, so the toolbar's Filters button counts it.
|
||||
await page.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
production.tagFilter=await page.getByRole('dialog').getByRole('textbox',{name:'Tags',exact:true}).inputValue();
|
||||
await page.keyboard.press('Escape');
|
||||
await openProductionPanel(page,theme);production.history=await historyOf(page);
|
||||
|
||||
const diff=compare(reference,actual,viewport);
|
||||
const path=testInfo.outputPath('inspector-geometry.json');
|
||||
await writeFile(path,JSON.stringify({reference,actual,diff,concept,production},null,2));
|
||||
await testInfo.attach('inspector-geometry',{path,contentType:'application/json'});
|
||||
|
||||
// The concept itself is what this comparison assumes (spec §3.3, s523–615).
|
||||
const {panel}=reference,coarse=await page.evaluate(()=>matchMedia('(pointer: coarse)').matches);
|
||||
expect.soft(panel.width,'concept panel width').toBe(width>700?310:width-28);
|
||||
expect.soft(panel.position,'concept panel position').toBe(width>700?'sticky':'static');
|
||||
expect.soft(panel.cover.height,'concept cover').toBe(width>700?150:200);
|
||||
expect.soft(panel.inner.paddingTop,'concept inner padding').toBe('17px');
|
||||
expect.soft(panel.heading.h2.fontSize,'concept h2').toBe('14px');
|
||||
for(const section of panel.sections) {
|
||||
expect.soft(section.label,`concept ${section.className} label`).toMatchObject({fontSize:'10px',letterSpacing:'0.7px',textTransform:'uppercase',fontWeight:'650'});
|
||||
expect.soft(section.text,`concept ${section.className} text`).toMatchObject({fontSize:'12px',lineHeight:'21px'});
|
||||
for(const button of section.actions.buttons)expect.soft(button,`concept ${section.className} action`).toMatchObject(coarse?{minHeight:'36px',minWidth:'36px'}:{minHeight:'27px',minWidth:'28px'});
|
||||
}
|
||||
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
|
||||
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
|
||||
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
|
||||
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
|
||||
|
||||
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);
|
||||
if(browserName==='chromium')expect.soft(production.copy,'copy toast').toEqual({toast:'Prompt copied.',dialog:false});
|
||||
// Send: the concept previews both roles; production applies exactly those texts and ComfyUI acknowledges them.
|
||||
const [positive,negative]=concept.send;
|
||||
expect.soft(concept.send.map(([heading])=>heading),'concept send').toEqual(['+ Positive · replace','− Negative · replace']);
|
||||
expect.soft(production.send,'acknowledged send').toEqual([{positive:positive[1],negative:negative[1]}]);
|
||||
expect.soft(negative[1],'fixture negative').toBe('blurry, low quality, low resolution');
|
||||
// Tag chip: D21, the Images Tags filter (the concept searches the tag's text instead), leaving the same images.
|
||||
expect.soft(production.tagFilter,'tag chip filter').toBe('cinematic');
|
||||
// The concept's search also matches the forest prompt's text ("cinematic still"), which is not tagged cinematic
|
||||
// (app.js 73, 115, 130): production leaves the tagged prompts' images, in the concept's order.
|
||||
const tagged=concept.tag.cards.filter(card=>!card.startsWith('Open forest_'));
|
||||
expect.soft(concept.tag.count,'concept tag search').toBe('12 of 12 images · 3 positive prompts');
|
||||
expect.soft(production.tag,'tag chip collection').toEqual({count:countCopy(`${tagged.length} of ${tagged.length} images · 2 positive prompts`),cards:tagged});
|
||||
// Prompt history: the concept's `#prompt/<id>` is production's Library history of the linked positive.
|
||||
expect.soft(concept.history,'concept history').toBe('#prompt/alpine');
|
||||
expect.soft(production.history,'history').toBe(`#/library/${manifest.prompts[0].positive_id}`);
|
||||
});
|
||||
|
||||
// D39 (ruled 2026-10-21): a recipe-linked image shows its recipe's own category and tags when set, otherwise its
|
||||
// positive's, field by field. The concept fixture's recipes have neither, so this recipe-linked image shows its
|
||||
// positive's, as the concept does, and its tag chips are Tags filter buttons again (D21: the positive's own tags).
|
||||
test('a recipe-linked image without recipe metadata shows its positive prompt metadata (D39)',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
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']);
|
||||
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
|
||||
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
|
||||
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
|
||||
test('inspector comparison detects mutations',async({page})=>{
|
||||
test.setTimeout(120_000);
|
||||
const viewport={width:1600,height:1100};
|
||||
await page.setViewportSize(viewport);
|
||||
await openConceptPanel(page,'dark');
|
||||
const reference=await inventory(page,true);
|
||||
await openProductionPanel(page,'dark');
|
||||
const {viewerDetails,fileControls}=await inventory(page);
|
||||
const measure=async()=>({panel:(await inspectorOf(page)).panel,viewerDetails,fileControls});
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.prompt-heading .role-label { letter-spacing: 0 }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,cssMutated,viewport)).toThrow(/panel\.sections\.0\.label\.letterSpacing/);
|
||||
await restoreCss();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']});
|
||||
expect(()=>assertInspectorMatch(reference,withoutActions,viewport)).toThrow(/viewerDetails\.actions/);
|
||||
const withoutReveal=await viewerMutated({remove:['#viewer-details button[aria-label$=" in folder"]']});
|
||||
expect(()=>assertInspectorMatch(reference,withoutReveal,viewport)).toThrow(/fileControls/);
|
||||
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
||||
|
||||
await mutate(page,{remove:['#image-inspector > .detail-footer']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
||||
|
||||
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
||||
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
||||
// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six
|
||||
// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100
|
||||
// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.library-grid .prompt-card';
|
||||
const CONCEPT_PROMPTS=6;
|
||||
|
||||
/** Recorded Library deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
export function mapLibraryConcept(reference) {
|
||||
const library=reference.library;
|
||||
// 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']};
|
||||
// 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;
|
||||
});
|
||||
// A shorter toolbar moves what follows it up by as much.
|
||||
const rise=library.toolbar.height-toolbar.height;
|
||||
const cards=library.cards.map((card,index)=>({...card,
|
||||
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
|
||||
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
|
||||
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
|
||||
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
|
||||
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
||||
tags:sortedTags(card.tags),
|
||||
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
||||
// run_count (4) and last_used_at (now) 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`)}}}));
|
||||
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)}))},
|
||||
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
|
||||
// D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none.
|
||||
trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}};
|
||||
}
|
||||
|
||||
/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */
|
||||
const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }';
|
||||
const CLAMP_LINES=2;
|
||||
/**
|
||||
* D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the
|
||||
* text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the
|
||||
* grid's rows then close up by their tallest card's loss.
|
||||
*/
|
||||
export function clampedCards({grid,cards}) {
|
||||
const shrunk=cards.map(card=>{
|
||||
const text=card.section.text;
|
||||
if(text.lines<=CLAMP_LINES)return {card,loss:0};
|
||||
const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight));
|
||||
return {loss,card:{...card,height:card.height-loss,
|
||||
content:{...card.content,height:card.content.height-loss},
|
||||
section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}},
|
||||
meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss},
|
||||
footer:{...card.footer,top:card.footer.top-loss}}};
|
||||
});
|
||||
// Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above.
|
||||
const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b);
|
||||
const rowLoss=top=>{
|
||||
const row=cards.flatMap((card,index)=>card.top===top?[index]:[]);
|
||||
return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height));
|
||||
};
|
||||
const above=top=>tops.filter(other=>other<top).reduce((sum,other)=>sum+rowLoss(other),0);
|
||||
return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)},
|
||||
cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))};
|
||||
}
|
||||
|
||||
/** D43: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */
|
||||
export function sortedTags(tags) {
|
||||
return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text)));
|
||||
}
|
||||
|
||||
const SECTIONS=['heading','library','noPageScroll'];
|
||||
function compare(reference,actual,{sections=SECTIONS,library=null}={}) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library
|
||||
?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]]));
|
||||
return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
function assertLibraryMatch(reference,actual) {
|
||||
const diff=compare(reference,actual);
|
||||
if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||||
}
|
||||
|
||||
// Two frames, then every running transition finished.
|
||||
const settle=page=>page.evaluate(async()=>{
|
||||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||||
});
|
||||
// Every card image decoded, so nothing moves while it is measured.
|
||||
const decoded=page=>page.evaluate(async()=>{
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
|
||||
});
|
||||
|
||||
const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant';
|
||||
/**
|
||||
* The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural
|
||||
* reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own
|
||||
* layout. The concept page is restored afterwards.
|
||||
*/
|
||||
async function conceptInventory(page) {
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await page.evaluate(({plural,singular})=>{
|
||||
window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural);
|
||||
for(const chip of window.__conceptPlurals)chip.textContent=singular;
|
||||
},{plural:PLURAL_ONE,singular:SINGULAR_ONE});
|
||||
const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta);
|
||||
await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE);
|
||||
return {...reference,oracle:{meta}};
|
||||
}
|
||||
|
||||
/** The concept page with each card's tag chips in alphabetical order (the D43 oracle); returns the undo. */
|
||||
async function sortConceptTags(page) {
|
||||
await page.evaluate(()=>{
|
||||
const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return x<y?-1:x>y?1:0;};
|
||||
window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{
|
||||
const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];});
|
||||
});
|
||||
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
|
||||
}
|
||||
|
||||
async function openConceptLibrary(page,theme,motion) {
|
||||
await openConcept(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
async function openProductionLibrary(page,theme,motion) {
|
||||
await openProduction(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
|
||||
// The category list is read once per visit; it is part of the toolbar inventory.
|
||||
await expect(page.locator('#category-filter option')).toHaveCount(5);
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
|
||||
const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents();
|
||||
const captionOf=page=>page.locator('.section-caption > span').first().textContent();
|
||||
|
||||
/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */
|
||||
async function filteredOf(page,apply,undo) {
|
||||
const before=await captionOf(page);
|
||||
await apply();
|
||||
await expect.poll(()=>captionOf(page)).not.toBe(before);
|
||||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
|
||||
const result={caption:await captionOf(page),titles:await titlesOf(page)};
|
||||
await undo();
|
||||
await expect.poll(()=>captionOf(page)).toBe(before);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */
|
||||
async function historyFrom(page,control,back) {
|
||||
const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim();
|
||||
await page.locator(CARDS).first().locator(control).click();
|
||||
const h1=page.locator('h1#page-title');
|
||||
await expect(h1).toHaveText(title);
|
||||
const result={title,h1:(await h1.textContent()).trim()};
|
||||
await back();
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */
|
||||
async function behaviorsOf(page,{back,sent}) {
|
||||
const first=page.locator(CARDS).first();
|
||||
const title=await historyFrom(page,'.card-title > button',back);
|
||||
const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back);
|
||||
// The footer send opens the sender with the positive prompt only.
|
||||
const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim();
|
||||
await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click();
|
||||
const send={positive,roles:await sent(page)};
|
||||
// Copy writes the full positive and shows the toast.
|
||||
await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click();
|
||||
await expect(page.locator('.toast')).not.toHaveText('');
|
||||
const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))};
|
||||
const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill(''));
|
||||
const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption(''));
|
||||
return {title,explore,send,copy,search,category};
|
||||
}
|
||||
|
||||
/** The concept's demo sender: the roles its preview lists, with their text. */
|
||||
async function conceptSent(page) {
|
||||
const modal=page.locator('dialog#modal[open]');
|
||||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||||
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
|
||||
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
|
||||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||||
return roles;
|
||||
}
|
||||
|
||||
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
|
||||
function productionSent(host) {
|
||||
return async page=>{
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
|
||||
return roles;
|
||||
};
|
||||
}
|
||||
async function openHost(context) {
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
|
||||
window.previewed=[];
|
||||
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
|
||||
listTargets:()=>[target],
|
||||
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
|
||||
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
|
||||
apply:roles=>({targetId:target.id,values:roles}),
|
||||
}});
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{
|
||||
testInfo.manifest=await seedConcept(request);
|
||||
// Both documents copy through the clipboard; the stub records what they wrote.
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
});
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
|
||||
test.setTimeout(240_000);
|
||||
await page.setViewportSize({width,height});
|
||||
const narrow=width<=700;
|
||||
const host=await openHost(context);
|
||||
try {
|
||||
const result={};
|
||||
for(const motion of MOTIONS) {
|
||||
const capture=motion==='no-preference';
|
||||
await openConceptLibrary(page,theme,motion);
|
||||
const reference=await conceptInventory(page);
|
||||
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
|
||||
// The browser as the re-layout's oracle: the concept page itself with the D23 clamp applied to its cards.
|
||||
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
|
||||
const clamped=(await inventoryOf(page,'#main')).library;
|
||||
await restoreClamp();
|
||||
// ...and with every card's tag chips put in alphabetical order (D43).
|
||||
const restoreTags=await sortConceptTags(page);
|
||||
const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags);
|
||||
await restoreTags();
|
||||
const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null;
|
||||
|
||||
await openProductionLibrary(page,theme,motion);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
|
||||
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
|
||||
result[motion]={reference,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||||
}
|
||||
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||||
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
|
||||
|
||||
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||||
const {grid,cards}=reference.library,card=cards[0];
|
||||
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
|
||||
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
|
||||
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
|
||||
const art=narrow?150:170;
|
||||
expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'});
|
||||
const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat);
|
||||
expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1);
|
||||
expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]);
|
||||
const pad=narrow?'15px':'18px';
|
||||
expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad});
|
||||
expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'});
|
||||
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(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([]);
|
||||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||||
if(!concept)continue;
|
||||
// Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes
|
||||
// the full positive with the concept's toast; search and category narrow the shelf alike.
|
||||
expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text});
|
||||
expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive});
|
||||
expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive});
|
||||
expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']);
|
||||
// D35: the shelf caption uses the singular for one.
|
||||
const counted=filter=>({...filter,caption:countCopy(filter.caption)});
|
||||
expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)});
|
||||
}
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
|
||||
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width:1800,height:1100});
|
||||
await openConceptLibrary(page,theme,'no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`);
|
||||
await openProductionLibrary(page,theme,'no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`);
|
||||
expect(reference.library.grid.columns,'concept columns').toBe(3);
|
||||
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
|
||||
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
|
||||
test('library comparison detects mutations',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConceptLibrary(page,'dark','no-preference');
|
||||
const reference=await conceptInventory(page);
|
||||
await openProductionLibrary(page,'dark','no-preference');
|
||||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||||
assertLibraryMatch(reference,await measure());
|
||||
|
||||
const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'});
|
||||
const cssMutated=await measure();
|
||||
expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/);
|
||||
await restoreCss();await settle(page);
|
||||
assertLibraryMatch(reference,await measure());
|
||||
|
||||
await mutate(page,{remove:['.card-meta .chip.red']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/);
|
||||
});
|
||||
@@ -19,7 +19,7 @@ for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`e
|
||||
await page.setViewportSize({width,height});const f=page.fixture;
|
||||
await page.goto(`${workspace}#/library/${f.positive_id}`);await expect(variants(page)).toBeVisible();
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-variants':'dialog.library-editor');
|
||||
const shot=name=>captureCanonicalState(page,testInfo,`production-${name}-${width}-${theme}`,name.startsWith('editor-archived')?'.library-history .timeline-panel':'dialog.library-editor');
|
||||
|
||||
await variants(page).getByRole('button',{name:'Select negative variant 2',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(editor(page)).toBeVisible();
|
||||
|
||||
@@ -2,9 +2,9 @@ import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mj
|
||||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('region',{name:'Image details',exact:true});
|
||||
const details=page=>page.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
const role=(scope,name)=>scope.getByRole('region',{name:`${name} prompt`,exact:true});
|
||||
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Open /}).click();
|
||||
const select=async(page,key)=>page.locator(`article[data-image-key="${key}"]`).getByRole('button',{name:/^Inspect /}).click();
|
||||
const dialog=page=>page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
@@ -45,10 +45,10 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK();
|
||||
const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0'));
|
||||
await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed');
|
||||
await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
await page.locator('article[data-image-key="0:shared/same.png"] .image-open').click();const preview=page.locator('dialog#viewer-dialog');
|
||||
const message='The source changed. Refresh the image collection before inspecting this file.';
|
||||
await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0);
|
||||
const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true});
|
||||
const original=preview.locator('#viewer-toolbar').getByLabel('Download original',{exact:true});
|
||||
await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./);
|
||||
let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0);
|
||||
await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden();
|
||||
@@ -58,8 +58,8 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0);
|
||||
const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope);
|
||||
const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json();
|
||||
await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text);
|
||||
await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
await select(page,'0:shared/same.png');await page.locator('.media-card.selected .image-open').click();await expect(preview).toContainText(evidence.prompts.positive.text);
|
||||
await preview.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]);
|
||||
} finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
});
|
||||
@@ -67,17 +67,18 @@ test('collection viewer rejects stale source and sends only refreshed API eviden
|
||||
test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark');
|
||||
await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
|
||||
await select(page,'1:shared/same.png');await expect(role(details(page),'Positive')).toContainText('Dog landscape');
|
||||
await expect(role(details(page),'Negative')).toContainText('low quality');await expect(details(page)).not.toContainText('Cat portrait');
|
||||
});
|
||||
|
||||
test('explicit empty negative is sendable while unrecorded text is never invented',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:empty-negative.png');
|
||||
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
|
||||
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toBeEnabled();
|
||||
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toBeEnabled();
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt$/})).toHaveCount(0);
|
||||
// Every inspector send is named after its prompt ("…to ComfyUI: <title>"); an untitled one ends at "ComfyUI".
|
||||
await expect(details(page).getByRole('button',{name:/^Send (positive|negative) prompt to ComfyUI(: |$)/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inspection failures retry and a delayed previous selection cannot replace the current prompt',async({page})=>{
|
||||
@@ -100,7 +101,7 @@ test('inspection failures retry and a delayed previous selection cannot replace
|
||||
test('sender requires explicit destination, previews full changes and acknowledges only the applied pair',async({page,context},testInfo)=>{
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=dialog(page);await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
const chosen=await chooseDestination(page);await chosen.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
for(const text of ['Existing positive','Existing negative','Cat portrait','blurry, watermark','trigger Cat portrait detail'])await expect(chosen).toContainText(text);
|
||||
@@ -113,10 +114,10 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-dark.png'),fullPage:true});
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption('light');
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-light.png'),fullPage:true});
|
||||
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await page.locator('.media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-prompts.png')});
|
||||
await page.getByRole('button',{name:'Close preview',exact:true}).click();
|
||||
await page.getByRole('button',{name:'Close image viewer',exact:true}).click();
|
||||
await page.setViewportSize({width:390,height:844});await details(page).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({path:testInfo.outputPath('inspector-mobile.png')});
|
||||
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
|
||||
@@ -137,7 +138,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
|
||||
await select(page,'0:uncertain.png');await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await link.click();await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true]);
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeEnabled();
|
||||
await select(page,'0:shared/same.png');await expect(details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /})).toBeEnabled();
|
||||
await link.click();
|
||||
const sender=await chooseDestination(page);
|
||||
expect(await page.evaluate(()=>window.followed)).toEqual([true,true,false]);
|
||||
@@ -157,7 +158,7 @@ test('ComfyUI ↗ opens the pair send for a selected resolved image, else follow
|
||||
test('host apply rejection retains the current text and requires a new preview',async({page,context})=>{
|
||||
const host=await fakeHost(context,page,{rejectApply:true});
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText(/Workflow changed/);
|
||||
await expect(sender.getByRole('button',{name:'Apply to ComfyUI',exact:true})).toBeDisabled();
|
||||
@@ -167,9 +168,9 @@ test('host apply rejection retains the current text and requires a new preview',
|
||||
|
||||
test('nested viewer sender Escape restores focus without closing the image and navigation disposes both overlays',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
await page.locator('.media-card.selected .image-open').click();
|
||||
const viewer=page.locator('dialog[open]').filter({has:page.locator('.viewer-canvas')});await expect(viewer).toBeVisible();
|
||||
const send=viewer.getByRole('button',{name:'Send positive prompt',exact:true});await send.click();await expect(dialog(page)).toBeVisible();
|
||||
const send=viewer.getByRole('button',{name:/^Send positive prompt to ComfyUI: /});await send.click();await expect(dialog(page)).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(dialog(page)).toBeHidden();await expect(viewer).toBeVisible();await expect(send).toBeFocused();
|
||||
await send.click();await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
@@ -178,7 +179,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:empty-negative.png');
|
||||
await details(page).getByRole('button',{name:'Send negative prompt',exact:true}).click();
|
||||
await details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /}).click();
|
||||
const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.hostValues)).toEqual({positive:'Existing positive',negative:''});
|
||||
@@ -187,7 +188,7 @@ test('empty negative send clears only the negative destination and preserves its
|
||||
|
||||
test('no open ComfyUI window is actionable and rediscovery finds a newly opened host',async({page,context})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
await details(page).getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
await details(page).getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
const sender=dialog(page);await expect(sender).toContainText('No compatible ComfyUI windows found.');
|
||||
const host=await fakeHost(context,page);
|
||||
try {
|
||||
@@ -203,12 +204,13 @@ test('mobile inspector exposes full long prompts, truthful uncertainty and copy
|
||||
await page.addInitScript(()=>Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async()=>{throw new Error('Permission denied');}}}));
|
||||
await page.route('**/images/inspect/**',route=>route.fulfill({json:{success:true,root_scope:new URL(route.request().url()).searchParams.get('root_scope'),image:{root_index:0,path:'shared/same.png',filename:'same.png'},prompts:{positive:{status:'resolved',text,source:'execution'},negative:{status:'ambiguous',text:null,source:null}}}}));
|
||||
await page.setViewportSize({width:390,height:844});await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
const positive=role(details(page),'Positive');await expect(positive.locator('pre')).toHaveText(text);
|
||||
const positive=role(details(page),'Positive');await expect(positive.locator('p.prompt-text')).toHaveText(text);
|
||||
await expect(role(details(page),'Negative')).toContainText('Multiple possible prompts. Source could not be determined.');
|
||||
await expect(details(page).getByRole('button',{name:'Send negative prompt',exact:true})).toHaveCount(0);
|
||||
await positive.getByRole('button',{name:'Copy positive prompt',exact:true}).click();await expect(positive).toContainText('Copy unavailable. Select the prompt text to copy it.');
|
||||
await expect(details(page).getByRole('button',{name:/^Send negative prompt to ComfyUI: /})).toHaveCount(0);
|
||||
await positive.getByRole('button',{name:/^Copy positive prompt: /}).click();await expect(page.getByRole('dialog',{name:'Copy prompt',exact:true})).toBeVisible();
|
||||
await page.getByRole('button',{name:'Close dialog',exact:true}).click();
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await positive.getByRole('button',{name:'Send positive prompt',exact:true}).click();
|
||||
await positive.getByRole('button',{name:/^Send positive prompt to ComfyUI: /}).click();
|
||||
await expect(dialog(page)).toBeVisible();const bounds=await dialog(page).boundingBox();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);expect(bounds.x+bounds.width).toBeLessThanOrEqual(391);
|
||||
});
|
||||
@@ -227,8 +229,9 @@ test('filtering away a selected image aborts its pending metadata request',async
|
||||
try {
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');await requested;
|
||||
const baseline=await page.evaluate(()=>window.inspectionAborts);
|
||||
await page.getByRole('searchbox',{name:'Search filenames',exact:true}).fill('no-such-file');
|
||||
await expect(page.getByText('No matching images or media. Try changing the filters.',{exact:true})).toBeVisible();
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('no-such-file');
|
||||
await expect(page.getByText('No matching images · 0 images',{exact:true})).toBeVisible();
|
||||
await expect(page.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
|
||||
await expect.poll(()=>page.evaluate(()=>window.inspectionAborts),{timeout:1500}).toBeGreaterThan(baseline);
|
||||
} finally {release();}
|
||||
});
|
||||
@@ -247,10 +250,10 @@ test('responsive gallery defers observed size writes and still fits the new view
|
||||
};
|
||||
});
|
||||
await page.setViewportSize({width:1280,height:900});await page.goto(workspace);
|
||||
await expect(page.locator('.image-card')).toHaveCount(4);
|
||||
await expect(page.locator('.media-card')).toHaveCount(4);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await expect.poll(()=>page.locator('.image-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
|
||||
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(383.75,0);
|
||||
await expect.poll(()=>page.locator('.media-card').first().evaluate(node=>node.getBoundingClientRect().width)).toBeLessThan(390);
|
||||
await expect.poll(()=>page.locator('.image-grid').evaluate(node=>Number.parseFloat(node.style.height))).toBeCloseTo(384.4,0); // the concept's 1.33 media plus the measured info row
|
||||
expect(await page.evaluate(()=>window.inlineResizeWrites)).toBe(0);
|
||||
});
|
||||
|
||||
@@ -276,20 +279,37 @@ test('unresolved roles name the blocking node as text and offer no prompt action
|
||||
await expect(details(page).getByRole('button',{name:/^(Send|Copy) /})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('prompt role headings contain compact real actions and honest source evidence',async({page})=>{
|
||||
test('prompt role headings contain the concept label and compact real actions without a source line',async({page})=>{
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
for (const name of ['Positive','Negative']) {
|
||||
const block=role(details(page),name),heading=block.locator('.prompt-block-heading');
|
||||
await expect(heading.getByRole('heading',{name:`${name} prompt`,exact:true})).toBeVisible();
|
||||
await expect(heading.getByRole('button',{name:`Copy ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
|
||||
await expect(heading.getByRole('button',{name:`Send ${name.toLowerCase()} prompt`,exact:true})).toBeVisible();
|
||||
for (const [name,label] of [['Positive','+ Positive prompt'],['Negative','\u2212 Negative prompt']]) {
|
||||
const block=role(details(page),name),heading=block.locator('.prompt-heading');
|
||||
await expect(block).toHaveClass(new RegExp(`^prompt-section ${name.toLowerCase()}$`));
|
||||
await expect(heading.locator('span.role-label')).toHaveText(label);
|
||||
await expect(heading.locator('.prompt-actions button.icon-btn').first()).toHaveAccessibleName(new RegExp(`^Copy ${name.toLowerCase()} prompt: same\\.png$`));
|
||||
await expect(heading.locator('.prompt-actions button.send.icon-btn')).toHaveAccessibleName(new RegExp(`^Send ${name.toLowerCase()} prompt to ComfyUI: same\\.png$`));
|
||||
await expect(heading.locator('[data-copy-role] svg')).toHaveCount(1);
|
||||
expect(await heading.evaluate(node=>node.nextElementSibling.classList.contains('prompt-source'))).toBe(true);
|
||||
expect(await heading.evaluate(node=>node.nextElementSibling.matches('p.prompt-text')&&!node.nextElementSibling.nextElementSibling)).toBe(true);
|
||||
}
|
||||
await expect(role(details(page),'Positive').locator('.prompt-source')).toHaveText('Inferred from the saved workflow');
|
||||
await expect(details(page)).not.toContainText('Inferred from the saved workflow');
|
||||
await select(page,'0:empty-negative.png');
|
||||
await expect(role(details(page),'Negative')).toContainText('Empty negative prompt');
|
||||
await expect(role(details(page),'Negative').locator('.prompt-block-heading [data-copy-role]')).toHaveCount(1);
|
||||
await expect(role(details(page),'Negative').locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(role(details(page),'Negative').locator('.prompt-heading [data-copy-role]')).toHaveCount(1);
|
||||
await select(page,'0:uncertain.png');await expect(details(page).locator('[data-copy-role]')).toHaveCount(0);
|
||||
await expect(details(page)).toContainText('Not recorded in this file.');
|
||||
await expect(details(page).locator('p.prompt-text.muted').first()).toHaveText('Not recorded in this file.');
|
||||
});
|
||||
|
||||
test('copy toast and fallback dialog',async({page})=>{
|
||||
await page.addInitScript(()=>{window.copied=[];window.clipboardFails=false;Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{if(window.clipboardFails)throw new Error('Permission denied');window.copied.push(text);}}});});
|
||||
await page.goto(workspace);await select(page,'0:shared/same.png');
|
||||
const positive=role(details(page),'Positive'),text=await positive.locator('p.prompt-text').textContent();
|
||||
const copy=positive.getByRole('button',{name:/^Copy positive prompt: /});await copy.click();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Prompt copied.');expect(await page.evaluate(()=>window.copied)).toEqual([text]);
|
||||
await expect(positive.getByRole('status')).toHaveCount(0);
|
||||
await page.evaluate(()=>{window.clipboardFails=true;});await copy.click();
|
||||
const fallback=page.getByRole('dialog',{name:'Copy prompt',exact:true});await expect(fallback).toBeVisible();
|
||||
const area=fallback.getByRole('textbox',{name:'Select and copy this text',exact:true});
|
||||
await expect(area).toHaveValue(text);await expect(area).toBeFocused();
|
||||
expect(await area.evaluate(node=>[node.readOnly,node.selectionStart,node.selectionEnd])).toEqual([true,0,text.length]);
|
||||
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
|
||||
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
|
||||
});
|
||||
|
||||
+91
-5
@@ -16,6 +16,7 @@ from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from tests.browser.workspace_concept_fixture import (
|
||||
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
|
||||
MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
seed_workspace_concept,
|
||||
)
|
||||
from tests.browser.workspace_visual_fixture import seed_workspace_visual
|
||||
@@ -75,6 +76,53 @@ class TestModel:
|
||||
return ["generated", Path(image_path).parent.parent.name]
|
||||
|
||||
|
||||
REVEAL_OUTCOMES = ("opened", "unavailable", "fail")
|
||||
|
||||
|
||||
def register_reveal_stub(routes, api, root: Path):
|
||||
"""D38: Show in folder never opens a real file manager here.
|
||||
|
||||
The API's opener records the resolved path; ``POST /__test__/reveal``
|
||||
sets the next outcome and ``GET /__test__/reveal`` lists the opened paths.
|
||||
"""
|
||||
from utils.file_reveal import OpenerUnavailable, RevealGate
|
||||
|
||||
state = {"outcome": "opened", "opened": []}
|
||||
|
||||
def opener(path: Path, gallery_root: Path) -> None:
|
||||
if state["outcome"] == "unavailable":
|
||||
raise OpenerUnavailable()
|
||||
if state["outcome"] == "fail":
|
||||
raise OSError("stubbed start failure")
|
||||
state["opened"].append(Path(path).relative_to(root.resolve()).as_posix())
|
||||
|
||||
api._reveal_opener = opener
|
||||
# Each click gets an answer here; the cooldown is a unit-tested default.
|
||||
api._reveal_gate = RevealGate(cooldown=0)
|
||||
|
||||
@routes.post("/__test__/reveal")
|
||||
async def set_reveal_outcome(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"outcome"}
|
||||
or data["outcome"] not in REVEAL_OUTCOMES
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
state["outcome"] = data["outcome"]
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/reveal")
|
||||
async def reveal_calls(request: web.Request) -> web.Response:
|
||||
return web.json_response({"opened": state["opened"]})
|
||||
|
||||
def reset() -> None:
|
||||
state["outcome"] = "opened"
|
||||
state["opened"].clear()
|
||||
|
||||
return types.SimpleNamespace(reset=reset)
|
||||
|
||||
|
||||
def create_app(root: Path) -> web.Application:
|
||||
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
|
||||
os.chdir(root)
|
||||
@@ -110,6 +158,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
|
||||
async def canonical_asset(request: web.Request) -> web.Response:
|
||||
filename, content_type = CANONICAL_ASSETS[request.path]
|
||||
@@ -132,6 +181,8 @@ def create_app(root: Path) -> web.Application:
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -309,6 +360,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_fixture = manifest
|
||||
return web.json_response({"success": True, **manifest})
|
||||
history_commands.reset()
|
||||
reveal_stub.reset()
|
||||
workspace_visual = data.get("workspace_visual", False)
|
||||
full_inspector = data.get("full_inspector", False)
|
||||
extra = data.get("extra_images", 0)
|
||||
@@ -388,25 +440,59 @@ def create_app(root: Path) -> web.Application:
|
||||
"""Seed the concept-shaped fixture (D30); only a bounded image count."""
|
||||
nonlocal history_fixture
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
limits = {
|
||||
"extra_images": CONCEPT_MAX_EXTRA_IMAGES,
|
||||
"seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
|
||||
}
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - {"extra_images"}
|
||||
or isinstance(data.get("extra_images", 0), bool)
|
||||
or not isinstance(data.get("extra_images", 0), int)
|
||||
or not 0 <= data.get("extra_images", 0) <= CONCEPT_MAX_EXTRA_IMAGES
|
||||
or set(data) - set(limits)
|
||||
or any(
|
||||
isinstance(data.get(key, 0), bool)
|
||||
or not isinstance(data.get(key, 0), int)
|
||||
or not 0 <= data.get(key, 0) <= limit
|
||||
for key, limit in limits.items()
|
||||
)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
history_commands.reset()
|
||||
library_editor_commands.reset()
|
||||
reveal_stub.reset()
|
||||
history_fixture = None
|
||||
manifest = await api._run_in_executor(
|
||||
lambda: seed_workspace_concept(
|
||||
api, root, extra_images=data.get("extra_images", 0)
|
||||
api,
|
||||
root,
|
||||
extra_images=data.get("extra_images", 0),
|
||||
seedless_images=data.get("seedless_images", 0),
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.post("/__test__/saved-views/raw")
|
||||
async def insert_raw_saved_view(request: web.Request) -> web.Response:
|
||||
"""Store a view row as an older build wrote it, bypassing the API's rules."""
|
||||
data = await request.json()
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or set(data) != {"page", "name", "state"}
|
||||
or not all(isinstance(data[key], str) for key in data)
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
|
||||
def insert() -> int:
|
||||
with api.db.model.get_connection() as conn:
|
||||
return conn.execute(
|
||||
"INSERT INTO saved_views (page, name, state, position, "
|
||||
"created_at, updated_at) VALUES (?, ?, ?, 0, 'x', 'x')",
|
||||
(data["page"], data["name"], data["state"]),
|
||||
).lastrowid
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "id": await api._run_in_executor(insert)}
|
||||
)
|
||||
|
||||
@routes.post("/__test__/add-nested-image")
|
||||
async def add_nested_image(request: web.Request) -> web.Response:
|
||||
destination = root / "output" / "project" / "day" / "new-output.png"
|
||||
|
||||
@@ -56,13 +56,23 @@ function remainingFooter(footer,spans) {
|
||||
// its `activity` pulse stays reserved for the Activity row D5 omits, so the two entries never share one glyph.
|
||||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||||
|
||||
/**
|
||||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
|
||||
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
|
||||
*/
|
||||
function savedNav(nav) {
|
||||
const rows=nav.rows.filter(row=>row.text!=='Needs review');
|
||||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||||
}
|
||||
|
||||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||||
function mapShellConcept(reference) {
|
||||
const {chrome}=reference;
|
||||
const navs=chrome.navs
|
||||
// D28: SAVED VIEWS and COLLECTIONS are proposals with no persisted store, so their navs are not rendered.
|
||||
.filter(nav=>nav.id!=='saved-nav'&&nav.id!=='collections-nav')
|
||||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||||
.filter(nav=>nav.id!=='collections-nav')
|
||||
.map(nav=>{
|
||||
if(nav.id==='saved-nav')return savedNav(nav);
|
||||
if(nav.id!=='system-nav')return nav;
|
||||
const system=rowNamed(nav,'System');
|
||||
return {...nav,rows:[
|
||||
@@ -76,8 +86,9 @@ function mapShellConcept(reference) {
|
||||
return {...reference,chrome:{...chrome,
|
||||
// D29: no prototype suffix in the document title.
|
||||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||||
// D28: only the WORKSPACE group label remains.
|
||||
labels:chrome.labels.filter(label=>label.text==='WORKSPACE'),
|
||||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||||
navs,
|
||||
connection:{...chrome.connection,
|
||||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||||
@@ -199,6 +210,30 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry
|
||||
}
|
||||
});
|
||||
|
||||
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
|
||||
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
|
||||
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const save=async(action,mainSelector)=>{
|
||||
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
|
||||
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
|
||||
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
|
||||
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
|
||||
await dialog.getByRole('button',{name:action,exact:true}).click();
|
||||
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
|
||||
await page.mouse.move(0,0);
|
||||
return inventoryOf(page,mainSelector);
|
||||
};
|
||||
await openConcept(page,'images','dark');
|
||||
const reference=await save('Apply in prototype','#main');
|
||||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||||
await openProduction(page,'images','dark');
|
||||
const actual=await save('Save','#workspace-main');
|
||||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||||
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
|
||||
});
|
||||
|
||||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||||
@@ -220,13 +255,13 @@ test('the concept fixture renders the concept inspector facts',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await openProduction(page,'images','dark');
|
||||
// The first image is the concept's first sample, selected into the panel on first load (D19).
|
||||
const facts=page.locator('#workspace-main .generation-facts');
|
||||
const facts=page.locator('#workspace-main #image-inspector .meta-grid');
|
||||
await expect(facts).toBeVisible();
|
||||
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
|
||||
// app.js:404-408: the concept's facts for alpine_00001.png.
|
||||
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
|
||||
// D21 maps execution evidence to "Execution recorded" in PR D; today's panel names the same source this way.
|
||||
await expect(page.locator('#workspace-main').getByText('Execution prompt evidence',{exact:true})).toBeVisible();
|
||||
// D21: execution evidence is the concept's "Execution recorded" badge.
|
||||
await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded');
|
||||
});
|
||||
|
||||
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
|
||||
|
||||
@@ -9,8 +9,9 @@ const dialog=page=>page.getByRole('dialog',{name:'Recorded run',exact:true});
|
||||
const saved=page=>page.getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
|
||||
const open=async(page,id)=>{await page.goto(`/prompt_manager/workspace#/library/${id}`);await expect(recorded(page)).toBeVisible();};
|
||||
// A history opens on its first negative variant, as in the concept; these contracts start from the whole shared gallery.
|
||||
const showAllImages=async page=>{const all=page.getByRole('button',{name:'All images for this positive',exact:true});await all.click();await expect(all).toHaveAttribute('aria-pressed','true');};
|
||||
// A history opens on its first negative variant, as in the concept; these contracts start from the lane that links the
|
||||
// fixture image (#249 D26: All images for this positive is a dialog now, not a gallery filter).
|
||||
const showLinkedImage=async page=>{const dot=page.getByRole('button',{name:'Select negative Saved exact negative',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');};
|
||||
const marker=(page,id,anchor='10')=>recorded(page).locator(`button[data-run-id="${id}"][data-anchor-id="${anchor}"]`);
|
||||
const exact=async locator=>locator.evaluate(node=>node.textContent);
|
||||
|
||||
@@ -93,7 +94,7 @@ test('same-path replacement is current content and a during-inspection change is
|
||||
|
||||
test('root reconfiguration rejects in-flight candidates and every old scope URL without changing receipts',async({page,request})=>{
|
||||
const f=page.fixture,window=await historyRead(request,f.detail_id),base=`/prompt_manager/history/runs/${f.runs.shared}`;
|
||||
await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const before=await(await request.get(`${base}/outputs?epoch=${window.history_epoch}`)).json(),media=before.items[0].current_candidate.media;
|
||||
await command(request,{action:'arm',boundary:'candidate'});const pending=request.get(`${base}/outputs?epoch=${window.history_epoch}`);
|
||||
try {
|
||||
@@ -128,10 +129,10 @@ test('archive delete merge and prune preserve frozen evidence without repairing
|
||||
});
|
||||
|
||||
test('actual restore with reused numeric owner rejects stale history and offers explicit saved recovery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const f=page.fixture;await open(page,f.detail_id);const original=await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
const source=await request.get('/__test__/history/reused-snapshot');await expect(source).toBeOK();await restoreSnapshot(request,await source.body());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(original);
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(saved(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(original);
|
||||
await expect(recorded(page).getByRole('button',{name:'Reload recorded history',exact:true})).toBeVisible();
|
||||
await page.getByRole('button',{name:'Reload saved Library view',exact:true}).click();await expect(saved(page)).toContainText('Restored owner with reused numeric ID');
|
||||
expect((await request.get(`/prompt_manager/history/runs/${f.runs.shared}`)).status()).toBe(404);
|
||||
@@ -163,8 +164,8 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
|
||||
const fixture=page.fixture;let galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.positive_id}/images`)galleryReads++;});
|
||||
await open(page,fixture.positive_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${fixture.positive_id}`)).json();
|
||||
const selected=detail.variants.findIndex(v=>v.negative_text==='A negative')+1;
|
||||
await page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true}).click();
|
||||
const selected=detail.variants.find(v=>v.negative_text==='A negative').title;
|
||||
await page.getByRole('button',{name:`Select negative ${selected}`,exact:true}).click();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const before=galleryReads,keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey));
|
||||
await expect(recorded(page)).toContainText('3 distinct recorded runs as of this read');
|
||||
@@ -182,7 +183,7 @@ test('strict reset seeds real A,A,B positions, two contexts and independently se
|
||||
await expect(marker(page,id)).toBeFocused();
|
||||
}
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('A negative');
|
||||
await expect(page.getByRole('button',{name:`Select negative variant ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(page.getByRole('button',{name:`Select negative ${selected}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(galleryReads).toBe(before);expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(n=>n.dataset.imageKey))).toEqual(keys);
|
||||
});
|
||||
|
||||
@@ -199,18 +200,20 @@ test('frozen exact roles distinguish unknown, explicit empty, whitespace, mode a
|
||||
const detail=await(await request.get(`/prompt_manager/history/runs/${f.runs.shared}?epoch=${window.history_epoch}`)).json();
|
||||
const chosen=detail.contexts.find(c=>c.anchor_id==='z');
|
||||
const pos=dialog(page).getByRole('region',{name:'Positive prompt',exact:true}),neg=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});
|
||||
expect(await exact(pos.locator('pre'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('pre'))).toBe(chosen.negative.raw);
|
||||
expect(await exact(pos.locator('p.prompt-text'))).toBe(chosen.positive.raw);expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.raw);
|
||||
expect(await pos.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('pre'))).toBe(chosen.negative.effective);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(neg.locator('p.prompt-text'))).toBe(chosen.negative.effective);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.unknown).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
|
||||
const unknown=dialog(page).getByRole('region',{name:'Negative prompt',exact:true});await expect(unknown.getByRole('button')).toHaveCount(0);await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0);
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('pre'))).toBe(' \n\t ');await expect(unknown).toContainText('host_cache_output');
|
||||
await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();expect(await exact(unknown.locator('p.prompt-text'))).toBe(' \n\t ');
|
||||
// D22: the frozen block has no source line; each field's source stays in "Frozen field provenance".
|
||||
await expect(unknown).not.toContainText('host_cache_output');await expect(dialog(page).locator('.recorded-provenance')).toContainText(/Effective · \w+ · host_cache_output/);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
await marker(page,f.runs.empty).click();await expect(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(dialog(page).getByRole('button',{name:'Copy negative prompt',exact:true})).toBeVisible();await expect(dialog(page).getByRole('button',{name:'Send prompt pair',exact:true})).toBeVisible();
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');
|
||||
await marker(page,f.runs.whitespace).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');
|
||||
await expect(dialog(page)).toContainText('graph_literal');await expect(dialog(page)).toContainText('Role assignment: inferred');
|
||||
await dialog(page).getByText('Frozen field provenance',{exact:true}).click();await expect(dialog(page)).toContainText('Prepend');await expect(dialog(page)).toContainText('Append');await expect(dialog(page)).toContainText('graph_semantics');
|
||||
expect(shared.matching_anchors.map(a=>a.anchor_id)).toEqual(['10','2','z']);
|
||||
@@ -224,7 +227,7 @@ test('every ordered receipt remains selected through distinct current locations,
|
||||
await expect(receipts.nth(3)).toContainText('missing');await expect(receipts.nth(4)).toContainText('Transient');
|
||||
await expect(dialog(page)).toContainText('Context membership unknown');
|
||||
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
|
||||
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});await expect(viewer).toBeVisible();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
|
||||
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
|
||||
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
|
||||
@@ -245,7 +248,7 @@ test('inspection first discovers a real restore and immediately retires every su
|
||||
const inspectionRead=page.waitForResponse(response=>new URL(response.url()).pathname.endsWith('/output/current/inspection'));
|
||||
try {
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await expect.poll(async()=> (await(await request.get('/__test__/history/checkpoint')).json()).entered).toBe(true);
|
||||
await page.evaluate(()=>{
|
||||
window.recordedRecoveryStates=[];
|
||||
@@ -259,7 +262,7 @@ test('inspection first discovers a real restore and immediately retires every su
|
||||
} finally {await command(request,{action:'release'});}
|
||||
const response=await inspectionRead;expect(response.status()).toBe(409);expect((await response.json()).code).toBe('history-changed');
|
||||
await expect(dialog(page).locator('.recorded-run-status')).toContainText('Recorded evidence changed');
|
||||
const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
const viewer=page.locator('dialog#viewer-dialog');
|
||||
if(await viewer.count())await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toBeVisible();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);
|
||||
await expect(dialog(page).locator('[data-send-role]:enabled:visible,[data-copy-role]:enabled:visible,[data-view-receipt]:enabled:visible')).toHaveCount(0);
|
||||
@@ -280,7 +283,7 @@ test('active stale inspection echo reaches history ownership without a second me
|
||||
});
|
||||
|
||||
test('unavailable exact receipt selection and targeted retry observe a newly available file without writes',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
const f=page.fixture;await open(page,f.detail_id);await page.getByRole('button',{name:'Select negative Saved empty',exact:true}).click();
|
||||
const keys=await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey));
|
||||
let writes=0,galleryReads=0,outputReads=0;
|
||||
page.on('request',request=>{const url=new URL(request.url());if(url.pathname.startsWith('/prompt_manager/')&&request.method()!=='GET')writes++;if(url.pathname.endsWith('/images'))galleryReads++;if(url.pathname.endsWith('/outputs'))outputReads++;});
|
||||
@@ -297,9 +300,9 @@ test('unavailable exact receipt selection and targeted retry observe a newly ava
|
||||
await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).locator('[aria-pressed="true"]')).toContainText('z');
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
|
||||
await missing.getByRole('button',{name:'View current file',exact:true}).click();await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(missing).toHaveAttribute('aria-current','true');await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(page.getByRole('button',{name:'Select negative Saved empty',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
|
||||
});
|
||||
|
||||
@@ -378,7 +381,7 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
|
||||
await recorded(page).getByRole('button',{name:'View selected run',exact:true}).click();await expect(dialog(page)).toContainText(oldRun);await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await reviewCapture(page,testInfo,`outside-selected-dialog-${width}-${theme}`,dialog(page));
|
||||
const hashes={};
|
||||
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/library.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
|
||||
for(const path of ['docs/concepts/workspace/index.html','docs/concepts/workspace/app.js','docs/concepts/workspace/style.css','database/history_reads.py','py/api/history.py','web/js/workspace/history-state.mjs','web/js/workspace/history-media.mjs','web/js/workspace/history.mjs','web/js/workspace/recorded-run.mjs','web/js/workspace/workspace.css'])hashes[path]=createHash('sha256').update(await readFile(new URL(`../../${path}`,import.meta.url))).digest('hex');
|
||||
expect(hashes['docs/concepts/workspace/index.html']).toBe('d84712c7284bb232919a1ed9b154575c8ac574f63b2404a4ac4bc06cbf323c03');expect(hashes['docs/concepts/workspace/app.js']).toBe('e33e7652da12a68cd3fe31e94d77d680aa439b6434ceee94f21cc4ce8883263e');expect(hashes['docs/concepts/workspace/style.css']).toBe('2c479ca1a2ddbbd14a550eae27dd731c326ccdb91fe9ddc87e2b81cd5298fce8');
|
||||
const manifest=testInfo.outputPath('final-fix-visual-source-and-scroll.json');await writeFile(manifest,JSON.stringify({width,height,theme,oldRun,before,after,hashes},null,2));
|
||||
await testInfo.attach('final-fix-visual-source-and-scroll.json',{path:manifest,contentType:'application/json'});
|
||||
@@ -397,7 +400,7 @@ test('history, run and output failures retry their own reads and route exit igno
|
||||
const f=page.fixture;let windowFailure=true,runFailure=true,outputFailure=true;
|
||||
await page.route(/\/library\/\d+\/history\?/,async route=>windowFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.goto(`/prompt_manager/workspace#/library/${f.detail_id}`);await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
|
||||
await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
|
||||
await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);windowFailure=false;await recorded(page).getByRole('button',{name:'Retry recorded history',exact:true}).click();
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>runFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>outputFailure?route.fulfill({status:503,json:{success:false}}):route.continue());
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page).getByRole('button',{name:'Retry run details',exact:true})).toBeVisible();runFailure=false;await dialog(page).getByRole('button',{name:'Retry run details',exact:true}).click();
|
||||
@@ -447,7 +450,7 @@ test('frozen Effective pair uses the shared acknowledged sender and Escape close
|
||||
});
|
||||
try {
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.unknown).click();await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();
|
||||
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const positive=await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
const send=dialog(page).getByRole('button',{name:'Send prompt pair',exact:true});await send.click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'History ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'History destination'});await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
@@ -466,16 +469,16 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
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();
|
||||
const frozen=dialog(page).getByRole('region',{name:'Frozen run prompt texts',exact:true});
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'));
|
||||
const before=await exact(frozen.getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'));
|
||||
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
|
||||
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});await expect(current).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('pre')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('pre')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:'Copy current file positive prompt',exact:true})).toBeVisible();
|
||||
await expect(current.getByRole('region',{name:'Current file positive prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file positive');
|
||||
await expect(current.getByRole('region',{name:'Current file negative prompt',exact:true}).locator('p.prompt-text')).toHaveText('current file negative');
|
||||
await expect(current.getByRole('button',{name:/^Copy current file positive prompt: /})).toBeVisible();
|
||||
for(const role of ['Positive','Negative'])await expect(dialog(page).getByRole('region',{name:`${role} prompt`,exact:true})).toHaveCount(1);
|
||||
await expect(current.getByRole('region',{name:/^(Positive|Negative) prompt$/})).toHaveCount(0);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('pre'))).toBe(before);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
test('the recorded run dialog with a resolved current file has no duplicate button or region names',async({page})=>{
|
||||
@@ -485,7 +488,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
|
||||
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.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
await expect(dialog(page).getByRole('region',{name:'Current reported file',exact:true})).toBeVisible();
|
||||
await expect(dialog(page).getByRole('region',{name:'Current file positive prompt',exact:true})).toBeVisible();
|
||||
// Receipt rows repeat per-row actions by design; every other control and region in the dialog must be uniquely named.
|
||||
@@ -500,7 +503,7 @@ test('the recorded run dialog with a resolved current file has no duplicate butt
|
||||
|
||||
test('pagehide tears down dialogs and persisted reentry revalidates before enabling recorded actions',async({page})=>{
|
||||
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).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();await expect(page.getByRole('dialog',{name:'Image preview',exact:true})).toBeVisible();
|
||||
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.evaluate(()=>dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));await expect(page.locator('dialog[open],.viewer-container')).toHaveCount(0);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});let checks=0;
|
||||
await page.route(/\/history\/runs\/[^/]+\?/,async route=>{checks++;await held;await route.continue().catch(()=>{});});
|
||||
@@ -562,7 +565,7 @@ test('capture recovery reloads only reported outputs while retaining frozen run
|
||||
});
|
||||
|
||||
test('history epoch recovery reports saved-parent read failures and retries without resetting the gallery',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
let parentFailure=true,galleryReads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.detail_id}(?:\\?|$)`),async route=>parentFailure?route.fulfill({status:503,json:{success:false,error:'Unavailable'}}):route.continue());
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
@@ -573,8 +576,8 @@ test('history epoch recovery reports saved-parent read failures and retries with
|
||||
});
|
||||
|
||||
test('changed saved-owner identity is disclosed without silently relabeling or rebinding the existing gallery',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);await showAllImages(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre'));
|
||||
const f=page.fixture;await open(page,f.detail_id);await showLinkedImage(page);await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('p.prompt-text'));
|
||||
const cards=gallery(page).locator('article[data-image-key]');await cards.first().evaluate(node=>{window.savedGalleryCard=node;});let galleryReads=0;
|
||||
page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${f.detail_id}/images`)galleryReads++;});
|
||||
const details=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
@@ -583,7 +586,7 @@ test('changed saved-owner identity is disclosed without silently relabeling or r
|
||||
await expect(await request.put(`/prompt_manager/prompts/${f.detail_id}`,{data:{text:'Replaced saved owner'}})).toBeOK();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('pre'))).toBe(original);
|
||||
await expect(page.locator('.library-parent-status')).toContainText('changed');expect(await exact(positive.locator('p.prompt-text'))).toBe(original);
|
||||
const reload=page.getByRole('button',{name:'Reload saved Library view',exact:true});await expect(reload).toBeVisible();
|
||||
expect(await cards.first().evaluate(node=>node===window.savedGalleryCard)).toBe(true);expect(galleryReads).toBe(0);
|
||||
await reload.click();await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Replaced saved owner');
|
||||
@@ -593,7 +596,7 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified
|
||||
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
|
||||
await open(page,f.detail_id);await expect(page.getByRole('button',{name:'Load more negative variants',exact:true})).toBeVisible();await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
|
||||
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
|
||||
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();await expect(choices.last()).toHaveAttribute('aria-pressed','true');
|
||||
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
@@ -606,7 +609,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
const f=page.fixture;const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
for(let index=0;index<41;index++)await expect(await request.post('/prompt_manager/pairs',{data:{positive_text:detail.positive.text,negative_text:`Bounded negative ${index}`}})).toBeOK();
|
||||
await open(page,f.detail_id);await page.getByRole('button',{name:'Load more negative variants',exact:true}).click();
|
||||
const choices=page.getByRole('button',{name:/^Select negative variant /});await expect(choices).toHaveCount(43);await choices.last().click();
|
||||
const choices=page.getByRole('button',{name:/^Select negative /});await expect(choices).toHaveCount(43);await choices.last().click();
|
||||
const negative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent();
|
||||
await page.route(/\/history\/runs\/[^/]+\/outputs\?/,async route=>route.fulfill({status:409,json:{success:false,history_version:1,code:'history-changed',error:'History changed',recovery:{action:'refresh-history'}}}));
|
||||
await marker(page,f.runs.shared).click();await expect(dialog(page)).toContainText('Recorded evidence changed');await page.keyboard.press('Escape');
|
||||
@@ -615,7 +618,7 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
await expect(editor.getByRole('button',{name:'Save',exact:true})).toBeEnabled();await editor.getByLabel('Notes',{exact:true}).fill('Saved after stale revalidation');
|
||||
await editor.getByRole('button',{name:'Save',exact:true}).click();await expect(editor).toHaveCount(0);
|
||||
await expect(page.locator('.library-parent-status')).toBeHidden();await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeHidden();
|
||||
await expect(choices.first()).toBeEnabled();await expect(page.locator('.library-variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
await expect(choices.first()).toBeEnabled();await expect(page.locator('.variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative);
|
||||
const names=await choices.evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
@@ -623,15 +626,15 @@ test('an authoritative save after a stale revalidation makes the saved view curr
|
||||
async function retainSavedSelection(page,request,fixture) {
|
||||
await open(page,fixture.detail_id);
|
||||
const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json();
|
||||
const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair);
|
||||
await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click();
|
||||
const name=details.variants.find(variant=>variant.id===fixture.saved_pair).title;
|
||||
await page.getByRole('button',{name:`Select negative ${name}`,exact:true}).click();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1);
|
||||
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'));
|
||||
const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'));
|
||||
await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;});
|
||||
let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;});
|
||||
return async()=>{
|
||||
await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative);
|
||||
await expect(page.getByRole('button',{name:`Select negative ${name}`,exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(negative);
|
||||
expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0);
|
||||
};
|
||||
}
|
||||
@@ -702,7 +705,7 @@ test('capture-only recovery uses the changed real output capture without re-read
|
||||
const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete');
|
||||
await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting');
|
||||
await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2);
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained();
|
||||
});
|
||||
|
||||
test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{
|
||||
@@ -719,3 +722,310 @@ test('completed page one stays bounded across mode changes until explicit output
|
||||
await page.keyboard.press('Escape');await retained();
|
||||
} finally {releaseNext();}
|
||||
});
|
||||
|
||||
// #249 PR F1: the concept heading (app.js a591–597) and summary surface (a598) with real counts (D24).
|
||||
const historyHeading=page=>page.locator('#workspace-main > .heading');
|
||||
const summary=page=>page.locator('#workspace-main .library-history article.surface');
|
||||
test('history heading actions stay on one row at 1600 and are enabled after load',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});const f=page.fixture;
|
||||
let releaseDetail;const held=new Promise(resolve=>{releaseDetail=resolve;});
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.positive_id}\\?`),async route=>{await held;await route.continue().catch(()=>{});});
|
||||
try {
|
||||
await page.goto(`/prompt_manager/workspace#/library/${f.positive_id}`);
|
||||
const heading=historyHeading(page),send=heading.locator('.heading-actions > button.send');
|
||||
await expect(heading.locator('p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
// D24: disabled only while the selected pair's roles are loading.
|
||||
await expect(send).toBeDisabled();releaseDetail();
|
||||
await expect(recorded(page)).toBeVisible();await expect(send).toBeEnabled();
|
||||
} finally {releaseDetail();}
|
||||
const heading=historyHeading(page),title=await heading.locator('h1').textContent();
|
||||
const actions=heading.locator('.heading-actions > button');
|
||||
await expect(actions).toHaveCount(3);
|
||||
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()))).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']);
|
||||
await expect(actions.nth(2)).toHaveClass('send');
|
||||
await expect(actions.nth(2)).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${title}`);
|
||||
for(const action of [actions.nth(0),actions.nth(1)]) await expect(action).toBeEnabled();
|
||||
const boxes=await actions.evaluateAll(nodes=>nodes.map(node=>{const box=node.getBoundingClientRect();return {y:box.y,height:box.height};}));
|
||||
expect(new Set(boxes.map(box=>box.y)).size).toBe(1);
|
||||
const row=await heading.locator('.heading-actions').evaluate(node=>node.getBoundingClientRect().height);
|
||||
expect(row).toBe(boxes[0].height);
|
||||
});
|
||||
test('summary meta uses recorded runs',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.positive_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${f.positive_id}`)).json(),runs=(await historyRead(request,f.positive_id)).recorded_run_count;
|
||||
const surface=summary(page),title=await historyHeading(page).locator('h1').textContent();
|
||||
await expect(surface).toHaveCount(1);
|
||||
await expect(surface.locator(':scope > span.chip')).toHaveText(detail.positive.category || 'Uncategorized');
|
||||
const plural=(count,word)=>`${count} ${count===1?word:`${word}s`}`;
|
||||
await expect(surface.locator(':scope > .card-meta > span')).toHaveText([plural(runs,'tracked run'),plural(detail.positive.image_count,'image'),plural(detail.positive.variant_count,'negative variant')]);
|
||||
// The concept order: category chip, positive block, selected negative block, then the meta line.
|
||||
expect(await surface.evaluate(node=>[...node.querySelectorAll('.chip, .prompt-section, .card-meta')].filter(item=>item.checkVisibility()).map(item=>item.className))).toEqual(['chip','prompt-section positive','prompt-section negative','card-meta']);
|
||||
for(const name of ['Copy positive prompt','Send positive prompt to ComfyUI','Copy negative prompt','Send negative prompt to ComfyUI'])
|
||||
await expect(saved(page).getByRole('button',{name:`${name}: ${title}`,exact:true})).toBeVisible();
|
||||
await expect(surface.locator('.prompt-section').first()).toHaveCSS('border-top-width','1px');
|
||||
});
|
||||
|
||||
// #249 F2: the concept's negative variants panel (app.js a598, style.css 1059-1154) and its two dialogs (D26).
|
||||
const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants',exact:true});
|
||||
const modalDialog=(page,name)=>page.getByRole('dialog',{name,exact:true});
|
||||
test('variants panel matches the concept',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await expect(panel).toHaveClass('surface timeline-panel');
|
||||
const heading=panel.locator(':scope > .panel-heading');
|
||||
await expect(heading.locator(':scope > div > h2')).toHaveText('Negative variants');
|
||||
await expect(heading.locator(':scope > div > p.small')).toHaveText('Choose a red dot to change the negative and filter this gallery.');
|
||||
const actions=heading.locator(':scope > .row-actions > button');
|
||||
expect(await actions.evaluateAll(nodes=>nodes.map(node=>node.textContent))).toEqual(['Compare negatives','+ Add negative','Show archived (0)']);
|
||||
const compare=actions.nth(0);await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
const timeline=panel.locator(':scope > .timeline');
|
||||
await expect(timeline.locator(':scope > .positive-lane > span')).toHaveText('Positive prompt · unchanged');
|
||||
const lanes=timeline.locator(':scope > .variant-lane');await expect(lanes).toHaveCount(3);
|
||||
// Newest saved pair first, as the bounded variant page reads; every lane is offset 38 + 47i.
|
||||
const names=['No negative','Clean geometry','Quality baseline'],runs=['0 runs','2 runs','2 runs'];
|
||||
for(const [index,name] of names.entries()) {
|
||||
const lane=lanes.nth(index);
|
||||
await expect(lane).toHaveAttribute('style',`--lane-offset: ${38+index*47}px;`);
|
||||
const dot=lane.locator(':scope > button.variant-dot');
|
||||
await expect(dot).toHaveText('−');await expect(dot).toHaveAccessibleName(`Select negative ${name}`);
|
||||
await expect(dot).toHaveAttribute('aria-pressed',String(index===0));
|
||||
await expect(lane.locator(':scope > span')).toHaveText(`${name} · ${runs[index]}`);
|
||||
expect(await lane.evaluate(node=>[...node.children].filter(child=>!child.hidden).map(child=>child.tagName.toLowerCase()+'.'+child.className))).toEqual(index===0?['button.variant-dot','span.']:['button.variant-dot','span.','button.run-dot']);
|
||||
}
|
||||
await lanes.nth(2).locator('.variant-dot').click();await expect(lanes.nth(2).locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(lanes.nth(0).locator('.variant-dot')).toHaveAttribute('aria-pressed','false');
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(2);
|
||||
const lower=panel.locator(':scope > .timeline-lower');
|
||||
await expect(lower.locator(':scope > span.small')).toHaveText('Green + positive · red − negative · small dot repeat run');
|
||||
await expect(lower.locator(':scope > button')).toHaveText('All images for this positive');
|
||||
await expect(lower.locator(':scope > button')).not.toHaveAttribute('aria-pressed',/.*/);
|
||||
await expect(panel).not.toContainText('Saved combinations, not an execution timeline.');
|
||||
// The concept tokens reach the lanes (style.css 1096-1137).
|
||||
await expect(lanes.first()).toHaveCSS('height','57px');await expect(lanes.nth(1)).toHaveCSS('margin-left','85px');
|
||||
await expect(lanes.first().locator('.variant-dot')).toHaveCSS('border-radius','50%');
|
||||
await expect(lanes.nth(1).locator('.run-dot')).toHaveCSS('left','190px');
|
||||
});
|
||||
test('run dot appears only for pairs with more than one run and opens its run',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await expect(panel.getByRole('button',{name:/^Show repeat run for /})).toHaveCount(2);
|
||||
await expect(panel.getByRole('button',{name:'Show repeat run for No negative',exact:true})).toHaveCount(0);
|
||||
// D45: the lane text wraps before the run dot, and a coarse pointer reaches the 12px dot through a 44px target.
|
||||
for(const lane of await panel.locator('.variant-lane:has(> .run-dot:not([hidden]))').all()) {
|
||||
const [text,dot]=await Promise.all([lane.locator(':scope > span').boundingBox(),lane.locator(':scope > .run-dot').boundingBox()]);
|
||||
expect(text.width).toBeLessThanOrEqual(139.5);expect(text.x+text.width).toBeLessThan(dot.x);
|
||||
}
|
||||
if(await page.evaluate(()=>matchMedia('(pointer: coarse)').matches)) {
|
||||
const dot=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await dot.scrollIntoViewIfNeeded();
|
||||
const reach=await dot.evaluate(node=>{
|
||||
const box=node.getBoundingClientRect(),x=box.x+box.width/2,y=box.y+box.height/2;
|
||||
return [[x-20,y],[x+20,y],[x,y-20],[x,y+20]].map(([px,py])=>{const hit=document.elementFromPoint(px,py);return hit?.closest('button')===node?'dot':`${hit?.tagName}.${hit?.className}`;});
|
||||
});
|
||||
expect(reach).toEqual(['dot','dot','dot','dot']);
|
||||
}
|
||||
// The baseline has a recorded execution: the dot opens Recorded run for it.
|
||||
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});await baseline.click();
|
||||
await expect(dialog(page)).toBeVisible();await expect(dialog(page)).toContainText(f.canonical_run);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();await expect(dialog(page)).toHaveCount(0);
|
||||
// Clean geometry was counted but never recorded: Repeat run shows its newest linked image only.
|
||||
const clean=panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true});
|
||||
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
|
||||
await clean.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
|
||||
await expect(repeat.locator('#modal-body > p.small')).toHaveText('Same positive and negative, another execution.');
|
||||
await expect(repeat.locator('#modal-body > .spaced-15 article[data-image-key]')).toHaveCount(1);
|
||||
// D47: as in the concept (a1291-1304), the dialog title names the image; no collection heading or count repeats it.
|
||||
await expect(repeat.locator('.section-caption')).toHaveCount(0);
|
||||
await expect(repeat.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
|
||||
await expect(repeat.getByText(/ loaded · /)).toHaveCount(0);
|
||||
expect(await repeat.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Repeat run']);
|
||||
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('limit')).toBe('1');
|
||||
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(clean).toBeFocused();
|
||||
});
|
||||
test('all images dialog lists every image for the positive',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.detail_id);
|
||||
const detail=await(await request.get(`/prompt_manager/library/${f.detail_id}`)).json();
|
||||
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
||||
// Opening on the first lane, the shared gallery shows that lane only.
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
|
||||
await all.click();const listing=modalDialog(page,'All images for this positive');await expect(listing).toBeVisible();
|
||||
await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(all).toBeFocused();
|
||||
await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(0);
|
||||
// Leaving the route closes an open dialog and its gallery.
|
||||
await all.click();await expect(listing.locator('article[data-image-key]')).toHaveCount(detail.positive.image_count);
|
||||
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0);
|
||||
});
|
||||
test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
const compare=panel.getByRole('button',{name:'Compare negatives',exact:true});
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section).toHaveCount(0);
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');
|
||||
// The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25).
|
||||
await expect(section).toHaveCount(1);
|
||||
expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']);
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.');
|
||||
const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2);
|
||||
await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark');
|
||||
await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0);
|
||||
// D46: the two blocks are named apart, so no region or action name repeats on the page.
|
||||
await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await expect(section.locator('.diff-added')).toHaveCount(0);
|
||||
// Another dot updates the same section in place.
|
||||
const node=await section.elementHandle();
|
||||
await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.');
|
||||
await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light');
|
||||
await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']);
|
||||
await expect(section.locator(':scope > p.small')).toHaveCount(1);
|
||||
expect(await node.evaluate(element=>element.isConnected)).toBe(true);
|
||||
await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block');
|
||||
await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px');
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0);
|
||||
});
|
||||
test('compare waits for the page holding the oldest active negative',async({page})=>{
|
||||
const f=page.fixture;
|
||||
// A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46).
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{
|
||||
const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue();
|
||||
const response=await route.fetch(),body=await response.json();
|
||||
await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}});
|
||||
});
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await panel.getByRole('button',{name:'Compare negatives',exact:true}).click();
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.');
|
||||
await expect(section.locator('.compare-grid')).toHaveCount(0);
|
||||
await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`));
|
||||
});
|
||||
test('run dot falls back to Repeat run when the lookup fails or Recorded run cannot open',async({page})=>{
|
||||
const f=page.fixture;
|
||||
await page.route(/\/history\/pairs\/\d+\/newest/,route=>route.fulfill({status:503,json:{success:false}}));
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
const baseline=panel.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true});
|
||||
await baseline.click();const repeat=modalDialog(page,'Repeat run');await expect(repeat).toBeVisible();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('Recorded runs could not be read. Showing the newest linked image.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');await expect(repeat).toHaveCount(0);await expect(baseline).toBeFocused();
|
||||
await page.unroute(/\/history\/pairs\/\d+\/newest/);
|
||||
// The lookup names a run, but the recorded history never loaded, so Recorded run cannot open: Repeat run, no toast.
|
||||
await page.route(/\/library\/\d+\/history\?/,route=>route.fulfill({status:503,json:{success:false}}));
|
||||
await page.reload();await expect(recorded(page).getByRole('button',{name:'Retry recorded history',exact:true})).toBeVisible();
|
||||
await page.locator('#workspace-toast').evaluate(node=>{node.textContent='';});
|
||||
const newest=page.waitForResponse(response=>/\/history\/pairs\/\d+\/newest/.test(response.url()));
|
||||
await variantsPanel(page).getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
|
||||
expect((await(await newest).json()).run.run_id).toBe(f.canonical_run);
|
||||
await expect(modalDialog(page,'Repeat run')).toBeVisible();await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
});
|
||||
test('an archived pair\'s Repeat run reads the archived gallery',async({page,request})=>{
|
||||
const f=page.fixture;
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${f.clean_pair}`,{data:{archived:true}})).toBeOK();
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await panel.getByRole('button',{name:'Show archived (1)',exact:true}).click();
|
||||
const reads=[];page.on('request',request=>{const url=new URL(request.url());if(url.pathname===`/prompt_manager/library/${f.canonical_id}/images`)reads.push(url.searchParams);});
|
||||
await panel.getByRole('button',{name:'Show repeat run for Clean geometry',exact:true}).click();
|
||||
const repeat=modalDialog(page,'Repeat run');await expect(repeat.locator('.spaced-15 article[data-image-key]')).toHaveCount(1);
|
||||
expect(reads.at(-1).get('pair_id')).toBe(String(f.clean_pair));expect(reads.at(-1).get('archive')).toBe('archived');
|
||||
// Each open replaces the previous dialog and its gallery.
|
||||
await page.keyboard.press('Escape');await panel.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(modalDialog(page,'All images for this positive')).toBeVisible();await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
});
|
||||
test('Compare negatives is disabled without an active variant',async({page,request})=>{
|
||||
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
for(const variant of detail.variants) await expect(await request.patch(`/prompt_manager/pairs/${variant.id}`,{data:{archived:true}})).toBeOK();
|
||||
await open(page,f.canonical_id);const compare=variantsPanel(page).getByRole('button',{name:'Compare negatives',exact:true});
|
||||
await expect(compare).toBeDisabled();await expect(compare).toHaveAttribute('aria-pressed','false');
|
||||
await expect(page.locator('.library-history .timeline-panel + section.surface')).toHaveCount(0);
|
||||
});
|
||||
test('dot names never repeat for trimmed titles or titles that read like a fallback',async({page,request})=>{
|
||||
const f=page.fixture,detail=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
const byTitle=Object.fromEntries(detail.variants.map(variant=>[variant.title,variant.id]));
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['No negative']}`,{data:{title:'variant 2'}})).toBeOK();
|
||||
await expect(await request.patch(`/prompt_manager/pairs/${byTitle['Quality baseline']}`,{data:{title:' Clean geometry '}})).toBeOK();
|
||||
await open(page,f.canonical_id);
|
||||
const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(names).toEqual(['Select negative variant 1','Select negative variant 2','Select negative variant 3']);
|
||||
});
|
||||
// #249 F4: the concept's shared gallery (app.js a616-621, style.css 1546-1548) of PR C media cards (C3 activation).
|
||||
test('shared gallery uses concept cards, image opens the viewer, empty copy',async({page,request})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);
|
||||
const section=gallery(page),heading=section.locator(':scope > .panel-heading');
|
||||
expect(await section.evaluate(node=>`${node.tagName.toLowerCase()}.${node.className}`)).toBe('section.history-gallery');
|
||||
await expect(heading.locator(':scope > h2')).toHaveText('Shared gallery');
|
||||
// The page opens on the newest lane, a saved variant with no runs: the concept's empty copy.
|
||||
await expect(heading.locator(':scope > span.small')).toHaveText('No negative · same positive prompt');
|
||||
await expect(section.locator('.empty')).toHaveText('Saved variant · no runs yet.');await expect(section.locator('.empty')).toBeVisible();
|
||||
await expect(section.locator('article[data-image-key]')).toHaveCount(0);
|
||||
// D47 (maintainer ruling 2026-10-21): the concept's "Shared gallery" heading is the gallery's only heading, so the
|
||||
// collection's "Linked images" heading and its "N loaded · N linked images" count are gone; the layout row and the
|
||||
// selected-image side panel stay.
|
||||
await expect(section.locator('.section-caption')).toHaveCount(0);
|
||||
await expect(section.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
|
||||
await expect(section.getByText(/ loaded · /)).toHaveCount(0);
|
||||
// Outside the side panel (whose own heading names the selected image), the gallery has one section heading.
|
||||
expect(await section.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['Shared gallery']);
|
||||
await expect(section.getByRole('group',{name:'Image layout',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('combobox',{name:'Thumbnail size',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('complementary',{name:'Selected image details',exact:true})).toBeVisible();
|
||||
// As in the concept, the gallery follows the variants panel; recorded executions (#209) stay below it.
|
||||
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].map(item=>item.getAttribute('aria-label')).filter(name=>['Saved negative variants','Shared gallery','Recorded executions'].includes(name)));
|
||||
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
|
||||
const dot=variantsPanel(page).getByRole('button',{name:'Select negative Quality baseline',exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
|
||||
await expect(heading.locator(':scope > span.small')).toHaveText('Quality baseline · same positive prompt');
|
||||
const cards=section.locator('article.media-card[data-image-key]');await expect(cards).toHaveCount(2);
|
||||
await expect(section.locator('.empty')).toBeHidden();
|
||||
const details=await(await request.get(`/prompt_manager/library/${f.canonical_id}`)).json();
|
||||
const pair=details.variants.find(variant=>variant.title==='Quality baseline').id;
|
||||
const listing=await(await request.get(`/prompt_manager/library/${f.canonical_id}/images?pair_id=${pair}`)).json();
|
||||
const first=cards.first(),filename=(await first.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
const image=listing.images.find(item=>item.filename===filename);expect(typeof image.favorite).toBe('boolean');
|
||||
// The concept card: art (open button + overlays), then the info row; the star where the read returns favorite and
|
||||
// no checkbox (the selection bar stays Images-only, #259).
|
||||
expect(await first.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['media-art','media-info']);
|
||||
expect(await first.locator(':scope > .media-art').evaluate(node=>[...node.children].map(child=>`${child.tagName.toLowerCase()}.${child.className}`))).toEqual(['button.image-open','div.image-overlays']);
|
||||
await expect(first.locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
|
||||
const star=first.locator('.image-overlays > button.star-button');
|
||||
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
|
||||
await expect(star).toHaveText(image.favorite?'★':'☆');
|
||||
await expect(first.locator('.media-info h3')).toHaveCount(1);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
// C3 activation: the image opens the viewer and Escape returns focus to it.
|
||||
const openButton=first.locator('.image-open');await openButton.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(viewer).toBeHidden();await expect(openButton).toBeFocused();
|
||||
await expect(section.locator(':scope > .panel-heading')).toHaveCSS('justify-content','space-between');
|
||||
await expect(section).toHaveCSS('margin-top','24px');
|
||||
// D47 (maintainer ruling 2026-10-21) covers the history page's dialogs too: the All images dialog is named by its
|
||||
// own title, with no "Linked images" heading or "N loaded · N linked images" count, as the concept's (a1282-1290).
|
||||
await variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const all=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await expect(all.locator('article[data-image-key]').first()).toBeVisible();
|
||||
await expect(all.locator('.section-caption')).toHaveCount(0);
|
||||
await expect(all.getByRole('heading',{name:'Linked images',exact:true})).toHaveCount(0);
|
||||
await expect(all.getByText(/ loaded · /)).toHaveCount(0);
|
||||
expect(await all.evaluate(node=>[...node.querySelectorAll('h1, h2')].filter(item=>!item.closest('aside')).map(item=>item.textContent))).toEqual(['All images for this positive']);
|
||||
await page.keyboard.press('Escape');await expect(all).toHaveCount(0);
|
||||
// At 390px the heading stays inside the gallery and the page never scrolls sideways.
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await expect.poll(()=>page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const [box,text]=await Promise.all([section.boundingBox(),heading.locator(':scope > span.small').boundingBox()]);
|
||||
expect(text.x+text.width).toBeLessThanOrEqual(box.x+box.width+0.5);
|
||||
});
|
||||
|
||||
+1396
-126
File diff suppressed because it is too large
Load Diff
@@ -438,7 +438,7 @@ const variantsPanel=page=>page.getByRole('region',{name:'Saved negative variants
|
||||
const savedTexts=page=>historyRegion(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const toast=page=>page.locator('#workspace-toast');
|
||||
const visibleNames=locator=>locator.evaluateAll(nodes=>nodes.map(node=>node.textContent.trim()));
|
||||
const pressedVariant=page=>variantsPanel(page).locator('.library-variant-dot[aria-pressed="true"]');
|
||||
const pressedVariant=page=>variantsPanel(page).locator('.variant-dot[aria-pressed="true"]');
|
||||
async function openHistory(page,id) {
|
||||
await page.goto(`${workspace}#/library/${id}`);await expect(variantsPanel(page)).toBeVisible();
|
||||
}
|
||||
@@ -450,14 +450,14 @@ async function leaveAndReturn(page,id) {
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await page.evaluate(id=>{location.hash=`#/library/${id}`;},id);await expect(variantsPanel(page)).toBeVisible();await expect(pressedVariant(page)).toHaveCount(1);
|
||||
}
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.library-variant-lane').filter({hasText:text});
|
||||
const laneLabel=(page,text)=>variantsPanel(page).locator('.variant-lane').filter({hasText:text});
|
||||
|
||||
test('library heading offers Export then + New prompt, and New opens the saved prompt history',async({page,request})=>{
|
||||
await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]').first()).toBeVisible();
|
||||
const actions=headingActions(page).locator('button');
|
||||
expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']);
|
||||
// One linked image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1);
|
||||
// One image is counted in the singular.
|
||||
const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 image',{exact:true})).toHaveCount(1);
|
||||
await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/);
|
||||
await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/);
|
||||
for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32);
|
||||
@@ -472,14 +472,15 @@ test('library heading offers Export then + New prompt, and New opens the saved p
|
||||
// Exactly one saved negative is counted in the singular, in history and on the shelf card.
|
||||
await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1);
|
||||
await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('− 1 negative variant',{exact:true})).toHaveCount(1);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']);
|
||||
});
|
||||
|
||||
test('new from Images cancel preserves browse',async({page})=>{
|
||||
await page.goto(`${workspace}#/images`);await expect(page.locator('#workspace-main h1')).toHaveText('Images');
|
||||
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['New prompt']);
|
||||
const trigger=actions.first();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
|
||||
// #249 C2: the concept's Images heading puts Import before New prompt.
|
||||
const actions=headingActions(page).locator('button');expect(await visibleNames(actions)).toEqual(['Import','New prompt']);
|
||||
const trigger=actions.last();await expect(trigger).toHaveClass(/\bca-primary\b/);await expect(trigger.locator('svg')).toHaveCount(1);
|
||||
await trigger.click();let dialog=editorDialog(page);await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
await field(dialog,'Title').fill('Discarded');await button(dialog,'Cancel').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();await expect(page).toHaveURL(/#\/images$/);await expect(page.locator('.library-shelf, .library-history')).toHaveCount(0);
|
||||
@@ -496,7 +497,7 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0);
|
||||
const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1);
|
||||
await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`);
|
||||
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['+ Add negative','Show archived (1)']);
|
||||
expect(await visibleNames(variantsPanel(page).locator('.row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
// The concept opens a prompt history on its first negative variant (app.js navigate: state.variant = 0), so Send starts enabled.
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(send).toBeEnabled();
|
||||
@@ -508,15 +509,17 @@ test('history heading follows the concept and sends the selected saved pair',asy
|
||||
expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returning to a history opens on the first negative, forgetting a clicked variant or All images',async({page})=>{
|
||||
test('returning to a history opens on the first negative, forgetting a clicked variant; All images is a dialog',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await selectPair(page,'Select negative variant 2');
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
// #249 D26: All images opens the concept dialog and leaves the selected negative as it was.
|
||||
const all=variantsPanel(page).getByRole('button',{name:'All images for this positive',exact:true});
|
||||
await all.click();await expect(all).toHaveAttribute('aria-pressed','true');await expect(pressedVariant(page)).toHaveCount(0);
|
||||
await all.click();const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});await expect(listing).toBeVisible();
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await leaveAndReturn(page,f.positive_id);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false');
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -530,8 +533,8 @@ test('add negative re-renders history with the saved or reused pair selected',as
|
||||
await expect(laneLabel(page,'Negative variant 1 ·')).toHaveCount(1);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
await add.click();dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(` ${f.selected_negative.toUpperCase()} `);await button(dialog,'Save').click();
|
||||
await expect(dialog).toHaveCount(0);await expect(toast(page)).toHaveText('This prompt pair already exists. Showing the saved version.');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(laneLabel(page,/· 1 linked image$/)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
await expect(laneLabel(page,/· 2 runs$/)).toHaveCount(1);
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 3');
|
||||
// The saved selection applies to that save only; returning opens on the first negative, as in the concept.
|
||||
await leaveAndReturn(page,f.positive_id);await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');
|
||||
@@ -549,9 +552,9 @@ test('reused pair outside first forty remains selected without loading every pag
|
||||
await openHistory(page,f.positive_id);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');page.writes.length=0;
|
||||
await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();
|
||||
const dialog=editorDialog(page);await ready(dialog);await field(dialog,'Negative prompt').fill(f.first_negative);await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.first_negative);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.first_negative);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(variantsPanel(page)).toContainText('40 of 44 saved negative variants');
|
||||
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(41);
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(41);
|
||||
expect(variantCursorRequests).toHaveLength(0);expect(page.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -564,7 +567,7 @@ test('edit metadata stays on the page and re-reads the selected pair',async({pag
|
||||
await field(dialog,'Title').fill('Edited in place');await field(dialog,'Category').fill('Studio work');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Edited in place');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(toast(page)).toHaveText('Saved.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('.library-surface .library-chip').first()).toHaveText('Studio work');
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));await expect(historyRegion(page).locator('article.surface > span.chip').first()).toHaveText('Studio work');
|
||||
await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 2');
|
||||
await expect.poll(()=>pairReads.length).toBeGreaterThan(before);
|
||||
await expect(headingActions(page).locator('button.send')).toHaveAccessibleName('Send prompt pair to ComfyUI: Edited in place');
|
||||
@@ -638,6 +641,29 @@ test('restored storage before an Edit or Add negative save offers only the Libra
|
||||
expect((await detail(request,f.positive_id)).variants.some(variant=>variant.negative_text==='typed before the restore')).toBe(false);
|
||||
});
|
||||
|
||||
// #249 F2: archived lanes (#235) are concept lanes marked archived, with the D45 reduced emphasis.
|
||||
test('show archived and restore still work',async({page,request})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
const timeline=variantsPanel(page).locator(':scope > .timeline');
|
||||
expect(await visibleNames(variantsPanel(page).locator('.panel-heading .row-actions button'))).toEqual(['Compare negatives','+ Add negative','Show archived (1)']);
|
||||
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
|
||||
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true});await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
||||
const lane=timeline.locator(':scope > .variant-lane.library-variant-archived');await expect(lane).toHaveCount(1);
|
||||
await expect(lane.locator(':scope > span')).toContainText(/ · \d+ runs? · Archived/);
|
||||
const dot=lane.locator(':scope > button.variant-dot');await expect(dot).toHaveText('−');
|
||||
expect(Number(await dot.evaluate(node=>getComputedStyle(node).opacity))).toBeLessThan(1);
|
||||
expect(Number(await lane.evaluate(node=>getComputedStyle(node,'::before').opacity))).toBeLessThan(1);
|
||||
await expect(lane.locator(':scope > span')).toHaveCSS('opacity','1');
|
||||
await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true');
|
||||
const writes=page.writes.length;
|
||||
await lane.getByRole('button',{name:/^Restore /}).click();
|
||||
await expect(timeline.locator('.library-variant-archived')).toHaveCount(0);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (0)',exact:true})).toBeVisible();
|
||||
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.archived_pair_id}`,body:{archived:false}}]);
|
||||
expect((await pairRead(request,f.archived_pair_id)).archived).toBe(false);
|
||||
await expect(timeline.locator(':scope > .variant-lane > .variant-dot[aria-pressed="true"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('Restore stays disabled until the restore settles and sends one PATCH',async({page,request})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
@@ -667,26 +693,30 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Archived. Find it under Show archived in Negative variants.');await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(variantsPanel(page).locator('.library-variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(variantsPanel(page).locator('.variant-lane')).toHaveCount(2);await expect(pressedVariant(page)).toHaveCount(1);
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText(f.selected_negative);
|
||||
expect(await evidence()).toEqual(before);
|
||||
const toggle=variantsPanel(page).getByRole('button',{name:'Show archived (2)',exact:true});await expect(toggle).toHaveAttribute('aria-pressed','false');
|
||||
await toggle.click();await expect(toggle).toHaveAttribute('aria-pressed','true');
|
||||
const archived=variantsPanel(page).locator('.library-variant-archived');await expect(archived).toHaveCount(2);
|
||||
const lane=archived.filter({hasText:'· 1 linked image · Archived'});await expect(lane).toHaveCount(1);
|
||||
const opacity=await lane.locator('.library-variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
||||
const lane=archived.filter({hasText:'· 2 runs · Archived'});await expect(lane).toHaveCount(1);
|
||||
const opacity=await lane.locator('.variant-dot').evaluate(node=>getComputedStyle(node).opacity);expect(Number(opacity)).toBeLessThan(1);
|
||||
const galleryReads=[];page.on('request',item=>{if(item.url().includes('/images?'))galleryReads.push(new URL(item.url()).searchParams);});
|
||||
await lane.locator('.library-variant-dot').click();await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await lane.locator('.variant-dot').click();await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
|
||||
// #249 D8 ruling (a): the Library-embedded gallery has no select checkbox and no selection bar.
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.image-overlays input[type="checkbox"]')).toHaveCount(0);
|
||||
await expect(page.locator('#bulkbar, .bulkbar')).toHaveCount(0);
|
||||
// D47: the archived pair's gallery is named by its "Shared gallery" heading, with no separate loaded count.
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('.panel-heading .small')).toHaveText(/ · Archived · same positive prompt$/);
|
||||
expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id));
|
||||
await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled();
|
||||
const writes=page.writes.length;
|
||||
await lane.getByRole('button',{name:/^Restore/}).click();
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toBeVisible();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(1);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative);
|
||||
await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.selected_negative);
|
||||
expect(page.writes.slice(writes)).toEqual([{method:'PATCH',url:`/prompt_manager/pairs/${f.pair_id}`,body:{archived:false}}]);
|
||||
expect((await pairRead(request,f.pair_id)).archived).toBe(false);expect(await evidence()).toEqual(before);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
@@ -694,14 +724,14 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
});
|
||||
|
||||
test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone');
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Standalone');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);});
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click();
|
||||
await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone');
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Standalone');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible();
|
||||
expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true);
|
||||
@@ -724,7 +754,7 @@ test('export downloads the full Library JSON without a toast and reports a failu
|
||||
expect(downloads).toBe(0);await expect(toast(page)).toBeHidden();expect(page.writes).toHaveLength(0);
|
||||
});
|
||||
|
||||
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.library-variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
|
||||
const uniqueDotNames=async page=>{const names=await variantsPanel(page).locator('.variant-dot').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')));expect(new Set(names).size).toBe(names.length);};
|
||||
|
||||
test('archived reuse is reported, shown under Show archived and selected read-only from Add negative and New',async({page})=>{
|
||||
const f=page.fixture;await openHistory(page,f.positive_id);
|
||||
@@ -735,8 +765,8 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
await expect(toast(page)).toHaveText(message);
|
||||
await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
const lane=variantsPanel(page).locator('.library-variant-archived');await expect(lane).toHaveCount(1);
|
||||
await expect(lane.locator('.library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.archived_negative);
|
||||
await expect(lane.locator('.variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(f.archived_negative);
|
||||
await expect.poll(()=>galleryReads.at(-1)?.get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.archived_pair_id));
|
||||
await uniqueDotNames(page);
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
@@ -744,7 +774,7 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
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 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 .library-variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -805,7 +835,7 @@ test('turning Show archived on again after a hidden archive shows the newly arch
|
||||
await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled();await button(dialog,'Archive').click();await expect(dialog).toHaveCount(0);
|
||||
await expect(toggle()).toHaveText('Show archived (2)');await toggle().click();
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived')).toHaveCount(2);
|
||||
await expect(laneLabel(page,'· 1 linked image · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
await expect(laneLabel(page,'· 2 runs · Archived')).toHaveCount(1);await uniqueDotNames(page);
|
||||
});
|
||||
|
||||
test('the comparison reports a wrong role color, a wrong field color and a removed Save control',async({page})=>{
|
||||
@@ -869,7 +899,7 @@ test('long and error states retain footer focus and reflow',async({page})=>{
|
||||
|
||||
// Task A2: the editor dialogs keep matching the concept on the concept base stylesheet, with library-editor.css retired.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
'js/workspace/workspace.css'];
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});const f=page.fixture;const references={};
|
||||
for(const mode of Object.keys(conceptModes)) {
|
||||
|
||||
@@ -7,13 +7,32 @@ const history=page=>page.getByRole('region',{name:'Prompt history',exact:true});
|
||||
const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true});
|
||||
const media=page=>gallery(page).locator('article[data-image-key]');
|
||||
const allImages=page=>page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
|
||||
const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
/** The Library's Tags filter lives in "Refine this view" (#249 D14). */
|
||||
async function applyTags(page,tags) {
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter is active.
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:/^Filters(?: · 1)?$/}).click();
|
||||
await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill(tags);
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
}
|
||||
/** The first shelf page, then `pages` more, each waited for by its own response (the caption counts the filtered set). */
|
||||
async function loadShelfPages(page,more,pages) {
|
||||
await expect(page.locator('.section-caption')).toContainText('203 positive prompts');await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
for(let index=0;index<pages;index++) {
|
||||
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
||||
await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
}
|
||||
}
|
||||
async function openHistory(page,text='Cat portrait') {
|
||||
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
|
||||
await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
await expect(history(page)).toBeVisible();
|
||||
}
|
||||
// The fixture's pairs are titled, so each dot is named by its title (#249 D45); these contracts address lanes by position.
|
||||
const variantDot=(page,index)=>history(page).locator('.timeline > .variant-lane').nth(index).locator(':scope > .variant-dot');
|
||||
async function selectVariant(page,index) {
|
||||
const choice=history(page).getByRole('button',{name:`Select negative variant ${index+1}`,exact:true});
|
||||
const choice=variantDot(page,index);
|
||||
await choice.click();await expect(choice).toHaveAttribute('aria-pressed','true');
|
||||
}
|
||||
|
||||
@@ -23,7 +42,8 @@ test.beforeEach(async({page,request,browserName})=>{
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);});
|
||||
|
||||
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page})=>{
|
||||
test('library groups reusable negatives under one positive without exposing negative-only components as parents',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();
|
||||
await page.goto(route);await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library');
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(cards(page).filter({has:page.getByText('Cat portrait',{exact:true})})).toHaveCount(1);
|
||||
@@ -31,7 +51,8 @@ test('library groups reusable negatives under one positive without exposing nega
|
||||
await expect(cards(page).filter({hasText:'low contrast'})).toHaveCount(0);
|
||||
const cat=cards(page).filter({has:page.getByText('Cat portrait',{exact:true})});
|
||||
await expect(cat).toContainText(/4.*variants/i);await expect(cat).toContainText(/3.*images/i);
|
||||
await expect(page.locator('#workspace-main')).not.toContainText(/\b\d+ (?:tracked )?runs\b/i);
|
||||
// D23: "N runs" is the parent's own run_count, never a recorded-execution count.
|
||||
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'}`);
|
||||
});
|
||||
|
||||
test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{
|
||||
@@ -39,31 +60,34 @@ test('history displays the selected full negative beneath its positive and filte
|
||||
const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
// The concept opens on the first negative variant and filters the gallery to it (app.js navigate: state.variant = 0).
|
||||
await page.goto(route);await openHistory(page);
|
||||
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(media(page)).toHaveCount(['blurry, watermark','low quality'].includes(data.variants[0].negative_text)?1:0);
|
||||
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).toHaveAttribute('aria-pressed','false');
|
||||
// #249 D26: All images for this positive opens a dialog; it is not a gallery filter.
|
||||
await expect(history(page).getByRole('button',{name:'All images for this positive',exact:true})).not.toHaveAttribute('aria-pressed',/.*/);
|
||||
for(const negative of ['blurry, watermark','low quality','low contrast','']) {
|
||||
const index=data.variants.findIndex(variant=>variant.negative_text===negative);await selectVariant(page,index);
|
||||
const neg=saved(page).getByRole('region',{name:'Negative prompt',exact:true});
|
||||
await expect(neg).toContainText(negative||'Empty negative prompt');
|
||||
await expect(neg.locator('p.prompt-text')).toHaveText(negative||'No negative prompt (explicitly empty)');
|
||||
await expect(media(page)).toHaveCount(negative==='blurry, watermark'||negative==='low quality'?1:0);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
|
||||
await expect(positive).toContainText('Cat portrait');
|
||||
const order=await positive.evaluate((element)=>element.compareDocumentPosition(element.parentElement.querySelector('[aria-label="Negative prompt"]')));
|
||||
expect(order&4).toBeTruthy();
|
||||
}
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(media(page)).toHaveCount(3);
|
||||
expect(await media(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const listing=allImages(page).locator('article[data-image-key]');await expect(listing).toHaveCount(3);
|
||||
expect(await listing.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(expect.arrayContaining(['0:shared/same.png','1:shared/same.png','0:legacy-unassigned.png']));
|
||||
await page.keyboard.press('Escape');await expect(allImages(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{
|
||||
await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape');
|
||||
await page.goto(route);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Dog landscape');
|
||||
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');
|
||||
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('');
|
||||
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits');
|
||||
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0);
|
||||
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('');
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
|
||||
await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
await applyTags(page,'outdoors');await expect(cards(page)).toHaveCount(0);
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{
|
||||
@@ -83,10 +107,34 @@ test('library infinite loading keeps unique parents and keyboard-accessible hist
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
|
||||
await expect(page.getByRole('button',{name:/next page|previous page/i})).toHaveCount(0);
|
||||
const firstId=await cards(page).first().getAttribute('data-prompt-id');
|
||||
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('link',{name:'Explore history',exact:true});
|
||||
const explore=page.locator(`article[data-prompt-id="${firstId}"]`).getByRole('button',{name:'Explore history',exact:true});
|
||||
await explore.focus();await expect(explore).toBeFocused();await page.keyboard.press('Enter');await expect(history(page)).toBeVisible();
|
||||
});
|
||||
|
||||
// D40/D15: with no Refresh, Retry also re-reads a category list that failed with the shelf.
|
||||
test('Retry re-reads a failed category list with the shelf',async({page})=>{
|
||||
let fail=true;const reads=[];
|
||||
await page.route(url=>['/prompt_manager/library','/prompt_manager/library/options'].includes(url.pathname),async request=>{
|
||||
reads.push(new URL(request.request().url()).pathname);
|
||||
if(fail)return request.fulfill({status:503,json:{success:false,error:'Unavailable'}});
|
||||
await request.continue().catch(()=>{});
|
||||
});
|
||||
await page.goto(route);
|
||||
const retry=page.getByRole('button',{name:'Retry prompt library',exact:true});await expect(retry).toBeVisible();
|
||||
const categoriesFailed=page.getByRole('alert').filter({hasText:'Categories could not be loaded. Open the Prompt library again to retry.'});
|
||||
await expect(categoriesFailed).toBeVisible();
|
||||
fail=false;reads.length=0;await retry.click();await expect(cards(page)).toHaveCount(3);
|
||||
await expect(categoriesFailed).toBeHidden();
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true}).locator('option',{hasText:'Portraits'})).toHaveCount(1);
|
||||
expect(reads).toContain('/prompt_manager/library/options');
|
||||
// Apply with the same Tags, and a search that lands on the values already shown, reload nothing.
|
||||
reads.length=0;
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click();
|
||||
await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0);
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' ');
|
||||
await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{
|
||||
let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/library?*',async request=>{
|
||||
@@ -97,11 +145,82 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
});
|
||||
await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible();
|
||||
fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog');
|
||||
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
|
||||
});
|
||||
|
||||
test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{
|
||||
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']);
|
||||
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);
|
||||
await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1);
|
||||
// Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does.
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1);
|
||||
// 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 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);
|
||||
const sorted=page.waitForRequest(item=>new URL(item.url()).searchParams.get('sort')==='name');
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption({label:'Name A–Z'});await sorted;
|
||||
});
|
||||
|
||||
test('caption counts equal the filtered cards',async({page,request})=>{
|
||||
const totals=async query=>{const data=await(await request.get(`/prompt_manager/library?${query}`)).json();return [data.items.length,data.items.reduce((sum,item)=>sum+item.variant_count,0),data.variant_total];};
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const caption=page.locator('#workspace-main .section-caption');
|
||||
for(const [query,act] of [['',null],['category=Portraits',()=>page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits')],['category=Portraits&tag=outdoors',()=>applyTags(page,'outdoors')]]) {
|
||||
if(act)await act();
|
||||
const [count,variants,total]=await totals(query);expect(variants).toBe(total);
|
||||
await expect(cards(page)).toHaveCount(count);
|
||||
await expect(caption.locator('span')).toHaveText([`${count} positive prompt${count===1?'':'s'} · ${variants} negative variant${variants===1?'':'s'}`,'Images are grouped by positive prompt']);
|
||||
}
|
||||
});
|
||||
|
||||
// #249 final review M5: the count is a status region, written only when it changes, so Load more never re-announces it.
|
||||
test('the caption count is announced once per change and kept while the shelf reloads',async({page,request})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await page.goto(route);const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
const count=page.locator('#workspace-main .section-caption > span').first();
|
||||
await expect(count).toHaveAttribute('role','status');await expect(count).toHaveText(/^203 positive prompts · \d+ negative variants?$/);
|
||||
await count.evaluate(node=>{window.__countWrites=0;new MutationObserver(records=>{window.__countWrites+=records.length;}).observe(node,{childList:true,characterData:true,subtree:true});});
|
||||
await loadShelfPages(page,more,1);
|
||||
expect(await page.evaluate(()=>window.__countWrites)).toBe(0);
|
||||
// A filter change keeps the last count shown while the next page loads, then writes the new one.
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library'&&url.searchParams.get('category')==='Portraits',async route=>{await held;await route.continue();});
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');
|
||||
await expect(page.locator('.library-status')).toHaveText('Loading prompt library…');
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();await expect(count).toHaveText(/^203 positive prompts/);
|
||||
release();await expect(count).not.toHaveText(/^203 /);
|
||||
expect(await page.evaluate(()=>window.__countWrites)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a Library-embedded media card stars its file and the star survives a reload',async({page})=>{
|
||||
await page.goto(route);await openHistory(page);
|
||||
// The unassigned file is reached through the All images dialog (#249 D26).
|
||||
await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();await expect(allImages(page).locator('article[data-image-key]')).toHaveCount(3);
|
||||
const card=allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]');
|
||||
const star=card.getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true});
|
||||
await expect(star).toHaveAttribute('aria-pressed','false');await expect(card.locator('input[type=checkbox]')).toHaveCount(0);
|
||||
await expect(page.locator('.bulkbar, #bulkbar')).toHaveCount(0);
|
||||
const saved=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/images/favorite'&&item.request().method()==='PUT');
|
||||
await star.click();expect((await saved).ok()).toBe(true);
|
||||
await expect(star).toHaveAttribute('aria-pressed','true');await expect(star).toHaveText('★');
|
||||
await expect(page.getByText('Added to favorites.',{exact:true})).toBeVisible();
|
||||
await page.reload();await history(page).getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(allImages(page).locator('article[data-image-key="0:legacy-unassigned.png"]').getByRole('button',{name:'Favorite legacy-unassigned.png',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
});
|
||||
|
||||
test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id');
|
||||
await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK();
|
||||
@@ -111,31 +230,32 @@ test('refresh atomically discards old row geometry and renders changed parent da
|
||||
window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);});
|
||||
});
|
||||
try {
|
||||
await page.getByRole('button',{name:'Refresh library',exact:true}).click();
|
||||
// D15: no Refresh; any filter change restarts the stream the same way.
|
||||
await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');
|
||||
await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…');
|
||||
} 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.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1);
|
||||
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('★★★★★');
|
||||
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
|
||||
});
|
||||
|
||||
test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await page.goto(route);
|
||||
await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals');
|
||||
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Animals');
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await openHistory(page);await selectVariant(page,0);
|
||||
await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:300});await page.goto(route);
|
||||
await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive');
|
||||
await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive');
|
||||
await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.');
|
||||
await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight));
|
||||
await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0);
|
||||
@@ -143,17 +263,19 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor'
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click());
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click());
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('No matching saved positive');
|
||||
await expect(cards(page)).toHaveCount(0);
|
||||
await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1);
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape');
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.focus();await search.fill('Dog landscape');
|
||||
await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused();
|
||||
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1);
|
||||
await page.evaluate(()=>scrollTo(0,0));await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');await expect(cards(page)).toHaveCount(1);
|
||||
expect(await page.evaluate(()=>scrollY)).toBe(0);
|
||||
const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
|
||||
await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape');
|
||||
await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
// A short phone window, so the one-card shelf can scroll far enough to put the parent back at its offset (nothing
|
||||
// follows the shelf's continuation since the legacy link moved to Overview, #249 E3).
|
||||
await page.setViewportSize({width:390,height:600});await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape');
|
||||
});
|
||||
|
||||
test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{
|
||||
@@ -163,7 +285,7 @@ test('history detail failures retry and returning to the list ignores late detai
|
||||
await hold;await route.continue().catch(()=>{});
|
||||
});
|
||||
try {
|
||||
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
|
||||
await page.goto(route);await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
await expect(page.getByRole('button',{name:'Retry prompt history',exact:true})).toBeVisible();
|
||||
fail=false;await page.getByRole('button',{name:'Retry prompt history',exact:true}).click();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();release();
|
||||
@@ -187,7 +309,7 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
const parentId=await cards(page).filter({has:page.getByText('Cat portrait',{exact:true})}).getAttribute('data-prompt-id');
|
||||
const recipe=await(await request.get(`/prompt_manager/library/${parentId}`)).json();
|
||||
await openHistory(page);await selectVariant(page,recipe.variants.findIndex(variant=>variant.negative_text==='blurry, watermark'));
|
||||
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
|
||||
const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
|
||||
await page.locator('#workspace-main > .heading').getByRole('button',{name:'Send prompt pair to ComfyUI: Cat portrait',exact:true}).click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
@@ -197,11 +319,13 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.libraryApplied)).toEqual([{positive:'Cat portrait',negative:chosenNegative}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();
|
||||
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();
|
||||
const inspector=page.getByRole('region',{name:'Image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
await expect(inspector.locator('.generation-facts dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(inspector.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await media(page).first().getByRole('button',{name:'Inspect same.png',exact:true}).click();
|
||||
const inspector=page.getByRole('complementary',{name:'Selected image details',exact:true});await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait');
|
||||
await expect(inspector.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
// D20: provenance and raw records are the viewer's full details, opened from the card image (D19).
|
||||
await expect(inspector.locator('summary')).toHaveCount(0);
|
||||
await media(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
|
||||
await expect(page.locator('dialog[open] #viewer-details summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click();await expect(page.locator('.viewer-container')).toHaveCount(0);
|
||||
} finally {await host.evaluate(()=>window.libraryHost.dispose());await host.close();}
|
||||
@@ -210,11 +334,11 @@ test('history shares real image metadata, vendored Viewer and the acknowledged p
|
||||
test('large variant histories append bounded pages without replacing the selected negative',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_variants:41}})).toBeOK();
|
||||
await page.goto(route);await openHistory(page);
|
||||
const variants=history(page).getByRole('button',{name:/^Select negative variant \d+$/});await expect(variants).toHaveCount(40);
|
||||
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').textContent();
|
||||
const variants=history(page).getByRole('button',{name:/^Select negative /});await expect(variants).toHaveCount(40);
|
||||
await selectVariant(page,0);const chosenNegative=await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').textContent();
|
||||
await history(page).getByRole('button',{name:'Load more negative variants',exact:true}).click();await expect(variants).toHaveCount(45);
|
||||
await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative);
|
||||
await expect(variantDot(page,0)).toHaveAttribute('aria-pressed','true');
|
||||
await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text')).toHaveText(chosenNegative);
|
||||
});
|
||||
|
||||
async function observeLibrary(page, hold=false) {
|
||||
@@ -231,7 +355,7 @@ async function observeLibrary(page, hold=false) {
|
||||
record.native=new NativeResizeObserver(entries=>{
|
||||
const ready=[];
|
||||
for(const entry of entries) {
|
||||
if(entry.target.matches('.library-card')) {
|
||||
if(entry.target.matches('.prompt-card')) {
|
||||
if(!entry.target.isConnected)unmounted++;
|
||||
record.latest.set(entry.target,entry);
|
||||
if(holding) {record.entries.set(entry.target,entry);continue;}
|
||||
@@ -245,7 +369,7 @@ async function observeLibrary(page, hold=false) {
|
||||
unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);}
|
||||
disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();}
|
||||
};
|
||||
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
|
||||
new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.prompt-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']});
|
||||
window.libraryObservation={
|
||||
hold(){holding=true;},
|
||||
release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}},
|
||||
@@ -273,37 +397,40 @@ async function retainedParent(page, anchor) {
|
||||
await expect.poll(async()=>Math.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
test('Library natural rows retain long prompt content without overflow',async({page,request})=>{
|
||||
test('Library cards clamp long prompts and keep real content inside the card',async({page,request})=>{
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
const data=await(await request.get('/prompt_manager/library')).json();
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6);
|
||||
expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toContainText('One positive.');
|
||||
await expect(page.locator('#workspace-main p').filter({hasText:'One positive. Reusable negatives.'})).toHaveCount(1);
|
||||
for(const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
|
||||
await expect(page.locator('.prompt-section.positive .role-label').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)');
|
||||
}
|
||||
await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
|
||||
await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
|
||||
await expect(page.locator('.prompt-card .card-art').first()).toHaveCSS('height',width===1600?'170px':'150px');
|
||||
await expect(page.locator('.card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px');
|
||||
await expect(page.locator('.library-grid')).toHaveCSS('gap','18px');
|
||||
const long=cards(page).filter({hasText:'A study in very long creative context'});
|
||||
const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500);
|
||||
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560);
|
||||
for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) {
|
||||
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true);
|
||||
// D23: two lines of the concept's 12px/1.75 prompt text, the rest clipped inside the card.
|
||||
expect(await prompt.evaluate(node=>({height:node.clientHeight,clipped:node.scrollHeight>node.clientHeight+1}))).toEqual({height:42,clipped:true});
|
||||
expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeLessThan(560);
|
||||
for(const selector of ['.card-title','.card-content','.card-content > .tags','.card-footer']) {
|
||||
expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1&&node.scrollWidth<=node.clientWidth+1),selector).toBe(true);
|
||||
}
|
||||
await prompt.focus();await expect(prompt).toBeFocused();
|
||||
await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
|
||||
await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible();
|
||||
await long.getByRole('button',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded();
|
||||
await expect(long.getByRole('button',{name:'Explore history',exact:true})).toBeVisible();
|
||||
for(const item of data.items) {
|
||||
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
|
||||
await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1);
|
||||
await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags);
|
||||
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`:[]);
|
||||
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'}`);
|
||||
}
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i);
|
||||
const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};}));
|
||||
for(let i=0;i<boxes.length;i++) for(const b of boxes.slice(i+1)) {const a=boxes[i];expect(a.right<=b.x+1||b.right<=a.x+1||a.bottom<=b.y+1||b.bottom<=a.y+1).toBe(true);}
|
||||
}
|
||||
@@ -317,23 +444,22 @@ test('row measurement preserves anchor after width invalidation and append',asyn
|
||||
try {
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
|
||||
await expect(page.locator('.library-caption')).toContainText('160 of 203');
|
||||
await loadShelfPages(page,more,3);
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25));
|
||||
await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull();
|
||||
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor);
|
||||
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
|
||||
await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.prompt-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after);
|
||||
{const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');}await settleLibrary(page);await retainedParent(page,after);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
expect(await cards(page).count()).toBeLessThan(80);
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length);
|
||||
for(const changedWidth of [false,true]) {
|
||||
const before=await firstVisibleParent(page);
|
||||
await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
await page.locator(`article[data-prompt-id="${before.id}"]`).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible();
|
||||
if(changedWidth)await page.setViewportSize({width:1600,height:1100});
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before);
|
||||
expect(await cards(page).count()).toBeLessThan(80);
|
||||
@@ -347,11 +473,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
|
||||
await expect(page.locator('.library-caption')).toContainText('160 of 203');
|
||||
await loadShelfPages(page,more,3);
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25));
|
||||
await settleLibrary(page);await page.mouse.move(1100,550);
|
||||
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('button').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
try {
|
||||
// The same queued-frame wheel sequence is stable without changed measurements.
|
||||
const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY);
|
||||
@@ -362,7 +487,7 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160});
|
||||
|
||||
const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';});
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='45px';});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
|
||||
const parent=page.locator(`article[data-prompt-id="${before.id}"]`);
|
||||
const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset;
|
||||
@@ -378,10 +503,10 @@ test('queued row growth preserves newer native wheel movement and correction eve
|
||||
|
||||
// The correction's own native scroll event must not become new user motion.
|
||||
const corrected=await firstVisibleParent(page);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';});
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='65px';});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
|
||||
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] button`).first()).toBeFocused();
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length);
|
||||
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
|
||||
@@ -411,8 +536,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId;
|
||||
} else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);}
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
|
||||
await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page);
|
||||
await loadShelfPages(page,more,3);await settleLibrary(page);
|
||||
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0);
|
||||
const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls}));
|
||||
await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page);
|
||||
@@ -422,7 +546,7 @@ test('unchanged row observations settle without resize feedback',async({page,req
|
||||
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls);
|
||||
const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page);
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page);
|
||||
const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);
|
||||
if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1);
|
||||
await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page);
|
||||
@@ -436,12 +560,151 @@ test('saved choices retain curved staggered lanes and the full negative immediat
|
||||
await page.goto(route);await openHistory(page);await selectVariant(page,1);
|
||||
const positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});
|
||||
expect(await positive.evaluate(node=>node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
const values=await page.locator('.library-variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
|
||||
const values=await page.locator('.library-history .variant-lane').evaluateAll(nodes=>nodes.map(node=>({offset:parseFloat(getComputedStyle(node).marginInlineStart),radius:getComputedStyle(node,'::before').borderBottomLeftRadius})));
|
||||
expect(values).toEqual([{offset:38,radius:'22px'},{offset:85,radius:'22px'},{offset:132,radius:'22px'},{offset:179,radius:'22px'}]);
|
||||
const order=await page.locator('.library-history').evaluate(node=>[...node.querySelectorAll('section[aria-label]')].filter(section=>['Saved negative variants','Recorded executions','Shared gallery'].includes(section.getAttribute('aria-label'))).map(section=>section.getAttribute('aria-label')));
|
||||
expect(order).toEqual(['Saved negative variants','Recorded executions','Shared gallery']);
|
||||
expect(order).toEqual(['Saved negative variants','Shared gallery','Recorded executions']);
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
const details=await(await request.get(`/prompt_manager/library/${cat.id}`)).json();
|
||||
const selected=details.variants[1].negative_text;
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre').evaluate(node=>node.textContent)).toBe(selected);
|
||||
expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('p.prompt-text').evaluate(node=>node.textContent)).toBe(selected);
|
||||
});
|
||||
|
||||
/** Rewrites each shelf item the server sends: run counts, last use and long texts are values the fixture never records. */
|
||||
async function patchShelf(page,patch) {
|
||||
await page.route(url=>url.pathname==='/prompt_manager/library',async route=>{
|
||||
const response=await route.fetch();const body=await response.json();
|
||||
body.items=body.items.map(item=>({...item,...patch(item)}));
|
||||
await route.fulfill({response,json:body});
|
||||
});
|
||||
}
|
||||
/** The footer line as seen: everything but its visually hidden rating sentence. */
|
||||
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');
|
||||
|
||||
test('card matches the concept and never wraps the category mid-word',async({page,request})=>{
|
||||
const before=await(await request.get('/prompt_manager/library')).json();
|
||||
const dog=before.items.find(item=>item.text==='Dog landscape'),cat=before.items.find(item=>item.text==='Cat portrait');
|
||||
const dual=before.items.find(item=>item.text==='Dual role portrait');
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dog.id],category:'asian'}})).toBeOK();
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[dual.id],category:''}})).toBeOK();
|
||||
// A long saved title fills the title row, so only the chip's own min-content keeps "Uncategorized" and "asian" whole.
|
||||
const wide='A remarkably long saved positive prompt title that fills the whole card title row';
|
||||
await patchShelf(page,item=>item.id===cat.id?{run_count:4,last_used_at:new Date().toISOString(),rating:5}:item.id===dual.id||item.id===dog.id?{title:wide,run_count:1,last_used_at:null,rating:null}:{});
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
// app.js renderLibrary a586: article.prompt-card > .card-art, .card-content, .card-footer.
|
||||
await expect(card).toHaveClass('prompt-card');
|
||||
expect(await card.evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-art','card-content','card-footer']);
|
||||
expect(await card.locator('.card-art').evaluate(node=>[...node.children].map(child=>child.tagName))).toEqual(['IMG','IMG']);
|
||||
expect(await card.locator('.card-content').evaluate(node=>[...node.children].map(child=>child.className))).toEqual(['card-title','prompt-inspector','card-meta','tags']);
|
||||
expect(await card.locator('.card-title').evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['BUTTON.','SPAN.chip']);
|
||||
await expect(card.locator('.card-title > button')).toHaveText('Cat portrait');await expect(card.locator('.card-title > .chip')).toHaveText('Animals');
|
||||
await expect(card.locator('.card-title > button')).toHaveCSS('font-size','16px');await expect(card.locator('.card-title > button')).toHaveCSS('font-weight','600');
|
||||
const positive=card.locator('.card-content > .prompt-inspector > section.prompt-section.positive');await expect(positive).toHaveCount(1);
|
||||
await expect(card.locator('section.prompt-section.negative')).toHaveCount(0);
|
||||
await expect(positive.getByRole('button',{name:'Copy positive prompt: Cat portrait',exact:true})).toHaveCount(1);
|
||||
await expect(positive.getByRole('button',{name:'Send positive prompt to ComfyUI: Cat portrait',exact:true})).toHaveCount(1);
|
||||
await expect(card.locator('.card-meta > *')).toHaveText(['− 4 negative variants','4 runs','3 images']);
|
||||
await expect(card.locator('.card-meta > .chip.red')).toHaveCount(1);
|
||||
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');
|
||||
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.
|
||||
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);
|
||||
await expect(unrated.locator('.card-meta > *')).toHaveText([`− ${dual.variant_count} negative variant${dual.variant_count===1?'':'s'}`,'1 run',`${dual.image_count} image${dual.image_count===1?'':'s'}`]);
|
||||
for(const [id,text] of [[dual.id,'Uncategorized'],[dog.id,'asian']]) {
|
||||
const chip=page.locator(`article[data-prompt-id="${id}"] .card-title > .chip`);await expect(chip).toHaveText(text);
|
||||
expect(await chip.evaluate(node=>{const range=document.createRange();range.selectNodeContents(node);return range.getClientRects().length;}),`${text} at ${width}`).toBe(1);
|
||||
expect(await page.locator(`article[data-prompt-id="${id}"] .card-title`).evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true);
|
||||
}
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('long prompt is clamped to two lines while copy sends the full text',async({page,context,request})=>{
|
||||
const before=await(await request.get('/prompt_manager/library')).json();const cat=before.items.find(item=>item.text==='Cat portrait');
|
||||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||||
await patchShelf(page,item=>item.id===cat.id?{text:longPrompt,title:'Long observatory'}:{});
|
||||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||||
await host.evaluate(async()=>{
|
||||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||||
const target={id:'positive-1',label:'Card destination',type:'PromptManager',roles:['positive']};
|
||||
window.cardApplied=[];
|
||||
window.cardHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Card ComfyUI',adapter:{
|
||||
listTargets:()=>[target],preview:(_id,roles)=>({preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive}},snapshot:roles}),
|
||||
apply:roles=>{window.cardApplied.push(roles);return{targetId:target.id,values:roles};},
|
||||
}});
|
||||
});
|
||||
try {
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});await page.goto(route);
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`),text=card.locator('.prompt-text');
|
||||
await expect(text).toHaveText(longPrompt);
|
||||
const box=await text.evaluate(node=>{const style=getComputedStyle(node);return {lines:Math.round(node.clientHeight/parseFloat(style.lineHeight)),clipped:node.scrollHeight>node.clientHeight+1,clamp:style.webkitLineClamp,overflow:style.overflowY};});
|
||||
expect(box).toEqual({lines:2,clipped:true,clamp:'2',overflow:'hidden'});
|
||||
}
|
||||
const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
await card.getByRole('button',{name:'Copy positive prompt: Long observatory',exact:true}).click();
|
||||
await expect.poll(()=>page.evaluate(()=>window.copied)).toEqual([longPrompt]);
|
||||
await card.locator('.card-footer .row-actions > button.send').click();
|
||||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Card ComfyUI'});
|
||||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Card destination'});
|
||||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||||
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.');
|
||||
expect(await host.evaluate(()=>window.cardApplied)).toEqual([{positive:longPrompt}]);
|
||||
await sender.getByRole('button',{name:'Close',exact:true}).click();
|
||||
} finally {await host.evaluate(()=>window.cardHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
test('title and Explore history are buttons that open history',async({page,request})=>{
|
||||
const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait');
|
||||
await page.goto(route);const card=page.locator(`article[data-prompt-id="${cat.id}"]`);
|
||||
await expect(card.locator('a')).toHaveCount(0);
|
||||
const title=card.locator('.card-title > button');await expect(title).toHaveAttribute('type','button');
|
||||
await title.click();await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3);
|
||||
const explore=page.locator(`article[data-prompt-id="${cat.id}"]`).getByRole('button',{name:'Explore history',exact:true});
|
||||
await explore.focus();await page.keyboard.press('Enter');await expect(page).toHaveURL(new RegExp(`#/library/${cat.id}$`));await expect(history(page)).toBeVisible();
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
// The card's navigation buttons keep the shelf's Home/End and arrow movement between cards.
|
||||
const first=cards(page).first();await first.locator('.card-title > button').focus();await page.keyboard.press('End');
|
||||
await expect(cards(page).last().locator('.card-title > button')).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');await expect(cards(page).nth(1).locator('.card-title > button')).toBeFocused();
|
||||
});
|
||||
|
||||
test('virtualized rows keep measured heights with the clamp',async({page,request,isMobile})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await patchShelf(page,item=>({text:`${item.text}\n${longPrompt}`}));
|
||||
// Two columns on a desktop window; the touch project's phone gives the one-column shelf.
|
||||
{
|
||||
if(!isMobile)await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
await loadShelfPages(page,more,3);
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+3000));await settleLibrary(page);
|
||||
const geometry=await page.evaluate(()=>[...document.querySelector('.library-grid').children].map(node=>{
|
||||
const box=node.getBoundingClientRect();
|
||||
return {row:node.classList.contains('library-row'),top:box.top,height:box.height,
|
||||
cards:[...node.querySelectorAll('.prompt-card')].map(card=>({height:card.getBoundingClientRect().height,text:card.querySelector('.prompt-text').clientHeight,clipped:card.querySelector('.prompt-text').scrollHeight>card.querySelector('.prompt-text').clientHeight+1}))};
|
||||
}));
|
||||
const rows=geometry.filter(item=>item.row);expect(rows.length).toBeGreaterThan(2);expect(rows.length).toBeLessThan(60);
|
||||
for(const row of rows) {
|
||||
expect(row.cards.length).toBeGreaterThan(0);
|
||||
for(const card of row.cards) {expect(card.text).toBe(42);expect(card.clipped).toBe(true);expect(Math.abs(card.height-row.height)).toBeLessThanOrEqual(1);}
|
||||
}
|
||||
// Consecutive mounted rows sit one concept gap apart: every height the layout used is the measured one.
|
||||
for(let index=1;index<geometry.length;index++) if(geometry[index].row&&geometry[index-1].row) expect(Math.abs(geometry[index].top-(geometry[index-1].top+geometry[index-1].height+18))).toBeLessThanOrEqual(1);
|
||||
const anchor=await firstVisibleParent(page);await page.evaluate(()=>window.libraryObservation.equal());await settleLibrary(page);await retainedParent(page,anchor);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||||
|
||||
// Saved views (#249 D16, maintainer ruling 2026-10-21): the toolbar's Save view (concept app.js a359, a1330–1346),
|
||||
// the sidebar SAVED VIEWS rows (a331–335) and `#/saved/<id>` (a1158–1162), on Images and the Prompt library.
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const API = '/prompt_manager/saved-views';
|
||||
const main = page => page.locator('#workspace-main');
|
||||
const toolbar = page => main(page).locator('search.toolbar');
|
||||
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
|
||||
const sortBy = page => page.getByRole('combobox', { name: 'Sort results', exact: true });
|
||||
const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filters(?: · \d+)?$/ });
|
||||
const imageCards = page => page.locator('article[data-image-key]');
|
||||
const promptCards = page => page.locator('article[data-prompt-id]');
|
||||
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
|
||||
// The saved views' own rows, after the Favorites row (D51).
|
||||
const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]');
|
||||
const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]');
|
||||
const favoritesCount = page => favoritesRow(page).locator('.nav-count');
|
||||
const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true });
|
||||
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true });
|
||||
const heading = page => main(page).getByRole('heading', { level: 1 });
|
||||
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
|
||||
async function refine(page, values) {
|
||||
await filtersButton(page).click();
|
||||
for (const [label, value] of Object.entries(values)) {
|
||||
const control = refineDialog(page).getByLabel(label, { exact: true });
|
||||
if (label === 'Tags') await control.fill(value);
|
||||
else await control.selectOption(value);
|
||||
}
|
||||
await refineDialog(page).getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await expect(refineDialog(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function listed(request) {
|
||||
const response = await request.get(API);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).views;
|
||||
}
|
||||
|
||||
async function create(request, data) {
|
||||
const response = await request.post(API, { data });
|
||||
expect(response.status()).toBe(201);
|
||||
return (await response.json()).view;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
page.savedErrors = [];
|
||||
page.on('pageerror', error => page.savedErrors.push(error.message));
|
||||
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
|
||||
page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
|
||||
// No listing read may fail on a saved view's values (an unknown Source would be a 400).
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') {
|
||||
page.savedErrors.push(`${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.savedErrors).toEqual([]);
|
||||
});
|
||||
|
||||
test('Save view saves the Images toolbar and Refine filters, and its sidebar row reopens them', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
|
||||
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
|
||||
await search(page).fill('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await sortBy(page).selectOption('name');
|
||||
await refine(page, { Source: '1', Media: 'image' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
const saveButton = toolbar(page).getByRole('button', { name: 'Save view', exact: true });
|
||||
await saveButton.click();
|
||||
// The concept's simpleInput: "View name", the search's default name, Cancel and the primary action.
|
||||
const dialog = saveDialog(page);
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toHaveValue('Search: dogs');
|
||||
await expect(dialog.getByRole('textbox', { name: 'View name', exact: true })).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(saveButton).toBeFocused();
|
||||
expect(await listed(request)).toEqual([]);
|
||||
|
||||
await saveButton.click();
|
||||
await dialog.getByRole('textbox', { name: 'View name', exact: true }).fill('Dog shots');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const [view] = await listed(request);
|
||||
expect(view).toMatchObject({ page: 'images', name: 'Dog shots',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'dogs', sort: 'name', source: '1', media: 'image' } });
|
||||
// The concept's links() row: the search icon, then the name, and no count.
|
||||
const row = savedNav(page).getByRole('link', { name: 'Dog shots', exact: true });
|
||||
await expect(row).toHaveAttribute('href', `#/saved/${view.id}`);
|
||||
expect(await row.evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE ? `text:${node.textContent}`
|
||||
: `${node.localName}.${node.getAttribute('class')}`))).toEqual(['svg.icon', 'text:Dog shots']);
|
||||
await expect(row).not.toHaveAttribute('aria-current', 'page');
|
||||
|
||||
// Another page, then the saved row: its page opens with every saved value and the Filters count (D44).
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Prompt library/ }).click();
|
||||
await expect(promptCards(page).first()).toBeVisible();
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Dog shots');
|
||||
await expect(page).toHaveTitle('Dog shots · Prompt Manager');
|
||||
await expect(row).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(sortBy(page)).toHaveValue('name');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await filtersButton(page).click();
|
||||
await expect(refineDialog(page).getByLabel('Source', { exact: true })).toHaveValue('1');
|
||||
await expect(refineDialog(page).getByLabel('Media', { exact: true })).toHaveValue('image');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// A reload opens the same view from its link.
|
||||
await page.reload();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Dog shots');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(savedNav(page).getByRole('link', { name: 'Dog shots', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('an empty name is Saved view, and a Library view reopens the shelf filters', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const name = saveDialog(page).getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('My workspace view');
|
||||
await name.fill(' ');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(viewRows(page)).toHaveText(['Saved view']);
|
||||
expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS });
|
||||
|
||||
await page.goto(`${workspace}#/library`);
|
||||
await expect(promptCards(page)).toHaveCount(2);
|
||||
await search(page).fill('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await refine(page, { Tags: 'dogs' });
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await expect(name).toHaveValue('Search: dog');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']);
|
||||
const library = (await listed(request))[1];
|
||||
expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } });
|
||||
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await savedNav(page).getByRole('link', { name: 'Search: dog', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Search: dog');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 1');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
// Saved views open the page; one saved view to another re-renders with the other's state.
|
||||
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Saved view');
|
||||
await expect(search(page)).toHaveValue('');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a saved value the page no longer accepts stays default while the rest applies', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'images', name: 'Gone source',
|
||||
state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'name-desc', source: '7', folder: 'missing/folder' } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Gone source');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(sortBy(page)).toHaveValue('name-desc');
|
||||
// The missing Source and Folder are dropped, so no refinement is active.
|
||||
await expect(filtersButton(page)).toHaveText('Filters');
|
||||
await expect(main(page).locator('.filter-row > span.chip')).toHaveText('All output folders');
|
||||
|
||||
// A Source that does exist, with a folder it lists, applies.
|
||||
const kept = await create(request, { page: 'images', name: 'Input shared',
|
||||
state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
|
||||
await page.goto(`${workspace}#/saved/${kept.id}`);
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Input shared');
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
test('a view stored under older rules still lists and opens with its usable values (#249 I1)', async ({ page, request }) => {
|
||||
// No favorites key, a retired key and a sort that is no longer offered, as an older build stored it.
|
||||
const raw = async data => {
|
||||
const response = await request.post('/__test__/saved-views/raw', { data });
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).id;
|
||||
};
|
||||
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
|
||||
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: [] } }),
|
||||
]);
|
||||
// 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' });
|
||||
expect((await listed(request)).map(view => view.id)).not.toContain(corruptId);
|
||||
await page.goto(`${workspace}#/saved/${corruptId}`);
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
|
||||
|
||||
await page.goto(`${workspace}#/saved/${imagesId}`);
|
||||
await expect(heading(page)).toHaveText('Old cats');
|
||||
await expect(viewRows(page)).toHaveText(['Old cats', 'Saved view']);
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(sortBy(page)).toHaveValue('newest');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await savedNav(page).getByRole('link', { name: 'Saved view', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Saved view');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
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: [] } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await search(page).fill('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await viewRows(page).filter({ hasText: 'Dogs' }).click();
|
||||
await expect(heading(page)).toHaveText('Dogs');
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(promptCards(page)).toHaveCount(2);
|
||||
await search(page).fill('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await page.goto(`${workspace}#/saved/${cats.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await page.locator('#primary-nav a[data-route="library"]').click();
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
expect(dogs.id).not.toBe(cats.id);
|
||||
});
|
||||
|
||||
test('a toolbar change while sources load keeps the saved Source and Folder (#249 M3)', async ({ page, request }) => {
|
||||
const view = await create(request, { page: 'images', name: 'Input shared', state: { ...IMAGE_DEFAULTS, source: '1', folder: 'shared' } });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
await page.route('**/prompt_manager/gallery/subfolders?**', async route => { await held; await route.fallback(); });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Input shared');
|
||||
await sortBy(page).selectOption('name');
|
||||
await expect(imageCards(page)).toHaveCount(0);
|
||||
release();
|
||||
await expect(filtersButton(page)).toHaveText('Filters · 2');
|
||||
await expect(sortBy(page)).toHaveValue('name');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
});
|
||||
|
||||
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: [] } });
|
||||
let releaseList = () => {};
|
||||
const holdList = async () => {
|
||||
const held = new Promise(resolve => { releaseList = resolve; });
|
||||
await page.unroute(`**${API}`);
|
||||
await page.route(`**${API}`, async route => { if (route.request().method() === 'GET') await held; await route.fallback(); });
|
||||
};
|
||||
// Leaving while the shell's first read is pending: the view never mounts over the page left to.
|
||||
await holdList();
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await page.evaluate(() => { window.location.hash = '#/tags'; });
|
||||
await expect(heading(page)).toHaveText('Tags & categories');
|
||||
releaseList();
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await expect(heading(page)).toHaveText('Tags & categories');
|
||||
// An unknown id re-reads the list once; leaving during that read keeps the page left to.
|
||||
await holdList();
|
||||
await page.evaluate(() => { window.location.hash = '#/saved/999'; });
|
||||
await page.evaluate(() => { window.location.hash = '#/library'; });
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
releaseList();
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await page.unroute(`**${API}`);
|
||||
|
||||
// A rename that lands after the view was left does not retitle the next page.
|
||||
let releaseRename;
|
||||
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
|
||||
await page.route(`**${API}/${view.id}`, async route => { await renameHeld; await route.fallback(); });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await main(page).locator('.heading-actions > button').first().click();
|
||||
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.locator('#primary-nav a[data-route="images"]').click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
releaseRename();
|
||||
await expect(viewRows(page)).toHaveText(['Kittens']);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(page).toHaveTitle('Images · Prompt Manager');
|
||||
});
|
||||
|
||||
test('an unknown saved view opens Images, and a failed listing offers Retry', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/saved/999`);
|
||||
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: [] } });
|
||||
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"}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
failing = false;
|
||||
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
});
|
||||
|
||||
test('a failed save says so and adds nothing', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.route(`**${API}`, route => route.request().method() === 'POST'
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be saved. Try again.');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
});
|
||||
|
||||
async function star(page, key) {
|
||||
await page.locator(`article[data-image-key="${key}"] .star-button`).click();
|
||||
}
|
||||
|
||||
test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
// Hidden until its read settles: never a placeholder "0".
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites');
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
release();
|
||||
// The concept's row (app.js a332): the star icon, "Favorites", then the count.
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE
|
||||
? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`)))
|
||||
.toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']);
|
||||
expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']);
|
||||
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Added to favorites.');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417).
|
||||
const listings = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams);
|
||||
});
|
||||
await favoritesRow(page).click();
|
||||
await expect(page).toHaveURL(/#\/favorites$/);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites');
|
||||
await expect(page).toHaveTitle('Favorites · Prompt Manager');
|
||||
await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/);
|
||||
expect(listings.at(-1).get('favorites')).toBe('1');
|
||||
|
||||
// Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows.
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(toast(page)).toHaveText('Removed from favorites.');
|
||||
await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false');
|
||||
await expect(favoritesCount(page)).toHaveText('0');
|
||||
await star(page, '1:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
|
||||
// Favorites never replaces the Images return state: Images still lists everything.
|
||||
await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click();
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(heading(page)).toHaveText('Favorites');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a failed Favorites count shows no badge', async ({ page }) => {
|
||||
await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1'
|
||||
&& url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2');
|
||||
await expect(favoritesRow(page)).toBeVisible();
|
||||
await expect(favoritesCount(page)).toHaveCount(0);
|
||||
// The refused count read is this test's own; nothing else failed.
|
||||
page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error));
|
||||
});
|
||||
|
||||
test('Save view on Favorites reopens the favorites', async ({ page, request }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await star(page, '0:shared/same.png');
|
||||
await expect(favoritesCount(page)).toHaveText('1');
|
||||
await page.goto(`${workspace}#/favorites`);
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best');
|
||||
await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
const [view] = await listed(request);
|
||||
expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } });
|
||||
await expect(viewRows(page)).toHaveText(['Best']);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Best');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
});
|
||||
|
||||
test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(2);
|
||||
// Plain pages have no Edit.
|
||||
await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0);
|
||||
await viewRows(page).click();
|
||||
await expect(heading(page)).toHaveText('Dogs');
|
||||
// D51: Edit leads the heading's actions of an open view.
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
await edit.click();
|
||||
const dialog = editDialog(page);
|
||||
const name = dialog.getByRole('textbox', { name: 'View name', exact: true });
|
||||
await expect(name).toHaveValue('Dogs');
|
||||
await expect(name).toBeFocused();
|
||||
await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
|
||||
await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(edit).toBeFocused();
|
||||
|
||||
// An empty name keeps the dialog open on its field.
|
||||
await edit.click();
|
||||
await name.fill(' ');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(name).toBeFocused();
|
||||
await name.fill('Good dogs');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view renamed.');
|
||||
await expect(viewRows(page)).toHaveText(['Good dogs']);
|
||||
await expect(heading(page)).toHaveText('Good dogs');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs');
|
||||
await expect(page).toHaveTitle('Good dogs · Prompt Manager');
|
||||
await expect(edit).toBeFocused();
|
||||
expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]);
|
||||
// The rename kept the view's page state.
|
||||
await expect(search(page)).toHaveValue('dogs');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
|
||||
// Delete… asks first; its Cancel deletes nothing.
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const confirm = deleteDialog(page);
|
||||
await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.');
|
||||
await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']);
|
||||
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
|
||||
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(confirm).toHaveCount(0);
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
await edit.click();
|
||||
await dialog.getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await confirm.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(heading(page)).toHaveText('Images');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
expect(await listed(request)).toEqual([]);
|
||||
// The dead link left no history entry.
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
});
|
||||
|
||||
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: [] } });
|
||||
await page.goto(`${workspace}#/saved/${view.id}`);
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
const edit = main(page).locator('.heading-actions > button').first();
|
||||
await expect(edit).toHaveText('Edit');
|
||||
let failing = true;
|
||||
await page.route(`**${API}/${view.id}`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' })
|
||||
: route.fallback());
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be renamed. Try again.');
|
||||
await expect(heading(page)).toHaveText('Cats');
|
||||
await expect(viewRows(page)).toHaveText(['Cats']);
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('The view could not be deleted. Try again.');
|
||||
await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`));
|
||||
expect(await listed(request)).toHaveLength(1);
|
||||
|
||||
failing = false;
|
||||
await edit.click();
|
||||
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view deleted.');
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(heading(page)).toHaveText('Prompt library');
|
||||
await expect(viewRows(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -29,7 +29,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) {
|
||||
const screenshotPath = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshotPath, fullPage });
|
||||
await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' });
|
||||
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.heading', 'h1', '.image-workspace', '.image-grid', '.media-card', '.detail-panel', '.prompt-heading', '.library-grid', '.prompt-card', '.prompt-card .card-art', '.prompt-card .card-content'].map(selector => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [selector, null];
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
@@ -94,23 +94,24 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
await page.goto(`${workspace}#/library/${fixture.canonical_id}`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
const details = await (await request.get(`/prompt_manager/library/${fixture.canonical_id}`)).json();
|
||||
const index = details.variants.findIndex(variant => variant.id === fixture.canonical_pair);
|
||||
const choice = page.getByRole('button', { name: `Select negative variant ${index + 1}`, exact: true });
|
||||
const pairTitle = details.variants.find(variant => variant.id === fixture.canonical_pair).title;
|
||||
const choice = page.getByRole('button', { name: `Select negative ${pairTitle}`, exact: true });
|
||||
await choice.focus();await page.keyboard.press('Enter');await expect(choice).toHaveAttribute('aria-pressed', 'true');
|
||||
const actual = await historyGeometry(page, true);
|
||||
const roles = page.getByRole('region', { name: 'Saved prompt texts', exact: true });
|
||||
const positive = roles.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
expect(await positive.evaluate(node => node.nextElementSibling?.getAttribute('aria-label'))).toBe('Negative prompt');
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt']) await expect(roles.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
const title = await page.locator('#workspace-main > .heading h1').textContent();
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(roles.getByRole('button', { name: `${name}: ${title}`, exact: true })).toBeVisible();
|
||||
// As in the concept, the selected pair's send sits in the history heading, not among the saved texts.
|
||||
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('.library-shared-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-shared-gallery .image-open').first().click();
|
||||
await expect(page.getByRole('region', { name: 'Image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.getByRole('button', { name: 'Open full preview', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();await page.keyboard.press('Escape');
|
||||
for (const [name, selector] of [['saved', '.library-surface'], ['branches', '.library-variants'], ['gallery', '.library-shared-gallery'], ['chronology', '.recorded-history']]) {
|
||||
await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
for (const [name, selector] of [['saved', '.library-history article.surface'], ['branches', '.library-history .timeline-panel'], ['gallery', '.library-history .history-gallery'], ['chronology', '.recorded-history']]) {
|
||||
await captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
const run = page.locator(`button[data-run-id="${fixture.canonical_run}"][data-anchor-id="10"]`);
|
||||
@@ -122,16 +123,20 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
for (const name of ['Raw', 'Effective', 'Close recorded run', 'Send prompt pair']) await expect(runDialog.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
await captureCanonicalState(page, testInfo, `production-run-${width}-${theme}`, '.recorded-run-dialog');
|
||||
const view = runDialog.getByRole('button', { name: 'View current file', exact: true }).first();await view.click();
|
||||
const viewer = page.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer.getByRole('region', { name: 'Positive prompt', exact: true })).toContainText(fixture.canonical_positive);
|
||||
await expect(viewer.getByRole('region', { name: 'Negative prompt', exact: true })).toContainText(fixture.canonical_negative);
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt', 'Copy negative prompt', 'Send negative prompt', 'Send prompt pair']) await expect(viewer.getByRole('button', { name, exact: true })).toBeVisible();
|
||||
for (const name of ['Copy positive prompt', 'Send positive prompt to ComfyUI', 'Copy negative prompt', 'Send negative prompt to ComfyUI']) await expect(viewer.getByRole('button', { name: new RegExp(`^${name}: `) })).toBeVisible();
|
||||
await expect(viewer.getByRole('button', { name: /^Send current file prompt pair to ComfyUI: |^Send prompt pair to ComfyUI: / })).toBeVisible();
|
||||
await expect(viewer).toContainText('42001');await expect(viewer).toContainText('720');await expect(viewer).toContainText('480');
|
||||
const currentPositive = viewer.getByRole('region', { name: 'Positive prompt', exact: true });
|
||||
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
|
||||
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
|
||||
await currentPositive.getByRole('button', { name: /^Copy positive prompt: / }).click();
|
||||
// The concept's copy outcome: the shared toast, or its "Copy prompt" dialog when the clipboard is refused.
|
||||
const copyFallback = page.getByRole('dialog', { name: 'Copy prompt', exact: true });
|
||||
await expect(page.locator('#workspace-toast').filter({ hasText: 'Prompt copied.' }).or(copyFallback)).toHaveCount(1);
|
||||
if (await copyFallback.isVisible()) { await page.keyboard.press('Escape'); await expect(copyFallback).toBeHidden(); }
|
||||
await exerciseCurrentPairSend(page, { positive: fixture.canonical_positive, negative: fixture.canonical_negative });
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog.image-preview');
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${width}-${theme}`, 'dialog#viewer-dialog');
|
||||
await page.keyboard.press('Escape');await expect(view).toBeFocused();await page.keyboard.press('Escape');await expect(run).toBeFocused();
|
||||
await compareCanonicalHistory(reference, actual, testInfo);
|
||||
|
||||
@@ -242,9 +247,10 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r
|
||||
await text.scrollIntoViewIfNeeded();
|
||||
expect((await text.textContent()).length).toBeGreaterThan(1500);
|
||||
await text.focus(); await expect(text).toBeFocused();
|
||||
// #249 D23: the card clamps the prompt to two lines; the card content itself never scrolls or overflows.
|
||||
expect(await text.evaluate(node => {
|
||||
const scroller = node.closest('.library-card-prompts');
|
||||
return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1;
|
||||
const content = node.closest('.card-content');
|
||||
return content.scrollHeight <= content.clientHeight + 1 && content.scrollWidth <= content.clientWidth + 1 && node.scrollHeight > node.clientHeight + 1;
|
||||
})).toBe(true);
|
||||
await noOverflow(page);
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = ''; });
|
||||
@@ -290,18 +296,18 @@ async function containedImageContent(page) {
|
||||
const failures = [];
|
||||
for (const card of nodes) {
|
||||
const outer = card.getBoundingClientRect();
|
||||
for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) {
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
|
||||
const node = card.querySelector(selector), box = node.getBoundingClientRect();
|
||||
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
|
||||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
|
||||
(selector === 'small' && node.scrollHeight > node.clientHeight + 1)) {
|
||||
(selector === '.media-info p' && node.scrollHeight > node.clientHeight + 1)) {
|
||||
failures.push({ key: card.dataset.imageKey, selector,
|
||||
card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom },
|
||||
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
|
||||
}
|
||||
}
|
||||
const action = card.querySelector('.image-card-footer button').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('small'));
|
||||
const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
|
||||
for (const text of range.getClientRects()) {
|
||||
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
|
||||
Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) {
|
||||
@@ -341,18 +347,31 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused();
|
||||
expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey);
|
||||
}
|
||||
const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' });
|
||||
const filename = await longCard.locator('strong').textContent();
|
||||
const longCard = cards.filter({ has: page.locator('[aria-label^="Open a-very-long-geometric-composition-"]') });
|
||||
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
expect(filename).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.image-details h2')).toHaveText(filename);
|
||||
const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true });
|
||||
// D19: Info selects into the panel; the image itself opens the viewer.
|
||||
await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
// D19: the card image, not the panel, opens the viewer.
|
||||
const fullPreview = longCard.locator('.image-open');
|
||||
await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.image-preview > header h2')).toHaveText(filename);
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();
|
||||
await expect(page.locator('#viewer-title')).toHaveText(filename);
|
||||
await page.keyboard.press('Escape');
|
||||
await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab');
|
||||
await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused();
|
||||
// The concept's card order (app.js mediaCard): image, the D8 checkbox, the D7 star, then Inspect. List hides
|
||||
// the overlays as the concept does (`.list-grid .image-overlays`), so there the image is followed by Inspect.
|
||||
const box = longCard.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
|
||||
const star = longCard.getByRole('button', { name: `Favorite ${filename}`, exact: true });
|
||||
const overlays = layout === 'List' ? [] : [box, star];
|
||||
if (layout === 'List') { await expect(box).toBeHidden(); await expect(star).toBeHidden(); }
|
||||
await longCard.locator('.image-open').focus();
|
||||
for (const control of overlays) { await page.keyboard.press('Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(longCard.getByRole('button', { name: `Inspect ${filename}`, exact: true })).toBeFocused();
|
||||
for (const control of [...overlays].reverse()) { await page.keyboard.press('Shift+Tab'); await expect(control).toBeFocused(); }
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(longCard.locator('.image-open')).toBeFocused();
|
||||
await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
@@ -374,7 +393,7 @@ test('zoomed virtual Images keeps the visible real media mounted during native s
|
||||
const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26);
|
||||
const more = page.getByRole('button', { name: 'Load more images', exact: true });
|
||||
for (const loaded of [80, 120, 160, 200, 202]) {
|
||||
await more.click(); await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`);
|
||||
await more.click(); await expect(page.locator('.section-caption')).toContainText(`${loaded} of 202 images`);
|
||||
}
|
||||
await expect.poll(() => cards.count()).toBeLessThan(120);
|
||||
await page.evaluate(stride => {
|
||||
@@ -401,33 +420,32 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
|
||||
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
|
||||
await selected.locator('.image-open').click(); await selected.locator('.image-open').focus();
|
||||
const filename = await selected.locator('strong').textContent();
|
||||
await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
|
||||
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
|
||||
await page.evaluate(() => {
|
||||
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')];
|
||||
const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get;
|
||||
const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]);
|
||||
const restore = () => { for (const [node, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(node, 'offsetHeight', descriptor); else delete node.offsetHeight;
|
||||
} };
|
||||
const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.media-info')];
|
||||
// Production measures each footer's fractional height (`getBoundingClientRect().height / scale`, #249 C7), so the
|
||||
// probe records the visible anchor at the first measurement after the zoom, before the grid relays out.
|
||||
const nativeRect = Element.prototype.getBoundingClientRect;
|
||||
const restore = () => { for (const node of footers) delete node.getBoundingClientRect; };
|
||||
window.footerMeasurementProbe = { before: null, restore };
|
||||
for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() {
|
||||
const height = nativeHeight.call(this);
|
||||
for (const footer of footers) Object.defineProperty(footer, 'getBoundingClientRect', { configurable: true, writable: true, value() {
|
||||
const rect = nativeRect.call(this);
|
||||
if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' &&
|
||||
Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
|
||||
Math.abs(Number.parseFloat(this.closest('.media-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) {
|
||||
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
|
||||
const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
|
||||
window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey,
|
||||
top: first.getBoundingClientRect().top - top } : null;
|
||||
restore();
|
||||
}
|
||||
return height;
|
||||
return rect;
|
||||
} });
|
||||
document.documentElement.style.zoom = '4';
|
||||
const box = grid.getBoundingClientRect();
|
||||
// The approved initial reservation is 54px with 4:3 media and a 13px row gap.
|
||||
const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13;
|
||||
// The approved initial reservation is 54px under the concept's 1.33 media and a 13px row gap.
|
||||
const stride = (box.width / 4 - 2) / 1.33 + 54 + 2 + 13;
|
||||
// Place row eight inside the 52px scaled gap so the preceding row is not visible.
|
||||
const index = 8;
|
||||
scrollTo(0, box.top + scrollY + index * stride * 4 - 16);
|
||||
@@ -442,7 +460,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2);
|
||||
await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/);
|
||||
await expect(selected.locator('.image-open')).toBeFocused();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(filename);
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
const mutations = await grid.evaluate(async node => {
|
||||
let changes = 0;
|
||||
const observer = new MutationObserver(records => { changes += records.length; });
|
||||
@@ -454,10 +472,10 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
await page.getByRole('button', { name: 'List', exact: true }).click();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97);
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('no-real-media-match');
|
||||
await expect(cards).toHaveCount(0);
|
||||
expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54);
|
||||
await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('');
|
||||
await expect(cards).toHaveCount(24);
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')));
|
||||
@@ -479,7 +497,7 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(40);
|
||||
await cards.first().locator('.image-open').click();
|
||||
await cards.first().locator('.image-card-actions button').click();
|
||||
const selectedKey = await cards.first().getAttribute('data-image-key');
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
@@ -491,18 +509,23 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
});
|
||||
try {
|
||||
const more = page.getByRole('button', { name: 'Load more images', exact: true });
|
||||
await more.click(); await expect.poll(() => pending).toBe(true);
|
||||
// At 4x zoom the concept continuation (copy + Load more) fills the viewport, so keep the last cards in view and
|
||||
// press Load more from the keyboard without scrolling to it.
|
||||
await cards.last().scrollIntoViewIfNeeded();
|
||||
await more.evaluate(node => node.focus({ preventScroll: true })); await page.keyboard.press('Enter');
|
||||
await expect.poll(() => pending).toBe(true);
|
||||
const anchor = await cards.evaluateAll(nodes => {
|
||||
const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight);
|
||||
const node = nodes.find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; });
|
||||
return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null;
|
||||
});
|
||||
expect(anchor).not.toBeNull();
|
||||
release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images');
|
||||
release(); await expect(page.locator('.section-caption')).toContainText('80 of 202 images');
|
||||
await expect(cards).toHaveCount(80);
|
||||
await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1);
|
||||
await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/);
|
||||
await expect(more).toBeFocused();
|
||||
// The concept focuses the first appended image after a Load more button press.
|
||||
await expect(page.locator('article[aria-posinset="41"] .image-open')).toBeFocused();
|
||||
await expect.poll(() => containedImageContent(page)).toEqual([]);
|
||||
} finally { release(); }
|
||||
});
|
||||
@@ -517,9 +540,9 @@ test('Library visual evidence keeps natural cards and returns from real history
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false);
|
||||
await noOverflow(page);
|
||||
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
|
||||
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive. Reusable negatives. Every experiment stays connected.');
|
||||
@@ -577,53 +600,57 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(`${workspace}#/images`,{waitUntil:'domcontentloaded'});
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(24);
|
||||
await expect.poll(()=>mediaRequests).toBeGreaterThan(0);
|
||||
expect(await page.locator('.image-card img').first().evaluate(image=>image.complete)).toBe(false);
|
||||
await expect(page.locator('.image-card').first()).toHaveCSS('border-radius','7px');
|
||||
expect(await page.locator('.media-card img').first().evaluate(image=>image.complete)).toBe(false);
|
||||
await expect(page.locator('.media-card').first()).toHaveCSS('border-radius','7px');
|
||||
await orderedMedia(page);
|
||||
} finally {release();}
|
||||
for (const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});
|
||||
for (const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.image-card .image-open').first().click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText('study-01.png');
|
||||
await page.locator('.media-card .image-card-actions button').first().click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
|
||||
if(layout==='List') await expect(page.locator('.image-card').first()).toHaveCSS('height','97px');
|
||||
if(layout==='List') await expect(page.locator('.media-card').first()).toHaveCSS('height','97px');
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-${layout.toLowerCase()}-${width}-${theme}`,false);
|
||||
}
|
||||
await page.locator('article[data-image-key]').last().scrollIntoViewIfNeeded();
|
||||
const longCard=page.locator('article[data-image-key]').filter({hasText:'a-very-long-geometric-composition-'});
|
||||
await expect(longCard.locator('strong')).toHaveText(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-open').click();
|
||||
await expect(page.locator('.image-details h2')).toHaveText(await longCard.locator('strong').textContent());
|
||||
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
|
||||
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
expect(longName).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-card-actions button').click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
|
||||
}
|
||||
}
|
||||
await page.evaluate(()=>{location.hash='#/overview';});
|
||||
await expect(page.locator('.image-card,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('.media-card,.detail-panel,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
|
||||
await request.post('/__test__/reset',{data:{workspace_visual:true}});
|
||||
for (const width of [1600,390]) {
|
||||
await page.setViewportSize({width,height:width===1600?1100:844});await page.goto(`${workspace}#/library`);
|
||||
await page.getByRole('link',{name:'Explore history',exact:true}).first().click();
|
||||
await page.getByRole('button',{name:'Explore history',exact:true}).first().click();
|
||||
await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible();
|
||||
await expect(page.locator('h1')).not.toHaveText('Prompt library');
|
||||
const title=await page.locator('h1').textContent();expect(title).not.toBe('Images');
|
||||
// History opens on its first negative variant (concept navigate: state.variant = 0); the layout contract uses the whole gallery.
|
||||
// History opens on its first negative variant (concept navigate: state.variant = 0); the fixture's images are linked
|
||||
// to the positive only, so the layout contract uses the All images dialog's embedded gallery (#249 D26).
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
await expect(page.locator('article[data-image-key]').first()).toBeVisible();
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await expect(listing.locator('article[data-image-key]').first()).toBeVisible();
|
||||
for (const layout of ['List','Masonry','Grid']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await listing.getByRole('button',{name:layout,exact:true}).click();await orderedMedia(page);
|
||||
await expect(page.locator('h1')).toHaveText(title);
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
await expect(page.locator('#workspace-main > .heading p.muted')).toHaveText('Prompt history · one positive, one shared gallery');
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
await page.keyboard.press('Escape');await expect(listing).toHaveCount(0);
|
||||
await page.getByRole('button',{name:'← Prompt library',exact:true}).click();
|
||||
await expect(page.locator('.image-card,.image-details,dialog[open]')).toHaveCount(0);
|
||||
await expect(page.locator('.media-card,.detail-panel,dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,13 +24,18 @@ async function expectLibrary(page,{tags='',category='',count}) {
|
||||
await expect(page).toHaveURL(/#\/library$/);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
await expect(main(page).getByRole('heading',{level:1})).toBeFocused();
|
||||
await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('');
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||||
const select=page.getByRole('combobox',{name:'Category',exact:true});
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('');
|
||||
const select=page.getByRole('combobox',{name:'Filter by category',exact:true});
|
||||
await expect(select).toHaveValue(category);
|
||||
if(!category) await expect(select.locator('option:checked')).toHaveText('All categories');
|
||||
await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`);
|
||||
await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(new RegExp(`^${count} positive prompts? · \\d+ negative variants?$`));
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(count);
|
||||
// Browse sets exactly this Tags filter, which lives in "Refine this view" (#249 D14); Filters counts it (D44).
|
||||
const filters=page.locator('#workspace-main search.toolbar').getByRole('button',{name:tags?'Filters · 1':'Filters',exact:true});
|
||||
await expect(filters).toBeVisible();await filters.click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await expect(refine.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags);
|
||||
await refine.getByRole('button',{name:'Close dialog',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
}
|
||||
|
||||
let manifest;
|
||||
@@ -85,9 +90,31 @@ test('prompt counts equal Library Browse results',async({page})=>{
|
||||
}
|
||||
});
|
||||
|
||||
// #249 D44: a Browse-set Tags filter is visible on the shelf as "Filters · 1", and Clear filters returns the full shelf.
|
||||
test('a tag Browse shows Filters · 1 until its filter is cleared',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const shelf=page.locator('article[data-prompt-id]');await expect(shelf.first()).toBeVisible();const total=await shelf.count();
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await page.goto(route);
|
||||
const row=tagRow(page,'cinematic');await row.getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expectLibrary(page,{tags:'cinematic',count:2});
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']);
|
||||
await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click();
|
||||
const refine=page.getByRole('dialog',{name:'Refine this view',exact:true});
|
||||
await refine.getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine).toHaveCount(0);
|
||||
await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused();
|
||||
await expect(shelf).toHaveCount(total);
|
||||
// The cleared filter stays cleared on the next in-app visit (the shelf remembers its filters).
|
||||
const nav=page.getByRole('navigation',{name:'Workspace',exact:true});
|
||||
await nav.getByRole('link',{name:/^Tags & categories(?:\s*[\d,]+)?$/}).click();await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await nav.getByRole('link',{name:/^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(shelf).toHaveCount(total);await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
});
|
||||
|
||||
test('category Browse sets only the category constraint',async({page})=>{
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});
|
||||
const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});
|
||||
await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click();
|
||||
const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts');
|
||||
@@ -401,8 +428,8 @@ test('delete tag confirms real impact and removes only the vocabulary name',asyn
|
||||
expect(after.prompts.map(([id,text,category])=>[id,text,category])).toEqual(before.prompts.map(([id,text,category])=>[id,text,category]));
|
||||
await page.goto('/prompt_manager/workspace#/library');
|
||||
await expect(page.locator('article[data-prompt-id]')).toHaveCount(6);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||||
await expect(page.locator('.library-chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||||
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^cinematic$/})).toHaveCount(0);
|
||||
await expect(page.locator('article[data-prompt-id] .tags .chip').filter({hasText:/^blue_hour$/})).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('cancel and escape write nothing and restore focus',async({page,request})=>{
|
||||
@@ -486,7 +513,7 @@ test('lost response is outcome-unknown',async({page,request})=>{
|
||||
|
||||
// Concept nav links end in their real `.nav-count` once it loads.
|
||||
const nav=(page,name)=>page.locator('aside.sidebar nav').getByRole('link',{name:new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}(?:\\s*[\\d,]+)?$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] .library-chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
const chips=(page,name)=>main(page).locator('article[data-prompt-id] :is(.card-title, .tags) > .chip').filter({hasText:new RegExp(`^${name}$`)});
|
||||
/** Visit the Library so its shelf is cached, then open Rename on blue_hour and start a rename whose PATCH is held. */
|
||||
async function heldRename(page) {
|
||||
await page.goto('/prompt_manager/workspace#/library');await expect(chips(page,'blue_hour')).toHaveCount(1);
|
||||
@@ -798,7 +825,7 @@ test('mutation check: the comparison reports a changed surface padding and a cha
|
||||
// Task A2: the workspace loads the verbatim concept stylesheet as its base and one overlay last; the screen and its
|
||||
// dialogs must stay equal to the concept with the retired vocabulary and editor sheets gone.
|
||||
const CONCEPT_BASE=['js/shared/toolkit/controls.css','lib/viewerjs/viewer.min.css','js/workspace/concept.css',
|
||||
'js/workspace/images.css','js/workspace/library.css','js/workspace/inspector.css','js/workspace/workspace.css'];
|
||||
'js/workspace/workspace.css'];
|
||||
const loadedSheets=page=>page.evaluate(()=>[...document.styleSheets].filter(sheet=>sheet.href).map(sheet=>new URL(sheet.href).pathname.replace(/^\/prompt_manager\//,'')));
|
||||
for(const [width,height,theme] of [[1600,1100,'dark'],[390,844,'light']])test(`concept base keeps the tags screen and editor dialogs equal to the concept ${width} ${theme}`,async({page})=>{
|
||||
await page.setViewportSize({width,height});
|
||||
|
||||
@@ -11,11 +11,15 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole
|
||||
const sidebarLinks = page => page.locator('aside.sidebar nav a');
|
||||
const routes = [
|
||||
['overview', 'Overview', null],
|
||||
['images', 'Images', '/prompt_manager/gallery'],
|
||||
['library', 'Prompt library', '/prompt_manager/admin'],
|
||||
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
|
||||
['images', 'Images', null],
|
||||
// #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does.
|
||||
['library', 'Prompt library', null],
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
['system', 'System', '/prompt_manager/admin'],
|
||||
// #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites.
|
||||
['favorites', 'Favorites', null],
|
||||
];
|
||||
|
||||
async function countThemeWrites(page) {
|
||||
@@ -80,6 +84,16 @@ test('all shell destinations deep-link, reload and expose working existing appli
|
||||
await page.reload();
|
||||
await expect(main.getByRole('heading', { level: 1, name: label, exact: true })).toBeVisible();
|
||||
}
|
||||
// D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages.
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true });
|
||||
await expect(library).toHaveAttribute('href', '/prompt_manager/admin');
|
||||
await expect(await request.get('/prompt_manager/admin')).toBeOK();
|
||||
const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
await expect(await request.get('/prompt_manager/gallery')).toBeOK();
|
||||
await gallery.click();
|
||||
await expect(page).toHaveURL(/\/prompt_manager\/gallery$/);
|
||||
});
|
||||
|
||||
test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => {
|
||||
@@ -308,7 +322,7 @@ test('leaving a loading overview prevents its delayed result from replacing the
|
||||
await expect(page.locator('#primary-nav')).toHaveCount(1);
|
||||
await expect(page.locator('#system-nav')).toHaveCount(1);
|
||||
await expect(page.getByRole('combobox', { name: 'Theme', exact: true })).toHaveCount(1);
|
||||
await expect(page.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toBeVisible();
|
||||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
|
||||
await countThemeWrites(page);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption('light');
|
||||
expect(await page.evaluate(() => window.__themeChanges)).toBe(1);
|
||||
@@ -415,10 +429,13 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
await expect(page.locator('body > .app-shell > .workspace > #workspace-main')).toHaveCount(1);
|
||||
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
|
||||
const sidebar = page.locator('aside.sidebar');
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-bottom']);
|
||||
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
|
||||
'nav#saved-nav', 'div.sidebar-bottom']);
|
||||
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE']);
|
||||
await expect(page.locator('#saved-nav, #collections-nav, .new-label')).toHaveCount(0);
|
||||
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
|
||||
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
|
||||
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
|
||||
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
|
||||
const primary = page.locator('nav#primary-nav');
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
|
||||
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
|
||||
@@ -445,7 +462,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
|
||||
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(navCount(page, 'library')).toBeHidden();
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(2);
|
||||
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
|
||||
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
|
||||
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -155,6 +155,9 @@ CONCEPT_RUN_COUNT = 4
|
||||
# The concept's Model fact (app.js:408).
|
||||
CONCEPT_MODEL = "illustrative-model.safetensors"
|
||||
MAX_EXTRA_IMAGES = 120
|
||||
# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
|
||||
MAX_SEEDLESS_IMAGES = 2
|
||||
SEEDLESS_SIZE = (1536, 1152)
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
"execution_run_outputs",
|
||||
@@ -165,6 +168,8 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -176,6 +181,11 @@ _TABLES = (
|
||||
)
|
||||
|
||||
|
||||
def concept_starred(prompt_index: int, round_index: int) -> bool:
|
||||
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
|
||||
return round_index == 0 and prompt_index % 2 == 0
|
||||
|
||||
|
||||
def concept_seed(prompt_index: int, round_index: int) -> int:
|
||||
"""The concept's sample seed (app.js:185)."""
|
||||
return 42001 + prompt_index * 100 + round_index
|
||||
@@ -313,23 +323,41 @@ def _record_run(
|
||||
return start.run_id
|
||||
|
||||
|
||||
def _write_seedless_png(path: Path) -> None:
|
||||
"""A plain PNG with no metadata: no prompt, no seed, no link."""
|
||||
with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
|
||||
image.save(path)
|
||||
|
||||
|
||||
def _bounded(name: str, value: int, maximum: int) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise TypeError(f"{name} must be an integer")
|
||||
if not 0 <= value <= maximum:
|
||||
raise ValueError(f"{name} is out of range")
|
||||
|
||||
|
||||
def seed_workspace_concept(
|
||||
api: PromptManagerAPI, root: Path, *, extra_images: int = 0
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
*,
|
||||
extra_images: int = 0,
|
||||
seedless_images: int = 0,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows and files with the concept's data.
|
||||
|
||||
``extra_images`` continues the concept's rounds (same prompts, dimensions and
|
||||
seed rule) after its 24 images, for bounded-rendering and Load more checks.
|
||||
``seedless_images`` adds unlinked PNGs without metadata, newest of all.
|
||||
"""
|
||||
if isinstance(extra_images, bool) or not isinstance(extra_images, int):
|
||||
raise TypeError("extra_images must be an integer")
|
||||
if not 0 <= extra_images <= MAX_EXTRA_IMAGES:
|
||||
raise ValueError("extra_images is out of range")
|
||||
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
||||
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
||||
_clear(api, root)
|
||||
db = api.db
|
||||
epoch = db.get_execution_epoch()
|
||||
prompts = []
|
||||
for prompt in CONCEPT_PROMPTS:
|
||||
# The concept lists its prompts most recent first (renderLibrary under Recently
|
||||
# added keeps the array order), so they are created last to first.
|
||||
for prompt in reversed(CONCEPT_PROMPTS):
|
||||
first_name, first_text = prompt.negatives[0]
|
||||
receipt = db.create_library_recipe(
|
||||
prompt.positive,
|
||||
@@ -349,61 +377,89 @@ def seed_workspace_concept(
|
||||
pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name))
|
||||
# Real queue-hook counting (D23): four runs, the last one now.
|
||||
db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT)
|
||||
prompts.append(
|
||||
{"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
prompts.insert(
|
||||
0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids}
|
||||
)
|
||||
|
||||
folder = root / "output" / "concepts"
|
||||
folder.mkdir()
|
||||
roots = snapshot_output_roots("posix", str(root / "output"), str(root / "temp"))
|
||||
# The API package needs the host stubs the server installs before importing it.
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
favorite_root = _favorite_root_key(list(api._get_all_output_dirs())[0])
|
||||
total = len(CONCEPT_PROMPTS) * CONCEPT_IMAGE_ROUNDS + extra_images
|
||||
rounds = math.ceil(total / len(CONCEPT_PROMPTS))
|
||||
now = time.time()
|
||||
images, runs = [], []
|
||||
for round_index in range(rounds):
|
||||
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS):
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
if index >= total:
|
||||
break
|
||||
name = concept_filename(prompt, round_index)
|
||||
path = folder / name
|
||||
facts = write_concept_png(path, prompt_index, round_index)
|
||||
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
|
||||
# The concept lists images newest first in this order.
|
||||
stamp = now - 60 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
slots = [
|
||||
(round_index, prompt_index, prompt)
|
||||
for round_index in range(rounds)
|
||||
for prompt_index, prompt in enumerate(CONCEPT_PROMPTS)
|
||||
if round_index * len(CONCEPT_PROMPTS) + prompt_index < total
|
||||
]
|
||||
# Linked and recorded oldest first, as real runs arrive, so link and run ids
|
||||
# grow with time and every newest-first read (by id or by mtime) lists the
|
||||
# concept's order (app.js:180-239). The manifest keeps the concept's order.
|
||||
for round_index, prompt_index, prompt in reversed(slots):
|
||||
index = round_index * len(CONCEPT_PROMPTS) + prompt_index
|
||||
name = concept_filename(prompt, round_index)
|
||||
path = folder / name
|
||||
facts = write_concept_png(path, prompt_index, round_index)
|
||||
variant, negative, seed = facts["variant"], facts["negative"], facts["seed"]
|
||||
# The concept lists images newest first in this order.
|
||||
stamp = now - 60 - index * 60
|
||||
os.utime(path, (stamp, stamp))
|
||||
owner = prompts[prompt_index]
|
||||
image_id = db.link_image_to_prompt(owner["positive_id"], str(path))
|
||||
pair_id = owner["pair_ids"][variant]
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"})
|
||||
# Real pair counting: each image is one run of its pair, as the
|
||||
# concept's "<name> · N runs" counts its images (app.js a598, D26).
|
||||
db.record_prompt_pair_use(pair_id)
|
||||
if concept_starred(prompt_index, round_index):
|
||||
db.set_image_favorite(
|
||||
favorite_root, _favorite_relative_key(f"concepts/{name}")
|
||||
)
|
||||
reference = OutputReference(
|
||||
"4", 0, "output", "concepts", name, "posix", roots.output_key
|
||||
)
|
||||
runs.append(
|
||||
_record_run(
|
||||
api,
|
||||
epoch=epoch,
|
||||
item=index,
|
||||
context=context,
|
||||
output=reference,
|
||||
observed_at=int(stamp * 1000),
|
||||
)
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": name,
|
||||
"image_id": image_id,
|
||||
"pair_id": pair_id,
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
)
|
||||
reference = OutputReference(
|
||||
"4", 0, "output", "concepts", name, "posix", roots.output_key
|
||||
)
|
||||
runs.append(
|
||||
_record_run(
|
||||
api,
|
||||
epoch=epoch,
|
||||
item=index,
|
||||
context=context,
|
||||
output=reference,
|
||||
observed_at=int(stamp * 1000),
|
||||
)
|
||||
)
|
||||
images.append(
|
||||
{
|
||||
"filename": name,
|
||||
"image_id": image_id,
|
||||
"pair_id": pair_id,
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"path": f"concepts/{name}",
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
)
|
||||
images.reverse()
|
||||
runs.reverse()
|
||||
seedless = []
|
||||
for number in range(seedless_images):
|
||||
name = f"seedless_{number + 1:05}.png"
|
||||
path = folder / name
|
||||
_write_seedless_png(path)
|
||||
stamp = now - 10 - number * 10
|
||||
os.utime(path, (stamp, stamp))
|
||||
seedless.append({"filename": name, "path": f"concepts/{name}"})
|
||||
with db.model.get_connection() as conn:
|
||||
counts = conn.execute(
|
||||
"SELECT COUNT(*), MIN(run_count), MAX(run_count) FROM prompts "
|
||||
@@ -416,6 +472,9 @@ def seed_workspace_concept(
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"epoch": epoch,
|
||||
# The newest file's mtime (epoch seconds), for the D17 caption date.
|
||||
"newest_modified": now - 10 if seedless_images else now - 60,
|
||||
"seedless": seedless,
|
||||
"prompts": prompts,
|
||||
"images": images,
|
||||
"runs": runs,
|
||||
|
||||
@@ -479,8 +479,14 @@ def seed_workspace_history(
|
||||
canonical_positive, canonical_negative, title="Quality baseline"
|
||||
)
|
||||
canonical_id = api.db.get_prompt_pair(canonical_pair)["positive_prompt_id"]
|
||||
api.db.save_prompt_pair(canonical_positive, "flat light", title="Clean geometry")
|
||||
clean_pair = api.db.save_prompt_pair(
|
||||
canonical_positive, "flat light", title="Clean geometry"
|
||||
)
|
||||
api.db.save_prompt_pair(canonical_positive, "", title="No negative")
|
||||
# Queue-hook pair counts (#249 D26): both lanes show the repeat-run dot; only
|
||||
# the baseline has a recorded execution, so Clean geometry opens Repeat run.
|
||||
api.db.record_prompt_pair_use(canonical_pair, 2)
|
||||
api.db.record_prompt_pair_use(clean_pair, 2)
|
||||
for number, name in enumerate(("canonical.png", "canonical-alt.png")):
|
||||
path = folder / name
|
||||
_write_art(path, number, canonical_positive, canonical_negative)
|
||||
@@ -500,6 +506,8 @@ def seed_workspace_history(
|
||||
os.utime(path, (stamp, stamp))
|
||||
image_id = api.db.link_image_to_prompt(canonical_id, str(path))
|
||||
api.db.link_image_to_pair(canonical_pair, image_id, {"source": "fixture"})
|
||||
if number:
|
||||
api.db.link_image_to_pair(clean_pair, image_id, {"source": "fixture"})
|
||||
canonical_run = run(
|
||||
(
|
||||
RecipeContext(
|
||||
@@ -529,6 +537,7 @@ def seed_workspace_history(
|
||||
"detail_image_id": current,
|
||||
"canonical_id": canonical_id,
|
||||
"canonical_pair": canonical_pair,
|
||||
"clean_pair": clean_pair,
|
||||
"canonical_run": canonical_run,
|
||||
"canonical_positive": canonical_positive,
|
||||
"canonical_negative": canonical_negative,
|
||||
|
||||
@@ -26,6 +26,8 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -112,6 +112,8 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -79,6 +79,8 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -237,13 +237,13 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
const run = workspace.getByRole('dialog', { name: 'Recorded run', exact: true });await expect(run).toBeVisible();
|
||||
await expect(run.locator('[data-receipt]')).toHaveCount(4);
|
||||
await run.getByRole('button', { name: 'View current file', exact: true }).first().click();
|
||||
const viewer = workspace.getByRole('dialog', { name: 'Image preview', exact: true });await expect(viewer).toBeVisible();
|
||||
const viewer = workspace.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await workspace.keyboard.press('Escape');await expect(run).toBeVisible();
|
||||
const frozen = attempts[0].context;
|
||||
for (const mode of ['Raw', 'Effective']) {
|
||||
await run.getByRole('button', { name: mode, exact: true }).click();
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('pre').evaluate(node => node.textContent)).toBe(value);
|
||||
for (const [role, value] of [['Positive', frozen.positive[mode.toLowerCase()]], ['Negative', frozen.negative[mode.toLowerCase()]]]) expect(await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('region', { name: `${role} prompt`, exact: true }).locator('p.prompt-text').evaluate(node => node.textContent)).toBe(value);
|
||||
await page.evaluate(async id => {const { app } = await import('/scripts/app.js');const node = app.graph.getNodeById(id);node.widgets.find(w => w.name === 'text').value = 'Before send';window.chronologyEffects = { queue: 0, load: 0 };window.chronologyOriginals = {};for (const [method, key] of [['queuePrompt', 'queue'], ['loadGraphData', 'load']]) {const original = app[method];window.chronologyOriginals[method] = original;app[method] = function(...args) {window.chronologyEffects[key]++;return original.apply(this, args);};}}, destination);
|
||||
try {
|
||||
await run.getByRole('region', { name: 'Frozen run prompt texts', exact: true }).getByRole('button', { name: 'Send prompt pair', exact: true }).click();const sender = workspace.getByRole('dialog', { name: 'Send to ComfyUI', exact: true });
|
||||
|
||||
@@ -22,10 +22,10 @@ test('native library sends the selected saved pair into a real ComfyUI destinati
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/library');
|
||||
await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive);
|
||||
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click();
|
||||
await workspace.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(positive);
|
||||
await workspace.locator('article[data-prompt-id]').filter({has:workspace.getByText(positive,{exact:true})}).getByRole('button',{name:'Explore history',exact:true}).click();
|
||||
const history=workspace.getByRole('region',{name:'Prompt history',exact:true});
|
||||
await history.getByRole('button',{name:'Select negative variant 1',exact:true}).click();
|
||||
await history.getByRole('button',{name:'Select negative Host saved pair',exact:true}).click();
|
||||
await expect(history.getByRole('region',{name:'Saved prompt texts',exact:true}).getByRole('region',{name:'Negative prompt',exact:true})).toContainText(negative);
|
||||
// The pair send lives in the history heading, as in the approved concept.
|
||||
await workspace.locator('#workspace-main > .heading').getByRole('button',{name:`Send prompt pair to ComfyUI: ${positive}`,exact:true}).click();
|
||||
@@ -67,7 +67,7 @@ test('native editor preview sends exact saved roles across renderer transitions'
|
||||
await expect(workspace).toHaveURL(/#\/library\/\d+$/);const positiveId=Number(new URL(workspace.url()).hash.split('/').at(-1));
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText(title);
|
||||
const saved=workspace.getByRole('region',{name:'Saved prompt texts',exact:true});
|
||||
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('Empty negative prompt');
|
||||
await expect(saved.getByRole('region',{name:'Negative prompt',exact:true})).toContainText('No negative prompt (explicitly empty)');
|
||||
const evidence=async()=>{
|
||||
const history=await(await request.get(`/prompt_manager/library/${positiveId}/history`)).json();
|
||||
const detail=await(await request.get(`/prompt_manager/library/${positiveId}?archive=all`)).json();
|
||||
|
||||
@@ -41,15 +41,17 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message));
|
||||
try {
|
||||
await workspace.goto('/prompt_manager/workspace#/images');
|
||||
await workspace.getByRole('button',{name:'Refresh',exact:true}).click();
|
||||
// D15: Refresh is Import → Rescan.
|
||||
await workspace.getByRole('button',{name:'Import',exact:true}).click();
|
||||
await workspace.getByRole('dialog',{name:'Import & inspect',exact:true}).getByRole('button',{name:'Rescan',exact:true}).click();
|
||||
const card=workspace.locator('article[data-image-key]').filter({has:workspace.getByRole('button',{name:`Open ${filename}`,exact:true})});
|
||||
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('region',{name:'Image details',exact:true});
|
||||
await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('complementary',{name:'Selected image details',exact:true});
|
||||
await expect(inspector.getByRole('region',{name:'Positive prompt',exact:true})).toContainText(positive);
|
||||
await inspector.getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
const viewer=workspace.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
await card.getByRole('button',{name:`Open ${filename}`,exact:true}).click();
|
||||
const viewer=workspace.locator('dialog#viewer-dialog');
|
||||
await expect(viewer.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(viewer.locator('.generation-facts dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
|
||||
await expect(viewer.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','host-fixture.safetensors']);
|
||||
await viewer.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
await expect(viewer.locator('pre code').first()).toContainText('9007199254740993');
|
||||
await expect(viewer.getByRole('button',{name:'Download workflow JSON',exact:true})).not.toHaveAttribute('aria-disabled','true');
|
||||
@@ -57,12 +59,12 @@ test('native image inspector previews and sends a pair into real visible widgets
|
||||
for(const renderer of [!testInfo.project.metadata.initialRenderer,testInfo.project.metadata.initialRenderer]) {
|
||||
await page.evaluate(async renderer=>{const {app}=await import('/scripts/app.js');await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled',renderer);await app.loadGraphData(app.graph.serialize());app.canvas.centerOnNode(app.graph._nodes[0]);},renderer);
|
||||
await expect.poll(()=>page.evaluate(()=>LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(viewer).toBeVisible();await expect(viewer.locator('.generation-facts dd').first()).toHaveText('9007199254740993');
|
||||
await expect(viewer).toBeVisible();await expect(viewer.locator('.meta-grid dd').first()).toHaveText('9007199254740993');
|
||||
await expect(nodeRoot.getByRole('textbox',{name:'Positive prompt',exact:true})).toHaveValue('Before positive');
|
||||
}
|
||||
expect(await page.evaluate(()=>window.senderEffects.queue)).toBe(0);
|
||||
await page.evaluate(()=>{window.senderSendBaseline={...window.senderEffects};});
|
||||
await viewer.getByRole('button',{name:'Send prompt pair',exact:true}).click();
|
||||
await viewer.getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
|
||||
const sender=workspace.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||||
const hosts=sender.getByRole('combobox',{name:'ComfyUI window',exact:true});
|
||||
await expect(hosts.locator('option')).toHaveCount(2);
|
||||
|
||||
@@ -34,9 +34,16 @@ test('workspace serves real library data beside either ComfyUI renderer', async
|
||||
if (collection.images.length) {
|
||||
await expect(workspace.locator('article[data-image-key]').first()).toBeVisible();
|
||||
} else {
|
||||
await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible();
|
||||
await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible();
|
||||
await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.');
|
||||
}
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
// D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does.
|
||||
await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0);
|
||||
await workspace.evaluate(() => { location.hash = '/overview'; });
|
||||
const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true });
|
||||
await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery');
|
||||
expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true);
|
||||
await workspace.evaluate(() => { location.hash = '/images'; });
|
||||
await workspace.reload();
|
||||
await expect(workspace.locator('#workspace-main h1')).toHaveText('Images');
|
||||
await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light');
|
||||
|
||||
@@ -163,6 +163,19 @@ test("post, put and del send JSON bodies with the right method", async () => {
|
||||
await Promise.all([p1, p2, p3]);
|
||||
});
|
||||
|
||||
test("del sends an optional JSON body", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const controller = new AbortController();
|
||||
const pending = api.del("/prompt_manager/images/favorite", { body: { root_index: 1, path: "a/b.png" }, signal: controller.signal });
|
||||
assert.equal(fetch.calls[0].init.method, "DELETE");
|
||||
assert.equal(fetch.calls[0].init.body, JSON.stringify({ root_index: 1, path: "a/b.png" }));
|
||||
assert.equal(fetch.calls[0].init.headers["Content-Type"], "application/json");
|
||||
assert.equal(fetch.calls[0].init.signal, controller.signal);
|
||||
fetch.calls[0].resolve(jsonResponse(200, { success: true }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
test("network failures propagate as ordinary errors", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
+135
-15
@@ -5,7 +5,7 @@ import * as module from '../../web/js/workspace/prompt-inspector.mjs';
|
||||
|
||||
function dom() {
|
||||
class Element {
|
||||
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};}
|
||||
constructor(tag){this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this.classList={add:()=>{}};this.className='';}
|
||||
append(...children){for(const child of children){child.parent=this;this.children.push(child);}}
|
||||
replaceChildren(...children){this.children=[];this.append(...children);}
|
||||
setAttribute(key,value){this.attributes[key]=value;}
|
||||
@@ -13,9 +13,9 @@ function dom() {
|
||||
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&Object.hasOwn(this.dataset,'sendRole'))return this;if(selector==='[data-copy-role]'&&Object.hasOwn(this.dataset,'copyRole'))return this;if(selector==='[data-retry-prompts]'&&Object.hasOwn(this.dataset,'retryPrompts'))return this;return this.parent?.closest(selector);}
|
||||
click(target){this.listeners.get('click')?.({target});}
|
||||
}
|
||||
const previous=global.document;global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag)};
|
||||
const previous=global.document,toast=new Element('div');global.document={createElement:tag=>new Element(tag),createElementNS:(_,tag)=>new Element(tag),getElementById:id=>id==='workspace-toast'?toast:null};
|
||||
const walk=(root,predicate)=>[...(predicate(root)?[root]:[]),...root.children.flatMap(child=>walk(child,predicate))];
|
||||
return {Element,walk,restore:()=>{global.document=previous;}};
|
||||
return {Element,walk,toast,restore:()=>{global.document=previous;}};
|
||||
}
|
||||
const record=text=>({status:'resolved',text,source:'library'});
|
||||
test('shared prompt blocks send complete selected library values and explicit empty negatives',()=>{
|
||||
@@ -27,7 +27,7 @@ test('shared prompt blocks send complete selected library values and explicit em
|
||||
view.update({positive:record('Complete positive'),negative:record('')});
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'Complete positive',negative:''}]);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,2);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Saved library prompt').length,0,'no per-block source line');
|
||||
view.dispose();container.click(pair);assert.equal(sent.length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
@@ -51,19 +51,19 @@ test('disposed blocks ignore updates and aborted parents never mount content',()
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('frozen fields disclose their exact source and keep null, empty and whitespace actions distinct',()=>{
|
||||
test('frozen fields keep null, empty and whitespace actions distinct without a source line',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
const field=(text,source)=>({status:text===null?'unresolved':'resolved',text,source:'execution',evidence:{provenance:'recorded',source,reason:null}});
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:field('positive','runtime_return'),negative:field(null,'unknown')}});
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole==='negative'||node.dataset.sendRole==='pair'||node.dataset.copyRole==='negative').length,0);
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('runtime_return')).length);
|
||||
assert.equal(f.walk(container,node=>node.textContent?.includes('runtime_return')).length,0,'no per-block source line');
|
||||
view.update({positive:field('positive','runtime_return'),negative:field('','host_cache_output')});
|
||||
assert.ok(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length);
|
||||
assert.equal(f.walk(container,node=>node.textContent?.includes('host_cache_output')).length,0);
|
||||
const pair=f.walk(container,node=>node.dataset.sendRole==='pair')[0];container.click(pair);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
view.update({positive:field('positive','runtime_return'),negative:field(' \n\t ','host_cache_output')});
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent===' \n\t ').length,1);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent===' \n\t ').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='negative')[0]);
|
||||
assert.deepEqual(sent[1],{negative:' \n\t '});
|
||||
view.setState({prompts:{positive:field('positive','runtime_return'),negative:field('old','host_cache_output')},actionsEnabled:false});
|
||||
@@ -71,19 +71,19 @@ test('frozen fields disclose their exact source and keep null, empty and whitesp
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a zeroed conditioning negative shows as an explicit empty negative with its own source',()=>{
|
||||
test('a zeroed conditioning negative shows as an explicit empty negative without a source line',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:{status:'resolved',text:'positive',source:'graph-inferred'},negative:{status:'resolved',text:'',source:'zeroed-conditioning'}}});
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,1);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Empty negative prompt').length,1);
|
||||
assert.equal(f.walk(container,node=>node.textContent==='Zeroed conditioning (no negative text)').length,0);
|
||||
assert.equal(f.walk(container,node=>node.className==='prompt-text'&&node.textContent==='No negative prompt (explicitly empty)').length,1);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{positive:'positive',negative:''}]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
const unresolved=message=>({status:'unresolved',text:null,source:null,...(message===undefined?{}:{message})});
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='muted').map(node=>node.textContent);
|
||||
const statusLines=(f,container)=>f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text muted').map(node=>node.textContent);
|
||||
test('unsupported node status names the class',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
const line='Prompt uses a node the inspector does not support (DualCFGGuider).';
|
||||
@@ -101,7 +101,7 @@ test('runtime and composite statuses',()=>{
|
||||
assert.deepEqual(statusLines(f,container),[runtime,runtime]);
|
||||
view.update({positive:unresolved(composite),negative:{status:'resolved',text:'kept',source:'execution'}});
|
||||
assert.deepEqual(statusLines(f,container),[composite]);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='pre'&&node.textContent==='kept').length,1);
|
||||
assert.equal(f.walk(container,node=>node.tagName==='p'&&node.className==='prompt-text'&&node.textContent==='kept').length,1);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('limit status',()=>{
|
||||
@@ -136,10 +136,130 @@ test('a context label names nested prompt regions and actions apart from the fro
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',prompts:{positive:record('p'),negative:record('n')}});
|
||||
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt','Copy current file negative prompt','Send current file negative prompt']) assert.ok(labels.includes(name),name);
|
||||
for(const name of ['Current file positive prompt','Current file negative prompt','Copy current file positive prompt','Send current file positive prompt to ComfyUI','Copy current file negative prompt','Send current file negative prompt to ComfyUI']) assert.ok(labels.includes(name),name);
|
||||
assert.equal(labels.filter(name=>/^(Positive|Negative) prompt$/.test(name)).length,0);
|
||||
assert.deepEqual(f.walk(container,node=>node.tagName==='h3').map(node=>node.textContent),['Positive prompt','Negative prompt']);
|
||||
assert.deepEqual(f.walk(container,node=>node.className==='role-label').map(node=>node.textContent),['+ Positive prompt','\u2212 Negative prompt']);
|
||||
const plain=new f.Element('div');module.mountPromptBlocks(plain,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('p')}});
|
||||
assert.ok(f.walk(plain,node=>node.attributes['aria-label']==='Positive prompt').length);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a title names each role action after its prompt as the concept does',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',prompts:{positive:record('p'),negative:record('n')}});
|
||||
const actions=f.walk(container,node=>node.tagName==='button').map(node=>node.attributes['aria-label']);
|
||||
assert.deepEqual(actions,['Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory',
|
||||
'Copy negative prompt: Alpine observatory','Send negative prompt to ComfyUI: Alpine observatory','Send prompt pair']);
|
||||
const nested=new f.Element('div');
|
||||
module.mountPromptBlocks(nested,{parentScope:scope,sender:{open:()=>{}},contextLabel:'Current file',title:'Alpine observatory',includePairAction:false,prompts:{positive:record('p')}});
|
||||
assert.deepEqual(f.walk(nested,node=>node.tagName==='button').map(node=>node.attributes['aria-label']),
|
||||
['Copy current file positive prompt: Alpine observatory','Send current file positive prompt to ComfyUI: Alpine observatory']);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
|
||||
test('a title names each action after its prompt (concept "Copy positive prompt: <title>") while regions keep their names',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Alpine observatory',roles:['positive'],prompts:{positive:record('p')}});
|
||||
const labels=f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
assert.deepEqual(labels,['Positive prompt','Copy positive prompt: Alpine observatory','Send positive prompt to ComfyUI: Alpine observatory']);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('setTitle renames the actions in place and keeps the last prompts and state',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},title:'Old name',roles:['positive'],prompts:{positive:record('p')}});
|
||||
view.setState({prompts:{positive:record('kept')},actionsEnabled:false});
|
||||
const labels=()=>f.walk(container,node=>node.attributes['aria-label']).map(node=>node.attributes['aria-label']);
|
||||
const rendered=container.children[0];view.setTitle('Old name');assert.equal(container.children[0],rendered,'an unchanged title does not re-render');
|
||||
view.setTitle('New name');
|
||||
assert.deepEqual(labels(),['Positive prompt','Copy positive prompt: New name','Send positive prompt to ComfyUI: New name']);
|
||||
assert.ok(f.walk(container,node=>node.textContent==='kept').length);
|
||||
assert.ok(f.walk(container,node=>node.dataset.copyRole==='positive').every(node=>node.disabled===true));
|
||||
view.setTitle('');assert.deepEqual(labels(),['Positive prompt','Copy positive prompt','Send positive prompt to ComfyUI']);
|
||||
scope.dispose();view.setTitle('After dispose');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
const shape=node=>({tag:node.tagName,className:node.className,...(node.attributes['aria-label']?{label:node.attributes['aria-label']}:{}),
|
||||
...(node.children.length?{children:node.children.filter(child=>child.tagName!=='svg').map(shape)}:{text:node.textContent})});
|
||||
test('prompt block emits concept markup without a source line',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div');
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,includePairAction:false,sender:{open:()=>{}},prompts:{positive:{...record('a lighthouse'),evidence:{provenance:'recorded',source:'runtime_return'}},negative:record('blurry')}});
|
||||
const section=(role,label,text)=>({tag:'section',className:`prompt-section ${role}`,label:`${role[0].toUpperCase()}${role.slice(1)} prompt`,children:[
|
||||
{tag:'div',className:'prompt-heading',children:[{tag:'span',className:'role-label',text:label},{tag:'div',className:'prompt-actions',children:[
|
||||
{tag:'button',className:'icon-btn',label:`Copy ${role} prompt`,children:[]},{tag:'button',className:'send icon-btn',label:`Send ${role} prompt to ComfyUI`,children:[]}]}]},
|
||||
{tag:'p',className:'prompt-text',text}]});
|
||||
assert.deepEqual(container.children.map(shape),[section('positive','+ Positive prompt','a lighthouse'),section('negative','\u2212 Negative prompt','blurry')]);
|
||||
for(const button of f.walk(container,node=>node.tagName==='button')) assert.equal(button.children.filter(child=>child.tagName==='svg').length,1,'one icon per action');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('empty negative uses the concept copy and still sends an explicit clear',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:roles=>sent.push(roles)},prompts:{positive:record('positive'),negative:record('')}});
|
||||
const negative=container.children[1];
|
||||
assert.equal(negative.children[1].className,'prompt-text');assert.equal(negative.children[1].textContent,'No negative prompt (explicitly empty)');
|
||||
container.click(f.walk(negative,node=>node.dataset.sendRole==='negative')[0]);
|
||||
container.click(f.walk(container,node=>node.dataset.sendRole==='pair')[0]);
|
||||
assert.deepEqual(sent,[{negative:''},{positive:'positive',negative:''}]);
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('unknown roles never offer copy or send',()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),sent=[];
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,roles:['positive','negative','style'],sender:{open:roles=>sent.push(roles)},
|
||||
prompts:{positive:{status:'absent'},negative:{status:'ambiguous',text:'candidate'},style:record('not a role')}});
|
||||
assert.deepEqual(container.children.map(node=>node.className),['prompt-section positive','prompt-section negative'],'an unknown role name renders nothing');
|
||||
assert.deepEqual(statusLines(f,container),['Not recorded in this file.','Multiple possible prompts. Source could not be determined.']);
|
||||
assert.equal(f.walk(container,node=>node.dataset.sendRole!==undefined||node.dataset.copyRole!==undefined||node.className==='prompt-actions').length,0);
|
||||
const forged=new f.Element('button');forged.dataset.sendRole='style';container.append(forged);container.click(forged);
|
||||
assert.equal(sent.length,0,'a forged action for an unknown role sends nothing');
|
||||
}finally{scope.dispose();f.restore();}
|
||||
});
|
||||
test('copy shows the shared toast "Prompt copied." with the exact role text',async()=>{
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),copied=[];
|
||||
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
|
||||
Object.defineProperty(globalThis,'navigator',{configurable:true,value:{clipboard:{writeText:async text=>{copied.push(text);}}}});
|
||||
try{
|
||||
module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record(' exact\ntext '),negative:record('')}});
|
||||
await container.listeners.get('click')({target:f.walk(container,node=>node.dataset.copyRole==='positive')[0]});
|
||||
assert.deepEqual(copied,[' exact\ntext ']);assert.equal(f.toast.textContent,'Prompt copied.');
|
||||
assert.equal(f.walk(container,node=>node.attributes.role==='status').length,0,'no per-block copy status line');
|
||||
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
|
||||
});
|
||||
|
||||
/** Runs one copy click with `clipboard` as `navigator.clipboard`, recording what the fallback seam receives. */
|
||||
async function copyWithClipboard(clipboard,{detach=false}={}) {
|
||||
const f=dom(),scope=createScope(),container=new f.Element('div'),fallbacks=[];
|
||||
const previous=Object.getOwnPropertyDescriptor(globalThis,'navigator');
|
||||
Object.defineProperty(globalThis,'navigator',{configurable:true,value:clipboard===undefined?{}:{clipboard}});
|
||||
try{
|
||||
const view=module.mountPromptBlocks(container,{parentScope:scope,sender:{open:()=>{}},prompts:{positive:record('exact text'),negative:record('')},
|
||||
copyFallback:(text,options)=>fallbacks.push({text,...options})});
|
||||
const copy=f.walk(container,node=>node.dataset.copyRole==='positive')[0];
|
||||
let current=copy;
|
||||
if(detach) {
|
||||
// The blocks re-render while the clipboard is pending: the clicked button is detached, its successor is not.
|
||||
copy.isConnected=false;container.querySelector=selector=>selector==='[data-copy-role="positive"]'?current:null;
|
||||
}
|
||||
const pending=container.listeners.get('click')({target:copy});
|
||||
if(detach) {view.update({positive:record('exact text'),negative:record('')});current=f.walk(container,node=>node.dataset.copyRole==='positive')[0];}
|
||||
await pending;
|
||||
return {fallbacks,toast:f.toast.textContent,copy,current,scope};
|
||||
}finally{scope.dispose();f.restore();if(previous)Object.defineProperty(globalThis,'navigator',previous);else delete globalThis.navigator;}
|
||||
}
|
||||
test('copy without a clipboard opens the select-to-copy fallback and shows no toast',async()=>{
|
||||
const {fallbacks,toast,copy,scope}=await copyWithClipboard(undefined);
|
||||
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.equal(fallbacks[0].trigger,copy);assert.ok(fallbacks[0].scope);
|
||||
assert.notEqual(toast,'Prompt copied.');assert.equal(scope.signal.aborted,true);
|
||||
});
|
||||
test('a refused clipboard write opens the fallback instead of the toast',async()=>{
|
||||
const {fallbacks,toast}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}});
|
||||
assert.equal(fallbacks.length,1);assert.equal(fallbacks[0].text,'exact text');assert.notEqual(toast,'Prompt copied.');
|
||||
});
|
||||
test('a fallback after a re-render returns focus to the same-role copy button, not the detached one',async()=>{
|
||||
const {fallbacks,copy,current}=await copyWithClipboard({writeText:async()=>{throw new Error('Denied');}},{detach:true});
|
||||
assert.equal(fallbacks.length,1);assert.notEqual(current,copy);assert.equal(fallbacks[0].trigger,current);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const modulePath='../../web/js/workspace/file-inspector.mjs';
|
||||
const load=()=>import(modulePath);
|
||||
const rawWorkflow='{ "nodes": [], "seed": 9007199254740993, "cfg": 1.0000000000000000001 }';
|
||||
function record() {
|
||||
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{metadata_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
return {identity:{kind:'rooted-file',key:'2:a.png',root_index:2,path:'a.png',root_scope:'a'.repeat(64)},file:{format:'PNG'},prompts:{positive:{status:'resolved',text:'a\n猫'}},generation:{seed:{status:'known',value:'9007199254740993',source:'workflow',evidence:'configuration'}},library:{category_scope:'pair',tags_scope:'pair',tags:['saved']},download:{filename:'a.png',url:'/prompt_manager/images/serve/a.png?root=2'},metadata:{status:'available',omitted_reason:null,raw:{workflow:rawWorkflow,prompt:'{broken',parameters:'literal <svg> </pre>'},record_formats:{workflow:'original-text',prompt:'original-text',parameters:'original-text'},workflow_graph:{status:'parsed',value:{seed:'9007199254740993'}},prompt_graph:{status:'malformed',value:null},parameters:'literal <svg> </pre>'}};
|
||||
}
|
||||
|
||||
test('metadata export retains literal raw and scoped evidence',async()=>{
|
||||
@@ -65,3 +65,24 @@ test('limited workflow record exports its representation and disables workflow d
|
||||
assert.equal(downloads.workflow.available,false);assert.equal(downloads.workflow.bodyText,null);
|
||||
assert.equal(downloads.workflow.reason,'Workflow exceeds inspection limits. Download the original file to inspect it externally.');
|
||||
});
|
||||
|
||||
test('Show in folder needs the full listing identity (D38)',async()=>{
|
||||
const {revealTarget}=await load(),scope='a'.repeat(64);
|
||||
assert.deepEqual(revealTarget(record().identity),{root_index:2,root_scope:scope,path:'a.png'});
|
||||
for(const identity of [
|
||||
{kind:'rooted-file',root_index:2,path:'a.png',root_scope:null},
|
||||
{kind:'rooted-file',root_index:'2',path:'a.png',root_scope:scope},
|
||||
{kind:'rooted-file',root_index:2,path:'',root_scope:scope},
|
||||
{kind:'reported-location',path:'folder/a.png',current_scope:scope},
|
||||
null,
|
||||
])assert.equal(revealTarget(identity),null);
|
||||
});
|
||||
test('Show in folder outcomes use the D38 toasts',async()=>{
|
||||
const {revealOutcome}=await load();
|
||||
assert.equal(revealOutcome(null),'Opened the folder on this computer.');
|
||||
assert.equal(revealOutcome({status:403,code:'not-local'}),'Show in folder works only in a browser on the computer running ComfyUI.');
|
||||
assert.equal(revealOutcome({status:501,code:'unavailable'}),'No file manager is available on this computer.');
|
||||
assert.equal(revealOutcome({status:429,code:'busy'}),'The folder is already opening.');
|
||||
for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')])
|
||||
assert.equal(revealOutcome(error),'The folder could not be opened. Try again.');
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.
|
||||
const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64);
|
||||
function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};}
|
||||
const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}});
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],category_scope:null,tags_scope:null,positive_id:null,pair_id:null,title:null,category:null}};}
|
||||
function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;}
|
||||
|
||||
test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);});
|
||||
|
||||
@@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async()
|
||||
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('added terms split, trim and exact-diff like the concept',()=>{
|
||||
// The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly.
|
||||
assert.equal(typeof m.addedTerms,'function','addedTerms is exported');
|
||||
assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']);
|
||||
assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']);
|
||||
assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']);
|
||||
// Duplicates in the selected negative stay, as the concept lists every added term in order.
|
||||
assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']);
|
||||
// Case and inner whitespace are exact.
|
||||
assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']);
|
||||
assert.deepEqual(m.addedTerms('same, text','text, same'),[]);
|
||||
assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ test('layouts preserve data order, remain within width and never overlap', async
|
||||
|
||||
test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{
|
||||
const {layoutImages}=await source;
|
||||
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0});
|
||||
assert.deepEqual(layoutImages([],0,'grid'),{positions:[],columns:1,height:0,maxHeight:0});
|
||||
for(const mode of ['grid','list','masonry']) {
|
||||
const result=layoutImages([{}, {width:0,height:null}],1,mode);
|
||||
assert.ok(Number.isFinite(result.height));
|
||||
@@ -74,64 +74,78 @@ test('binary-searched windows handle boundaries, empty collections and focus wit
|
||||
|
||||
test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{
|
||||
const {layoutImages,visibleImages}=await source;
|
||||
const geometry=layoutImages([{}, {}, {}],10000,'grid',20000);
|
||||
assert.equal(geometry.maxHeight,7554.5);
|
||||
const geometry=layoutImages([{}, {}, {}],10000,'grid',{columns:1});
|
||||
assert.equal(geometry.maxHeight,9998/1.33+56);
|
||||
assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]);
|
||||
});
|
||||
|
||||
// The concept's mountMasonry reads the same two values (app.js 530-534); its breakpoints live in concept.css only.
|
||||
function computedGrid(columns, gap) {
|
||||
globalThis.getComputedStyle = node => {
|
||||
assert.equal(node, grid);
|
||||
return {gridTemplateColumns:columns, columnGap:gap};
|
||||
};
|
||||
const grid = {};
|
||||
return grid;
|
||||
}
|
||||
|
||||
test('approved workspace densities share viewport metrics and reject unknown values', async () => {
|
||||
const {workspaceImageMetrics, layoutImages} = await source;
|
||||
assert.equal(typeof workspaceImageMetrics, 'function', 'workspace density metrics must be exported');
|
||||
for (const [width, expected] of [[1600,[3,2,4]], [1250,[2,2,3]], [950,[2,2,3]], [700,[2,2,2]], [390,[2,2,2]], [1700,[4,2,5]]]) {
|
||||
assert.deepEqual(['comfortable','large','compact'].map(density => workspaceImageMetrics(width,density).columns), expected);
|
||||
for (const density of ['comfortable','large','compact']) {
|
||||
const metrics = workspaceImageMetrics(width,density);
|
||||
assert.deepEqual(metrics, {columns: expected[['comfortable','large','compact'].indexOf(density)], gap:13, footerHeight:54, listHeight:97});
|
||||
assert.equal(layoutImages([{},{}],990,'list',240,metrics).positions[1].x,0);
|
||||
}
|
||||
}
|
||||
assert.throws(() => workspaceImageMetrics(1600,'unknown'), TypeError);
|
||||
assert.deepEqual(workspaceImageMetrics(1600),workspaceImageMetrics(1600,'comfortable'));
|
||||
});
|
||||
|
||||
test('approved grid uses inner media at four to three and explicit columns with thirteen pixel gaps', async () => {
|
||||
const {workspaceImageMetrics,layoutImages} = await source;
|
||||
assert.equal(typeof workspaceImageMetrics,'function');
|
||||
const metrics = workspaceImageMetrics(1600);
|
||||
const {positions} = layoutImages(Array(4).fill({}),990,'grid',240,metrics);
|
||||
assert.equal(positions[1].x-positions[0].width,13);
|
||||
assert.equal(positions[3].x,0);
|
||||
assert.equal(positions[3].y-positions[0].height,13);
|
||||
assert.equal(positions[0].height-54-2,(positions[0].width-2)*.75);
|
||||
assert.equal(layoutImages([{}],990,'list',240,metrics).height,97);
|
||||
const custom=layoutImages([{}],990,'grid',240,{columns:2,gap:20,footerHeight:60,listHeight:100});
|
||||
assert.equal(custom.positions[0].width,485);
|
||||
assert.equal(custom.height,(485-2)*.75+62);
|
||||
assert.equal(layoutImages([{}],990,'list',240,{listHeight:100}).height,100);
|
||||
test('layout uses columns and gap from computed style', async () => {
|
||||
const {gridMetrics, layoutImages} = await source;
|
||||
const original = globalThis.getComputedStyle;
|
||||
try {
|
||||
for (const [columns, gap, expected] of [
|
||||
['329.333px 329.333px 329.333px', '13px', {columns:3, gap:13}],
|
||||
['200px 200px 200px 200px 200px', '20px', {columns:5, gap:20}],
|
||||
['990px', '13px', {columns:1, gap:13}],
|
||||
['none', 'normal', {columns:1, gap:0}],
|
||||
['', '', {columns:1, gap:0}],
|
||||
]) assert.deepEqual(gridMetrics(computedGrid(columns, gap)), expected, `${columns} / ${gap}`);
|
||||
} finally { globalThis.getComputedStyle = original; }
|
||||
const {positions, columns} = layoutImages(Array(6).fill({}), 990, 'grid', {columns:3, gap:13});
|
||||
assert.equal(columns, 3);
|
||||
assert.equal(positions[1].x, positions[0].width + 13);
|
||||
assert.equal(positions[3].x, 0);
|
||||
assert.equal(positions[3].y, positions[0].height + 13);
|
||||
// The concept media is `aspect-ratio: 1.33` inside a 1px card border, under the measured `.media-info`.
|
||||
assert.equal(positions[0].height, (positions[0].width - 2) / 1.33 + 54 + 2);
|
||||
const wide = layoutImages(Array(6).fill({}), 990, 'grid', {columns:5, gap:20, footerHeight:60});
|
||||
assert.equal(wide.positions[0].width, (990 - 80) / 5);
|
||||
assert.equal(wide.positions[0].height, (wide.positions[0].width - 2) / 1.33 + 62);
|
||||
assert.equal(layoutImages([{}, {}], 990, 'list', {columns:1, gap:13}).positions[1].x, 0);
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:1, gap:13}).height, 97);
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:1, listHeight:100}).height, 100);
|
||||
});
|
||||
|
||||
test('masonry uses real finite proportions and falls back for invalid dimensions', async () => {
|
||||
const {layoutImages}=await source;
|
||||
const images=[{width:100,height:1000},{width:1000,height:100},{width:Infinity,height:10},{width:10,height:NaN}];
|
||||
const {positions}=layoutImages(images,990,'masonry',240,{columns:3});
|
||||
const {positions}=layoutImages(images,990,'masonry',{columns:3});
|
||||
assert.equal(positions[0].height,(positions[0].width-2)*10+56);
|
||||
assert.equal(positions[1].height,(positions[1].width-2)/10+56);
|
||||
assert.equal(positions[2].height,(positions[2].width-2)*.75+56);
|
||||
for (const position of positions) assert.ok(Number.isFinite(position.height));
|
||||
});
|
||||
|
||||
test('gallery width limits density columns without changing ordinary workspace geometry', async () => {
|
||||
const {workspaceImageMetrics, layoutImages} = await source;
|
||||
for (const [viewport, width, density, columns] of [
|
||||
[1600,990,'comfortable',3], [1600,990,'compact',4],
|
||||
[951,428,'compact',3], [390,362,'comfortable',2],
|
||||
[1600,190,'comfortable',1], [1600,190,'compact',1],
|
||||
[390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1],
|
||||
test('masonry falls back to the concept ratio when a finite size underflows the proportion', async () => {
|
||||
const {layoutImages}=await source;
|
||||
// Both sides are finite and positive, but their ratio underflows to 0, so the proportional height is infinite.
|
||||
const {positions,height}=layoutImages([{width:Number.MIN_VALUE,height:1e300}],300,'masonry',{columns:1});
|
||||
assert.equal(positions[0].height,(300-2)/1.33+56);
|
||||
assert.ok(Number.isFinite(height));
|
||||
});
|
||||
|
||||
test('a narrow gallery caps the concept columns at the smallest readable card', async () => {
|
||||
const {layoutImages, MINIMUM_CARD_WIDTH} = await source;
|
||||
// One shared minimum: the page marks `.narrow-cards` below the same width the layout caps columns at.
|
||||
assert.equal(MINIMUM_CARD_WIDTH, 120);
|
||||
// At 200 percent zoom the concept's 390px breakpoint still asks for two columns of a half-width grid (D3, D31).
|
||||
for (const [width, asked, columns] of [
|
||||
[990,3,3], [990,4,4], [428,3,3], [362,2,2], [190,3,1], [190,4,1], [167,2,1], [69.5,2,1], [0,2,1],
|
||||
]) {
|
||||
const metrics = workspaceImageMetrics(viewport,density,width);
|
||||
assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`);
|
||||
const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics);
|
||||
assert.equal(positions[columns].x,0);
|
||||
assert.ok(positions[columns].y > 0);
|
||||
const geometry = layoutImages(Array(8).fill({}), width, 'grid', {columns:asked, gap:13});
|
||||
assert.equal(geometry.columns, columns, `${width}/${asked}`);
|
||||
assert.equal(geometry.positions[columns].x, 0);
|
||||
assert.ok(geometry.positions[columns].y > 0);
|
||||
}
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-category.mjs');
|
||||
|
||||
const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}});
|
||||
|
||||
test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => {
|
||||
const {categoryTargets} = await source;
|
||||
const images = [
|
||||
linked(7), linked(3), linked(7),
|
||||
{path:'u.png', linked:{status:'unlinked', positive_id:null}},
|
||||
{path:'a.png', linked:{status:'ambiguous', positive_id:4}},
|
||||
{path:'v.png', linked:{status:'unavailable', positive_id:null}},
|
||||
{path:'none.png'},
|
||||
{path:'bad.png', linked:{status:'linked', positive_id:'9'}},
|
||||
{path:'zero.png', linked:{status:'linked', positive_id:0}},
|
||||
{path:'frac.png', linked:{status:'linked', positive_id:1.5}},
|
||||
null,
|
||||
];
|
||||
assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8});
|
||||
assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0});
|
||||
assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0});
|
||||
});
|
||||
|
||||
test('the outcome toast counts the prompts and the skipped images', async () => {
|
||||
const {categoryOutcome} = await source;
|
||||
assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.');
|
||||
assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.');
|
||||
assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.');
|
||||
assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
|
||||
});
|
||||
|
||||
test('the dialog and failure copy is the plan\'s', async () => {
|
||||
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
|
||||
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
assert.equal(CATEGORY_NONE_LINKED, 'None of the selected images has a linked prompt.');
|
||||
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
|
||||
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
|
||||
});
|
||||
|
||||
test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => {
|
||||
const {updatedCount} = await source;
|
||||
assert.equal(updatedCount({updated_count:0}, 3), 0);
|
||||
assert.equal(updatedCount({updated_count:2}, 3), 2);
|
||||
for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) {
|
||||
assert.equal(updatedCount(result, 3), 3, JSON.stringify(result));
|
||||
}
|
||||
});
|
||||
|
||||
test('the category field is limited to the server\'s 100 characters', async () => {
|
||||
const {CATEGORY_MAX_LENGTH} = await source;
|
||||
assert.equal(CATEGORY_MAX_LENGTH, 100);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/images-export.mjs');
|
||||
const inspector = import('../../web/js/workspace/file-inspector.mjs');
|
||||
|
||||
function record(path, status = 'available') {
|
||||
return {identity:{kind:'rooted-file', key:`0:${path}`, root_index:0, path, root_scope:null}, file:{format:'PNG'},
|
||||
prompts:{positive:{status:'resolved', text:path}}, generation:{}, library:{status:'unlinked'},
|
||||
download:{filename:path.split('/').at(-1), url:`/prompt_manager/images/serve/${path}?root=0`},
|
||||
metadata:{status, omitted_reason:null, raw:{prompt:'{}'}, record_formats:{prompt:'original-text'}}};
|
||||
}
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => {resolve = a; reject = b;}); return {promise, resolve, reject}; };
|
||||
|
||||
test('the single-image download and the bundle share one envelope builder', async () => {
|
||||
const {metadataEnvelope, inspectionDownloads} = await inspector;
|
||||
const value = record('a/one.png');
|
||||
assert.deepEqual(JSON.parse(inspectionDownloads(value).metadata.bodyText), metadataEnvelope(value));
|
||||
assert.equal(metadataEnvelope(record('b.png', 'limited')), null);
|
||||
assert.equal(metadataEnvelope({}), null);
|
||||
});
|
||||
|
||||
test('the bundle holds the format, version, export time and envelopes in the given order', async () => {
|
||||
const {metadataBundle, BUNDLE_FILENAME} = await source;
|
||||
const envelopes = [{inspection_version:1, n:1}, {inspection_version:1, n:2}];
|
||||
const bundle = metadataBundle(envelopes, '2026-10-20T00:00:00.000Z');
|
||||
assert.deepEqual(bundle, {format:'prompt-manager-images-metadata', version:1, exported_at:'2026-10-20T00:00:00.000Z', images:envelopes});
|
||||
assert.notEqual(bundle.images, envelopes);
|
||||
assert.equal(BUNDLE_FILENAME, 'prompt-manager-images-metadata.json');
|
||||
});
|
||||
|
||||
test('collection keeps listing order, runs at most four reads at once and counts failures', async () => {
|
||||
const {collectImagesMetadata} = await source;
|
||||
const {metadataEnvelope} = await inspector;
|
||||
const images = Array.from({length:9}, (_, index) => ({path:`img-${index}.png`, root_index:0}));
|
||||
const waits = new Map(images.map(image => [image.path, deferred()]));
|
||||
let running = 0, peak = 0;
|
||||
const done = collectImagesMetadata(images, async image => {
|
||||
running += 1; peak = Math.max(peak, running);
|
||||
try { return await waits.get(image.path).promise; } finally { running -= 1; }
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(running, 4);
|
||||
// Settle out of order: later images finish first, one read fails and one file's metadata is limited.
|
||||
for (const image of [...images].reverse()) {
|
||||
const wait = waits.get(image.path);
|
||||
if (image.path === 'img-3.png') wait.reject(new Error('unreadable'));
|
||||
else wait.resolve(record(image.path, image.path === 'img-5.png' ? 'limited' : 'available'));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
const {envelopes, failed} = await done;
|
||||
assert.equal(peak, 4);
|
||||
assert.equal(failed, 2);
|
||||
assert.deepEqual(envelopes.map(envelope => envelope.identity.path),
|
||||
['img-0.png', 'img-1.png', 'img-2.png', 'img-4.png', 'img-6.png', 'img-7.png', 'img-8.png']);
|
||||
assert.deepEqual(envelopes[0], metadataEnvelope(record('img-0.png')));
|
||||
});
|
||||
|
||||
test('a read that throws synchronously counts as a failure', async () => {
|
||||
const {collectImagesMetadata} = await source;
|
||||
const result = await collectImagesMetadata([{path:'a.png'}, {path:'b.png'}], image => {
|
||||
if (image.path === 'a.png') throw new TypeError('Invalid inspection identity');
|
||||
return record(image.path);
|
||||
});
|
||||
assert.equal(result.failed, 1);
|
||||
assert.deepEqual(result.envelopes.map(envelope => envelope.identity.path), ['b.png']);
|
||||
assert.deepEqual(await collectImagesMetadata([], () => assert.fail('no reads')), {envelopes:[], failed:0});
|
||||
});
|
||||
|
||||
test('the outcome toasts name the exported and unread counts', async () => {
|
||||
const {exportOutcome} = await source;
|
||||
assert.equal(exportOutcome(1, 1), 'Exported metadata for 1 image.');
|
||||
assert.equal(exportOutcome(2, 2), 'Exported metadata for 2 images.');
|
||||
assert.equal(exportOutcome(1, 3), 'Exported metadata for 1 of 3 images. 2 could not be read.');
|
||||
assert.equal(exportOutcome(2, 3), 'Exported metadata for 2 of 3 images. 1 could not be read.');
|
||||
assert.equal(exportOutcome(0, 2), 'Metadata could not be read. Try again.');
|
||||
});
|
||||
@@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported
|
||||
await stream.reset({root:'all',media:'all',sort:'name-desc'});
|
||||
assert.equal(query.has('root'),false);assert.equal(query.has('media'),false);
|
||||
assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40');
|
||||
assert.equal(query.has('favorites'),false);
|
||||
});
|
||||
|
||||
test('the Favorites page asks the listing for favorites only (#249 D51)', async () => {
|
||||
const {createImageStream}=await source;let query;
|
||||
const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}});
|
||||
await stream.reset({favorites:true,q:'fog'});
|
||||
assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog');
|
||||
await stream.reset({favorites:false});
|
||||
assert.equal(query.has('favorites'),false);
|
||||
for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/);
|
||||
});
|
||||
|
||||
test('restored browsing state resumes its cursor and filters without an initial request', async () => {
|
||||
@@ -183,3 +194,161 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
|
||||
assert.equal(await stream.loadMore(),1);assert.equal(calls[1].parameters.get('cursor'),'next');assert.equal(stream.state.loaded,2);stream.dispose();
|
||||
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
|
||||
});
|
||||
|
||||
test('stream sends category, tags and the rating sort and validates them', 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({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);
|
||||
});
|
||||
|
||||
test('positive count comes from the first page, survives plain cursor pages and hides when unknown', async () => {
|
||||
const {createImageStream}=await source;
|
||||
const replies=[{...page([image('a')],3,'one'),positive_count:2},page([image('b')],3,'two'),{...page([image('c')],3),positive_count:null}];
|
||||
const stream=createImageStream({api:{get:async()=>replies.shift()}});
|
||||
assert.equal(stream.state.positiveCount,null);
|
||||
await stream.reset();assert.equal(stream.state.positiveCount,2);
|
||||
await stream.loadMore();assert.equal(stream.state.positiveCount,2);
|
||||
await stream.loadMore();assert.equal(stream.state.positiveCount,2);
|
||||
const failed=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count:null})}});
|
||||
await failed.reset();assert.equal(failed.state.positiveCount,null);
|
||||
const absent=createImageStream({api:{get:async()=>page([image('a')])}});
|
||||
await absent.reset();assert.equal(absent.state.positiveCount,null);
|
||||
for(const positive_count of [-1,'2',1.5]) {
|
||||
const invalid=createImageStream({api:{get:async()=>({...page([image('a')]),positive_count})}});
|
||||
await invalid.reset();assert.match(invalid.state.error.message,/page/i);
|
||||
}
|
||||
const restored=createImageStream({api:{get(){assert.fail('unexpected request');}},restore:{filters:{},state:{...stream.state}}});
|
||||
assert.equal(restored.state.positiveCount,2);
|
||||
assert.throws(()=>createImageStream({api:{get(){}},restore:{filters:{},state:{...stream.state,positiveCount:-3}}}),TypeError);
|
||||
});
|
||||
|
||||
test('the count line reads N of M images and K positive prompts only when known', async () => {
|
||||
const {countLine}=await source;
|
||||
assert.equal(countLine({loaded:40,total:102,positiveCount:2}),'40 of 102 images · 2 positive prompts');
|
||||
assert.equal(countLine({loaded:1,total:1,positiveCount:1}),'1 of 1 image · 1 positive prompt');
|
||||
assert.equal(countLine({loaded:2,total:2,positiveCount:null}),'2 of 2 images');
|
||||
assert.equal(countLine({loaded:1200,total:3400,positiveCount:0}),'1,200 of 3,400 images · 0 positive prompts');
|
||||
});
|
||||
|
||||
test('the caption date names the newest result day under Newest first only', async () => {
|
||||
const {captionDate}=await source;
|
||||
const now=new Date(2026,9,17,15,0);
|
||||
const at=(...parts)=>({modified_time:new Date(...parts).getTime()/1000});
|
||||
assert.equal(captionDate([at(2026,9,17,9),at(2026,9,10)],'newest',now),'Today · October 17');
|
||||
assert.equal(captionDate([at(2026,9,16,23),at(2026,9,10)],'newest',now),'Yesterday · October 16');
|
||||
// D17 ruling: only under Newest first; Oldest first hides the date too.
|
||||
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),'');
|
||||
assert.equal(captionDate([],'newest',now),'');
|
||||
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
|
||||
});
|
||||
|
||||
test('the scope chip summarizes source and folder', async () => {
|
||||
const {scopeLabel}=await source;
|
||||
const roots=[{index:0,label:'output'},{index:1,label:'input'}];
|
||||
assert.equal(scopeLabel({root:'all',subfolder:''},roots),'All output folders');
|
||||
assert.equal(scopeLabel({root:'1',subfolder:'shared'},roots),'input · shared');
|
||||
assert.equal(scopeLabel({root:'0',subfolder:''},roots),'output · All output folders');
|
||||
assert.equal(scopeLabel({root:'all',subfolder:'bulk'},roots),'All sources · bulk');
|
||||
assert.equal(scopeLabel({root:'7',subfolder:''},roots),'Source 7 · All output folders');
|
||||
});
|
||||
|
||||
test('the Filters count names each active Refine filter once (D44)', async () => {
|
||||
const {activeRefinements}=await source;
|
||||
assert.equal(activeRefinements({root:'all',subfolder:'',media:'all',tags:[]}),0);
|
||||
assert.equal(activeRefinements({root:'',subfolder:'',media:'all',tags:[]}),0);
|
||||
assert.equal(activeRefinements({root:'1',subfolder:'',media:'all',tags:[]}),1);
|
||||
assert.equal(activeRefinements({root:'all',subfolder:'bulk',media:'video',tags:['a','b']}),3);
|
||||
assert.equal(activeRefinements({root:'0',subfolder:'bulk',media:'image',tags:['a']}),4);
|
||||
});
|
||||
|
||||
test('the card title is the confirmed linked title, else the filename (D10)', async () => {
|
||||
const {cardTitle}=await source;
|
||||
const linked=(status,title)=>({filename:'alpine_00001.png',linked:{status,positive_id:1,title,title_source:'title',category:null,rating:null}});
|
||||
assert.equal(cardTitle(linked('linked','Alpine observatory')),'Alpine observatory');
|
||||
for (const status of ['ambiguous','unlinked','unavailable','none']) assert.equal(cardTitle(linked(status,'Alpine observatory')),'alpine_00001.png');
|
||||
assert.equal(cardTitle(linked('linked',null)),'alpine_00001.png');
|
||||
assert.equal(cardTitle(linked('linked','')),'alpine_00001.png');
|
||||
assert.equal(cardTitle({filename:'plain.png'}),'plain.png');
|
||||
assert.equal(cardTitle({filename:'plain.png',linked:null}),'plain.png');
|
||||
});
|
||||
|
||||
test('the card subtitle is W × H · EXT · Seed N with unknown parts omitted (D11)', async () => {
|
||||
const {cardSubtitle, mediaRatio}=await source;
|
||||
assert.equal(cardSubtitle({filename:'a.png',width:1536,height:1152,seed:'42001'}),'1536 × 1152 · PNG · Seed 42001');
|
||||
// The seed is the listing's decimal string: never a Number, so 2^53 + 1 survives.
|
||||
assert.equal(cardSubtitle({filename:'a.png',width:8,height:8,seed:'9007199254740993'}),'8 × 8 · PNG · Seed 9007199254740993');
|
||||
assert.equal(cardSubtitle({filename:'clip.webm',width:null,height:null,seed:null}),'WEBM');
|
||||
assert.equal(cardSubtitle({filename:'clip.mp4',media_type:'video',width:640,height:360}),'640 × 360 · MP4');
|
||||
assert.equal(cardSubtitle({filename:'noext',width:0,height:10,seed:''}),'');
|
||||
assert.equal(cardSubtitle({filename:'.hidden',width:10,height:Infinity,seed:42}),'');
|
||||
assert.equal(cardSubtitle({filename:'trailing.',seed:'7'}),'Seed 7');
|
||||
assert.equal(mediaRatio({width:1536,height:1152}),'1536/1152');
|
||||
for (const image of [{}, {width:0,height:5}, {width:5,height:NaN}, {width:Infinity,height:5}]) assert.equal(mediaRatio(image),'4/3');
|
||||
});
|
||||
|
||||
test('updating one item replaces it immutably and later pages append after it', async () => {
|
||||
const { createImageStream, imageKey } = await source;
|
||||
const pages = [page([{...image('a'), favorite:false}, {...image('b'), favorite:true}], 3, 'next'), page([image('c')], 3)];
|
||||
const states = [];
|
||||
const hints = [];
|
||||
const stream = createImageStream({api:{get:async () => pages.shift()}, onChange:(s, hint) => { states.push(s); hints.push(hint); }, batchSize:2});
|
||||
await stream.reset({});
|
||||
const before = stream.state, notified = states.length;
|
||||
// Page loads carry no hint; a patch says so, so the page can repaint one card instead of laying out the collection.
|
||||
assert.ok(hints.every(hint => hint === undefined));
|
||||
assert.equal(stream.updateItem('0:a', {favorite:true}), true);
|
||||
const after = stream.state;
|
||||
assert.equal(states.length, notified + 1); assert.equal(states.at(-1), after);
|
||||
assert.deepEqual(hints.at(-1), {patch:'0:a'});
|
||||
assert.notEqual(after.items, before.items); assert.ok(Object.isFrozen(after.items)); assert.ok(Object.isFrozen(after.items[0]));
|
||||
assert.equal(after.items[0].favorite, true); assert.equal(before.items[0].favorite, false);
|
||||
assert.equal(after.items[1], before.items[1]);
|
||||
assert.equal(after.loaded, before.loaded); assert.equal(after.cursor, before.cursor); assert.equal(after.total, before.total);
|
||||
// Identity fields cannot be patched, and an unknown key changes nothing.
|
||||
assert.throws(() => stream.updateItem('0:a', {path:'z'}), TypeError);
|
||||
assert.throws(() => stream.updateItem('0:a', {root_index:1}), TypeError);
|
||||
assert.equal(stream.updateItem('0:missing', {favorite:true}), false);
|
||||
assert.equal(stream.state, after); assert.equal(states.length, notified + 1);
|
||||
await stream.loadMore();
|
||||
assert.deepEqual(stream.state.items.map(imageKey), ['0:a', '0:b', '0:c']);
|
||||
assert.equal(stream.state.items[0].favorite, true);
|
||||
stream.dispose();
|
||||
assert.equal(stream.updateItem('0:b', {favorite:false}), false);
|
||||
assert.equal(stream.state.items[1].favorite, true);
|
||||
});
|
||||
|
||||
test('withItem patches a saved browsing state without touching the other items', async () => {
|
||||
const { withItem } = await source;
|
||||
const items = Object.freeze([Object.freeze({...image('a'), favorite:false}), Object.freeze({...image('b'), favorite:false})]);
|
||||
const state = Object.freeze({items, total:2, cursor:null, hasMore:false, loading:false, error:null, loaded:2, positiveCount:null});
|
||||
const next = withItem(state, '0:b', {favorite:true});
|
||||
assert.ok(Object.isFrozen(next)); assert.equal(next.items[1].favorite, true); assert.equal(next.items[0], items[0]);
|
||||
assert.equal(state.items[1].favorite, false);
|
||||
assert.equal(withItem(state, '1:b', {favorite:true}), state);
|
||||
});
|
||||
|
||||
test('toggleSelection returns a new frozen set and leaves the given one untouched', async () => {
|
||||
const { toggleSelection } = await source;
|
||||
const empty = Object.freeze(new Set());
|
||||
const one = toggleSelection(empty, '0:a', true);
|
||||
assert.ok(Object.isFrozen(one)); assert.deepEqual([...one], ['0:a']); assert.equal(empty.size, 0);
|
||||
const two = toggleSelection(one, '1:a', true);
|
||||
assert.deepEqual([...two], ['0:a', '1:a']); assert.deepEqual([...one], ['0:a']);
|
||||
assert.equal(toggleSelection(two, '0:a', true), two, 'an already selected key keeps the same set');
|
||||
assert.equal(toggleSelection(one, '9:z', false), one, 'an unselected key keeps the same set');
|
||||
assert.deepEqual([...toggleSelection(two, '0:a', false)], ['1:a']);
|
||||
});
|
||||
|
||||
test('selectionLabel counts the selected images as the concept bar does', async () => {
|
||||
const { selectionLabel } = await source;
|
||||
assert.equal(selectionLabel(1), '1 selected');
|
||||
assert.equal(selectionLabel(2), '2 selected');
|
||||
assert.equal(selectionLabel(1234), '1,234 selected');
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ function envelope(entry = image()) {
|
||||
workflow_graph: { status: 'absent', value: null }, parameters: null,
|
||||
},
|
||||
resolution: { state: 'ambiguous', sampler_id: null, reason: 'unknown negative' },
|
||||
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], metadata_scope: 'pair' },
|
||||
library: { status: 'linked', scope: 'saved-library', positive_id: 7, pair_id: 8, title: 'Recipe', category: 'Subject', tags: ['cat'], category_scope: 'pair', tags_scope: 'pair' },
|
||||
};
|
||||
}
|
||||
function pendingApi() {
|
||||
@@ -276,6 +276,10 @@ test('record rejects root path media and invalid versioned fields with retryable
|
||||
data => { data.file.size = -1; }, data => { data.library.positive_id = 9007199254740992; },
|
||||
data => { data.metadata.prompt_graph = { status: 'limited', value: {} }; },
|
||||
data => { data.resolution.sampler_id = {}; },
|
||||
data => { data.library.tags_scope = 'recipe'; },
|
||||
data => { data.library.category_scope = 'recipe'; },
|
||||
data => { delete data.library.category_scope; },
|
||||
data => { delete data.library.tags_scope; },
|
||||
]) {
|
||||
const data = envelope(); mutate(data);
|
||||
assert.throws(() => toRecord(data, image(), extractGenerationFacts), error => error instanceof TypeError && !error.refreshRequired);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const source = import('../../web/js/workspace/library-layout.mjs');
|
||||
|
||||
/** Runs `body` with the process time zone set to `zone`, restoring the previous one. */
|
||||
async function inZone(zone, body) {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = zone;
|
||||
try { await body(); } finally {
|
||||
if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test('used label boundaries in UTC and local time', async () => {
|
||||
const { usedLabel } = await source;
|
||||
await inZone('UTC', () => {
|
||||
const now = new Date('2026-10-20T12:00:00Z');
|
||||
assert.equal(usedLabel('2026-10-20T00:00:00Z', now), 'Used today');
|
||||
assert.equal(usedLabel('2026-10-20T11:59:59Z', now), 'Used today');
|
||||
assert.equal(usedLabel('2026-10-19T23:59:59Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-19T00:00:00Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-18T23:59:59Z', now), 'Used 2 days ago');
|
||||
assert.equal(usedLabel('2026-10-14T00:00:00Z', now), 'Used 6 days ago');
|
||||
assert.equal(usedLabel('2026-10-13T23:59:59Z', now), 'Used Oct 13');
|
||||
assert.equal(usedLabel('2026-01-02T08:00:00Z', now), 'Used Jan 2');
|
||||
// D23: a use in another calendar year names its year, so last year's Oct 21 never reads as this year's.
|
||||
assert.equal(usedLabel('2025-10-21T08:00:00Z', now), 'Used Oct 21, 2025');
|
||||
assert.equal(usedLabel('2025-12-31T23:59:59Z', now), 'Used Dec 31, 2025');
|
||||
// A clock ahead of this browser is not a future use: it reads as today.
|
||||
assert.equal(usedLabel('2026-10-21T09:00:00Z', now), 'Used today');
|
||||
});
|
||||
// The day boundary is the viewer's local midnight, not UTC's (PDT is UTC-7 in October).
|
||||
await inZone('America/Los_Angeles', () => {
|
||||
const now = new Date('2026-10-20T07:30:00Z');
|
||||
assert.equal(usedLabel('2026-10-20T07:10:00Z', now), 'Used today');
|
||||
assert.equal(usedLabel('2026-10-20T06:30:00Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-14T07:00:00Z', now), 'Used 6 days ago');
|
||||
assert.equal(usedLabel('2026-10-14T06:59:59Z', now), 'Used Oct 13');
|
||||
});
|
||||
// Across the November DST change a calendar day is still one day.
|
||||
await inZone('America/New_York', () => {
|
||||
const now = new Date('2026-11-02T05:30:00Z');
|
||||
assert.equal(usedLabel('2026-11-01T04:30:00Z', now), 'Used yesterday');
|
||||
assert.equal(usedLabel('2026-10-31T05:30:00Z', now), 'Used 2 days ago');
|
||||
});
|
||||
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: '' });
|
||||
});
|
||||
@@ -2,12 +2,21 @@ const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const source=import('../../web/js/workspace/library-state.mjs');
|
||||
const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]});
|
||||
const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor});
|
||||
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();
|
||||
});
|
||||
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}];
|
||||
const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.variantTotal,null);
|
||||
await s.reset();assert.equal(s.state.variantTotal,6);await s.loadMore();assert.equal(s.state.variantTotal,7);
|
||||
const reset=s.reset({text:'x'});assert.equal(s.state.variantTotal,null);await reset;assert.equal(s.state.variantTotal,1);
|
||||
const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.variantTotal,1);restored.dispose();
|
||||
for(const bad of [undefined,-1,1.5,'2']){const broken=createLibraryStream({api:{get:async()=>({...page([item(1)]),variant_total:bad})}});await broken.reset();assert.ok(broken.state.error);assert.equal(broken.state.variantTotal,null);broken.dispose();}
|
||||
s.dispose();
|
||||
});
|
||||
test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{
|
||||
const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}});
|
||||
const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]);
|
||||
@@ -36,7 +45,7 @@ test('detail reads validate identities, abort stale selection and expose retryab
|
||||
test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{
|
||||
const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[];
|
||||
const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose();
|
||||
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}}));
|
||||
assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}}));
|
||||
});
|
||||
test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{
|
||||
const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24);
|
||||
|
||||
@@ -40,8 +40,10 @@ function mountedDom() {
|
||||
set textContent(value){this._text=String(value);this.children=[];}
|
||||
get childNodes(){return this.children;}
|
||||
get isConnected(){return this.root || !!this.parent?.isConnected;}
|
||||
append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}}
|
||||
prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);}
|
||||
// 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);}}
|
||||
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;}
|
||||
setAttribute(key,value){this.attributes[key]=String(value);}
|
||||
@@ -89,7 +91,7 @@ test('occurrence Viewer retains exact caller selection and synchronously closes
|
||||
const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/);
|
||||
const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url);
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2');
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
|
||||
f.walk(f.container,n=>n.attributes['aria-label']==='Close image viewer')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0);
|
||||
const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count);
|
||||
view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
@@ -106,7 +108,7 @@ test('slideshow stops for detail reading nested send close and parent disposal w
|
||||
const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}};
|
||||
controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location · identity unverified').length);
|
||||
find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow'));
|
||||
find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
|
||||
find('Play slideshow').fire('click');find('Close image viewer').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load');
|
||||
view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0);
|
||||
} finally {view.dispose();parentScope.dispose();f.restore();}
|
||||
});
|
||||
@@ -117,7 +119,7 @@ function currentInspectionEnvelope(target) {
|
||||
file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},
|
||||
prompts:{positive:{status:'resolved',text:'current positive',source:'execution'},negative:{status:'resolved',text:'',source:'execution'}},
|
||||
metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],metadata_scope:null}};
|
||||
resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null}};
|
||||
}
|
||||
|
||||
test('real inspection navigation preserves slideshow across two advances and deferred metadata loads',async()=>{
|
||||
@@ -152,7 +154,7 @@ test('real inspection navigation preserves slideshow across two advances and def
|
||||
find('Play slideshow').fire('click');f.walk(f.container,n=>n.tagName==='aside')[0].fire('click',{target:retryButton});retryButton.fire('click');
|
||||
assert.ok(find('Play slideshow'),'explicit retry interaction stops intent');assert.equal(controller.state.loading,true);assert.equal(timers.size,0);
|
||||
calls.at(-1).resolve(currentInspectionEnvelope(items[2]));await new Promise(resolve=>setImmediate(resolve));
|
||||
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close preview').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
|
||||
find('Play slideshow').fire('click');const lateTimer=[...timers.values()][0];find('Close image viewer').fire('click');assert.equal(closed,1);assert.equal(timers.size,0);
|
||||
lateTimer();assert.equal(selected.length,3);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3','closing keeps the borrowed controller live');
|
||||
view.open({items,selectedKey:items[2].location_key});const lateMedia=image();lateMedia.fire('load');find('Play slideshow').fire('click');parentScope.dispose();assert.equal(timers.size,0);assert.equal(closed,1);lateMedia.fire('load');
|
||||
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
|
||||
@@ -173,7 +175,7 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [loading] = instances, image = loading.node, removed = [];
|
||||
image.removeAttribute = key => removed.push(key);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending');
|
||||
// Viewer.js lists only images that still have a src; the pending view needs it.
|
||||
assert.deepEqual(removed, [], 'the image keeps its src while the view is pending');
|
||||
@@ -185,7 +187,63 @@ test('an inline Viewer still loading its image is destroyed only after it has vi
|
||||
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Close image viewer')[0].fire('click');
|
||||
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
|
||||
});
|
||||
|
||||
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')];
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items,selectedKey:'c'.repeat(64)});
|
||||
const [dialog] = f.container.children;
|
||||
assert.equal(dialog.tagName,'dialog');assert.equal(dialog.id,'viewer-dialog');assert.equal(dialog.attributes['aria-labelledby'],'viewer-title');
|
||||
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;
|
||||
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']);
|
||||
assert.equal(layout.className,'viewer-layout');
|
||||
const [left,details] = layout.children;
|
||||
assert.equal(left.className,'viewer-left');
|
||||
assert.deepEqual([details.tagName,details.id,details.attributes['aria-label']],['aside','viewer-details','Image metadata']);
|
||||
const [stage,toolbar] = left.children;
|
||||
assert.deepEqual([stage.tagName,stage.id,toolbar.tagName,toolbar.id,toolbar.className],['div','viewer-stage','div','viewer-toolbar','viewer-toolbar']);
|
||||
// #249 D34 #8: the toolbar keeps production's named group (the concept's has no role; adding one is invisible).
|
||||
assert.deepEqual([toolbar.attributes.role,toolbar.attributes['aria-label']],['group','Media controls']);
|
||||
// With more than one loaded item, Previous/Next are enabled and not described by the position line.
|
||||
for (const label of ['Previous media','Next media']) {
|
||||
const [node] = f.walk(f.container,n => n.attributes['aria-label'] === label);
|
||||
assert.deepEqual([node.attributes['aria-disabled'],node.attributes['aria-describedby']],['false',undefined],label);
|
||||
}
|
||||
// The concept's toolbar order, then production's position status and its two reasons.
|
||||
assert.deepEqual(toolbar.children.map(node => node.textContent.split(':')[0]),
|
||||
['← Previous','−','+','Fit','Rotate','Flip','Play','Download','Next →','1 of 2 loaded','Image transforms are unavailable because Viewer.js could not load.','']);
|
||||
assert.deepEqual(toolbar.children.slice(9).map(node => [node.tagName,node.attributes.role ?? null]),[['p','status'],['p',null],['p',null]]);
|
||||
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
|
||||
assert.equal(toolbar.children[9].textContent,'2 of 2 loaded: a.png');
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
|
||||
});
|
||||
|
||||
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
|
||||
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
|
||||
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
|
||||
const linked = {...occurrenceMedia('1',1,'c'),linked:{status:'linked',title:'Quiet geometry'}};
|
||||
const unlinked = {...occurrenceMedia('2',0,'d'),linked:{status:'unlinked',title:'Ignored'}};
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[linked,unlinked],selectedKey:'c'.repeat(64)});
|
||||
const title = f.walk(f.container,n=>n.id==='viewer-title')[0];
|
||||
assert.equal(title.textContent,`Quiet geometry · ${linked.filename}`);
|
||||
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
|
||||
const g = mountedDom(), scope = createScope();
|
||||
const other = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
|
||||
try {
|
||||
other.open({items:[linked,unlinked],selectedKey:'d'.repeat(64)});
|
||||
assert.equal(g.walk(g.container,n=>n.id==='viewer-title')[0].textContent,unlinked.filename);
|
||||
} finally { other.dispose(); scope.dispose(); g.restore(); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// Saved views (#249 D16): the state whitelist, the concept's default name, the listing parser and the shell's store.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const source = import('../../web/js/workspace/saved-views.mjs');
|
||||
|
||||
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
|
||||
const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] };
|
||||
|
||||
test('a saved state always holds every page key and keeps only values its page accepts', async () => {
|
||||
const { normalizeViewState, SAVED_VIEW_PAGES } = await source;
|
||||
assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']);
|
||||
assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS);
|
||||
assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS);
|
||||
const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true };
|
||||
assert.deepEqual(normalizeViewState('images', full), full);
|
||||
// D51: favorites is a boolean; anything else is the whole listing.
|
||||
for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false);
|
||||
// Each rejected value falls back on its own; the rest of the view still applies.
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }),
|
||||
{ ...full, sort: 'newest', source: 'all', media: 'all', tags: [] });
|
||||
assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }),
|
||||
{ ...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', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
|
||||
// The returned tags are a copy.
|
||||
const tags = ['one'];
|
||||
const state = normalizeViewState('library', { tags });
|
||||
state.tags.push('two');
|
||||
assert.deepEqual(tags, ['one']);
|
||||
assert.throws(() => normalizeViewState('tags', {}), TypeError);
|
||||
});
|
||||
|
||||
test('the default name is the concept search name, else My workspace view, within the name limit', async () => {
|
||||
const { defaultViewName } = await source;
|
||||
assert.equal(defaultViewName('neon'), 'Search: neon');
|
||||
assert.equal(defaultViewName(' neon city '), 'Search: neon city');
|
||||
assert.equal(defaultViewName(''), 'My workspace view');
|
||||
assert.equal(defaultViewName(' '), 'My workspace view');
|
||||
assert.equal(defaultViewName(undefined), 'My workspace view');
|
||||
assert.equal(defaultViewName('x'.repeat(500)).length, 100);
|
||||
});
|
||||
|
||||
test('the listing keeps usable views in order and normalizes their state', async () => {
|
||||
const { parseSavedViews, parseSavedView, savedViewHash } = await source;
|
||||
const views = parseSavedViews({ success: true, views: [
|
||||
{ id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } },
|
||||
{ id: 0, page: 'images', name: 'Bad id', state: {} },
|
||||
{ id: 4, page: 'tags', name: 'Bad page', state: {} },
|
||||
{ id: 5, page: 'images', name: ' ', state: {} },
|
||||
{ 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: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } },
|
||||
]);
|
||||
assert.equal(parseSavedView(null), null);
|
||||
assert.throws(() => parseSavedViews({ success: true }), TypeError);
|
||||
assert.equal(savedViewHash(12), '#/saved/12');
|
||||
});
|
||||
|
||||
test('page filters and saved states convert both ways', async () => {
|
||||
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true };
|
||||
const state = imagesViewState(filters);
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true });
|
||||
assert.deepEqual(imagesFilters(state), filters);
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false });
|
||||
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'] });
|
||||
assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf);
|
||||
});
|
||||
|
||||
function fakeApi() {
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const api = {
|
||||
calls,
|
||||
get(url, options) {
|
||||
calls.push(['GET', url, options.cache]);
|
||||
return new Promise((resolve, reject) => pending.push({ resolve, reject, signal: options.signal }));
|
||||
},
|
||||
post(url, body) {
|
||||
calls.push(['POST', url, body]);
|
||||
return Promise.resolve({ success: true, view: { id: 9, ...body } });
|
||||
},
|
||||
patch(url, body) {
|
||||
calls.push(['PATCH', url, body]);
|
||||
return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } });
|
||||
},
|
||||
del(url) {
|
||||
calls.push(['DELETE', url]);
|
||||
return api.deleteResult ?? Promise.resolve({ success: true, id: 9 });
|
||||
},
|
||||
};
|
||||
return { api, pending };
|
||||
}
|
||||
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test('the store reports loads, keeps the last views after a failed read, and a newer read supersedes', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const controller = new AbortController();
|
||||
const store = createSavedViews(api, controller.signal);
|
||||
const seen = [];
|
||||
store.subscribe(next => seen.push([next.status, next.views.map(view => view.id)]));
|
||||
store.load();
|
||||
const settled = store.settled();
|
||||
pending[0].resolve({ success: true, views: [{ id: 2, page: 'images', name: 'A', state: {} }] });
|
||||
assert.equal((await settled).status, 'ready');
|
||||
assert.deepEqual(store.find(2).state, IMAGE_DEFAULTS);
|
||||
assert.equal(store.find(3), null);
|
||||
assert.deepEqual(api.calls[0], ['GET', SAVED_VIEWS_URL, 'no-store']);
|
||||
|
||||
store.load();
|
||||
pending[1].reject(new Error('offline'));
|
||||
await flush();
|
||||
assert.equal(store.status, 'error');
|
||||
assert.deepEqual(store.views.map(view => view.id), [2]);
|
||||
|
||||
// A second read aborts the first; only the newer one lands.
|
||||
store.load();
|
||||
store.load();
|
||||
assert.equal(pending[2].signal.aborted, true);
|
||||
pending[2].resolve({ success: true, views: [] });
|
||||
pending[3].resolve({ success: true, views: [{ id: 5, page: 'library', name: 'B', state: {} }] });
|
||||
await flush();
|
||||
assert.deepEqual(store.views.map(view => view.id), [5]);
|
||||
assert.deepEqual(seen, [['loading', []], ['ready', [2]], ['loading', [2]], ['error', [2]], ['loading', [2]], ['loading', [2]], ['ready', [5]]]);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
test('creating a view posts the full state and re-reads the list', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
const created = store.create('library', 'Neon', { q: 'neon' });
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[0], ['POST', SAVED_VIEWS_URL, { page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } }]);
|
||||
pending[0].resolve({ success: true, views: [{ id: 9, page: 'library', name: 'Neon', state: { q: 'neon' } }] });
|
||||
assert.deepEqual(await created, { id: 9, page: 'library', name: 'Neon', state: { ...LIBRARY_DEFAULTS, q: 'neon' } });
|
||||
assert.equal(store.find(9).name, 'Neon');
|
||||
const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) });
|
||||
await assert.rejects(broken.create('images', 'X', {}), TypeError);
|
||||
});
|
||||
|
||||
test('renaming and deleting a view re-read the list, whatever the outcome', async () => {
|
||||
const { createSavedViews, SAVED_VIEWS_URL } = await source;
|
||||
const { api, pending } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
const renamed = store.rename(9, 'Dusk');
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]);
|
||||
assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']);
|
||||
pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] });
|
||||
assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS });
|
||||
assert.equal(store.find(9).name, 'Dusk');
|
||||
|
||||
const removed = store.remove(9);
|
||||
await flush();
|
||||
assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]);
|
||||
pending[1].resolve({ success: true, views: [] });
|
||||
assert.equal(await removed, 9);
|
||||
assert.equal(store.find(9), null);
|
||||
|
||||
// A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read.
|
||||
api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 }));
|
||||
const gone = store.remove(4);
|
||||
await flush();
|
||||
pending[2].resolve({ success: true, views: [] });
|
||||
assert.equal(await gone, 4);
|
||||
api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 }));
|
||||
const failed = store.remove(4);
|
||||
await flush();
|
||||
pending[3].resolve({ success: true, views: [] });
|
||||
await assert.rejects(failed, /Locked/);
|
||||
api.patchResult = Promise.resolve({ success: true, view: null });
|
||||
const invalid = store.rename(4, 'X');
|
||||
await flush();
|
||||
pending[4].resolve({ success: true, views: [] });
|
||||
await assert.rejects(invalid, TypeError);
|
||||
assert.equal(pending.length, 5);
|
||||
assert.throws(() => store.rename(0, 'X'), TypeError);
|
||||
assert.throws(() => store.remove('9'), TypeError);
|
||||
});
|
||||
|
||||
test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)', async () => {
|
||||
const { createSavedViews, stateFits, SavedViewTooLarge, MAX_STATE_BYTES } = await source;
|
||||
assert.equal(MAX_STATE_BYTES, 4096);
|
||||
assert.equal(stateFits(IMAGE_DEFAULTS), true);
|
||||
// Each value is within its own limit; their multibyte UTF-8 together passes 4 KB.
|
||||
const big = { ...IMAGE_DEFAULTS, q: 'é'.repeat(500), folder: 'é'.repeat(1024), tags: Array.from({ length: 20 }, () => 'é'.repeat(50)) };
|
||||
assert.equal(stateFits(big), false);
|
||||
const { api } = fakeApi();
|
||||
const store = createSavedViews(api);
|
||||
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
|
||||
assert.deepEqual(api.calls, []);
|
||||
});
|
||||
@@ -79,18 +79,19 @@ test('positive library deep links preserve the selected identity without duplica
|
||||
|
||||
test('nav counts reject negative, non-integer and missing totals', async () => {
|
||||
const { summarizeNavCounts } = await source;
|
||||
const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}});
|
||||
const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}});
|
||||
assert.ok(counts instanceof Map);
|
||||
assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]);
|
||||
assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]);
|
||||
assert.equal(counts.has('inspect'), false, 'Inspect a file has no count');
|
||||
for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN},
|
||||
{total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) {
|
||||
const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid});
|
||||
const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid});
|
||||
assert.equal(result.get('images'), null, JSON.stringify(invalid));
|
||||
assert.equal(result.get('favorites'), null);
|
||||
assert.equal(result.get('library'), 2);
|
||||
assert.equal(result.get('tags'), null);
|
||||
}
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]);
|
||||
assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts settle independently so a slow images read never delays the others', async () => {
|
||||
@@ -98,6 +99,7 @@ test('nav counts settle independently so a slow images read never delays the oth
|
||||
let releaseImages;
|
||||
const images = new Promise(resolve => { releaseImages = resolve; });
|
||||
const api = {get: async url => {
|
||||
if (url.includes('favorites=1')) return {success: true, total: 2};
|
||||
if (url.startsWith('/prompt_manager/images')) return images;
|
||||
if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3};
|
||||
throw new Error('Unavailable');
|
||||
@@ -105,14 +107,14 @@ test('nav counts settle independently so a slow images read never delays the oth
|
||||
const settled = [];
|
||||
const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]);
|
||||
assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
releaseImages({success: true, total: 40});
|
||||
const counts = await done;
|
||||
assert.deepEqual(settled.at(-1), ['images', 40]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]);
|
||||
assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]);
|
||||
});
|
||||
|
||||
test('nav counts issue three bounded requests and drop only the failed count', async () => {
|
||||
test('nav counts issue four bounded requests and drop only the failed count', async () => {
|
||||
const { loadNavCounts } = await source;
|
||||
const calls = [];
|
||||
const signal = new AbortController().signal;
|
||||
@@ -123,15 +125,42 @@ test('nav counts issue three bounded requests and drop only the failed count', a
|
||||
return {success: true, total: 12};
|
||||
}};
|
||||
const counts = await loadNavCounts(api, signal);
|
||||
assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]);
|
||||
assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]);
|
||||
const urls = calls.map(([url]) => new URL(url, 'http://host'));
|
||||
assert.deepEqual(urls.map(url => url.pathname),
|
||||
['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
|
||||
for (const url of urls) assert.equal(url.searchParams.get('limit'), '1');
|
||||
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
|
||||
assert.equal(urls[0].searchParams.has('favorites'), false);
|
||||
// D51: the Favorites count is the Favorites page's own listing.
|
||||
assert.equal(urls[1].searchParams.get('mode'), 'workspace');
|
||||
assert.equal(urls[1].searchParams.get('favorites'), '1');
|
||||
assert.ok(calls.every(([, used]) => used === signal));
|
||||
});
|
||||
|
||||
test('a favorite change re-reads only the Favorites count', async () => {
|
||||
const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']);
|
||||
assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS));
|
||||
const calls = [];
|
||||
const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }};
|
||||
const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS);
|
||||
assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']);
|
||||
// A real zero is a count, unlike an outstanding read.
|
||||
assert.deepEqual([...counts], [['favorites', 0]]);
|
||||
});
|
||||
|
||||
test('the Favorites route is native and resolves by its hash', async () => {
|
||||
const { resolveRoute, isHandoff, routeTitle, routes } = await source;
|
||||
const route = resolveRoute('#/favorites');
|
||||
assert.equal(route.id, 'favorites');
|
||||
assert.equal(route.label, 'Favorites');
|
||||
assert.equal(routeTitle(route), 'Favorites');
|
||||
assert.equal(isHandoff(route), false);
|
||||
// No href: Overview lists no legacy card for it.
|
||||
assert.equal(routes.find(item => item.id === 'favorites').href, undefined);
|
||||
});
|
||||
|
||||
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
|
||||
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
|
||||
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
|
||||
@@ -147,3 +176,37 @@ test('a Library or vocabulary write re-reads only the Library and Tags counts',
|
||||
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
|
||||
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
|
||||
});
|
||||
|
||||
test('navigating to the current route re-renders it, as the concept go() does', async () => {
|
||||
const {mountRouter,navigateTo}=await source;
|
||||
const win=new EventTarget();win.location={hash:'#/library/12'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,route=>seen.push([route.id,route.positiveId]));
|
||||
navigateTo('#/library/12',win);
|
||||
assert.deepEqual(seen,[['library',12],['library',12]]);
|
||||
// A different route only sets the hash; its hashchange renders it once.
|
||||
navigateTo('#/library/13',win);assert.equal(win.location.hash,'#/library/13');assert.equal(seen.length,2);
|
||||
win.dispatchEvent(new Event('hashchange'));assert.deepEqual(seen.at(-1),['library',13]);
|
||||
dispose();navigateTo('#/library/13',win);assert.equal(seen.length,3);
|
||||
});
|
||||
|
||||
// Saved views (#249 D16): `#/saved/<id>` opens one persisted view; the shell resolves it to its page.
|
||||
test('saved-view links resolve to their id and re-render only when the id changes', async () => {
|
||||
const {resolveRoute,mountRouter,isHandoff,routes}=await source;
|
||||
const route=resolveRoute('#/saved/7');
|
||||
assert.deepEqual([route.id,route.savedId,route.hash],['saved',7,'#/saved/7']);
|
||||
assert.equal(isHandoff(route),false);
|
||||
assert.equal(routes.some(item=>item.id==='saved'),false,'saved views are not an Overview card or a WORKSPACE link');
|
||||
for(const hash of ['#/saved','#/saved/0','#/saved/-2','#/saved/1.5','#/saved/9007199254740992','#/saved/3/x','#/saved/abc']) {
|
||||
assert.equal(resolveRoute(hash).id,'images',hash);
|
||||
}
|
||||
const win=new EventTarget();win.location={hash:'#/saved/3'};
|
||||
win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}};
|
||||
const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.savedId]));
|
||||
assert.equal(win.location.hash,'#/saved/3');
|
||||
win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange'));
|
||||
win.location.hash='#/images';win.dispatchEvent(new Event('hashchange'));
|
||||
assert.deepEqual(seen,[['saved',3],['saved',4],['images',undefined]]);
|
||||
dispose();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
// The shared concept toolbar and dialogs (#249 C1): concept app.js toolbar() a347–360, "Refine this view"
|
||||
// a1348–1364, "Import & inspect" a1483–1491 (D15) and the "Copy prompt" fallback a1223–1241.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const toolbarModule = import('../../web/js/workspace/toolbar.mjs');
|
||||
const dialogsModule = import('../../web/js/workspace/workspace-dialogs.mjs');
|
||||
|
||||
function optionsCollection(list) {
|
||||
const collection = { length: list.length, item: index => list[index] ?? null, [Symbol.iterator]: () => list[Symbol.iterator]() };
|
||||
list.forEach((option, index) => { collection[index] = option; });
|
||||
return Object.freeze(collection);
|
||||
}
|
||||
|
||||
/** A minimal DOM: enough element behavior for markup, events, focus and dialogs. */
|
||||
function fakeDOM() {
|
||||
const previous = { document: global.document };
|
||||
let active = null;
|
||||
class Element extends EventTarget {
|
||||
constructor(tag) {
|
||||
super();
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.parentNode = null;
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
this.selected = false;
|
||||
this.selectionStart = 0;
|
||||
this.selectionEnd = 0;
|
||||
this._text = '';
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
get isConnected() { return this === body || Boolean(this.parentNode?.isConnected); }
|
||||
// Like a browser's HTMLOptionsCollection: indexed and iterable, but not an Array (no .some/.map).
|
||||
get options() { return optionsCollection(this.children.filter(child => child.tagName === 'OPTION')); }
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
node.parentNode?.children.splice(node.parentNode.children.indexOf(node), 1);
|
||||
node.parentNode = this;
|
||||
this.children.push(node);
|
||||
}
|
||||
}
|
||||
replaceChildren(...nodes) {
|
||||
for (const child of this.children) child.parentNode = null;
|
||||
this.children = [];
|
||||
this._text = '';
|
||||
this.append(...nodes);
|
||||
}
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||||
this.parentNode = null;
|
||||
}
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
focus() { active = this; }
|
||||
select() { this.selectionStart = 0; this.selectionEnd = this.value.length; }
|
||||
showModal() { this.open = true; }
|
||||
close() {
|
||||
if (!this.open) return;
|
||||
this.open = false;
|
||||
// Browsers queue the close event as a later task.
|
||||
setTimeout(() => this.dispatchEvent(new Event('close')), 0);
|
||||
}
|
||||
click() { this.dispatchEvent(new Event('click', { cancelable: true })); }
|
||||
}
|
||||
const body = new Element('body');
|
||||
global.document = {
|
||||
body,
|
||||
get activeElement() { return active ?? body; },
|
||||
createElement: tag => new Element(tag),
|
||||
createElementNS: (namespace, tag) => new Element(tag),
|
||||
};
|
||||
const all = (root, predicate, found = []) => {
|
||||
if (predicate(root)) found.push(root);
|
||||
for (const child of root.children) all(child, predicate, found);
|
||||
return found;
|
||||
};
|
||||
return {
|
||||
body, Element,
|
||||
all: predicate => all(body, predicate),
|
||||
find: (root, predicate) => all(root, predicate)[0],
|
||||
restore() { Object.assign(global, previous); },
|
||||
};
|
||||
}
|
||||
|
||||
const byId = (dom, id) => dom.all(node => node.id === id)[0];
|
||||
const tags = node => node.children.map(child => child.tagName.toLowerCase());
|
||||
const optionPairs = select => [...select.options].map(option => [option.value, option.textContent]);
|
||||
const modal = dom => byId(dom, 'modal');
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 5));
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
const button = (root, text) => {
|
||||
const found = [];
|
||||
const walk = node => {
|
||||
if (node.tagName === 'BUTTON' && node.textContent === text) found.push(node);
|
||||
node.children.forEach(walk);
|
||||
};
|
||||
walk(root);
|
||||
return found[0];
|
||||
};
|
||||
|
||||
test('toolbar emits concept markup and order', async () => {
|
||||
const { mountToolbar } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
dom.body.append(container);
|
||||
const changes = [];
|
||||
const filters = [];
|
||||
const saves = [];
|
||||
const toolbar = mountToolbar(container, {
|
||||
scope, categories: ['Portraits', 'Landscapes'],
|
||||
sorts: [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated']],
|
||||
value: { q: 'castle', category: 'Landscapes', sort: 'name' },
|
||||
onChange: value => changes.push(value), onFilters: trigger => filters.push(trigger),
|
||||
onSaveView: trigger => saves.push(trigger),
|
||||
});
|
||||
const root = container.children[0];
|
||||
assert.equal(toolbar.element, root);
|
||||
assert.equal(root.tagName, 'SEARCH');
|
||||
assert.equal(root.className, 'toolbar');
|
||||
assert.deepEqual(tags(root), ['label', 'select', 'select', 'button', 'button']);
|
||||
|
||||
const [label, category, sort, saveButton, filtersButton] = root.children;
|
||||
assert.equal(label.className, 'search-field');
|
||||
assert.equal(label.htmlFor, 'search');
|
||||
assert.deepEqual(tags(label), ['svg', 'input']);
|
||||
assert.equal(label.children[0].getAttribute('class'), 'icon');
|
||||
assert.equal(label.children[0].getAttribute('aria-hidden'), 'true');
|
||||
const input = label.children[1];
|
||||
assert.equal(input.id, 'search');
|
||||
assert.equal(input.type, 'search');
|
||||
assert.equal(input.placeholder, 'Search prompts, tags, filenames…');
|
||||
// The listing API rejects search text over 500 characters, as the legacy search capped it.
|
||||
assert.equal(input.maxLength, 500);
|
||||
assert.equal(input.getAttribute('aria-label'), 'Search workspace');
|
||||
assert.equal(input.value, 'castle');
|
||||
|
||||
assert.equal(category.id, 'category-filter');
|
||||
assert.equal(category.getAttribute('aria-label'), 'Filter by category');
|
||||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Landscapes', 'Landscapes']]);
|
||||
assert.equal(category.value, 'Landscapes');
|
||||
assert.equal(sort.id, 'sort');
|
||||
assert.equal(sort.getAttribute('aria-label'), 'Sort results');
|
||||
assert.equal(sort.value, 'name');
|
||||
|
||||
// The concept's Save view, between sort and Filters (a359), reports its own button as the trigger.
|
||||
assert.equal(button(root, 'Save view'), saveButton);
|
||||
assert.equal(saveButton.type, 'button');
|
||||
saveButton.click();
|
||||
assert.deepEqual(saves, [saveButton]);
|
||||
assert.deepEqual(filters, []);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.equal(filtersButton.type, 'button');
|
||||
filtersButton.click();
|
||||
assert.deepEqual(filters, [filtersButton]);
|
||||
|
||||
category.value = '';
|
||||
category.dispatchEvent(new Event('change'));
|
||||
sort.value = 'rating';
|
||||
sort.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(changes, [
|
||||
{ q: 'castle', category: '', sort: 'name' },
|
||||
{ q: 'castle', category: '', sort: 'rating' },
|
||||
]);
|
||||
|
||||
// Categories refresh without dropping a still-listed selection.
|
||||
toolbar.setCategories(['Archive']);
|
||||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Archive', 'Archive']]);
|
||||
toolbar.setValue({ q: 'moon', category: 'Archive', sort: 'newest' });
|
||||
assert.deepEqual([input.value, category.value, sort.value], ['moon', 'Archive', 'newest']);
|
||||
assert.deepEqual(toolbar.value(), { q: 'moon', category: 'Archive', sort: 'newest' });
|
||||
// A still-listed selection survives a category refresh.
|
||||
toolbar.setCategories(['Portraits', 'Archive']);
|
||||
assert.equal(category.value, 'Archive');
|
||||
assert.deepEqual(optionPairs(category), [['', 'All categories'], ['Portraits', 'Portraits'], ['Archive', 'Archive']]);
|
||||
// A selection that is no longer listed drops to All categories.
|
||||
toolbar.setCategories(['Portraits']);
|
||||
assert.equal(category.value, '');
|
||||
// An unknown sort falls back to the first option instead of an empty select.
|
||||
toolbar.setValue({ sort: 'bogus' });
|
||||
assert.equal(sort.value, 'newest');
|
||||
assert.throws(() => mountToolbar(container, { categories: [], sorts: [['newest', 'Recently added']],
|
||||
onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||||
// Every toolbar page offers Save view, so the callback is required.
|
||||
assert.throws(() => mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
onChange() {}, onFilters() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('toolbar search uses a debounced onChange that stops with its scope', async () => {
|
||||
const { mountToolbar, SEARCH_DELAY } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
const changes = [];
|
||||
mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
value: { q: '', category: '', sort: 'newest' }, onChange: value => changes.push(value), onFilters() {}, onSaveView() {} });
|
||||
const input = container.children[0].children[0].children[1];
|
||||
assert.equal(input.id, 'search');
|
||||
for (const text of ['c', 'ca', 'cat']) {
|
||||
input.value = text;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
}
|
||||
assert.deepEqual(changes, []);
|
||||
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
|
||||
assert.deepEqual(changes, [{ q: 'cat', category: '', sort: 'newest' }]);
|
||||
input.value = 'dog';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
scope.dispose();
|
||||
await new Promise(resolve => setTimeout(resolve, SEARCH_DELAY + 30));
|
||||
assert.equal(changes.length, 1);
|
||||
assert.equal(container.children.length, 0);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('sort lists concept options first', async () => {
|
||||
const { mountToolbar, orderSorts } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
// D14: the concept's three, in the concept's order, then the page's appended options in their own order.
|
||||
const given = [['newest', 'Recently added'], ['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A'],
|
||||
['rating', 'Highest rated'], ['name', 'Name A–Z']];
|
||||
assert.deepEqual(orderSorts(given), [['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
|
||||
['oldest', 'Oldest first'], ['name-desc', 'Filename Z–A']]);
|
||||
// A page without a concept option keeps the ones it has, concept first.
|
||||
assert.deepEqual(orderSorts([['oldest', 'Oldest first'], ['newest', 'Recently added']]),
|
||||
[['newest', 'Recently added'], ['oldest', 'Oldest first']]);
|
||||
// Matching is by the concept's exact labels (en dash): a variant label is an appended option.
|
||||
assert.deepEqual(orderSorts([['name', 'Name A-Z'], ['newest', 'Recently added']]),
|
||||
[['newest', 'Recently added'], ['name', 'Name A-Z']]);
|
||||
assert.throws(() => orderSorts([]), TypeError);
|
||||
assert.throws(() => orderSorts([['newest']]), TypeError);
|
||||
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
mountToolbar(container, { scope, categories: [], sorts: given, value: { q: '', category: '', sort: 'oldest' },
|
||||
onChange() {}, onFilters() {}, onSaveView() {} });
|
||||
const sort = container.children[0].children[2];
|
||||
assert.deepEqual(optionPairs(sort).map(([, label]) => label),
|
||||
['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Filename Z–A']);
|
||||
assert.equal(sort.value, 'oldest');
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
const REFINE_FIELDS = [
|
||||
{ key: 'source', options: [['all', 'All sources'], ['0', 'output'], ['1', 'archive']] },
|
||||
{ key: 'folder', options: [['', 'All output folders'], ['portraits', 'portraits']] },
|
||||
{ key: 'media', options: [['all', 'All media'], ['image', 'Images'], ['video', 'Videos']] },
|
||||
{ key: 'tags' },
|
||||
];
|
||||
|
||||
test('refine dialog round-trips values and clear resets them', async () => {
|
||||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
const applied = [];
|
||||
const cleared = [];
|
||||
const values = { source: '1', folder: 'portraits', media: 'video', tags: 'cinematic' };
|
||||
openRefineDialog({ scope, fields: REFINE_FIELDS, values, trigger,
|
||||
onApply: value => applied.push(value), onClear: value => cleared.push(value) });
|
||||
const dialog = modal(dom);
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Refine this view');
|
||||
const body = byId(dom, 'modal-body');
|
||||
const fields = body.children.filter(child => child.className === 'field');
|
||||
assert.deepEqual(fields.map(field => field.children[0].textContent), ['Source', 'Folder', 'Media', 'Tags']);
|
||||
assert.deepEqual(fields.map(field => field.children[0].htmlFor), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
|
||||
assert.deepEqual(fields.map(field => field.children[1].id), ['filter-source', 'filter-folder', 'filter-media', 'filter-tags']);
|
||||
const [source, folder, media, tagsInput] = fields.map(field => field.children[1]);
|
||||
assert.deepEqual(optionPairs(source), REFINE_FIELDS[0].options);
|
||||
assert.deepEqual([source.value, folder.value, media.value, tagsInput.value], ['1', 'portraits', 'video', 'cinematic']);
|
||||
assert.equal(tagsInput.placeholder, 'e.g. cinematic');
|
||||
// D14: no mock-status note, because every field is a real filter.
|
||||
assert.deepEqual(tags(body), ['div', 'div', 'div', 'div', 'div']);
|
||||
assert.equal(body.children.some(child => child.tagName === 'P'), false);
|
||||
const actions = body.children[4];
|
||||
assert.equal(actions.className, 'dialog-actions');
|
||||
assert.deepEqual(actions.children.map(child => [child.textContent, child.className]),
|
||||
[['Clear filters', ''], ['Apply filters', 'ca-primary']]);
|
||||
|
||||
// Apply returns the values as edited and leaves the caller's object alone.
|
||||
media.value = 'image';
|
||||
tagsInput.value = ' moody ';
|
||||
button(body, 'Apply filters').click();
|
||||
assert.deepEqual(applied, [{ source: '1', folder: 'portraits', media: 'image', tags: 'moody' }]);
|
||||
assert.equal(values.media, 'video');
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(trigger.isConnected && document.activeElement, trigger);
|
||||
|
||||
// Reopening with the applied values shows them again (a round trip).
|
||||
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value), onClear() {} });
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied[1], applied[0]);
|
||||
|
||||
// Clear resets every field to its first option (Tags to empty) and reports that.
|
||||
openRefineDialog({ scope, fields: REFINE_FIELDS, values: applied[0], trigger, onApply: value => applied.push(value),
|
||||
onClear: value => cleared.push(value) });
|
||||
const reopened = byId(dom, 'modal-body');
|
||||
button(reopened, 'Clear filters').click();
|
||||
// The concept's Clear also clears the toolbar category; the page applies `category`.
|
||||
assert.deepEqual(cleared, [{ source: 'all', folder: '', media: 'all', tags: '', category: '' }]);
|
||||
assert.equal(applied.length, 2);
|
||||
assert.equal(modal(dom).open, false);
|
||||
|
||||
// A page offers only its own fields; the modal is the one shared shell.
|
||||
openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, onApply: value => applied.push(value), onClear() {} });
|
||||
const only = byId(dom, 'modal-body').children.filter(child => child.className === 'field');
|
||||
assert.deepEqual(only.map(field => field.children[0].textContent), ['Tags']);
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied[2], { tags: '' });
|
||||
assert.equal(dom.all(node => node.id === 'modal').length, 1);
|
||||
assert.throws(() => openRefineDialog({ scope, fields: [{ key: 'evidence' }], values: {}, onApply() {}, onClear() {} }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Folder list follows the chosen Source', async () => {
|
||||
const { openRefineDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const bySource = { all: ['portraits', 'temp-only'], 0: ['portraits'], 1: ['temp-only'] };
|
||||
const optionsFor = source => [['', 'All output folders'], ...(bySource[source] ?? []).map(path => [path, path])];
|
||||
const fields = [REFINE_FIELDS[0], { key: 'folder', options: optionsFor('all'), optionsFor }, REFINE_FIELDS[2]];
|
||||
const applied = [];
|
||||
openRefineDialog({ scope, fields, values: { source: '1', folder: 'temp-only', media: 'all' },
|
||||
onApply: value => applied.push(value), onClear() {} });
|
||||
const [source, folder] = byId(dom, 'modal-body').children.filter(child => child.className === 'field')
|
||||
.map(field => field.children[1]);
|
||||
// Opened on a chosen Source: only its folders.
|
||||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['temp-only', 'temp-only']]);
|
||||
assert.equal(folder.value, 'temp-only');
|
||||
// Another Source drops a folder it does not have.
|
||||
source.value = '0';
|
||||
source.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(optionPairs(folder), [['', 'All output folders'], ['portraits', 'portraits']]);
|
||||
assert.equal(folder.value, '');
|
||||
folder.value = 'portraits';
|
||||
// A folder the next Source also has stays chosen.
|
||||
source.value = 'all';
|
||||
source.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(optionPairs(folder).map(([value]) => value), ['', 'portraits', 'temp-only']);
|
||||
assert.equal(folder.value, 'portraits');
|
||||
button(byId(dom, 'modal-body'), 'Apply filters').click();
|
||||
assert.deepEqual(applied, [{ source: 'all', folder: 'portraits', media: 'all' }]);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a running rescan marks Rescan busy and ignores a second press', async () => {
|
||||
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const calls = [];
|
||||
const handle = openImportDialog({ scope, rescanBusy: true, onInspect: () => calls.push('inspect'),
|
||||
onRescan: () => calls.push('rescan') });
|
||||
const body = byId(dom, 'modal-body');
|
||||
const busy = button(body, 'Rescanning…');
|
||||
assert.ok(busy, 'the busy Rescan says it is running');
|
||||
assert.equal(busy.getAttribute('aria-disabled'), 'true');
|
||||
busy.click();
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(modal(dom).open, true, 'an ignored press keeps the dialog open');
|
||||
handle.setRescanBusy(false);
|
||||
assert.equal(busy.textContent, 'Rescan');
|
||||
assert.equal(busy.getAttribute('aria-disabled'), null);
|
||||
busy.click();
|
||||
assert.deepEqual(calls, ['rescan']);
|
||||
assert.equal(modal(dom).open, false);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('import dialog lists only real operations', async () => {
|
||||
const { openImportDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const calls = [];
|
||||
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||||
const body = byId(dom, 'modal-body');
|
||||
assert.deepEqual(tags(body), ['p', 'div']);
|
||||
assert.equal(body.children[0].className, 'small');
|
||||
const list = body.children[1];
|
||||
assert.equal(list.className, 'operation-list');
|
||||
const rows = list.children.map(row => {
|
||||
assert.equal(row.className, 'operation');
|
||||
const [text, actions] = row.children;
|
||||
assert.deepEqual(tags(text), ['h3', 'p']);
|
||||
assert.equal(actions.className, 'row-actions');
|
||||
return [text.children[0].textContent, text.children[1].textContent, actions.children[0].textContent];
|
||||
});
|
||||
// D15: no "Import portable recipes" until import exists; Rescan finds added or removed files.
|
||||
assert.deepEqual(rows, [
|
||||
['Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector'],
|
||||
['Scan an output folder', 'Find files added or removed in the output folders.', 'Rescan'],
|
||||
]);
|
||||
assert.doesNotMatch(body.textContent, /recipe|portable|sample/i);
|
||||
|
||||
button(body, 'Rescan').click();
|
||||
assert.deepEqual(calls, ['rescan']);
|
||||
assert.equal(modal(dom).open, false);
|
||||
openImportDialog({ scope, onInspect: () => calls.push('inspect'), onRescan: () => calls.push('rescan') });
|
||||
button(byId(dom, 'modal-body'), 'Open inspector').click();
|
||||
assert.deepEqual(calls, ['rescan', 'inspect']);
|
||||
assert.equal(modal(dom).open, false);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('copy fallback selects the text', async () => {
|
||||
const { openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const text = '<b>a castle</b>, "moonlight" & fog';
|
||||
openCopyFallback(text, { scope });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||||
const body = byId(dom, 'modal-body');
|
||||
assert.deepEqual(tags(body), ['div', 'p']);
|
||||
const [field, note] = body.children;
|
||||
assert.equal(field.className, 'field');
|
||||
const [label, area] = field.children;
|
||||
assert.equal(label.textContent, 'Select and copy this text');
|
||||
assert.equal(label.htmlFor, 'copy-text');
|
||||
assert.equal(area.tagName, 'TEXTAREA');
|
||||
assert.equal(area.id, 'copy-text');
|
||||
assert.equal(area.readOnly, true);
|
||||
assert.equal(area.value, text);
|
||||
assert.deepEqual([area.selectionStart, area.selectionEnd], [0, text.length]);
|
||||
assert.equal(document.activeElement, area);
|
||||
assert.equal(note.className, 'small');
|
||||
assert.equal(note.textContent, 'Automatic clipboard access requires a secure browser context.');
|
||||
assert.throws(() => openCopyFallback(42, { scope }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// The concept's Save view dialog (a1330–1346 via simpleInput a1138): an empty name falls back to "Saved view".
|
||||
test('an input dialog with a fallback applies it for an empty value', async () => {
|
||||
const { openInputDialog, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const applied = [];
|
||||
const open = value => openInputDialog({ scope, title: 'Save this view', label: 'View name', action: 'Save',
|
||||
value, maxLength: 100, fallback: 'Saved view', onApply: name => applied.push(name) });
|
||||
open('My workspace view');
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Save this view');
|
||||
const body = byId(dom, 'modal-body');
|
||||
const [field, actions] = body.children;
|
||||
const [label, input] = field.children;
|
||||
assert.equal(label.textContent, 'View name');
|
||||
assert.equal(input.id, 'action-input');
|
||||
assert.equal(input.value, 'My workspace view');
|
||||
assert.equal(input.maxLength, 100);
|
||||
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Save']);
|
||||
input.value = ' ';
|
||||
actions.children[1].click();
|
||||
assert.deepEqual(applied, ['Saved view']);
|
||||
open('');
|
||||
byId(dom, 'action-input').value = ' Castles ';
|
||||
byId(dom, 'modal-body').children[1].children[1].click();
|
||||
assert.deepEqual(applied, ['Saved view', 'Castles']);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', fallback: '', onApply() {} }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs restore focus to the trigger on Escape', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const opens = [
|
||||
trigger => openRefineDialog({ scope, fields: [{ key: 'tags' }], values: {}, trigger, onApply() {}, onClear() {} }),
|
||||
trigger => openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} }),
|
||||
trigger => openCopyFallback('text', { trigger, scope }),
|
||||
];
|
||||
for (const open of opens) {
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
open(trigger);
|
||||
const dialog = modal(dom);
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(dialog.getAttribute('aria-labelledby'), 'modal-title');
|
||||
const header = dialog.children[0];
|
||||
assert.equal(header.className, 'dialog-header');
|
||||
assert.deepEqual(tags(header), ['h2', 'button']);
|
||||
const close = header.children[1];
|
||||
assert.equal(close.id, 'modal-close');
|
||||
assert.equal(close.textContent, '×');
|
||||
assert.equal(close.getAttribute('aria-label'), 'Close dialog');
|
||||
assert.notEqual(document.activeElement, trigger);
|
||||
// Escape fires `cancel` on a modal dialog.
|
||||
const escape = new Event('cancel', { cancelable: true });
|
||||
dialog.dispatchEvent(escape);
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
}
|
||||
|
||||
// × closes and restores focus too; a removed trigger leaves focus alone.
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||||
byId(dom, 'modal-close').click();
|
||||
assert.equal(modal(dom).open, false);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||||
trigger.remove();
|
||||
const close = byId(dom, 'modal-close');
|
||||
close.click();
|
||||
assert.notEqual(document.activeElement, trigger);
|
||||
|
||||
// Opening over an open dialog swaps its content and keeps the first trigger; dispose removes the shell.
|
||||
const first = new dom.Element('button');
|
||||
dom.body.append(first);
|
||||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||||
openCopyFallback('swap', { scope });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
assert.equal(document.activeElement, first);
|
||||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||||
closeWorkspaceDialog();
|
||||
assert.equal(modal(dom), undefined);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an action may open the next dialog without the late close event closing it', async () => {
|
||||
const { openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const first = new dom.Element('button');
|
||||
const second = new dom.Element('button');
|
||||
dom.body.append(first, second);
|
||||
openImportDialog({ scope, trigger: first, onInspect() {},
|
||||
onRescan: () => openCopyFallback('next', { scope, trigger: second }) });
|
||||
button(byId(dom, 'modal-body'), 'Rescan').click();
|
||||
await tick();
|
||||
assert.equal(modal(dom).open, true);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Copy prompt');
|
||||
assert.equal(document.activeElement, byId(dom, 'copy-text'));
|
||||
// Its own Escape still restores focus to its own trigger.
|
||||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
await tick();
|
||||
assert.equal(document.activeElement, second);
|
||||
// Replacing the shell ignores the old shell's late close event too.
|
||||
openImportDialog({ scope, trigger: first, onInspect() {}, onRescan() {} });
|
||||
closeWorkspaceDialog();
|
||||
openImportDialog({ scope, trigger: second, onInspect() {}, onRescan() {} });
|
||||
await tick();
|
||||
assert.equal(modal(dom).open, true);
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'Import & inspect');
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a content dialog shows page nodes and releases them once on close or replacement', async () => {
|
||||
const { openContentDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
let closed = 0;
|
||||
const grid = new dom.Element('div');
|
||||
openContentDialog({ scope, title: 'All images for this positive', nodes: [grid], trigger, onClose: () => closed++ });
|
||||
assert.equal(byId(dom, 'modal-title').textContent, 'All images for this positive');
|
||||
assert.deepEqual(byId(dom, 'modal-body').children, [grid]);
|
||||
assert.equal(modal(dom).open, true);
|
||||
modal(dom).dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
await tick();
|
||||
assert.equal(closed, 1);
|
||||
// Another dialog replacing the body releases it too, exactly once.
|
||||
openContentDialog({ scope, title: 'Repeat run', nodes: [new dom.Element('p')], trigger, onClose: () => closed++ });
|
||||
openCopyFallback('swap', { scope });
|
||||
assert.equal(closed, 2);
|
||||
closeWorkspaceDialog();
|
||||
assert.equal(closed, 2);
|
||||
// Disposing the page scope closes it.
|
||||
openContentDialog({ scope, title: 'Repeat run', nodes: [], onClose: () => closed++ });
|
||||
scope.dispose();
|
||||
assert.equal(closed, 3);
|
||||
assert.throws(() => openContentDialog({ title: 'x', nodes: [] }), /page scope/);
|
||||
const other = (await coreModule).createScope();
|
||||
assert.throws(() => openContentDialog({ scope: other, title: '', nodes: [] }), TypeError);
|
||||
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [null] }), TypeError);
|
||||
assert.throws(() => openContentDialog({ scope: other, title: 'x', nodes: [], onClose: 1 }), TypeError);
|
||||
other.dispose();
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs need the page scope and close with it', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
try {
|
||||
assert.throws(() => openImportDialog({ onInspect() {}, onRescan() {} }), /page scope/);
|
||||
assert.throws(() => openRefineDialog({ fields: [], onApply() {}, onClear() {} }), /page scope/);
|
||||
assert.throws(() => openCopyFallback('text'), /page scope/);
|
||||
const scope = createScope();
|
||||
assert.throws(() => openImportDialog({ scope, onInspect() {} }), TypeError);
|
||||
assert.throws(() => openRefineDialog({ scope, fields: [], onApply() {} }), TypeError);
|
||||
assert.equal(modal(dom), undefined);
|
||||
|
||||
// Disposing the page scope closes and removes the shell, however often the page opened it.
|
||||
const trigger = new dom.Element('button');
|
||||
dom.body.append(trigger);
|
||||
openImportDialog({ scope, trigger, onInspect() {}, onRescan() {} });
|
||||
openCopyFallback('again', { scope });
|
||||
scope.dispose();
|
||||
assert.equal(modal(dom), undefined);
|
||||
assert.equal(document.activeElement, trigger);
|
||||
|
||||
// An old page's scope never closes the next page's dialog.
|
||||
const older = createScope();
|
||||
const newer = createScope();
|
||||
openImportDialog({ scope: older, onInspect() {}, onRescan() {} });
|
||||
openCopyFallback('next page', { scope: newer });
|
||||
older.dispose();
|
||||
assert.equal(modal(dom).open, true);
|
||||
newer.dispose();
|
||||
assert.equal(modal(dom), undefined);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('every route change closes the shared workspace dialog', async () => {
|
||||
const { readFile } = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const source = await readFile(path.join(__dirname, '../../web/js/workspace/index.mjs'), 'utf8');
|
||||
assert.match(source, /import \{[^}]*\bcloseWorkspaceDialog\b[^}]*\} from '\.\/workspace-dialogs\.mjs';/);
|
||||
const render = source.slice(source.indexOf('function render(route) {'));
|
||||
const body = render.slice(0, render.indexOf('viewScope = createScope();'));
|
||||
assert.match(body, /closeWorkspaceDialog\(\);/, 'render closes the dialog before the next page mounts');
|
||||
});
|
||||
|
||||
// D44: the concept has no indicator, so a filter set in "Refine this view" (or by Browse) was invisible on the page.
|
||||
test('the Filters button shows the active refinement count', async () => {
|
||||
const { mountToolbar } = await toolbarModule;
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
const dom = fakeDOM();
|
||||
const scope = createScope();
|
||||
try {
|
||||
const container = new dom.Element('div');
|
||||
const toolbar = mountToolbar(container, { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: 2, onChange() {}, onFilters() {}, onSaveView() {} });
|
||||
const filtersButton = container.children[0].children[4];
|
||||
// The visible text is the accessible name, so the count is spoken too.
|
||||
assert.equal(filtersButton.textContent, 'Filters · 2');
|
||||
toolbar.setActiveFilters(1);
|
||||
assert.equal(filtersButton.textContent, 'Filters · 1');
|
||||
toolbar.setActiveFilters(0);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
for (const bad of [-1, 1.5, '1', null, Number.NaN]) assert.throws(() => toolbar.setActiveFilters(bad), TypeError);
|
||||
assert.equal(filtersButton.textContent, 'Filters');
|
||||
assert.throws(() => mountToolbar(new dom.Element('div'), { scope, categories: [], sorts: [['newest', 'Recently added']],
|
||||
activeFilters: -1, onChange() {}, onFilters() {}, onSaveView() {} }), TypeError);
|
||||
} finally {
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
@@ -279,6 +279,86 @@ class HistoryAPITests(ImageAPITestCase):
|
||||
response, body, 409, "history-changed", {"action": "refresh-history"}
|
||||
)
|
||||
|
||||
async def test_newest_pair_run_route_names_the_run_or_none(self):
|
||||
pair = self.api.db.save_prompt_pair("positive", "negative")
|
||||
quiet = self.api.db.save_prompt_pair("positive", "quiet")
|
||||
foreign = self.api.db.save_prompt_pair("elsewhere", "negative")
|
||||
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
self.capture(0, 100)
|
||||
newest = self.capture(1, 200)
|
||||
base = f"/prompt_manager/library/{positive}/history/pairs"
|
||||
response = await self.client.get(f"{base}/{pair}/newest")
|
||||
body = await response.json()
|
||||
self.assertEqual(response.status, 200, body)
|
||||
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
|
||||
self.assertEqual(body["history_version"], 1)
|
||||
self.assertEqual(body["history_epoch"], newest.storage_epoch)
|
||||
self.assertEqual(body["run"]["run_id"], newest.run_id)
|
||||
self.assertEqual(body["run"]["anchor_id"], "4")
|
||||
response = await self.client.get(f"{base}/{quiet}/newest")
|
||||
self.assertEqual((await response.json())["run"], None)
|
||||
response = await self.client.get(f"{base}/{foreign}/newest")
|
||||
self.assert_error(response, await response.json(), 404, "not-found")
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/library/999999/history/pairs/{pair}/newest"
|
||||
)
|
||||
self.assert_error(response, await response.json(), 404, "not-found")
|
||||
for path in (f"{base}/0/newest", f"{base}/01/newest", f"{base}/true/newest"):
|
||||
response = await self.client.get(path)
|
||||
self.assert_error(response, await response.json(), 400, "invalid-request")
|
||||
response = await self.client.get(f"{base}/{pair}/newest", params={"x": "1"})
|
||||
self.assert_error(response, await response.json(), 400, "invalid-request")
|
||||
|
||||
async def test_newest_pair_run_route_reports_storage_and_epoch_failures(self):
|
||||
pair = self.api.db.save_prompt_pair("positive", "negative")
|
||||
saved = self.api.db.get_prompt_pair(pair)
|
||||
self.capture(0, 100)
|
||||
url = (
|
||||
f"/prompt_manager/library/{saved['positive_prompt_id']}"
|
||||
f"/history/pairs/{pair}/newest"
|
||||
)
|
||||
# A prompt saved only as a negative is not a Library positive.
|
||||
response = await self.client.get(
|
||||
f"/prompt_manager/library/{saved['negative_prompt_id']}"
|
||||
f"/history/pairs/{pair}/newest"
|
||||
)
|
||||
self.assert_error(response, await response.json(), 404, "not-found")
|
||||
private = "SECRET_PROMPT /private/data/secret.db"
|
||||
for exc in (sqlite3.OperationalError("busy " + private), OSError(private)):
|
||||
with patch.object(self.api.db, "get_newest_pair_run", side_effect=exc):
|
||||
with self.assertLogs("prompt_manager.api", "WARNING") as logs:
|
||||
response = await self.client.get(url)
|
||||
body = await response.json()
|
||||
self.assert_error(
|
||||
response, body, 503, "storage-unavailable", {"action": "retry-read"}
|
||||
)
|
||||
self.assertNotIn(private, json.dumps(body) + " ".join(logs.output))
|
||||
source = str(self.comfy_dir / "source.db")
|
||||
self.assertTrue(self.api.db.model.backup_database(source))
|
||||
writer = PromptDatabase(self.api.db.model.db_path)
|
||||
original = self.api.db.get_newest_pair_run
|
||||
|
||||
def during_snapshot(*args, **kwargs):
|
||||
result = original(*args, **kwargs)
|
||||
with patch("database.execution_history.notify_history_restore"):
|
||||
writer.model.restore_from_file(source)
|
||||
return result
|
||||
|
||||
try:
|
||||
with patch.object(
|
||||
self.api.db, "get_newest_pair_run", side_effect=during_snapshot
|
||||
):
|
||||
response = await self.client.get(url)
|
||||
self.assert_error(
|
||||
response,
|
||||
await response.json(),
|
||||
409,
|
||||
"history-changed",
|
||||
{"action": "refresh-history"},
|
||||
)
|
||||
finally:
|
||||
writer.close_all()
|
||||
|
||||
async def test_nullable_frozen_fields_and_every_recorded_time_remain_exact(self):
|
||||
start = make_start(
|
||||
SESSION,
|
||||
|
||||
@@ -339,6 +339,11 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
relative = "nested/猫 % #.png"
|
||||
pair = self.api.db.save_prompt_pair("Cat", "Noise", title="Pair title")
|
||||
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
with self.api.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
(positive, "Positive title"),
|
||||
)
|
||||
path = make_png(self.output_dir / relative)
|
||||
make_png(self.output_dir / "thumbnails/nested/猫 % #_thumb.png")
|
||||
image = self.api.db.link_image_to_prompt(positive, str(path))
|
||||
@@ -370,10 +375,54 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
result["image"],
|
||||
{"root_index": 0, "path": relative, "filename": "猫 % #.png"},
|
||||
)
|
||||
self.assertEqual(result["library"]["metadata_scope"], "pair")
|
||||
# D39: the recipe has no category or tags, so both fall back.
|
||||
self.assertEqual(result["library"]["category_scope"], "positive")
|
||||
self.assertEqual(result["library"]["tags_scope"], "positive")
|
||||
self.assertEqual(result["library"]["pair_id"], pair)
|
||||
self.assertEqual(result["library"]["positive_id"], positive)
|
||||
self.assertEqual(result["library"]["title"], "Pair title")
|
||||
# D10/D20: the linked positive's title; "Pair title" names the variant.
|
||||
self.assertEqual(result["library"]["title"], "Positive title")
|
||||
|
||||
async def test_library_gallery_reports_favorites_with_one_lookup_per_page(self):
|
||||
# The Library-embedded media cards carry the Images star (#249 PR E):
|
||||
# favorites come from the store keyed by root and relative path.
|
||||
pair = self.api.db.save_prompt_pair("Cat", "Noise")
|
||||
positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
for index in range(5):
|
||||
path = make_png(self.output_dir / "sub" / f"{index}.png")
|
||||
image = self.api.db.link_image_to_prompt(positive, str(path))
|
||||
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
response = await self.client.put(
|
||||
"/prompt_manager/images/favorite",
|
||||
json={"root_index": 0, "path": "sub/3.png"},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
lookups = []
|
||||
original = self.api.db.get_image_favorites
|
||||
|
||||
def counted(root_key, paths):
|
||||
lookups.append(list(paths))
|
||||
return original(root_key, paths)
|
||||
|
||||
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
|
||||
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
|
||||
stars = {image["path"]: image["favorite"] for image in gallery["images"]}
|
||||
self.assertEqual(stars, {f"sub/{index}.png": index == 3 for index in range(5)})
|
||||
# One batched read per page, never one per card.
|
||||
self.assertEqual([len(paths) for paths in lookups], [5])
|
||||
# Shelf previews show no star and read no favorites.
|
||||
lookups.clear()
|
||||
with patch.object(self.api.db, "get_image_favorites", side_effect=counted):
|
||||
_, listing = await self.get()
|
||||
self.assertEqual(lookups, [])
|
||||
self.assertNotIn("favorite", listing["items"][0]["previews"][0])
|
||||
response = await self.client.delete(
|
||||
"/prompt_manager/images/favorite",
|
||||
json={"root_index": 0, "path": "sub/3.png"},
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
_, gallery = await self.get(f"/{positive}/images", pair_id=pair)
|
||||
self.assertFalse(any(image["favorite"] for image in gallery["images"]))
|
||||
|
||||
async def test_epoch_scoped_all_library_cursors(self):
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
|
||||
@@ -1302,6 +1302,64 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
)
|
||||
roots.assert_called_once_with()
|
||||
|
||||
async def test_recipe_link_falls_back_to_positive_category_and_tags_per_field(
|
||||
self,
|
||||
):
|
||||
# D39 ruling (2026-10-21): a recipe's own category and tags win when set;
|
||||
# otherwise each field falls back to the linked positive's, separately, and
|
||||
# the scopes say where each value came from (no merging of tag sets).
|
||||
cases = (
|
||||
("Recipe cat", ["recipe"], "Recipe cat", "pair", ["recipe"], "pair"),
|
||||
(None, [], "Positive cat", "positive", ["positive"], "positive"),
|
||||
(" ", [], "Positive cat", "positive", ["positive"], "positive"),
|
||||
("Recipe cat", [], "Recipe cat", "pair", ["positive"], "positive"),
|
||||
(None, ["recipe"], "Positive cat", "positive", ["recipe"], "pair"),
|
||||
)
|
||||
for index, case in enumerate(cases):
|
||||
recipe_category, recipe_tags = case[:2]
|
||||
category, category_scope, tags, tags_scope = case[2:]
|
||||
with self.subTest(case=index):
|
||||
text = f"Fallback owner {index}"
|
||||
positive = self._save_prompt(
|
||||
text, category="Positive cat", tags=["positive"]
|
||||
)
|
||||
pair = self.api.db.save_prompt_pair(
|
||||
text,
|
||||
f"Fallback noise {index}",
|
||||
category=recipe_category,
|
||||
tags=recipe_tags,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.api.db.get_prompt_pair(pair)["positive_prompt_id"], positive
|
||||
)
|
||||
filename = f"fallback-{index}.png"
|
||||
path = png(self.output_dir / filename)
|
||||
image = self.api.db.link_image_to_prompt(positive, str(path))
|
||||
self.api.db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
response, result = await self.inspect(filename)
|
||||
self.assertEqual(response.status, 200)
|
||||
library = result["library"]
|
||||
self.assertEqual(library["status"], "linked")
|
||||
self.assertEqual(library["pair_id"], pair)
|
||||
self.assertEqual(library["category"], category)
|
||||
self.assertEqual(library["category_scope"], category_scope)
|
||||
self.assertEqual(library["tags"], tags)
|
||||
self.assertEqual(library["tags_scope"], tags_scope)
|
||||
self.assertNotIn("metadata_scope", library)
|
||||
|
||||
async def test_positive_only_link_keeps_positive_scopes(self):
|
||||
positive = self._save_prompt("Solo owner", tags=["solo"])
|
||||
path = png(self.output_dir / "solo.png")
|
||||
self.api.db.link_image_to_prompt(positive, str(path))
|
||||
response, result = await self.inspect("solo.png")
|
||||
self.assertEqual(response.status, 200)
|
||||
library = result["library"]
|
||||
self.assertEqual(library["pair_id"], None)
|
||||
self.assertEqual(library["category"], None)
|
||||
self.assertEqual(library["category_scope"], "positive")
|
||||
self.assertEqual(library["tags"], ["solo"])
|
||||
self.assertEqual(library["tags_scope"], "positive")
|
||||
|
||||
async def test_exact_saved_association_ambiguity_preserves_physical_file_evidence(
|
||||
self,
|
||||
):
|
||||
@@ -1340,8 +1398,9 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
if count == 1:
|
||||
self.assertEqual(result["library"]["positive_id"], positive)
|
||||
self.assertEqual(
|
||||
result["library"]["metadata_scope"], "positive"
|
||||
result["library"]["category_scope"], "positive"
|
||||
)
|
||||
self.assertEqual(result["library"]["tags_scope"], "positive")
|
||||
self.assertEqual(result["library"]["category"], "Category 1")
|
||||
self.assertEqual(result["library"]["tags"], ["owner-1"])
|
||||
if not readable:
|
||||
@@ -1361,7 +1420,8 @@ class TestPromptInspector(ImageAPITestCase):
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
},
|
||||
)
|
||||
for field in (
|
||||
|
||||
@@ -26,6 +26,7 @@ from database.operations import PromptDatabase # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
import py.api.prompts as prompts_module # noqa: E402
|
||||
from py.api.prompts import ( # noqa: E402
|
||||
BULK_CATEGORY_MAX_PROMPTS,
|
||||
MAX_PAGE_LIMIT,
|
||||
MAX_PAGE_OFFSET,
|
||||
safe_error_message,
|
||||
@@ -909,6 +910,98 @@ class TestBulkRoutes(RouteCoverageCase):
|
||||
self.assertEqual(ok_data["updated_count"], 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "c")
|
||||
|
||||
async def _set_category(self, body):
|
||||
return await self._json("POST", "/prompt_manager/bulk/category", json=body)
|
||||
|
||||
async def test_bulk_set_category_rejects_malformed_prompt_ids(self):
|
||||
pid = self._save_prompt("bulk cat strict")
|
||||
cases = (
|
||||
None,
|
||||
"1",
|
||||
{"id": pid},
|
||||
[],
|
||||
[pid, "2"],
|
||||
[pid, 0],
|
||||
[pid, -3],
|
||||
[pid, 1.5],
|
||||
[True],
|
||||
[pid, None],
|
||||
# Beyond SQLite's INTEGER range: binding it would raise OverflowError.
|
||||
[pid, 2**63],
|
||||
[2**64],
|
||||
list(range(1, BULK_CATEGORY_MAX_PROMPTS + 2)),
|
||||
)
|
||||
for prompt_ids in cases:
|
||||
with self.subTest(prompt_ids=str(prompt_ids)[:40]):
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": prompt_ids, "category": "c"}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
|
||||
|
||||
async def test_bulk_set_category_validates_the_category(self):
|
||||
pid = self._save_prompt("bulk cat validate")
|
||||
for category in ("x" * 101, "bad\x00name", 7, ["c"]):
|
||||
with self.subTest(category=str(category)[:20]):
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": [pid], "category": category}
|
||||
)
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertFalse(data["success"])
|
||||
for body in ([pid], "text"):
|
||||
with self.subTest(body=body):
|
||||
status, data = await self._set_category(body)
|
||||
self.assertEqual(status, 400, data)
|
||||
resp = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
data="{not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertIsNone(self.api.db.get_prompt_by_id(pid)["category"])
|
||||
|
||||
async def test_bulk_set_category_dedupes_and_accepts_the_cap(self):
|
||||
ids = [self._save_prompt(f"bulk cat dedupe {i}") for i in range(2)]
|
||||
seen = []
|
||||
original = self.api.db.bulk_set_category
|
||||
|
||||
def spy(prompt_ids, category):
|
||||
seen.append((list(prompt_ids), category))
|
||||
return original(prompt_ids, category)
|
||||
|
||||
self.api.db.bulk_set_category = spy
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": [ids[1], ids[0], ids[1], ids[0]], "category": " Land "}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["updated_count"], 2)
|
||||
self.assertEqual(seen, [([ids[1], ids[0]], "Land")])
|
||||
# The cap itself is accepted; unknown ids simply do not count.
|
||||
capped = ids + list(range(10**6, 10**6 + BULK_CATEGORY_MAX_PROMPTS - 2))
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": capped, "category": "Sea"}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(data["updated_count"], 2)
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(ids[0])["category"], "Sea")
|
||||
|
||||
async def test_bulk_set_category_keeps_the_legacy_admin_request(self):
|
||||
# web/js/admin.js posts parseInt ids and a trimmed, non-empty category.
|
||||
ids = [self._save_prompt(f"bulk cat legacy {i}") for i in range(3)]
|
||||
status, data = await self._set_category(
|
||||
{"prompt_ids": ids, "category": "Portraits"}
|
||||
)
|
||||
self.assertEqual(status, 200, data)
|
||||
self.assertEqual(
|
||||
set(data), {"success", "message", "updated_count"}, "response shape"
|
||||
)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["updated_count"], 3)
|
||||
self.assertEqual(data["message"], "Set category for 3 prompts")
|
||||
for pid in ids:
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(pid)["category"], "Portraits")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -247,6 +247,33 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_workspace_roots_list_their_own_folders(self):
|
||||
# "Refine this view" offers only the chosen Source's folders (#249 m1).
|
||||
second = self.comfy_dir / "second"
|
||||
second.mkdir()
|
||||
self.media("first/deep/a.png")
|
||||
self.media("nested/b.png", root=second)
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
deep = os.path.join("first", "deep")
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/gallery/subfolders?mode=workspace&include_ancestors=true"
|
||||
)
|
||||
folders = await response.json()
|
||||
self.assertEqual(folders["subfolders"], sorted(["first", deep, "nested"]))
|
||||
self.assertEqual(
|
||||
[(r["index"], r["subfolders"]) for r in folders["roots"]],
|
||||
[(0, sorted(["first", deep])), (1, ["nested"])],
|
||||
)
|
||||
# A chosen root narrows the union; every root still lists its own.
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/gallery/subfolders?mode=workspace&root=1"
|
||||
)
|
||||
folders = await response.json()
|
||||
self.assertEqual(folders["subfolders"], ["nested"])
|
||||
self.assertEqual(
|
||||
[r["subfolders"] for r in folders["roots"]], [[deep], ["nested"]]
|
||||
)
|
||||
|
||||
async def test_workspace_dimensions_are_header_only_and_video_is_unknown(self):
|
||||
self.media("a.png")
|
||||
self.media("b.mp4")
|
||||
@@ -420,7 +447,8 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
)
|
||||
|
||||
def _link_fixtures(self):
|
||||
"""Linked (titled, first-line, pair, untitled pair), ambiguous and none."""
|
||||
"""Linked (titled, first-line, pair, variant-titled pair, untitled pair),
|
||||
ambiguous and none."""
|
||||
db = self.api.db
|
||||
long_line = "lantern " * 15
|
||||
titled = self._save_prompt("Titled positive", category="Night", rating=5)
|
||||
@@ -430,12 +458,20 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
(titled, "Stored library title"),
|
||||
)
|
||||
plain = self._save_prompt(long_line)
|
||||
# A pair's own title names its negative variant; the card shows the
|
||||
# linked positive's title (D10/D20), never the variant name.
|
||||
pair = db.save_prompt_pair("Pair positive", "Noise", title="Pair title")
|
||||
with db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
(db.get_prompt_pair(pair)["positive_prompt_id"], "Pair positive title"),
|
||||
)
|
||||
variant = db.save_prompt_pair("Variant positive", "Grain", title="Variant name")
|
||||
bare = db.save_prompt_pair("Bare pair positive\nmore", "Blur")
|
||||
first, second = self._save_prompt("Owner one"), self._save_prompt("Owner two")
|
||||
files = {
|
||||
name: self.media(f"{name}.png")
|
||||
for name in ("titled", "pair", "ambiguous", "none")
|
||||
for name in ("titled", "pair", "variant", "ambiguous", "none")
|
||||
}
|
||||
# First-line titles come from files whose own prompt evidence agrees.
|
||||
files["plain"] = embedded_png(self.output_dir / "plain.png", long_line)
|
||||
@@ -444,7 +480,7 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
)
|
||||
db.link_image_to_prompt(titled, str(files["titled"]))
|
||||
db.link_image_to_prompt(plain, str(files["plain"]))
|
||||
for name, pair_id in (("pair", pair), ("bare", bare)):
|
||||
for name, pair_id in (("pair", pair), ("variant", variant), ("bare", bare)):
|
||||
positive = db.get_prompt_pair(pair_id)["positive_prompt_id"]
|
||||
image = db.link_image_to_prompt(positive, str(files[name]))
|
||||
db.link_image_to_pair(pair_id, image, {"source": "fixture"})
|
||||
@@ -456,7 +492,13 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
"pair": (
|
||||
"linked",
|
||||
db.get_prompt_pair(pair)["positive_prompt_id"],
|
||||
"Pair title",
|
||||
"Pair positive title",
|
||||
),
|
||||
# No stored title and no file evidence: the card shows the filename.
|
||||
"variant": (
|
||||
"linked",
|
||||
db.get_prompt_pair(variant)["positive_prompt_id"],
|
||||
None,
|
||||
),
|
||||
"bare": (
|
||||
"linked",
|
||||
@@ -568,6 +610,13 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
self.assertEqual(response.status, 200)
|
||||
return (await response.json())["library"]
|
||||
|
||||
def test_link_summary_reads_no_pair_title(self):
|
||||
# The pair's own title names its negative variant and never titles a
|
||||
# card (D10), so the summary query does not read it (#249 m4).
|
||||
from database.operations import _LINK_SUMMARY_SQL
|
||||
|
||||
self.assertNotIn("pp.title", _LINK_SUMMARY_SQL)
|
||||
|
||||
async def test_link_summary_uses_one_query_per_page(self):
|
||||
prompt = self._save_prompt("Paged prompt")
|
||||
for index in range(12):
|
||||
@@ -1043,6 +1092,27 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
db.link_image_to_prompt(owner, str(files["ambiguous"]))
|
||||
return files
|
||||
|
||||
async def test_bulk_category_change_shows_on_the_next_listing(self):
|
||||
files = self._filter_fixtures()
|
||||
db = self.api.db
|
||||
night_a = db.get_prompt_id_for_image(str(files["night_a"]))
|
||||
# Warm every listing cache (scan, orders, card facts, link reads).
|
||||
for params in ({}, {"category": "Landscapes"}, {"category": "Night"}):
|
||||
response, data = await self.get(**params)
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(data["total"], 3)
|
||||
response = await self.client.post(
|
||||
"/prompt_manager/bulk/category",
|
||||
json={"prompt_ids": [night_a], "category": "Landscapes"},
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.json())
|
||||
_, moved = await self.get(category="Landscapes")
|
||||
self.assertEqual(self._names(moved), ["night_a.png"])
|
||||
self.assertEqual(moved["images"][0]["linked"]["category"], "Landscapes")
|
||||
_, night = await self.get(category="Night")
|
||||
self.assertEqual(night["total"], 2)
|
||||
self.assertNotIn("night_a.png", self._names(night))
|
||||
|
||||
async def test_positive_count_counts_distinct_linked_positives(self):
|
||||
files = self._filter_fixtures()
|
||||
db = self.api.db
|
||||
@@ -1053,8 +1123,9 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
cases = (
|
||||
# Six unambiguous positives; the ambiguous file and unlinked count none.
|
||||
({}, 10, 6),
|
||||
# Text, title, tag, pair title (its positive) and the filename match.
|
||||
({"q": "ZEBRA"}, 6, 4),
|
||||
# Text, stored title, tag and the filename match (never a pair's
|
||||
# own variant title, D12).
|
||||
({"q": "ZEBRA"}, 5, 3),
|
||||
({"category": "Night"}, 4, 3),
|
||||
({"tags": "a,b"}, 3, 2),
|
||||
({"q": "plain_unlinked"}, 1, 0),
|
||||
@@ -1185,18 +1256,24 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
|
||||
files = self._filter_fixtures()
|
||||
_, data = await self.get(q="ZEBRA", sort="name")
|
||||
# Text, stored title, tag, pair title and filename; never an ambiguous link.
|
||||
# Text, stored title, tag and filename; never an ambiguous link.
|
||||
self.assertEqual(
|
||||
self._names(data),
|
||||
[
|
||||
"day_ab.png",
|
||||
"night_ab.png",
|
||||
"paired.png",
|
||||
"titled.png",
|
||||
"zebra_unlinked.png",
|
||||
],
|
||||
)
|
||||
self.assertEqual(data["total"], 5)
|
||||
self.assertEqual(data["total"], 4)
|
||||
# D12: a pair's own title names its negative variant, so the variant
|
||||
# name alone matches nothing; the linked positive's text still does.
|
||||
_, data = await self.get(q="Zebra pair")
|
||||
self.assertEqual(self._names(data), [])
|
||||
self.assertEqual(data["total"], 0)
|
||||
_, data = await self.get(q="pair positive")
|
||||
self.assertEqual(self._names(data), ["paired.png"])
|
||||
_, data = await self.get(q="0% w")
|
||||
self.assertEqual(self._names(data), ["percent.png"])
|
||||
_, data = await self.get(q="%")
|
||||
@@ -1207,7 +1284,7 @@ class TestWorkspaceImages(ImageAPITestCase):
|
||||
self.api.db.get_image_paths_matching("zebra", None, []),
|
||||
{
|
||||
normalize_image_path(str(files[name]))
|
||||
for name in ("night_ab", "day_ab", "titled", "paired")
|
||||
for name in ("night_ab", "day_ab", "titled")
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1541,6 +1541,40 @@ class TestImageLinkSummaries(DatabaseTestCase):
|
||||
self.assertEqual(sources["plain"], (long_line.strip()[:80] + "\u2026", "text"))
|
||||
self.assertEqual(sources["blank"], ("Blank title text", "text"))
|
||||
|
||||
def test_pair_summary_title_is_the_linked_positives_never_the_variant(self):
|
||||
# D10/D20: a pair's own title names its negative variant.
|
||||
stored = self.db.save_prompt_pair("Stored positive", "Noise", title="Variant")
|
||||
plain = self.db.save_prompt_pair("Plain positive\nmore", "Blur", title="Other")
|
||||
positives = {
|
||||
name: self.db.get_prompt_pair(pair_id)["positive_prompt_id"]
|
||||
for name, pair_id in (("stored", stored), ("plain", plain))
|
||||
}
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
(positives["stored"], "Positive title"),
|
||||
)
|
||||
paths = {}
|
||||
for name, pair_id in (("stored", stored), ("plain", plain)):
|
||||
paths[name] = os.path.join("out", f"pair-{name}.png")
|
||||
image = self.db.link_image_to_prompt(positives[name], paths[name])
|
||||
self.db.link_image_to_pair(pair_id, image, {"source": "test"})
|
||||
summaries = self.db.get_image_link_summaries(list(paths.values()))
|
||||
titles = {
|
||||
name: (
|
||||
summaries[path]["positive_id"],
|
||||
summaries[path]["title"],
|
||||
summaries[path]["title_source"],
|
||||
)
|
||||
for name, path in paths.items()
|
||||
}
|
||||
self.assertEqual(
|
||||
titles["stored"], (positives["stored"], "Positive title", "stored")
|
||||
)
|
||||
self.assertEqual(
|
||||
titles["plain"], (positives["plain"], "Plain positive", "text")
|
||||
)
|
||||
|
||||
def test_display_title_matches_library_title_cut(self):
|
||||
from database.library import display_title
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ except ImportError:
|
||||
import test_execution_history_writer as fixtures
|
||||
|
||||
|
||||
# Upper bound a blocked fake write waits for its release. Tests prove ordering
|
||||
# with events, so this only caps a failing run and never decides a pass.
|
||||
BLOCK_SECONDS = 10
|
||||
|
||||
|
||||
class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
setUp = fixtures.HistoryWriterTests.setUp
|
||||
cleanup_workers = fixtures.HistoryWriterTests.cleanup_workers
|
||||
@@ -408,59 +413,81 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
def test_bounded_close_and_early_native_join_keep_inflight_combined_charge(self):
|
||||
for early in (False, True):
|
||||
with self.subTest(early_join=early):
|
||||
writer = self.writer()
|
||||
start = self.start_command(len(self.writers))
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
original = self.db.write_execution_outputs_for_finish
|
||||
self.assert_bounded_close_keeps_inflight_charge(early)
|
||||
|
||||
def write(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
return original(*args, **kwargs)
|
||||
def assert_bounded_close_keeps_inflight_charge(self, early):
|
||||
# Every writer in this test charges the process-wide budget, so a
|
||||
# failing subtest must not leave its worker blocked (and charged) for
|
||||
# the next one: release and join it before the next subtest starts.
|
||||
writer = self.writer()
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
start = self.start_command(len(self.writers))
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
left = threading.Event()
|
||||
original = self.db.write_execution_outputs_for_finish
|
||||
|
||||
with patch.object(self.db, "write_execution_outputs_for_finish", write):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(1))
|
||||
charge = writer.context_budget.charged_bytes
|
||||
self.finalization(writer, start)
|
||||
clock = fixtures.ManualClock()
|
||||
native_join = writer._thread.join
|
||||
def write(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(BLOCK_SECONDS)
|
||||
left.set()
|
||||
return original(*args, **kwargs)
|
||||
|
||||
def return_early(timeout):
|
||||
native_join(0)
|
||||
clock.advance(min(timeout, 0.004))
|
||||
try:
|
||||
with patch.object(self.db, "write_execution_outputs_for_finish", write):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(BLOCK_SECONDS))
|
||||
charge = writer.context_budget.charged_bytes
|
||||
self.finalization(writer, start)
|
||||
clock = fixtures.ManualClock()
|
||||
native_join = writer._thread.join
|
||||
|
||||
def sleep(delay):
|
||||
clock.advance(delay)
|
||||
def return_early(timeout):
|
||||
native_join(0)
|
||||
clock.advance(min(timeout, 0.004))
|
||||
|
||||
clock_patch = (
|
||||
patch(
|
||||
"utils.execution_history_writer.time",
|
||||
SimpleNamespace(monotonic=clock, sleep=sleep),
|
||||
)
|
||||
if early
|
||||
else nullcontext()
|
||||
def sleep(delay):
|
||||
clock.advance(delay)
|
||||
|
||||
clock_patch = (
|
||||
patch(
|
||||
"utils.execution_history_writer.time",
|
||||
SimpleNamespace(monotonic=clock, sleep=sleep),
|
||||
)
|
||||
join_patch = (
|
||||
patch.object(writer._thread, "join", return_early)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
with clock_patch, join_patch:
|
||||
before = clock() if early else time.monotonic()
|
||||
self.assertFalse(writer.close(0.02))
|
||||
elapsed = (clock() if early else time.monotonic()) - before
|
||||
self.assertGreaterEqual(elapsed, 0.02)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
join_patch = (
|
||||
patch.object(writer._thread, "join", return_early)
|
||||
if early
|
||||
else nullcontext()
|
||||
)
|
||||
with clock_patch, join_patch:
|
||||
before = clock() if early else time.monotonic()
|
||||
self.assertFalse(writer.close(0.02))
|
||||
elapsed = (clock() if early else time.monotonic()) - before
|
||||
# close() waits until its own monotonic deadline, so the lower
|
||||
# bound holds on any runner.
|
||||
self.assertGreaterEqual(elapsed, 0.02)
|
||||
if early:
|
||||
# Manual clock: only the patched join/sleep advance it, so
|
||||
# this exact bound proves early joins do not spin forever.
|
||||
self.assertLess(elapsed, 0.2)
|
||||
self.assertEqual(writer.context_budget.charged_bytes, charge)
|
||||
replacement = HistoryWriter(lambda: self.db)
|
||||
self.writers.append(replacement)
|
||||
self.assertIs(replacement.context_budget, writer.context_budget)
|
||||
self.assertIsNone(replacement.context_budget.reserve(8388608))
|
||||
release.set()
|
||||
self.assertTrue(writer.close(2))
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
# Bounded, not unbounded: close() gave up while the in-flight
|
||||
# write was still blocked, instead of waiting it out.
|
||||
self.assertFalse(left.is_set())
|
||||
self.assertTrue(writer._thread.is_alive())
|
||||
self.assertEqual(writer.context_budget.charged_bytes, charge)
|
||||
replacement = HistoryWriter(lambda: self.db)
|
||||
self.writers.append(replacement)
|
||||
self.assertIs(replacement.context_budget, writer.context_budget)
|
||||
self.assertIsNone(replacement.context_budget.reserve(8388608))
|
||||
release.set()
|
||||
self.assertTrue(writer.close(2))
|
||||
finally:
|
||||
release.set()
|
||||
writer.close(BLOCK_SECONDS)
|
||||
self.assertEqual(writer.context_budget.charged_bytes, 0)
|
||||
|
||||
def test_pending_saturation_and_invalid_command_release_both_payloads(self):
|
||||
writer = self.writer()
|
||||
@@ -552,11 +579,13 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
self.db.write_execution_start(start)
|
||||
entered, release = self.block()
|
||||
invalidating, invalidated = threading.Event(), threading.Event()
|
||||
committed = threading.Event()
|
||||
original = outputs_db._capture
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(3)
|
||||
release.wait(BLOCK_SECONDS)
|
||||
committed.set()
|
||||
return original(*args, **kwargs)
|
||||
|
||||
def invalidate():
|
||||
@@ -566,20 +595,24 @@ class OutputWriterTests(fixtures.unittest.TestCase):
|
||||
|
||||
with patch.object(outputs_db, "_capture", capture):
|
||||
self.finalization(writer, start)
|
||||
self.assertTrue(entered.wait(1))
|
||||
self.assertTrue(entered.wait(BLOCK_SECONDS))
|
||||
invalidator = threading.Thread(target=invalidate)
|
||||
invalidator.start()
|
||||
try:
|
||||
self.assertTrue(invalidating.wait(1))
|
||||
self.assertTrue(invalidating.wait(BLOCK_SECONDS))
|
||||
self.assertFalse(invalidated.wait(0.03))
|
||||
before = time.monotonic()
|
||||
self.assertTrue(writer.submit(self.finish_command(start)))
|
||||
self.assertFalse(writer.close(0))
|
||||
self.assertLess(time.monotonic() - before, 0.1)
|
||||
# submit() and close() returned while the commit still held the
|
||||
# gate: the blocked invalidation has not finished and the
|
||||
# commit is still waiting for its release. Had either call
|
||||
# queued behind the gate, it could only return after both.
|
||||
self.assertFalse(invalidated.is_set())
|
||||
self.assertFalse(committed.is_set())
|
||||
self.assertGreater(writer.context_budget.charged_bytes, 0)
|
||||
finally:
|
||||
release.set()
|
||||
invalidator.join(3)
|
||||
invalidator.join(BLOCK_SECONDS)
|
||||
self.assertFalse(invalidator.is_alive())
|
||||
self.assertTrue(writer.close(2))
|
||||
self.assertEqual(
|
||||
|
||||
@@ -16,6 +16,7 @@ from tests.test_api_images import make_png
|
||||
from database.operations import PromptDatabase
|
||||
from py.config import GalleryConfig
|
||||
from utils.execution_outputs import snapshot_output_roots
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
class CurrentLocationTests(unittest.TestCase):
|
||||
@@ -331,6 +332,93 @@ class CurrentLocationTests(unittest.TestCase):
|
||||
self.assertEqual(result[unique_locator]["library"]["status"], "limited")
|
||||
self.assertIsNone(result[unique_locator]["library"].get("pair_id"))
|
||||
|
||||
def test_gallery_recipe_display_falls_back_to_positive_per_field(self):
|
||||
# D39 ruling (2026-10-21), shared with the Images inspector.
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "gallery.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
cases = (
|
||||
("Recipe cat", ["recipe"], ("Recipe cat", "pair", ["recipe"], "pair")),
|
||||
(None, [], ("Positive cat", "positive", ["positive"], "positive")),
|
||||
("Recipe cat", [], ("Recipe cat", "pair", ["positive"], "positive")),
|
||||
("", ["recipe"], ("Positive cat", "positive", ["recipe"], "pair")),
|
||||
)
|
||||
for index, (recipe_category, recipe_tags, expected) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
text = f"owner {index}"
|
||||
positive = db.save_prompt(
|
||||
text,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
category="Positive cat",
|
||||
)
|
||||
db.set_prompt_tags(positive, ["positive"])
|
||||
pair = db.save_prompt_pair(
|
||||
text, "noise", category=recipe_category, tags=recipe_tags
|
||||
)
|
||||
path = make_png(self.output / f"fallback-{index}.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
image = db.link_image_to_prompt(positive, str(path))
|
||||
db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
conn = db.model.get_connection()
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))
|
||||
library = result[locator]["library"]
|
||||
self.assertEqual(library["pair_id"], pair)
|
||||
self.assertEqual(
|
||||
(
|
||||
library["category"],
|
||||
library["category_scope"],
|
||||
library["tags"],
|
||||
library["tags_scope"],
|
||||
),
|
||||
expected,
|
||||
)
|
||||
|
||||
def test_gallery_blank_recipe_category_and_skipped_positive_tags_query(self):
|
||||
mod = self.module("database.history_gallery")
|
||||
db = PromptDatabase(str(self.base / "blank.db"))
|
||||
self.addCleanup(db.close_all)
|
||||
cases = (
|
||||
(" ", ["recipe"], 0),
|
||||
("\u3000", [], 1),
|
||||
("\u00a0 ", ["recipe"], 0),
|
||||
)
|
||||
for index, (blank, recipe_tags, positive_tag_queries) in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
text = f"blank owner {index}"
|
||||
positive = db.save_prompt(
|
||||
text,
|
||||
prompt_hash=generate_prompt_hash(text),
|
||||
category="Positive cat",
|
||||
)
|
||||
db.set_prompt_tags(positive, ["positive"])
|
||||
pair = db.save_prompt_pair(text, "noise", tags=recipe_tags)
|
||||
path = make_png(self.output / f"blank-{index}.png")
|
||||
locator = os.path.normcase(str(path))
|
||||
image = db.link_image_to_prompt(positive, str(path))
|
||||
db.link_image_to_pair(pair, image, {"source": "fixture"})
|
||||
conn = db.model.get_connection()
|
||||
# Legacy rows can hold a blank-looking category the writers now trim.
|
||||
conn.execute(
|
||||
"UPDATE prompt_pairs SET category=? WHERE id=?", (blank, pair)
|
||||
)
|
||||
conn.commit()
|
||||
statements = []
|
||||
conn.set_trace_callback(statements.append)
|
||||
self.addCleanup(conn.set_trace_callback, None)
|
||||
with db.recorded_history_snapshot():
|
||||
result = mod.read_history_gallery(conn, (locator,))
|
||||
conn.set_trace_callback(None)
|
||||
library = result[locator]["library"]
|
||||
self.assertEqual(library["category"], "Positive cat")
|
||||
self.assertEqual(library["category_scope"], "positive")
|
||||
tag_queries = [
|
||||
statement
|
||||
for statement in statements
|
||||
if "FROM prompt_tags pt" in statement
|
||||
]
|
||||
self.assertEqual(len(tag_queries), positive_tag_queries, statements)
|
||||
|
||||
def test_host_root_failure_and_removal_do_not_search_another_root(self):
|
||||
make_png(self.output / "image.png")
|
||||
make_png(self.temp / "image.png")
|
||||
|
||||
@@ -913,3 +913,79 @@ class HistoryReadTests(unittest.TestCase):
|
||||
db.list_history_outputs(
|
||||
start.run_id, after=OutputPosition("0" * 64, (node, 0))
|
||||
)
|
||||
|
||||
def test_newest_pair_run_follows_current_hints_in_the_raw_scope(self):
|
||||
db = self.database()
|
||||
pair_a = db.save_prompt_pair("positive", "A")
|
||||
pair_b = db.save_prompt_pair("positive", "B")
|
||||
quiet = db.save_prompt_pair("positive", "C")
|
||||
other = db.save_prompt_pair("elsewhere", "A")
|
||||
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
|
||||
self.capture(db, 0, bundle("A"), time=100)
|
||||
newest = self.capture(db, 1, bundle("A"), time=200)
|
||||
b = self.capture(db, 2, bundle("B"), time=300)
|
||||
with db.recorded_history_snapshot():
|
||||
found = db.get_newest_pair_run(positive, pair_a)
|
||||
found_b = db.get_newest_pair_run(positive, pair_b)
|
||||
none = db.get_newest_pair_run(positive, quiet)
|
||||
foreign = db.get_newest_pair_run(positive, other)
|
||||
missing = db.get_newest_pair_run(9999, pair_a)
|
||||
self.assertEqual(found["run"]["run_id"], newest.run_id)
|
||||
self.assertEqual(found["run"]["anchor_id"], "4")
|
||||
self.assertRegex(found["run"]["context_id"], "^[0-9a-f]{64}$")
|
||||
self.assertEqual(found_b["run"]["run_id"], b.run_id)
|
||||
self.assertEqual(none, {"run": None})
|
||||
self.assertIsNone(foreign)
|
||||
self.assertIsNone(missing)
|
||||
# A hint that no longer names the current pair is not followed.
|
||||
db.delete_prompt_pair(pair_b)
|
||||
recreated = db.save_prompt_pair("positive", "B")
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(db.get_newest_pair_run(positive, recreated), {"run": None})
|
||||
with self.assertRaises(sqlite3.ProgrammingError):
|
||||
db.get_newest_pair_run(positive, pair_a)
|
||||
|
||||
def test_newest_pair_run_skips_stale_epoch_and_bounds_stale_hints(self):
|
||||
db = self.database()
|
||||
pair_a = db.save_prompt_pair("positive", "A")
|
||||
pair_b = db.save_prompt_pair("positive", "B")
|
||||
positive = db.get_prompt_pair(pair_a)["positive_prompt_id"]
|
||||
older = self.capture(db, 0, bundle("A"), time=100)
|
||||
stale_epoch = self.capture(db, 1, bundle("A"), time=200)
|
||||
conn = db.model.get_connection()
|
||||
# A row whose reference epoch is not the current one is never followed.
|
||||
conn.execute(
|
||||
"UPDATE execution_run_contexts SET reference_epoch=? WHERE run_id=?",
|
||||
("00000000-0000-4000-8000-000000000000", stale_epoch.run_id),
|
||||
)
|
||||
conn.commit()
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(
|
||||
db.get_newest_pair_run(positive, pair_a)["run"]["run_id"],
|
||||
older.run_id,
|
||||
)
|
||||
# Seventeen newer rows whose hint names pair A but whose texts resolve to
|
||||
# pair B fill the candidate bound, so the older valid run is not reached.
|
||||
for item in range(2, 19):
|
||||
start = self.capture(db, item, bundle("B"), time=300 + item)
|
||||
conn.execute(
|
||||
"UPDATE execution_run_contexts SET pair_id=? WHERE run_id=?",
|
||||
(pair_a, start.run_id),
|
||||
)
|
||||
conn.commit()
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertEqual(db.get_newest_pair_run(positive, pair_a), {"run": None})
|
||||
self.assertIsNotNone(db.get_newest_pair_run(positive, pair_b))
|
||||
|
||||
def test_newest_pair_run_rejects_an_ineligible_or_deleted_positive(self):
|
||||
db = self.database()
|
||||
pair = db.save_prompt_pair("positive", "negative")
|
||||
saved = db.get_prompt_pair(pair)
|
||||
self.capture(db, 0, bundle("negative"), time=100)
|
||||
with db.recorded_history_snapshot():
|
||||
# A prompt saved only as a pair's negative is not a Library positive.
|
||||
self.assertIsNone(db.get_newest_pair_run(saved["negative_prompt_id"], pair))
|
||||
db.delete_prompt_pair(pair)
|
||||
db.delete_prompt(saved["positive_prompt_id"])
|
||||
with db.recorded_history_snapshot():
|
||||
self.assertIsNone(db.get_newest_pair_run(saved["positive_prompt_id"], pair))
|
||||
|
||||
@@ -292,6 +292,134 @@ class TestImageFavoriteApi(ImageAPITestCase):
|
||||
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
|
||||
|
||||
|
||||
class TestWorkspaceFavoritesFilter(ImageAPITestCase):
|
||||
"""``favorites=1`` on the workspace listing: the Favorites page (#249 D51)."""
|
||||
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
async def page(self, status=200, **params):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace", **params}
|
||||
)
|
||||
data = await response.json()
|
||||
self.assertEqual(response.status, status, data)
|
||||
return data
|
||||
|
||||
async def star(self, path, root_index=0, method="PUT"):
|
||||
response = await self.client.request(
|
||||
method, FAVORITE, json={"root_index": root_index, "path": path}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
def make(self, *names):
|
||||
for offset, name in enumerate(names):
|
||||
target = make_png(self.output_dir / name)
|
||||
os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset))
|
||||
|
||||
async def test_lists_only_favorites_and_counts_them(self):
|
||||
self.make("a.png", "sub/b.png", "c.png", "sub/d.png")
|
||||
await self.star("a.png")
|
||||
await self.star("sub/d.png")
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"])
|
||||
self.assertEqual(data["total"], 2)
|
||||
self.assertTrue(all(i["favorite"] is True for i in data["images"]))
|
||||
# The nav count's read: limit=1 still reports every favorite's total.
|
||||
self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2)
|
||||
# Other filters intersect with it.
|
||||
sub = await self.page(favorites="1", subfolder="sub")
|
||||
self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"])
|
||||
self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1)
|
||||
self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0)
|
||||
# favorites=0 (and no parameter) is the whole listing.
|
||||
self.assertEqual((await self.page(favorites="0"))["total"], 4)
|
||||
self.assertEqual((await self.page())["total"], 4)
|
||||
await self.star("a.png", method="DELETE")
|
||||
self.assertEqual((await self.page(favorites="1"))["total"], 1)
|
||||
|
||||
async def test_cursor_pages_walk_only_favorites_in_every_order(self):
|
||||
self.make("a.png", "b.png", "c.png", "d.png", "e.png")
|
||||
for name in ("a.png", "c.png", "e.png"):
|
||||
await self.star(name)
|
||||
expected = {
|
||||
"newest": ["e.png", "c.png", "a.png"],
|
||||
"oldest": ["a.png", "c.png", "e.png"],
|
||||
"name": ["a.png", "c.png", "e.png"],
|
||||
"name-desc": ["e.png", "c.png", "a.png"],
|
||||
"rating": ["e.png", "c.png", "a.png"],
|
||||
}
|
||||
for sort, names in expected.items():
|
||||
seen, cursor = [], None
|
||||
while True:
|
||||
params = {"favorites": "1", "sort": sort, "limit": "1"}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
data = await self.page(**params)
|
||||
self.assertEqual(data["total"], 3, sort)
|
||||
seen += [i["path"] for i in data["images"]]
|
||||
cursor = data["next_cursor"]
|
||||
if not data["has_more"]:
|
||||
break
|
||||
self.assertEqual(seen, names, sort)
|
||||
# A cursor is bound to its filters: a whole-listing cursor is refused.
|
||||
whole = await self.page(limit="1")
|
||||
await self.page(400, favorites="1", cursor=whole["next_cursor"])
|
||||
|
||||
async def test_favorites_follow_their_own_root(self):
|
||||
second = self.comfy_dir / "second"
|
||||
make_png(second / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
await self.star("a.png", root_index=1)
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual(
|
||||
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
|
||||
)
|
||||
self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0)
|
||||
self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1)
|
||||
|
||||
async def test_windows_case_folded_favorite_is_listed(self):
|
||||
make_png(self.output_dir / "Sub" / "A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
await self.star("Sub/A.png")
|
||||
(self.output_dir / "Sub" / "A.png").rename(
|
||||
self.output_dir / "Sub" / "a.png"
|
||||
)
|
||||
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
||||
self.api.invalidate_gallery_cache()
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"])
|
||||
|
||||
async def test_a_favorite_whose_file_is_gone_is_not_listed(self):
|
||||
self.make("a.png", "b.png")
|
||||
await self.star("a.png")
|
||||
await self.star("b.png")
|
||||
(self.output_dir / "b.png").unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
data = await self.page(favorites="1")
|
||||
self.assertEqual([i["path"] for i in data["images"]], ["a.png"])
|
||||
self.assertEqual(data["total"], 1)
|
||||
|
||||
async def test_rejects_other_values(self):
|
||||
for value in ("true", "2", "", "yes"):
|
||||
data = await self.page(400, favorites=value)
|
||||
self.assertFalse(data["success"])
|
||||
|
||||
async def test_store_failure_is_an_error_not_an_unfiltered_page(self):
|
||||
self.make("a.png")
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
self.api.db.get_root_favorites = broken
|
||||
data = await self.page(500, favorites="1")
|
||||
self.assertFalse(data["success"])
|
||||
self.assertNotIn("images", data)
|
||||
|
||||
|
||||
class TestImageFavoriteSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
@@ -314,6 +442,10 @@ class TestImageFavoriteSchema(unittest.TestCase):
|
||||
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
|
||||
)
|
||||
self.assertEqual(db.get_image_favorites("output", []), set())
|
||||
# One root's favorites, read through the primary key.
|
||||
self.assertEqual(db.get_root_favorites("output"), {"a.png"})
|
||||
self.assertEqual(db.get_root_favorites("other"), {"a.png"})
|
||||
self.assertEqual(db.get_root_favorites("missing"), set())
|
||||
many = [f"{n}.png" for n in range(2000)] + ["a.png"]
|
||||
self.assertEqual(db.get_image_favorites("output", many), {"a.png"})
|
||||
self.assertTrue(db.clear_image_favorite("output", "a.png"))
|
||||
|
||||
@@ -0,0 +1,919 @@
|
||||
"""Show in folder (TESTING.md D38): the local-only reveal route and its openers.
|
||||
|
||||
No test launches a file manager: ``subprocess.Popen``, ``sys.platform`` and
|
||||
``shutil.which`` are mocked, and route tests swap the API's opener.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from aiohttp import web
|
||||
from multidict import CIMultiDict
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
|
||||
import utils.file_reveal as file_reveal
|
||||
from utils.file_reveal import (
|
||||
OpenerUnavailable,
|
||||
is_local_request,
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
|
||||
REVEAL = "/prompt_manager/images/reveal"
|
||||
PROXY_HEADERS = (
|
||||
"Forwarded",
|
||||
"Via",
|
||||
"X-Forwarded-For",
|
||||
"X-Forwarded-Host",
|
||||
"X-Forwarded-Proto",
|
||||
"X-Forwarded-Port",
|
||||
"X-Forwarded-Server",
|
||||
"X-Real-IP",
|
||||
"X-Original-Forwarded-For",
|
||||
"X-Original-Host",
|
||||
"CF-Connecting-IP",
|
||||
"True-Client-IP",
|
||||
"X-Client-IP",
|
||||
"X-Cluster-Client-IP",
|
||||
"Fastly-Client-IP",
|
||||
)
|
||||
ORIGIN = "http://127.0.0.1:8188"
|
||||
|
||||
|
||||
def headers(**values):
|
||||
base = {"Host": "127.0.0.1:8188", "Origin": ORIGIN}
|
||||
base.update({key.replace("_", "-"): value for key, value in values.items()})
|
||||
return CIMultiDict({key: value for key, value in base.items() if value is not None})
|
||||
|
||||
|
||||
class TestIsLocalRequest(unittest.TestCase):
|
||||
def test_same_origin_loopback_clients_by_loopback_name_are_local(self):
|
||||
for remote, host, origin in (
|
||||
("127.0.0.1", "127.0.0.1:8188", ORIGIN),
|
||||
("127.8.9.10", "localhost:8188", "http://localhost:8188"),
|
||||
("::1", "[::1]:8188", "http://[::1]:8188"),
|
||||
("::ffff:127.0.0.1", "comfy.localhost", "http://comfy.localhost"),
|
||||
("127.0.0.1", "LOCALHOST:8188", "http://localhost:8188"),
|
||||
("127.0.0.1", "localhost", "http://localhost:80"),
|
||||
):
|
||||
with self.subTest(remote=remote, host=host):
|
||||
self.assertTrue(
|
||||
is_local_request(remote, headers(Host=host, Origin=origin))
|
||||
)
|
||||
self.assertTrue(
|
||||
is_local_request(
|
||||
"127.0.0.1",
|
||||
headers(Host="localhost", Origin="https://localhost:443"),
|
||||
"https",
|
||||
)
|
||||
)
|
||||
|
||||
def test_non_loopback_or_unknown_remote_is_not_local(self):
|
||||
for remote in ("192.168.1.5", "10.0.0.1", "::2", "fe80::1%eth0", "", None):
|
||||
with self.subTest(remote=remote):
|
||||
self.assertFalse(is_local_request(remote, headers()))
|
||||
|
||||
def test_any_forwarding_header_makes_loopback_remote(self):
|
||||
for name in (*PROXY_HEADERS, "X-Forwarded-Anything", "X-Original-URI"):
|
||||
for spelling in (name, name.lower()):
|
||||
with self.subTest(header=spelling):
|
||||
request_headers = headers()
|
||||
request_headers[spelling] = "127.0.0.1"
|
||||
self.assertFalse(is_local_request("127.0.0.1", request_headers))
|
||||
|
||||
def test_origin_is_required_and_must_be_the_request_origin(self):
|
||||
# A header-less proxy or curl leaves Origin out; a page on another
|
||||
# loopback port or scheme sends its own (security review M1, L2).
|
||||
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=None)))
|
||||
for origin in (
|
||||
"",
|
||||
"null",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1",
|
||||
"https://127.0.0.1:8188",
|
||||
"http://localhost:8188",
|
||||
"http://foo.localhost:8188",
|
||||
"http://evil.example:8188",
|
||||
"http://[bad",
|
||||
"http://127.0.0.1:8188/path",
|
||||
"http://user@127.0.0.1:8188",
|
||||
"file://127.0.0.1:8188",
|
||||
):
|
||||
with self.subTest(origin=origin):
|
||||
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=origin)))
|
||||
self.assertFalse(is_local_request("127.0.0.1", headers(Origin=ORIGIN), "https"))
|
||||
|
||||
def test_sec_fetch_site_must_be_same_origin_when_sent(self):
|
||||
self.assertTrue(
|
||||
is_local_request("127.0.0.1", headers(Sec_Fetch_Site="same-origin"))
|
||||
)
|
||||
for site in ("same-site", "cross-site", "none", ""):
|
||||
with self.subTest(site=site):
|
||||
self.assertFalse(
|
||||
is_local_request("127.0.0.1", headers(Sec_Fetch_Site=site))
|
||||
)
|
||||
|
||||
def test_foreign_host_is_not_local(self):
|
||||
# A DNS-rebinding page reaches 127.0.0.1 under its own name.
|
||||
for host in ("evil.example:8188", "", "[bad", "127.0.0.1:bad"):
|
||||
with self.subTest(host=host):
|
||||
self.assertFalse(
|
||||
is_local_request(
|
||||
"127.0.0.1", headers(Host=host, Origin=f"http://{host}")
|
||||
)
|
||||
)
|
||||
self.assertFalse(is_local_request("127.0.0.1", CIMultiDict({"Origin": ORIGIN})))
|
||||
|
||||
|
||||
class TestRevealGate(unittest.TestCase):
|
||||
def test_one_reveal_at_a_time_then_a_cooldown(self):
|
||||
now = [100.0]
|
||||
gate = file_reveal.RevealGate(cooldown=1.0, clock=lambda: now[0])
|
||||
self.assertTrue(gate.try_enter())
|
||||
self.assertFalse(gate.try_enter())
|
||||
now[0] += 5
|
||||
self.assertFalse(gate.try_enter())
|
||||
gate.leave()
|
||||
now[0] += 0.5
|
||||
self.assertFalse(gate.try_enter())
|
||||
now[0] += 0.5
|
||||
self.assertTrue(gate.try_enter())
|
||||
|
||||
def test_zero_cooldown_only_blocks_while_busy(self):
|
||||
gate = file_reveal.RevealGate(cooldown=0)
|
||||
self.assertTrue(gate.try_enter())
|
||||
self.assertFalse(gate.try_enter())
|
||||
gate.leave()
|
||||
self.assertTrue(gate.try_enter())
|
||||
|
||||
|
||||
class TestRevealInFileManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(temp.cleanup)
|
||||
self.root = Path(os.path.realpath(temp.name)) / "out"
|
||||
self.path = self.root / "sub" / "a.png"
|
||||
self.path.parent.mkdir(parents=True)
|
||||
self.path.write_bytes(b"png")
|
||||
self.popen = patch.object(subprocess, "Popen").start()
|
||||
self.reaped = patch.object(file_reveal, "_reap").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def reveal(self, path=None):
|
||||
reveal_in_file_manager(self.path if path is None else path, self.root)
|
||||
|
||||
def which(self, available):
|
||||
def lookup(name, path=None):
|
||||
self.assertEqual(path, file_reveal.SYSTEM_PATH)
|
||||
return available.get(name)
|
||||
|
||||
return patch.object(file_reveal.shutil, "which", side_effect=lookup)
|
||||
|
||||
def argv(self, call=0):
|
||||
return self.popen.call_args_list[call].args[0]
|
||||
|
||||
def assert_detached(self, call=0):
|
||||
options = self.popen.call_args_list[call].kwargs
|
||||
for stream in ("stdin", "stdout", "stderr"):
|
||||
self.assertIs(options[stream], subprocess.DEVNULL)
|
||||
self.assertIs(options["close_fds"], True)
|
||||
self.assertIs(options["start_new_session"], True)
|
||||
self.assertNotIn("shell", options)
|
||||
self.assertEqual(options["env"]["PATH"], file_reveal.SYSTEM_PATH)
|
||||
|
||||
def windows(self, selected=True, startfile=None):
|
||||
"""Windows without Windows: the shell libraries and os.startfile
|
||||
injected; the patched shell records its calls."""
|
||||
shell = FakeShell(select=0 if selected else -2147467259)
|
||||
select = patch.object(
|
||||
file_reveal,
|
||||
"_windows_libraries",
|
||||
return_value=(shell.ole32, shell.shell32),
|
||||
)
|
||||
select.shell = shell
|
||||
start = patch.object(
|
||||
file_reveal.os, "startfile", startfile or MagicMock(), create=True
|
||||
)
|
||||
platform = patch.object(file_reveal.sys, "platform", "win32")
|
||||
return select, start, platform
|
||||
|
||||
def test_windows_selects_through_the_shell_api_without_a_process(self):
|
||||
# A comma path splits Explorer's own command line (security review H1);
|
||||
# the shell API takes the path as one value, unchanged.
|
||||
path = self.root / "a," / "b,c.png"
|
||||
path.parent.mkdir()
|
||||
path.write_bytes(b"png")
|
||||
select, start, platform = self.windows(selected=True)
|
||||
with select, start as startfile, platform:
|
||||
self.reveal(path)
|
||||
self.assertEqual(dict(select.shell.calls)["ILCreateFromPathW"], (str(path),))
|
||||
startfile.assert_not_called()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_windows_falls_back_to_opening_the_folder(self):
|
||||
select, start, platform = self.windows(selected=False)
|
||||
with select, start as startfile, platform:
|
||||
self.reveal()
|
||||
self.assertEqual(
|
||||
dict(select.shell.calls)["ILCreateFromPathW"], (str(self.path),)
|
||||
)
|
||||
startfile.assert_called_once_with(str(self.path.parent), "explore")
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_windows_folder_start_failure_raises(self):
|
||||
select, start, platform = self.windows(
|
||||
selected=False, startfile=MagicMock(side_effect=OSError("denied"))
|
||||
)
|
||||
with select, start, platform, self.assertRaises(OSError):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_windows_without_startfile_is_unavailable(self):
|
||||
with (
|
||||
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
|
||||
patch.object(file_reveal.sys, "platform", "win32"),
|
||||
patch.object(file_reveal.os, "startfile", None, create=True),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_macos_reveals_with_the_system_open(self):
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "darwin"),
|
||||
patch.object(file_reveal, "_macos_open", return_value="/usr/bin/open"),
|
||||
self.which({"open": "/planted/open"}),
|
||||
):
|
||||
self.reveal()
|
||||
self.assertEqual(self.argv(), ["/usr/bin/open", "-R", str(self.path)])
|
||||
self.assert_detached()
|
||||
self.popen.return_value.wait.assert_not_called()
|
||||
self.reaped.assert_called_once_with(self.popen.return_value)
|
||||
|
||||
def test_macos_open_is_the_fixed_system_path(self):
|
||||
self.assertEqual(file_reveal.MACOS_OPEN, "/usr/bin/open")
|
||||
with patch.object(file_reveal.os.path, "isfile", return_value=False):
|
||||
self.assertIsNone(file_reveal._macos_open())
|
||||
with (
|
||||
patch.object(file_reveal.os.path, "isfile", return_value=True),
|
||||
patch.object(file_reveal.os, "access", return_value=True),
|
||||
):
|
||||
self.assertEqual(file_reveal._macos_open(), "/usr/bin/open")
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "darwin"),
|
||||
patch.object(file_reveal, "_macos_open", return_value=None),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_relative_path_lookup_never_counts(self):
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which({"dbus-send": "./dbus-send", "xdg-open": "bin/xdg-open"}),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_posix_opener_lookup_does_not_depend_on_the_host_path_rules(self):
|
||||
# Python 3.13 on Windows no longer treats "/usr/bin/x" as absolute. The
|
||||
# Linux and macOS openers are POSIX paths whatever host runs the code,
|
||||
# so a host os.path with those rules must not reject them.
|
||||
windows_313 = SimpleNamespace(isabs=lambda value: False)
|
||||
with (
|
||||
patch.object(file_reveal.os, "path", windows_313),
|
||||
self.which({"xdg-open": "/usr/bin/xdg-open", "rel": "bin/rel"}),
|
||||
):
|
||||
self.assertEqual(file_reveal._executable("xdg-open"), "/usr/bin/xdg-open")
|
||||
self.assertIsNone(file_reveal._executable("rel"))
|
||||
|
||||
def test_children_get_a_minimal_environment(self):
|
||||
inherited = {
|
||||
"PATH": "/venv/bin:/usr/bin",
|
||||
"PYTHONPATH": "/comfy",
|
||||
"LD_LIBRARY_PATH": "/venv/lib",
|
||||
"VIRTUAL_ENV": "/venv",
|
||||
"CIVITAI_API_KEY": "secret",
|
||||
"HOME": "/home/u",
|
||||
"DISPLAY": ":0",
|
||||
"WAYLAND_DISPLAY": "wayland-0",
|
||||
"XDG_RUNTIME_DIR": "/run/user/1000",
|
||||
"XDG_CURRENT_DESKTOP": "KDE",
|
||||
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
|
||||
"LANG": "en_US.UTF-8",
|
||||
# What a desktop opener can need (final review m1): the X11
|
||||
# cookie under GDM/SDDM, the session and the user's mimeapps.
|
||||
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
|
||||
"DESKTOP_SESSION": "plasma",
|
||||
"XDG_SESSION_DESKTOP": "KDE",
|
||||
"XDG_CONFIG_HOME": "/home/u/.config",
|
||||
"XDG_DATA_DIRS": "/usr/share",
|
||||
}
|
||||
with patch.dict(os.environ, inherited, clear=True):
|
||||
env = file_reveal._child_env()
|
||||
self.assertEqual(
|
||||
env,
|
||||
{
|
||||
"PATH": file_reveal.SYSTEM_PATH,
|
||||
"HOME": "/home/u",
|
||||
"DISPLAY": ":0",
|
||||
"WAYLAND_DISPLAY": "wayland-0",
|
||||
"XDG_RUNTIME_DIR": "/run/user/1000",
|
||||
"XDG_CURRENT_DESKTOP": "KDE",
|
||||
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
|
||||
"LANG": "en_US.UTF-8",
|
||||
"XAUTHORITY": "/run/user/1000/gdm/Xauthority",
|
||||
"DESKTOP_SESSION": "plasma",
|
||||
"XDG_SESSION_DESKTOP": "KDE",
|
||||
"XDG_CONFIG_HOME": "/home/u/.config",
|
||||
"XDG_DATA_DIRS": "/usr/share",
|
||||
},
|
||||
)
|
||||
self.assertNotIn("/venv", file_reveal.SYSTEM_PATH)
|
||||
|
||||
def test_linux_asks_the_file_manager_service_and_waits_for_its_reply(self):
|
||||
self.popen.return_value.wait.return_value = 0
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which({"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/x"}),
|
||||
):
|
||||
self.reveal()
|
||||
self.assertEqual(
|
||||
self.argv(),
|
||||
[
|
||||
"/usr/bin/dbus-send",
|
||||
"--session",
|
||||
"--print-reply",
|
||||
"--reply-timeout=2000",
|
||||
"--dest=org.freedesktop.FileManager1",
|
||||
"--type=method_call",
|
||||
"/org/freedesktop/FileManager1",
|
||||
"org.freedesktop.FileManager1.ShowItems",
|
||||
f"array:string:{self.path.as_uri()}",
|
||||
"string:",
|
||||
],
|
||||
)
|
||||
self.assertEqual(self.popen.call_count, 1)
|
||||
self.popen.return_value.wait.assert_called_once_with(
|
||||
timeout=file_reveal.DBUS_WAIT
|
||||
)
|
||||
self.assert_detached()
|
||||
self.reaped.assert_not_called()
|
||||
|
||||
def test_linux_falls_back_to_xdg_open_on_the_folder(self):
|
||||
both = {"dbus-send": "/usr/bin/dbus-send", "xdg-open": "/usr/bin/xdg-open"}
|
||||
cases = {
|
||||
"missing": ({"xdg-open": "/usr/bin/xdg-open"}, None),
|
||||
"no reply": (both, 1),
|
||||
"start error": (both, OSError("no bus")),
|
||||
"hung": (both, subprocess.TimeoutExpired("dbus-send", 3)),
|
||||
}
|
||||
for name, (available, outcome) in cases.items():
|
||||
with self.subTest(name):
|
||||
self.popen.reset_mock(side_effect=True, return_value=True)
|
||||
self.reaped.reset_mock()
|
||||
dbus = MagicMock()
|
||||
if isinstance(outcome, OSError):
|
||||
self.popen.side_effect = [outcome, MagicMock()]
|
||||
elif isinstance(outcome, subprocess.TimeoutExpired):
|
||||
dbus.wait.side_effect = [outcome, None]
|
||||
self.popen.side_effect = [dbus, MagicMock()]
|
||||
else:
|
||||
self.popen.return_value.wait.return_value = outcome
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which(available),
|
||||
):
|
||||
self.reveal()
|
||||
self.assertEqual(
|
||||
self.popen.call_args.args[0],
|
||||
["/usr/bin/xdg-open", str(self.path.parent)],
|
||||
)
|
||||
self.assert_detached(self.popen.call_count - 1)
|
||||
self.reaped.assert_called_once()
|
||||
if name == "hung":
|
||||
dbus.kill.assert_called_once_with()
|
||||
|
||||
def test_linux_without_any_opener_is_unavailable(self):
|
||||
with (
|
||||
patch.object(file_reveal.sys, "platform", "linux"),
|
||||
self.which({}),
|
||||
self.assertRaises(OpenerUnavailable),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_relative_paths_are_refused(self):
|
||||
with self.assertRaises(ValueError):
|
||||
reveal_in_file_manager(Path("sub/a.png"), self.root)
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_containment_is_rechecked_just_before_launching(self):
|
||||
# A parent swapped for a symlink after the route resolved the path
|
||||
# (security review L4) must not open a folder outside the root.
|
||||
outside = self.root.parent / "elsewhere"
|
||||
outside.mkdir()
|
||||
(outside / "a.png").write_bytes(b"png")
|
||||
(self.root / "sub" / "a.png").unlink()
|
||||
(self.root / "sub").rmdir()
|
||||
try:
|
||||
os.symlink(outside, self.root / "sub", target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
self.skipTest("symlinks unavailable")
|
||||
missing = self.root / "gone.png"
|
||||
folder = self.root / "folder.png"
|
||||
folder.mkdir()
|
||||
for path in (self.path, missing, folder):
|
||||
for platform in ("linux", "darwin", "win32"):
|
||||
with (
|
||||
self.subTest(path=path.name, platform=platform),
|
||||
patch.object(file_reveal.sys, "platform", platform),
|
||||
patch.object(file_reveal, "_windows_libraries") as shell,
|
||||
self.assertRaises(file_reveal.RevealTargetChanged),
|
||||
):
|
||||
self.reveal(path)
|
||||
shell.assert_not_called()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
def test_a_path_on_another_drive_is_not_contained(self):
|
||||
# Windows: commonpath raises ValueError across drives.
|
||||
with (
|
||||
patch.object(file_reveal.os.path, "commonpath", side_effect=ValueError),
|
||||
self.assertRaises(file_reveal.RevealTargetChanged),
|
||||
):
|
||||
self.reveal()
|
||||
self.popen.assert_not_called()
|
||||
|
||||
|
||||
class TestReaping(unittest.TestCase):
|
||||
def test_reap_waits_on_a_daemon_thread(self):
|
||||
process = MagicMock()
|
||||
with patch.object(file_reveal.threading, "Thread") as thread:
|
||||
file_reveal._reap(process)
|
||||
thread.assert_called_once()
|
||||
kwargs = thread.call_args.kwargs
|
||||
self.assertIs(kwargs["target"], process.wait)
|
||||
self.assertIs(kwargs["daemon"], True)
|
||||
thread.return_value.start.assert_called_once_with()
|
||||
|
||||
def test_reveal_runs_on_its_own_single_worker(self):
|
||||
executor = file_reveal.reveal_executor()
|
||||
self.assertIs(file_reveal.reveal_executor(), executor)
|
||||
self.assertEqual(executor._max_workers, 1)
|
||||
name = executor.submit(lambda: file_reveal.threading.current_thread().name)
|
||||
self.assertTrue(name.result(timeout=5).startswith("pm-reveal"))
|
||||
|
||||
|
||||
class FakeShell:
|
||||
"""ole32 and shell32 stand-ins recording the shell calls (H1)."""
|
||||
|
||||
def __init__(self, init=0, pidl=1234, select=0):
|
||||
self.calls = []
|
||||
self.ole32 = types.SimpleNamespace(
|
||||
CoInitializeEx=self.fn("CoInitializeEx", init),
|
||||
CoUninitialize=self.fn("CoUninitialize", None),
|
||||
)
|
||||
self.shell32 = types.SimpleNamespace(
|
||||
ILCreateFromPathW=self.fn("ILCreateFromPathW", pidl),
|
||||
SHOpenFolderAndSelectItems=self.fn("SHOpenFolderAndSelectItems", select),
|
||||
ILFree=self.fn("ILFree", None),
|
||||
)
|
||||
|
||||
def fn(self, name, result):
|
||||
def call(*args):
|
||||
self.calls.append((name, args))
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
return MagicMock(side_effect=call)
|
||||
|
||||
def names(self):
|
||||
return [name for name, _ in self.calls]
|
||||
|
||||
|
||||
class TestWindowsReveal(unittest.TestCase):
|
||||
"""The Windows opener with the shell libraries and os.startfile faked.
|
||||
The folder fallback is recorded in the same call list, so its place
|
||||
inside the COM bracket is visible (final review m2)."""
|
||||
|
||||
def reveal(self, shell, path=r"C:\out\a,\b.png"):
|
||||
def startfile(*args):
|
||||
shell.calls.append(("startfile", args))
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
file_reveal,
|
||||
"_windows_libraries",
|
||||
return_value=(shell.ole32, shell.shell32),
|
||||
),
|
||||
patch.object(
|
||||
file_reveal.os,
|
||||
"startfile",
|
||||
MagicMock(side_effect=startfile),
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
file_reveal._reveal_windows(Path(path))
|
||||
return shell
|
||||
|
||||
def test_selects_the_item_and_frees_everything(self):
|
||||
path = r"C:\out\a,\Windows\System32\calc.exe,b\x.png"
|
||||
shell = self.reveal(FakeShell(), path)
|
||||
self.assertEqual(
|
||||
shell.names(),
|
||||
[
|
||||
"CoInitializeEx",
|
||||
"ILCreateFromPathW",
|
||||
"SHOpenFolderAndSelectItems",
|
||||
"ILFree",
|
||||
"CoUninitialize",
|
||||
],
|
||||
)
|
||||
self.assertEqual(dict(shell.calls)["ILCreateFromPathW"], (str(Path(path)),))
|
||||
self.assertEqual(
|
||||
dict(shell.calls)["SHOpenFolderAndSelectItems"], (1234, 0, None, 0)
|
||||
)
|
||||
self.assertEqual(dict(shell.calls)["ILFree"], (1234,))
|
||||
|
||||
def test_declares_the_shell_signatures(self):
|
||||
shell = self.reveal(FakeShell())
|
||||
create = shell.shell32.ILCreateFromPathW
|
||||
self.assertEqual(create.argtypes, [ctypes.c_wchar_p])
|
||||
self.assertIs(create.restype, ctypes.c_void_p)
|
||||
open_select = shell.shell32.SHOpenFolderAndSelectItems
|
||||
self.assertEqual(
|
||||
open_select.argtypes,
|
||||
[ctypes.c_void_p, ctypes.c_uint, ctypes.c_void_p, ctypes.c_ulong],
|
||||
)
|
||||
self.assertIs(open_select.restype, ctypes.c_long)
|
||||
self.assertEqual(shell.shell32.ILFree.argtypes, [ctypes.c_void_p])
|
||||
self.assertEqual(
|
||||
shell.ole32.CoInitializeEx.argtypes, [ctypes.c_void_p, ctypes.c_ulong]
|
||||
)
|
||||
self.assertIs(shell.ole32.CoInitializeEx.restype, ctypes.c_long)
|
||||
|
||||
def test_already_initialized_com_is_used_and_left_alone(self):
|
||||
shell = self.reveal(FakeShell(init=file_reveal.RPC_E_CHANGED_MODE))
|
||||
self.assertIn("SHOpenFolderAndSelectItems", shell.names())
|
||||
self.assertNotIn("CoUninitialize", shell.names())
|
||||
self.assertNotIn("startfile", shell.names())
|
||||
shell = self.reveal(FakeShell(init=file_reveal.S_FALSE))
|
||||
self.assertNotIn("startfile", shell.names())
|
||||
self.assertEqual(shell.names()[-1], "CoUninitialize")
|
||||
|
||||
def test_the_folder_fallback_runs_inside_com(self):
|
||||
# ShellExecute callers should initialize COM first (final review m2).
|
||||
for name, shell in {
|
||||
"no pidl": FakeShell(pidl=None),
|
||||
"select failed": FakeShell(select=-2147467259),
|
||||
"raises": FakeShell(select=OSError("access violation")),
|
||||
}.items():
|
||||
with self.subTest(name):
|
||||
names = self.reveal(shell).names()
|
||||
self.assertEqual(names[0], "CoInitializeEx")
|
||||
self.assertEqual(names[-2:], ["startfile", "CoUninitialize"])
|
||||
if name != "no pidl":
|
||||
self.assertIn("ILFree", names)
|
||||
|
||||
def test_failed_com_still_opens_the_folder_without_balancing(self):
|
||||
for init in (-2147024882, OSError("no ole32 export")):
|
||||
with self.subTest(init=init):
|
||||
names = self.reveal(FakeShell(init=init)).names()
|
||||
self.assertEqual(names, ["CoInitializeEx", "startfile"])
|
||||
|
||||
def test_missing_libraries_open_the_folder(self):
|
||||
startfile = MagicMock()
|
||||
with (
|
||||
patch.object(file_reveal, "_windows_libraries", side_effect=OSError),
|
||||
patch.object(file_reveal.os, "startfile", startfile, create=True),
|
||||
):
|
||||
file_reveal._reveal_windows(Path(r"C:\out\a.png"))
|
||||
startfile.assert_called_once_with(str(Path(r"C:\out\a.png").parent), "explore")
|
||||
|
||||
def test_libraries_are_unavailable_off_windows(self):
|
||||
if sys.platform == "win32":
|
||||
self.skipTest("loads the real shell libraries on Windows")
|
||||
with self.assertRaises((AttributeError, OSError)):
|
||||
file_reveal._windows_libraries()
|
||||
|
||||
|
||||
@unittest.skipUnless(sys.platform == "win32", "the real Windows shell libraries")
|
||||
class TestRealWindowsShell(unittest.TestCase):
|
||||
"""Final review I1: the declared signatures against the real ole32 and
|
||||
shell32, without SHOpenFolderAndSelectItems (no window opens on CI)."""
|
||||
|
||||
def setUp(self):
|
||||
self.ole32, self.shell32 = file_reveal._windows_libraries()
|
||||
file_reveal._declare_shell_signatures(self.ole32, self.shell32)
|
||||
|
||||
def test_a_pidl_is_created_for_a_real_file_and_freed(self):
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as folder:
|
||||
path = Path(folder) / "a,b.png"
|
||||
path.write_bytes(b"png") # write_bytes closes its handle
|
||||
pidl = self.shell32.ILCreateFromPathW(str(path))
|
||||
self.assertIsInstance(pidl, int)
|
||||
self.assertNotEqual(pidl, 0)
|
||||
self.shell32.ILFree(pidl)
|
||||
self.assertIsNone(self.shell32.ILCreateFromPathW(str(path) + ".gone"))
|
||||
path.unlink()
|
||||
|
||||
def probe(self):
|
||||
"""CoInitializeEx's answer on this thread, balanced at once."""
|
||||
result = self.ole32.CoInitializeEx(
|
||||
None,
|
||||
file_reveal.COINIT_APARTMENTTHREADED | file_reveal.COINIT_DISABLE_OLE1DDE,
|
||||
)
|
||||
if result in (file_reveal.S_OK, file_reveal.S_FALSE):
|
||||
self.ole32.CoUninitialize()
|
||||
return result
|
||||
|
||||
def test_com_is_entered_and_balanced_through_the_module_helper(self):
|
||||
before = self.probe()
|
||||
with file_reveal._com_apartment(self.ole32) as ready:
|
||||
self.assertIs(ready, True)
|
||||
# An unbalanced bracket would leave COM initialized: S_FALSE after S_OK.
|
||||
self.assertEqual(self.probe(), before)
|
||||
|
||||
|
||||
class TestRevealRoute(ImageAPITestCase):
|
||||
async def get_application(self):
|
||||
app = await super().get_application()
|
||||
self.opened, self.roots, self.threads = [], [], []
|
||||
self.outcome = None
|
||||
|
||||
def opener(path, root):
|
||||
self.threads.append(file_reveal.threading.current_thread().name)
|
||||
if self.outcome is not None:
|
||||
raise self.outcome
|
||||
self.opened.append(path)
|
||||
self.roots.append(root)
|
||||
|
||||
self.api._reveal_opener = opener
|
||||
self.api._reveal_gate = file_reveal.RevealGate(cooldown=0)
|
||||
return app
|
||||
|
||||
def origin(self):
|
||||
return str(self.client.make_url("/").origin())
|
||||
|
||||
def scope(self, index=0):
|
||||
roots = self.api._get_all_output_dirs()
|
||||
return self.api._gallery_root_scope(roots[index], index)
|
||||
|
||||
async def post(self, body, **request_headers):
|
||||
# Like the workspace's fetch: a same-origin JSON POST.
|
||||
sent = {"Origin": self.origin(), **request_headers}
|
||||
sent = {key: value for key, value in sent.items() if value is not None}
|
||||
response = await self.client.post(REVEAL, json=body, headers=sent)
|
||||
return response.status, await response.json()
|
||||
|
||||
def body(self, path="sub/a.png", **changes):
|
||||
return {"root_index": 0, "root_scope": self.scope(), "path": path, **changes}
|
||||
|
||||
async def test_loopback_request_opens_the_resolved_file(self):
|
||||
target = make_png(self.output_dir / "sub" / "a.png")
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual((status, data), (200, {"success": True}))
|
||||
self.assertEqual(self.opened, [Path(os.path.realpath(target))])
|
||||
self.assertEqual(self.roots, [self.output_dir])
|
||||
# Its own worker, never ComfyUI's shared default executor (L1).
|
||||
self.assertTrue(self.threads[0].startswith("pm-reveal"), self.threads)
|
||||
|
||||
async def test_target_changed_before_launch_is_404(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = file_reveal.RevealTargetChanged()
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual(data, {"success": False, "error": "Image file not found"})
|
||||
|
||||
async def test_non_loopback_remote_is_not_local(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
with patch.object(
|
||||
web.BaseRequest, "remote", new_callable=PropertyMock
|
||||
) as remote:
|
||||
remote.return_value = "192.168.1.20"
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 403)
|
||||
self.assertEqual(data["code"], "not-local")
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_loopback_with_a_proxy_header_is_not_local(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
for name in PROXY_HEADERS:
|
||||
with self.subTest(header=name):
|
||||
status, data = await self.post(self.body(), **{name: "127.0.0.1"})
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
status, data = await self.post(self.body(), Host="evil.example:8188")
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_missing_or_foreign_origin_is_not_local(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
port = self.client.make_url("/").port
|
||||
for origin in (None, f"http://127.0.0.1:{port + 1}", "null"):
|
||||
with self.subTest(origin=origin):
|
||||
status, data = await self.post(self.body(), Origin=origin)
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
status, data = await self.post(self.body(), **{"Sec-Fetch-Site": "same-site"})
|
||||
self.assertEqual((status, data["code"]), (403, "not-local"))
|
||||
status, _ = await self.post(self.body(), **{"Sec-Fetch-Site": "same-origin"})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(self.opened), 1)
|
||||
|
||||
async def test_only_json_bodies_are_accepted(self):
|
||||
# text/plain would be a CORS "simple" request with no preflight (L2).
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
body = json.dumps(self.body())
|
||||
for content_type in ("text/plain", "application/x-www-form-urlencoded", None):
|
||||
with self.subTest(content_type=content_type):
|
||||
sent = {"Origin": self.origin()}
|
||||
if content_type:
|
||||
sent["Content-Type"] = content_type
|
||||
response = await self.client.post(
|
||||
REVEAL, data=body.encode(), headers=sent
|
||||
)
|
||||
self.assertEqual(response.status, 415)
|
||||
data = await response.json()
|
||||
self.assertEqual(
|
||||
(data["success"], data["code"]), (False, "unsupported-media-type")
|
||||
)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_a_reveal_in_progress_or_cooling_down_is_busy(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
gate = self.api._reveal_gate
|
||||
self.assertTrue(gate.try_enter())
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 429)
|
||||
self.assertEqual(data, {**data, "success": False, "code": "busy"})
|
||||
gate.leave()
|
||||
now = [50.0]
|
||||
self.api._reveal_gate = file_reveal.RevealGate(
|
||||
cooldown=1.0, clock=lambda: now[0]
|
||||
)
|
||||
statuses = [(await self.post(self.body()))[0] for _ in range(2)]
|
||||
now[0] += 1.0
|
||||
statuses.append((await self.post(self.body()))[0])
|
||||
self.assertEqual(statuses, [200, 429, 200])
|
||||
self.assertEqual(len(self.opened), 2)
|
||||
|
||||
async def test_a_failed_reveal_releases_the_gate(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = OSError("cannot start")
|
||||
status, _ = await self.post(self.body())
|
||||
self.assertEqual(status, 500)
|
||||
self.outcome = None
|
||||
status, _ = await self.post(self.body())
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
async def test_default_gate_is_one_per_process_with_a_cooldown(self):
|
||||
gate = type(self.api)._reveal_gate
|
||||
self.assertIsInstance(gate, file_reveal.RevealGate)
|
||||
self.assertEqual(gate.cooldown, file_reveal.REVEAL_COOLDOWN)
|
||||
self.assertGreaterEqual(file_reveal.REVEAL_COOLDOWN, 1.0)
|
||||
|
||||
async def test_resolution_errors_are_404_and_never_log_the_path(self):
|
||||
# ENAMETOOLONG and friends must not escape as a non-JSON 500 (L3).
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
secret = "sub/" + "n" * 300 + "/a.png"
|
||||
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
|
||||
self.api.logger.debug("start")
|
||||
status, data = await self.post(self.body(path=secret))
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual(data, {"success": False, "error": "Image file not found"})
|
||||
with patch.object(
|
||||
type(self.api),
|
||||
"_reveal_path",
|
||||
staticmethod(MagicMock(side_effect=OSError(36, "too long", secret))),
|
||||
):
|
||||
status, data = await self.post(self.body(path=secret))
|
||||
self.assertEqual(status, 404)
|
||||
self.assertIs(data["success"], False)
|
||||
for record in logs.records:
|
||||
if record.levelno >= logging.WARNING:
|
||||
self.assertNotIn("n" * 300, record.getMessage())
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_start_failure_never_logs_the_path_at_error_level(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = OSError(2, "No such file", str(self.output_dir / "sub"))
|
||||
with self.assertLogs(self.api.logger, level="DEBUG") as logs:
|
||||
status, _ = await self.post(self.body())
|
||||
self.assertEqual(status, 500)
|
||||
errors = [r for r in logs.records if r.levelno >= logging.ERROR]
|
||||
self.assertTrue(errors)
|
||||
for record in errors:
|
||||
self.assertNotIn(str(self.output_dir), record.getMessage())
|
||||
self.assertIsNone(record.exc_info)
|
||||
|
||||
async def test_changed_or_missing_scope_is_rejected(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
status, data = await self.post(self.body(root_scope="0" * 64))
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
status, data = await self.post(self.body(root_index=3))
|
||||
self.assertEqual((status, data["code"]), (409, "source-changed"))
|
||||
for scope in (None, "ABC", 7, "g" * 64):
|
||||
with self.subTest(scope=scope):
|
||||
status, _ = await self.post(self.body(root_scope=scope))
|
||||
self.assertEqual(status, 400)
|
||||
body = self.body()
|
||||
del body["root_scope"]
|
||||
status, _ = await self.post(body)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_body_types_are_strict(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
for changes in (
|
||||
{"root_index": True},
|
||||
{"root_index": "0"},
|
||||
{"root_index": -1},
|
||||
{"root_index": 0.0},
|
||||
{"path": 5},
|
||||
{"path": ""},
|
||||
{"path": "a" * 5000 + ".png"},
|
||||
{"path": "sub/a\0.png"},
|
||||
):
|
||||
with self.subTest(changes=changes):
|
||||
status, data = await self.post(self.body(**changes))
|
||||
self.assertEqual(status, 400, data)
|
||||
response = await self.client.post(
|
||||
REVEAL,
|
||||
data="[1]",
|
||||
headers={"Origin": self.origin(), "Content-Type": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_traversal_and_absolute_paths_are_rejected(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
for path in (
|
||||
"../outside.png",
|
||||
"sub/../../outside.png",
|
||||
"sub\\..\\..\\outside.png",
|
||||
str(outside),
|
||||
"/" + outside.name,
|
||||
"C:/outside.png",
|
||||
"C:outside.png",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
status, data = await self.post(self.body(path=path))
|
||||
self.assertEqual(status, 400, data)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_symlink_out_of_the_root_is_refused(self):
|
||||
outside = make_png(self.comfy_dir / "outside.png")
|
||||
self._make_symlink(outside, self.output_dir / "link.png")
|
||||
status, _ = await self.post(self.body(path="link.png"))
|
||||
self.assertIn(status, (403, 404))
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_missing_non_media_and_non_regular_files(self):
|
||||
(self.output_dir / "folder.png").mkdir()
|
||||
(self.output_dir / "notes.txt").write_text("x")
|
||||
for path, expected in (
|
||||
("sub/missing.png", 404),
|
||||
("folder.png", 404),
|
||||
("notes.txt", 403),
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
status, data = await self.post(self.body(path=path))
|
||||
self.assertEqual(status, expected, data)
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
async def test_unavailable_opener_is_501_and_start_failure_500(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
self.outcome = OpenerUnavailable()
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 501)
|
||||
self.assertEqual(data, {**data, "success": False, "code": "unavailable"})
|
||||
self.outcome = OSError("cannot start")
|
||||
status, data = await self.post(self.body())
|
||||
self.assertEqual(status, 500)
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertNotIn("cannot start", data["error"])
|
||||
|
||||
async def test_default_opener_is_the_platform_file_manager(self):
|
||||
self.assertIs(type(self.api)._reveal_opener, file_reveal.reveal_in_file_manager)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -36,7 +36,8 @@ LIBRARY = {
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
NODE_TIMEOUT_SECONDS = 120
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ def encode(read, *, prompts=None, file=None, library=None):
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -663,6 +664,21 @@ class TestInspectionResponse(unittest.TestCase):
|
||||
for key in ("positive_id", "pair_id", "category", "title"):
|
||||
self.assertNotIn(key, result["library"])
|
||||
self.assertEqual(result["library"]["tags"], ["valid"])
|
||||
# An unknown or non-string scope is dropped, never passed through.
|
||||
for bad in ("recipe", 3, ["pair"]):
|
||||
result = json.loads(
|
||||
encode(
|
||||
None,
|
||||
library={
|
||||
"status": "linked",
|
||||
"category_scope": bad,
|
||||
"tags_scope": bad,
|
||||
"tags": [],
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertNotIn("category_scope", result["library"])
|
||||
self.assertNotIn("tags_scope", result["library"])
|
||||
result = json.loads(
|
||||
encode(inspection({}), library={"status": "linked", "tags": ["猫" * 12000]})
|
||||
)
|
||||
|
||||
@@ -92,6 +92,17 @@ class TestLibrary(unittest.TestCase):
|
||||
self.db.list_library_images(positive, pair_id=unrelated)
|
||||
self.assertIsNone(self.db.get_library_positive(9999))
|
||||
|
||||
def test_detail_variants_carry_their_pair_run_count(self):
|
||||
counted = self.db.save_prompt_pair("Cat", "Blur")
|
||||
fresh = self.db.save_prompt_pair("Cat", "Noise")
|
||||
self.db.record_prompt_pair_use(counted, 3)
|
||||
positive = self.db.get_prompt_pair(counted)["positive_prompt_id"]
|
||||
variants = {
|
||||
row["id"]: row for row in self.db.get_library_positive(positive)["variants"]
|
||||
}
|
||||
self.assertEqual(variants[counted]["run_count"], 3)
|
||||
self.assertEqual(variants[fresh]["run_count"], 0)
|
||||
|
||||
def test_options_include_active_pair_categories_not_negative_only_or_archived(self):
|
||||
self.save("Legacy", category="Legacy category")
|
||||
pair = self.db.save_prompt_pair("Cat", "Noise", category="Pair category")
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
"""Saved views (D16): the persisted store, its restore schema and the API."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase
|
||||
from open_handles import assert_closed
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase
|
||||
from tests.open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from database.saved_views import (
|
||||
MAX_STATE_BYTES,
|
||||
TABLE,
|
||||
SavedViewInvalid,
|
||||
encode_state,
|
||||
migrate_saved_views,
|
||||
normalize_state,
|
||||
saved_views_schema_error,
|
||||
validate_name,
|
||||
)
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
URL = "/prompt_manager/saved-views"
|
||||
|
||||
IMAGES_STATE = {
|
||||
"q": "cat",
|
||||
"category": "Animals",
|
||||
"sort": "newest",
|
||||
"source": "all",
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": ["fluffy"],
|
||||
"favorites": False,
|
||||
}
|
||||
LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []}
|
||||
IMAGES_DEFAULTS = {
|
||||
"q": "",
|
||||
"category": "",
|
||||
"sort": "newest",
|
||||
"source": "all",
|
||||
"folder": "",
|
||||
"media": "all",
|
||||
"tags": [],
|
||||
"favorites": False,
|
||||
}
|
||||
LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []}
|
||||
# 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(
|
||||
{
|
||||
"q": "sunset",
|
||||
"category": "",
|
||||
"sort": "shuffle",
|
||||
"source": "all",
|
||||
"folder": "x" * 2000,
|
||||
"media": "image",
|
||||
"tags": ["golden"],
|
||||
"layout": "grid",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestSavedViewsApi(ImageAPITestCase):
|
||||
async def send(self, method, path="", body=None, **kwargs):
|
||||
if body is not None:
|
||||
kwargs["json"] = body
|
||||
response = await self.client.request(method, URL + path, **kwargs)
|
||||
return response, await response.json()
|
||||
|
||||
async def create(self, page="images", name="Cats", state=None):
|
||||
state = dict(
|
||||
IMAGES_STATE if page == "images" else LIBRARY_STATE, **(state or {})
|
||||
)
|
||||
response, data = await self.send(
|
||||
"POST", body={"page": page, "name": name, "state": state}
|
||||
)
|
||||
self.assertEqual(response.status, 201, data)
|
||||
return data["view"]
|
||||
|
||||
async def test_create_list_rename_delete_round_trip(self):
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data, {"success": True, "views": []})
|
||||
|
||||
first = await self.create(name=" Cats ")
|
||||
self.assertEqual(
|
||||
first,
|
||||
{
|
||||
"id": first["id"],
|
||||
"page": "images",
|
||||
"name": "Cats",
|
||||
"state": IMAGES_STATE,
|
||||
},
|
||||
)
|
||||
second = await self.create(page="library", name="Best", state={})
|
||||
_, data = await self.send("GET")
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["views"], [first, second])
|
||||
|
||||
response, data = await self.send(
|
||||
"PATCH", f"/{first['id']}", {"name": " Kittens "}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data, {"success": True, "view": {**first, "name": "Kittens"}})
|
||||
_, listed = await self.send("GET")
|
||||
# Renaming keeps the view's place in the list.
|
||||
self.assertEqual(
|
||||
[view["name"] for view in listed["views"]], ["Kittens", "Best"]
|
||||
)
|
||||
|
||||
response, data = await self.send("DELETE", f"/{first['id']}")
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data, {"success": True, "id": first["id"]})
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(listed["views"], [second])
|
||||
|
||||
for method, body in (("PATCH", {"name": "Again"}), ("DELETE", None)):
|
||||
response, data = await self.send(method, f"/{first['id']}", body)
|
||||
self.assertEqual(response.status, 404)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIn("error", data)
|
||||
|
||||
async def test_views_are_ordered_by_position_and_ids_are_not_reused(self):
|
||||
views = [await self.create(name=f"View {n}") for n in range(3)]
|
||||
await self.send("DELETE", f"/{views[2]['id']}")
|
||||
later = await self.create(name="Later")
|
||||
self.assertGreater(later["id"], views[2]["id"])
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE {TABLE} SET position = -1 WHERE id = ?", (views[1]["id"],)
|
||||
)
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(
|
||||
[view["name"] for view in listed["views"]], ["View 1", "View 0", "Later"]
|
||||
)
|
||||
|
||||
async def test_create_rejects_invalid_bodies(self):
|
||||
good = {"page": "images", "name": "Cats", "state": IMAGES_STATE}
|
||||
cases = [
|
||||
["not an object"],
|
||||
{"page": "images", "name": "Cats"},
|
||||
{**good, "extra": 1},
|
||||
{**good, "page": "history"},
|
||||
{**good, "page": 1},
|
||||
{**good, "name": ""},
|
||||
{**good, "name": " "},
|
||||
{**good, "name": "x" * 101},
|
||||
{**good, "name": 7},
|
||||
{**good, "name": "bad\nname"},
|
||||
{**good, "state": "q=cat"},
|
||||
{**good, "state": None},
|
||||
{**good, "state": {**IMAGES_STATE, "unknown": ""}},
|
||||
{**good, "state": {k: v for k, v in IMAGES_STATE.items() if k != "q"}},
|
||||
{**good, "state": {**IMAGES_STATE, "q": 5}},
|
||||
{**good, "state": {**IMAGES_STATE, "q": "x" * 501}},
|
||||
{**good, "state": {**IMAGES_STATE, "category": "x" * 101}},
|
||||
{**good, "state": {**IMAGES_STATE, "sort": "random"}},
|
||||
{**good, "state": {**IMAGES_STATE, "media": "audio"}},
|
||||
{**good, "state": {**IMAGES_STATE, "source": "-1"}},
|
||||
{**good, "state": {**IMAGES_STATE, "source": 0}},
|
||||
{**good, "state": {**IMAGES_STATE, "folder": None}},
|
||||
{**good, "state": {**IMAGES_STATE, "folder": "x" * 1025}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": "cat"}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": [1]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": [" "]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}},
|
||||
{**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}},
|
||||
# Favorites (the Favorites page's filter) is a JSON boolean.
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": "true"}},
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": 1}},
|
||||
{**good, "state": {**IMAGES_STATE, "favorites": None}},
|
||||
{
|
||||
**good,
|
||||
"state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"},
|
||||
},
|
||||
# Library has no source, folder or media, and its own sorts.
|
||||
{"page": "library", "name": "L", "state": IMAGES_STATE},
|
||||
{
|
||||
"page": "library",
|
||||
"name": "L",
|
||||
"state": {**LIBRARY_STATE, "sort": "name-desc"},
|
||||
},
|
||||
]
|
||||
for body in cases:
|
||||
with self.subTest(body=body):
|
||||
response, data = await self.send("POST", body=body)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertIsInstance(data["error"], str)
|
||||
response = await self.client.post(URL, data="{not json")
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertFalse((await response.json())["success"])
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(listed["views"], [])
|
||||
|
||||
async def test_encoded_state_is_capped(self):
|
||||
# Each value is within its own limit; together they pass 4 KB.
|
||||
state = {
|
||||
**IMAGES_STATE,
|
||||
"q": "é" * 500,
|
||||
"folder": "é" * 1024,
|
||||
"tags": ["é" * 50 for _ in range(20)],
|
||||
}
|
||||
encoded = json.dumps(state, ensure_ascii=False, separators=(",", ":"))
|
||||
self.assertGreater(len(encoded.encode("utf-8")), MAX_STATE_BYTES)
|
||||
response, data = await self.send(
|
||||
"POST", body={"page": "images", "name": "Big", "state": state}
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertIn("4 KB", data["error"])
|
||||
|
||||
async def test_every_documented_value_is_accepted(self):
|
||||
for sort in ("newest", "oldest", "name", "name-desc", "rating"):
|
||||
for media in ("all", "image", "video"):
|
||||
await self.create(state={"sort": sort, "media": media, "source": "3"})
|
||||
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"]})
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(len(listed["views"]), 20)
|
||||
|
||||
async def test_rename_and_delete_validate_id_and_body(self):
|
||||
view = await self.create()
|
||||
for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"):
|
||||
for method, body in (("PATCH", {"name": "X"}), ("DELETE", None)):
|
||||
with self.subTest(path=path, method=method):
|
||||
response, data = await self.send(method, path, body)
|
||||
self.assertEqual(response.status, 400)
|
||||
self.assertFalse(data["success"])
|
||||
for body in ({}, {"name": ""}, {"name": "X", "page": "library"}, ["X"]):
|
||||
with self.subTest(body=body):
|
||||
response, _ = await self.send("PATCH", f"/{view['id']}", body)
|
||||
self.assertEqual(response.status, 400)
|
||||
_, listed = await self.send("GET")
|
||||
self.assertEqual(listed["views"], [view])
|
||||
|
||||
async def test_database_failure_is_a_500_envelope(self):
|
||||
def broken():
|
||||
raise sqlite3.OperationalError("disk I/O error")
|
||||
|
||||
self.api.db.list_saved_views = broken
|
||||
with self.assertLogs("prompt_manager.api", level="ERROR"):
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertEqual(
|
||||
data, {"success": False, "error": "Saved view operation failed"}
|
||||
)
|
||||
|
||||
async def test_internal_value_errors_are_a_generic_500(self):
|
||||
# Only the request's own validation is a 400; a ValueError or
|
||||
# TypeError raised by the database layer is a server fault (M4).
|
||||
for error in (
|
||||
ValueError("secret parser detail"),
|
||||
TypeError("secret type detail"),
|
||||
json.JSONDecodeError("secret json detail", "{", 0),
|
||||
):
|
||||
|
||||
def broken(*_args, error=error):
|
||||
raise error
|
||||
|
||||
for method, path, body, attr in (
|
||||
("GET", "", None, "list_saved_views"),
|
||||
(
|
||||
"POST",
|
||||
"",
|
||||
{"page": "library", "name": "L", "state": LIBRARY_STATE},
|
||||
"create_saved_view",
|
||||
),
|
||||
("PATCH", "/1", {"name": "X"}, "rename_saved_view"),
|
||||
("DELETE", "/1", None, "delete_saved_view"),
|
||||
):
|
||||
with self.subTest(error=type(error).__name__, method=method):
|
||||
setattr(self.api.db, attr, broken)
|
||||
try:
|
||||
with self.assertLogs("prompt_manager.api", level="ERROR"):
|
||||
response, data = await self.send(method, path, body)
|
||||
finally:
|
||||
delattr(self.api.db, attr)
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertEqual(
|
||||
data,
|
||||
{"success": False, "error": "Saved view operation failed"},
|
||||
)
|
||||
|
||||
async def test_a_corrupt_state_is_skipped_by_the_listing(self):
|
||||
kept = await self.create(page="library", name="Kept", state={})
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', 'Broken', '{', 0, 'x', 'x')"
|
||||
)
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(data["views"], [kept])
|
||||
# Hidden from the listing, it is not found by rename either.
|
||||
broken_id = kept["id"] + 1
|
||||
response, _ = await self.send("PATCH", f"/{broken_id}", {"name": "X"})
|
||||
self.assertEqual(response.status, 404)
|
||||
response, _ = await self.send("DELETE", f"/{broken_id}")
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
async def test_a_row_saved_under_older_rules_lists_normalized(self):
|
||||
stale = json.dumps(
|
||||
{
|
||||
"q": "cat",
|
||||
"sort": "random",
|
||||
"media": "video",
|
||||
"tags": ["ok", 7],
|
||||
"retired": True,
|
||||
}
|
||||
)
|
||||
with self.api.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', ' Old\tview ', ?, 0, 'x', 'x')",
|
||||
(stale,),
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('library', '', '{}', 1, 'x', 'x')"
|
||||
)
|
||||
response, data = await self.send("GET")
|
||||
self.assertEqual(response.status, 200, data)
|
||||
images, library = data["views"]
|
||||
self.assertEqual(images["name"], "Oldview")
|
||||
self.assertEqual(
|
||||
images["state"],
|
||||
{**IMAGES_DEFAULTS, "q": "cat", "media": "video"},
|
||||
)
|
||||
self.assertEqual(library["name"], "Saved view")
|
||||
self.assertEqual(library["state"], LIBRARY_DEFAULTS)
|
||||
# A stale view still renames and deletes.
|
||||
response, data = await self.send(
|
||||
"PATCH", f"/{images['id']}", {"name": "Renamed"}
|
||||
)
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(data["view"]["state"], images["state"])
|
||||
|
||||
|
||||
class TestSavedViewsSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self.tmpdir, "prompts.db")
|
||||
self.db = PromptDatabase(self.path)
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
PromptModel.close_all_instances()
|
||||
assert_closed(self, self.tmpdir)
|
||||
for name in os.listdir(self.tmpdir):
|
||||
os.unlink(os.path.join(self.tmpdir, name))
|
||||
os.rmdir(self.tmpdir)
|
||||
|
||||
def _source(self, name="source.db", views=()):
|
||||
"""A closed database file holding a prompt and the given views."""
|
||||
path = os.path.join(self.tmpdir, name)
|
||||
src = PromptDatabase(path)
|
||||
try:
|
||||
src.save_prompt(text="kept", prompt_hash=generate_prompt_hash("kept"))
|
||||
for page, view_name, state in views:
|
||||
src.create_saved_view(page, view_name, state)
|
||||
finally:
|
||||
src.close_all()
|
||||
return path
|
||||
|
||||
def test_current_database_has_a_valid_table(self):
|
||||
conn = self.db.model.get_connection()
|
||||
self.assertIsNone(saved_views_schema_error(conn, allow_missing=False))
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
self.path, require_library_state=True
|
||||
)
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_migration_is_idempotent(self):
|
||||
conn = self.db.model.get_connection()
|
||||
self.db.create_saved_view("images", "Cats", IMAGES_STATE)
|
||||
migrate_saved_views(conn)
|
||||
self.assertEqual(len(self.db.list_saved_views()), 1)
|
||||
|
||||
def test_backup_restore_round_trip_keeps_saved_views(self):
|
||||
self.db.create_saved_view("library", "Live", LIBRARY_STATE)
|
||||
src_path = self._source(
|
||||
views=[
|
||||
("images", "Cats", IMAGES_STATE),
|
||||
("library", "Best", LIBRARY_STATE),
|
||||
]
|
||||
)
|
||||
# The views may still sit in the WAL; the backup must carry them.
|
||||
backup = os.path.join(self.tmpdir, "backup.db")
|
||||
reopened = PromptDatabase(src_path)
|
||||
try:
|
||||
self.assertTrue(reopened.model.backup_database(backup))
|
||||
finally:
|
||||
reopened.close_all()
|
||||
|
||||
previous = self.db.model.restore_from_file(backup)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
(view["page"], view["name"], view["state"])
|
||||
for view in self.db.list_saved_views()
|
||||
],
|
||||
[("images", "Cats", IMAGES_STATE), ("library", "Best", LIBRARY_STATE)],
|
||||
)
|
||||
created = self.db.create_saved_view("images", "After", IMAGES_STATE)
|
||||
self.assertEqual(self.db.list_saved_views()[-1], created)
|
||||
with closing(sqlite3.connect(previous)) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT name FROM {TABLE}").fetchall(), [("Live",)]
|
||||
)
|
||||
|
||||
def test_restore_old_backup_without_saved_views(self):
|
||||
self.db.create_saved_view("images", "Live", IMAGES_STATE)
|
||||
src = self._source()
|
||||
with closing(sqlite3.connect(src)) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.commit()
|
||||
self.assertTrue(PromptModel.verify_database_file(src)[0])
|
||||
ok, _ = PromptModel.verify_database_file(src, require_library_state=True)
|
||||
self.assertFalse(ok)
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
self.assertEqual(self.db.list_saved_views(), [])
|
||||
self.db.create_saved_view("library", "New", LIBRARY_STATE)
|
||||
self.assertEqual(len(self.db.list_saved_views()), 1)
|
||||
with closing(sqlite3.connect(src)) as conn: # the source is untouched
|
||||
self.assertIsNone(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name=?", (TABLE,)
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def _tampered(self, *statements, views=(("images", "Cats", IMAGES_STATE),)):
|
||||
src = self._source(
|
||||
name=f"tampered{len(os.listdir(self.tmpdir))}.db", views=views
|
||||
)
|
||||
with closing(sqlite3.connect(src)) as conn:
|
||||
for statement in statements:
|
||||
conn.execute(statement)
|
||||
conn.commit()
|
||||
return src
|
||||
|
||||
def test_restore_rejects_tampered_saved_views(self):
|
||||
cases = {
|
||||
"definition": (
|
||||
f"DROP TABLE {TABLE}",
|
||||
f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY, page TEXT, "
|
||||
"name TEXT, state TEXT, position INTEGER, created_at TEXT, "
|
||||
"updated_at TEXT)",
|
||||
),
|
||||
"index": (f"CREATE INDEX saved_views_extra ON {TABLE}(name)",),
|
||||
"page": (
|
||||
"PRAGMA ignore_check_constraints = ON",
|
||||
f"UPDATE {TABLE} SET page = 'history'",
|
||||
),
|
||||
"long name": (f"UPDATE {TABLE} SET name = '{'x' * 401}'",),
|
||||
"blob name": (f"UPDATE {TABLE} SET name = CAST('Cats' AS BLOB)",),
|
||||
"state json": (f"UPDATE {TABLE} SET state = '{{'",),
|
||||
"state array": (f"UPDATE {TABLE} SET state = '[]'",),
|
||||
"state string": (f"UPDATE {TABLE} SET state = '\"q\"'",),
|
||||
"state size": (
|
||||
f"UPDATE {TABLE} SET state = '{'x' * (MAX_STATE_BYTES + 1)}'",
|
||||
),
|
||||
"position": (f"UPDATE {TABLE} SET position = 'first'",),
|
||||
"timestamp": (f"UPDATE {TABLE} SET created_at = X'00'",),
|
||||
}
|
||||
for label, statements in cases.items():
|
||||
with self.subTest(label=label):
|
||||
src = self._tampered(*statements)
|
||||
ok, reason = PromptModel.verify_database_file(src)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("Saved views", reason)
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
def _insert_old_rows(self, path):
|
||||
"""Rows written under older value rules, straight into the file."""
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('images', 'Golden hour', ?, 5, 'x', 'x')",
|
||||
(OLD_IMAGES_STATE,),
|
||||
)
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, created_at, "
|
||||
"updated_at) VALUES ('library', ?, ?, 6, 'x', 'x')",
|
||||
(" " + "n" * 120, json.dumps({"sort": "name-desc", "q": 4})),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _assert_old_rows_normalized(self, views):
|
||||
self.assertEqual(
|
||||
[(view["page"], view["name"], view["state"]) for view in views],
|
||||
[
|
||||
(
|
||||
"images",
|
||||
"Golden hour",
|
||||
{
|
||||
**IMAGES_DEFAULTS,
|
||||
"q": "sunset",
|
||||
"media": "image",
|
||||
"tags": ["golden"],
|
||||
},
|
||||
),
|
||||
("library", "n" * 100, LIBRARY_DEFAULTS),
|
||||
],
|
||||
)
|
||||
|
||||
def test_a_database_with_rows_from_older_rules_still_opens(self):
|
||||
self.db.close_all()
|
||||
self._insert_old_rows(self.path)
|
||||
PromptModel.reset_schema_cache()
|
||||
|
||||
reopened = PromptDatabase(self.path)
|
||||
try:
|
||||
self._assert_old_rows_normalized(reopened.list_saved_views())
|
||||
finally:
|
||||
reopened.close_all()
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
self.path, require_library_state=True
|
||||
)
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
def test_restore_accepts_rows_from_older_rules(self):
|
||||
src = self._source()
|
||||
self._insert_old_rows(src)
|
||||
ok, reason = PromptModel.verify_database_file(src, require_library_state=True)
|
||||
self.assertTrue(ok, reason)
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
self._assert_old_rows_normalized(self.db.list_saved_views())
|
||||
|
||||
def _insert_corrupt_rows(self, path):
|
||||
"""A valid view between two rows whose state is no JSON object."""
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
ids = []
|
||||
for name, state in (
|
||||
("Broken", "{not json"),
|
||||
("Kept", json.dumps(LIBRARY_STATE)),
|
||||
("Array", "[1, 2]"),
|
||||
):
|
||||
ids.append(
|
||||
conn.execute(
|
||||
f"INSERT INTO {TABLE} (page, name, state, position, "
|
||||
"created_at, updated_at) VALUES ('library', ?, ?, 0, "
|
||||
"'x', 'x')",
|
||||
(name, state),
|
||||
).lastrowid
|
||||
)
|
||||
conn.commit()
|
||||
return ids
|
||||
|
||||
def test_startup_tolerates_a_corrupt_state_and_list_skips_it(self):
|
||||
self.db.close_all()
|
||||
broken, kept, array = self._insert_corrupt_rows(self.path)
|
||||
PromptModel.reset_schema_cache()
|
||||
|
||||
with self.assertLogs("prompt_manager.database", level="WARNING") as logs:
|
||||
reopened = PromptDatabase(self.path)
|
||||
try:
|
||||
self.assertEqual(
|
||||
[(view["id"], view["name"]) for view in reopened.list_saved_views()],
|
||||
[(kept, "Kept")],
|
||||
)
|
||||
finally:
|
||||
reopened.close_all()
|
||||
output = "\n".join(logs.output)
|
||||
self.assertIn(f"id {broken}", output)
|
||||
self.assertIn(f"id {array}", output)
|
||||
self.assertNotIn("not json", output)
|
||||
# The rows stay in place, untouched.
|
||||
with closing(sqlite3.connect(self.path)) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0], 3
|
||||
)
|
||||
|
||||
def test_restore_rejects_a_backup_with_a_corrupt_state(self):
|
||||
src = self._source()
|
||||
self._insert_corrupt_rows(src)
|
||||
ok, reason = PromptModel.verify_database_file(src)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(reason, "Saved views contains invalid values")
|
||||
with self.assertRaises(ValueError):
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
def test_normalize_state_keeps_valid_values_and_defaults_the_rest(self):
|
||||
self.assertEqual(normalize_state("images", IMAGES_STATE), IMAGES_STATE)
|
||||
self.assertEqual(normalize_state("library", LIBRARY_STATE), LIBRARY_STATE)
|
||||
self.assertEqual(
|
||||
normalize_state("images", json.dumps(IMAGES_STATE)), IMAGES_STATE
|
||||
)
|
||||
for raw in (None, "{", "[]", '"q"', 7, ["q"], b"{}"):
|
||||
with self.subTest(raw=raw):
|
||||
self.assertEqual(normalize_state("library", raw), LIBRARY_DEFAULTS)
|
||||
self.assertEqual(
|
||||
normalize_state(
|
||||
"images",
|
||||
{
|
||||
"q": "bad\x00",
|
||||
"source": 3,
|
||||
"favorites": "true",
|
||||
"tags": [f"t{n}" for n in range(21)],
|
||||
"folder": "kept",
|
||||
},
|
||||
),
|
||||
{**IMAGES_DEFAULTS, "folder": "kept"},
|
||||
)
|
||||
self.assertEqual(normalize_state("history", {}), {})
|
||||
|
||||
def test_validation_errors_are_saved_view_invalid(self):
|
||||
for call in (
|
||||
lambda: validate_name(""),
|
||||
lambda: validate_name(3),
|
||||
lambda: encode_state("history", {}),
|
||||
lambda: encode_state("library", {"q": ""}),
|
||||
lambda: encode_state("library", {**LIBRARY_STATE, "sort": "x"}),
|
||||
):
|
||||
with self.assertRaises(SavedViewInvalid):
|
||||
call()
|
||||
self.assertTrue(issubclass(SavedViewInvalid, ValueError))
|
||||
|
||||
def test_migration_refuses_an_unsupported_table(self):
|
||||
path = os.path.join(self.tmpdir, "foreign.db")
|
||||
with closing(sqlite3.connect(path)) as conn:
|
||||
conn.execute(f"CREATE TABLE {TABLE} (id INTEGER PRIMARY KEY)")
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_saved_views(conn)
|
||||
# The savepoint rolled back, so the foreign table is untouched.
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
f"SELECT COUNT(*) FROM pragma_table_info('{TABLE}')"
|
||||
).fetchone()[0],
|
||||
1,
|
||||
)
|
||||
self.assertTrue(Path(path).exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+54
-8
@@ -73,6 +73,55 @@ test('overlay rules on concept selectors carry a decision marker', () => {
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
// #249 PR F-b Task 3: every page is rebuilt, so no rule waits for a page PR any more and the history page's
|
||||
// production-only rules live in the overlay in the concept's tokens (D48). A comment that opens with a page PR
|
||||
// ("PR F: ...", "PR E/F: ...") or defers to one ("until ... PR", "retired in PR F") is a waiting rule; a provenance
|
||||
// label inside a heading ("(#249 PR E)") is not.
|
||||
const WAITING_RULE = [
|
||||
/\/\*\s*PR [A-F](\/[A-F]|-[ab])?\b/i,
|
||||
/\buntil\b[^*]*\b(PR [A-F]?|rebuild)/i,
|
||||
/\bretired in PR\b/i,
|
||||
/\binterim page guards\b/i,
|
||||
];
|
||||
const waitingRules = css => WAITING_RULE.filter(pattern => pattern.test(css)).map(String);
|
||||
|
||||
test('the waiting-rule guard catches the retired comments and spares provenance labels', () => {
|
||||
// The exact comments this PR retired (workspace.css and library.css at 10a4847).
|
||||
for (const retired of [
|
||||
'/* Interim page guards: concept element rules (`.ca-tools dialog`, `pre`) now reach production dialogs and prompt text',
|
||||
' that keep their own design until their page PR rebuilds them in concept markup. */',
|
||||
'.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */',
|
||||
'.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */',
|
||||
' .ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */',
|
||||
'/* Until their pages rebuild (PR F history): the history page\'s and the recorded run\'s prompt blocks keep their',
|
||||
'/* PR E/F: the Archive dialog and the frozen field provenance keep `pre.prompt-text`, guarded against the concept `pre` rule. */',
|
||||
" keeps the concept's) and are retired in PR F (history). */",
|
||||
]) assert.notDeepEqual(waitingRules(retired), [], retired);
|
||||
for (const kept of [
|
||||
"/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */",
|
||||
'/* History variants panel (#249 PR F). */',
|
||||
]) assert.deepEqual(waitingRules(kept), [], kept);
|
||||
});
|
||||
|
||||
test('the overlay keeps no rule waiting for a page rebuild', () => {
|
||||
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
|
||||
assert.deepEqual(waitingRules(css), []);
|
||||
for (const retired of ['.library-chip', '--library-positive', '--library-negative', '--prompt-role']) {
|
||||
assert.equal(css.includes(retired), false, `${retired} is retired`);
|
||||
}
|
||||
});
|
||||
|
||||
// #249 C8 and D3: the Images page and the inspector render the concept DOM, so the replaced production rules stay
|
||||
// retired; the few production-only rules still consumed live in the overlay.
|
||||
test('the overlay keeps no retired Images page or inspector rules', () => {
|
||||
const css = readFileSync(join(root, 'web/js/workspace/workspace.css'), 'utf8');
|
||||
// `.image-grid` and `.image-card-actions` are concept selectors now, so only the retired production names are listed.
|
||||
for (const retired of ['.image-facts', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation',
|
||||
'.image-details', '.file-cover', '.file-inspector-inner', '.file-title', '.file-chip', '.file-facts', '.generation-facts', '.file-inspector-footer']) {
|
||||
assert.equal(css.includes(retired), false, `${retired} is retired`);
|
||||
}
|
||||
});
|
||||
|
||||
test('only workspace.html loads workspace CSS', () => {
|
||||
const offenders = walk(join(root, 'web'))
|
||||
.filter(path => /\.(html|js|mjs)$/.test(path) && !path.includes('/lib/') && !path.includes('/shared/'))
|
||||
@@ -81,24 +130,21 @@ test('only workspace.html loads workspace CSS', () => {
|
||||
assert.ok(offenders.every(path => path === 'web/workspace.html'), `unexpected consumers: ${offenders.join(', ')}`);
|
||||
});
|
||||
|
||||
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept) with the page stylesheets that their own PRs
|
||||
// retire, then the production overlay last.
|
||||
test('workspace.html loads controls, viewer, concept, page CSS and the overlay in order', () => {
|
||||
// Task A2 order: the concept's own order (toolkit, Viewer.js, concept), then the production overlay last; the page
|
||||
// stylesheets that waited for their PRs are all retired (PR F-b deleted the last, library.css).
|
||||
test('workspace.html loads controls, viewer, concept and the overlay in order', () => {
|
||||
const html = readFileSync(join(root, 'web/workspace.html'), 'utf8');
|
||||
const hrefs = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)].map(match => match[1]);
|
||||
assert.deepEqual(hrefs, [
|
||||
'js/shared/toolkit/controls.css',
|
||||
'lib/viewerjs/viewer.min.css',
|
||||
'js/workspace/concept.css',
|
||||
'js/workspace/images.css',
|
||||
'js/workspace/library.css',
|
||||
'js/workspace/inspector.css',
|
||||
'js/workspace/workspace.css',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the retired shell, vocabulary and editor stylesheets stay deleted', () => {
|
||||
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css']) {
|
||||
test('the retired shell, vocabulary, editor, Images, inspector and library stylesheets stay deleted', () => {
|
||||
for (const name of ['style.css', 'vocabulary.css', 'library-editor.css', 'images.css', 'inspector.css', 'library.css']) {
|
||||
assert.equal(existsSync(join(root, 'web/js/workspace', name)), false, `web/js/workspace/${name} must not come back`);
|
||||
}
|
||||
});
|
||||
|
||||
+42
@@ -197,6 +197,24 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-category.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-export.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-selection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -287,6 +305,12 @@
|
||||
"functions": 77,
|
||||
"statements": 95
|
||||
},
|
||||
"workspace/saved-views.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 95,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -299,6 +323,18 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/toast.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 64,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/toolbar.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/topbar.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -328,5 +364,11 @@
|
||||
"branches": 91,
|
||||
"functions": 93,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/workspace-dialogs.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
"""Show one gallery file in this computer's file manager (TESTING.md D38).
|
||||
|
||||
The route that calls this is local-only (``is_local_request``) and
|
||||
single-flight (``RevealGate``), and hands in a path it has already resolved
|
||||
inside a gallery root, which is re-checked just before launching. Windows
|
||||
goes through the shell API (no command line exists to parse); macOS and Linux
|
||||
openers run as an argument list with an absolute executable from a fixed
|
||||
system PATH, no shell, no inherited streams and a minimal environment, so
|
||||
neither a file name nor ``PATH`` can change what runs or what it parses.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import posixpath
|
||||
import shutil
|
||||
|
||||
# Only POSIX argv launches of a fixed opener (see _launch); Windows never
|
||||
# starts a process (see _select_item).
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# A local reverse proxy makes every client look like loopback, so a request
|
||||
# that carries any forwarding-style header counts as remote: these names, and
|
||||
# every X-Forwarded-*, X-Original-* and *-Client-IP header.
|
||||
FORWARDING_HEADERS = frozenset(
|
||||
(
|
||||
"forwarded",
|
||||
"via",
|
||||
"x-real-ip",
|
||||
"cf-connecting-ip",
|
||||
"true-client-ip",
|
||||
"x-client-ip",
|
||||
"x-cluster-client-ip",
|
||||
"fastly-client-ip",
|
||||
)
|
||||
)
|
||||
FORWARDING_PREFIXES = ("x-forwarded-", "x-original-")
|
||||
FORWARDING_SUFFIXES = ("-client-ip",)
|
||||
DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
# One reveal at a time per process, then a pause before the next.
|
||||
REVEAL_COOLDOWN = 1.0
|
||||
# dbus-send waits this long for the FileManager1 reply, so its exit status
|
||||
# says whether a file manager really took the request.
|
||||
DBUS_REPLY_TIMEOUT_MS = 2000
|
||||
# The dbus-send process itself gets a little longer before it is killed.
|
||||
DBUS_WAIT = 3.0
|
||||
FILE_MANAGER_DEST = "org.freedesktop.FileManager1"
|
||||
# Openers are looked up here only, never on ComfyUI's (venv) PATH, and the
|
||||
# children get this as their PATH.
|
||||
SYSTEM_PATH = os.pathsep.join(
|
||||
("/usr/local/bin", "/usr/bin", "/bin", "/run/current-system/sw/bin")
|
||||
)
|
||||
MACOS_OPEN = "/usr/bin/open"
|
||||
# What a desktop opener needs from ComfyUI's environment; everything else
|
||||
# (venv PATH, PYTHONPATH, LD_LIBRARY_PATH, API keys) stays behind.
|
||||
CHILD_ENV_NAMES = (
|
||||
"HOME",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_SESSION_TYPE",
|
||||
"XDG_SESSION_DESKTOP",
|
||||
"DESKTOP_SESSION",
|
||||
"XDG_DATA_DIRS",
|
||||
"XDG_CONFIG_DIRS",
|
||||
"XDG_CONFIG_HOME",
|
||||
# The X11 cookie: a non-default path under GDM or SDDM.
|
||||
"XAUTHORITY",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
)
|
||||
|
||||
|
||||
class OpenerUnavailable(Exception):
|
||||
"""No file manager can be started on this computer."""
|
||||
|
||||
|
||||
class RevealTargetChanged(Exception):
|
||||
"""The file is no longer a regular file inside its root."""
|
||||
|
||||
|
||||
_executor_lock = threading.Lock()
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
|
||||
|
||||
def reveal_executor() -> ThreadPoolExecutor:
|
||||
"""The one worker that runs openers, so a slow file manager or dbus reply
|
||||
never holds a thread of ComfyUI's shared default executor."""
|
||||
global _executor
|
||||
with _executor_lock:
|
||||
if _executor is None:
|
||||
_executor = ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="pm-reveal"
|
||||
)
|
||||
return _executor
|
||||
|
||||
|
||||
def _loopback_address(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return False
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
return (mapped or address).is_loopback
|
||||
|
||||
|
||||
def _loopback_host(host: str) -> bool:
|
||||
"""True for a Host name naming this computer.
|
||||
|
||||
Rejects every other name, so a DNS-rebinding page whose name now resolves
|
||||
to 127.0.0.1 still reads as remote.
|
||||
"""
|
||||
host = host.rstrip(".").lower()
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
return True
|
||||
return _loopback_address(host.strip("[]"))
|
||||
|
||||
|
||||
def _authority(authority: str) -> tuple[str, int | None] | None:
|
||||
"""``(host, port)`` of a Host value, or None when it does not parse."""
|
||||
try:
|
||||
parts = urlsplit(f"//{authority}")
|
||||
host, port = parts.hostname, parts.port
|
||||
except ValueError:
|
||||
return None
|
||||
return (host, port) if host else None
|
||||
|
||||
|
||||
def _forwarded(headers: Mapping) -> bool:
|
||||
for name in headers.keys():
|
||||
name = name.lower()
|
||||
if (
|
||||
name in FORWARDING_HEADERS
|
||||
or name.startswith(FORWARDING_PREFIXES)
|
||||
or name.endswith(FORWARDING_SUFFIXES)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _same_origin(origin: str, scheme: str, host: tuple[str, int | None]) -> bool:
|
||||
"""True when *origin* is exactly this request's scheme://host:port."""
|
||||
default = DEFAULT_PORTS.get(scheme)
|
||||
if default is None:
|
||||
return False
|
||||
try:
|
||||
parts = urlsplit(origin)
|
||||
origin_host, origin_port = parts.hostname, parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
if (
|
||||
parts.scheme != scheme
|
||||
or not origin_host
|
||||
or parts.username is not None
|
||||
or parts.password is not None
|
||||
or parts.path
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
):
|
||||
return False
|
||||
host_name, host_port = host
|
||||
return (origin_host.rstrip("."), origin_port or default) == (
|
||||
host_name.rstrip("."),
|
||||
host_port or default,
|
||||
)
|
||||
|
||||
|
||||
def is_local_request(remote: str | None, headers: Mapping, scheme="http") -> bool:
|
||||
"""True only for a same-origin browser request from this computer.
|
||||
|
||||
That is: no forwarding-style header, a loopback client, a loopback name
|
||||
in Host, an ``Origin`` that is exactly this request's scheme://host:port
|
||||
(browsers send it on every fetch POST, so a missing one is refused), and
|
||||
``Sec-Fetch-Site: same-origin`` whenever that header is sent.
|
||||
"""
|
||||
if _forwarded(headers):
|
||||
return False
|
||||
if not _loopback_address(remote):
|
||||
return False
|
||||
host = _authority(headers.get("Host", ""))
|
||||
if host is None or not _loopback_host(host[0]):
|
||||
return False
|
||||
origin = headers.get("Origin")
|
||||
if not origin or not _same_origin(origin, scheme, host):
|
||||
return False
|
||||
site = headers.get("Sec-Fetch-Site")
|
||||
return site is None or site == "same-origin"
|
||||
|
||||
|
||||
class RevealGate:
|
||||
"""Single flight plus a cooldown: at most one reveal runs per process,
|
||||
and the next may start only *cooldown* seconds after it finished."""
|
||||
|
||||
def __init__(self, cooldown: float = REVEAL_COOLDOWN, clock=time.monotonic):
|
||||
self.cooldown = cooldown
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._busy = False
|
||||
self._finished: float | None = None
|
||||
|
||||
def try_enter(self) -> bool:
|
||||
with self._lock:
|
||||
if self._busy:
|
||||
return False
|
||||
if (
|
||||
self._finished is not None
|
||||
and self._clock() - self._finished < self.cooldown
|
||||
):
|
||||
return False
|
||||
self._busy = True
|
||||
return True
|
||||
|
||||
def leave(self) -> None:
|
||||
with self._lock:
|
||||
self._busy = False
|
||||
self._finished = self._clock()
|
||||
|
||||
|
||||
def _executable(name: str) -> str | None:
|
||||
"""Absolute path of *name* on the fixed SYSTEM_PATH, never a relative hit.
|
||||
|
||||
Only the Linux and macOS openers are looked up here, so absoluteness follows
|
||||
POSIX rules rather than the host's (Python 3.13 on Windows no longer counts
|
||||
"/usr/bin/x" as absolute).
|
||||
"""
|
||||
found = shutil.which(name, path=SYSTEM_PATH)
|
||||
return found if found and posixpath.isabs(found) else None
|
||||
|
||||
|
||||
def _macos_open() -> str | None:
|
||||
"""macOS's own ``open``, at its fixed system path."""
|
||||
if os.path.isfile(MACOS_OPEN) and os.access(MACOS_OPEN, os.X_OK):
|
||||
return MACOS_OPEN
|
||||
return None
|
||||
|
||||
|
||||
def _child_env() -> dict[str, str]:
|
||||
env = {name: os.environ[name] for name in CHILD_ENV_NAMES if name in os.environ}
|
||||
return {"PATH": SYSTEM_PATH, **env}
|
||||
|
||||
|
||||
def _reap(process: subprocess.Popen) -> None:
|
||||
"""Wait for *process* on a daemon thread, so it never lingers as a zombie
|
||||
and is never waited on by the caller."""
|
||||
threading.Thread(target=process.wait, name="pm-reveal-reaper", daemon=True).start()
|
||||
|
||||
|
||||
def _contained_file(path: Path, root: Path) -> Path:
|
||||
"""*path*'s real path when it is still a regular file inside *root*.
|
||||
|
||||
Run right before launching: a parent swapped for a symlink since the
|
||||
route resolved the path must not open a folder outside the root.
|
||||
"""
|
||||
real = os.path.realpath(path)
|
||||
real_root = os.path.realpath(root)
|
||||
try:
|
||||
inside = os.path.commonpath(
|
||||
[os.path.normcase(real), os.path.normcase(real_root)]
|
||||
) == os.path.normcase(real_root)
|
||||
except ValueError:
|
||||
inside = False
|
||||
if not inside or real == real_root or not os.path.isfile(real):
|
||||
raise RevealTargetChanged()
|
||||
return Path(real)
|
||||
|
||||
|
||||
# HRESULTs as the signed 32-bit values ctypes.c_long returns.
|
||||
S_OK = 0
|
||||
S_FALSE = 1
|
||||
RPC_E_CHANGED_MODE = -2147417850 # 0x80010106: COM already set up otherwise
|
||||
COINIT_APARTMENTTHREADED = 0x2
|
||||
COINIT_DISABLE_OLE1DDE = 0x4
|
||||
|
||||
|
||||
def _windows_libraries():
|
||||
"""Private ole32 and shell32 handles, so the signatures set here never
|
||||
change another library's view of ``ctypes.windll``."""
|
||||
import ctypes
|
||||
|
||||
return ctypes.WinDLL("ole32"), ctypes.WinDLL("shell32")
|
||||
|
||||
|
||||
def _declare_shell_signatures(ole32, shell32) -> None:
|
||||
import ctypes
|
||||
|
||||
ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
ole32.CoInitializeEx.restype = ctypes.c_long
|
||||
ole32.CoUninitialize.argtypes = []
|
||||
ole32.CoUninitialize.restype = None
|
||||
shell32.ILCreateFromPathW.argtypes = [ctypes.c_wchar_p]
|
||||
shell32.ILCreateFromPathW.restype = ctypes.c_void_p
|
||||
shell32.SHOpenFolderAndSelectItems.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
shell32.SHOpenFolderAndSelectItems.restype = ctypes.c_long
|
||||
shell32.ILFree.argtypes = [ctypes.c_void_p]
|
||||
shell32.ILFree.restype = None
|
||||
|
||||
|
||||
def _shell_libraries():
|
||||
"""The declared ole32 and shell32, or None where they cannot be loaded."""
|
||||
try:
|
||||
ole32, shell32 = _windows_libraries()
|
||||
_declare_shell_signatures(ole32, shell32)
|
||||
except Exception: # no shell libraries: the folder fallback still runs
|
||||
return None
|
||||
return ole32, shell32
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _com_apartment(ole32):
|
||||
"""The calling thread inside COM for the block; yields whether COM is
|
||||
usable. Only a successful CoInitializeEx (S_OK or S_FALSE) is balanced,
|
||||
so the persistent reveal thread never drifts."""
|
||||
try:
|
||||
result = ole32.CoInitializeEx(
|
||||
None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE
|
||||
)
|
||||
except Exception: # an unusable ole32 leaves COM alone
|
||||
result = None
|
||||
try:
|
||||
yield result in (S_OK, S_FALSE, RPC_E_CHANGED_MODE)
|
||||
finally:
|
||||
if result in (S_OK, S_FALSE):
|
||||
ole32.CoUninitialize()
|
||||
|
||||
|
||||
def _select_item(shell32, path: Path) -> bool:
|
||||
"""Open Explorer on *path*'s folder with *path* selected, via the shell
|
||||
API. The path is one wide-string value, so no command line is ever
|
||||
parsed (Explorer splits its own on commas). False on any failure; never
|
||||
raises into the route."""
|
||||
try:
|
||||
pidl = shell32.ILCreateFromPathW(str(path))
|
||||
if not pidl:
|
||||
return False
|
||||
try:
|
||||
return shell32.SHOpenFolderAndSelectItems(pidl, 0, None, 0) >= 0
|
||||
finally:
|
||||
shell32.ILFree(pidl)
|
||||
except Exception: # any shell failure falls back to the folder
|
||||
return False
|
||||
|
||||
|
||||
def _explore(folder: Path) -> None:
|
||||
startfile = getattr(os, "startfile", None)
|
||||
if startfile is None:
|
||||
raise OpenerUnavailable()
|
||||
# ShellExecute on the folder itself: no command line is built or parsed.
|
||||
startfile(str(folder), "explore")
|
||||
|
||||
|
||||
def _reveal_windows(path: Path) -> None:
|
||||
libraries = _shell_libraries()
|
||||
if libraries is None:
|
||||
_explore(path.parent)
|
||||
return
|
||||
ole32, shell32 = libraries
|
||||
with _com_apartment(ole32) as ready:
|
||||
if ready and _select_item(shell32, path):
|
||||
return
|
||||
# ShellExecute inside the same apartment: shell extensions may need COM.
|
||||
_explore(path.parent)
|
||||
|
||||
|
||||
def _launch(argv: list[str]) -> subprocess.Popen:
|
||||
options = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"close_fds": True,
|
||||
# Its own session, so stopping ComfyUI does not take the window along.
|
||||
"start_new_session": True,
|
||||
"env": _child_env(),
|
||||
}
|
||||
# POSIX only (Windows uses the shell API): argv[0] is an absolute opener,
|
||||
# the rest are fixed flags and one absolute path inside a gallery root,
|
||||
# passed to execve as separate strings with no shell to re-parse them.
|
||||
return subprocess.Popen(argv, **options) # nosec B603
|
||||
|
||||
|
||||
def _show_items(path: Path) -> bool:
|
||||
"""Ask the freedesktop FileManager1 service to select *path*; True only
|
||||
when the service replied (``--print-reply`` makes the exit status real)."""
|
||||
dbus = _executable("dbus-send")
|
||||
if dbus is None:
|
||||
return False
|
||||
argv = [
|
||||
dbus,
|
||||
"--session",
|
||||
"--print-reply",
|
||||
f"--reply-timeout={DBUS_REPLY_TIMEOUT_MS}",
|
||||
f"--dest={FILE_MANAGER_DEST}",
|
||||
"--type=method_call",
|
||||
"/org/freedesktop/FileManager1",
|
||||
f"{FILE_MANAGER_DEST}.ShowItems",
|
||||
f"array:string:{path.as_uri()}",
|
||||
"string:",
|
||||
]
|
||||
try:
|
||||
process = _launch(argv)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
return process.wait(timeout=DBUS_WAIT) == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
return False
|
||||
|
||||
|
||||
def reveal_in_file_manager(path: Path, root: Path) -> None:
|
||||
"""Open the file manager at *path*, a regular file inside *root*.
|
||||
|
||||
Re-checks that containment first (RevealTargetChanged). Raises
|
||||
OpenerUnavailable when no opener exists and OSError when the one found
|
||||
cannot start. Never waits on the file manager itself.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.is_absolute():
|
||||
raise ValueError("path must be absolute")
|
||||
path = _contained_file(path, Path(root))
|
||||
if sys.platform == "win32":
|
||||
_reveal_windows(path)
|
||||
return
|
||||
if sys.platform == "darwin":
|
||||
opener = _macos_open()
|
||||
if opener is None:
|
||||
raise OpenerUnavailable()
|
||||
_reap(_launch([opener, "-R", str(path)]))
|
||||
return
|
||||
if _show_items(path):
|
||||
return
|
||||
opener = _executable("xdg-open")
|
||||
if opener is None:
|
||||
raise OpenerUnavailable()
|
||||
_reap(_launch([opener, str(path.parent)]))
|
||||
@@ -289,6 +289,39 @@ def _file_projection(file: dict, inspection: "InspectionRead | None") -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _blank(value: str | bytes | None) -> bool:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", "replace")
|
||||
return value is None or not value.strip()
|
||||
|
||||
|
||||
def library_metadata(
|
||||
positive_category: str | bytes | None,
|
||||
positive_tags: list,
|
||||
recipe: tuple | None = None,
|
||||
) -> dict:
|
||||
"""The panel's saved category and tags, each with the scope it came from.
|
||||
|
||||
D39 (maintainer ruling, 2026-10-21): a recipe-linked image shows the recipe's
|
||||
own category and tags when set; otherwise each field separately falls back to
|
||||
the linked positive prompt's, the values Set category writes and the Images
|
||||
filters match. Tag sets are never merged. ``recipe`` is (category, tags).
|
||||
"""
|
||||
category, category_scope = positive_category, "positive"
|
||||
tags, tags_scope = positive_tags, "positive"
|
||||
if recipe is not None:
|
||||
if not _blank(recipe[0]):
|
||||
category, category_scope = recipe[0], "pair"
|
||||
if recipe[1]:
|
||||
tags, tags_scope = recipe[1], "pair"
|
||||
return {
|
||||
"category": category,
|
||||
"category_scope": category_scope,
|
||||
"tags": tags,
|
||||
"tags_scope": tags_scope,
|
||||
}
|
||||
|
||||
|
||||
def _library_projection(library: dict) -> dict:
|
||||
status = library.get("status")
|
||||
result = {
|
||||
@@ -307,9 +340,10 @@ def _library_projection(library: dict) -> dict:
|
||||
value = library.get(key)
|
||||
if value is None or isinstance(value, str):
|
||||
result[key] = value
|
||||
scope = library.get("metadata_scope")
|
||||
if scope in ("pair", "positive", None):
|
||||
result["metadata_scope"] = scope
|
||||
for key in ("category_scope", "tags_scope"):
|
||||
scope = library.get(key)
|
||||
if scope in ("pair", "positive", None):
|
||||
result[key] = scope
|
||||
tags = library.get("tags")
|
||||
result["tags"] = (
|
||||
[tag for tag in tags if isinstance(tag, str)] if isinstance(tags, list) else []
|
||||
@@ -323,7 +357,8 @@ def _library_projection(library: dict) -> dict:
|
||||
"title": None,
|
||||
"category": None,
|
||||
"tags": [],
|
||||
"metadata_scope": None,
|
||||
"category_scope": None,
|
||||
"tags_scope": None,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@@ -91,7 +91,8 @@
|
||||
post: (url, body, { signal } = {}) => request("POST", url, { body, signal }),
|
||||
put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }),
|
||||
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
|
||||
del: (url, { signal } = {}) => request("DELETE", url, { signal }),
|
||||
// A DELETE body is optional; the favorites store names its file in one.
|
||||
del: (url, { signal, body } = {}) => request("DELETE", url, { body, signal }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+222
-109
@@ -3,6 +3,8 @@ import { mountPromptBlocks, promptRoles } from './prompt-blocks.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { createSendButton } from './prompt-sender.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
|
||||
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
|
||||
@@ -15,22 +17,26 @@ const REPRESENTATIONS = {
|
||||
limited: 'Record exceeds inspection limits; text is unavailable',
|
||||
};
|
||||
const TEXTLESS = new Set(['invalid', 'limited']);
|
||||
const REVEAL_URL = '/prompt_manager/images/reveal';
|
||||
// D38 Show in folder: the route's answers, in the maintainer's words.
|
||||
const REVEAL_OPENED = 'Opened the folder on this computer.';
|
||||
const REVEAL_NOT_LOCAL = 'Show in folder works only in a browser on the computer running ComfyUI.';
|
||||
const REVEAL_UNAVAILABLE = 'No file manager is available on this computer.';
|
||||
const REVEAL_BUSY = 'The folder is already opening.';
|
||||
const REVEAL_FAILED = 'The folder could not be opened. Try again.';
|
||||
const FACTS = [['seed', 'Seed'], ['steps', 'Steps'], ['cfg', 'CFG'], ['sampler', 'Sampler'], ['scheduler', 'Scheduler'], ['model', 'Model']];
|
||||
const FACT_SOURCES = {'api-graph': 'Embedded API configuration', workflow: 'Saved workflow configuration', 'embedded-parameters': 'Embedded generation parameters'};
|
||||
|
||||
function safeFilename(value) {
|
||||
return String(value || 'file').replace(/[\\/\x00-\x1f\x7f<>:"|?*]/g, '_').replace(/^\.+|[. ]+$/g, '') || 'file';
|
||||
}
|
||||
|
||||
/** Export only the bounded public evidence, never normalized graphs or container carriers. */
|
||||
export function inspectionDownloads(record) {
|
||||
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
|
||||
const stem = name.replace(/\.[^.]+$/, '') || 'file';
|
||||
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
|
||||
/**
|
||||
* One file's metadata envelope: only the bounded public evidence, never normalized graphs or container carriers.
|
||||
* Null when the inspection holds no full metadata. Shared by "Download metadata JSON" and the Images bulk export.
|
||||
*/
|
||||
export function metadataEnvelope(record) {
|
||||
const metadata = record?.metadata;
|
||||
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
|
||||
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
|
||||
if (!metadata || metadata.status === 'limited') return result;
|
||||
if (!metadata || metadata.status === 'limited') return null;
|
||||
const raw = {}, record_formats = {}, representations = {};
|
||||
for (const key of RAW_KEYS) {
|
||||
if (Object.hasOwn(REPRESENTATIONS, metadata.record_formats?.[key])) {
|
||||
@@ -39,10 +45,23 @@ export function inspectionDownloads(record) {
|
||||
if (!TEXTLESS.has(record_formats[key]) && typeof metadata.raw?.[key] === 'string') raw[key] = metadata.raw[key];
|
||||
}
|
||||
}
|
||||
const envelope = {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
|
||||
return {inspection_version:1, identity:record.identity, file:record.file, prompts:record.prompts,
|
||||
generation:record.generation, library:record.library,
|
||||
metadata:{status:metadata.status, omitted_reason:metadata.omitted_reason, raw, record_formats, representations,
|
||||
scope:'Supported embedded records only; one selected container carrier for video. Other metadata and private carrier text are excluded.'}};
|
||||
}
|
||||
|
||||
/** The inspector's metadata and workflow downloads, each with its filename or the reason it is unavailable. */
|
||||
export function inspectionDownloads(record) {
|
||||
const name = safeFilename(record?.download?.filename || record?.identity?.path?.split('/').at(-1));
|
||||
const stem = name.replace(/\.[^.]+$/, '') || 'file';
|
||||
const unavailable = (suffix, reason) => ({available:false, reason, filename:`${stem}.${suffix}.json`, bodyText:null, mime:'application/json'});
|
||||
const metadata = record?.metadata;
|
||||
const reason = metadata?.status === 'limited' ? RAW_LIMIT : 'Full metadata is unavailable for this inspection response.';
|
||||
const result = {metadata:unavailable('metadata', reason), workflow:unavailable('workflow', reason)};
|
||||
const envelope = metadataEnvelope(record);
|
||||
if (!envelope) return result;
|
||||
const {raw, record_formats} = envelope.metadata;
|
||||
result.metadata = {...result.metadata, available:true, reason:null, bodyText:JSON.stringify(envelope, null, 2)};
|
||||
if (record_formats.workflow === 'reserialized-json') result.workflow.reason = CONTAINER_WORKFLOW;
|
||||
else if (record_formats.workflow === 'limited') result.workflow.reason = LIMITED_WORKFLOW;
|
||||
@@ -52,6 +71,23 @@ export function inspectionDownloads(record) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The reveal body for a listed file's identity (`root_index`, `root_scope`, `path`), else null (D38). */
|
||||
export function revealTarget(identity) {
|
||||
if (identity?.kind !== 'rooted-file') return null;
|
||||
const {root_index, root_scope, path} = identity;
|
||||
if (!Number.isInteger(root_index) || root_index < 0 || typeof root_scope !== 'string' || !/^[0-9a-f]{64}$/.test(root_scope) ||
|
||||
typeof path !== 'string' || !path) return null;
|
||||
return {root_index, root_scope, path};
|
||||
}
|
||||
/** The toast for a reveal outcome: null is success, otherwise the request's error (D38). */
|
||||
export function revealOutcome(error) {
|
||||
if (error === null) return REVEAL_OPENED;
|
||||
if (error?.status === 403 && error.code === 'not-local') return REVEAL_NOT_LOCAL;
|
||||
if (error?.status === 501) return REVEAL_UNAVAILABLE;
|
||||
if (error?.status === 429 && error.code === 'busy') return REVEAL_BUSY;
|
||||
return REVEAL_FAILED;
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -71,7 +107,8 @@ function disclosure(label) {
|
||||
function definitionList(entries, className) {
|
||||
const list = el('dl', undefined, className);
|
||||
for (const [label, value] of entries) {
|
||||
const row = el('div'); row.append(el('dt', label), el('dd', value ?? 'Not available')); list.append(row);
|
||||
// A value is its text, or a prepared `dd` (one a caller keeps to add to, such as D38's Path).
|
||||
const row = el('div'); row.append(el('dt', label), typeof value === 'object' && value !== null ? value : el('dd', value ?? 'Not available')); list.append(row);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -85,7 +122,41 @@ function formatBytes(value) {
|
||||
return value === null || value === undefined ? null : value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
|
||||
}
|
||||
|
||||
export function mountFileInspector(container, {sender, parentScope, inline = false, promptContext = '', onPreview, onRetry, onRefresh, onInteraction = () => {}}) {
|
||||
/** The six facts, and the limit note their values point to when the configuration exceeded inspection limits. */
|
||||
function generationFacts(record, listClass, noteClass) {
|
||||
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), listClass);
|
||||
if (!FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) return [facts];
|
||||
const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', noteClass);
|
||||
explanation.id = `file-fact-limit-${++factReasonId}`;
|
||||
FACTS.forEach(([key], index) => {
|
||||
if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id);
|
||||
});
|
||||
return [facts, explanation];
|
||||
}
|
||||
// D21: the panel badge names the strongest resolved prompt source; any other source shows no badge.
|
||||
const EVIDENCE_BADGES = [['execution', 'Execution recorded'], ['graph-inferred', 'Workflow inferred'], ['embedded-parameters', 'Embedded parameters']];
|
||||
function evidenceBadge(prompts) {
|
||||
const sources = new Set(Object.values(prompts).filter(role => role?.status === 'resolved').map(role => role.source));
|
||||
return EVIDENCE_BADGES.find(([source]) => sources.has(source))?.[1] ?? null;
|
||||
}
|
||||
/** The file's extension without its leading dot, upper case ("PNG"). */
|
||||
function extensionOf(record) {
|
||||
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
|
||||
}
|
||||
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
|
||||
function prefixedChip(prefix, text) {
|
||||
const chip = el('span', undefined, 'chip');
|
||||
chip.append(el('span', prefix, 'ca-visually-hidden'), text);
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's inspector. `variant: 'panel'` is the Images side panel in the concept's structure (app.js imageDetails
|
||||
* a400–414, D20): cover, heading, filename, D21 badges, prompt blocks, tag chips (`onTagFilter`), facts and footer.
|
||||
* `variant: 'full'` (the viewer's `#viewer-details`, imageDetails(i, true)) leaves out the cover and adds "File &
|
||||
* provenance" and "Workflow & raw metadata" with the downloads. `inline: true` means `panel`; neither means `full`.
|
||||
*/
|
||||
export function mountFileInspector(container, {api, sender, parentScope, variant, inline = false, promptContext = '', onTagFilter, onRetry, onRefresh, onInteraction = () => {}}) {
|
||||
if (parentScope.signal.aborted) return {setState() {}, dispose() {}};
|
||||
const scope = childScope(parentScope);
|
||||
let contentScope;
|
||||
@@ -96,121 +167,174 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
scope.listen(container, 'wheel', onInteraction, {passive:true});
|
||||
scope.cleanup(() => {contentScope?.dispose(); container.replaceChildren(); container.classList.remove('file-inspector');});
|
||||
|
||||
const full = (variant ?? (inline ? 'panel' : 'full')) === 'full';
|
||||
function statusView(state, parent) {
|
||||
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'small');
|
||||
status.setAttribute('role', state.error ? 'alert' : 'status'); parent.append(status);
|
||||
if (state.error && state.refreshRequired) {
|
||||
if (onRefresh) {
|
||||
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
|
||||
contentScope.listen(refresh, 'click', onRefresh); parent.append(refresh);
|
||||
} else parent.append(el('p', 'Reopen this file from a refreshed image collection.', 'small'));
|
||||
} else if (state.error && onRetry) {
|
||||
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); parent.append(retry);
|
||||
}
|
||||
}
|
||||
function setState(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
contentScope?.dispose(); contentScope = childScope(scope);
|
||||
container.replaceChildren();
|
||||
if (state.loading || state.error) {
|
||||
const status = el('p', state.loading ? 'Loading file details…' : state.refreshRequired ? state.message : 'Could not read prompts from this file.', 'file-status muted');
|
||||
status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status);
|
||||
if (state.error && state.refreshRequired) {
|
||||
if (onRefresh) {
|
||||
const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection');
|
||||
contentScope.listen(refresh, 'click', onRefresh); container.append(refresh);
|
||||
} else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted'));
|
||||
} else if (state.error && onRetry) {
|
||||
const retry = createButton('Retry prompt details'); contentScope.listen(retry, 'click', onRetry); container.append(retry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
render(state);
|
||||
}
|
||||
/**
|
||||
* The concept's imageDetails (app.js a400–414). The side panel has the cover, `.detail-inner` and `.detail-footer`;
|
||||
* the viewer (`full`) has a class-less wrapper that adds the two disclosures, with the footer buttons after it (D20).
|
||||
*/
|
||||
function render(state) {
|
||||
const inner = el('div', undefined, full ? undefined : 'detail-inner');
|
||||
if (state.loading || state.error) {container.append(inner); statusView(state, inner); return;}
|
||||
const record = state.record;
|
||||
if (!record) return;
|
||||
const {file, library, metadata} = record;
|
||||
const {file, library} = record;
|
||||
const filename = record.identity.path.split('/').at(-1);
|
||||
if (inline && record.download) {
|
||||
const cover = createButton('', 'file-cover'); cover.setAttribute('aria-label', 'Open full preview');
|
||||
const video = file?.media_type === 'video';
|
||||
const media = el(video ? 'video' : 'img'); media.src = record.download.url;
|
||||
if (video) {
|
||||
media.muted = true; media.playsInline = true; media.preload = 'metadata';
|
||||
contentScope.cleanup(() => {media.pause(); media.removeAttribute('src'); media.load();});
|
||||
} else {media.alt = filename; media.decoding = 'async'; contentScope.cleanup(() => media.removeAttribute('src'));}
|
||||
cover.append(media); if (onPreview) contentScope.listen(cover, 'click', onPreview);
|
||||
else {cover.setAttribute('aria-disabled', 'true'); cover.title = 'Full preview is unavailable in this view.';}
|
||||
container.append(cover);
|
||||
}
|
||||
const inner = el('div', undefined, 'file-inspector-inner'); container.append(inner);
|
||||
const header = el('div', undefined, 'file-title');
|
||||
header.append(el('h2', library?.status === 'linked' && library.title ? library.title : filename));
|
||||
const extension = file?.extension || filename.split('.').at(-1);
|
||||
header.append(el('span', file?.format || `${extension.toUpperCase()} extension`, 'file-format file-chip'));
|
||||
inner.append(header, el('p', filename, 'file-name muted'));
|
||||
const chips = el('div', undefined, 'file-chips');
|
||||
const sources = new Set(Object.values(record.prompts).filter(role => role?.status === 'resolved').map(role => role.source));
|
||||
const evidence = sources.has('execution') ? 'Execution prompt evidence' : sources.has('graph-inferred') ? 'Workflow prompt evidence' : sources.has('embedded-parameters') ? 'Embedded parameter evidence' : null;
|
||||
if (evidence) chips.append(el('span', evidence, 'file-chip file-evidence'));
|
||||
if (library?.status === 'linked' && library.category) {
|
||||
const label = library.metadata_scope === 'pair' ? 'Recipe category' : 'Positive prompt category';
|
||||
chips.append(el('span', `${label}: ${library.category}`, 'file-chip'));
|
||||
}
|
||||
if (chips.childNodes.length) inner.append(chips);
|
||||
const linked = library?.status === 'linked';
|
||||
const title = linked && library.title ? library.title : filename;
|
||||
if (record.download && !full) container.append(cover(record));
|
||||
container.append(inner);
|
||||
const heading = el('div', undefined, 'panel-heading');
|
||||
heading.append(el('h2', title), file?.format ? el('span', file.format, 'chip') : prefixedChip('File extension ', extensionOf(record)));
|
||||
inner.append(heading, el('p', filename, 'small'));
|
||||
const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts);
|
||||
if (badge) badges.append(el('span', badge, 'chip blue'));
|
||||
if (linked && library.category) badges.append(prefixedChip(library.category_scope === 'pair' ? 'Recipe category: ' : 'Positive prompt category: ', library.category));
|
||||
if (badges.childNodes.length) inner.append(badges);
|
||||
const prompts = el('div'); inner.append(prompts);
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext});
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
|
||||
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
|
||||
if (library?.status === 'linked') {
|
||||
const vocabulary = el('div', undefined, 'file-vocabulary');
|
||||
vocabulary.append(el('p', library.metadata_scope === 'pair' ? 'Recipe tags' : 'Positive prompt tags', 'muted'));
|
||||
const tags = el('div', undefined, 'file-chips');
|
||||
for (const tag of library.tags) tags.append(el('span', tag, 'file-chip'));
|
||||
vocabulary.append(library.tags.length ? tags : el('p', 'No saved tags', 'muted')); inner.append(vocabulary);
|
||||
} else if (!library) inner.append(el('p', 'Saved vocabulary is unavailable for this inspection response.', 'muted'));
|
||||
const facts = definitionList(FACTS.map(([key, label]) => [label, factText(record.generation[key])]), 'generation-facts');
|
||||
inner.append(facts);
|
||||
if (FACTS.some(([key]) => record.generation[key]?.reason === 'projection-limited')) {
|
||||
const explanation = el('p', 'Configuration exceeds inspection limits; download the original file to inspect it locally.', 'generation-limit muted');
|
||||
explanation.id = `file-fact-limit-${++factReasonId}`;
|
||||
FACTS.forEach(([key], index) => {
|
||||
if (record.generation[key]?.reason === 'projection-limited') facts.children[index].querySelector('dd').setAttribute('aria-describedby', explanation.id);
|
||||
if (linked && library.tags.length) {
|
||||
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
|
||||
// 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.
|
||||
const filterable = onTagFilter && library.tags_scope === 'positive';
|
||||
const tags = el('div', undefined, 'tags');
|
||||
for (const tag of library.tags) {
|
||||
if (!filterable) {tags.append(el('span', tag, 'chip')); continue;}
|
||||
const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip);
|
||||
}
|
||||
inner.append(tags);
|
||||
}
|
||||
inner.append(...generationFacts(record, 'meta-grid', 'small'));
|
||||
if (full) inner.append(provenance(record, badge), rawMetadata(record));
|
||||
const footer = full ? container : el('div', undefined, 'detail-footer'), roles = promptRoles(record.prompts);
|
||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createButton('', 'send');
|
||||
pair.setAttribute('aria-label', `Send ${promptContext ? `${promptContext.toLowerCase()} ` : ''}prompt pair to ComfyUI: ${title}`);
|
||||
pair.append(createWorkspaceIcon('send'), ' Send to ComfyUI'); pair.dataset.sendRole = 'pair';
|
||||
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
|
||||
}
|
||||
if (record.historyTarget) {
|
||||
const history = createButton('');
|
||||
history.append(createWorkspaceIcon('branch'), ' Prompt history');
|
||||
// The concept's go(): the viewer closes, and a link to the route already shown re-renders it.
|
||||
contentScope.listen(history, 'click', () => {
|
||||
document.querySelector('dialog#viewer-dialog[open]')?.close();
|
||||
navigateTo(record.historyTarget);
|
||||
});
|
||||
inner.append(explanation);
|
||||
footer.append(history);
|
||||
}
|
||||
const provenance = disclosure('File & provenance');
|
||||
provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted'));
|
||||
if (record.identity.kind === 'reported-location') {
|
||||
const occurrence = record.identity.occurrence;
|
||||
provenance.append(definitionList([
|
||||
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
|
||||
], 'file-occurrence'));
|
||||
if (!full && footer.childNodes.length) container.append(footer);
|
||||
}
|
||||
/** The concept's plain cover; the card image opens the viewer (D19). Video keeps its own element. */
|
||||
function cover(record) {
|
||||
const {file} = record, video = file?.media_type === 'video';
|
||||
const node = el(video ? 'video' : 'img', undefined, 'detail-cover'); node.src = record.download.url;
|
||||
if (video) {
|
||||
node.muted = true; node.playsInline = true; node.preload = 'metadata';
|
||||
contentScope.cleanup(() => {node.pause(); node.removeAttribute('src'); node.load();});
|
||||
} else {
|
||||
node.alt = ''; node.decoding = 'async';
|
||||
if (file?.width && file?.height) {node.width = file.width; node.height = file.height;}
|
||||
contentScope.cleanup(() => node.removeAttribute('src'));
|
||||
}
|
||||
if (file) provenance.append(definitionList([
|
||||
['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)],
|
||||
return node;
|
||||
}
|
||||
/** "File & provenance": the current-file facts, the D21 prompt evidence, the occurrence and the saved association. */
|
||||
function provenance(record, badge) {
|
||||
const {file, library} = record, section = disclosure('File & provenance');
|
||||
const reported = record.identity.kind === 'reported-location';
|
||||
section.append(el('p', reported ? 'Current file at the reported location · identity unverified' : 'Current file at this source; historical bytes are not verified.', 'small'));
|
||||
const path = el('dd', record.identity.path ?? 'Not available');
|
||||
const facts = file && definitionList([
|
||||
['Source', file.source_label], ['Path', path], ['Size', formatBytes(file.size)],
|
||||
['Dimensions', file.width && file.height ? `${file.width} × ${file.height}` : null],
|
||||
['Format', file.format || `${file.extension.toUpperCase()} extension`], ['Media type', file.media_type],
|
||||
['Format', file.format || `${extensionOf(record)} extension`], ['Media type', file.media_type],
|
||||
['Modified', file.modified_time !== null ? new Date(file.modified_time * 1000).toLocaleString() : null],
|
||||
], 'file-facts'));
|
||||
else provenance.append(el('p', 'Current file observations are unavailable for this inspection response.', 'muted'));
|
||||
for (const [key, label] of FACTS) {
|
||||
const fact = record.generation[key];
|
||||
if (fact?.evidence) provenance.append(el('p', `${label}: ${fact.evidence === 'configuration' ? 'Configuration evidence' : 'Embedded parameter evidence'} · ${FACT_SOURCES[fact.source] || 'Source not recorded'}`, 'muted'));
|
||||
], 'meta-grid');
|
||||
if (facts) {
|
||||
// D38: "Show in folder" follows the Path value.
|
||||
const target = api ? revealTarget(record.identity) : null;
|
||||
if (target) path.append(' ', revealButton(target));
|
||||
section.append(facts);
|
||||
} else section.append(el('p', 'Current file observations are unavailable for this inspection response.', 'small'));
|
||||
// The concept's "Prompt evidence: execution recorded" line, in the D21 badge's words; no badge, no line.
|
||||
if (badge) section.append(el('p', `Prompt evidence: ${badge.toLowerCase()}`, 'small'));
|
||||
if (reported) {
|
||||
const occurrence = record.identity.occurrence;
|
||||
section.append(definitionList([
|
||||
['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)],
|
||||
], 'meta-grid'));
|
||||
}
|
||||
provenance.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'muted'));
|
||||
section.append(el('p', record.historyTarget ? 'Saved association; not verified against current file' : library?.status === 'ambiguous' ? 'Saved association is ambiguous; no unique saved positive prompt is available.' : 'No unique saved positive prompt association is available.', 'small'));
|
||||
if (record.download) {
|
||||
const original = el('a', 'Download original', 'workspace-link'); original.href = record.download.url;
|
||||
original.download = safeFilename(record.download.filename); provenance.append(original);
|
||||
const original = el('a', 'Download original'); original.href = record.download.url;
|
||||
original.download = safeFilename(record.download.filename); section.append(original);
|
||||
}
|
||||
inner.append(provenance);
|
||||
const raw = disclosure('Workflow & raw metadata');
|
||||
raw.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'muted'));
|
||||
if (!metadata) raw.append(el('p', 'Full metadata is unavailable for this inspection response.', 'muted'));
|
||||
return section;
|
||||
}
|
||||
/** D38: "Show in folder" right after the Path value, for a listed file only; the server opens its file manager. */
|
||||
function revealButton(target) {
|
||||
const button = createButton('Show in folder');
|
||||
button.setAttribute('aria-label', `Show ${target.path.split('/').at(-1)} in folder`);
|
||||
let pending = false;
|
||||
contentScope.listen(button, 'click', async () => {
|
||||
if (pending) return;
|
||||
pending = true; button.setAttribute('aria-busy', 'true');
|
||||
let error = null;
|
||||
try {
|
||||
await api.post(REVEAL_URL, target, {signal:contentScope.signal});
|
||||
} catch (failure) {
|
||||
error = failure ?? {};
|
||||
}
|
||||
pending = false; button.removeAttribute('aria-busy');
|
||||
if (!contentScope.signal.aborted) showWorkspaceToast(revealOutcome(error));
|
||||
});
|
||||
return button;
|
||||
}
|
||||
/** "Workflow & raw metadata": each supported record (`pre[tabindex=0] > code`), then the two downloads. */
|
||||
function rawMetadata(record) {
|
||||
const {metadata} = record, section = disclosure('Workflow & raw metadata');
|
||||
section.append(el('p', 'Supported embedded records only, not all EXIF or container metadata. Video records come from one selected container carrier; alternate carriers are not merged.', 'small'));
|
||||
if (!metadata) section.append(el('p', 'Full metadata is unavailable for this inspection response.', 'small'));
|
||||
else {
|
||||
if (metadata.status !== 'available') raw.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'muted'));
|
||||
if (metadata.status !== 'available') section.append(el('p', {limited:RAW_LIMIT, absent:'No supported embedded metadata was recorded in this file.', unreadable:'Embedded metadata could not be read.'}[metadata.status], 'small'));
|
||||
for (const key of RAW_KEYS) {
|
||||
const format = metadata.record_formats[key]; if (!format) continue;
|
||||
raw.append(el('p', key, 'raw-record-name'), el('p', REPRESENTATIONS[format], 'muted'));
|
||||
const name = el('p', undefined, 'small'); name.append(el('strong', key));
|
||||
section.append(name, el('p', REPRESENTATIONS[format], 'small'));
|
||||
if (Object.hasOwn(metadata.raw, key)) {
|
||||
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); raw.append(text);
|
||||
const text = el('pre'); text.tabIndex = 0; text.append(el('code', metadata.raw[key])); section.append(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
const downloads = inspectionDownloads(record);
|
||||
for (const [key, label] of [['metadata', 'Download metadata JSON'], ['workflow', 'Download workflow JSON']]) {
|
||||
const item = downloads[key], action = createButton(label);
|
||||
const group = el('div', undefined, 'file-download'); group.append(action);
|
||||
section.append(action);
|
||||
if (!item.available) {
|
||||
action.setAttribute('aria-disabled', 'true');
|
||||
const explanation = el('p', item.reason, 'muted');
|
||||
const explanation = el('p', item.reason, 'small');
|
||||
// IDs need only be unique within the current document, including its two inspectors.
|
||||
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); group.append(explanation);
|
||||
explanation.id = `file-download-${++downloadReasonId}`; action.setAttribute('aria-describedby', explanation.id); section.append(explanation);
|
||||
}
|
||||
contentScope.listen(action, 'click', () => {
|
||||
if (!item.available || contentScope.signal.aborted) return;
|
||||
@@ -221,19 +345,8 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal
|
||||
const anchor = el('a'); anchor.href = url; anchor.download = item.filename;
|
||||
container.append(anchor); anchor.click(); anchor.remove(); timer = setTimeout(release, 1000);
|
||||
});
|
||||
raw.append(group);
|
||||
}
|
||||
inner.append(raw);
|
||||
const footer = el('footer', undefined, 'file-inspector-footer'), roles = promptRoles(record.prompts);
|
||||
if (Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
const pair = createSendButton(promptContext ? `Send ${promptContext.toLowerCase()} prompt pair` : 'Send prompt pair'); pair.dataset.sendRole = 'pair';
|
||||
contentScope.listen(pair, 'click', () => sender.open(roles, pair)); footer.append(pair);
|
||||
}
|
||||
if (record.historyTarget) {
|
||||
const history = el('a', 'Prompt history', 'workspace-link'); history.href = record.historyTarget;
|
||||
history.prepend(createWorkspaceIcon('branch')); footer.append(history);
|
||||
}
|
||||
if (footer.childNodes.length) container.append(footer);
|
||||
return section;
|
||||
}
|
||||
return {setState, dispose:() => scope.dispose()};
|
||||
}
|
||||
|
||||
@@ -114,6 +114,16 @@ function validateDetail(value, runId, epoch) {
|
||||
value.contexts.forEach(validateContext);
|
||||
requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length);
|
||||
}
|
||||
/**
|
||||
* Compare negatives (#249 D25): the selected negative's terms the baseline lacks, by the concept's rule (app.js a600-614):
|
||||
* split on commas, trim, drop empty terms, then an exact, case-sensitive difference that keeps the selected order and repeats.
|
||||
*/
|
||||
export function addedTerms(base, selected) {
|
||||
if (typeof base !== 'string' || typeof selected !== 'string') throw new TypeError('Negative texts must be strings');
|
||||
const terms = text => text.split(',').map(term => term.trim());
|
||||
const baseline = new Set(terms(base));
|
||||
return terms(selected).filter(term => term && !baseline.has(term));
|
||||
}
|
||||
export function frozenPromptRoles(context, mode) {
|
||||
requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context);
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => {
|
||||
|
||||
@@ -29,9 +29,10 @@ function place(parent, node, before) {
|
||||
else parent.insertBefore(node, before);
|
||||
}
|
||||
|
||||
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate}) {
|
||||
export function mountRecordedHistory(container, {api, positiveId, parentScope, sender, visit = {}, onParentRevalidate, onRecordedCount}) {
|
||||
const scope = createScope(), dispose = () => scope.dispose();
|
||||
if (parentScope.signal.aborted) {dispose();return {dispose};}
|
||||
// openRun is a hoisted declaration: on an aborted parent it answers false before reading any later state.
|
||||
if (parentScope.signal.aborted) {dispose();return {dispose, openRun};}
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
scope.cleanup(() => parentScope.signal.removeEventListener('abort', dispose));
|
||||
const section = el('section', undefined, 'library-surface recorded-history');
|
||||
@@ -61,6 +62,9 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
|
||||
const controller = createHistoryController({api, positiveId, restore:visit, onChange:next => {
|
||||
render(next);runView?.setState(next);
|
||||
if (next.parent !== activeParent) {activeParent = next.parent;onParentRevalidate?.(next.parent);}
|
||||
// The history summary's "N tracked runs" (#249 D24): a completed window read's count; a read in flight keeps the last.
|
||||
const window = next.window;
|
||||
if (!window.loading) onRecordedCount?.(window.history_epoch && !window.error ? window.recorded_run_count : null);
|
||||
}});
|
||||
state = controller.state;
|
||||
runView = mountRecordedRun(container, {api, controller, sender, parentScope:scope, onRestoreFocus:restoreMarker});
|
||||
@@ -218,5 +222,11 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s
|
||||
});
|
||||
render(state);
|
||||
void controller.load();
|
||||
return {dispose};
|
||||
/** Open Recorded run for one run (the history run dot, #249 D26); false while recorded actions are unavailable. */
|
||||
function openRun({runId, anchorId = null, contextId = null, trigger}) {
|
||||
if (scope.signal.aborted || !state.actionsEnabled) return false;
|
||||
runView.open({runId, anchorId, contextId, trigger});
|
||||
return true;
|
||||
}
|
||||
return {dispose, openRun};
|
||||
}
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
export function workspaceImageMetrics(viewportWidth, density = 'comfortable', availableWidth = Infinity) {
|
||||
if (!['comfortable', 'large', 'compact'].includes(density)) throw new TypeError('Unknown thumbnail density');
|
||||
const columns = viewportWidth <= 700 || density === 'large' ? 2 :
|
||||
density === 'compact' ? (viewportWidth >= 1700 ? 5 : viewportWidth <= 1250 ? 3 : 4) :
|
||||
viewportWidth >= 1700 ? 4 : viewportWidth <= 1250 ? 2 : 3;
|
||||
const gap = 13, minimumCardWidth = 120;
|
||||
const fittingColumns = Math.max(1, Math.floor((availableWidth + gap) / (minimumCardWidth + gap)));
|
||||
return {columns: Math.min(columns, fittingColumns), gap, footerHeight:54, listHeight:97};
|
||||
// The concept card (the concept stylesheet's `.media-card`): a 1px border around `aspect-ratio: 1.33` media and the measured
|
||||
// `.media-info` row; a list row is the concept's 95px image plus that border.
|
||||
const MEDIA_RATIO = 1.33, BORDER = 2, FOOTER_HEIGHT = 54, LIST_HEIGHT = 97;
|
||||
// Bounded rendering keeps a card readable when zoom shrinks the grid below the concept's columns (D3, D31).
|
||||
export const MINIMUM_CARD_WIDTH = 120;
|
||||
const AUTOMATIC_CARD_WIDTH = 240;
|
||||
|
||||
/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */
|
||||
export function gridMetrics(grid) {
|
||||
const style = getComputedStyle(grid);
|
||||
const tracks = String(style.gridTemplateColumns || '').trim();
|
||||
const gap = Number.parseFloat(style.columnGap);
|
||||
return {columns: tracks && tracks !== 'none' ? tracks.split(/\s+/).length : 1, gap: Number.isFinite(gap) ? gap : 0};
|
||||
}
|
||||
|
||||
export function layoutImages(images, width, mode, minWidth = 240, metrics = {}) {
|
||||
const {gap = 13, footerHeight = 54, listHeight = 97} = metrics;
|
||||
const columns = mode === 'list' ? 1 : metrics.columns ?? Math.max(1, Math.floor((width + gap) / (minWidth + gap)));
|
||||
export function layoutImages(images, width, mode, metrics = {}) {
|
||||
const {gap = 13, footerHeight = FOOTER_HEIGHT, listHeight = LIST_HEIGHT} = metrics;
|
||||
const fitting = Math.max(1, Math.floor((width + gap) / (MINIMUM_CARD_WIDTH + gap)));
|
||||
const asked = metrics.columns ?? Math.max(1, Math.floor((width + gap) / (AUTOMATIC_CARD_WIDTH + gap)));
|
||||
const columns = mode === 'list' ? 1 : Math.min(asked, fitting);
|
||||
const cardWidth = Math.max(1, (width - gap * (columns - 1)) / columns);
|
||||
const bottoms = Array(columns).fill(0);
|
||||
let maxHeight = 0;
|
||||
const positions = images.map((image, index) => {
|
||||
const column = mode === 'masonry' ? bottoms.indexOf(Math.min(...bottoms)) : index % columns;
|
||||
const ratio = Number.isFinite(image.width) && Number.isFinite(image.height) && image.width > 0 && image.height > 0 ? image.width / image.height : 4 / 3;
|
||||
const mediaWidth = Math.max(1, cardWidth - 2);
|
||||
const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth * .75;
|
||||
const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth * .75) + footerHeight + 2;
|
||||
const mediaWidth = Math.max(1, cardWidth - BORDER);
|
||||
const mediaHeight = mode === 'masonry' ? mediaWidth / ratio : mediaWidth / MEDIA_RATIO;
|
||||
const height = mode === 'list' ? listHeight : (Number.isFinite(mediaHeight) ? mediaHeight : mediaWidth / MEDIA_RATIO) + footerHeight + BORDER;
|
||||
maxHeight = Math.max(maxHeight, height);
|
||||
const y = mode === 'grid' ? Math.floor(index / columns) * (height + gap) : bottoms[column];
|
||||
bottoms[column] = y + height + gap;
|
||||
return { x: column * (cardWidth + gap), y, width: cardWidth, height };
|
||||
});
|
||||
return { positions, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight };
|
||||
return { positions, columns, height: Math.max(0, ...bottoms) - (images.length ? gap : 0), maxHeight };
|
||||
}
|
||||
|
||||
export function visibleImages(positions, top, viewportHeight, focusedIndex = -1, maxHeight = 1024) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { countOf, noun } from './plural.mjs';
|
||||
|
||||
/**
|
||||
* The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category
|
||||
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
|
||||
*/
|
||||
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
export const CATEGORY_NONE_LINKED = 'None of the selected images has a linked prompt.';
|
||||
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
|
||||
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
|
||||
export const CATEGORY_MAX_LENGTH = 100;
|
||||
|
||||
function linkedPositive(image) {
|
||||
const link = image?.linked;
|
||||
if (link?.status !== 'linked') return null;
|
||||
return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null;
|
||||
}
|
||||
|
||||
/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */
|
||||
export function categoryTargets(images) {
|
||||
const ids = new Set();
|
||||
let skipped = 0;
|
||||
for (const image of images) {
|
||||
const id = linkedPositive(image);
|
||||
if (id === null) skipped += 1;
|
||||
else ids.add(id);
|
||||
}
|
||||
return {promptIds:[...ids], skipped};
|
||||
}
|
||||
|
||||
/** The success toast: the prompts changed, then any selected images that had no linked prompt. */
|
||||
export function categoryOutcome(prompts, skipped) {
|
||||
const done = `Category set on ${countOf(prompts, 'prompt')}.`;
|
||||
if (!skipped) return done;
|
||||
return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`;
|
||||
}
|
||||
|
||||
/** The prompts the server reports changed; without a count, the number of prompts posted. */
|
||||
export function updatedCount(result, posted) {
|
||||
const count = result?.updated_count;
|
||||
return Number.isSafeInteger(count) && count >= 0 ? count : posted;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { metadataEnvelope } from './file-inspector.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
|
||||
/** The Images selection bar's "Export metadata" (#249 D8): one JSON bundle of the inspector's envelopes. */
|
||||
export const BUNDLE_FILENAME = 'prompt-manager-images-metadata.json';
|
||||
const BUNDLE_FORMAT = 'prompt-manager-images-metadata';
|
||||
const EXPORT_CONCURRENCY = 4;
|
||||
|
||||
export function metadataBundle(envelopes, exportedAt) {
|
||||
return {format:BUNDLE_FORMAT, version:1, exported_at:exportedAt, images:[...envelopes]};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every image's inspection record, at most four at a time, and keep the envelopes in the images' order.
|
||||
* A read that fails, or a record without full metadata, counts as one image that could not be read.
|
||||
*/
|
||||
export async function collectImagesMetadata(images, read) {
|
||||
const envelopes = new Array(images.length).fill(null);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < images.length) {
|
||||
const index = next++;
|
||||
try {
|
||||
envelopes[index] = metadataEnvelope(await read(images[index]));
|
||||
} catch {
|
||||
// Counted below with the records that carry no full metadata.
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:Math.min(EXPORT_CONCURRENCY, images.length)}, worker));
|
||||
const found = envelopes.filter(envelope => envelope !== null);
|
||||
return {envelopes:found, failed:images.length - found.length};
|
||||
}
|
||||
|
||||
/** The export's toast; with nothing exported there is no download. */
|
||||
export function exportOutcome(exported, total) {
|
||||
if (!exported) return 'Metadata could not be read. Try again.';
|
||||
if (exported === total) return `Exported metadata for ${countOf(exported, 'image')}.`;
|
||||
return `Exported metadata for ${exported} of ${countOf(total, 'image')}. ${total - exported} could not be read.`;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
|
||||
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
|
||||
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
|
||||
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs';
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
const CHECKBOX = '.image-overlays input[type="checkbox"]';
|
||||
const CATEGORY_URL = '/prompt_manager/bulk/category';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
if (text !== undefined) element.textContent = text;
|
||||
if (className) element.className = className;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Images page's checked cards and the concept's selection bar (#249 D8; app.js renderImages, updateBulk and the
|
||||
* bulk handlers). `element` is the `#bulkbar` the page places between its filter row and gallery layout: empty while
|
||||
* nothing is checked, else "N selected" and the real actions in the concept's order. The page renders each card's
|
||||
* checkbox from `isChecked`; this module follows the boxes through the grid's `change` events.
|
||||
*
|
||||
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
|
||||
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
|
||||
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
|
||||
*/
|
||||
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
|
||||
let checked = initial;
|
||||
let exporting = false, categorizing = false;
|
||||
const element = el('div');
|
||||
element.id = 'bulkbar';
|
||||
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
|
||||
const setCategory = createButton('Set category');
|
||||
scope.listen(setCategory, 'click', () => openCategory(setCategory));
|
||||
const exportMetadata = createButton('Export metadata');
|
||||
scope.listen(exportMetadata, 'click', () => exportChecked(exportMetadata));
|
||||
const clearButton = createButton('Clear');
|
||||
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
|
||||
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
|
||||
bulk.append(count, setCategory, exportMetadata, clearButton);
|
||||
|
||||
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
|
||||
function render() {
|
||||
if (!checked.size) { bulk.remove(); return; }
|
||||
const label = selectionLabel(checked.size);
|
||||
if (count.textContent !== label) count.textContent = label;
|
||||
if (bulk.parentNode !== element) element.append(bulk);
|
||||
}
|
||||
function clear() {
|
||||
if (!checked.size) return;
|
||||
checked = Object.freeze(new Set());
|
||||
for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false;
|
||||
render();
|
||||
}
|
||||
function checkedImages() {
|
||||
return getItems().filter(image => checked.has(imageKey(image)));
|
||||
}
|
||||
// Every card carries its image key; its only form control is the checkbox, so a toggle never opens or inspects.
|
||||
scope.listen(grid, 'change', event => {
|
||||
const box = event.target;
|
||||
if (!box.matches(CHECKBOX)) return;
|
||||
checked = toggleSelection(checked, box.closest('article[data-image-key]').dataset.imageKey, box.checked);
|
||||
render();
|
||||
});
|
||||
|
||||
/** Busy until it settles: `aria-disabled` now, released in a `finally` so a throw cannot leave it stuck. */
|
||||
function busy(button) {
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
return () => button.removeAttribute('aria-disabled');
|
||||
}
|
||||
/**
|
||||
* Export metadata: the checked images' inspector envelopes in listing order, as one JSON download. A second press
|
||||
* while it runs is ignored and the selection is kept. Leaving the page aborts the reads: no download, no toast.
|
||||
*/
|
||||
async function exportChecked(button) {
|
||||
if (exporting) return;
|
||||
exporting = true;
|
||||
const release = busy(button);
|
||||
try {
|
||||
const { envelopes, failed } = await collectImagesMetadata(checkedImages(),
|
||||
async image => toInspectionRecord(await api.get(inspectPath(image), { signal: scope.signal }), image));
|
||||
if (scope.signal.aborted) return;
|
||||
if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString()));
|
||||
showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed));
|
||||
} finally {
|
||||
exporting = false;
|
||||
release();
|
||||
}
|
||||
}
|
||||
/** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */
|
||||
function downloadJson(filename, value) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
|
||||
let timer, released = false;
|
||||
const release = () => { if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url); };
|
||||
scope.cleanup(release);
|
||||
const anchor = el('a'); anchor.href = url; anchor.download = filename;
|
||||
element.append(anchor); anchor.click(); anchor.remove();
|
||||
timer = setTimeout(release, 1000);
|
||||
}
|
||||
/**
|
||||
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
|
||||
* distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change:
|
||||
* a toast says so and no dialog opens.
|
||||
*/
|
||||
function openCategory(button) {
|
||||
if (categorizing) return;
|
||||
const targets = categoryTargets(checkedImages());
|
||||
if (!targets.promptIds.length) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
|
||||
openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label,
|
||||
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) });
|
||||
}
|
||||
/**
|
||||
* The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page
|
||||
* not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`.
|
||||
*/
|
||||
async function applyCategory(button, { promptIds, skipped }, category) {
|
||||
categorizing = true;
|
||||
const release = busy(button);
|
||||
let result = null;
|
||||
try {
|
||||
result = await api.post(CATEGORY_URL, { prompt_ids: promptIds, category });
|
||||
} catch {
|
||||
// Reported below; the selection stays as it was.
|
||||
} finally {
|
||||
categorizing = false;
|
||||
release();
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
|
||||
showWorkspaceToast(categoryOutcome(updatedCount(result, promptIds.length), skipped));
|
||||
const focused = document.activeElement === button;
|
||||
onCategorySet();
|
||||
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
|
||||
if (focused) focusFallback.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
render();
|
||||
return {
|
||||
element,
|
||||
isChecked: key => checked.has(key),
|
||||
checked: () => checked,
|
||||
clear,
|
||||
/** Whether a Set category write is still in flight. */
|
||||
writing: () => categorizing,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
/** Identity is the source and relative path; content hashes may be shared by files. */
|
||||
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']);
|
||||
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
function queryFor(filters, batchSize) {
|
||||
const query = new URLSearchParams({ mode: 'workspace', limit: String(batchSize) });
|
||||
for (const key of ['q', 'subfolder']) {
|
||||
for (const key of ['q', 'subfolder', 'category']) {
|
||||
const value = filters[key];
|
||||
if (value !== undefined && typeof value !== 'string') throw new TypeError('Invalid image filter');
|
||||
if (value) query.set(key, value);
|
||||
}
|
||||
const tags = filters.tags ?? [];
|
||||
// The listing takes a comma list that must all match, so a tag can neither be empty nor hold a comma.
|
||||
if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string' || !tag.trim() || tag.includes(','))) {
|
||||
throw new TypeError('Invalid tags filter');
|
||||
}
|
||||
if (tags.length) query.set('tags', tags.map(tag => tag.trim()).join(','));
|
||||
const root = filters.root;
|
||||
if (root !== undefined && root !== 'all' && root !== '') {
|
||||
if (!/^(0|[1-9]\d*)$/.test(String(root)) || !Number.isSafeInteger(Number(root))) {
|
||||
@@ -21,8 +34,12 @@ function queryFor(filters, batchSize) {
|
||||
if (!['all', 'image', 'video'].includes(media)) throw new TypeError('Invalid media filter');
|
||||
if (media !== 'all') query.set('media', media);
|
||||
const sort = filters.sort ?? 'newest';
|
||||
if (!['newest', 'oldest', 'name', 'name-desc'].includes(sort)) throw new TypeError('Invalid sort filter');
|
||||
if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter');
|
||||
query.set('sort', sort);
|
||||
// The Favorites page (#249 D51): the listing keeps only favorites.
|
||||
const favorites = filters.favorites ?? false;
|
||||
if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter');
|
||||
if (favorites) query.set('favorites', '1');
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
@@ -30,7 +47,8 @@ function validatePage(page, cursor) {
|
||||
if (!page || !Array.isArray(page.images) || !Number.isSafeInteger(page.total) || page.total < 0 ||
|
||||
typeof page.has_more !== 'boolean' ||
|
||||
!(page.next_cursor === null || (typeof page.next_cursor === 'string' && page.next_cursor.length > 0)) ||
|
||||
page.has_more !== (page.next_cursor !== null)) {
|
||||
page.has_more !== (page.next_cursor !== null) ||
|
||||
!(page.positive_count === undefined || page.positive_count === null || isCount(page.positive_count))) {
|
||||
throw new TypeError('Invalid image page');
|
||||
}
|
||||
if (page.has_more && page.next_cursor === cursor) throw new TypeError('Image cursor did not advance');
|
||||
@@ -42,6 +60,18 @@ function validatePage(page, cursor) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `state` with the item named by `key` patched (both frozen); the same state when no item has that key. */
|
||||
export function withItem(state, key, patch) {
|
||||
if (!patch || typeof patch !== 'object' || 'path' in patch || 'root_index' in patch) {
|
||||
throw new TypeError('An image patch cannot change its identity');
|
||||
}
|
||||
const index = state.items.findIndex(item => imageKey(item) === key);
|
||||
if (index < 0) return state;
|
||||
const items = [...state.items];
|
||||
items[index] = Object.freeze({ ...items[index], ...patch });
|
||||
return Object.freeze({ ...state, items: Object.freeze(items) });
|
||||
}
|
||||
|
||||
/** Keep metadata for backward scrolling; the view bounds rendered media independently. */
|
||||
export function createImageStream({ api, onChange = () => {}, batchSize = 40, restore, fetchPage }) {
|
||||
if (fetchPage !== undefined && typeof fetchPage !== 'function') throw new TypeError('Invalid image page adapter');
|
||||
@@ -50,8 +80,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 200) {
|
||||
throw new TypeError('Invalid image batch size');
|
||||
}
|
||||
// positiveCount: distinct linked positives over the whole matching set, or null while unknown (PR B contract).
|
||||
let state = Object.freeze({ items: Object.freeze([]), total: 0, cursor: null,
|
||||
hasMore: true, loading: false, error: null, loaded: 0 });
|
||||
hasMore: true, loading: false, error: null, loaded: 0, positiveCount: null });
|
||||
let query = queryFor({}, batchSize);
|
||||
let controller = null;
|
||||
let disposed = false;
|
||||
@@ -65,13 +96,13 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
const pristine = Array.isArray(saved.items) && saved.items.length === 0 && saved.total === 0 &&
|
||||
saved.cursor === null && saved.hasMore === true;
|
||||
validatePage({ images: saved.items, total: saved.total, next_cursor: saved.cursor,
|
||||
has_more: pristine ? false : saved.hasMore });
|
||||
has_more: pristine ? false : saved.hasMore, positive_count: saved.positiveCount });
|
||||
seen = new Set(saved.items.map(imageKey));
|
||||
if (seen.size !== saved.items.length) throw new TypeError('Duplicate restored image identity');
|
||||
const items = Object.isFrozen(saved.items) && saved.items.every(Object.isFrozen) ? saved.items :
|
||||
Object.freeze(saved.items.map(item => Object.freeze({ ...item })));
|
||||
state = Object.freeze({ items, total: saved.total, cursor: saved.cursor, hasMore: saved.hasMore,
|
||||
loading: false, error: null, loaded: items.length });
|
||||
loading: false, error: null, loaded: items.length, positiveCount: saved.positiveCount ?? null });
|
||||
}
|
||||
|
||||
function publish(update) {
|
||||
@@ -101,9 +132,12 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
}
|
||||
for (const key of batchKeys) seen.add(key);
|
||||
const items = incoming.length ? Object.freeze([...state.items, ...incoming]) : state.items;
|
||||
// A plain cursor page leaves the count out and a failed count read is null: a later page keeps the
|
||||
// first page's count, and a first page without one leaves it unknown.
|
||||
const positiveCount = isCount(page.positive_count) ? page.positive_count : cursor === null ? null : state.positiveCount;
|
||||
controller = null;
|
||||
publish({ items, total: page.total, cursor: page.next_cursor, hasMore: page.has_more,
|
||||
loading: false, error: null, loaded: items.length });
|
||||
loading: false, error: null, loaded: items.length, positiveCount });
|
||||
return incoming.length;
|
||||
} catch (error) {
|
||||
if (!disposed && request === controller) {
|
||||
@@ -122,7 +156,7 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
query = nextQuery;
|
||||
seen = new Set();
|
||||
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
|
||||
loading: false, error: null, loaded: 0 });
|
||||
loading: false, error: null, loaded: 0, positiveCount: null });
|
||||
return load ? loadMore() : 0;
|
||||
}
|
||||
|
||||
@@ -130,6 +164,15 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
get state() { return state; },
|
||||
reset,
|
||||
loadMore,
|
||||
/** Patch one loaded item (a favorite toggle); false when it is not loaded or the stream is gone. */
|
||||
updateItem(key, patch) {
|
||||
const next = withItem(state, key, patch);
|
||||
if (disposed || next === state) return false;
|
||||
state = next;
|
||||
// The hint names the one patched item, so a page can repaint it without laying out the collection again.
|
||||
onChange(state, {patch:key});
|
||||
return true;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
@@ -138,3 +181,74 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The concept's count line (app.js a446): "N of M images · K positive prompts", without the count while unknown. */
|
||||
export function countLine({ loaded, total, positiveCount }) {
|
||||
const images = `${loaded.toLocaleString('en-US')} of ${total.toLocaleString('en-US')} ${noun(total, 'image')}`;
|
||||
return isCount(positiveCount) ? `${images} · ${positiveCount.toLocaleString('en-US')} ${noun(positiveCount, 'positive prompt')}` : images;
|
||||
}
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function captionDate(items, sort, now = new Date()) {
|
||||
if (sort !== 'newest') return '';
|
||||
const newest = items.reduce((latest, item) => Number.isFinite(item.modified_time) ? Math.max(latest, item.modified_time) : latest, -Infinity);
|
||||
if (!Number.isFinite(newest)) return '';
|
||||
const date = new Date(newest * 1000);
|
||||
const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY_MS);
|
||||
const day = days === 0 ? 'Today' : days === 1 ? 'Yesterday' : date.toLocaleDateString('en-US', { weekday: 'long' });
|
||||
const options = { month: 'long', day: 'numeric', ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }) };
|
||||
return `${day} · ${date.toLocaleDateString('en-US', options)}`;
|
||||
}
|
||||
|
||||
/** D14: the filter row's scope chip, "All output folders" or "<source> · <folder>". */
|
||||
export function scopeLabel({ root = 'all', subfolder = '' }, roots = []) {
|
||||
if ((root === 'all' || root === '') && !subfolder) return 'All output folders';
|
||||
const source = root === 'all' || root === '' ? 'All sources' :
|
||||
roots.find(item => String(item.index) === String(root))?.label ?? `Source ${root}`;
|
||||
return `${source} · ${subfolder || 'All output folders'}`;
|
||||
}
|
||||
|
||||
/** D44: how many "Refine this view" filters (Source, Folder, Media, Tags) narrow the view, for the Filters label. */
|
||||
export function activeRefinements({ root = 'all', subfolder = '', media = 'all', tags = [] }) {
|
||||
return [root !== 'all' && root !== '', Boolean(subfolder), media !== 'all', tags.length > 0].filter(Boolean).length;
|
||||
}
|
||||
|
||||
/** D10: a card shows its confirmed linked positive's display title; any other link status shows the filename. */
|
||||
export function cardTitle(image) {
|
||||
const title = image.linked?.status === 'linked' ? image.linked.title : null;
|
||||
return typeof title === 'string' && title ? title : image.filename;
|
||||
}
|
||||
|
||||
const hasSize = image => [image.width, image.height].every(value => Number.isFinite(value) && value > 0);
|
||||
|
||||
/** D11: "W × H · EXT · Seed N", omitting unknown parts. The seed is the listing's decimal string, shown as sent. */
|
||||
export function cardSubtitle(image) {
|
||||
const dot = image.filename.lastIndexOf('.');
|
||||
const extension = dot > 0 ? image.filename.slice(dot + 1) : '';
|
||||
return [hasSize(image) ? `${image.width} × ${image.height}` : '', extension.toUpperCase(),
|
||||
typeof image.seed === 'string' && image.seed ? `Seed ${image.seed}` : ''].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/** The concept card's `--media-ratio` (masonry art); unknown dimensions use the layout's 4:3 fallback. */
|
||||
export function mediaRatio(image) {
|
||||
return hasSize(image) ? `${image.width}/${image.height}` : '4/3';
|
||||
}
|
||||
|
||||
/** The card checkboxes' selection (#249 D8): a new frozen set with `key` added or removed; unchanged, the same set. */
|
||||
export function toggleSelection(selected, key, on) {
|
||||
if (selected.has(key) === on) return selected;
|
||||
const next = new Set(selected);
|
||||
if (on) next.add(key); else next.delete(key);
|
||||
return Object.freeze(next);
|
||||
}
|
||||
|
||||
/** The concept selection bar's count (app.js updateBulk), grouped like the page's other counts. */
|
||||
export function selectionLabel(count) {
|
||||
return `${count.toLocaleString('en-US')} selected`;
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
.image-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
|
||||
.image-filters .ca-field { display: flex; flex: 1 1 130px; min-inline-size: 0; flex-direction: column; align-items: stretch; gap: 5px; }
|
||||
.image-filters .ca-field:first-child { flex: 3 1 260px; }
|
||||
.image-filters .ca-field-label { font-size: 10px; color: var(--ca-muted); }
|
||||
.ca-tools .image-filters :where(input, select) { inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 11px; }
|
||||
.image-view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-block: 14px 22px; }
|
||||
.ca-tools .image-view-controls button { min-block-size: 36px; font-size: 11px; }
|
||||
.image-view-controls > .workspace-link { gap: 7px; min-block-size: 36px; font-size: 11px; margin-inline-start: auto; }
|
||||
.image-view-controls .ca-field { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-inline-size: 0; }
|
||||
.image-view-controls .ca-field-label { font-size: 10px; color: var(--ca-muted); }
|
||||
.ca-tools .image-view-controls select { min-block-size: 36px; font-size: 11px; }
|
||||
.image-toolbar { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin-block: 0 13px; }
|
||||
.image-toolbar h2 { font-size: 14px; font-weight: 600; margin: 0; }
|
||||
.image-toolbar p { margin: 0; font-size: 11px; }
|
||||
.image-layouts { display: inline-flex; flex-wrap: wrap; gap: 3px; }
|
||||
.image-layouts button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.image-layouts .icon { inline-size: 14px; block-size: 14px; }
|
||||
.ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); }
|
||||
.images-main { container: images-page / inline-size; }
|
||||
.image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; }
|
||||
.image-collection { min-inline-size: 0; }
|
||||
.image-grid { position: relative; inline-size: 100%; }
|
||||
.image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) var(--image-footer-height, 54px); }
|
||||
.image-card.selected { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: 0; }
|
||||
.image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 6px 6px 0 0; background: var(--ca-field); }
|
||||
.image-open :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.image-grid[data-layout="grid"] .image-open { aspect-ratio: 4 / 3; }
|
||||
.image-card-footer { position: relative; align-self: start; min-inline-size: 0; min-block-size: 54px; padding: 10px 11px; }
|
||||
.image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; }
|
||||
.ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
|
||||
.image-card-footer .icon { inline-size: 14px; block-size: 14px; }
|
||||
.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; }
|
||||
.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; }
|
||||
.image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; }
|
||||
.image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; }
|
||||
.image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; }
|
||||
.image-facts { font-size: 10px; margin-block: 17px; }
|
||||
.image-facts > div { padding-block: 8px; border-block-end: 1px solid var(--ca-line); }
|
||||
.image-facts dt { color: var(--ca-muted); margin-block-end: 4px; }
|
||||
.image-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.image-details .workspace-link { display: flex; font-size: 11px; }
|
||||
.image-continuation { text-align: center; padding-block: 24px; font-size: 12px; }
|
||||
.image-continuation [role="alert"] { color: var(--ca-error-text); }
|
||||
.image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; }
|
||||
.image-preview[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 20px; }
|
||||
.image-preview::backdrop { background: #080b12b8; overflow: hidden; overscroll-behavior: contain; }
|
||||
.image-preview-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-inline-size: 0; }
|
||||
.image-preview-header > div { min-inline-size: 0; }
|
||||
.image-preview-header h2 { font-size: 18px; font-weight: 600; line-height: 1.5; margin: 0; overflow-wrap: anywhere; }
|
||||
.image-preview-position { font-size: 12px; line-height: 1.5; margin: 0; overflow-wrap: anywhere; }
|
||||
.ca-tools .image-preview-header > button { align-self: start; min-inline-size: 32px; padding: 4px 10px; font-size: 20px; line-height: 1.5; }
|
||||
.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 20px; min-block-size: 0; min-inline-size: 0; }
|
||||
.image-preview-left { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-inline-size: 0; min-block-size: 0; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 6px; overflow: hidden; }
|
||||
.image-preview-stage { position: relative; min-inline-size: 0; min-block-size: 0; }
|
||||
.image-preview-media { position: absolute; inset: 0; }
|
||||
.image-preview-media > :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; }
|
||||
.image-preview-media > img[hidden] { display: none; }
|
||||
.image-preview-media > .viewer-container { position: absolute; inset: 0; inline-size: 100% !important; block-size: 100% !important; background: var(--ca-field); }
|
||||
.image-preview-toolbar { border-block-start: 1px solid var(--ca-line); padding: 10px; }
|
||||
.image-preview-controls { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; }
|
||||
.ca-tools .image-preview-controls :where(button, a) { display: inline-flex; align-items: center; justify-content: center; min-block-size: 32px; padding: 4px 10px; background: var(--ca-control); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 12px; line-height: 1.5; text-decoration: none; }
|
||||
.image-preview-controls [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
|
||||
.image-preview-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; }
|
||||
.image-preview-failure { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; }
|
||||
.image-preview-failure[hidden] { display: none; }
|
||||
.image-preview-details { min-inline-size: 0; min-block-size: 0; overflow: auto; overscroll-behavior: contain; padding: 0 6px 18px; }
|
||||
.image-preview :where(button, a, .image-preview-stage):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; }
|
||||
@media (max-width: 1250px) {
|
||||
.image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; }
|
||||
.image-preview-body { grid-template-columns: minmax(0, 1fr) 300px; }
|
||||
}
|
||||
@media (max-width: 950px) {
|
||||
.image-workspace { grid-template-columns: minmax(0, 1fr) 280px; }
|
||||
.image-preview-body { grid-template-columns: minmax(0, 1fr) 270px; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.image-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
.image-details { position: static; max-block-size: none; padding: 17px; }
|
||||
.image-details > :where(img, video) { block-size: 200px; }
|
||||
.image-filters { gap: 8px; }
|
||||
.image-filters .ca-field { flex-basis: 140px; }
|
||||
.image-filters .ca-field:first-child { flex-basis: 100%; }
|
||||
.image-view-controls { gap: 8px; }
|
||||
.image-view-controls > .workspace-link { margin-inline-start: 0; }
|
||||
.image-preview { block-size: 94dvh; overflow: auto; }
|
||||
.image-preview[open] { grid-template-rows: auto auto; }
|
||||
.image-preview-body { grid-template-columns: minmax(0, 1fr); }
|
||||
.image-preview-left { block-size: 48dvh; min-block-size: 300px; }
|
||||
.image-preview-details { overflow: visible; }
|
||||
.ca-tools .image-preview-controls :where(button, a) { font-size: 10px; }
|
||||
}
|
||||
@container images-page (inline-size < 600px) {
|
||||
.image-workspace { grid-template-columns: minmax(0, 1fr); }
|
||||
.image-details { position: static; max-block-size: none; }
|
||||
}
|
||||
.image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
|
||||
.options-status { font-size: 12px; padding-inline: 4px; }
|
||||
|
||||
.prompt-inspector { margin-block-start: 22px; min-inline-size: 0; }
|
||||
.prompt-block { border-block-start: 1px solid var(--ca-line); padding-block: 13px; min-inline-size: 0; }
|
||||
.prompt-positive { --prompt-role: #73dda9; }
|
||||
[data-ca-theme="light"] .prompt-positive { --prompt-role: #176b46; }
|
||||
.prompt-negative { --prompt-role: #ff8c98; }
|
||||
[data-ca-theme="light"] .prompt-negative { --prompt-role: #b71931; }
|
||||
.prompt-block-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; }
|
||||
.prompt-block h3 { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; margin: 0; color: var(--prompt-role); }
|
||||
.prompt-source, .prompt-empty, .prompt-block > .muted { font-size: 10px; }
|
||||
.prompt-source { margin-block: 6px 10px; }
|
||||
.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; margin-block: 10px; }
|
||||
.prompt-text:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 3px; }
|
||||
.prompt-actions { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ca-tools .prompt-actions button { display: inline-flex; align-items: center; justify-content: center; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; }
|
||||
.prompt-actions .prompt-send-button span { display: none; }
|
||||
.prompt-actions .icon { inline-size: 14px; block-size: 14px; }
|
||||
.prompt-copy-status { font-size: 11px; margin-block-end: 0; }
|
||||
.prompt-copy-status:empty { display: none; }
|
||||
.image-preview-details .prompt-inspector { margin-block-start: 0; }
|
||||
.prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); }
|
||||
.prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
|
||||
.prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; }
|
||||
.prompt-sender::backdrop { background: #000b; }
|
||||
.prompt-sender-header, .prompt-sender-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.prompt-sender-header h2 { font-size: 20px; margin: 0; }
|
||||
.prompt-sender-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-block: 20px; }
|
||||
.prompt-sender-options .ca-field { display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.prompt-sender select { inline-size: 100%; min-block-size: 38px; }
|
||||
.prompt-sender-status { font-size: 13px; line-height: 1.6; }
|
||||
.prompt-sender-status[role="alert"] { color: var(--ca-error-text); }
|
||||
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; }
|
||||
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; }
|
||||
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; }
|
||||
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; }
|
||||
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; }
|
||||
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; }
|
||||
@media (max-width: 780px) {
|
||||
.prompt-sender-options { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .image-preview :is(button, a) { min-inline-size: 40px; min-block-size: 40px; }
|
||||
.ca-tools .image-preview .prompt-actions button { min-inline-size: 36px; min-block-size: 36px; }
|
||||
}
|
||||
+520
-145
@@ -1,15 +1,26 @@
|
||||
import { createScope, createButton, createField, createSelect } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, imageKey } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, workspaceImageMetrics } from './image-layout.mjs';
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { mountSelectionBar } from './images-selection.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { promptRoles } from './prompt-blocks.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createMediaViewer } from './media-viewer.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { mountToolbar } from './toolbar.mjs';
|
||||
import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
|
||||
/** The concept's panel without an image (app.js updateImages a456–458). */
|
||||
function noSelection() {
|
||||
const inner = el('div', undefined, 'detail-inner');
|
||||
inner.append(el('h2', 'No image selected'), el('p', 'Clear the filters or choose a matching image.', 'small'));
|
||||
return inner;
|
||||
}
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
if (text !== undefined) element.textContent = text;
|
||||
@@ -17,13 +28,91 @@ function el(tag, text, className) {
|
||||
return element;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
return value >= 1048576 ? `${(value / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(value / 1024))} KB`;
|
||||
/**
|
||||
* Live regions re-announce replaced text even when it is the same, and a patched publish (a favorite) repeats the
|
||||
* same status: write text and attributes only when they change.
|
||||
*/
|
||||
function setText(element, text) {
|
||||
if (element.textContent !== text) element.textContent = text;
|
||||
}
|
||||
function setAttr(element, name, value) {
|
||||
if (element.getAttribute(name) !== value) element.setAttribute(name, value);
|
||||
}
|
||||
|
||||
let lastVisit = null;
|
||||
function selectOf(id, options, value) {
|
||||
const select = el('select');
|
||||
select.id = id;
|
||||
select.replaceChildren(...options.map(([optionValue, label]) => {
|
||||
const node = el('option', label);
|
||||
node.value = optionValue;
|
||||
return node;
|
||||
}));
|
||||
select.value = value;
|
||||
return select;
|
||||
}
|
||||
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) {
|
||||
// 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'],
|
||||
['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.';
|
||||
const RETRY_REFRESH = 'Refresh image collection to retry.';
|
||||
/** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */
|
||||
function loadFailure(sourcesRetry, categoriesFailed) {
|
||||
if (sourcesRetry && categoriesFailed) return `Sources, folders and categories could not be loaded. ${sourcesRetry}`;
|
||||
if (sourcesRetry) return `Sources and folders could not be loaded. ${sourcesRetry}`;
|
||||
return categoriesFailed ? `Categories could not be loaded. ${RETRY_RESCAN}` : '';
|
||||
}
|
||||
// Import → Rescan (D15): the concept has no rescan copy, so its toasts follow the workspace pattern. The answer
|
||||
// counts every listed file across the output roots it walked.
|
||||
const RESCAN_STARTED = 'Rescanning output folders…';
|
||||
const RESCAN_FAILED = 'Rescan could not finish. Try again.';
|
||||
function rescanFinished(result) {
|
||||
const {total, roots} = result ?? {};
|
||||
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
|
||||
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
|
||||
}
|
||||
// D7 = A: the concept's star (app.js actions.favorite) against the favorites store; its toasts without the demo's
|
||||
// "in this tab". The concept has no failure copy, so a failure follows the workspace "could not be …" pattern.
|
||||
const FAVORITE_URL = '/prompt_manager/images/favorite';
|
||||
const FAVORITE_DONE = Object.freeze({true:'Added to favorites.', false:'Removed from favorites.'});
|
||||
const FAVORITE_FAILED = Object.freeze({true:'Favorite could not be added. Try again.', false:'Favorite could not be removed. Try again.'});
|
||||
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
|
||||
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
|
||||
|
||||
let lastVisit = null;
|
||||
// One favorite request per file at a time (TESTING.md): the requests outlive the Images page that sent them, so the keys
|
||||
// in flight and the page that receives each answer live here, beside the saved visit.
|
||||
const favoritePending = new Set();
|
||||
let favoriteTarget = null;
|
||||
// Library-embedded galleries showing media cards (#249 PR E); keyed like the Images page, so each repaints its own copy.
|
||||
const embeddedFavoriteTargets = new Set();
|
||||
/**
|
||||
* Give a favorite state to the mounted Images page, or to the saved visit while none is mounted, and to every mounted
|
||||
* Library-embedded gallery.
|
||||
*/
|
||||
function deliverFavorite(key, favorite) {
|
||||
if (favoriteTarget) favoriteTarget(key, favorite);
|
||||
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
for (const target of embeddedFavoriteTargets) target(key, favorite);
|
||||
}
|
||||
// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51).
|
||||
const favoriteListeners = new Set();
|
||||
/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */
|
||||
export function onFavoriteChange(listener) {
|
||||
favoriteListeners.add(listener);
|
||||
return () => favoriteListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
|
||||
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
|
||||
* `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view.
|
||||
* `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the
|
||||
* Images last visit.
|
||||
*/
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null,
|
||||
favorites = false, onSaveView = () => {}} = {}) {
|
||||
const scope = createScope();
|
||||
const dispose = () => scope.dispose();
|
||||
parentScope.signal.addEventListener('abort', dispose, {once:true});
|
||||
@@ -36,42 +125,72 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
const visit = embedded ? null : lastVisit;
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const visit = embedded || saved || favorites ? null : lastVisit;
|
||||
let density = visit?.density || 'comfortable';
|
||||
let metrics = workspaceImageMetrics(window.innerWidth, density);
|
||||
let metrics = {...INITIAL_METRICS};
|
||||
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, recovering = false;
|
||||
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
|
||||
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
|
||||
let roots = [], folders = [];
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
|
||||
favorites: favorites || saved?.favorites === true };
|
||||
filters.tags = [...filters.tags];
|
||||
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
|
||||
// A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched.
|
||||
let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== '');
|
||||
// The first discovery settled (found or failed): only then may a pending saved scope be kept or dropped (#249 M3).
|
||||
let discoverySettled = false;
|
||||
scope.cleanup(() => {
|
||||
if (!embedded) lastVisit = state ? { filters: {...filters}, state, layout, density, selection, scrollY: window.scrollY } : null;
|
||||
// A Set category write still in flight may change what the saved collection shows: the return reloads instead.
|
||||
// A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the
|
||||
// Images return state (#249 D51, and M2 for saved views).
|
||||
if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null;
|
||||
cancelAnimationFrame(restoreFrame);
|
||||
});
|
||||
main.classList.add('images-main');
|
||||
scope.cleanup(() => main.classList.remove('images-main'));
|
||||
const description = embedded ? null : pageHeading(main)?.description;
|
||||
if (description) description.textContent = 'Browse your images, explore the details, keep your creative context.';
|
||||
const filtersBar = el('div', undefined, 'image-filters');
|
||||
const search = el('input');
|
||||
search.value = filters.q;
|
||||
search.type = 'search'; search.placeholder = 'Find an image by filename or folder…'; search.maxLength = 256;
|
||||
const source = createSelect([['all', 'All sources']], '');
|
||||
const folder = createSelect([['', 'All folders']], '');
|
||||
const media = createSelect([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']], '');
|
||||
const sort = createSelect([['newest', 'Newest first'], ['oldest', 'Oldest first'], ['name', 'Filename A–Z'], ['name-desc', 'Filename Z–A']], '');
|
||||
media.value = filters.media; sort.value = filters.sort;
|
||||
filtersBar.append(createField('Search filenames', search, 'image-search'), createField('Source', source, 'image-source'),
|
||||
createField('Folder', folder, 'image-folder'), createField('Media', media, 'image-media'), createField('Sort', sort, 'image-sort'));
|
||||
// The concept's Images heading (app.js renderImages): description, then Import before the shell's New prompt.
|
||||
const heading = embedded ? null : pageHeading(main);
|
||||
let importButton = null, importDialog = null;
|
||||
if (heading) {
|
||||
heading.description.textContent = 'Your generations, their prompts, and the next idea.';
|
||||
importButton = createButton('');
|
||||
importButton.append(createWorkspaceIcon('upload'), el('span', 'Import'));
|
||||
heading.actions.prepend(importButton);
|
||||
scope.cleanup(() => importButton.remove());
|
||||
scope.listen(importButton, 'click', () => {
|
||||
importDialog = openImportDialog({scope, trigger:importButton, rescanBusy:rescanning,
|
||||
onInspect:() => { window.location.hash = '#/inspect'; }, onRescan:rescan});
|
||||
});
|
||||
}
|
||||
// The shared concept toolbar: search (filename, linked prompt text, title and tags), category and sort (D12–D14).
|
||||
const toolbar = embedded ? null : mountToolbar(main, {scope, categories:filters.category ? [filters.category] : [],
|
||||
sorts:IMAGE_SORT_OPTIONS, value:{q:filters.q, category:filters.category, sort:filters.sort},
|
||||
activeFilters:activeRefinements(filters),
|
||||
onChange:value => {
|
||||
if (recovering) return;
|
||||
const next = {q:value.q.trim(), category:value.category, sort:value.sort};
|
||||
// A debounced search report can land after a select change already applied the same values; reloading
|
||||
// then would refetch the first page and drop the reader's scroll position.
|
||||
if (Object.entries(next).every(([key, item]) => filters[key] === item)) return;
|
||||
Object.assign(filters, next);
|
||||
reset();
|
||||
}, onFilters:openFilters, onSaveView:trigger => {
|
||||
const value = toolbar.value();
|
||||
onSaveView(trigger, imagesViewState({...filters, q:value.q.trim(), category:value.category, sort:value.sort}));
|
||||
}});
|
||||
const toolbarControls = toolbar ? [...toolbar.element.querySelectorAll('input, select, button')] : [];
|
||||
const optionsStatus = el('p', '', 'options-status muted');
|
||||
optionsStatus.hidden = true; optionsStatus.setAttribute('role', 'alert');
|
||||
if (!embedded) main.append(filtersBar, optionsStatus);
|
||||
const controls = el('div', undefined, 'image-view-controls');
|
||||
const toolbar = el('div', undefined, 'image-toolbar');
|
||||
const collectionHeading = el('h2', embedded ? 'Linked images' : 'All images');collectionHeading.tabIndex = -1;
|
||||
toolbar.append(collectionHeading);
|
||||
const count = el('p', 'Loading images…', 'muted');
|
||||
const layouts = el('div', undefined, 'image-layouts');
|
||||
// The failed loads' retry hint (null once sources load) and whether the categories failed.
|
||||
let sourcesRetry = null, categoriesFailed = false;
|
||||
if (!embedded) main.append(optionsStatus);
|
||||
// The concept's filter row: layouts, thumbnail size, the scope chip and Group by positive prompt (D14, D15).
|
||||
const filterRow = el('div', undefined, 'filter-row');
|
||||
const layouts = el('div', undefined, 'segmented');
|
||||
layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout');
|
||||
for (const mode of ['grid', 'list', 'masonry']) {
|
||||
const button = createButton(mode[0].toUpperCase() + mode.slice(1));
|
||||
@@ -84,60 +203,69 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
});
|
||||
layouts.append(button);
|
||||
}
|
||||
const refresh = createButton('Refresh');
|
||||
scope.listen(refresh, 'click', async () => {
|
||||
if (refresh.disabled) return;
|
||||
refresh.disabled = true;
|
||||
optionsRequest?.abort(); optionsRequest = null;
|
||||
optionsStatus.hidden = true;
|
||||
try {
|
||||
await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
|
||||
if (scope.signal.aborted) return;
|
||||
if (await loadOptions() && !scope.signal.aborted) reset();
|
||||
} catch {
|
||||
if (scope.signal.aborted) return;
|
||||
optionsStatus.textContent = 'Images could not be refreshed. Try Refresh again.';
|
||||
optionsStatus.hidden = false;
|
||||
} finally {
|
||||
refresh.disabled = recovering;
|
||||
}
|
||||
});
|
||||
const currentGallery = el('a', 'Current gallery', 'workspace-link');
|
||||
currentGallery.href = resolveUrl('/prompt_manager/gallery');
|
||||
const thumbnails = createSelect([['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], '');
|
||||
thumbnails.value = density;
|
||||
const sizeLabel = el('label', 'Thumbnail size', 'small');
|
||||
sizeLabel.htmlFor = 'image-size';
|
||||
const thumbnails = selectOf('image-size', [['comfortable', 'Comfortable'], ['large', 'Large'], ['compact', 'Compact']], density);
|
||||
scope.listen(thumbnails, 'change', () => { density = thumbnails.value; updateLayout(); });
|
||||
controls.append(layouts, createField('Thumbnail size', thumbnails, 'image-density'));
|
||||
filterRow.append(layouts, sizeLabel, thumbnails);
|
||||
const scopeText = document.createTextNode('');
|
||||
if (!embedded) {
|
||||
const group = el('a', 'Group by positive prompt', 'workspace-link');
|
||||
group.href = '#/library'; group.prepend(createWorkspaceIcon('branch'));
|
||||
controls.append(group, refresh);
|
||||
const chip = el('span', undefined, 'chip');
|
||||
chip.append(createWorkspaceIcon('folder'), scopeText);
|
||||
const group = createButton('Group by positive prompt →', 'chip');
|
||||
scope.listen(group, 'click', () => { window.location.hash = '#/library'; });
|
||||
filterRow.append(chip, group);
|
||||
}
|
||||
main.append(controls);
|
||||
toolbar.append(count);
|
||||
const body = el('div', undefined, 'image-workspace');
|
||||
const collection = el('section', undefined, 'image-collection');
|
||||
main.append(filterRow);
|
||||
const body = el('div', undefined, 'gallery-layout image-workspace');
|
||||
const collection = el('section', undefined, 'gallery-body');
|
||||
collection.setAttribute('aria-label', 'Images');
|
||||
// The concept's caption replaces the old "All images" heading.
|
||||
const caption = el('div', undefined, 'section-caption');
|
||||
const count = el('span', 'Loading images…');
|
||||
count.id = 'result-count';
|
||||
const captionDay = el('span');
|
||||
captionDay.hidden = true;
|
||||
caption.append(count, captionDay);
|
||||
const focusFallback = focusTarget || heading?.h1 || main;
|
||||
const grid = el('div', undefined, 'image-grid');
|
||||
grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection');
|
||||
const continuation = el('div', undefined, 'image-continuation');
|
||||
const message = el('p');
|
||||
const continuation = el('div', undefined, 'infinite-continuation');
|
||||
// The concept's ids (app.js renderImages); the Library-embedded gallery has its own page around it.
|
||||
if (!embedded) { grid.id = 'image-grid'; continuation.id = 'gallery-continuation'; }
|
||||
const message = el('p', '', 'small');
|
||||
const more = createButton('Load more images');
|
||||
const retry = createButton('Retry');
|
||||
const end = el('p', '', 'muted');
|
||||
continuation.append(message, more, retry, end);
|
||||
collection.append(toolbar, grid, continuation);
|
||||
if (!embedded) collection.append(currentGallery);
|
||||
const details = el('section', undefined, 'image-details panel');
|
||||
details.setAttribute('aria-label', 'Image details');
|
||||
details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
continuation.append(message, more, retry);
|
||||
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
|
||||
// An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620).
|
||||
const showsEmpty = !embedded || typeof emptyText === 'string';
|
||||
const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty');
|
||||
empty.hidden = true;
|
||||
// D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption.
|
||||
if (!embedded) collection.append(caption);
|
||||
collection.append(grid);
|
||||
if (showsEmpty) collection.append(empty);
|
||||
collection.append(continuation);
|
||||
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
|
||||
let focusAfterLoad = null;
|
||||
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
|
||||
const details = el('aside', undefined, 'detail-panel');
|
||||
if (!embedded) details.id = 'image-inspector';
|
||||
details.setAttribute('aria-label', 'Selected image details');
|
||||
details.append(noSelection());
|
||||
body.append(collection, details);
|
||||
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
|
||||
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
|
||||
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback,
|
||||
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }});
|
||||
if (bar) main.append(bar.element);
|
||||
main.append(body);
|
||||
const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
|
||||
onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);},
|
||||
onRefresh:refreshInspectionCollection,onClose:scheduleLoad,onRestoreFocus:image => {
|
||||
const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image));
|
||||
if (index >= 0) focusImage(index);else collectionHeading.focus({preventScroll:true});
|
||||
if (index >= 0) focusImage(index);else focusFallback.focus({preventScroll:true});
|
||||
}});
|
||||
scope.cleanup(() => mediaViewer.dispose());
|
||||
|
||||
@@ -146,7 +274,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const node = el(video ? 'video' : 'img');
|
||||
node.src = resolveUrl(image.thumbnail_url || image.url);
|
||||
if (video) { node.muted = true; node.playsInline = true; node.preload = 'metadata'; }
|
||||
else { node.alt = image.filename; node.decoding = 'async'; if (lazy) node.loading = 'lazy'; }
|
||||
else {
|
||||
// The concept card image: alt is the card title, with the listing's intrinsic size when known.
|
||||
node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy';
|
||||
if ([image.width, image.height].every(value => Number.isFinite(value) && value > 0)) {
|
||||
node.width = image.width; node.height = image.height;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function releaseVideo(video) {
|
||||
@@ -156,45 +290,111 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
detailInspector?.dispose();
|
||||
selection = imageKey(image);
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, inline:true,
|
||||
onPreview:() => previewImage(image), onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
|
||||
detailInspector = mountFileInspector(details, {sender, parentScope:scope, variant:'panel',
|
||||
onTagFilter:embedded ? undefined : filterByTag, onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection});
|
||||
detailInspector.setState(inspection.state);
|
||||
inspection.select(image);
|
||||
for (const [key, card] of rendered) {
|
||||
card.classList.toggle('selected', key === selection);
|
||||
card.querySelector('.image-open').setAttribute('aria-pressed', String(key === selection));
|
||||
}
|
||||
for (const [key, card] of rendered) markSelected(card, key === selection);
|
||||
}
|
||||
/** The concept's `.media-card.selected` (D19); as in the concept, Info carries no pressed state. */
|
||||
function markSelected(card, selected) {
|
||||
card.classList.toggle('selected', selected);
|
||||
}
|
||||
function previewImage(image) {
|
||||
if (!image) return;
|
||||
mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement});
|
||||
}
|
||||
/** The concept media card (app.js mediaCard): the image opens the viewer, Info inspects (D7–D11, D19). */
|
||||
function cardFor(image) {
|
||||
const key = imageKey(image);
|
||||
const card = el('article', undefined, 'image-card');
|
||||
const card = el('article', undefined, 'media-card');
|
||||
card.dataset.imageKey = key; card.setAttribute('role', 'listitem');
|
||||
const open = createButton('');
|
||||
open.className = 'image-open'; open.setAttribute('aria-label', `Open ${image.filename}`);
|
||||
open.title = 'Show image details'; open.setAttribute('aria-pressed', String(key === selection));
|
||||
const img = mediaElement(image, true);
|
||||
open.append(img);
|
||||
const footer = el('div', undefined, 'image-card-footer');
|
||||
const title = el('strong', image.filename); title.title = image.path;
|
||||
const dimensions = image.width && image.height ? `${image.width} × ${image.height} · ` : '';
|
||||
const type = image.media_type === 'video' ? 'Video' : image.filename.split('.').pop().toUpperCase();
|
||||
const sub = el('small', `${dimensions}${type} · ${formatBytes(image.size)}`);
|
||||
const preview = createButton('', 'ca-icon-button');
|
||||
preview.append(createWorkspaceIcon('images'));
|
||||
preview.setAttribute('aria-label', `Preview ${image.filename}`);
|
||||
footer.append(title, sub, preview); card.append(open, footer);
|
||||
card.style.setProperty('--media-ratio', mediaRatio(image));
|
||||
const art = el('div', undefined, 'media-art');
|
||||
const open = createButton('', 'image-open');
|
||||
open.setAttribute('aria-label', `Open ${image.filename}`);
|
||||
open.append(mediaElement(image, true));
|
||||
// D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star,
|
||||
// wherever the listing reports favorites (Images and Library-embedded media).
|
||||
const overlays = el('div', undefined, 'image-overlays');
|
||||
if (bar) {
|
||||
const label = el('label'), box = el('input');
|
||||
box.type = 'checkbox'; box.checked = bar.isChecked(key);
|
||||
box.setAttribute('aria-label', `Select ${image.filename}`);
|
||||
label.append(box);
|
||||
overlays.append(label);
|
||||
}
|
||||
if (typeof image.favorite === 'boolean') {
|
||||
const star = createButton('', 'star-button');
|
||||
star.setAttribute('aria-label', `Favorite ${image.filename}`);
|
||||
paintStar(star, image.favorite, favoritePending.has(key));
|
||||
overlays.append(star);
|
||||
}
|
||||
art.append(open, overlays);
|
||||
const info = el('div', undefined, 'media-info');
|
||||
const text = el('div');
|
||||
const title = el('h3', cardTitle(image)); title.title = image.path;
|
||||
text.append(title, el('p', cardSubtitle(image)));
|
||||
const actions = el('div', undefined, 'image-card-actions');
|
||||
// D9: the tag button waits for the image-annotations slice.
|
||||
const inspectButton = createButton('', 'icon-btn');
|
||||
inspectButton.append(createWorkspaceIcon('info'));
|
||||
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
|
||||
actions.append(inspectButton);
|
||||
info.append(text, actions);
|
||||
card.append(art, info);
|
||||
markSelected(card, key === selection);
|
||||
return card;
|
||||
}
|
||||
/** A star whose request is still in flight is marked busy: pressing it again does nothing until the answer. */
|
||||
function paintStar(button, favorite, pending) {
|
||||
button.textContent = favorite ? '★' : '☆';
|
||||
button.setAttribute('aria-pressed', String(favorite));
|
||||
if (pending) button.setAttribute('aria-disabled', 'true'); else button.removeAttribute('aria-disabled');
|
||||
}
|
||||
function setFavorite(key, favorite) {
|
||||
if (scope.signal.aborted) return;
|
||||
stream.updateItem(key, {favorite});
|
||||
const button = rendered.get(key)?.querySelector('.star-button');
|
||||
if (button) paintStar(button, favorite, favoritePending.has(key));
|
||||
}
|
||||
// The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E).
|
||||
if (!embedded) {
|
||||
favoriteTarget = setFavorite;
|
||||
scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; });
|
||||
} else {
|
||||
embeddedFavoriteTargets.add(setFavorite);
|
||||
scope.cleanup(() => embeddedFavoriteTargets.delete(setFavorite));
|
||||
}
|
||||
/** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
|
||||
async function toggleFavorite(image) {
|
||||
const key = imageKey(image);
|
||||
if (favoritePending.has(key)) return;
|
||||
const favorite = !image.favorite;
|
||||
favoritePending.add(key);
|
||||
deliverFavorite(key, favorite);
|
||||
const body = {root_index: image.root_index, path: image.path};
|
||||
let saved = false;
|
||||
try {
|
||||
await (favorite ? api.put(FAVORITE_URL, body) : api.del(FAVORITE_URL, {body}));
|
||||
saved = true;
|
||||
} catch {
|
||||
// Announced below; the star returns to its previous state.
|
||||
}
|
||||
favoritePending.delete(key);
|
||||
deliverFavorite(key, saved ? favorite : !favorite);
|
||||
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
|
||||
// On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51).
|
||||
if (saved) for (const listener of favoriteListeners) listener();
|
||||
}
|
||||
scope.listen(grid, 'click', event => {
|
||||
const card = event.target.closest('article[data-image-key]');
|
||||
if (!card) return;
|
||||
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
|
||||
if (event.target.closest('.image-card-footer button')) previewImage(image);
|
||||
else if (event.target.closest('button')) inspect(image);
|
||||
if (!image) return;
|
||||
if (event.target.closest('.star-button')) toggleFavorite(image);
|
||||
else if (event.target.closest('.image-open')) previewImage(image);
|
||||
else if (event.target.closest('.image-card-actions button')) inspect(image);
|
||||
});
|
||||
|
||||
function renderVisible() {
|
||||
@@ -225,9 +425,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild);
|
||||
previous = card;
|
||||
}
|
||||
// Natural footer measurements and absolute reservations share unzoomed CSS units.
|
||||
const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.image-card-footer').offsetHeight));
|
||||
if (footerHeight > metrics.footerHeight) {
|
||||
// Natural footer measurements and absolute reservations share unzoomed CSS units. The fractional height, as the
|
||||
// concept's own flow lays it out: `offsetHeight` rounds a wrapped 67.5px row up and adds half a pixel per row.
|
||||
const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').getBoundingClientRect().height / scale));
|
||||
// A small epsilon: under zoom `rect / scale` carries float noise that must not relayout and scroll for nothing.
|
||||
if (footerHeight > metrics.footerHeight + 0.01) {
|
||||
const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight);
|
||||
const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y;
|
||||
metrics = {...metrics, footerHeight, listHeight: Math.max(metrics.listHeight, footerHeight + 2)};
|
||||
@@ -242,12 +444,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
function positionCard(card, index) {
|
||||
const image = state.items[index], position = geometry.positions[index];
|
||||
card.dataset.index = index;
|
||||
card.classList.toggle('selected', imageKey(image) === selection);
|
||||
card.querySelector('.image-open').setAttribute('aria-pressed', String(imageKey(image) === selection));
|
||||
markSelected(card, imageKey(image) === selection);
|
||||
card.setAttribute('aria-posinset', String(index + 1)); card.setAttribute('aria-setsize', String(state.total));
|
||||
Object.assign(card.style, { left: `${position.x}px`, top: `${position.y}px`, width: `${position.width}px`, height: `${position.height}px` });
|
||||
}
|
||||
function focusImage(index, preview = false) {
|
||||
function focusImage(index, actions = false, {scroll = true} = {}) {
|
||||
if (index < 0 || index >= state.items.length) return;
|
||||
const image = state.items[index], key = imageKey(image);
|
||||
let card = rendered.get(key);
|
||||
@@ -255,8 +456,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
card = cardFor(image); rendered.set(key, card); positionCard(card, index);
|
||||
grid.insertBefore(card, [...grid.children].find(child => Number(child.dataset.index) > index) || null);
|
||||
}
|
||||
card.querySelector(preview ? '.image-card-footer button' : '.image-open').focus({ preventScroll: true });
|
||||
card.scrollIntoView({ block: 'nearest' });
|
||||
card.querySelector(actions ? '.image-card-actions button' : '.image-open').focus({ preventScroll: true });
|
||||
if (scroll) card.scrollIntoView({ block: 'nearest' });
|
||||
cancelAnimationFrame(frame); frame = 0;
|
||||
renderVisible();
|
||||
}
|
||||
@@ -264,13 +465,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const card = event.target.closest('article[data-image-key]');
|
||||
if (!card) return;
|
||||
const index = Number(card.dataset.index);
|
||||
const columns = layout === 'list' ? 1 : metrics.columns;
|
||||
const columns = geometry.columns;
|
||||
const target = { Home: 0, End: state.items.length - 1, ArrowRight: index + 1,
|
||||
ArrowLeft: index - 1, ArrowDown: index + columns, ArrowUp: index - columns }[event.key];
|
||||
if (target !== undefined) { event.preventDefault(); focusImage(Math.max(0, Math.min(state.items.length - 1, target))); }
|
||||
else if (event.key === 'Tab') {
|
||||
const backward = event.shiftKey && event.target.matches('.image-open');
|
||||
const forward = !event.shiftKey && event.target.closest('.image-card-footer');
|
||||
const forward = !event.shiftKey && event.target.closest('.image-card-actions');
|
||||
const adjacent = backward ? index - 1 : index + 1;
|
||||
if ((backward || forward) && state.items[adjacent] && !rendered.has(imageKey(state.items[adjacent]))) {
|
||||
event.preventDefault(); focusImage(adjacent, backward);
|
||||
@@ -279,17 +480,22 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
});
|
||||
function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); }
|
||||
function updateGeometry() {
|
||||
geometry = layoutImages(state.items, galleryWidth, layout, 240, metrics);
|
||||
geometry = layoutImages(state.items, galleryWidth, layout, metrics);
|
||||
// Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31).
|
||||
grid.classList.toggle('narrow-cards', galleryWidth < MINIMUM_CARD_WIDTH);
|
||||
grid.style.height = `${geometry.height}px`;
|
||||
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
|
||||
}
|
||||
function updateLayout() {
|
||||
if (!state || scope.signal.aborted) return;
|
||||
cancelAnimationFrame(frame); frame = 0;
|
||||
// The concept's grid classes (app.js updateImages); the concept stylesheet then owns the columns and gap (D3).
|
||||
grid.className = ['image-grid', density === 'comfortable' ? '' : density,
|
||||
layout === 'list' ? 'list-grid' : layout === 'masonry' ? 'masonry-grid' : ''].filter(Boolean).join(' ');
|
||||
const width = Number.parseFloat(getComputedStyle(grid).width);
|
||||
const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null;
|
||||
galleryWidth = width;
|
||||
metrics = workspaceImageMetrics(window.innerWidth, density, galleryWidth);
|
||||
metrics = {...INITIAL_METRICS, ...gridMetrics(grid)};
|
||||
if (retained) {
|
||||
metrics.footerHeight = retained.footerHeight;
|
||||
metrics.listHeight = retained.listHeight;
|
||||
@@ -315,21 +521,42 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); });
|
||||
});
|
||||
scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); });
|
||||
function renderState(next) {
|
||||
const changed = state?.items !== next.items;
|
||||
function continuationText(next) {
|
||||
if (recovering) return 'Images paused until sources and folders are available.';
|
||||
if (next.error) return 'Images could not be loaded. Retry or rescan the collection.';
|
||||
if (next.loading && !next.loaded) return 'Loading images…';
|
||||
if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`;
|
||||
if (embedded) return next.loaded ? 'You’re all caught up.' : 'No linked media available for this selection.';
|
||||
// The concept's end of the collection (app.js updateImageContinuation).
|
||||
return `${next.loaded ? 'You’re all caught up' : 'No matching images'} · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')}`;
|
||||
}
|
||||
function renderState(next, {patch} = {}) {
|
||||
const previous = state;
|
||||
// A patched item (a favorite) keeps every card's size and place; setFavorite repaints its card.
|
||||
const changed = !patch && state?.items !== next.items;
|
||||
if (changed) {
|
||||
if (!next.items.length) itemIndexes.clear();
|
||||
for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
|
||||
}
|
||||
state = next;
|
||||
count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} ${noun(next.total, 'linked image')}` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} ${noun(next.total, 'image')}`;
|
||||
if (!embedded) {
|
||||
setText(count, countLine(next));
|
||||
setText(captionDay, captionDate(next.items, filters.sort));
|
||||
if (captionDay.hidden !== !captionDay.textContent) captionDay.hidden = !captionDay.textContent;
|
||||
}
|
||||
if (changed) updateLayout();
|
||||
message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : '';
|
||||
if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status');
|
||||
// The concept's Load more toast (app.js loadMoreImages) for every batch appended to a loaded collection.
|
||||
if (!embedded && changed && previous?.loaded && next.loaded > previous.loaded) {
|
||||
const added = next.loaded - previous.loaded;
|
||||
showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`);
|
||||
}
|
||||
setText(message, continuationText(next));
|
||||
setAttr(message, 'role', next.error ? 'alert' : 'status');
|
||||
retry.hidden = !next.error;
|
||||
more.hidden = !next.hasMore || !!next.error;
|
||||
more.setAttribute('aria-disabled', String(next.loading || recovering));
|
||||
end.textContent = !recovering && !next.loading && !next.error && !next.hasMore ? (next.loaded ? 'You’re all caught up.' : embedded ? 'No linked media available for this selection.' : 'No matching images or media. Try changing the filters.') : '';
|
||||
if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0;
|
||||
if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next);
|
||||
if (!selection && next.items.length) inspect(next.items[0]);
|
||||
scheduleLoad();
|
||||
}
|
||||
@@ -338,10 +565,31 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
stream.dispose();
|
||||
for (const video of body.querySelectorAll('video')) releaseVideo(video);
|
||||
});
|
||||
scope.listen(more, 'click', () => { if (!recovering) stream.loadMore(); });
|
||||
scope.listen(retry, 'click', () => { if (!recovering) stream.loadMore(); });
|
||||
function pressLoad() {
|
||||
if (recovering || state?.loading) return;
|
||||
focusAfterLoad = state?.loaded ?? 0;
|
||||
stream.loadMore();
|
||||
}
|
||||
/** The concept focuses the first appended image; focus never stays on a control that just hid. */
|
||||
function settleLoadFocus(next) {
|
||||
const first = focusAfterLoad;
|
||||
focusAfterLoad = null;
|
||||
const active = document.activeElement;
|
||||
// Only while focus is still on the pressed control: a viewer or another control the user moved to keeps it.
|
||||
const stranded = !active || active === document.body || active === more || active === retry;
|
||||
if (!stranded) return;
|
||||
if (next.error) { retry.focus(); return; }
|
||||
// Without scrolling: the visible cards keep their place (bounded-DOM anchor); the next arrow key reveals it.
|
||||
if (next.loaded > first) focusImage(first, false, {scroll:false});
|
||||
else if (more.hidden) {
|
||||
if (next.loaded) focusImage(next.loaded - 1, false, {scroll:false});
|
||||
else focusFallback.focus({preventScroll:true});
|
||||
}
|
||||
}
|
||||
scope.listen(more, 'click', pressLoad);
|
||||
scope.listen(retry, 'click', pressLoad);
|
||||
function maybeLoad() {
|
||||
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return;
|
||||
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
|
||||
const bounds = continuation.getBoundingClientRect();
|
||||
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
|
||||
}
|
||||
@@ -354,65 +602,191 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}, { rootMargin: '250px' });
|
||||
observer.observe(continuation);
|
||||
}
|
||||
function updateScope() {
|
||||
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
|
||||
toolbar?.setActiveFilters(activeRefinements(filters));
|
||||
}
|
||||
function reset() {
|
||||
mediaViewer.stopSlideshow();
|
||||
main.querySelector('.image-preview[open]')?.close();
|
||||
main.querySelector('#viewer-dialog[open]')?.close();
|
||||
if (!recovering) {
|
||||
inspection.clear();
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
details.replaceChildren(el('h2', 'Image details'), el('p', 'Select an image to inspect it.'));
|
||||
details.replaceChildren(noSelection());
|
||||
}
|
||||
selection = null;
|
||||
focusAfterLoad = null;
|
||||
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
|
||||
bar?.clear();
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
// A toolbar change while discovery is still pending keeps the saved Source and Folder; the settled discovery
|
||||
// then lists the view with every current value (#249 M3).
|
||||
if (savedScopePending && !discoverySettled) { updateScope(); return; }
|
||||
settleSavedScope();
|
||||
updateScope();
|
||||
stream.reset(filters, {load:!recovering});
|
||||
}
|
||||
/** A saved view's Source and Folder apply only while the discovered sources still list them (#249 D16). */
|
||||
function settleSavedScope() {
|
||||
if (!savedScopePending) return;
|
||||
savedScopePending = false;
|
||||
if (filters.root !== 'all' && !roots.some(root => String(root.index) === filters.root)) filters.root = 'all';
|
||||
if (!folderOptions(filters.root).some(([path]) => path === filters.subfolder)) filters.subfolder = '';
|
||||
}
|
||||
/** A panel tag chip (D21): the Images Tags filter becomes exactly that tag, as "Refine this view" would set it. */
|
||||
function filterByTag(tag) {
|
||||
if (recovering || !tag.trim() || tag.includes(',')) return;
|
||||
if (filters.tags.length === 1 && filters.tags[0] === tag) return;
|
||||
filters.tags = [tag];
|
||||
reset();
|
||||
}
|
||||
/** "Refine this view": Source, Folder, Media and Tags, the filters the old field row held (D14). */
|
||||
function openFilters(trigger) {
|
||||
if (recovering) return;
|
||||
openRefineDialog({scope, trigger, fields:[
|
||||
{key:'source', options:[['all', 'All sources'], ...roots.map(root => [String(root.index), root.label])]},
|
||||
{key:'folder', options:folderOptions('all'), optionsFor:folderOptions},
|
||||
{key:'media', options:MEDIA_OPTIONS},
|
||||
{key:'tags'},
|
||||
], values:{source:filters.root, folder:filters.subfolder, media:filters.media, tags:filters.tags.join(', ')},
|
||||
onApply:applyRefinement, onClear:cleared => {
|
||||
toolbar.setValue({category:cleared.category});
|
||||
applyRefinement(cleared);
|
||||
}});
|
||||
}
|
||||
/** The chosen Source's folders, as the old field row reloaded them; every folder under All sources. */
|
||||
function folderOptions(source) {
|
||||
const root = source === 'all' ? null : roots.find(item => String(item.index) === source);
|
||||
return [['', 'All output folders'], ...(root?.subfolders ?? folders).map(path => [path, path])];
|
||||
}
|
||||
/** Apply the dialog's values (Clear adds `category`); unchanged filters keep the loaded page, as the toolbar does. */
|
||||
function applyRefinement({source, folder, media, tags, category = filters.category}) {
|
||||
if (recovering) return;
|
||||
const next = {root:source, subfolder:folder, media, category,
|
||||
tags:[...new Set(tags.split(',').map(tag => tag.trim()).filter(Boolean))]};
|
||||
const same = Object.entries(next).every(([key, value]) => key === 'tags'
|
||||
? value.length === filters.tags.length && value.every((tag, index) => tag === filters.tags[index])
|
||||
: filters[key] === value);
|
||||
if (same) return;
|
||||
Object.assign(filters, next);
|
||||
reset();
|
||||
}
|
||||
function setControlsDisabled(disabled) {
|
||||
for (const control of [...toolbarControls, more, retry]) control.disabled = disabled;
|
||||
if (importButton) importButton.disabled = disabled;
|
||||
}
|
||||
/**
|
||||
* Import → "Scan an output folder" (D15): rescan the output roots, then reload the options and the listing. A toast
|
||||
* says it started and how it ended; meanwhile a reopened Import dialog shows Rescan as running.
|
||||
*/
|
||||
async function rescan() {
|
||||
if (rescanning || recovering || scope.signal.aborted) return;
|
||||
rescanning = true;
|
||||
optionsRequest?.abort(); optionsRequest = null;
|
||||
showWorkspaceToast(RESCAN_STARTED);
|
||||
try {
|
||||
const result = await api.post('/prompt_manager/gallery/rescan', {}, {signal:scope.signal});
|
||||
if (scope.signal.aborted) return;
|
||||
const [found] = await Promise.all([loadOptions(), loadCategories()]);
|
||||
if (scope.signal.aborted) return;
|
||||
renderLoadStatus();
|
||||
if (found) reset();
|
||||
if (!scope.signal.aborted) announceRescan(rescanFinished(result));
|
||||
} catch {
|
||||
// A failed rescan leaves the collection and any discovery alert as they were.
|
||||
if (!scope.signal.aborted) announceRescan(RESCAN_FAILED);
|
||||
} finally {
|
||||
rescanning = false;
|
||||
importDialog?.setRescanBusy(false);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* D42: the concept's toast stays fixed at the bottom, so a discovery alert the rescan leaves is scrolled clear of
|
||||
* the toast's band (its `scroll-margin-block-end`) only when it is in that band; elsewhere the page stays put.
|
||||
*/
|
||||
function announceRescan(message) {
|
||||
showWorkspaceToast(message);
|
||||
if (optionsStatus.hidden) return;
|
||||
const {top, bottom} = optionsStatus.getBoundingClientRect();
|
||||
const band = parseFloat(getComputedStyle(optionsStatus).scrollMarginBlockEnd) || 0;
|
||||
if (bottom > innerHeight - band && top < innerHeight) optionsStatus.scrollIntoView({block:'nearest', inline:'nearest'});
|
||||
}
|
||||
async function refreshInspectionCollection() {
|
||||
if (scope.signal.aborted) return;
|
||||
// Numeric roots may now name another source; retire the old stream first.
|
||||
recovering = true;
|
||||
filters.root = 'all';filters.subfolder = '';filters.q = search.value.trim();
|
||||
source.replaceChildren(new Option('All sources', 'all'));folder.replaceChildren(new Option('All folders', ''));
|
||||
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = true;
|
||||
optionsStatus.hidden = true;
|
||||
filters.root = 'all';filters.subfolder = '';
|
||||
if (toolbar) filters.q = toolbar.value().q.trim();
|
||||
roots = [];folders = [];
|
||||
setControlsDisabled(true);
|
||||
sourcesRetry = null;
|
||||
renderLoadStatus();
|
||||
reset();
|
||||
const discovery = loadOptions(), request = optionsRequest;
|
||||
if (!await discovery || scope.signal.aborted || request !== optionsRequest) return;
|
||||
const found = await discovery;
|
||||
if (scope.signal.aborted || request !== optionsRequest) return;
|
||||
renderLoadStatus();
|
||||
if (!found) return;
|
||||
recovering = false;
|
||||
for (const control of [search,source,folder,media,sort,refresh,more,retry]) control.disabled = false;
|
||||
setControlsDisabled(false);
|
||||
reset();
|
||||
}
|
||||
function renderLoadStatus() {
|
||||
const text = loadFailure(sourcesRetry, categoriesFailed);
|
||||
setText(optionsStatus, text);
|
||||
optionsStatus.hidden = !text;
|
||||
}
|
||||
/** Discover the sources and folders; the caller renders the outcome with renderLoadStatus. */
|
||||
async function loadOptions() {
|
||||
optionsRequest?.abort();
|
||||
const request = new AbortController(); optionsRequest = request;
|
||||
try {
|
||||
// Every source's folders (with ancestors), listed per source too, so the dialog can follow its Source.
|
||||
const params = new URLSearchParams({mode:'workspace', include_ancestors:'true'});
|
||||
if (filters.root !== 'all') params.set('root', filters.root);
|
||||
const data = await api.get(`/prompt_manager/gallery/subfolders?${params}`, {signal:request.signal});
|
||||
if (scope.signal.aborted || request !== optionsRequest) return false;
|
||||
source.replaceChildren(new Option('All sources', 'all'), ...data.roots.map(root => new Option(root.label, String(root.index))));
|
||||
source.value = filters.root;
|
||||
folder.replaceChildren(new Option('All folders', ''), ...data.subfolders.map(path => new Option(path, path)));
|
||||
folder.value = filters.subfolder;
|
||||
optionsStatus.hidden = true;
|
||||
if (!Array.isArray(data?.roots) || !Array.isArray(data.subfolders)) throw new TypeError('Invalid image sources');
|
||||
const paths = list => Array.isArray(list) ? list.filter(path => typeof path === 'string' && path) : null;
|
||||
roots = data.roots.filter(root => Number.isSafeInteger(root?.index) && typeof root.label === 'string')
|
||||
.map(root => ({index:root.index, label:root.label, subfolders:paths(root.subfolders)}));
|
||||
folders = paths(data.subfolders);
|
||||
updateScope();
|
||||
sourcesRetry = null;
|
||||
return true;
|
||||
} catch {
|
||||
if (scope.signal.aborted || request !== optionsRequest) return false;
|
||||
optionsStatus.textContent = recovering ? 'Sources and folders could not be loaded. Refresh image collection to retry.' : 'Sources and folders could not be loaded. Refresh to retry.';
|
||||
optionsStatus.hidden = false;
|
||||
sourcesRetry = recovering ? RETRY_REFRESH : RETRY_RESCAN;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
scope.cleanup(() => optionsRequest?.abort());
|
||||
scope.listen(search, 'input', scope.debounce(() => { if (!recovering) { filters.q = search.value.trim(); reset(); } }, 200));
|
||||
for (const [input, key] of [[source, 'root'], [folder, 'subfolder'], [media, 'media'], [sort, 'sort']]) {
|
||||
scope.listen(input, 'change', () => {
|
||||
if (recovering) return;
|
||||
filters[key] = input.value;
|
||||
if (key === 'root') { filters.subfolder = ''; folder.value = ''; loadOptions(); }
|
||||
reset();
|
||||
});
|
||||
/** The toolbar's categories are the Library's (the linked positive's category, D13). */
|
||||
async function loadCategories() {
|
||||
if (!toolbar) return;
|
||||
categoryRequest?.abort();
|
||||
const request = new AbortController(); categoryRequest = request;
|
||||
try {
|
||||
const data = await api.get('/prompt_manager/library/options', {signal:request.signal});
|
||||
if (scope.signal.aborted || request !== categoryRequest) return;
|
||||
if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories');
|
||||
// D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
|
||||
const names = new Set([...data.categories.filter(value => typeof value === 'string' && value), filters.category]);
|
||||
names.delete('');
|
||||
toolbar.setCategories([...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0)));
|
||||
categoriesFailed = false;
|
||||
} catch {
|
||||
if (scope.signal.aborted || request !== categoryRequest) return;
|
||||
categoriesFailed = true;
|
||||
}
|
||||
}
|
||||
if (!embedded) loadOptions();
|
||||
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
|
||||
updateScope();
|
||||
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
|
||||
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => {
|
||||
if (scope.signal.aborted) return;
|
||||
discoverySettled = true;
|
||||
renderLoadStatus();
|
||||
if (savedScopePending) reset();
|
||||
});
|
||||
if (visit) {
|
||||
renderState(stream.state);
|
||||
const selected = stream.state.items.find(image => imageKey(image) === selection);
|
||||
@@ -421,7 +795,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!scope.signal.aborted) window.scrollTo(0, visit.scrollY);
|
||||
});
|
||||
if (visit.state.loading || visit.state.error) stream.loadMore();
|
||||
} else reset();
|
||||
} else if (savedScopePending) updateScope();
|
||||
else reset();
|
||||
/** D6: the selected file's prompt pair, only once its inspection loaded with both roles resolved. */
|
||||
function selectedPair() {
|
||||
const {record, prompts, loading, error, refreshRequired} = inspection.state;
|
||||
|
||||
+118
-10
@@ -1,14 +1,16 @@
|
||||
import '../api-client.js';
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
|
||||
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS } from './state.mjs';
|
||||
import { mountWorkspaceTheme } from './theme.mjs';
|
||||
import { mountImages } from './images.mjs';
|
||||
import { mountImages, onFavoriteChange } from './images.mjs';
|
||||
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
|
||||
import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading } from './heading.mjs';
|
||||
import { renderHeading, pageHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -30,6 +32,7 @@ function mountWorkspace() {
|
||||
const api = createApiClient();
|
||||
const main = document.getElementById('workspace-main');
|
||||
const primaryNav = document.getElementById('primary-nav');
|
||||
const savedNav = document.getElementById('saved-nav');
|
||||
const systemNav = document.getElementById('system-nav');
|
||||
scope.listen(document.querySelector('.skip'), 'click', event => {
|
||||
event.preventDefault();
|
||||
@@ -60,6 +63,35 @@ function mountWorkspace() {
|
||||
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
|
||||
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
|
||||
const sidebar = document.querySelector('aside.sidebar');
|
||||
// The route key a nav link carries in `data-route`: a saved view's is `saved/<id>`.
|
||||
const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id;
|
||||
let currentKey = null;
|
||||
const markCurrent = () => {
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === currentKey) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
}
|
||||
};
|
||||
// SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and
|
||||
// re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. The first row is
|
||||
// the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51).
|
||||
const [favoritesLink] = navLinks([['favorites', 'star']]);
|
||||
savedNav.replaceChildren(favoritesLink);
|
||||
const savedViews = createSavedViews(api, scope.signal);
|
||||
scope.cleanup(savedViews.subscribe(({ views }) => {
|
||||
savedNav.replaceChildren(favoritesLink, ...views.map(view => {
|
||||
const link = element('a');
|
||||
link.append(createWorkspaceIcon('search'), document.createTextNode(view.name));
|
||||
link.href = savedViewHash(view.id);
|
||||
link.dataset.route = `saved/${view.id}`;
|
||||
return link;
|
||||
}));
|
||||
markCurrent();
|
||||
}));
|
||||
savedViews.load();
|
||||
/** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */
|
||||
const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q,
|
||||
onSave: name => savedViews.create(page, name, state) });
|
||||
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
|
||||
const countRequests = new Map();
|
||||
function refreshCounts(ids) {
|
||||
@@ -70,7 +102,7 @@ function mountWorkspace() {
|
||||
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
|
||||
loadNavCounts(api, request.signal, (_, count) => {
|
||||
if (request.signal.aborted) return;
|
||||
const link = primaryNav.querySelector(`[data-route="${id}"]`);
|
||||
const link = sidebar.querySelector(`nav [data-route="${id}"]`);
|
||||
link?.querySelector('.nav-count')?.remove();
|
||||
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
|
||||
}, [id]);
|
||||
@@ -79,20 +111,95 @@ function mountWorkspace() {
|
||||
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
|
||||
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
|
||||
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
|
||||
// Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51), once
|
||||
// after a run of quick toggles settles rather than once per click (#249 M1).
|
||||
let favoriteCountTimer = 0;
|
||||
scope.cleanup(onFavoriteChange(() => {
|
||||
clearTimeout(favoriteCountTimer);
|
||||
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
|
||||
}));
|
||||
scope.cleanup(() => clearTimeout(favoriteCountTimer));
|
||||
refreshCounts(NAV_COUNT_ROUTES);
|
||||
let viewScope;
|
||||
let firstRender = true;
|
||||
scope.cleanup(() => viewScope?.dispose());
|
||||
|
||||
function render(route) {
|
||||
// A shared page dialog never stays open over the next route.
|
||||
closeWorkspaceDialog();
|
||||
viewScope?.dispose();
|
||||
images = null;
|
||||
viewScope = createScope();
|
||||
const view = viewScope;
|
||||
for (const link of sidebar.querySelectorAll('nav [data-route]')) {
|
||||
if (link.dataset.route === route.id) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
currentKey = routeKey(route);
|
||||
markCurrent();
|
||||
if (route.id === 'saved') {
|
||||
openSavedView(route, view);
|
||||
return;
|
||||
}
|
||||
renderPage(route, view);
|
||||
}
|
||||
|
||||
/** A saved view opens its page with its state; the heading, breadcrumb and title are the view's name. */
|
||||
async function openSavedView(route, view) {
|
||||
main.replaceChildren();
|
||||
await savedViews.settled();
|
||||
if (view.signal.aborted) return;
|
||||
// A view saved since the list loaded (another tab) is read once more before the link counts as gone.
|
||||
if (!savedViews.find(route.savedId) && savedViews.status === 'ready') {
|
||||
await savedViews.load();
|
||||
if (view.signal.aborted) return;
|
||||
}
|
||||
const saved = savedViews.find(route.savedId);
|
||||
if (saved) {
|
||||
const page = routes.find(item => item.id === saved.page);
|
||||
renderPage({...page, label:saved.name, title:saved.name}, view, saved);
|
||||
mountEditAction(saved, view);
|
||||
return;
|
||||
}
|
||||
if (savedViews.status === 'ready') {
|
||||
// A removed or unknown view: the shell's default page, without a history entry for the dead link.
|
||||
window.location.replace('#/images');
|
||||
return;
|
||||
}
|
||||
const {description} = renderHeading(main, {title:route.label});
|
||||
description.textContent = 'Saved views could not be loaded. Your saved data has not changed.';
|
||||
description.setAttribute('role', 'alert');
|
||||
const retry = createButton('Retry');
|
||||
view.listen(retry, 'click', () => {
|
||||
savedViews.load();
|
||||
openSavedView(route, view);
|
||||
});
|
||||
main.append(retry);
|
||||
}
|
||||
|
||||
// /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and
|
||||
// retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page.
|
||||
function mountEditAction(saved, view) {
|
||||
// renderPage has just written the heading.
|
||||
const edit = createButton('Edit');
|
||||
pageHeading(main).actions.prepend(edit);
|
||||
view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved,
|
||||
onRename: async name => {
|
||||
const renamed = await savedViews.rename(saved.id, name);
|
||||
if (view.signal.aborted) return;
|
||||
saved = renamed;
|
||||
retitle(renamed.name);
|
||||
},
|
||||
onDelete: async () => {
|
||||
await savedViews.remove(saved.id);
|
||||
window.location.replace(`#/${saved.page}`);
|
||||
} }));
|
||||
}
|
||||
|
||||
function retitle(name) {
|
||||
// Only the mounted view retitles (its rename returns once aborted), so its heading is present.
|
||||
pageHeading(main).h1.textContent = name;
|
||||
document.getElementById('workspace-breadcrumb').textContent = name;
|
||||
document.title = `${name} · Prompt Manager`;
|
||||
}
|
||||
|
||||
function renderPage(route, view, saved = null) {
|
||||
document.title = `${routeTitle(route)} · Prompt Manager`;
|
||||
// The concept's heading(); each view fills its description and actions.
|
||||
// The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`.
|
||||
@@ -100,13 +207,14 @@ function mountWorkspace() {
|
||||
if (!firstRender) h1.focus();
|
||||
firstRender = false;
|
||||
document.getElementById('workspace-breadcrumb').textContent = routeTitle(route);
|
||||
if (route.id === 'images') {
|
||||
if (route.id === 'images' || route.id === 'favorites') {
|
||||
mountNewPromptAction(main, api, view);
|
||||
images = mountImages(main, api, view);
|
||||
images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites',
|
||||
onSaveView:saveView('images', view)});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'library') {
|
||||
mountLibrary(main, api, view, {positiveId:route.positiveId});
|
||||
mountLibrary(main, api, view, {positiveId:route.positiveId, savedState:saved?.state, onSaveView:saveView('library', view)});
|
||||
return;
|
||||
}
|
||||
if (route.id === 'tags') {
|
||||
|
||||
@@ -153,8 +153,10 @@ function resolutionFor(value) {
|
||||
|
||||
function libraryFor(value) {
|
||||
if (!object(value) || !['linked', 'none', 'ambiguous', 'unavailable', 'limited'].includes(value.status) || value.scope !== 'saved-library' ||
|
||||
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') || ![null, 'pair', 'positive'].includes(value.metadata_scope)) throw new TypeError('Invalid inspection library');
|
||||
const library = { status: value.status, scope: value.scope, tags: [...value.tags], metadata_scope: value.metadata_scope };
|
||||
!Array.isArray(value.tags) || value.tags.some(tag => typeof tag !== 'string') ||
|
||||
![value.category_scope, value.tags_scope].every(scope => [null, 'pair', 'positive'].includes(scope))) throw new TypeError('Invalid inspection library');
|
||||
// D39: each field names its own source, so a recipe's category can sit beside its positive's tags.
|
||||
const library = { status: value.status, scope: value.scope, tags: [...value.tags], category_scope: value.category_scope, tags_scope: value.tags_scope };
|
||||
for (const key of ['positive_id', 'pair_id', 'title', 'category']) {
|
||||
const item = value[key] ?? null;
|
||||
if (item !== null && (key.endsWith('_id') ? !Number.isSafeInteger(item) || item <= 0 : typeof item !== 'string')) throw new TypeError('Invalid inspection library field');
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
.image-details.file-inspector { padding: 0; }
|
||||
.image-preview-slideshow-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; }
|
||||
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.file-inspector-inner { padding: 17px; }
|
||||
.image-preview-details .file-inspector-inner { padding: 0; }
|
||||
.image-preview-details .file-inspector-footer { padding-inline: 0; }
|
||||
.ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); }
|
||||
.file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; }
|
||||
.file-inspector .file-title h2 { font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.file-name { font-size: 12px; margin-block: 12px; }
|
||||
.file-chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.file-chip { display: inline-block; padding: 3px 7px; border: 1px solid var(--ca-line); border-radius: 4px; background: var(--ca-field); font-size: 10px; }
|
||||
.file-evidence { color: var(--ca-focus); }
|
||||
.file-format { flex-shrink: 0; max-inline-size: 50%; }
|
||||
.file-inspector .prompt-inspector { margin-block-start: 15px; }
|
||||
.file-inspector .prompt-block { padding-block: 15px 0; margin-block-start: 15px; }
|
||||
.file-inspector .prompt-text { margin-block: 8px 0; }
|
||||
.file-inspector .prompt-negative { margin-block-end: 12px; }
|
||||
.file-vocabulary > p { margin-block: 0 6px; font-size: 10px; }
|
||||
.file-vocabulary .file-chip { font-size: 12px; }
|
||||
.generation-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-block: 16px; font-size: 11px; }
|
||||
.generation-limit { font-size: 10px; line-height: 1.65; margin-block: 12px; }
|
||||
.generation-facts dt, .file-facts dt { color: var(--ca-muted); font-size: 10px; }
|
||||
.generation-facts dd, .file-facts dd { margin: 3px 0 0; }
|
||||
.file-inspector details { border-block-start: 1px solid var(--ca-line); margin-block-start: 16px; padding-block-start: 13px; font-size: 12px; }
|
||||
.file-inspector summary { cursor: pointer; min-block-size: 24px; }
|
||||
.file-inspector :where(summary, button, a):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.file-inspector details p { font-size: 10px; line-height: 1.65; }
|
||||
.file-facts { font-size: 11px; }
|
||||
.file-facts > div { margin-block: 10px; }
|
||||
.file-inspector details pre { white-space: pre-wrap; overflow-wrap: anywhere; max-block-size: 350px; overflow: auto; padding: 10px; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 4px; font-size: 11px; line-height: 1.6; }
|
||||
.raw-record-name { font-weight: 600; }
|
||||
.file-download { margin-block: 12px; }
|
||||
.ca-tools .file-download button { font-size: 11px; min-block-size: 30px; }
|
||||
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
|
||||
.file-inspector-footer { display: flex; flex-wrap: wrap; gap: 8px; border-block-start: 1px solid var(--ca-line); padding: 13px 17px; }
|
||||
.ca-tools .file-inspector-footer :where(button, a) { font-size: 11px; min-block-size: 30px; }
|
||||
.ca-tools .image-preview-details .file-inspector-footer button { white-space: normal; }
|
||||
.file-inspector-footer .workspace-link { display: inline-flex; gap: 7px; border: 1px solid var(--ca-line); border-radius: 4px; padding: 5px 8px; }
|
||||
.file-inspector-footer .icon { inline-size: 14px; block-size: 14px; }
|
||||
.file-status { padding: 17px; }
|
||||
@media (max-width: 700px) { .ca-tools .file-cover { block-size: 200px; } }
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .file-inspector :is(button, a, summary) { min-inline-size: 40px; min-block-size: 40px; }
|
||||
.ca-tools .file-inspector .prompt-actions button { inline-size: 36px; block-size: 36px; min-inline-size: 36px; min-block-size: 36px; }
|
||||
}
|
||||
@@ -19,21 +19,12 @@ const NO_PAIR = 'Archive needs a selected saved negative variant.';
|
||||
const ALREADY_ARCHIVED = 'This negative variant is already archived.';
|
||||
const REVIEW = 'Loaded the latest saved values. Review your entries, then save.';
|
||||
const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.';
|
||||
const TOAST_MS = 6500;
|
||||
const REFRESH = ['Reload Library', 'refresh'];
|
||||
// The concept has no discard confirmation (its Cancel and × simply close), so the state says it inline:
|
||||
// every exit (Reload Library, Cancel, × or Escape) drops the draft.
|
||||
const DISCARDS = 'Leaving this dialog discards your entries.';
|
||||
let toastTimer;
|
||||
|
||||
/** Show one outcome in the shared workspace toast (role="status"); it clears itself like the concept's. */
|
||||
export function showWorkspaceToast(message) {
|
||||
const toast = document.getElementById('workspace-toast');
|
||||
if (!toast) return;
|
||||
toast.textContent = message;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {toast.textContent = '';}, TOAST_MS);
|
||||
}
|
||||
// The toast lives in the shell module; re-exported for the pages that already import it from here.
|
||||
export { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
|
||||
@@ -40,3 +40,29 @@ export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1)
|
||||
}
|
||||
return [...visible].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
// #249 D23: the card footer's words. English month names as the concept's copy, independent of the browser locale.
|
||||
const MONTHS = Object.freeze(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']);
|
||||
const DAY_MS = 86400000;
|
||||
/** The local calendar day as a UTC day number, so a DST change never makes a day 23 or 25 hours long. */
|
||||
const localDay = date => Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS;
|
||||
|
||||
/** "Used today", "Used yesterday", "Used N days ago" (under a week), "Used <Mon D>" (", <YYYY>" in another year), or "Not used yet". */
|
||||
export function usedLabel(lastUsedAt, now = new Date()) {
|
||||
const used = typeof lastUsedAt === 'string' && lastUsedAt ? new Date(lastUsedAt) : null;
|
||||
if (!used || Number.isNaN(used.getTime())) return 'Not used yet';
|
||||
// A server clock ahead of this browser is not a future use.
|
||||
const days = Math.max(0, localDay(now) - localDay(used));
|
||||
if (days === 0) return 'Used today';
|
||||
if (days === 1) return 'Used yesterday';
|
||||
if (days < 7) return `Used ${days} days ago`;
|
||||
// A use in another calendar year names it ("Used Oct 21, 2025"), so it never reads as this year's date.
|
||||
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
|
||||
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
|
||||
}
|
||||
|
||||
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
|
||||
export function ratingLabel(rating) {
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
|
||||
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ function parameters(filters,limit) {
|
||||
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
query.set('sort',sort);return query;
|
||||
}
|
||||
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
|
||||
function validatePage(page,cursor) {
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page');
|
||||
}
|
||||
export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});
|
||||
let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});
|
||||
let query=parameters({},40),request,disposed=false,seen=new Set();
|
||||
if(restore) {
|
||||
query=parameters(restore.filters,40);const saved=restore.state;
|
||||
validatePage({items:saved.items,total:saved.total,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
|
||||
validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null);
|
||||
if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent');
|
||||
state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id));
|
||||
}
|
||||
@@ -37,11 +38,11 @@ export function createLibraryStream({api,onChange=()=>{},restore}) {
|
||||
validatePage(page,cursor);const incoming=[];const added=new Set();
|
||||
for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));}
|
||||
for(const value of added) seen.add(value);
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length;
|
||||
} catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;}
|
||||
}
|
||||
return {get state(){return state;},loadMore,async reset(filters={}) {
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
|
||||
if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore();
|
||||
},dispose(){disposed=true;request?.abort();request=null;}};
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
main.library-main { --library-positive: #73dda9; }
|
||||
.library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; }
|
||||
.library-main .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; }
|
||||
.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; }
|
||||
.library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; }
|
||||
.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; }
|
||||
.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; }
|
||||
.ca-tools .library-main .library-toolbar > button { inline-size: auto; }
|
||||
.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; }
|
||||
.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; }
|
||||
.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; }
|
||||
.library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); }
|
||||
.library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; }
|
||||
.library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; }
|
||||
.library-main .library-card-art > :only-child { grid-column: 1 / -1; }
|
||||
.library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); }
|
||||
.library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; }
|
||||
.library-main .library-card-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
|
||||
.library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; }
|
||||
.library-main .library-title-link { color: var(--ca-text); text-decoration: none; }
|
||||
.library-main .library-title-link:hover { color: var(--ca-accent); }
|
||||
.library-main .library-card-prompts .prompt-block { margin: 0; padding-block: 10px 0; }
|
||||
.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); }
|
||||
.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; }
|
||||
.library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); }
|
||||
.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 10px; }
|
||||
.library-main .library-rating { color: var(--ca-muted); }
|
||||
.library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); }
|
||||
.library-main .library-history-link svg { inline-size: 18px; block-size: 18px; }
|
||||
.library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; }
|
||||
.library-main .library-continuation { padding-block: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
||||
.library-main .library-status { flex: 1 1 240px; color: var(--ca-muted); }
|
||||
.library-main .library-surface { padding: 22px; background: var(--ca-surface); border: 1px solid var(--ca-line); border-radius: 7px; margin-block: 18px; }
|
||||
.library-main .library-branches { padding: 18px 20px; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); margin-block: 16px; }
|
||||
.library-main .library-positive-lane { padding-block-end: 12px; border-block-end: 2px solid var(--library-positive); color: var(--library-positive); font-size: 12px; }
|
||||
.library-main .library-variant-lane { display: flex; align-items: center; gap: 16px; min-block-size: 57px; margin-inline-start: 18px; border-inline-start: 2px solid var(--ca-error); padding-inline-start: 18px; }
|
||||
.library-main .library-variant-lane span { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; color: var(--ca-muted); }
|
||||
.ca-tools .library-main .library-variant-dot { flex: 0 0 29px; inline-size: 29px; min-block-size: 29px; padding: 0; border-radius: 50%; background: var(--ca-error); border: 2px solid var(--ca-error); color: white; }
|
||||
.library-main .library-variant-dot[aria-pressed="true"] { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.library-main .library-variants > button[aria-pressed="true"] { border-color: var(--ca-focus); }
|
||||
.library-main .library-shared-gallery { margin-block-start: 24px; }
|
||||
.library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; }
|
||||
@media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } }
|
||||
|
||||
[data-ca-theme="light"] main.library-main { --library-positive: #176b46; }
|
||||
|
||||
/* Role colors and branch geometry follow the approved history surface. */
|
||||
main.library-main { --library-negative: #ff8c98; }
|
||||
[data-ca-theme="light"] main.library-main { --library-negative: #b71931; }
|
||||
.library-main :where(.library-surface, .library-recorded-container, .recorded-history) { min-inline-size: 0; }
|
||||
.library-main .library-branches { overflow-x: auto; position: relative; }
|
||||
.library-main .library-positive-lane { position: relative; box-sizing: border-box; block-size: 40px; min-inline-size: 520px; margin: 17px 0 0; padding: 0 0 0 38px; border: 0; border-block-start: 2px solid var(--library-positive); font-size: 10px; line-height: 1.6; }
|
||||
.library-main .library-positive-lane::before, .history-positive-lane::before { content: "+"; position: absolute; inset-block-start: -13px; inset-inline-start: 0; border-radius: 50%; inline-size: 25px; block-size: 25px; background: var(--library-positive); color: #172b22; text-align: center; font-size: 14px; line-height: 21px; }
|
||||
.library-positive-lane > span, .history-positive-lane > span { position: absolute; inset-block-start: -25px; inset-inline-start: 38px; font-size: 10px; color: var(--library-positive); }
|
||||
.library-main .library-variants h2 { font-size: 14px; line-height: 1.5; margin: 0; }
|
||||
.library-main .library-variants > p.muted { font-size: 12px; line-height: 1.5; margin: 0 0 12px; }
|
||||
.library-main .library-panel-heading p.muted { font-size: 12px; line-height: 1.5; margin: 0; }
|
||||
.library-main .library-variants .library-branches { margin-block: 0; }
|
||||
.library-variant-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-block-start: 14px; }
|
||||
.library-variant-summary { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.library-main .library-variant-summary > p { font-size: 12px; line-height: 1.5; margin: 0; }
|
||||
.ca-tools .library-variant-footer > button { margin-inline-start: auto; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-variant-lane { position: relative; display: block; box-sizing: border-box; min-inline-size: 520px; block-size: 57px; min-block-size: 57px; margin-inline-start: var(--lane-offset, 38px); padding: 0; border: 0; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.library-main .library-variant-lane::before, .history-context-branch::before { content: ""; position: absolute; inset-block-start: -25px; inset-inline-start: -30px; inline-size: 30px; block-size: 25px; border-inline-start: 2px solid var(--library-negative); border-block-end: 2px solid var(--library-negative); border-end-start-radius: 22px; }
|
||||
.library-main .library-variant-lane span { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; font-size: 10px; max-inline-size: 430px; }
|
||||
.ca-tools .library-main .library-variant-dot { position: absolute; inset-block-start: -16px; inset-inline-start: 0; inline-size: 29px; block-size: 29px; min-block-size: 29px; border-color: var(--library-negative); background: var(--library-negative); color: #38151b; font-size: 12px; }
|
||||
.history-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-block: 14px; }
|
||||
.ca-tools .library-main :where(.library-variants, .history-toolbar) > button { max-inline-size: 100%; white-space: normal; }
|
||||
.recorded-history > .muted, .history-count, .history-scope, .history-status, .history-legend { font-size: 11px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
.library-main .recorded-history > h2 { font-size: 14px; line-height: 1.5; }
|
||||
.library-main .recorded-history > h2 + p { font-size: 12px; line-height: 1.5; }
|
||||
.history-scope summary { cursor: pointer; }
|
||||
.history-timeline { position: relative; box-sizing: border-box; inline-size: 100%; max-inline-size: 100%; max-block-size: 490px; overflow: auto; overscroll-behavior: contain; overflow-anchor: none; border: 1px solid var(--ca-line); border-radius: 6px; background: var(--ca-field); }
|
||||
.history-canvas { position: relative; min-inline-size: calc(100% - 40px); margin: 18px 20px; }
|
||||
.history-positive-lane { position: absolute; inset-block-start: 17px; inset-inline: 0; border-block-start: 2px solid var(--library-positive); color: var(--library-positive); font-size: 10px; line-height: 1.6; padding-inline-start: 38px; }
|
||||
.history-earlier-edge { position: absolute; inset: 0 auto 0 0; inline-size: 1px; }
|
||||
.history-context-branch { position: absolute; inset-inline-end: 0; block-size: 57px; border-block-start: 2px solid color-mix(in srgb, var(--library-negative) 60%, transparent); }
|
||||
.history-context-label { position: absolute; inset-inline-start: 43px; inset-block-start: 6px; inline-size: 370px; max-inline-size: calc(100% - 43px); font-size: 10px; line-height: 1.5; color: var(--ca-muted); overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: clip; }
|
||||
.history-run-list { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.history-run-column { position: absolute; inset-block: 0; inline-size: 64px; pointer-events: none; }
|
||||
.history-position { position: absolute; inset-block-start: 41px; font-size: 10px; color: var(--ca-muted); }
|
||||
.ca-tools .history-run-marker { position: absolute; pointer-events: auto; transform: translate(-50%, -50%); inline-size: 12px; block-size: 12px; min-inline-size: 12px; min-block-size: 12px; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); padding: 0; color: var(--ca-field); font-size: 16px; line-height: 1; }
|
||||
.ca-tools .history-context-anchor { inline-size: 29px; block-size: 29px; min-inline-size: 29px; min-block-size: 29px; background: var(--library-negative); color: #38151b; font-size: 12px; }
|
||||
.history-run-marker[aria-pressed="true"], .history-run-marker:focus-visible { outline: 3px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.history-run-marker[aria-disabled="true"] { opacity: .6; cursor: progress; }
|
||||
.recorded-run-dialog { box-sizing: border-box; inline-size: min(1100px, calc(100% - 32px)); max-inline-size: calc(100% - 32px); max-block-size: calc(100dvh - 32px); padding: 22px; border: 1px solid var(--ca-border); border-radius: 7px; color: var(--ca-text); background: var(--ca-surface); overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; overflow-wrap: anywhere; }
|
||||
.recorded-run-dialog::backdrop { background: #0009; }
|
||||
.recorded-run-header { display: flex; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
.recorded-run-header h2 { margin: 0; font-size: 20px; }
|
||||
.recorded-run-body, .recorded-run-body > section { min-inline-size: 0; }
|
||||
.recorded-run-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; font-size: 12px; }
|
||||
.recorded-run-facts dt { color: var(--ca-muted); font-size: 10px; }
|
||||
.recorded-run-facts dd { margin: 4px 0 0; white-space: pre-wrap; }
|
||||
.recorded-run-anchors, .recorded-run-mode { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 18px; }
|
||||
.recorded-run-dialog [aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-run-dialog :where(.muted, .prompt-source, .recorded-output-status) { font-size: 11px; line-height: 1.65; }
|
||||
.recorded-provenance { border-block-start: 1px solid var(--ca-line); padding-block-start: 12px; margin-block: 18px; }
|
||||
.recorded-provenance summary { cursor: pointer; }
|
||||
.recorded-run-dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 100%; }
|
||||
.recorded-outputs { margin-block: 24px; }
|
||||
.recorded-receipts { list-style: decimal; padding-inline-start: 24px; }
|
||||
.recorded-receipt { border: 1px solid var(--ca-line); border-radius: 5px; padding: 12px; margin-block: 10px; font-size: 12px; }
|
||||
.recorded-receipt > div { margin-block-end: 10px; }
|
||||
.recorded-receipt[aria-current="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
|
||||
.recorded-current-file { border-block-start: 1px solid var(--ca-line); padding-block-start: 16px; }
|
||||
@media (max-width: 700px) {
|
||||
.library-main .library-surface { padding: 22px; }
|
||||
.recorded-run-dialog { padding: 16px; }
|
||||
.recorded-run-facts { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.recorded-history :where(.history-toolbar button, .history-scope summary) { min-inline-size: 44px; min-block-size: 44px; }
|
||||
.ca-tools .history-run-marker { inline-size: 44px; block-size: 44px; min-inline-size: 44px; min-block-size: 44px; border: 0; background: transparent; color: transparent; }
|
||||
.ca-tools .history-run-marker::before { content: ""; position: absolute; inset: 16px; box-sizing: border-box; border: 2px solid var(--library-negative); border-radius: 50%; background: var(--ca-field); }
|
||||
.ca-tools .history-context-anchor::before { content: "−"; inset: 7.5px; inline-size: 29px; block-size: 29px; line-height: 25px; color: #38151b; background: var(--library-negative); }
|
||||
.ca-tools .library-main .library-variant-dot::after { content: ""; position: absolute; inset: -9px; }
|
||||
.ca-tools .recorded-run-dialog :where(button, summary) { min-inline-size: 44px; min-block-size: 44px; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
/* Panel actions follow the concept (style.css 294-300, 540-549); the page heading is the concept's own (Task A5).
|
||||
These .row-actions rules restyle the concept's own selector and are retired in PR E (library cards). */
|
||||
.library-main .row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ca-tools .library-main .row-actions button { min-block-size: 32px; font-size: 12px; max-inline-size: 100%; white-space: normal; }
|
||||
.library-main .library-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 12px; }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .row-actions button { min-block-size: 40px; } }
|
||||
/* Archived pairs: the existing lane at reduced emphasis (the existing .6 disabled-marker opacity); text stays at full contrast. */
|
||||
.library-main .library-variant-archived::before, .ca-tools .library-main .library-variant-archived .library-variant-dot { opacity: .6; }
|
||||
.ca-tools .library-main .library-variant-restore { position: static; margin-inline-start: 8px; min-block-size: 24px; padding: 2px 8px; font-size: 10px; vertical-align: middle; }
|
||||
@media (pointer: coarse) { .ca-tools .library-main .library-variant-restore { min-block-size: 40px; } }
|
||||
+241
-82
@@ -1,4 +1,4 @@
|
||||
import {createScope,createButton,createField,createSelect} from '../shared/toolkit/core.js';
|
||||
import {createScope,createButton} from '../shared/toolkit/core.js';
|
||||
import {createPromptSender} from './prompt-sender.mjs';
|
||||
import {mountPromptBlocks} from './prompt-inspector.mjs';
|
||||
import {mountImages} from './images.mjs';
|
||||
@@ -6,11 +6,15 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr
|
||||
import {promptRoles} from './prompt-blocks.mjs';
|
||||
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
|
||||
import {createLibraryEditorController} from './library-editor-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
import {addedTerms} from './history-state.mjs';
|
||||
import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -23,11 +27,13 @@ const selectedPairs=new Map();
|
||||
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
|
||||
const metadataDrafts=new Map();
|
||||
const archivedVisible=new Map();
|
||||
// The concept's three sorts first, then the Library's appended Oldest first (D14).
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
|
||||
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
|
||||
const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.';
|
||||
function el(tag,text,className) {
|
||||
const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node;
|
||||
}
|
||||
function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;}
|
||||
// Cut at 80 UTF-16 units, never inside a surrogate pair (database/library.py titled() matches).
|
||||
export function libraryTitle(text) {
|
||||
const first=text.split('\n')[0].trim();
|
||||
@@ -128,7 +134,8 @@ export function mountNewPromptAction(main,api,parentScope) {
|
||||
return button;
|
||||
}
|
||||
|
||||
export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
/** `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view. */
|
||||
export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,onSaveView=()=>{}}={}) {
|
||||
const scope=createScope();parentScope.cleanup(()=>scope.dispose());
|
||||
main.classList.add('library-main');
|
||||
const sender=createPromptSender({scope});
|
||||
@@ -139,25 +146,29 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
scope.cleanup(()=>main.classList.remove('library-main'));
|
||||
|
||||
function mountShelf() {
|
||||
const visit=shelfVisit, mountedGeneration=writeGeneration;
|
||||
const saved=savedState?libraryFilters(savedState):null;
|
||||
const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration;
|
||||
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters};
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
const toolbar=el('search',undefined,'library-toolbar');
|
||||
const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500;
|
||||
const category=createSelect([['','All categories']], '');category.value=filters.category;
|
||||
const tags=el('input');tags.type='text';tags.placeholder='Comma-separated tags';tags.value=filters.tags.join(', ');tags.maxLength=2000;
|
||||
const sort=createSelect([['newest','Recently added'],['oldest','Oldest first'],['name','Prompt A–Z'],['rating','Highest rated']], '');sort.value=filters.sort;
|
||||
const refresh=createButton('Refresh library');
|
||||
toolbar.append(createField('Search prompts',search,'library-search'),createField('Category',category,'library-category'),createField('Tags',tags,'library-tags'),createField('Sort',sort,'library-sort'),refresh);
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
const toolbarScope=createScope();
|
||||
// D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf.
|
||||
const refinements=()=>filters.tags.length?1:0;
|
||||
const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS,
|
||||
value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters,
|
||||
onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}});
|
||||
const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert');
|
||||
const caption=el('p','','library-caption');caption.setAttribute('role','status');
|
||||
// The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants.
|
||||
const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true;
|
||||
// The count is announced when it changes; a reload keeps the last count shown, so nothing else re-announces it.
|
||||
captionCount.setAttribute('role','status');
|
||||
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
|
||||
const message=el('p','','library-status');message.setAttribute('role','status');
|
||||
const continuation=el('div',undefined,'library-continuation');
|
||||
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
|
||||
const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link');
|
||||
const actions=headingActions(main,section);
|
||||
const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON');
|
||||
const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt);
|
||||
@@ -184,7 +195,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
scope.listen(exportButton,'click',exportLibrary);
|
||||
headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.');
|
||||
continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section);
|
||||
// Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5).
|
||||
continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section);
|
||||
const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map();
|
||||
let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1;
|
||||
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
|
||||
@@ -195,29 +207,37 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
|
||||
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
|
||||
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
|
||||
/** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */
|
||||
function cardFor(item,index) {
|
||||
const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
|
||||
const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true');
|
||||
const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
|
||||
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
|
||||
const previews=item.previews || [];
|
||||
for(const image of previews.slice(0,2)) {
|
||||
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','library-art-empty'));
|
||||
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','small'));
|
||||
else {
|
||||
const img=el('img');img.src=window.ApiClient.resolveUrl(image.thumbnail_url || image.url);img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img);
|
||||
}
|
||||
}
|
||||
if(!previews.length) art.append(el('span','No available image previews','library-art-empty'));
|
||||
const content=el('div',undefined,'library-card-content');
|
||||
const heading=el('header',undefined,'library-card-title');
|
||||
const name=link(displayTitle(item),`#/library/${item.id}`,'library-title-link');const h2=el('h2');h2.append(name);
|
||||
heading.append(h2,el('span',item.category || 'Uncategorized','library-chip'));
|
||||
const prompts=el('div',undefined,'library-card-prompts');
|
||||
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']});
|
||||
const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?countOf(item.variant_count,'saved negative variant'):'No saved negatives','library-chip library-chip-negative'),el('span',countOf(item.image_count,'linked image')));
|
||||
const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip'));
|
||||
content.append(heading,prompts,stats,vocabulary);
|
||||
const footer=el('footer',undefined,'library-card-footer');
|
||||
const rating=el('span',item.rating?'★'.repeat(item.rating)+'☆'.repeat(5-item.rating):'Unrated','library-rating');rating.setAttribute('role','img');rating.setAttribute('aria-label',item.rating?`Rated ${item.rating} out of 5`:'Unrated');footer.append(rating);
|
||||
const history=link('',`#/library/${item.id}`,'library-history-link');history.append(createWorkspaceIcon('branch'),el('span','Explore history'));footer.append(history);
|
||||
if(!previews.length) art.append(el('span','No available image previews','small'));
|
||||
const name=displayTitle(item);
|
||||
const content=el('div',undefined,'card-content');
|
||||
const heading=el('div',undefined,'card-title');const open=createButton(name);open.dataset.history='';
|
||||
heading.append(open,el('span',item.category || 'Uncategorized','chip'));
|
||||
const prompts=el('div');
|
||||
const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive'],title:name});
|
||||
const meta=el('div',undefined,'card-meta');
|
||||
meta.append(el('span',`\u2212 ${countOf(item.variant_count,'negative variant')}`,'chip red'),el('span',countOf(item.run_count ?? 0,'run')),el('span',countOf(item.image_count,'image')));
|
||||
const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip'));
|
||||
content.append(heading,prompts,meta,vocabulary);
|
||||
const footer=el('div',undefined,'card-footer');
|
||||
const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at);
|
||||
const facts=el('span');
|
||||
if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);}
|
||||
else facts.textContent=used;
|
||||
const actions=el('div',undefined,'row-actions');
|
||||
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
|
||||
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
|
||||
actions.append(send,explore);footer.append(facts,actions);
|
||||
article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks};
|
||||
}
|
||||
function captureAnchor() {
|
||||
@@ -285,7 +305,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
pendingMeasurements.clear();
|
||||
const restore=dirty?(pendingAnchor || anchor):null;
|
||||
if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?448:468});dirty=false;}
|
||||
if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?422:448});dirty=false;}
|
||||
const index=restore?indexesById.get(restore.id):undefined;
|
||||
const target=index===undefined?undefined:Math.max(0,layout.rows[Math.floor(index/columnCount)].top-restore.offset);
|
||||
renderWindow(target);
|
||||
@@ -315,14 +335,21 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
function focusCard(index,last=false) {
|
||||
if(index<0 || index>=state.items.length) return;
|
||||
if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node;
|
||||
const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
|
||||
const controls=card.querySelectorAll('button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow();
|
||||
}
|
||||
// The title and Explore history open the prompt's history; the footer send sends the full positive (D23).
|
||||
scope.listen(grid,'click',event=>{
|
||||
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
|
||||
const item=state?.items[indexesById.get(Number(card.dataset.promptId))];if(!item) return;
|
||||
if(event.target.closest('[data-history]')) window.location.hash=`#/library/${item.id}`;
|
||||
const send=event.target.closest('[data-send-positive]');if(send) sender.open({positive:item.text},send);
|
||||
});
|
||||
scope.listen(grid,'keydown',event=>{
|
||||
const card=event.target.closest('article[data-prompt-id]');if(!card) return;
|
||||
const index=Number(card.dataset.index);
|
||||
if(event.target.matches('a') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
|
||||
if(event.target.matches('[data-history]') && ['Home','End','ArrowLeft','ArrowRight'].includes(event.key)) {event.preventDefault();focusCard(event.key==='Home'?0:event.key==='End'?state.items.length-1:index+(event.key==='ArrowLeft'?-1:1));}
|
||||
else if(event.key==='Tab') {
|
||||
const controls=card.querySelectorAll('a,button');const next=index+(event.shiftKey?-1:1);
|
||||
const controls=card.querySelectorAll('button');const next=index+(event.shiftKey?-1:1);
|
||||
const edge=event.shiftKey?controls[0]:controls[controls.length-1];
|
||||
if(event.target===edge && state.items[next] && !cards.has(state.items[next].id)) {event.preventDefault();focusCard(next,event.shiftKey);}
|
||||
}
|
||||
@@ -337,9 +364,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
} else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;}
|
||||
}
|
||||
state=next;
|
||||
caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`;
|
||||
if(next.variantTotal!==null) {
|
||||
const count=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`;
|
||||
if(captionCount.textContent!==count) captionCount.textContent=count;caption.hidden=false;
|
||||
}
|
||||
const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':'';
|
||||
message.textContent=(next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
|
||||
message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden;
|
||||
message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading));
|
||||
schedule();scheduleLoad();
|
||||
}
|
||||
@@ -354,30 +384,46 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
optionsRequest?.abort();const request=optionsRequest=new AbortController();
|
||||
try {
|
||||
const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return;
|
||||
const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true;
|
||||
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}}
|
||||
toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true;
|
||||
} catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}}
|
||||
}
|
||||
const reset=()=>{restoreScrollY=null;return stream.reset(filters);};
|
||||
scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200));
|
||||
scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200));
|
||||
scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();});
|
||||
scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore());
|
||||
const reset=()=>{restoreScrollY=null;toolbar.setActiveFilters(refinements());return stream.reset(filters);};
|
||||
/** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */
|
||||
function applyToolbar(value) {
|
||||
const next={text:value.q.trim(),category:value.category,sort:value.sort};
|
||||
if(Object.entries(next).every(([key,item])=>filters[key]===item)) return;
|
||||
Object.assign(filters,next);reset();
|
||||
}
|
||||
/** "Refine this view" with the Library's one extra filter, Tags (D14); Clear also clears the toolbar category. */
|
||||
function openFilters(trigger) {
|
||||
openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement,
|
||||
onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}});
|
||||
}
|
||||
function applyRefinement({tags,category=filters.category}) {
|
||||
const next=[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))];
|
||||
if(category===filters.category && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return;
|
||||
filters.category=category;filters.tags=next;reset();
|
||||
}
|
||||
scope.listen(more,'click',()=>stream.loadMore());
|
||||
// Retry also re-reads a category list that failed (D15: Refresh is gone).
|
||||
scope.listen(retry,'click',()=>{if(!optionsStatus.hidden)loadCategories();stream.loadMore();});
|
||||
scope.listen(window,'scroll',()=>{reconcileScroll();schedule();});
|
||||
// Mobile viewport resizing can adjust scroll before delivering its resize event.
|
||||
scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();});
|
||||
scope.cleanup(()=>{
|
||||
const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor();
|
||||
// A write while mounted makes this stream stale: keep only what still means something.
|
||||
shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
|
||||
stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
// A saved view is not the plain shelf, so it never replaces the Library return state (#249 M2, as Favorites).
|
||||
if(!saved) shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null;
|
||||
toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset();
|
||||
}
|
||||
|
||||
function mountHistory() {
|
||||
const history=el('section',undefined,'library-history');history.setAttribute('aria-label','Prompt history');main.append(history);
|
||||
const body=el('div');history.append(body);
|
||||
// Concept heading: ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
|
||||
headingDescription(main,history,'One positive prompt. Reusable negative variants. One shared gallery.');
|
||||
// Concept heading (app.js a591–597): ← Prompt library, Edit metadata, then the selected pair's Send to ComfyUI.
|
||||
headingDescription(main,history,'Prompt history · one positive, one shared gallery');
|
||||
const actions=headingActions(main,history);
|
||||
const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send');
|
||||
send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send);
|
||||
@@ -420,21 +466,62 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return;
|
||||
}
|
||||
detail=state.detail;const content=contentScope;
|
||||
const summary=el('article',undefined,'library-surface');const categoryChip=el('span','','library-chip');summary.append(categoryChip);
|
||||
// The concept summary (app.js a598): category chip, positive and selected negative blocks, then the counts (D24).
|
||||
const summary=el('article',undefined,'surface');const categoryChip=el('span','','chip');summary.append(categoryChip);
|
||||
const promptContainer=el('div');promptContainer.setAttribute('role','region');promptContainer.setAttribute('aria-label','Saved prompt texts');summary.append(promptContainer);
|
||||
const meta=el('div',undefined,'card-meta'),runsMeta=el('span'),imagesMeta=el('span'),variantsMeta=el('span');runsMeta.hidden=true;
|
||||
meta.append(runsMeta,imagesMeta,variantsMeta);summary.append(meta);
|
||||
const parentStatus=el('p','','library-parent-status muted');parentStatus.hidden=true;parentStatus.setAttribute('role','status');
|
||||
const retryParent=createButton('Retry saved Library details'),reloadSaved=createButton('Reload saved Library view');retryParent.hidden=reloadSaved.hidden=true;summary.append(parentStatus,retryParent,reloadSaved);
|
||||
const ownerText=detail.positive.text;let savedReadState='current',parentRequest,parentGeneration=0;
|
||||
content.cleanup(()=>{parentGeneration++;parentRequest?.abort();});
|
||||
const all=createButton('All images for this positive');
|
||||
const variants=el('section',undefined,'library-surface library-variants');variants.setAttribute('aria-label','Saved negative variants');
|
||||
const panelHeading=el('div',undefined,'library-panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
|
||||
panelCopy.append(el('h2','Negative variants'),el('p','Choose a saved negative to inspect its full text and filter the shared gallery.','muted'));
|
||||
const addNegative=createButton('+ Add negative'),showArchived=createButton('');showArchived.setAttribute('aria-pressed','false');
|
||||
rowActions.append(addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
|
||||
const branches=el('div',undefined,'library-branches');const positiveLane=el('p',undefined,'library-positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
|
||||
// The concept's negative variants panel (app.js a598, style.css 1059-1154): heading, the lanes, then the legend.
|
||||
const variants=el('section',undefined,'surface timeline-panel');variants.setAttribute('aria-label','Saved negative variants');
|
||||
const panelHeading=el('div',undefined,'panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
|
||||
panelCopy.append(el('h2','Negative variants'),el('p','Choose a red dot to change the negative and filter this gallery.','small'));
|
||||
// Compare negatives (aria-pressed) opens the comparison section below; Show archived is the approved #235 addition.
|
||||
const compare=createButton('Compare negatives'),addNegative=createButton('+ Add negative'),showArchived=createButton('');
|
||||
compare.setAttribute('aria-pressed','false');showArchived.setAttribute('aria-pressed','false');
|
||||
rowActions.append(compare,addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
|
||||
content.listen(compare,'click',()=>{compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true'));updateCompare();});
|
||||
// The concept's comparison (app.js a599-616, style.css 1155-1179): after the timeline panel, the baseline (the oldest
|
||||
// active variant, D25) beside the selected negative, then its added terms. Another dot updates it in place.
|
||||
const compareSection=el('section',undefined,'surface');let compareBlocks=[],compareKey=null;
|
||||
const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];};
|
||||
content.cleanup(disposeCompareBlocks);
|
||||
function compareBlock(text,label) {
|
||||
// The concept's `.compare-item` holds the block itself; the blocks mount in a transparent child (D25).
|
||||
const item=el('div',undefined,'compare-item'),mount=el('div');item.append(mount);
|
||||
// D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page.
|
||||
compareBlocks.push(mountPromptBlocks(mount,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)}));
|
||||
compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'});
|
||||
return item;
|
||||
}
|
||||
function updateCompare() {
|
||||
if(detail.variant_total===0) compare.setAttribute('aria-pressed','false');
|
||||
compare.disabled=detail.variant_total===0;
|
||||
if(compare.getAttribute('aria-pressed')!=='true') {compareSection.remove();disposeCompareBlocks();compareKey=null;return;}
|
||||
if(variants.nextSibling!==compareSection) variants.after(compareSection);
|
||||
// D46: the oldest active variant may sit on an unloaded page; the baseline is never guessed from the newest lanes.
|
||||
const baseline=detail.variants_has_more?null:detail.variants.reduce((oldest,variant)=>!oldest || variant.id<oldest.id?variant:oldest,null);
|
||||
const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId) || [...lanes.values()].find(item=>item.entry.variant.id===baseline?.id);
|
||||
const selected=(activePairId!==null && activePairId!==undefined && recordFor(activePairId)) || baseline;
|
||||
const name=lane?laneName(lane.entry):'';
|
||||
const key=JSON.stringify([baseline?.id,baseline?.negative_text,selected?.id,selected?.negative_text,name,displayTitle(detail.positive),savedReadState]);
|
||||
if(key===compareKey) return;
|
||||
compareKey=key;disposeCompareBlocks();compareSection.replaceChildren(el('h2','Compare negative variants'));
|
||||
if(!baseline || !selected) {compareSection.append(el('p','Load more negative variants to reach the baseline.','small'));return;}
|
||||
const grid=el('div',undefined,'compare-grid');grid.append(compareBlock(baseline.negative_text,'Baseline'),compareBlock(selected.negative_text,'Selected'));
|
||||
compareSection.append(el('p',`Baseline compared with ${name}. Choose another red dot to change the comparison.`),grid,el('p','Added terms','small'));
|
||||
const terms=addedTerms(baseline.negative_text,selected.negative_text);
|
||||
if(terms.length) for(const term of terms) compareSection.append(el('span',`+ ${term}`,'diff-added'));
|
||||
else compareSection.append(el('p','No added terms.','small'));
|
||||
}
|
||||
const branches=el('div',undefined,'timeline');const positiveLane=el('div',undefined,'positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
|
||||
const lanes=new Map();const emptyNote=el('p','No saved negative variants. Existing image associations remain available.','muted');
|
||||
const variantCount=el('p','','muted');variantCount.setAttribute('role','status');
|
||||
// D45: the bounded page count stays a status for assistive technology; the concept shows no count line.
|
||||
const variantCount=el('p','','ca-visually-hidden');variantCount.setAttribute('role','status');
|
||||
const variantError=el('p','','muted');variantError.setAttribute('role','alert');variantError.hidden=true;
|
||||
const restoreStatus=el('p','','muted library-restore-status');restoreStatus.setAttribute('role','alert');restoreStatus.hidden=true;
|
||||
const restoreAction=createButton('');restoreAction.hidden=true;let restoreHandler=null;
|
||||
@@ -471,13 +558,24 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
for(const [index,variant] of loadedArchived.entries()) entries.push({key:`archived:${variant.id}`,kind:'archived',variant,number:detail.variant_total+index+1});
|
||||
return entries;
|
||||
}
|
||||
/** D45: a dot names its pair by a title no other loaded lane shares, else by its number, so every name is unique. */
|
||||
// Titles compare trimmed (accessible names are whitespace-normalized), and a title that reads like a fallback
|
||||
// "variant N" is never used, so it cannot collide with an untitled lane's name.
|
||||
const trimmedTitle=variant=>typeof variant.title==='string'?variant.title.trim():'';
|
||||
function dotName(entry,entries) {
|
||||
const title=trimmedTitle(entry.variant);
|
||||
return title && !/^variant\s+\d+$/i.test(title) && entries.filter(other=>trimmedTitle(other.variant)===title).length===1?title:`variant ${entry.number}`;
|
||||
}
|
||||
const runCount=variant=>Number.isSafeInteger(variant.run_count) && variant.run_count>0?variant.run_count:0;
|
||||
function laneFor(entry) {
|
||||
let lane=lanes.get(entry.key);
|
||||
if(!lane) {
|
||||
const node=el('div',undefined,`library-variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','library-variant-dot');const label=el('span');
|
||||
node.append(button,label);lane={node,button,label,text:document.createTextNode(''),entry};label.append(lane.text);
|
||||
// Concept lane (app.js a598): the "−" dot, "<name> · N runs", then the repeat-run dot (D26).
|
||||
const node=el('div',undefined,`variant-lane${entry.kind==='archived'?' library-variant-archived':''}`);const button=createButton('−','variant-dot');const label=el('span');
|
||||
const runDot=createButton('','run-dot');runDot.hidden=true;
|
||||
node.append(button,label,runDot);lane={node,button,label,runDot,text:document.createTextNode(''),entry};label.append(lane.text);
|
||||
if(entry.kind==='archived') {lane.restore=createButton('Restore','library-variant-restore');label.append(lane.restore);content.listen(lane.restore,'click',()=>restorePair(lane.entry.variant.id,lane.restore));}
|
||||
content.listen(button,'click',()=>select(lane.entry.variant.id));lanes.set(entry.key,lane);
|
||||
content.listen(button,'click',()=>select(lane.entry.variant.id));content.listen(runDot,'click',()=>openRunDot(lane));lanes.set(entry.key,lane);
|
||||
}
|
||||
lane.entry=entry;return lane;
|
||||
}
|
||||
@@ -488,9 +586,12 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
for(const [index,entry] of entries.entries()) {
|
||||
const lane=laneFor(entry),name=laneName(entry);
|
||||
lane.node.style.setProperty('--lane-offset',`${38+index*47}px`);
|
||||
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative variant ${entry.number}`);
|
||||
const named=entry.kind==='separate'?'the saved negative variant outside the loaded list':dotName(entry,entries);
|
||||
lane.button.setAttribute('aria-label',entry.kind==='separate'?'Select the saved negative variant outside the loaded list':`Select negative ${named}`);
|
||||
lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current';
|
||||
lane.text.textContent=`${name} · ${countOf(entry.variant.image_count,'linked image')}${entry.kind==='archived'?' · Archived':''}`;
|
||||
lane.text.textContent=`${name} · ${countOf(runCount(entry.variant),'run')}${entry.kind==='archived'?' · Archived':''}`;
|
||||
// D26: the small dot appears only for a pair counted more than once.
|
||||
lane.runDot.hidden=runCount(entry.variant)<=1;lane.runDot.setAttribute('aria-label',`Show repeat run for ${named}`);lane.runDot.disabled=savedReadState!=='current';
|
||||
if(lane.restore) {lane.restore.setAttribute('aria-label',`Restore ${name}`);lane.restore.disabled=savedReadState!=='current' || restoringPairs.has(entry.variant.id);}
|
||||
if(previous.nextSibling!==lane.node) previous.after(lane.node);
|
||||
previous=lane.node;
|
||||
@@ -505,22 +606,28 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.';
|
||||
const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0;
|
||||
showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown));
|
||||
updateCompare();updateGalleryContext();
|
||||
};
|
||||
const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');};
|
||||
updateParent=()=>{
|
||||
main.querySelector('h1').textContent=displayTitle(detail.positive);categoryChip.textContent=detail.positive.category || 'Uncategorized';
|
||||
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${displayTitle(detail.positive)}`);
|
||||
const name=displayTitle(detail.positive);
|
||||
main.querySelector('h1').textContent=name;categoryChip.textContent=detail.positive.category || 'Uncategorized';updateMeta();
|
||||
send.setAttribute('aria-label',`Send prompt pair to ComfyUI: ${name}`);blocks?.setTitle(name);
|
||||
};
|
||||
let activePairId;
|
||||
updateParent();updateBranches(state);
|
||||
branches.append(emptyNote);
|
||||
const note=el('p','Saved combinations, not an execution timeline.','muted');
|
||||
const variantFooter=el('div',undefined,'library-variant-footer'),variantSummary=el('div',undefined,'library-variant-summary');variantSummary.append(variantCount,note);variantFooter.append(variantSummary,all);
|
||||
variants.append(branches,variantError,restoreStatus,restoreAction,more,retry,variantFooter);
|
||||
const context=el('p','','library-variant-context');context.setAttribute('role','status');
|
||||
const gallery=el('section',undefined,'library-shared-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(el('h2','Shared gallery'),context);
|
||||
const lower=el('div',undefined,'timeline-lower');lower.append(el('span','Green + positive · red − negative · small dot repeat run','small'),all);
|
||||
variants.append(branches,variantCount,variantError,restoreStatus,restoreAction,more,retry,lower);
|
||||
// The concept's shared gallery (app.js a616-621): its heading names the selected variant; the embedded
|
||||
// collection renders the PR C media cards. Recorded executions (#209) stay below it.
|
||||
const context=el('span','','small');context.setAttribute('role','status');
|
||||
const galleryTitle=el('h2','Shared gallery');galleryTitle.tabIndex=-1;
|
||||
const galleryHeading=el('div',undefined,'panel-heading');galleryHeading.append(galleryTitle,context);
|
||||
const gallery=el('section',undefined,'history-gallery');gallery.setAttribute('aria-label','Shared gallery');gallery.append(galleryHeading);
|
||||
const images=el('div',undefined,'library-linked-images');gallery.append(images);
|
||||
const recorded=el('div',undefined,'library-recorded-container');
|
||||
body.append(summary,variants,recorded,gallery);
|
||||
body.append(summary,variants,gallery,recorded);
|
||||
const recordFor=pairId=>detail.variants.find(variant=>variant.id===pairId) || archivedList().find(variant=>variant.id===pairId) || (separate()?.id===pairId?separate():null);
|
||||
const archivedSelection=pairId=>archivedList().some(variant=>variant.id===pairId) || (separate()?.id===pairId && separate().archived===true);
|
||||
function rolesFor(pairId) {
|
||||
@@ -549,7 +656,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(current.negative_text!==variant.negative_text || current.explicit_empty!==variant.explicit_empty) {changed=true;return variant;}
|
||||
return {...variant,...current};
|
||||
});
|
||||
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';
|
||||
detail={...detail,positive:fresh.positive,variants};categoryChip.textContent=fresh.positive.category || 'Uncategorized';updateMeta();
|
||||
savedReadState=changed || unobserved || additional?'stale':'current';
|
||||
parentStatus.textContent=changed?'A saved negative changed identity. This view retains its previous selection and gallery. Reload the saved Library view to inspect current data.':unobserved?(fresh.variants_has_more?'Some loaded saved negatives were not observed in this bounded revalidation page. Reload the saved Library view to validate them.':'Some loaded saved negatives were not present in the revalidation result. This view retains its previous selection and gallery.'):additional?'Saved Library choices changed. This view retains its previous selection and gallery. Reload the saved Library view to inspect current choices.':'Saved Library details revalidated.';
|
||||
reloadSaved.hidden=savedReadState==='current';
|
||||
@@ -568,23 +675,31 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{pendingSelection=null;loader.load(positiveId);});
|
||||
if(!historyVisits.has(positiveId)) historyVisits.set(positiveId,{});
|
||||
mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
const recordedView=mountRecordedHistory(recorded,{api,positiveId,parentScope:content,sender,visit:historyVisits.get(positiveId),onParentRevalidate:parent=>{
|
||||
if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent);
|
||||
}});
|
||||
},onRecordedCount:count=>{runsMeta.hidden=count===null;runsMeta.textContent=count===null?'':countOf(count,'tracked run');}});
|
||||
// The heading carries the pair send, as in the concept; the saved texts keep their per-role actions.
|
||||
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false});
|
||||
blocks=mountPromptBlocks(promptContainer,{prompts:libraryPromptRoles(detail),sender,parentScope:content,includePairAction:false,title:displayTitle(detail.positive)});
|
||||
/** The gallery heading's "<name> · same positive prompt" with the lane's own name, renamed with it (D47). */
|
||||
// With no saved variant the page shows every image of the positive, a state the concept never reaches.
|
||||
function updateGalleryContext() {
|
||||
if(activePairId===undefined) return;
|
||||
const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId);
|
||||
const archived=activePairId!==null && archivedSelection(activePairId);
|
||||
context.textContent=activePairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`;
|
||||
}
|
||||
function select(pairId,{force=false}={}) {
|
||||
if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return;
|
||||
if(pairId!==null && !recordFor(pairId)) pairId=null;
|
||||
activePairId=pairId;blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true});
|
||||
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));all.setAttribute('aria-pressed',String(pairId===null));
|
||||
const index=detail.variants.findIndex(variant=>variant.id===pairId),archived=pairId!==null && archivedSelection(pairId);
|
||||
const lane=[...lanes.values()].find(item=>item.entry.variant.id===pairId);
|
||||
context.textContent=pairId===null?'All images for this positive, including unassigned images. Choose a saved negative variant to inspect or send it.':index>=0?`Negative variant ${index+1} · same positive prompt`:`${lane?laneName(lane.entry):'Selected saved negative variant'}${archived?' · Archived':''} · same positive prompt`;
|
||||
for(const lane of lanes.values()) lane.button.setAttribute('aria-pressed',String(lane.entry.variant.id===pairId));
|
||||
const archived=pairId!==null && archivedSelection(pairId);
|
||||
updateGalleryContext();
|
||||
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
|
||||
// An archived pair's images are read in the archived scope; the gallery stays authoritative.
|
||||
mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}});
|
||||
updateSend();
|
||||
// An archived pair's images are read in the archived scope; the gallery stays authoritative. The
|
||||
// embedded collection has no caption: the gallery heading names it (D47).
|
||||
mountImages(images,api,galleryScope,{embedded:true,focusTarget:galleryTitle,emptyText:pairId===null?undefined:'Saved variant · no runs yet.',fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}});
|
||||
updateSend();updateCompare();
|
||||
}
|
||||
/** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */
|
||||
async function rereadSelectedPair() {
|
||||
@@ -648,7 +763,51 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
if(pair) select(pair.id,{force:true});
|
||||
}
|
||||
current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply};
|
||||
content.listen(all,'click',()=>select(null));
|
||||
/** A bounded media dialog (D26): its gallery lives until the dialog closes or the view goes. */
|
||||
// One slot: each open replaces the previous dialog's scope, so repeated opens never pile up cleanups.
|
||||
let imagesDialogScope=null;content.cleanup(()=>{imagesDialogScope?.dispose();imagesDialogScope=null;});
|
||||
function openImagesDialog({title,intro,className,fetchPage,trigger}) {
|
||||
imagesDialogScope?.dispose();
|
||||
const dialogScope=createScope();imagesDialogScope=dialogScope;
|
||||
const grid=el('div',undefined,className);
|
||||
openContentDialog({scope:content,title,nodes:intro?[el('p',intro,'small'),grid]:[grid],trigger,onClose:()=>{dialogScope.dispose();if(imagesDialogScope===dialogScope) imagesDialogScope=null;}});
|
||||
// D47: the dialog's own title names its images, as in the concept (a1282-1304), so the collection has no
|
||||
// "Linked images" heading or count; a stranded focus returns to the dialog's close button.
|
||||
mountImages(grid,api,dialogScope,{embedded:true,focusTarget:grid.closest('dialog')?.querySelector('.dialog-header > button') ?? null,fetchPage});
|
||||
}
|
||||
const imagesPage=(id,query,signal)=>api.get(`/prompt_manager/library/${id}/images?${query}`,{signal});
|
||||
/** Concept "All images for this positive" (a1282-1290): every image of the positive, Load more bounded. */
|
||||
function openAllImagesDialog(id,trigger) {
|
||||
openImagesDialog({title:'All images for this positive',trigger,fetchPage:({parameters,signal})=>imagesPage(id,new URLSearchParams(parameters),signal)});
|
||||
}
|
||||
/** Concept "Repeat run" (a1291-1304): the pair's newest linked image only. */
|
||||
function openRepeatRun(pair,trigger) {
|
||||
openImagesDialog({title:'Repeat run',intro:'Same positive and negative, another execution.',className:'spaced-15',trigger,fetchPage:async({parameters,signal})=>{
|
||||
const query=new URLSearchParams(parameters);query.set('pair_id',String(pair.id));query.set('limit','1');query.delete('cursor');
|
||||
if(pair.archived===true) query.set('archive','archived');
|
||||
const page=await imagesPage(positiveId,query,signal),images=Array.isArray(page?.images)?page.images.slice(0,1):page?.images;
|
||||
return {...page,images,total:Array.isArray(images)?images.length:page?.total,has_more:false,next_cursor:null};
|
||||
}});
|
||||
}
|
||||
/** D26: the run dot opens Recorded run for the pair's newest recorded execution, else Repeat run. */
|
||||
const runLookups=new Set();
|
||||
async function openRunDot(lane) {
|
||||
const pair=lane.entry.variant,trigger=lane.runDot;
|
||||
if(savedReadState!=='current' || runLookups.has(pair.id)) return;
|
||||
runLookups.add(pair.id);let run=null,failed=false;
|
||||
try {
|
||||
const value=await api.get(`/prompt_manager/library/${positiveId}/history/pairs/${pair.id}/newest`,{signal:content.signal,cache:'no-store'});
|
||||
run=value?.run && typeof value.run.run_id==='string'?value.run:null;
|
||||
} catch {
|
||||
failed=true;
|
||||
} finally {runLookups.delete(pair.id);}
|
||||
// The view may have gone stale while the lookup was in flight; a stale view opens nothing.
|
||||
if(content.signal.aborted || savedReadState!=='current') return;
|
||||
if(run && recordedView.openRun({runId:run.run_id,anchorId:run.anchor_id ?? null,contextId:run.context_id ?? null,trigger})) return;
|
||||
if(failed) showWorkspaceToast('Recorded runs could not be read. Showing the newest linked image.');
|
||||
openRepeatRun(pair,trigger);
|
||||
}
|
||||
content.listen(all,'click',()=>openAllImagesDialog(positiveId,all));
|
||||
const remembered=pendingSelection,outside=selectedPairs.get(positiveId);pendingSelection=null;
|
||||
if(outside?.archived && outside.id===remembered && !archivedShown) {archivedShown=true;archivedVisible.set(positiveId,true);}
|
||||
if(archivedShown) archivedLoader.load(positiveId);
|
||||
|
||||
@@ -19,7 +19,7 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
|
||||
export function createMediaViewer({api,container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) {
|
||||
const scope = createScope();
|
||||
let active = null, retryVersion = 0;
|
||||
const dispose = () => scope.dispose();
|
||||
@@ -40,23 +40,24 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
update(state,selected);
|
||||
}});
|
||||
const local = createScope();
|
||||
const dialog = element('dialog','image-preview');
|
||||
dialog.setAttribute('aria-label','Image preview');
|
||||
const header = element('header','image-preview-header');
|
||||
const heading = element('div'), title = element('h2');
|
||||
const position = element('p','image-preview-position muted');
|
||||
position.setAttribute('role','status');
|
||||
heading.append(title,position);
|
||||
const close = createButton('×');close.setAttribute('aria-label','Close preview');close.autofocus = true;
|
||||
// The concept's viewer shell (index.html h84–103, app.js a889–926): `dialog#viewer-dialog` named by its title,
|
||||
// `.dialog-header`, then `.viewer-layout` > `.viewer-left` (`#viewer-stage`, `#viewer-toolbar`) and
|
||||
// `aside#viewer-details`. #249 D34: production keeps a focusable stage, its controls' accessible names, the
|
||||
// Download link and, after Next →, the position status and the two unavailability reasons.
|
||||
const dialog = element('dialog');dialog.id = 'viewer-dialog';dialog.setAttribute('aria-labelledby','viewer-title');
|
||||
const header = element('div','dialog-header');
|
||||
const heading = element('div'), title = element('h2');title.id = 'viewer-title';
|
||||
heading.append(title,element('p','muted small','One viewer · image, prompts, generation data, workflow'));
|
||||
const close = createButton('×');close.id = 'viewer-close';close.setAttribute('aria-label','Close image viewer');close.autofocus = true;
|
||||
header.append(heading,close);
|
||||
const body = element('div','image-preview-body'), left = element('div','image-preview-left');
|
||||
const stage = element('section','image-preview-stage');stage.tabIndex = 0;stage.setAttribute('aria-label','Media preview');
|
||||
const media = element('div','image-preview-media'), failure = element('div','image-preview-failure');
|
||||
const body = element('div','viewer-layout'), left = element('div','viewer-left');
|
||||
const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0;
|
||||
stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview');
|
||||
const failure = element('div','media-failure');
|
||||
const error = element('p',undefined,'Media could not be loaded. Retry this file.');
|
||||
const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(media,failure);
|
||||
const toolbar = element('div','image-preview-toolbar'), controls = element('div','image-preview-controls');
|
||||
const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(failure);
|
||||
const controls = element('div','viewer-toolbar');controls.id = 'viewer-toolbar';
|
||||
controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls');
|
||||
const reason = element('p','image-preview-reason muted');reason.id = `media-viewer-reason-${++viewerId}`;
|
||||
function button(label,text,action) {
|
||||
const node = createButton(text);node.setAttribute('aria-label',label);
|
||||
local.listen(node,'click',() => { if (node.getAttribute('aria-disabled') !== 'true') action(); });
|
||||
@@ -67,16 +68,19 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)),
|
||||
button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))];
|
||||
const slideshow = button('Play slideshow','Play',() => session.state.requested ? session.stop() : session.play());
|
||||
const slideshowReason = element('p','image-preview-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
|
||||
const download = element('a','image-preview-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
|
||||
const download = element('a','media-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download','');
|
||||
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
|
||||
controls.append(download);
|
||||
const next = button('Next media','Next →',() => session.navigate(1));
|
||||
toolbar.append(controls,reason,slideshowReason);left.append(stage,toolbar);
|
||||
const details = element('aside','image-preview-details');details.setAttribute('aria-label','Preview prompt details');
|
||||
const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
|
||||
position.setAttribute('role','status');
|
||||
const reason = element('p','media-reason muted');reason.id = `media-viewer-reason-${viewerId}`;
|
||||
const slideshowReason = element('p','media-slideshow-reason muted');slideshowReason.id = `media-viewer-slideshow-${viewerId}`;
|
||||
controls.append(position,reason,slideshowReason);left.append(stage,controls);
|
||||
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
|
||||
body.append(left,details);dialog.append(header,body);container.append(dialog);
|
||||
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
|
||||
const inspector = mountFileInspector(details,{sender:localSender,parentScope:local,onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
|
||||
const inspector = mountFileInspector(details,{api,sender:localSender,parentScope:local,variant:'full',onInteraction:() => session.stop(),onRetry:() => inspectionController.retry(),
|
||||
onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined});
|
||||
function closeViewer(restore = true) {
|
||||
if (closed) return;
|
||||
@@ -106,7 +110,8 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1);
|
||||
});
|
||||
function rebuild(selected) {
|
||||
mediaScope?.dispose();mediaScope = createScope();viewer = null;media.replaceChildren();
|
||||
mediaScope?.dispose();mediaScope = createScope();viewer = null;
|
||||
for (const child of [...stage.children]) if (child !== failure) child.remove();
|
||||
failure.hidden = true;
|
||||
const mediaLocal = mediaScope, key = session.state.key;
|
||||
const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key;
|
||||
@@ -135,7 +140,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
mediaLocal.cleanup(() => node.removeAttribute('src'));
|
||||
}
|
||||
}
|
||||
node.src = url.href;media.append(node);
|
||||
node.src = url.href;stage.append(node);
|
||||
if (!selected.is_video && window.Viewer) {
|
||||
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
|
||||
const release = () => {
|
||||
@@ -147,12 +152,12 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
fitFrame = requestAnimationFrame(() => {
|
||||
fitFrame = 0;
|
||||
if (!current() || !instance.viewed) return;
|
||||
const width = media.offsetWidth, height = media.offsetHeight;
|
||||
const width = stage.offsetWidth, height = stage.offsetHeight;
|
||||
if (width === fitWidth && height === fitHeight) return;
|
||||
fitWidth = width;fitHeight = height;instance.resize();
|
||||
});
|
||||
};
|
||||
const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false,
|
||||
const instance = new window.Viewer(node,{inline:true,toolbar:false,navbar:false,title:false,button:false,
|
||||
keyboard:false,fullscreen:false,transition:false,viewed() {
|
||||
viewSettled = true;
|
||||
if (mediaLocal.signal.aborted) { release(); return; }
|
||||
@@ -183,7 +188,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
}
|
||||
for (const node of [previous,next]) {
|
||||
node.setAttribute('aria-disabled',String(state.total === 1));
|
||||
if (state.total === 1) {node.setAttribute('aria-describedby',position.id);}
|
||||
if (state.total === 1) node.setAttribute('aria-describedby',position.id);else node.removeAttribute('aria-describedby');
|
||||
}
|
||||
const action = state.phase === 'paused-video' ? 'Stop' : state.requested ? 'Pause' : 'Play';
|
||||
slideshow.textContent = action;slideshow.setAttribute('aria-label',`${action} slideshow`);
|
||||
@@ -211,14 +216,15 @@ export function createMediaViewer({container,parentScope,inspectionController,se
|
||||
const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${status ? ` — ${status}` : ''}`;
|
||||
if (position.textContent !== label) position.textContent = label;
|
||||
if (currentKey !== state.key) {
|
||||
currentKey = state.key;title.textContent = entry.filename;
|
||||
// A linked card already carries its title, so the header reads "<title> · <filename>" at once.
|
||||
const known = entry.linked?.status === 'linked' && entry.linked.title;
|
||||
currentKey = state.key;title.textContent = known ? `${known} · ${entry.filename}` : entry.filename;
|
||||
try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; }
|
||||
onSelect?.(entry);
|
||||
rebuild(entry);
|
||||
}
|
||||
updateControls(session.state);
|
||||
}
|
||||
position.id = `media-viewer-position-${viewerId}`;
|
||||
dialog.showModal();mounted = true;update(...initial);
|
||||
local.cleanup(inspectionController.subscribe(state => {
|
||||
inspectionState = state;updateControls(session.state);
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createSendButton } from './prompt-sender.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { openCopyFallback } from './workspace-dialogs.mjs';
|
||||
|
||||
export function promptRoles(prompts) {
|
||||
return Object.fromEntries(['positive', 'negative'].filter(role => prompts?.[role]?.status === 'resolved' && typeof prompts[role].text === 'string').map(role => [role, prompts[role].text]));
|
||||
}
|
||||
|
||||
const sources = {
|
||||
library: 'Saved library prompt',
|
||||
execution: 'Recorded at execution',
|
||||
'graph-inferred': 'Inferred from the saved workflow',
|
||||
'embedded-parameters': 'Embedded generation parameters',
|
||||
'zeroed-conditioning': 'Zeroed conditioning (no negative text)',
|
||||
'saved-association': 'Saved library association',
|
||||
};
|
||||
// #249 D22: the concept's prompt block (app.js a298–302) carries no source line; a file's evidence is its panel badge
|
||||
// (D21) and its "File & provenance" line. Roles without a confirmed text keep these status strings.
|
||||
const statuses = {
|
||||
absent: 'Not recorded in this file.',
|
||||
ambiguous: 'Multiple possible prompts. Source could not be determined.',
|
||||
unresolved: 'Prompt could not be resolved.',
|
||||
};
|
||||
const fieldSources = {
|
||||
runtime_return: 'Runtime return',
|
||||
host_cache_output: 'Host cache output',
|
||||
graph_literal: 'Inferred graph literal',
|
||||
graph_semantics: 'Inferred graph semantics',
|
||||
};
|
||||
const ROLE_LABELS = Object.freeze({positive: '+ Positive prompt', negative: '\u2212 Negative prompt'});
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
@@ -41,37 +32,48 @@ function childScope(parentScope) {
|
||||
return scope;
|
||||
}
|
||||
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = ''}) {
|
||||
export function mountPromptBlocks(container, {prompts, sender, parentScope, roles:visibleRoles = ['positive', 'negative'], onRetry, includePairAction = true, contextLabel = '', title = '', copyFallback = openCopyFallback}) {
|
||||
// A nested set of blocks (the current file inside a recorded run) names its regions and actions with a
|
||||
// context so assistive technology never meets two identically named prompt regions in one dialog.
|
||||
const named = role => contextLabel ? `${contextLabel} ${role} prompt` : `${role} prompt`;
|
||||
const named = roleLabel => contextLabel ? `${contextLabel} ${roleLabel.toLowerCase()} prompt` : `${roleLabel} prompt`;
|
||||
const action = role => contextLabel ? `${contextLabel[0].toLowerCase()}${contextLabel.slice(1)} ${role} prompt` : `${role} prompt`;
|
||||
if (parentScope.signal.aborted) return {update() {}, setState() {}, dispose() {}};
|
||||
// A card among many names its actions after its prompt, as the concept's do (app.js a298–302: "Copy positive prompt: <title>").
|
||||
// The title is mutable so a saved title edited in place (setTitle below, #249 F1) renames the actions.
|
||||
let currentTitle = title || '';
|
||||
const actionName = (verb, role, rest = '') => `${verb} ${action(role)}${rest}${currentTitle ? `: ${currentTitle}` : ''}`;
|
||||
// An aborted parent gets one inert handle; every method is the same no-op.
|
||||
const inert = () => {};
|
||||
if (parentScope.signal.aborted) return {update:inert, setState:inert, setTitle:inert, dispose:inert};
|
||||
const scope = childScope(parentScope);
|
||||
container.classList.add('prompt-inspector');
|
||||
scope.listen(container, 'click', async event => {
|
||||
if (!actionsEnabled) return;
|
||||
const send = event.target.closest('[data-send-role]');
|
||||
if (send) {
|
||||
if (send && (send.dataset.sendRole === 'pair' || Object.hasOwn(roles, send.dataset.sendRole))) {
|
||||
const role = send.dataset.sendRole;
|
||||
sender.open(role === 'pair' ? roles : {[role]:roles[role]}, send);
|
||||
}
|
||||
const copy = event.target.closest('[data-copy-role]');
|
||||
if (copy) {
|
||||
const role = copy.dataset.copyRole;
|
||||
const status = copy.closest('section').querySelector('.prompt-copy-status');
|
||||
if (copy && Object.hasOwn(roles, copy.dataset.copyRole)) {
|
||||
// The concept's copy: the shared toast, or its "Copy prompt" select-to-copy dialog without a clipboard.
|
||||
const text = roles[copy.dataset.copyRole];
|
||||
try {
|
||||
await navigator.clipboard.writeText(roles[role]);
|
||||
if (!scope.signal.aborted) status.textContent = 'Copied.';
|
||||
if (!navigator.clipboard) throw new Error('Clipboard requires a secure context');
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (!scope.signal.aborted) showWorkspaceToast('Prompt copied.');
|
||||
} catch {
|
||||
if (!scope.signal.aborted) status.textContent = 'Copy unavailable. Select the prompt text to copy it.';
|
||||
if (scope.signal.aborted) return;
|
||||
// A re-render during the pending write detaches the clicked button; Escape then returns to its successor.
|
||||
const trigger = copy.isConnected === false ? container.querySelector(`[data-copy-role="${copy.dataset.copyRole}"]`) : copy;
|
||||
copyFallback(text, {scope, trigger});
|
||||
}
|
||||
}
|
||||
if (event.target.closest('[data-retry-prompts]')) onRetry?.();
|
||||
});
|
||||
let roles = {}, actionsEnabled = true;
|
||||
let roles = {}, actionsEnabled = true, current = {prompts};
|
||||
function render(state) {
|
||||
if (scope.signal.aborted) return;
|
||||
current = state;
|
||||
roles = {};
|
||||
actionsEnabled = state.actionsEnabled !== false;
|
||||
container.replaceChildren();
|
||||
@@ -87,38 +89,32 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
return;
|
||||
}
|
||||
roles = Object.fromEntries(Object.entries(promptRoles(state.prompts)).filter(([role]) => visibleRoles.includes(role)));
|
||||
for (const role of visibleRoles) {
|
||||
const title = `${role[0].toUpperCase() + role.slice(1)} prompt`;
|
||||
for (const role of visibleRoles.filter(name => Object.hasOwn(ROLE_LABELS, name))) {
|
||||
const record = state.prompts?.[role];
|
||||
const block = el('section', undefined, `prompt-block prompt-${role}`);
|
||||
block.setAttribute('aria-label', contextLabel ? named(role) : title);
|
||||
const heading = el('div', undefined, 'prompt-block-heading');
|
||||
heading.append(el('h3', title));
|
||||
const block = el('section', undefined, `prompt-section ${role}`);
|
||||
block.setAttribute('aria-label', named(role[0].toUpperCase() + role.slice(1)));
|
||||
const heading = el('div', undefined, 'prompt-heading');
|
||||
heading.append(el('span', ROLE_LABELS[role], 'role-label'));
|
||||
block.append(heading);
|
||||
if (Object.hasOwn(roles, role)) {
|
||||
const proof = record.evidence;
|
||||
block.append(el('p', proof ? `${fieldSources[proof.source] || 'Source unknown'} · ${proof.source}` : sources[record.source] || 'Source not recorded', 'prompt-source muted'));
|
||||
if (roles[role] === '') block.append(el('p', `Empty ${role} prompt`, 'prompt-empty'));
|
||||
else {
|
||||
const text = el('pre', roles[role], 'prompt-text');
|
||||
text.tabIndex = 0;
|
||||
block.append(text);
|
||||
}
|
||||
const actions = el('div', undefined, 'prompt-actions');
|
||||
const send = createSendButton(`Send ${action(role)}`);
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
const copy = createButton('', 'ca-icon-button');
|
||||
copy.setAttribute('aria-label', `Copy ${action(role)}`);
|
||||
copy.title = `Copy ${action(role)}`;
|
||||
const copy = createButton('', 'icon-btn');
|
||||
copy.setAttribute('aria-label', actionName('Copy', role));
|
||||
copy.append(createWorkspaceIcon('copy'));
|
||||
copy.dataset.copyRole = role;
|
||||
copy.disabled = !actionsEnabled;
|
||||
const send = createButton('', 'send icon-btn');
|
||||
send.setAttribute('aria-label', actionName('Send', role, ' to ComfyUI'));
|
||||
send.append(createWorkspaceIcon('send'));
|
||||
send.dataset.sendRole = role;
|
||||
send.disabled = !actionsEnabled;
|
||||
actions.append(copy, send);
|
||||
heading.append(actions);
|
||||
const status = el('p', '', 'prompt-copy-status muted'); status.setAttribute('role', 'status');
|
||||
block.append(status);
|
||||
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'muted'));
|
||||
const text = el('p', roles[role] === '' ? `No ${role} prompt (explicitly empty)` : roles[role], 'prompt-text');
|
||||
// Production keeps a confirmed prompt keyboard-reachable for reading and selecting long text.
|
||||
text.tabIndex = 0;
|
||||
block.append(text);
|
||||
} else block.append(el('p', typeof record?.message === 'string' && record.message ? record.message : statuses[record?.status] || statuses.unresolved, 'prompt-text muted'));
|
||||
container.append(block);
|
||||
}
|
||||
if (includePairAction && Object.hasOwn(roles, 'positive') && Object.hasOwn(roles, 'negative')) {
|
||||
@@ -128,5 +124,10 @@ export function mountPromptBlocks(container, {prompts, sender, parentScope, role
|
||||
}
|
||||
}
|
||||
render({prompts});
|
||||
return {update:prompts => render({prompts}), setState:render, dispose:() => scope.dispose()};
|
||||
// A saved title edited in place renames the actions without a remount (the history summary, #249 F1).
|
||||
const setTitle = next => {
|
||||
const value = next || '';
|
||||
if (value !== currentTitle) {currentTitle = value;render(current);}
|
||||
};
|
||||
return {update:prompts => render({prompts}), setState:render, setTitle, dispose:() => scope.dispose()};
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop
|
||||
const receiptNodes = new Map();
|
||||
const evidenceValues = new Map();
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:local});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent});
|
||||
const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, promptContext:'Current file', onRetry:() => inspection.retry(), onRefresh:refreshCurrent});
|
||||
function selectedReceipt() {
|
||||
const selected = latest.selection.receipt;
|
||||
return selected && latest.outputs.items.find(item => occurrenceKey(item.occurrence) === occurrenceKey(selected));
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Saved views (#249 D16, maintainer ruling 2026-10-21): a named toolbar value plus its "Refine this view" filters, for
|
||||
* Images or the Prompt library, persisted through `/prompt_manager/saved-views`.
|
||||
*
|
||||
* - The toolbar's Save view opens the concept's dialog (app.js a1330–1346 through `simpleInput` a1138): "Save this
|
||||
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
|
||||
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
|
||||
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
|
||||
* accepts is dropped to that key's default, so an opened view applies what it can.
|
||||
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
|
||||
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { openInputDialog, openContentDialog, closeWorkspaceDialog, field } from './workspace-dialogs.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
export const SAVED_VIEWS_URL = '/prompt_manager/saved-views';
|
||||
export const SAVED_TOAST = 'Saved view added to the sidebar.';
|
||||
export const SAVE_FAILED_TOAST = 'The view could not be saved. Try again.';
|
||||
export const RENAMED_TOAST = 'Saved view renamed.';
|
||||
export const DELETED_TOAST = 'Saved view deleted.';
|
||||
export const RENAME_FAILED_TOAST = 'The view could not be renamed. Try again.';
|
||||
export const DELETE_FAILED_TOAST = 'The view could not be deleted. Try again.';
|
||||
export const TOO_LARGE_TOAST = 'The view is too large to save. Shorten its search, folder or tags and try again.';
|
||||
// The API's cap on a view's encoded state (database/saved_views.py MAX_STATE_BYTES), in UTF-8 bytes of compact JSON.
|
||||
export const MAX_STATE_BYTES = 4096;
|
||||
const MAX_NAME_LENGTH = 100;
|
||||
const MAX_TAGS = 20;
|
||||
const MAX_TAG_LENGTH = 50;
|
||||
const CONTROL = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
// The API's whitelist (database/saved_views.py _STATE_FIELDS), with each key's default.
|
||||
const text = (limit, fallback = '') => ({ fallback, accepts: value => typeof value === 'string' && value.length <= limit && !CONTROL.test(value) });
|
||||
const choice = (choices, fallback) => ({ fallback, accepts: value => choices.includes(value) });
|
||||
const tags = { fallback: [], accepts: value => Array.isArray(value) && value.length <= MAX_TAGS && value.every(tag =>
|
||||
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
|
||||
const FIELDS = Object.freeze({
|
||||
images: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
|
||||
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
|
||||
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
|
||||
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
|
||||
favorites: { fallback: false, accepts: value => typeof value === 'boolean' },
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
|
||||
}),
|
||||
});
|
||||
|
||||
export const SAVED_VIEW_PAGES = Object.freeze(Object.keys(FIELDS));
|
||||
|
||||
/** The page's full state: every key, each saved value its page accepts, the default otherwise. */
|
||||
export function normalizeViewState(page, state) {
|
||||
const fields = FIELDS[page];
|
||||
if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`);
|
||||
const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {};
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, field]) => {
|
||||
const value = field.accepts(source[key]) ? source[key] : field.fallback;
|
||||
return [key, Array.isArray(value) ? [...value] : value];
|
||||
}));
|
||||
}
|
||||
|
||||
/** True when the state's compact JSON fits the API's 4 KB cap (the server's encoding: no spaces, UTF-8). */
|
||||
export function stateFits(state) {
|
||||
return new TextEncoder().encode(JSON.stringify(state)).length <= MAX_STATE_BYTES;
|
||||
}
|
||||
|
||||
/** The error `create()` rejects with when the state is over the cap; nothing is sent. */
|
||||
export class SavedViewTooLarge extends RangeError {
|
||||
constructor() { super('Saved view state is over 4 KB'); this.name = 'SavedViewTooLarge'; }
|
||||
}
|
||||
|
||||
/** The concept's default name (a1336): `Search: <q>` while there is a search, else "My workspace view". */
|
||||
export function defaultViewName(q) {
|
||||
const search = typeof q === 'string' ? q.trim() : '';
|
||||
return (search ? `Search: ${search}` : 'My workspace view').slice(0, MAX_NAME_LENGTH);
|
||||
}
|
||||
|
||||
/** One listed view, or null when the response row is not a usable view. */
|
||||
export function parseSavedView(row) {
|
||||
if (!Number.isSafeInteger(row?.id) || row.id < 1 || !SAVED_VIEW_PAGES.includes(row.page)) return null;
|
||||
if (typeof row.name !== 'string' || !row.name.trim()) return null;
|
||||
return { id: row.id, page: row.page, name: row.name, state: normalizeViewState(row.page, row.state) };
|
||||
}
|
||||
|
||||
/** The listing's views in the server's order, skipping any row that is not a usable view. */
|
||||
export function parseSavedViews(data) {
|
||||
if (!Array.isArray(data?.views)) throw new TypeError('Invalid saved views');
|
||||
return data.views.map(parseSavedView).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The route of one view. */
|
||||
export const savedViewHash = id => `#/saved/${id}`;
|
||||
|
||||
/** Images filters (images.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
export function imagesFilters(state) {
|
||||
const view = normalizeViewState('images', state);
|
||||
return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media,
|
||||
tags: view.tags, favorites: view.favorites };
|
||||
}
|
||||
export function imagesViewState(filters) {
|
||||
return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root,
|
||||
folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true });
|
||||
}
|
||||
|
||||
/** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */
|
||||
export function libraryFilters(state) {
|
||||
const view = normalizeViewState('library', state);
|
||||
return { text: view.q, category: view.category, sort: view.sort, tags: view.tags };
|
||||
}
|
||||
export function libraryViewState(filters) {
|
||||
return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags });
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's list of saved views: `load()` re-reads it (a newer read supersedes an older one), `create()` saves one
|
||||
* and re-reads the list, `rename(id, name)` and `remove(id)` change one and re-read it (a failure re-reads it too), `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no
|
||||
* read is in flight. `status` is 'loading', 'ready' or 'error'; a failed read keeps the last listed views.
|
||||
*/
|
||||
export function createSavedViews(api, signal) {
|
||||
let views = [], status = 'loading', request = null;
|
||||
const listeners = new Set();
|
||||
let waiting = [];
|
||||
const emit = () => {
|
||||
const snapshot = { views, status };
|
||||
for (const listener of listeners) listener(snapshot);
|
||||
if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); }
|
||||
};
|
||||
function load() {
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
request = controller;
|
||||
const abort = () => controller.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
status = 'loading';
|
||||
emit();
|
||||
return api.get(SAVED_VIEWS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => {
|
||||
if (request !== controller) return;
|
||||
views = parseSavedViews(data);
|
||||
status = 'ready';
|
||||
}).catch(() => {
|
||||
if (request !== controller) return;
|
||||
status = 'error';
|
||||
}).finally(() => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request !== controller || signal?.aborted) return;
|
||||
request = null;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
async function create(page, name, state) {
|
||||
const full = normalizeViewState(page, state);
|
||||
if (!stateFits(full)) throw new SavedViewTooLarge();
|
||||
const data = await api.post(SAVED_VIEWS_URL, { page, name, state: full }, { signal });
|
||||
const view = parseSavedView(data?.view);
|
||||
if (!view) throw new TypeError('Invalid saved view');
|
||||
await load();
|
||||
return view;
|
||||
}
|
||||
/** Run one change of a listed view, then re-read the list whatever its outcome (another tab may have changed it). */
|
||||
async function change(send) {
|
||||
try { return await send(); } finally { await load(); }
|
||||
}
|
||||
const viewUrl = id => {
|
||||
if (!Number.isSafeInteger(id) || id < 1) throw new TypeError('Invalid saved view id');
|
||||
return `${SAVED_VIEWS_URL}/${id}`;
|
||||
};
|
||||
function rename(id, name) {
|
||||
const url = viewUrl(id);
|
||||
return change(async () => {
|
||||
const view = parseSavedView((await api.patch(url, { name }, { signal }))?.view);
|
||||
if (!view) throw new TypeError('Invalid saved view');
|
||||
return view;
|
||||
});
|
||||
}
|
||||
function remove(id) {
|
||||
const url = viewUrl(id);
|
||||
return change(async () => {
|
||||
try {
|
||||
await api.del(url, { signal });
|
||||
} catch (error) {
|
||||
// Already gone (another tab deleted it): the outcome the user asked for.
|
||||
if (error?.status !== 404) throw error;
|
||||
}
|
||||
return id;
|
||||
});
|
||||
}
|
||||
return {
|
||||
load, create, rename, remove,
|
||||
get views() { return views; },
|
||||
get status() { return status; },
|
||||
find: id => views.find(view => view.id === id) ?? null,
|
||||
settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ views, status }),
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's Save view (a1330–1346): the name dialog, then the save. `onSave(name)` returns the save's promise;
|
||||
* the toast reports either outcome.
|
||||
*/
|
||||
export function openSaveViewDialog({ scope, trigger, q, onSave }) {
|
||||
openInputDialog({ scope, trigger, title: 'Save this view', label: 'View name', action: 'Save', value: defaultViewName(q),
|
||||
maxLength: MAX_NAME_LENGTH, fallback: 'Saved view', onApply: async name => {
|
||||
try {
|
||||
await onSave(name);
|
||||
showWorkspaceToast(SAVED_TOAST);
|
||||
} catch (error) {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(error instanceof SavedViewTooLarge ? TOO_LARGE_TOAST : SAVE_FAILED_TOAST);
|
||||
}
|
||||
} });
|
||||
}
|
||||
|
||||
/**
|
||||
* D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel
|
||||
* and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so nothing is removed by one press and
|
||||
* no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either
|
||||
* outcome, and the shell navigates after a delete.
|
||||
*/
|
||||
export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) {
|
||||
const input = document.createElement('input');
|
||||
input.value = view.name;
|
||||
input.maxLength = MAX_NAME_LENGTH;
|
||||
const remove = createButton('Delete…', 'danger');
|
||||
const cancel = createButton('Cancel');
|
||||
const save = createButton('Save', 'ca-primary');
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
actions.append(remove, cancel, save);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
save.addEventListener('click', async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) { input.focus(); return; }
|
||||
closeWorkspaceDialog();
|
||||
if (name === view.name) return;
|
||||
try {
|
||||
await onRename(name);
|
||||
showWorkspaceToast(RENAMED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST);
|
||||
}
|
||||
});
|
||||
remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete }));
|
||||
openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] });
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
|
||||
function confirmDelete({ scope, trigger, view, onDelete }) {
|
||||
const text = document.createElement('p');
|
||||
text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'dialog-actions';
|
||||
const cancel = createButton('Cancel');
|
||||
const confirmButton = createButton('Delete', 'danger');
|
||||
actions.append(cancel, confirmButton);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
confirmButton.addEventListener('click', async () => {
|
||||
closeWorkspaceDialog();
|
||||
try {
|
||||
await onDelete();
|
||||
showWorkspaceToast(DELETED_TOAST);
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST);
|
||||
}
|
||||
});
|
||||
openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] });
|
||||
cancel.focus();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user