Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
728db36ac3 | ||
|
|
5ded3cfd39 | ||
|
|
2b8126b7be | ||
|
|
86d82186ee | ||
|
|
6c82098207 | ||
|
|
9d398f9862 |
@@ -8,6 +8,7 @@ on:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Superseded PR pushes cancel their older run. Every main push gets its own
|
||||
# group (run_id), so no main commit's run is ever cancelled or dropped as pending.
|
||||
@@ -146,6 +147,32 @@ jobs:
|
||||
python -m coverage combine
|
||||
python -m coverage report --fail-under=85
|
||||
|
||||
perf-budget:
|
||||
# The reference wall-clock measurement (TESTING.md "Performance budgets").
|
||||
# Shared-runner timing varies, so it never gates a pull request: it runs on
|
||||
# each push to main and on demand. A red run on main means fix the code.
|
||||
name: perf budget
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Workspace filter budget
|
||||
env:
|
||||
PROMPTMANAGER_PERF_BUDGET: "1"
|
||||
run: python -m unittest -v tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget
|
||||
|
||||
lint:
|
||||
name: black and flake8
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
|
||||
|
||||
+337
-10
@@ -125,6 +125,37 @@ not certify every existing feature, arbitrary host versions,
|
||||
GPU/model changes, cross-pack installation or visual design changes. Those still
|
||||
need targeted validation.
|
||||
|
||||
## Performance budgets
|
||||
|
||||
A spec performance budget gets two tests:
|
||||
|
||||
- A structural test that always runs, on every OS/Python job and under the
|
||||
coverage tracer. It asserts counts, never time: SELECTs per request, the
|
||||
`EXPLAIN QUERY PLAN` of the hot query, cache builds on cold and warm requests,
|
||||
and calls of the sort key per cursor page. Example:
|
||||
`test_filter_work_is_bounded` in `tests/test_api_workspace_images.py`.
|
||||
- A wall-clock test that runs only with `PROMPTMANAGER_PERF_BUDGET=1` and
|
||||
without a tracer (`sys.gettrace()` or a `sys.monitoring` tool). It skips
|
||||
otherwise. It warms up once, then measures three rounds and asserts the best
|
||||
round's p95; the failure message lists every timing. Example:
|
||||
`test_filter_budget` (Images filters, spec D12: p95 < 150 ms at 50,000 files
|
||||
and 20,000 links).
|
||||
|
||||
The `perf budget` job in `.github/workflows/test.yml` is the reference
|
||||
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
|
||||
It runs on every push to `main` and on `workflow_dispatch`, never on pull
|
||||
requests, because shared-runner timing must not block a merge. Dispatch it on a
|
||||
branch to measure before merging.
|
||||
Shared Windows and macOS runners do not run them; the structural tests cover
|
||||
portability. Run it locally with
|
||||
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
|
||||
and quote the printed p95 in the PR body.
|
||||
|
||||
A red `perf budget` run on `main` means the code got slower: fix the code (or
|
||||
change the budget in the spec with a reason) in a follow-up PR. Never rerun it to
|
||||
green. It is not a required check, and it cannot be one while it skips pull
|
||||
requests (`.github/branch-protection.json`).
|
||||
|
||||
## Windows portability preflight
|
||||
|
||||
Before changing paths, SQLite, streams, restore or cleanup:
|
||||
@@ -196,7 +227,12 @@ The shared inline and collection inspector reads the current rooted file's suppo
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
`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
|
||||
@@ -328,12 +364,14 @@ selector, so a narrower selector over a concept rule (for example
|
||||
leftovers such as `library.css` `.row-actions`, name the PR that retires them, by
|
||||
convention.
|
||||
|
||||
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.
|
||||
The lint also runs over every page stylesheet that loads before the overlay and is
|
||||
still kept (`images.css`, `library.css`, `inspector.css`; test `kept page stylesheets
|
||||
mark their concept-selector overrides`). After PR C, `images.css` holds only the side
|
||||
panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel
|
||||
and deletes with the file. Its three remaining concept-selector overrides
|
||||
(`.prompt-text`, `.prompt-actions`, `.ca-tools .prompt-actions button`) are marked
|
||||
`#249 D22 interim` until then, and `images.css keeps no retired Images page rules`
|
||||
keeps the replaced card, grid, filter, toolbar, preview and facts rules out.
|
||||
|
||||
**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
|
||||
@@ -392,6 +430,295 @@ 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),
|
||||
D16 (follow-up PR: no Save view), 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 (follow-up)**: no Save view in PR C; a follow-up PR adds real saved views.
|
||||
- **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.
|
||||
|
||||
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 \
|
||||
--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
|
||||
`mode=workspace`) and the legacy pages do not change, and existing workspace pages
|
||||
ignore the new keys.
|
||||
|
||||
Workspace image items (`/prompt_manager/images/output?mode=workspace`, and Library
|
||||
image galleries) gain three card facts. Library list previews carry none of them
|
||||
and skip the parse.
|
||||
|
||||
- `linked: {status, positive_id, title, title_source, category, rating}`. One
|
||||
`get_image_link_summaries` SELECT per 200 paths, matched exactly as the
|
||||
inspection route matches (exact or normalized `file_path`, no basename
|
||||
fallback). `status` is `linked`, `ambiguous` (several owners), `none`, or
|
||||
`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 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
|
||||
shows. A `text` title shows only when the file's own positive, parsed from its
|
||||
records by the shared parser, gives the same display title. Otherwise the title
|
||||
is null on the card and in the inspector header. Videos and unreadable files
|
||||
have no parsed positive, so their `text` titles are withheld.
|
||||
- `seed`: a decimal string, or null. Always a string, because `JSON.parse` rounds
|
||||
numbers past 2**53 and ComfyUI's randomized seeds are often that large. It is
|
||||
`extract_generation_params` from `utils/generation_params.py` (the inspector's
|
||||
legacy parser, #251), run on the `prompt`/`workflow` records read with the
|
||||
dimensions. A PNG is read by the inspector's own reader
|
||||
(`utils.prompt_inspector.read_inspection_file`): text after IDAT counts, the
|
||||
shared byte budgets apply, and a file with conflicting duplicate `prompt` or
|
||||
`workflow` chunks gets no card seed, as the inspector refuses it (the card
|
||||
keeps its dimensions). Other formats use Pillow's header `info`, as the
|
||||
inspector's header read does. The records go through the inspection
|
||||
projection first (`utils/inspection_response.project_graph`), which carries
|
||||
integers past 2**53 as their exact digits, so the card and the inspector read
|
||||
the same digits. The parse runs after the file handle closes. A record over
|
||||
2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a
|
||||
non-integral, boolean or non-finite seed gives null. A card never shows a seed
|
||||
that differs from the inspector's
|
||||
(`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s
|
||||
the listing in Node and compares `String(seed)` with the inspector's seed,
|
||||
including one past 2**53). The card shows no seed for A1111 `parameters` files
|
||||
or for a BasicGuider custom sampler, where the inspector does.
|
||||
- Header facts are cached. Dimensions, `seed` and the parsed positive's display
|
||||
title are read once per `(path, mtime)` of the current gallery scan and kept
|
||||
beside the scan's per-file values. A monitor invalidation or TTL rescan keeps
|
||||
the facts of unchanged files and drops the rest, and a roots change drops them
|
||||
all. A header that could not be read is not stored. Links and favorites are
|
||||
never cached (`test_warm_page_reuses_card_facts_per_scan`: a warm page reads
|
||||
and parses no header). Measured cold cost is about 3.4 ms per 50 KB record set
|
||||
and 25 ms per 400 KB workflow.
|
||||
- `favorite: bool`. One favorites SELECT per root per page. A read failure logs a
|
||||
warning and shows false.
|
||||
|
||||
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 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
|
||||
positive.
|
||||
- Ambiguous files never match a linked filter, and they rank as unlinked.
|
||||
- `sort` is `newest | oldest | name | name-desc | rating`. `rating` orders by
|
||||
rating 5 to 1, then linked but unrated, then unlinked and ambiguous; ties break
|
||||
newest first. A signed cursor covers every filter and the sort, so reusing it
|
||||
with a change is 400.
|
||||
- One link read (`get_image_link_matches`, streamed and bounded to the listed
|
||||
paths) serves the filters, the rating sort and the count.
|
||||
|
||||
`positive_count` is the number of distinct linked positives over the full matching
|
||||
set, not the page:
|
||||
|
||||
- Present on every first page, and on every page of a filtered (`q`, `category`,
|
||||
`tags`) or `sort=rating` listing.
|
||||
- Absent on a plain listing's cursor pages, which read no links. The frontend
|
||||
keeps the first page's value.
|
||||
- Null when a plain first page's link read failed. The listing still returns 200,
|
||||
and consumers then hide "· K positive prompts". A `q` search alone also
|
||||
survives the failure: it matches filenames only, as before linked search, and
|
||||
every page's count is null (logged with the traceback). A failure under
|
||||
`category`, `tags` or the rating sort stays a safe 500.
|
||||
|
||||
Library parents (list, detail and command receipts) gain `run_count` (an int; the
|
||||
D23 migration estimate for rows from before 3.2.4) and `last_used_at`, a UTC ISO
|
||||
string. A naive value reads as UTC, and an unreadable or out-of-range value is
|
||||
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.
|
||||
|
||||
Favorites store (D7 = A): `PUT` and `DELETE /prompt_manager/images/favorite` with
|
||||
`{root_index, path}`. Both are idempotent and answer
|
||||
`{success, favorite, root_index, path}`. The boundary returns 400 for an unknown
|
||||
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` (`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 `/`
|
||||
separators. It is stored server-side only and never returned. Folder labels are
|
||||
not unique across roots, so they are not used as keys.
|
||||
- `relative_path` is `normcase(path)` with `/`. On Windows a case-only rename
|
||||
keeps the star. On POSIX the key is the exact spelling, so `A.png` and `a.png`
|
||||
stay separate (tests swap in `ntpath` through `py.api.images._path_flavor`).
|
||||
Moving the install or changing a root's real path orphans its rows, which are
|
||||
harmless and never shown.
|
||||
- Schema validation (`image_favorites_schema_error`) runs at startup and on
|
||||
restore. Old backups without the table restore, and backup plus restore keeps
|
||||
WAL-resident favorites and closes every handle (`tests/test_database_backup.py`).
|
||||
|
||||
Budgets: `test_filter_work_is_bounded` always runs. It asserts that a plain first
|
||||
page makes 1 link SELECT and a plain cursor page none, that every filter shape
|
||||
makes exactly 1, the covering-index plan, and that the per-file values are built
|
||||
once per scan and only for new files after a monitor invalidation. The opt-in
|
||||
`test_filter_budget` (`PROMPTMANAGER_PERF_BUDGET=1`, the `perf budget` job) times
|
||||
the D12 p95 < 150 ms budget; see "Performance budgets".
|
||||
|
||||
```sh
|
||||
python -m unittest tests.test_api_workspace_images tests.test_card_seed \
|
||||
tests.test_image_favorites tests.test_library tests.test_api_library \
|
||||
tests.test_database_backup -q
|
||||
```
|
||||
|
||||
Installing these fields needs a maintainer ComfyUI restart (Python, routes and
|
||||
schema). Automated tests use disposable databases and output folders.
|
||||
|
||||
## Prompt sending and cross-pack compatibility
|
||||
|
||||
`tests/js/prompt-bridge-contracts.test.js` runs the Toolkit's shared protocol
|
||||
@@ -669,9 +996,9 @@ 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) and shared role-block
|
||||
spacing/padding/glyph polish remain outside #209. 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.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Persisted image favorites, keyed by gallery root + relative path.
|
||||
|
||||
The root key is the root's real, case-normalized path and the relative key is
|
||||
case-normalized the same way (see ``py/api/images.py``); both stay
|
||||
server-side. Folder names alone are not unique across roots, so a favorite
|
||||
follows the directory itself when the configured roots are reordered.
|
||||
Optional in old backups, required once a database is current (see
|
||||
``library_schema.py``).
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Iterable
|
||||
|
||||
TABLE = "image_favorites"
|
||||
MAX_KEY_LENGTH = 4096
|
||||
_LOOKUP_CHUNK = 500
|
||||
_DDL = (
|
||||
f"CREATE TABLE {TABLE} (root_key TEXT NOT NULL, relative_path TEXT NOT NULL, "
|
||||
"created_at TEXT NOT NULL, PRIMARY KEY(root_key, relative_path))"
|
||||
)
|
||||
_AUTOINDEX = f"sqlite_autoindex_{TABLE}_1"
|
||||
_COLUMNS = (
|
||||
("root_key", "TEXT", 1, 1),
|
||||
("relative_path", "TEXT", 1, 2),
|
||||
("created_at", "TEXT", 1, 0),
|
||||
)
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def image_favorites_schema_error(
|
||||
conn: sqlite3.Connection, *, allow_missing: bool = True
|
||||
) -> str | None:
|
||||
"""Reject unsupported favorites schemas and invalid persisted values.
|
||||
|
||||
Reads only. A missing table is acceptable for backups that predate it
|
||||
unless ``allow_missing`` is false.
|
||||
"""
|
||||
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 image favorites schema"
|
||||
tables = [sql for kind, _name, sql in objects if kind == "table"]
|
||||
others = {(kind, name, sql) for kind, name, sql in objects if kind != "table"}
|
||||
if len(tables) != 1 or others != {("index", _AUTOINDEX, None)}:
|
||||
return "Image favorites name is reserved by an unsupported schema object"
|
||||
if _compact(tables[0] or "") != _compact(_DDL):
|
||||
return "Image favorites 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 "Image favorites has invalid columns"
|
||||
# Bytes bound characters from above (UTF-8 is at most 4 bytes each).
|
||||
limit = MAX_KEY_LENGTH * 4
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {TABLE} WHERE typeof(root_key) != 'text' "
|
||||
"OR typeof(relative_path) != 'text' OR typeof(created_at) != 'text' "
|
||||
f"OR length(CAST(root_key AS BLOB)) > {limit} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
|
||||
"OR length(CAST(created_at AS BLOB)) > 64 LIMIT 1"
|
||||
).fetchone():
|
||||
return "Image favorites contains invalid values"
|
||||
for root, path in conn.execute(
|
||||
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) FROM {TABLE} "
|
||||
f"WHERE length(CAST(root_key AS BLOB)) > {MAX_KEY_LENGTH} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {MAX_KEY_LENGTH}"
|
||||
):
|
||||
for raw in (root, path):
|
||||
if len(bytes(raw).decode("utf-8", "replace")) > MAX_KEY_LENGTH:
|
||||
return "Image favorites contains invalid values"
|
||||
return None
|
||||
|
||||
|
||||
def migrate_image_favorites(conn: sqlite3.Connection) -> None:
|
||||
"""Install the table atomically; validate before and after creating it."""
|
||||
conn.execute("SAVEPOINT image_favorites_schema")
|
||||
try:
|
||||
error = image_favorites_schema_error(conn)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute(_DDL.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
error = image_favorites_schema_error(conn, allow_missing=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE image_favorites_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO image_favorites_schema")
|
||||
conn.execute("RELEASE image_favorites_schema")
|
||||
raise
|
||||
|
||||
|
||||
class ImageFavoriteOperations:
|
||||
"""PromptDatabase mixin: set, clear and look up image favorites."""
|
||||
|
||||
def set_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
||||
"""Mark one file as a favorite; repeating it is a no-op. Returns True."""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
with self.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)",
|
||||
(root_key, relative_path, utc_now_iso()),
|
||||
)
|
||||
return True
|
||||
|
||||
def clear_image_favorite(self, root_key: str, relative_path: str) -> bool:
|
||||
"""Remove one favorite; True when a row was removed."""
|
||||
with self.model.get_connection() as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM image_favorites WHERE root_key=? AND relative_path=?",
|
||||
(root_key, relative_path),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_image_favorites(
|
||||
self, root_key: str, relative_paths: Iterable[str]
|
||||
) -> set[str]:
|
||||
"""The subset of *relative_paths* favorited under *root_key*."""
|
||||
wanted = list(dict.fromkeys(relative_paths))
|
||||
found: set[str] = set()
|
||||
conn = self.model.get_connection()
|
||||
for start in range(0, len(wanted), _LOOKUP_CHUNK):
|
||||
chunk = wanted[start : start + _LOOKUP_CHUNK]
|
||||
marks = ",".join("?" * len(chunk))
|
||||
rows = conn.execute( # only "?" placeholders are interpolated
|
||||
"SELECT relative_path FROM image_favorites WHERE root_key=? "
|
||||
f"AND relative_path IN ({marks})", # nosec B608
|
||||
(root_key, *chunk),
|
||||
)
|
||||
found.update(path for (path,) in rows)
|
||||
return found
|
||||
+88
-7
@@ -1,5 +1,6 @@
|
||||
"""Read-only positive-parent views over existing prompt and pair tables."""
|
||||
|
||||
import datetime
|
||||
import sqlite3
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
@@ -13,10 +14,79 @@ _ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id
|
||||
_FROM = "prompts p LEFT JOIN prompt_library_state ls ON ls.prompt_id=p.id"
|
||||
# The characters JavaScript's String.prototype.trim() removes, so a title the
|
||||
# display treats as blank also falls back to the prompt text for name sorting.
|
||||
_BLANK = (
|
||||
"char(9,10,11,12,13,32,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,"
|
||||
"8200,8201,8202,8232,8233,8239,8287,12288,65279)"
|
||||
)
|
||||
_TRIM_CODES = (9, 10, 11, 12, 13, 32, 160, 5760, *range(8192, 8203))
|
||||
_TRIM_CODES += (8232, 8233, 8239, 8287, 12288, 65279)
|
||||
_BLANK = f"char({','.join(map(str, _TRIM_CODES))})"
|
||||
_JS_TRIM = "".join(map(chr, _TRIM_CODES))
|
||||
# The Library's display title: a stored title, else the prompt's first line.
|
||||
DISPLAY_TITLE_LIMIT = 80
|
||||
|
||||
|
||||
def _utf16_units(text: str) -> int:
|
||||
"""JavaScript ``length``: characters beyond the BMP count as two units."""
|
||||
return len(text) + sum(1 for ch in text if ord(ch) > 0xFFFF)
|
||||
|
||||
|
||||
def _utf16_prefix(text: str, units: int) -> str:
|
||||
"""The longest prefix within ``units`` UTF-16 units, never splitting a pair."""
|
||||
used = 0
|
||||
for index, ch in enumerate(text):
|
||||
used += 2 if ord(ch) > 0xFFFF else 1
|
||||
if used > units:
|
||||
return text[:index]
|
||||
return text
|
||||
|
||||
|
||||
def titled(stored_titles: tuple, text: object) -> tuple[str | None, str | None]:
|
||||
"""Return ``(title, source)``: a stored title, else the text's first line.
|
||||
|
||||
``source`` is ``"stored"`` for the first non-blank stored title (blank as
|
||||
in JavaScript's trim, like the Library view), ``"text"`` for the first line,
|
||||
and None when both are empty. The first line is trimmed and, past
|
||||
DISPLAY_TITLE_LIMIT UTF-16 units, cut with an ellipsis like the Library's
|
||||
``libraryTitle()`` (never inside a surrogate pair).
|
||||
"""
|
||||
for title in stored_titles:
|
||||
if isinstance(title, str) and title.strip(_JS_TRIM):
|
||||
return title, "stored"
|
||||
if not isinstance(text, str):
|
||||
return None, None
|
||||
first = text.split("\n")[0].strip(_JS_TRIM)
|
||||
if _utf16_units(first) > DISPLAY_TITLE_LIMIT:
|
||||
first = _utf16_prefix(first, DISPLAY_TITLE_LIMIT) + "\u2026"
|
||||
return (first, "text") if first else (None, None)
|
||||
|
||||
|
||||
def display_title(stored_titles: tuple, text: object) -> str | None:
|
||||
"""The Library display title of ``titled``, without its source."""
|
||||
return titled(stored_titles, text)[0]
|
||||
|
||||
|
||||
def utc_iso(value: object) -> str | None:
|
||||
"""A stored timestamp as UTC ISO text, or None when absent or unreadable.
|
||||
|
||||
Usage writes are already UTC ISO; a naive value (SQLite's CURRENT_TIMESTAMP
|
||||
form) is read as UTC, and an offset is converted to UTC. One bad row must
|
||||
not fail a whole Library read, so a value past the datetime range after
|
||||
conversion (OverflowError) is unreadable too. Python 3.11+ fromisoformat
|
||||
accepts more ISO forms than 3.10 (e.g. a 2-digit fraction); every form the
|
||||
app writes (utc_now_iso, the 3.2.4 backfill, CURRENT_TIMESTAMP, Z) parses
|
||||
the same on both, so only hand-edited values can differ by version.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith(("Z", "z")):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
stamp = datetime.datetime.fromisoformat(text)
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
|
||||
return stamp.astimezone(datetime.timezone.utc).isoformat()
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
_NAME = (
|
||||
f"LOWER(CASE WHEN TRIM(COALESCE(ls.title,''),{_BLANK})!='' "
|
||||
"THEN ls.title ELSE p.text END)"
|
||||
@@ -145,6 +215,7 @@ class LibraryOperations:
|
||||
) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT p.id,p.text,ls.title,p.category,p.rating,p.notes,p.created_at,"
|
||||
"p.run_count,p.last_used_at,"
|
||||
+ (f"{sort_expression} AS _sort," if sort_expression else "")
|
||||
+ "(SELECT COUNT(*) FROM prompt_pairs v WHERE v.positive_prompt_id=p.id "
|
||||
"AND v.archived=0) AS variant_count,"
|
||||
@@ -165,6 +236,9 @@ class LibraryOperations:
|
||||
):
|
||||
by_id[row["prompt_id"]]["tags"].append(row["name"])
|
||||
for row in result:
|
||||
# Usage is shown, never compared: the token hashes owner fields only.
|
||||
row["run_count"] = row["run_count"] if type(row["run_count"]) is int else 0
|
||||
row["last_used_at"] = utc_iso(row["last_used_at"])
|
||||
row["edit_token"] = library_edit_token(epoch, row)
|
||||
return result
|
||||
|
||||
@@ -196,9 +270,15 @@ class LibraryOperations:
|
||||
ceiling = after["ceiling"]
|
||||
where += " AND p.id<=?"
|
||||
params.append(ceiling)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM " + _FROM + " WHERE " + where, params
|
||||
).fetchone()[0]
|
||||
# One SELECT counts the filtered parents and their active variants.
|
||||
total, variant_total = conn.execute(
|
||||
"SELECT COUNT(*),COALESCE(SUM((SELECT COUNT(*) FROM prompt_pairs v "
|
||||
"WHERE v.positive_prompt_id=p.id AND v.archived=0)),0) FROM "
|
||||
+ _FROM
|
||||
+ " WHERE "
|
||||
+ where,
|
||||
params,
|
||||
).fetchone()
|
||||
expression, direction = _ORDERS[sort]
|
||||
if after is not None:
|
||||
operator = ">" if direction == "ASC" else "<"
|
||||
@@ -230,6 +310,7 @@ class LibraryOperations:
|
||||
"library_epoch": epoch,
|
||||
"items": rows,
|
||||
"total": total,
|
||||
"variant_total": variant_total,
|
||||
"has_more": has_more,
|
||||
"next_key": next_key,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
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 .execution_history_schema import (
|
||||
history_schema_error,
|
||||
migrate_execution_history,
|
||||
@@ -298,6 +299,7 @@ class PromptModel:
|
||||
self._migrate_prompt_pairs(conn)
|
||||
migrate_execution_history(conn)
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
|
||||
def _migrate_prompt_pairs(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("SAVEPOINT prompt_pair_schema")
|
||||
@@ -1016,6 +1018,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.
|
||||
|
||||
Returns:
|
||||
(ok, reason): reason is "ok" when the file is acceptable, otherwise
|
||||
@@ -1070,6 +1075,11 @@ class PromptModel:
|
||||
)
|
||||
if library_error:
|
||||
return False, library_error
|
||||
favorites_error = image_favorites_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if favorites_error:
|
||||
return False, favorites_error
|
||||
return True, "ok"
|
||||
except sqlite3.Error as e:
|
||||
return False, f"Invalid SQLite database: {e}"
|
||||
|
||||
+215
-2
@@ -6,11 +6,12 @@ import sqlite3
|
||||
import json
|
||||
import datetime
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Set, Tuple
|
||||
|
||||
from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations
|
||||
from .library import LibraryOperations
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .library_commands import LibraryCommandOperations
|
||||
from .vocabulary import VocabularyOperations
|
||||
from .execution_history import ExecutionHistoryOperations
|
||||
@@ -179,6 +180,122 @@ AND p.id NOT IN (SELECT prompt_id FROM live_hint_prompts)
|
||||
"""
|
||||
|
||||
|
||||
# One SELECT reads the link summaries of at most this many paths: three bound
|
||||
# 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 (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, 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
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
LEFT JOIN prompts pt ON pt.id = pp.positive_prompt_id
|
||||
LEFT JOIN prompt_library_state pls ON pls.prompt_id = pp.positive_prompt_id
|
||||
WHERE gi.image_path IN ({marks}) OR gi.file_path IN ({marks}){fallback}"""
|
||||
# One image row describes a card only when no other row claims the same file
|
||||
# (as in get_image_link_summaries, several rows are ambiguous). The pair scope
|
||||
# and the linked positive follow _LINK_SUMMARY_SQL; category, rating, tags and
|
||||
# the searchable text and titles come from that positive (spec D12/D13). Every
|
||||
# writer since the 3.2.4 migration stores file_path, so it is the path key.
|
||||
# The prompt-level conditions are uncorrelated IN sets SQLite builds once, and
|
||||
# the CASE evaluates the per-row ambiguity probe only for admitted rows.
|
||||
_LINK_FILTER_SQL = """SELECT gi.file_path, p.rating, {hit}, p.id
|
||||
FROM generated_images gi
|
||||
LEFT JOIN prompt_pairs pp ON pp.id = (
|
||||
SELECT CASE WHEN COUNT(DISTINCT ip.pair_id) = 1 THEN MIN(ip.pair_id) END
|
||||
FROM image_prompt_pairs ip WHERE ip.image_id = gi.id)
|
||||
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 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 '\\'))"""
|
||||
_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})"
|
||||
)
|
||||
|
||||
|
||||
def link_filter_query(
|
||||
q: str, category: Optional[str], tags: List[str], *, every_link: bool = False
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""The workspace filter SELECT and its named parameters (all bound).
|
||||
|
||||
Only code constants and enumerate indices are formatted into the SQL; see
|
||||
PromptDatabase.get_image_link_matches for the semantics.
|
||||
"""
|
||||
params: Dict[str, Any] = {}
|
||||
hit = "1"
|
||||
if q:
|
||||
hit = _LINK_FILTER_HIT
|
||||
params["pattern"] = f"%{escape_like(q)}%"
|
||||
admitted = []
|
||||
if category is not None:
|
||||
admitted.append("p.category = :category")
|
||||
params["category"] = category
|
||||
for index, tag in enumerate(tags):
|
||||
admitted.append(_LINK_FILTER_TAG.format(index=index))
|
||||
params[f"tag{index}"] = tag
|
||||
if q and not admitted and not every_link:
|
||||
admitted.append(hit)
|
||||
sql = _LINK_FILTER_SQL.format(hit=hit, admit=" AND ".join(admitted) or "1")
|
||||
return sql, params
|
||||
|
||||
|
||||
_ASCII_FOLD = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
||||
|
||||
|
||||
def _like_suffix(value: object, suffix: str) -> bool:
|
||||
"""Python form of SQLite's ``value LIKE '%' || suffix``: ASCII-only case."""
|
||||
return isinstance(value, str) and value.translate(_ASCII_FOLD).endswith(
|
||||
suffix.translate(_ASCII_FOLD)
|
||||
)
|
||||
|
||||
|
||||
def _empty_link_summary(status: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"positive_id": None,
|
||||
"title": None,
|
||||
"title_source": None,
|
||||
"category": None,
|
||||
"rating": None,
|
||||
}
|
||||
|
||||
|
||||
def _link_summary(row: tuple) -> Dict[str, Any]:
|
||||
if row[7] is not None:
|
||||
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],)
|
||||
title, source = titled(titles, text)
|
||||
return {
|
||||
"status": "linked",
|
||||
"positive_id": positive,
|
||||
"title": title,
|
||||
"title_source": source,
|
||||
"category": category,
|
||||
"rating": rating,
|
||||
}
|
||||
|
||||
|
||||
class PromptDatabase(
|
||||
PairOperations,
|
||||
LibraryOperations,
|
||||
@@ -188,6 +305,7 @@ class PromptDatabase(
|
||||
ExecutionContextOperations,
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
):
|
||||
"""Database operations class for managing prompts."""
|
||||
|
||||
@@ -2160,6 +2278,101 @@ class PromptDatabase(
|
||||
"parameters": row[8] if len(row) > 8 else None,
|
||||
}
|
||||
|
||||
def get_image_link_summaries(
|
||||
self, paths: List[str], allow_filename_fallback: bool = True
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Summarize each path's linked saved positive for an image card.
|
||||
|
||||
Matching equals get_image_prompt_info: exact image_path or normalized
|
||||
file_path rows win; otherwise, with the fallback, rows whose image_path
|
||||
ends with the basename. One match is linked, several are ambiguous.
|
||||
One SELECT reads up to LINK_SUMMARY_BATCH paths.
|
||||
"""
|
||||
unique = list(dict.fromkeys(paths))
|
||||
summaries: Dict[str, Dict[str, Any]] = {}
|
||||
for start in range(0, len(unique), LINK_SUMMARY_BATCH):
|
||||
batch = unique[start : start + LINK_SUMMARY_BATCH]
|
||||
summaries.update(self._link_summary_batch(batch, allow_filename_fallback))
|
||||
return summaries
|
||||
|
||||
def _link_summary_batch(
|
||||
self, paths: List[str], allow_filename_fallback: bool
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
normalized = {path: normalize_image_path(path) for path in paths}
|
||||
basenames = {path: os.path.basename(path) for path in paths}
|
||||
marks = ",".join("?" for _ in paths)
|
||||
params: List[Any] = [*paths, *normalized.values()]
|
||||
fallback = ""
|
||||
if allow_filename_fallback:
|
||||
fallback = "".join(" OR gi.image_path LIKE ? ESCAPE '\\'" for _ in paths)
|
||||
params.extend(f"%{escape_like(basenames[path])}" for path in paths)
|
||||
sql = _LINK_SUMMARY_SQL.format(marks=marks, fallback=fallback)
|
||||
with self.model.get_connection() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
summaries = {}
|
||||
for path in paths:
|
||||
exact = [
|
||||
row for row in rows if row[0] == path or row[1] == normalized[path]
|
||||
]
|
||||
matches = exact or (
|
||||
[row for row in rows if _like_suffix(row[0], basenames[path])]
|
||||
if allow_filename_fallback
|
||||
else []
|
||||
)
|
||||
if len(matches) == 1:
|
||||
summaries[path] = _link_summary(matches[0])
|
||||
else:
|
||||
summaries[path] = _empty_link_summary(
|
||||
"ambiguous" if matches else "none"
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_image_link_matches(
|
||||
self,
|
||||
q: str,
|
||||
category: Optional[str],
|
||||
tags: List[str],
|
||||
*,
|
||||
every_link: bool = False,
|
||||
wanted: Optional[Set[str]] = None,
|
||||
) -> Dict[str, Tuple[bool, Optional[int], int]]:
|
||||
"""Map normalized paths of unambiguously linked images to
|
||||
(hit, rating, positive id).
|
||||
|
||||
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; 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
|
||||
are streamed; with *wanted* (the listing's normalized paths) only those
|
||||
paths are kept, so memory is bounded by the listing, not the database.
|
||||
"""
|
||||
sql, params = link_filter_query(q, category, tags, every_link=every_link)
|
||||
matches: Dict[str, Tuple[bool, Optional[int], int]] = {}
|
||||
with self.model.get_connection() as conn:
|
||||
for path, rating, matched, positive in conn.execute(sql, params):
|
||||
if wanted is None or path in wanted:
|
||||
matches[path] = (bool(matched), rating, positive)
|
||||
return matches
|
||||
|
||||
def get_image_paths_matching(
|
||||
self, q: str, category: Optional[str], tags: List[str]
|
||||
) -> Set[str]:
|
||||
"""Normalized paths whose linked positive matches *q*, *category* and *tags*.
|
||||
|
||||
See get_image_link_matches; an empty *q* matches every linked image
|
||||
the category and tags admit.
|
||||
"""
|
||||
return {
|
||||
path
|
||||
for path, (hit, _rating, _positive) in self.get_image_link_matches(
|
||||
q, category, tags
|
||||
).items()
|
||||
if hit or not q
|
||||
}
|
||||
|
||||
def get_prompt_id_for_image(self, image_path: str) -> Optional[int]:
|
||||
"""Return the prompt_id linked to an image, or None."""
|
||||
with self.model.get_connection() as conn:
|
||||
|
||||
+24
-1
@@ -283,6 +283,16 @@ class PromptManagerAPI(
|
||||
self._html_cache = {} # Cached HTML file contents keyed by path
|
||||
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
|
||||
self._gallery_cache_ttl = 30 # Cache TTL in seconds
|
||||
# Workspace listing values and sort orders derived from a cached scan
|
||||
# (py/api/images.py _workspace_listed/_workspace_orders).
|
||||
self._workspace_listed_cache = {}
|
||||
self._workspace_order_cache = {}
|
||||
# Card facts (dimensions, seed, positive title) per root and
|
||||
# (path, mtime), kept and pruned with the per-file values
|
||||
# (py/api/images.py _card_facts).
|
||||
self._workspace_card_cache = {}
|
||||
# Favorite keys of each root's cached scan (images._favorite_listing).
|
||||
self._favorite_listing_cache = {}
|
||||
# Single-flight locks for long jobs, keyed by job family; see
|
||||
# _acquire_long_job(). Created lazily so no lock binds to an event
|
||||
# loop before the server's loop exists.
|
||||
@@ -348,9 +358,22 @@ class PromptManagerAPI(
|
||||
def invalidate_gallery_cache(self):
|
||||
"""Invalidate the cached gallery file listing.
|
||||
|
||||
Called by the image monitor when new files are detected.
|
||||
Called by the image monitor when new files are detected. The workspace
|
||||
keeps its per-file values and card facts, so the next request rebuilds
|
||||
only new or changed files (see ImageRoutesMixin._workspace_listed and
|
||||
_card_facts); its sort orders
|
||||
are dropped, as the rescan replaces them anyway, and so is the
|
||||
favorites lookup, which holds the old scan list.
|
||||
"""
|
||||
self._gallery_cache = {}
|
||||
self._workspace_order_cache = {}
|
||||
self._favorite_listing_cache = {}
|
||||
|
||||
def reset_gallery_roots_cache(self):
|
||||
"""Drop every cached listing and derived workspace value (roots changed)."""
|
||||
self.invalidate_gallery_cache()
|
||||
self._workspace_listed_cache = {}
|
||||
self._workspace_card_cache = {}
|
||||
|
||||
def add_routes(self, routes):
|
||||
"""Register all API routes with the ComfyUI server.
|
||||
|
||||
+1
-1
@@ -897,7 +897,7 @@ class AdminRoutesMixin:
|
||||
return False
|
||||
GalleryConfig.MONITORING_DIRECTORIES = roots
|
||||
self._cached_output_dir = None
|
||||
self._gallery_cache = {}
|
||||
self.reset_gallery_roots_cache()
|
||||
return True
|
||||
|
||||
def _persist_settings(self):
|
||||
|
||||
+728
-123
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -233,7 +233,9 @@ class LibraryRoutesMixin:
|
||||
self.add_library_negative
|
||||
)
|
||||
|
||||
def _library_media(self, records: list[dict], roots: list[Path]) -> list[dict]:
|
||||
def _library_media(
|
||||
self, records: list[dict], roots: list[Path], *, linked: bool = True
|
||||
) -> list[dict]:
|
||||
page = []
|
||||
for record in records:
|
||||
try:
|
||||
@@ -251,7 +253,7 @@ class LibraryRoutesMixin:
|
||||
break
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
continue
|
||||
return self._format_workspace_page(page)
|
||||
return self._format_workspace_page(page, linked=linked)
|
||||
|
||||
@pair_response
|
||||
async def get_library_options(self, request: web.Request) -> web.Response:
|
||||
@@ -296,7 +298,10 @@ class LibraryRoutesMixin:
|
||||
|
||||
def previews() -> None:
|
||||
for parent in result["items"]:
|
||||
parent["previews"] = self._library_media(records[parent["id"]], roots)
|
||||
# Previews belong to their parent; a per-parent link read is N+1.
|
||||
parent["previews"] = self._library_media(
|
||||
records[parent["id"]], roots, linked=False
|
||||
)
|
||||
|
||||
await self._run_in_executor(previews)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
+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
|
||||
|
||||
@@ -11,6 +11,10 @@ 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');
|
||||
|
||||
/** 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});
|
||||
@@ -88,14 +92,22 @@ 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 and history stay null until PRs E and F.
|
||||
*/
|
||||
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 +246,101 @@ 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,
|
||||
// ---- 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)};
|
||||
}
|
||||
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:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1};
|
||||
}
|
||||
|
||||
@@ -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', metadata_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);
|
||||
@@ -86,7 +90,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
});
|
||||
|
||||
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 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');
|
||||
@@ -98,7 +102,7 @@ async function downloadText(page,action) {
|
||||
|
||||
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.getByRole('heading',{name:POSITIVE_TITLE,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');
|
||||
expect(await panel.evaluate(node=>node.getBoundingClientRect().width)).toBe(310);
|
||||
expect(await panel.locator('.file-inspector-inner').evaluate(node=>getComputedStyle(node).padding)).toBe('17px');
|
||||
@@ -174,7 +178,7 @@ 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 panel.getByRole('button',{name:'Open full preview',exact:true}).click();const preview=page.locator('dialog#viewer-dialog');await expect(preview.locator('.generation-facts 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');
|
||||
@@ -194,7 +198,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
|
||||
|
||||
test('embedded gallery uses the same full inspector',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:'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('.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('#viewer-dialog .file-inspector')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('full inspector labels container representations and releases action URLs',async({page,request})=>{
|
||||
@@ -227,7 +231,7 @@ 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 page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.generation-facts 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();
|
||||
@@ -262,9 +266,9 @@ test.describe('full inspector coarse pointer',()=>{
|
||||
}
|
||||
await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText);
|
||||
}
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true});
|
||||
await panel.getByRole('button',{name:'Open full preview',exact:true}).click();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);
|
||||
}
|
||||
@@ -272,25 +276,98 @@ test.describe('full inspector coarse pointer',()=>{
|
||||
});
|
||||
});
|
||||
|
||||
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 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,30 +375,31 @@ 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-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');
|
||||
await page.getByRole('button',{name:'Filters',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 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();
|
||||
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:');
|
||||
});
|
||||
@@ -334,8 +412,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();
|
||||
@@ -378,7 +456,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
|
||||
await expect(mediaDialog(page).locator('.file-name')).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 +485,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,7 +516,7 @@ 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');
|
||||
@@ -450,18 +528,18 @@ test('slideshow interaction visibility sender and disposal clear intent',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.locator('#viewer-details').dispatchEvent('wheel',{deltaY:100}),
|
||||
()=>modal.getByRole('button',{name:'Copy positive prompt',exact:true}).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(),
|
||||
()=>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,12 +570,12 @@ 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')),
|
||||
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')),
|
||||
footer:node.querySelector('.file-inspector-footer')?box(node.querySelector('.file-inspector-footer')):null};
|
||||
})});
|
||||
if(page.viewportSize().width===1600)expect(await rail.evaluate(node=>node.getBoundingClientRect().width)).toBe(340);
|
||||
@@ -511,19 +589,19 @@ test('full inspector attaches matched theme and mobile evidence with reachable c
|
||||
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 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 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 modal.getByRole('button',{name:'Close image viewer',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 image viewer',exact:true}).click();
|
||||
await selectFile(page,'control-heavy.png');await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();
|
||||
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'});
|
||||
@@ -534,12 +612,12 @@ test('collection viewer preserves original download and rooted retries',async({p
|
||||
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 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,10 +625,10 @@ 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();
|
||||
@@ -565,20 +643,20 @@ test('collection viewer keeps nested dialog focus and route cleanup',async({page
|
||||
const text=modal.locator('.prompt-positive pre');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 page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.media-card.selected .image-open')).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);
|
||||
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,7 +672,7 @@ 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 page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
@@ -608,16 +686,16 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
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 +733,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 +757,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);
|
||||
@@ -692,7 +770,7 @@ test('collection viewer refits a late wrapped title without replacing current me
|
||||
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([]);
|
||||
|
||||
@@ -15,8 +15,7 @@ export const canonicalDifferences = {
|
||||
'Current-file candidates disclose unknown identity and context membership',
|
||||
],
|
||||
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',
|
||||
'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209',
|
||||
'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
@@ -106,7 +105,7 @@ 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 viewer = page.locator('dialog#viewer-dialog');
|
||||
const send = viewer.getByRole('button', { name: 'Send prompt pair', exact: true });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' });
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
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} 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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
function withoutItems(row,isRemoved) {
|
||||
return reflow(row,row.items.filter(item=>!isRemoved(item)));
|
||||
}
|
||||
|
||||
/** 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]));
|
||||
const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view');
|
||||
// D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts.
|
||||
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 Save view, then without the sample tag.
|
||||
const removed={};
|
||||
for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['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 Save view (D16) or 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.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
|
||||
`${motion} toolbar without Save view`).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);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/inde
|
||||
const workspace='/prompt_manager/workspace#/images';
|
||||
const details=page=>page.getByRole('region',{name:'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();
|
||||
@@ -114,9 +114,9 @@ test('sender requires explicit destination, previews full changes and acknowledg
|
||||
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 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();}
|
||||
@@ -227,8 +227,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 +248,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);
|
||||
});
|
||||
|
||||
|
||||
+17
-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
|
||||
@@ -132,6 +133,7 @@ def create_app(root: Path) -> web.Application:
|
||||
) -> None:
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -388,13 +390,20 @@ 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()
|
||||
@@ -402,7 +411,10 @@ def create_app(root: Path) -> web.Application:
|
||||
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})
|
||||
|
||||
@@ -224,7 +224,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 +245,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 +259,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);
|
||||
@@ -297,7 +297,7 @@ 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');
|
||||
expect(await gallery(page).locator('article[data-image-key]').evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).toEqual(keys);expect(writes).toBe(0);
|
||||
@@ -468,7 +468,7 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
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'));
|
||||
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');
|
||||
@@ -485,7 +485,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 +500,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(()=>{});});
|
||||
|
||||
+1307
-108
File diff suppressed because it is too large
Load Diff
@@ -478,8 +478,9 @@ test('library heading offers Export then + New prompt, and New opens the saved p
|
||||
|
||||
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);
|
||||
@@ -679,6 +680,9 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected
|
||||
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 expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1);
|
||||
// #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);
|
||||
await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible();
|
||||
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();
|
||||
|
||||
@@ -197,7 +197,7 @@ 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();
|
||||
await media(page).first().getByRole('button',{name:'Inspect 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']);
|
||||
|
||||
@@ -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', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => {
|
||||
const node = document.querySelector(selector);
|
||||
if (!node) return [selector, null];
|
||||
const box = node.getBoundingClientRect(), style = getComputedStyle(node);
|
||||
@@ -106,10 +106,10 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
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 page.locator('.library-shared-gallery .image-card-actions button').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');
|
||||
await expect(page.locator('dialog#viewer-dialog')).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 captureCanonicalState(page, testInfo, `production-${name}-${width}-${theme}`, selector);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ 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();
|
||||
@@ -131,7 +131,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
await currentPositive.getByRole('button', { name: 'Copy positive prompt', exact: true }).click();
|
||||
await expect(currentPositive.getByRole('status')).toContainText(/Copied\.|Copy unavailable/);
|
||||
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);
|
||||
|
||||
@@ -290,18 +290,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 +341,30 @@ 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');
|
||||
// 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('.image-details h2')).toHaveText(filename);
|
||||
const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true });
|
||||
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 +386,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 +413,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);
|
||||
@@ -454,10 +465,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 +490,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 +502,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(); }
|
||||
});
|
||||
@@ -577,33 +593,34 @@ 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 page.locator('.media-card .image-card-actions button').first().click();
|
||||
await expect(page.locator('.image-details 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('.image-details 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,.image-details,.viewer-container,dialog[open]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('embedded history gallery keeps the history heading and disposes its layouts',async({page,request},testInfo)=>{
|
||||
@@ -624,6 +641,6 @@ test('embedded history gallery keeps the history heading and disposes its layout
|
||||
}
|
||||
await attachEvidence(page,testInfo,`after-images-embedded-history-${width}`,false);
|
||||
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,.image-details,dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@ 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'],
|
||||
// D29: Images no longer links the legacy gallery; Overview's tool cards still do.
|
||||
['images', 'Images', null],
|
||||
['library', 'Prompt library', '/prompt_manager/admin'],
|
||||
['tags', 'Tags & categories', null],
|
||||
['inspect', 'Inspect a file', '/prompt_manager/gallery.html'],
|
||||
@@ -80,6 +81,13 @@ 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: the legacy image gallery is reached from Overview's tool card, not from Images.
|
||||
await page.goto(`${workspace}#/overview`);
|
||||
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 +316,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);
|
||||
|
||||
@@ -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,7 @@ _TABLES = (
|
||||
"execution_contexts",
|
||||
"execution_prompt_snapshots",
|
||||
"execution_context_texts",
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
@@ -176,6 +180,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,18 +322,34 @@ 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()
|
||||
@@ -356,6 +381,10 @@ def seed_workspace_concept(
|
||||
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()
|
||||
@@ -376,6 +405,10 @@ def seed_workspace_concept(
|
||||
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"})
|
||||
if concept_starred(prompt_index, round_index):
|
||||
db.set_image_favorite(
|
||||
favorite_root, _favorite_relative_key(f"concepts/{name}")
|
||||
)
|
||||
context = RecipeContext(
|
||||
"3", _snapshot(prompt.positive), _snapshot(negative), "inferred"
|
||||
)
|
||||
@@ -400,10 +433,19 @@ def seed_workspace_concept(
|
||||
"prompt": prompt.id,
|
||||
"variant": variant,
|
||||
"seed": seed,
|
||||
"path": f"concepts/{name}",
|
||||
"width": facts["width"],
|
||||
"height": facts["height"],
|
||||
}
|
||||
)
|
||||
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 +458,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,
|
||||
|
||||
@@ -26,6 +26,7 @@ EDITOR_METADATA = {
|
||||
"notes": "Keep the soft light.",
|
||||
}
|
||||
_TABLES = (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -112,6 +112,7 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
"""Replace only the owned browser harness records and media roots."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -79,6 +79,7 @@ UNUSED_TAG = "unused_vocabulary"
|
||||
MAX_EXTRA_TAGS = 300
|
||||
MAX_EXTRA_CATEGORIES = 200
|
||||
_TABLES = (
|
||||
"image_favorites",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
|
||||
@@ -237,7 +237,7 @@ 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;
|
||||
|
||||
@@ -41,13 +41,15 @@ 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();
|
||||
await card.getByRole('button',{name:`Inspect ${filename}`,exact:true}).click();
|
||||
const inspector=workspace.getByRole('region',{name:'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});
|
||||
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 viewer.getByText('Workflow & raw metadata',{exact:true}).click();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// The seed the workspace inspector shows for encoded inspection responses, through
|
||||
// its own path: projectInspectionPayload with the server facts and the legacy
|
||||
// parser fill (comfy-metadata.js, passed explicitly; Node has no window global).
|
||||
// Used by tests/test_api_workspace_images.py to compare card and inspector seeds.
|
||||
// stdin: {listing, inspections: [{name, body}]}; each body is the exact response
|
||||
// JSON text, and listing (optional) is the exact workspace listing response text.
|
||||
// stdout: {inspector: {name: {status, value}}, card: {path: String(seed) | null}}.
|
||||
// Inspector status is 'error' where the page cannot project the record. The card
|
||||
// values come from JSON.parse of the listing, as the browser reads them, so a
|
||||
// seed sent as a JSON number past 2**53 shows up rounded here.
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const web = path.join(here, '..', '..', '..', 'web', 'js');
|
||||
const require = createRequire(import.meta.url);
|
||||
const parser = require(path.join(web, 'comfy-metadata.js'));
|
||||
const { projectInspectionPayload } = await import(pathToFileURL(path.join(web, 'workspace', 'inspection-state.mjs')).href);
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
const { listing = null, inspections } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
const inspector = {};
|
||||
for (const { name, body } of inspections) {
|
||||
const identity = { kind: 'rooted-file', key: `0:${name}`, root_index: 0, path: name, root_scope: null };
|
||||
try {
|
||||
const record = projectInspectionPayload(JSON.parse(body), identity, null, parser.extractGenerationFacts, false, parser);
|
||||
inspector[name] = { status: record.generation.seed.status, value: record.generation.seed.value };
|
||||
} catch (error) {
|
||||
// The page shows no record here, so no seed either.
|
||||
inspector[name] = { status: 'error', value: String(error && error.name) };
|
||||
}
|
||||
}
|
||||
const card = {};
|
||||
for (const item of listing === null ? [] : JSON.parse(listing).images) {
|
||||
card[item.path] = item.seed === null || item.seed === undefined ? null : String(item.seed);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ inspector, card }));
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -183,3 +183,152 @@ 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 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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { libraryTitle } = await import('../../web/js/workspace/library.mjs').catch(() => ({}));
|
||||
|
||||
test('library title counts UTF-16 units and never splits a surrogate pair', () => {
|
||||
assert.equal(typeof libraryTitle, 'function', 'libraryTitle is exported');
|
||||
assert.equal(libraryTitle(' short line \nsecond'), 'short line');
|
||||
assert.equal(libraryTitle('a'.repeat(80)), 'a'.repeat(80));
|
||||
assert.equal(libraryTitle('a'.repeat(81)), `${'a'.repeat(80)}…`);
|
||||
// 80 emoji are 160 UTF-16 units: 40 fit.
|
||||
assert.equal(libraryTitle('😀'.repeat(80)), `${'😀'.repeat(40)}…`);
|
||||
// 79 units then an emoji: the emoji would straddle the limit, so it is dropped whole.
|
||||
assert.equal(libraryTitle(`${'a'.repeat(79)}😀b`), `${'a'.repeat(79)}…`);
|
||||
});
|
||||
@@ -89,7 +89,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 +106,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();}
|
||||
});
|
||||
@@ -152,7 +152,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 +173,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 +185,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,613 @@
|
||||
// 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 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),
|
||||
});
|
||||
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']);
|
||||
|
||||
const [label, category, sort, 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');
|
||||
|
||||
// D16: no Save view until saved views are persisted.
|
||||
assert.equal(button(root, 'Save view'), undefined);
|
||||
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() {} }), 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() {} });
|
||||
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() {} });
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
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('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');
|
||||
});
|
||||
@@ -188,6 +188,10 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
async def test_maximum_parent_page_uses_bounded_sql_queries_for_previews(self):
|
||||
for index in range(200):
|
||||
positive = self._save_prompt(f"Parent {index}", tags=[f"tag {index}"])
|
||||
if index % 3 == 0:
|
||||
self.api.db.save_prompt_pair(f"Parent {index}", f"Negative {index}")
|
||||
if index % 4 == 0:
|
||||
self.api.db.record_prompt_use(positive, times=index + 1)
|
||||
make_png(self.output_dir / f"{index}.png")
|
||||
self.api.db.link_image_to_prompt(
|
||||
positive, str(self.output_dir / f"{index}.png")
|
||||
@@ -232,6 +236,32 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
for item in result["items"]
|
||||
)
|
||||
)
|
||||
# Usage fields and the variant total ride on the same five SELECTs.
|
||||
self.assertEqual(result["variant_total"], 67)
|
||||
self.assertEqual(
|
||||
result["variant_total"],
|
||||
sum(item["variant_count"] for item in result["items"]),
|
||||
)
|
||||
used = {
|
||||
int(item["text"].split()[-1]): item
|
||||
for item in result["items"]
|
||||
if item["last_used_at"] is not None
|
||||
}
|
||||
self.assertEqual(sorted(used), list(range(0, 200, 4)))
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["run_count"] == index + 1
|
||||
and item["last_used_at"].endswith("+00:00")
|
||||
for index, item in used.items()
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
type(item["run_count"]) is int
|
||||
for item in result["items"]
|
||||
if item["last_used_at"] is None
|
||||
)
|
||||
)
|
||||
|
||||
async def test_long_name_cursor_stays_small_and_preserves_full_sorting(self):
|
||||
prefix = "猫" * 10_000
|
||||
@@ -309,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))
|
||||
@@ -343,7 +378,8 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
self.assertEqual(result["library"]["metadata_scope"], "pair")
|
||||
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_epoch_scoped_all_library_cursors(self):
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
@@ -438,6 +474,38 @@ class TestLibraryAPI(ImageAPITestCase):
|
||||
)
|
||||
return response, await response.json()
|
||||
|
||||
async def test_unconvertible_last_use_does_not_fail_list_detail_or_receipt(self):
|
||||
positive = self._save_prompt("Cat")
|
||||
other = self._save_prompt("Dog")
|
||||
with self.api.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=? WHERE id=?",
|
||||
("9999-12-31T23:59:59-01:00", positive),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=? WHERE id=?",
|
||||
("0001-01-01T00:00:00+01:00", other),
|
||||
)
|
||||
response, listed = await self.get()
|
||||
self.assertEqual(response.status, 200, listed)
|
||||
self.assertEqual(
|
||||
[item["last_used_at"] for item in listed["items"]], [None, None]
|
||||
)
|
||||
response, detail = await self.get(f"/{positive}")
|
||||
self.assertEqual(response.status, 200, detail)
|
||||
self.assertIsNone(detail["positive"]["last_used_at"])
|
||||
response, receipt = await self.patch_parent(
|
||||
positive,
|
||||
{
|
||||
"expected_epoch": detail["library_epoch"],
|
||||
"if_match": detail["positive"]["edit_token"],
|
||||
"metadata": {"title": "Portrait"},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status, 200, receipt)
|
||||
self.assertEqual(receipt["positive"]["title"], "Portrait")
|
||||
self.assertIsNone(receipt["positive"]["last_used_at"])
|
||||
|
||||
async def test_native_parent_patch_strict_validation(self):
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
||||
"""Card facts from header records: the shared legacy parser, bounded and exact."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.card_seed import ( # noqa: E402
|
||||
MAX_RECORD_BYTES,
|
||||
card_seed,
|
||||
header_params,
|
||||
parsed_positive,
|
||||
)
|
||||
from utils.generation_params import ( # noqa: E402
|
||||
GenerationParamsError,
|
||||
extract_from_records,
|
||||
extract_generation_params,
|
||||
)
|
||||
from utils.inspection_response import ( # noqa: E402
|
||||
MAX_PROJECTION_DEPTH,
|
||||
project_graph,
|
||||
)
|
||||
|
||||
try:
|
||||
from tests.generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from generation_params_cases import CASES, encoder, ksampler # noqa: E402
|
||||
|
||||
|
||||
def graph(*samplers, extra=None):
|
||||
nodes = {"6": encoder("a lantern at dusk"), "7": encoder("blurry")}
|
||||
for index, sampler in enumerate(samplers):
|
||||
nodes[str(10 + index)] = sampler
|
||||
nodes.update(extra or {})
|
||||
return {"prompt": json.dumps(nodes)}
|
||||
|
||||
|
||||
def seeded(seed, **settings):
|
||||
return ksampler(("6", 0), ("7", 0), seed=seed, **settings)
|
||||
|
||||
|
||||
def nested_workflow(depth=900):
|
||||
"""A small workflow whose node and link ids nest ``depth`` arrays deep."""
|
||||
nested = "[" * depth + "1" + "]" * depth
|
||||
return (
|
||||
'{"nodes":[{"id":%s,"type":"KSampler","widgets_values":[1]}],'
|
||||
'"links":[[%s,1,0,2,0]]}' % (nested, nested)
|
||||
)
|
||||
|
||||
|
||||
def info_seed(info):
|
||||
return card_seed(header_params(info))
|
||||
|
||||
|
||||
class CardSeed(unittest.TestCase):
|
||||
def test_card_seed_single_sampler(self):
|
||||
info = graph(seeded(42001))
|
||||
self.assertEqual(info_seed(info), "42001")
|
||||
self.assertEqual(parsed_positive(header_params(info)), "a lantern at dusk")
|
||||
|
||||
def test_card_seed_two_different_seeds_follows_shared_parser(self):
|
||||
# Ruling: no separate heuristic; the card shows what the legacy parser
|
||||
# (and so the inspector) shows for several samplers.
|
||||
info = graph(seeded(111), seeded(222))
|
||||
expected = extract_from_records(info["prompt"], None)["seed"]
|
||||
self.assertIn(expected, (111, 222))
|
||||
self.assertEqual(info_seed(info), str(expected))
|
||||
|
||||
def test_card_seed_linked_input_follows_shared_parser(self):
|
||||
primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}}
|
||||
info = graph(seeded(["20", 0]), extra={"20": primitive})
|
||||
self.assertEqual(info_seed(info), "555")
|
||||
dangling = graph(seeded(["99", 0]))
|
||||
self.assertEqual(
|
||||
extract_from_records(dangling["prompt"], None)["seed"], "Unknown"
|
||||
)
|
||||
self.assertIsNone(info_seed(dangling))
|
||||
|
||||
def test_card_seed_advanced_sampler_noise_seed(self):
|
||||
advanced = seeded(None)
|
||||
advanced["class_type"] = "KSamplerAdvanced"
|
||||
del advanced["inputs"]["seed"]
|
||||
advanced["inputs"]["noise_seed"] = 77
|
||||
self.assertEqual(info_seed(graph(advanced)), "77")
|
||||
|
||||
def test_card_seed_oversized_record_is_none(self):
|
||||
padding = "x" * (MAX_RECORD_BYTES + 1)
|
||||
big_prompt = graph(seeded(1), extra={"30": encoder(padding)})
|
||||
self.assertGreater(len(big_prompt["prompt"]), MAX_RECORD_BYTES)
|
||||
self.assertIsNone(header_params(big_prompt))
|
||||
big_workflow = {**graph(seeded(1)), "workflow": json.dumps({"x": padding})}
|
||||
self.assertIsNone(header_params(big_workflow))
|
||||
# Bytes, not characters: two-byte text over the budget is oversized.
|
||||
wide = "é" * (MAX_RECORD_BYTES // 2 + 1)
|
||||
self.assertLessEqual(len(wide), MAX_RECORD_BYTES)
|
||||
self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})}))
|
||||
self.assertEqual(info_seed(graph(seeded(1))), "1")
|
||||
|
||||
def test_records_past_the_inspection_projection_bound_give_nothing(self):
|
||||
# The inspector hands the browser parser only graphs its projection
|
||||
# parsed; a deeper graph is "limited" there, so the card skips it too.
|
||||
self.assertEqual(
|
||||
project_graph(nested_workflow(), "workflow")["status"], "limited"
|
||||
)
|
||||
self.assertIsNone(header_params({"workflow": nested_workflow()}))
|
||||
# A limited workflow beside a parsed prompt leaves the prompt's facts.
|
||||
both = header_params({**graph(seeded(1)), "workflow": nested_workflow()})
|
||||
self.assertEqual(card_seed(both), "1")
|
||||
|
||||
def test_depth_bound_is_the_inspectors(self):
|
||||
def deep(levels):
|
||||
nested = "[" * levels + "1" + "]" * levels
|
||||
extra = '{"class_type":"Note","inputs":{"v":%s}}' % nested
|
||||
text = graph(seeded(3))["prompt"]
|
||||
return {"prompt": text[:-1] + ',"30":' + extra + "}"}
|
||||
|
||||
# root object, node object, inputs object: three levels before "v".
|
||||
inside = MAX_PROJECTION_DEPTH - 3
|
||||
self.assertEqual(info_seed(deep(inside)), "3")
|
||||
self.assertIsNone(header_params(deep(inside + 1)))
|
||||
|
||||
def test_strict_projection_rules_apply(self):
|
||||
duplicate = '{"10": {"class_type": "KSampler", "inputs": {}}, "10": {}}'
|
||||
self.assertEqual(project_graph(duplicate, "prompt")["status"], "malformed")
|
||||
self.assertIsNone(header_params({"prompt": duplicate}))
|
||||
|
||||
def test_record_size_is_utf8_bytes_at_both_bounds(self):
|
||||
frame = '{"a":"%s"}'
|
||||
ascii_at = frame % ("x" * (MAX_RECORD_BYTES - 8))
|
||||
self.assertEqual(len(ascii_at), MAX_RECORD_BYTES)
|
||||
self.assertIsNotNone(header_params({"prompt": ascii_at}))
|
||||
four_byte = frame % ("\U0001f600" * ((MAX_RECORD_BYTES - 8) // 4))
|
||||
self.assertLessEqual(len(four_byte.encode()), MAX_RECORD_BYTES)
|
||||
self.assertIsNotNone(header_params({"prompt": four_byte}))
|
||||
padded = four_byte + " " * (MAX_RECORD_BYTES - len(four_byte.encode()) + 1)
|
||||
self.assertIsNone(header_params({"prompt": padded}))
|
||||
|
||||
def test_placeholder_and_non_integer_seeds_are_none(self):
|
||||
for value in ("randomize", " 12", True, 1.5, 7.0, 1e21, None):
|
||||
with self.subTest(seed=value):
|
||||
self.assertIsNone(info_seed(graph(seeded(value))))
|
||||
self.assertIsNone(card_seed({"seed": "Unknown"}))
|
||||
self.assertIsNone(card_seed({"seed": 7.0}))
|
||||
self.assertEqual(info_seed(graph(seeded(-3))), "-3")
|
||||
|
||||
def test_digit_string_seed_is_what_the_inspector_reads(self):
|
||||
# The projection carries big integers as text and keeps text as text;
|
||||
# the inspector shows a digit string as the seed, and so does the card.
|
||||
self.assertEqual(info_seed(graph(seeded("123"))), "123")
|
||||
|
||||
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
|
||||
# JSON.parse would round it; the projection carries the record's digits.
|
||||
case = CASES["seed-beyond-double-precision"]
|
||||
rounded = extract_from_records(case["prompt"], None)["seed"]
|
||||
self.assertEqual(rounded, float(4974052037430000001))
|
||||
self.assertEqual(info_seed({"prompt": case["prompt"]}), "4974052037430000001")
|
||||
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), str(-(2**60) - 1))
|
||||
|
||||
def test_seed_is_always_a_decimal_string(self):
|
||||
# JSON.parse rounds numbers past 2**53, so every seed travels as text.
|
||||
for value in (0, 7, 2**53, 2**53 + 1, 2**64, -(2**63)):
|
||||
with self.subTest(seed=value):
|
||||
self.assertEqual(card_seed({"seed": value}), str(value))
|
||||
self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64))
|
||||
|
||||
def test_digit_string_seed_is_bounded(self):
|
||||
# 2**64 has 20 digits; a longer string is no seed, and an unbounded one
|
||||
# must not reach int() (Python's int max-str-digits raises ValueError).
|
||||
self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20)
|
||||
self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20)
|
||||
self.assertIsNone(card_seed({"seed": "1" * 21}))
|
||||
self.assertIsNone(card_seed({"seed": "9" * 5000}))
|
||||
self.assertIsNone(info_seed(graph(seeded("9" * 5000))))
|
||||
|
||||
def test_seed_conversion_error_gives_none(self):
|
||||
class Huge(int):
|
||||
def __str__(self):
|
||||
raise ValueError("Exceeds the limit for integer string conversion")
|
||||
|
||||
self.assertIsNone(card_seed({"seed": Huge(5)}))
|
||||
|
||||
def test_workflow_only_record(self):
|
||||
case = next(
|
||||
case
|
||||
for case in CASES.values()
|
||||
if case["prompt"] is None
|
||||
and case["workflow"] is not None
|
||||
and isinstance(extract_from_records(None, case["workflow"])["seed"], int)
|
||||
)
|
||||
expected = extract_from_records(None, case["workflow"])["seed"]
|
||||
self.assertEqual(info_seed({"workflow": case["workflow"]}), str(expected))
|
||||
|
||||
def test_records_the_parser_cannot_use_give_nothing(self):
|
||||
self.assertIsNone(header_params({}))
|
||||
self.assertIsNone(header_params(None))
|
||||
self.assertIsNone(header_params({"parameters": "a cat\nSteps: 20, Seed: 9"}))
|
||||
self.assertIsNone(header_params({"prompt": b"{}"}))
|
||||
self.assertIsNone(card_seed(None))
|
||||
self.assertIsNone(parsed_positive(None))
|
||||
# The browser parser throws here; the card shows nothing.
|
||||
thrown = CASES["null-workflow-node"]
|
||||
self.assertIsNone(header_params(thrown))
|
||||
unparsable = header_params(CASES["unparsable-records"])
|
||||
self.assertIsNone(card_seed(unparsable))
|
||||
self.assertIsNone(parsed_positive(unparsable))
|
||||
|
||||
def test_every_shared_case_reads_the_projected_graphs(self):
|
||||
for name, case in CASES.items():
|
||||
with self.subTest(case=name):
|
||||
info = {k: v for k, v in case.items() if v is not None}
|
||||
params = header_params(info)
|
||||
graphs = {key: project_graph(text, key) for key, text in info.items()}
|
||||
values = {
|
||||
key: graph["value"]
|
||||
for key, graph in graphs.items()
|
||||
if graph["status"] == "parsed"
|
||||
}
|
||||
if not values:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
try:
|
||||
expected = extract_generation_params(values)
|
||||
except GenerationParamsError:
|
||||
self.assertIsNone(params)
|
||||
continue
|
||||
self.assertEqual(params, expected)
|
||||
# Prompt text is the same as from a plain parse of the records.
|
||||
if len(values) == len(info):
|
||||
raw = extract_from_records(case["prompt"], case["workflow"])
|
||||
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1432,3 +1432,201 @@ class TestImagePaging(DatabaseTestCase):
|
||||
self.assertEqual(len(second), 2)
|
||||
self.assertNotEqual({img["id"] for img in first}, {img["id"] for img in second})
|
||||
self.assertEqual(len(self.db.search_images_by_prompt("paged")), 5)
|
||||
|
||||
|
||||
class TestImageLinkSummaries(DatabaseTestCase):
|
||||
"""Batched card summaries follow get_image_prompt_info's matching rules."""
|
||||
|
||||
def _statements(self, call):
|
||||
statements = []
|
||||
original = self.db.model.get_connection
|
||||
|
||||
def traced():
|
||||
connection = original()
|
||||
connection.set_trace_callback(statements.append)
|
||||
return connection
|
||||
|
||||
try:
|
||||
with patch.object(self.db.model, "get_connection", side_effect=traced):
|
||||
result = call()
|
||||
finally:
|
||||
original().set_trace_callback(None)
|
||||
selects = [s for s in statements if s.lstrip().upper().startswith("SELECT")]
|
||||
return result, selects
|
||||
|
||||
def test_summary_status_matches_image_prompt_info_for_every_rule(self):
|
||||
owner = self._save("Owner prompt\nsecond line", category="Cat", rating=4)
|
||||
rival = self._save("Rival prompt")
|
||||
exact = os.path.join("out", "one", "same.png")
|
||||
other = os.path.join("out", "two", "same.png")
|
||||
unique = os.path.join("out", "three", "unique.png")
|
||||
shared = os.path.join("out", "shared.png")
|
||||
self.db.link_image_to_prompt(owner, exact)
|
||||
self.db.link_image_to_prompt(rival, other)
|
||||
self.db.link_image_to_prompt(owner, unique)
|
||||
self.db.link_image_to_prompt(owner, shared)
|
||||
self.db.link_image_to_prompt(rival, shared)
|
||||
probes = [
|
||||
exact,
|
||||
os.path.join("out", "x", "..", "one", "same.png"),
|
||||
os.path.join("elsewhere", "same.png"),
|
||||
os.path.join("elsewhere", "unique.png"),
|
||||
shared,
|
||||
"missing.png",
|
||||
]
|
||||
for fallback in (True, False):
|
||||
summaries = self.db.get_image_link_summaries(probes, fallback)
|
||||
self.assertEqual(set(summaries), set(probes))
|
||||
for probe in probes:
|
||||
with self.subTest(probe=probe, fallback=fallback):
|
||||
info = self.db.get_image_prompt_info(
|
||||
probe, fallback, report_ambiguity=True
|
||||
)
|
||||
summary = summaries[probe]
|
||||
if info is None:
|
||||
expected = "none"
|
||||
elif info.get("association_status") == "ambiguous":
|
||||
expected = "ambiguous"
|
||||
else:
|
||||
expected = "linked"
|
||||
self.assertEqual(summary["status"], expected)
|
||||
if expected == "linked":
|
||||
self.assertEqual(summary["positive_id"], info["prompt_id"])
|
||||
else:
|
||||
self.assertEqual(
|
||||
summary,
|
||||
{
|
||||
"status": expected,
|
||||
"positive_id": None,
|
||||
"title": None,
|
||||
"title_source": None,
|
||||
"category": None,
|
||||
"rating": None,
|
||||
},
|
||||
)
|
||||
linked = self.db.get_image_link_summaries([unique])[unique]
|
||||
self.assertEqual(
|
||||
linked,
|
||||
{
|
||||
"status": "linked",
|
||||
"positive_id": owner,
|
||||
"title": "Owner prompt",
|
||||
"title_source": "text",
|
||||
"category": "Cat",
|
||||
"rating": 4,
|
||||
},
|
||||
)
|
||||
|
||||
def test_summary_title_prefers_stored_title_then_first_line(self):
|
||||
long_line = "word " * 30
|
||||
titled = self._save("Untitled text")
|
||||
plain = self._save(f" {long_line}\nsecond")
|
||||
blank = self._save("Blank title text")
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_library_state(prompt_id,title) VALUES(?,?)",
|
||||
[(titled, "Stored title"), (blank, " \u3000 ")],
|
||||
)
|
||||
paths = {}
|
||||
for name, prompt in (("titled", titled), ("plain", plain), ("blank", blank)):
|
||||
paths[name] = os.path.join("out", f"{name}.png")
|
||||
self.db.link_image_to_prompt(prompt, paths[name])
|
||||
summaries = self.db.get_image_link_summaries(list(paths.values()))
|
||||
sources = {
|
||||
name: (summaries[path]["title"], summaries[path]["title_source"])
|
||||
for name, path in paths.items()
|
||||
}
|
||||
self.assertEqual(sources["titled"], ("Stored title", "stored"))
|
||||
# Cut like the Library's title(): 80 characters, then an ellipsis.
|
||||
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
|
||||
|
||||
exact = "x" * 80
|
||||
self.assertEqual(display_title((), exact), exact)
|
||||
self.assertEqual(display_title((), exact + "y"), exact + "\u2026")
|
||||
self.assertEqual(display_title((), " short \nnext"), "short")
|
||||
self.assertIsNone(display_title((" ",), " \n"))
|
||||
self.assertEqual(display_title(("Kept ",), "text"), "Kept ")
|
||||
|
||||
def test_summary_uses_one_select_per_200_paths(self):
|
||||
prompt = self._save("Batch")
|
||||
paths = [os.path.join("out", f"{index}.png") for index in range(201)]
|
||||
for path in paths[:5]:
|
||||
self.db.link_image_to_prompt(prompt, path)
|
||||
result, selects = self._statements(
|
||||
lambda: self.db.get_image_link_summaries(paths[:200])
|
||||
)
|
||||
self.assertEqual(len(selects), 1)
|
||||
self.assertEqual(len(result), 200)
|
||||
self.assertEqual(
|
||||
[result[path]["status"] for path in paths[:6]], ["linked"] * 5 + ["none"]
|
||||
)
|
||||
_, selects = self._statements(lambda: self.db.get_image_link_summaries(paths))
|
||||
self.assertEqual(len(selects), 2)
|
||||
_, selects = self._statements(lambda: self.db.get_image_link_summaries([]))
|
||||
self.assertEqual(selects, [])
|
||||
|
||||
def test_windows_style_paths_normalize(self):
|
||||
import ntpath
|
||||
|
||||
def windows_normalize(path):
|
||||
return ntpath.normcase(ntpath.normpath(path))
|
||||
|
||||
prompt = self._save("Windows prompt")
|
||||
stored = r"C:\ComfyUI\Output\Sub\Image.PNG"
|
||||
with patch("database.operations.normalize_image_path", windows_normalize):
|
||||
self.db.link_image_to_prompt(prompt, stored)
|
||||
probes = [
|
||||
stored,
|
||||
r"c:\comfyui\output\SUB\image.png",
|
||||
r"C:\ComfyUI\Output\x\..\Sub\Image.PNG",
|
||||
"C:/ComfyUI/Output/Sub/Image.PNG",
|
||||
r"D:\ComfyUI\Output\Sub\Other.png",
|
||||
]
|
||||
summaries = self.db.get_image_link_summaries(probes, False)
|
||||
for probe in probes:
|
||||
with self.subTest(probe=probe):
|
||||
info = self.db.get_image_prompt_info(probe, False)
|
||||
expected = "linked" if info else "none"
|
||||
self.assertEqual(summaries[probe]["status"], expected)
|
||||
self.assertEqual(
|
||||
[summaries[probe]["status"] for probe in probes],
|
||||
["linked"] * 4 + ["none"],
|
||||
)
|
||||
|
||||
@@ -428,6 +428,72 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
PromptModel.verify_database_file(dst, require_library_state=True)[0]
|
||||
)
|
||||
|
||||
def test_restore_old_backup_without_favorites(self):
|
||||
self.db.set_image_favorite("output", "live.png")
|
||||
src = self._make_source_db(["predates favorites"])
|
||||
with closing(sqlite3.connect(src)) as conn:
|
||||
conn.execute("DROP TABLE image_favorites")
|
||||
conn.commit()
|
||||
self.assertTrue(PromptModel.verify_database_file(src)[0])
|
||||
|
||||
self.db.model.restore_from_file(src)
|
||||
|
||||
self.assertEqual(_texts(self.path), ["predates favorites"])
|
||||
self.assertEqual(self.db.get_image_favorites("output", ["live.png"]), set())
|
||||
self.assertTrue(self.db.set_image_favorite("output", "new.png"))
|
||||
ok, reason = PromptModel.verify_database_file(
|
||||
self.path, require_library_state=True
|
||||
)
|
||||
self.assertTrue(ok, reason)
|
||||
with closing(sqlite3.connect(src)) as conn: # the source is untouched
|
||||
self.assertIsNone(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name='image_favorites'"
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def test_restore_preserves_favorites_and_closes_handles(self):
|
||||
self.db.set_image_favorite("output", "live.png")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
src_path = os.path.join(directory, "source.db")
|
||||
src = PromptDatabase(src_path)
|
||||
try:
|
||||
self._save(src, "with favorites")
|
||||
src.set_image_favorite("output", "sub/kept.png")
|
||||
src.set_image_favorite("extra/root", "kept.png")
|
||||
finally:
|
||||
src.close_all()
|
||||
# The favorites still sit in the WAL; a backup must carry them.
|
||||
backup = os.path.join(directory, "backup.db")
|
||||
reopened = PromptDatabase(src_path)
|
||||
try:
|
||||
self.assertTrue(reopened.model.backup_database(backup))
|
||||
finally:
|
||||
reopened.close_all()
|
||||
|
||||
self.db.model.restore_from_file(backup)
|
||||
|
||||
self.assertEqual(
|
||||
self.db.get_image_favorites("output", ["sub/kept.png", "live.png"]),
|
||||
{"sub/kept.png"},
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.get_image_favorites("extra/root", ["kept.png"]), {"kept.png"}
|
||||
)
|
||||
PromptModel.close_all_instances()
|
||||
# Windows refuses to delete a file a restore left open.
|
||||
assert_closed(self, directory)
|
||||
backups = list(Path(self.tmpdir).glob("prompts.db.backup_*"))
|
||||
self.assertEqual(len(backups), 1)
|
||||
with closing(sqlite3.connect(str(backups[0]))) as conn:
|
||||
self.assertEqual(
|
||||
conn.execute(
|
||||
"SELECT root_key, relative_path FROM image_favorites"
|
||||
).fetchall(),
|
||||
[("output", "live.png")],
|
||||
)
|
||||
assert_closed(self, str(backups[0]))
|
||||
|
||||
def test_restore_without_a_live_database_returns_empty_backup_path(self):
|
||||
src = self._make_source_db(["fresh"])
|
||||
self.db.close_all()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Persisted image favorites (spec D7): store, schema and rooted API boundary."""
|
||||
|
||||
import json
|
||||
import ntpath
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
from open_handles import assert_closed
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
from tests.open_handles import assert_closed
|
||||
|
||||
from database.image_favorites import (
|
||||
MAX_KEY_LENGTH,
|
||||
TABLE,
|
||||
image_favorites_schema_error,
|
||||
migrate_image_favorites,
|
||||
)
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
FAVORITE = "/prompt_manager/images/favorite"
|
||||
|
||||
|
||||
class TestImageFavoriteApi(ImageAPITestCase):
|
||||
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 send(self, method, body):
|
||||
response = await self.client.request(method, FAVORITE, json=body)
|
||||
return response, await response.json()
|
||||
|
||||
async def workspace(self, **params):
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace", **params}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
data = await response.json()
|
||||
return {image["path"]: image for image in data["images"]}
|
||||
|
||||
async def test_favorite_round_trip_and_idempotent(self):
|
||||
make_png(self.output_dir / "sub" / "a.png")
|
||||
make_png(self.output_dir / "b.png")
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], False)
|
||||
self.assertIs(items["b.png"]["favorite"], False)
|
||||
|
||||
body = {"root_index": 0, "path": "sub/a.png"}
|
||||
for _ in range(2): # a repeated PUT is a no-op, not an error
|
||||
response, data = await self.send("PUT", body)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(
|
||||
data,
|
||||
{
|
||||
"success": True,
|
||||
"favorite": True,
|
||||
"root_index": 0,
|
||||
"path": "sub/a.png",
|
||||
},
|
||||
)
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], True)
|
||||
self.assertIs(items["b.png"]["favorite"], False)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 1)
|
||||
|
||||
for _ in range(2):
|
||||
response, data = await self.send("DELETE", body)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertIs(data["favorite"], False)
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["sub/a.png"]["favorite"], False)
|
||||
|
||||
def rows(self):
|
||||
return [
|
||||
tuple(row)
|
||||
for row in self.api.db.model.get_connection().execute(
|
||||
f"SELECT root_key, relative_path FROM {TABLE} ORDER BY 1, 2"
|
||||
)
|
||||
]
|
||||
|
||||
async def test_favorite_is_keyed_by_root_not_index(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]
|
||||
response, data = await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotIn(str(self.comfy_dir), json.dumps(data))
|
||||
root_key = Path(os.path.normcase(os.path.realpath(second))).as_posix()
|
||||
self.assertEqual(self.rows(), [(root_key, "a.png")])
|
||||
# The same root at another index keeps its favorite; the other root
|
||||
# with the same relative path does not inherit it.
|
||||
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
||||
|
||||
async def test_roots_sharing_a_folder_name_keep_separate_favorites(self):
|
||||
# Two roots under different parents, both shown as "output".
|
||||
with tempfile.TemporaryDirectory() as other_parent:
|
||||
other = Path(os.path.realpath(other_parent)) / "output"
|
||||
make_png(other / "a.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, other]
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
labels = {
|
||||
item["root_index"]: item["root_dir"]
|
||||
for item in (await response.json())["images"]
|
||||
}
|
||||
self.assertEqual(labels, {0: "output", 1: "output"})
|
||||
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], True)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], False)
|
||||
|
||||
await self.send("PUT", {"root_index": 1, "path": "a.png"})
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "a.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertNotIn(other_parent, json.dumps(data))
|
||||
self.assertIs((await self.workspace(root=0))["a.png"]["favorite"], False)
|
||||
self.assertIs((await self.workspace(root=1))["a.png"]["favorite"], True)
|
||||
listing = await self.client.get(
|
||||
"/prompt_manager/images/output", params={"mode": "workspace"}
|
||||
)
|
||||
self.assertNotIn(other_parent, await listing.text())
|
||||
|
||||
async def test_windows_case_only_rename_keeps_the_star(self):
|
||||
make_png(self.output_dir / "Sub" / "A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
response, data = await self.send(
|
||||
"PUT", {"root_index": 0, "path": "Sub/A.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data["path"], "Sub/A.png") # the listing's spelling
|
||||
(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()
|
||||
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], True)
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "SUB/A.PNG"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(data["path"], "sub/a.png")
|
||||
self.assertIs((await self.workspace())["sub/a.png"]["favorite"], False)
|
||||
self.assertEqual(self.rows(), [])
|
||||
|
||||
async def test_posix_case_distinct_files_keep_independent_stars(self):
|
||||
make_png(self.output_dir / "A.png")
|
||||
make_png(self.output_dir / "a.png")
|
||||
if len(list(self.output_dir.iterdir())) != 2:
|
||||
self.skipTest("case-insensitive filesystem")
|
||||
await self.send("PUT", {"root_index": 0, "path": "A.png"})
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["A.png"]["favorite"], True)
|
||||
self.assertIs(items["a.png"]["favorite"], False)
|
||||
|
||||
async def test_favorite_rejects_traversal_and_unknown_root(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
make_png(self.comfy_dir / "outside.png")
|
||||
(self.output_dir / "notes.txt").write_text("not media")
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
cases = [
|
||||
({"root_index": 0, "path": "../outside.png"}, 403),
|
||||
({"root_index": 0, "path": "sub/../../outside.png"}, 403),
|
||||
({"root_index": 0, "path": str(self.comfy_dir / "outside.png")}, 403),
|
||||
({"root_index": 0, "path": "/a.png"}, 403),
|
||||
({"root_index": 0, "path": "C:/a.png"}, 403),
|
||||
({"root_index": 0, "path": "a.png\0.png"}, 403),
|
||||
({"root_index": 0, "path": "notes.txt"}, 403),
|
||||
({"root_index": 1, "path": "a.png"}, 400),
|
||||
({"root_index": -1, "path": "a.png"}, 400),
|
||||
({"root_index": "0", "path": "a.png"}, 400),
|
||||
({"root_index": True, "path": "a.png"}, 400),
|
||||
({"root_index": 0}, 400),
|
||||
({"root_index": 0, "path": ""}, 400),
|
||||
({"root_index": 0, "path": 7}, 400),
|
||||
({"root_index": 0, "path": "x" * 5000 + ".png"}, 400),
|
||||
({"root_index": 0, "path": "missing.png"}, 404),
|
||||
({"root_index": 0, "path": "thumbnails/a_thumb.png"}, 404),
|
||||
]
|
||||
for method in ("PUT", "DELETE"):
|
||||
for body, status in cases:
|
||||
if method == "DELETE" and status == 404:
|
||||
continue # unlisting a stale favorite is allowed (below)
|
||||
with self.subTest(method=method, body=str(body)[:60]):
|
||||
response, data = await self.send(method, body)
|
||||
self.assertEqual(response.status, status)
|
||||
self.assertIs(data["success"], False)
|
||||
self.assertNotIn(str(self.comfy_dir), data["error"])
|
||||
response = await self.client.put(FAVORITE, data=b"[1]")
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.put(FAVORITE, data=b"{not json")
|
||||
self.assertEqual(response.status, 400)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 0)
|
||||
|
||||
async def test_delete_clears_a_favorite_whose_file_is_gone(self):
|
||||
target = make_png(self.output_dir / "gone.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "gone.png"})
|
||||
target.unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
response, data = await self.send(
|
||||
"DELETE", {"root_index": 0, "path": "gone.png"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIs(data["favorite"], False)
|
||||
count = self.api.db.model.get_connection().execute(
|
||||
f"SELECT COUNT(*) FROM {TABLE}"
|
||||
)
|
||||
self.assertEqual(count.fetchone()[0], 0)
|
||||
|
||||
async def test_legacy_listing_is_unchanged_by_favorites(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
response = await self.client.get("/prompt_manager/images/output")
|
||||
data = await response.json()
|
||||
self.assertEqual(len(data["images"]), 1)
|
||||
self.assertNotIn("favorite", data["images"][0])
|
||||
|
||||
async def test_toggle_lookup_is_built_once_per_scan(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
files = await self.api._get_gallery_files(self.output_dir)
|
||||
first = self.api._favorite_listing(self.output_dir, files)
|
||||
self.assertEqual(first, {"a.png": "a.png"})
|
||||
self.assertIs(self.api._favorite_listing(self.output_dir, files), first)
|
||||
make_png(self.output_dir / "b.png")
|
||||
self.api.invalidate_gallery_cache()
|
||||
rescanned = await self.api._get_gallery_files(self.output_dir)
|
||||
self.assertEqual(
|
||||
self.api._favorite_listing(self.output_dir, rescanned),
|
||||
{"a.png": "a.png", "b.png": "b.png"},
|
||||
)
|
||||
self.api.reset_gallery_roots_cache()
|
||||
self.assertEqual(self.api._favorite_listing_cache, {})
|
||||
|
||||
async def test_monitor_invalidation_drops_the_favorites_lookup(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertNotEqual(self.api._favorite_listing_cache, {})
|
||||
# The image monitor's invalidation must not keep the old scan alive.
|
||||
self.api.invalidate_gallery_cache()
|
||||
self.assertEqual(self.api._favorite_listing_cache, {})
|
||||
self.assertIs((await self.workspace())["a.png"]["favorite"], True)
|
||||
|
||||
async def test_put_rejects_a_key_beyond_the_stored_bound(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
too_long = "/" + "r" * MAX_KEY_LENGTH
|
||||
with patch("py.api.images._favorite_root_key", return_value=too_long):
|
||||
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertEqual(response.status, 403)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertNotIn(too_long, json.dumps(data))
|
||||
self.assertEqual(self.rows(), [])
|
||||
# The live database still validates (a stored over-long key would
|
||||
# fail startup and restore).
|
||||
conn = self.api.db.model.get_connection()
|
||||
self.assertIsNone(image_favorites_schema_error(conn))
|
||||
|
||||
async def test_favorite_store_failure_keeps_the_listing(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
self.api.db.get_image_favorites = broken
|
||||
items = await self.workspace()
|
||||
self.assertIs(items["a.png"]["favorite"], False)
|
||||
self.api.db.set_image_favorite = broken
|
||||
response, data = await self.send("PUT", {"root_index": 0, "path": "a.png"})
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertEqual(data, {"success": False, "error": "Favorite update failed"})
|
||||
|
||||
|
||||
class TestImageFavoriteSchema(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self.tmpdir, "favorites.db")
|
||||
|
||||
def tearDown(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 test_store_round_trip_and_bounded_lookup(self):
|
||||
db = PromptDatabase(self.path)
|
||||
try:
|
||||
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
||||
self.assertTrue(db.set_image_favorite("output", "a.png"))
|
||||
db.set_image_favorite("other", "a.png")
|
||||
self.assertEqual(
|
||||
db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"}
|
||||
)
|
||||
self.assertEqual(db.get_image_favorites("output", []), 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"))
|
||||
self.assertFalse(db.clear_image_favorite("output", "a.png"))
|
||||
self.assertEqual(db.get_image_favorites("output", ["a.png"]), set())
|
||||
created = db.model.get_connection().execute(
|
||||
f"SELECT created_at FROM {TABLE}"
|
||||
)
|
||||
self.assertRegex(created.fetchone()[0], r"^\d{4}-\d\d-\d\dT")
|
||||
finally:
|
||||
db.close_all()
|
||||
|
||||
def test_schema_rejects_unsupported_definitions_and_values(self):
|
||||
variants = {
|
||||
"missing": None,
|
||||
"wrong table": f"CREATE TABLE {TABLE} (x TEXT)",
|
||||
"view": f"CREATE VIEW {TABLE} AS SELECT 1",
|
||||
"nullable key": (
|
||||
f"CREATE TABLE {TABLE} (root_key TEXT, relative_path TEXT, "
|
||||
"created_at TEXT, PRIMARY KEY(root_key, relative_path))"
|
||||
),
|
||||
}
|
||||
for label, ddl in variants.items():
|
||||
with self.subTest(label):
|
||||
with closing(sqlite3.connect(":memory:")) as conn:
|
||||
if ddl:
|
||||
conn.execute(ddl)
|
||||
error = image_favorites_schema_error(conn)
|
||||
if ddl is None:
|
||||
self.assertIsNone(error)
|
||||
self.assertIsNotNone(
|
||||
image_favorites_schema_error(conn, allow_missing=False)
|
||||
)
|
||||
else:
|
||||
self.assertIsNotNone(error)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
migrate_image_favorites(conn)
|
||||
with closing(sqlite3.connect(":memory:")) as conn:
|
||||
migrate_image_favorites(conn)
|
||||
migrate_image_favorites(conn) # idempotent
|
||||
self.assertIsNone(image_favorites_schema_error(conn, allow_missing=False))
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', 'a.png', 'now')")
|
||||
self.assertIsNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', x'00ff', 'now')")
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"DELETE FROM {TABLE} WHERE typeof(relative_path)='blob'")
|
||||
conn.execute(f"INSERT INTO {TABLE} VALUES ('r', ?, 'now')", ("x" * 5000,))
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
conn.execute(f"DELETE FROM {TABLE}")
|
||||
conn.execute(f"CREATE INDEX extra ON {TABLE}(created_at)")
|
||||
self.assertIsNotNone(image_favorites_schema_error(conn))
|
||||
|
||||
def test_verify_rejects_a_backup_with_an_invalid_favorites_table(self):
|
||||
db = PromptDatabase(self.path)
|
||||
db.close_all()
|
||||
PromptModel.close_all_instances()
|
||||
with closing(sqlite3.connect(self.path)) as conn:
|
||||
conn.execute(f"DROP TABLE {TABLE}")
|
||||
conn.execute(f"CREATE TABLE {TABLE} (anything TEXT)")
|
||||
conn.commit()
|
||||
ok, reason = PromptModel.verify_database_file(self.path)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("favorites", reason.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -487,6 +487,115 @@ class TestLibrary(unittest.TestCase):
|
||||
rotate_history_epoch(self.db.model.get_connection())
|
||||
self.assertNotEqual(token(), before)
|
||||
|
||||
def test_library_items_expose_run_count_and_last_used(self):
|
||||
import datetime
|
||||
|
||||
used = self.save("Used")
|
||||
never = self.save("Never")
|
||||
self.assertTrue(self.db.record_prompt_use(used))
|
||||
self.assertTrue(self.db.record_prompt_use(used, times=2))
|
||||
legacy = self.save("Legacy naive")
|
||||
offset = self.save("Offset")
|
||||
broken = self.save("Broken")
|
||||
zulu = self.save("Zulu")
|
||||
# Parse but cannot convert to UTC: past the datetime range either way.
|
||||
floor = self.save("Floor")
|
||||
ceiling = self.save("Ceiling")
|
||||
with self.db.model.get_connection() as connection:
|
||||
for prompt_id, value in (
|
||||
(legacy, "2026-10-07 18:39:06"),
|
||||
(zulu, "2026-10-07T18:39:06Z"),
|
||||
(floor, "0001-01-01T00:00:00+01:00"),
|
||||
(ceiling, "9999-12-31T23:59:59-01:00"),
|
||||
(offset, "2026-10-07T20:39:06.250+02:00"),
|
||||
(broken, "yesterday"),
|
||||
):
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at=?,run_count=1 WHERE id=?",
|
||||
(value, prompt_id),
|
||||
)
|
||||
items = {row["id"]: row for row in self.db.list_library()["items"]}
|
||||
self.assertEqual(items[used]["run_count"], 3)
|
||||
stamp = datetime.datetime.fromisoformat(items[used]["last_used_at"])
|
||||
self.assertEqual(stamp.utcoffset(), datetime.timedelta(0))
|
||||
self.assertLess(
|
||||
abs(datetime.datetime.now(datetime.timezone.utc) - stamp),
|
||||
datetime.timedelta(minutes=5),
|
||||
)
|
||||
self.assertEqual(items[never]["run_count"], 0)
|
||||
self.assertIsNone(items[never]["last_used_at"])
|
||||
self.assertEqual(items[legacy]["last_used_at"], "2026-10-07T18:39:06+00:00")
|
||||
self.assertEqual(
|
||||
items[offset]["last_used_at"], "2026-10-07T18:39:06.250000+00:00"
|
||||
)
|
||||
self.assertIsNone(items[broken]["last_used_at"])
|
||||
self.assertEqual(items[zulu]["last_used_at"], "2026-10-07T18:39:06+00:00")
|
||||
self.assertIsNone(items[floor]["last_used_at"])
|
||||
self.assertIsNone(items[ceiling]["last_used_at"])
|
||||
self.assertIsNone(
|
||||
self.db.get_library_positive(floor)["positive"]["last_used_at"]
|
||||
)
|
||||
self.assertEqual(type(items[broken]["run_count"]), int)
|
||||
detail = self.db.get_library_positive(used)["positive"]
|
||||
self.assertEqual(detail["run_count"], 3)
|
||||
self.assertEqual(detail["last_used_at"], items[used]["last_used_at"])
|
||||
|
||||
def test_variant_total_matches_filtered_cards(self):
|
||||
archived = self.db.save_prompt_pair("Cat", "Blur", category="A")
|
||||
self.db.save_prompt_pair("Cat", "Noise", category="A")
|
||||
self.db.save_prompt_pair("Cat", "Grain", category="B")
|
||||
self.db.archive_prompt_pair(archived)
|
||||
self.db.save_prompt_pair("Dog", "Blur", category="B")
|
||||
self.save("Legacy")
|
||||
everything = self.db.list_library()
|
||||
self.assertEqual(everything["variant_total"], 3)
|
||||
self.assertEqual(
|
||||
everything["variant_total"],
|
||||
sum(row["variant_count"] for row in everything["items"]),
|
||||
)
|
||||
cats = self.db.list_library(text="Cat")
|
||||
self.assertEqual(
|
||||
(cats["total"], cats["variant_total"]),
|
||||
(1, 2),
|
||||
)
|
||||
self.assertEqual(self.db.list_library(category="B")["variant_total"], 3)
|
||||
self.assertEqual(self.db.list_library(text="nothing")["variant_total"], 0)
|
||||
first = self.db.list_library(limit=1)
|
||||
self.assertEqual(first["variant_total"], 3)
|
||||
# A later parent is past the cursor's ceiling, like the total.
|
||||
self.db.save_prompt_pair("Bird", "Blur")
|
||||
second = self.db.list_library(limit=1, after=first["next_key"])
|
||||
self.assertEqual(
|
||||
(second["total"], second["variant_total"]),
|
||||
(first["total"], 3),
|
||||
)
|
||||
|
||||
def test_edit_token_unchanged_by_usage_fields(self):
|
||||
positive = self.save("Cat", category="A", tags=["a"], rating=3)
|
||||
|
||||
def parent():
|
||||
return self.db.get_library_positive(positive)["positive"]
|
||||
|
||||
before = parent()
|
||||
listed = self.db.list_library()["items"][0]
|
||||
self.assertEqual(listed["edit_token"], before["edit_token"])
|
||||
self.db.record_prompt_use(positive, times=4)
|
||||
with self.db.model.get_connection() as connection:
|
||||
connection.execute(
|
||||
"UPDATE prompts SET last_used_at='2001-01-01T00:00:00+00:00' "
|
||||
"WHERE id=?",
|
||||
(positive,),
|
||||
)
|
||||
after = parent()
|
||||
self.assertNotEqual(
|
||||
(after["run_count"], after["last_used_at"]),
|
||||
(before["run_count"], before["last_used_at"]),
|
||||
)
|
||||
self.assertEqual(after["edit_token"], before["edit_token"])
|
||||
self.assertEqual(
|
||||
self.db.list_library()["items"][0]["edit_token"], before["edit_token"]
|
||||
)
|
||||
|
||||
def test_library_snapshot_reads_epoch_and_ceiling_in_one_select_and_ends(self):
|
||||
self.save("Cat")
|
||||
connection = self.db.model.get_connection()
|
||||
@@ -1584,3 +1693,16 @@ class TestLibrary(unittest.TestCase):
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "does not belong to this positive"):
|
||||
self.db.list_library_images(positive, pair_id=foreign, archive="all")
|
||||
|
||||
|
||||
class DisplayTitleUnits(unittest.TestCase):
|
||||
"""The first-line title is cut like the Library's JavaScript title()."""
|
||||
|
||||
def test_title_counts_utf16_units_and_never_splits_a_surrogate_pair(self):
|
||||
from database.library import display_title
|
||||
|
||||
self.assertEqual(display_title((), " short line \nsecond"), "short line")
|
||||
self.assertEqual(display_title((), "a" * 80), "a" * 80)
|
||||
self.assertEqual(display_title((), "a" * 81), "a" * 80 + "…")
|
||||
self.assertEqual(display_title((), "\U0001f600" * 80), "\U0001f600" * 40 + "…")
|
||||
self.assertEqual(display_title((), "a" * 79 + "\U0001f600b"), "a" * 79 + "…")
|
||||
|
||||
Vendored
+16
@@ -73,6 +73,22 @@ test('overlay rules on concept selectors carry a decision marker', () => {
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
// PRs C and D lint each page stylesheet they keep: a page rule on a concept selector is an override like the overlay's.
|
||||
test('kept page stylesheets mark their concept-selector overrides', () => {
|
||||
for (const name of ['images.css', 'library.css', 'inspector.css']) {
|
||||
const { violations } = lintOverlay(readFileSync(concept, 'utf8'), readFileSync(join(root, 'web/js/workspace', name), 'utf8'));
|
||||
assert.deepEqual(violations, [], name);
|
||||
}
|
||||
});
|
||||
|
||||
// #249 C8: the Images page renders the concept DOM, so the replaced production rules stay retired.
|
||||
test('images.css keeps no retired Images page rules', () => {
|
||||
const css = readFileSync(join(root, 'web/js/workspace/images.css'), 'utf8');
|
||||
for (const retired of ['.image-facts', '.image-card', '.image-grid', '.image-filters', '.image-toolbar', '.image-preview', '.image-collection', '.image-continuation']) {
|
||||
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/'))
|
||||
|
||||
+36
@@ -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,
|
||||
@@ -299,6 +317,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 +358,11 @@
|
||||
"branches": 91,
|
||||
"functions": 93,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/workspace-dialogs.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Workspace card facts from the metadata records a header open already loaded.
|
||||
|
||||
The card shows what the inspector shows, so it reads exactly what the
|
||||
inspector's browser parser reads: each ``prompt``/``workflow`` record through
|
||||
the inspection projection (``project_graph``: strict JSON, the depth and node
|
||||
bounds, big integers and decimals as text), and only graphs it parsed, through
|
||||
the shared parser (``extract_generation_params``, the Python twin of the
|
||||
browser parser the inspector fills from since #251). No separate seed
|
||||
heuristic, no graph resolution and no extra file reads. One parse feeds both
|
||||
the seed and the positive that confirms a first-line card title.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
)
|
||||
from .inspection_response import project_graph
|
||||
|
||||
__all__ = [
|
||||
"MAX_RECORD_BYTES",
|
||||
"MAX_SEED_DIGITS",
|
||||
"card_seed",
|
||||
"header_params",
|
||||
"parsed_positive",
|
||||
]
|
||||
|
||||
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
|
||||
MAX_RECORD_BYTES = 2 * 1024 * 1024
|
||||
_RECORD_KEYS = ("prompt", "workflow")
|
||||
# A digit-string seed has at most this many digits (2**64 has 20). Longer text
|
||||
# is no seed, and never reaches int(), whose max-str-digits guard raises.
|
||||
MAX_SEED_DIGITS = 20
|
||||
_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS)
|
||||
|
||||
|
||||
def _utf8_size_over(text: str, limit: int) -> bool:
|
||||
"""True when ``text`` is over ``limit`` UTF-8 bytes, encoding only if unclear.
|
||||
|
||||
A code point takes one to four bytes, so most records are decided from
|
||||
their length alone without copying them.
|
||||
"""
|
||||
if len(text) > limit:
|
||||
return True
|
||||
if len(text) * 4 <= limit:
|
||||
return False
|
||||
return len(text.encode("utf-8", "surrogatepass")) > limit
|
||||
|
||||
|
||||
def header_params(info) -> dict | None:
|
||||
"""The shared parser's result for the inspector's view of ``info``'s records.
|
||||
|
||||
``info`` is a Pillow ``Image.info`` (or the inspection read's metadata).
|
||||
A record the projection does not parse is left out, as the inspector
|
||||
leaves it out. None when no record parses, a record is not text or is
|
||||
over ``MAX_RECORD_BYTES``, or the parser cannot use the graphs.
|
||||
"""
|
||||
if not isinstance(info, Mapping):
|
||||
return None
|
||||
graphs = {}
|
||||
for key in _RECORD_KEYS:
|
||||
text = info.get(key)
|
||||
if text is None:
|
||||
continue
|
||||
if not isinstance(text, str) or _utf8_size_over(text, MAX_RECORD_BYTES):
|
||||
return None
|
||||
graph = project_graph(text, key)
|
||||
if graph["status"] == "parsed":
|
||||
graphs[key] = graph["value"]
|
||||
if not graphs:
|
||||
return None
|
||||
try:
|
||||
return extract_generation_params(graphs)
|
||||
except (GenerationParamsError, RecursionError):
|
||||
# Defence in depth: a parser failure gives no card facts, never a 500.
|
||||
return None
|
||||
|
||||
|
||||
def card_seed(params: dict | None) -> str | None:
|
||||
"""The parsed seed as a decimal string, else None.
|
||||
|
||||
Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and
|
||||
ComfyUI's randomized seeds are often that large. The projection carries
|
||||
integers beyond 2**53 as their exact digits, so a digit string is the
|
||||
recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS``
|
||||
digits. A placeholder, other or longer text, a boolean or a decimal gives
|
||||
None.
|
||||
"""
|
||||
if not isinstance(params, Mapping):
|
||||
return None
|
||||
value = params.get("seed")
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, str) and _INTEGER.match(value):
|
||||
return str(int(value))
|
||||
except ValueError:
|
||||
# Python's integer string conversion limit: no seed, never a 500.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def parsed_positive(params: dict | None) -> str | None:
|
||||
"""The parsed positive prompt text, or None for the placeholder or blank."""
|
||||
if not isinstance(params, Mapping):
|
||||
return None
|
||||
text = params.get("positivePrompt")
|
||||
if not isinstance(text, str) or text == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return text if text.strip() else None
|
||||
@@ -161,6 +161,21 @@ def _graph(value: object, *, workflow: bool = False) -> dict:
|
||||
return {"status": "parsed", "value": value}
|
||||
|
||||
|
||||
def project_graph(text: str, kind: Literal["prompt", "workflow"]) -> dict:
|
||||
"""One record's graph as the inspector projects it: ``{status, value}``.
|
||||
|
||||
``status`` is ``parsed`` (``value`` is the projected graph the browser
|
||||
parser receives), ``limited`` (past the depth or node bound) or
|
||||
``malformed``. Shared with the workspace card so both read one bound.
|
||||
"""
|
||||
try:
|
||||
return _graph(_strict_projection(text, kind=kind), workflow=kind == "workflow")
|
||||
except _ProjectionLimit:
|
||||
return {"status": "limited", "value": None}
|
||||
except (ValueError, TypeError, KeyError, OverflowError):
|
||||
return {"status": "malformed", "value": None}
|
||||
|
||||
|
||||
def _metadata(inspection: "InspectionRead | None") -> dict:
|
||||
formats = (
|
||||
{}
|
||||
@@ -213,16 +228,15 @@ def _metadata(inspection: "InspectionRead | None") -> dict:
|
||||
graph["status"] = failed or "limited"
|
||||
elif formats.get(key) == "invalid":
|
||||
graph["status"] = "malformed"
|
||||
elif carrier is None and formats.get(key) == "reserialized-json":
|
||||
graph["status"] = "limited"
|
||||
elif carrier is None:
|
||||
graph = project_graph(raw.get(key), key)
|
||||
else:
|
||||
try:
|
||||
if carrier is not None:
|
||||
value = carrier.get(key)
|
||||
if isinstance(value, str):
|
||||
value = _strict_projection(value, kind=key)
|
||||
elif formats.get(key) == "reserialized-json":
|
||||
raise _ProjectionLimit()
|
||||
else:
|
||||
value = _strict_projection(raw[key], kind=key)
|
||||
value = carrier.get(key)
|
||||
if isinstance(value, str):
|
||||
value = _strict_projection(value, kind=key)
|
||||
graph = _graph(value, workflow=key == "workflow")
|
||||
except _ProjectionLimit:
|
||||
graph["status"] = "limited"
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,15 +22,13 @@ 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 +37,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;
|
||||
|
||||
@@ -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,7 +34,7 @@ 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);
|
||||
return query.toString();
|
||||
}
|
||||
@@ -30,7 +43,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 +56,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 +76,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 +92,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 +128,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 +152,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 +160,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 +177,69 @@ 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'}`;
|
||||
}
|
||||
|
||||
/** 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,101 +1,18 @@
|
||||
.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); }
|
||||
/* The Images page renders the concept DOM (#249 PR C), so its replaced rules are retired. What remains is the side
|
||||
panel, the prompt blocks and the prompt sender, which PR D moves to the concept panel and deletes with this file. */
|
||||
.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; }
|
||||
@@ -108,15 +25,16 @@
|
||||
.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; }
|
||||
/* #249 D22 interim: production's prompt blocks keep their text, action row and 26px buttons until PR D renders the
|
||||
concept prompt block and retires these three overrides. */
|
||||
.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 { /* #249 D22 interim (PR D) */ display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ca-tools .prompt-actions button { /* #249 D22 interim (PR D) */ 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; }
|
||||
@@ -137,7 +55,3 @@
|
||||
@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; }
|
||||
}
|
||||
|
||||
+424
-134
@@ -1,14 +1,18 @@
|
||||
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, 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';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const element = document.createElement(tag);
|
||||
@@ -17,11 +21,68 @@ 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
/** Give a favorite state to the mounted Images page, or to the saved visit while none is mounted. */
|
||||
function deliverFavorite(key, favorite) {
|
||||
if (favoriteTarget) favoriteTarget(key, favorite);
|
||||
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
}
|
||||
|
||||
export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) {
|
||||
const scope = createScope();
|
||||
@@ -38,40 +99,57 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
const visit = embedded ? 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 };
|
||||
filters.tags = [...filters.tags];
|
||||
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.
|
||||
if (!embedded) 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},
|
||||
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});
|
||||
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 +162,66 @@ 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; the embedded gallery keeps its own heading.
|
||||
const caption = el('div', undefined, 'section-caption');
|
||||
const collectionHeading = embedded ? el('h2', 'Linked images') : null;
|
||||
if (collectionHeading) { collectionHeading.tabIndex = -1; caption.append(collectionHeading); }
|
||||
const count = el('span', 'Loading images…');
|
||||
if (!embedded) count.id = 'result-count';
|
||||
const captionDay = el('span');
|
||||
captionDay.hidden = true;
|
||||
caption.append(count);
|
||||
if (!embedded) caption.append(captionDay);
|
||||
const focusFallback = collectionHeading || 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);
|
||||
continuation.append(message, more, retry);
|
||||
// The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards.
|
||||
const empty = el('div', 'No matching images. Try another search or category.', 'empty');
|
||||
empty.hidden = true;
|
||||
collection.append(caption, grid);
|
||||
if (!embedded) 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;
|
||||
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.'));
|
||||
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,
|
||||
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 +230,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) {
|
||||
@@ -160,41 +250,102 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
onPreview:() => previewImage(image), 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, then D7: the star, wherever the listing reports favorites (neither on
|
||||
// Library-embedded media, which has no selection bar).
|
||||
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));
|
||||
}
|
||||
// Only the Images page receives favorite answers; Library-embedded media has no star (#249 D7).
|
||||
if (!embedded) {
|
||||
favoriteTarget = setFavorite;
|
||||
scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; });
|
||||
}
|
||||
/** 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]);
|
||||
}
|
||||
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 +376,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 +395,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 +407,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 +416,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 +431,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 +472,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')}`;
|
||||
setText(count, embedded ? `${next.loaded.toLocaleString('en-US')} loaded · ${next.total.toLocaleString('en-US')} ${noun(next.total, 'linked image')}` : countLine(next));
|
||||
if (!embedded) {
|
||||
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 (!embedded) 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 +516,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 +553,156 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}, { rootMargin: '250px' });
|
||||
observer.observe(continuation);
|
||||
}
|
||||
function updateScope() {
|
||||
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
|
||||
}
|
||||
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.'));
|
||||
}
|
||||
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);
|
||||
updateScope();
|
||||
stream.reset(filters, {load:!recovering});
|
||||
}
|
||||
/** "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) showWorkspaceToast(rescanFinished(result));
|
||||
} catch {
|
||||
// A failed rescan leaves the collection and any discovery alert as they were.
|
||||
if (!scope.signal.aborted) showWorkspaceToast(RESCAN_FAILED);
|
||||
} finally {
|
||||
rescanning = false;
|
||||
importDialog?.setRescanBusy(false);
|
||||
}
|
||||
}
|
||||
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) renderLoadStatus(); });
|
||||
if (visit) {
|
||||
renderState(stream.state);
|
||||
const selected = stream.state.items.find(image => imageKey(image) === selection);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { mountVocabulary } from './vocabulary.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { bindCanvasLink, canvasHref } from './topbar.mjs';
|
||||
import { renderHeading } from './heading.mjs';
|
||||
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -85,6 +86,8 @@ function mountWorkspace() {
|
||||
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();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
.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; }
|
||||
#viewer-details .file-inspector-inner { padding: 0; }
|
||||
#viewer-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; }
|
||||
@@ -36,7 +35,7 @@
|
||||
.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; }
|
||||
.ca-tools #viewer-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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -28,7 +28,14 @@ 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;}
|
||||
function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;}
|
||||
// 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();
|
||||
if (first.length<=80) return first;
|
||||
const cut=first.slice(0,80);
|
||||
return `${/[\uD800-\uDBFF]$/.test(cut)?cut.slice(0,-1):cut}…`;
|
||||
}
|
||||
const title=libraryTitle;
|
||||
/** The full stored title; the first-line fallback only when it is blank. */
|
||||
function displayTitle(positive) {return typeof positive.title==='string' && positive.title.trim()?positive.title:title(positive.text);}
|
||||
function focusHeading(main) {const heading=main.querySelector('h1');if(!heading)return;heading.tabIndex=-1;heading.focus();}
|
||||
|
||||
@@ -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,13 +68,16 @@ 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(),
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** The shell's shared toast (`#workspace-toast`, role="status"), used by every workspace page. */
|
||||
const TOAST_MS = 6500;
|
||||
let toastTimer;
|
||||
|
||||
/** Show one outcome in the shared workspace toast; 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);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
|
||||
* Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
|
||||
* `select#sort`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options first and a
|
||||
* page's extra ones after them (D14); there is no `Save view` until saved views are persisted (D16).
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
|
||||
/** The search waits this long after the last keystroke before it reports a change. */
|
||||
export const SEARCH_DELAY = 250;
|
||||
|
||||
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
|
||||
// other label is one of the page's appended options.
|
||||
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
|
||||
|
||||
const isText = value => typeof value === 'string';
|
||||
|
||||
/** `sorts` as [value, label] pairs, the concept's options first in its order, then the rest in the given order (D14). */
|
||||
export function orderSorts(sorts) {
|
||||
if (!Array.isArray(sorts) || sorts.length === 0 ||
|
||||
sorts.some(pair => !Array.isArray(pair) || pair.length !== 2 || !isText(pair[0]) || !isText(pair[1]))) {
|
||||
throw new TypeError('Sorts must be a non-empty list of [value, label] pairs');
|
||||
}
|
||||
const rank = ([, label]) => {
|
||||
const index = CONCEPT_SORT_LABELS.indexOf(label);
|
||||
return index === -1 ? CONCEPT_SORT_LABELS.length : index;
|
||||
};
|
||||
return sorts.map((pair, index) => ({ pair, index }))
|
||||
.sort((a, b) => rank(a.pair) - rank(b.pair) || a.index - b.index)
|
||||
.map(({ pair }) => [...pair]);
|
||||
}
|
||||
|
||||
function option(value, label) {
|
||||
const node = document.createElement('option');
|
||||
node.value = value;
|
||||
node.textContent = label;
|
||||
return node;
|
||||
}
|
||||
|
||||
function categoryOptions(categories) {
|
||||
if (!Array.isArray(categories) || categories.some(value => !isText(value))) {
|
||||
throw new TypeError('Categories must be a list of strings');
|
||||
}
|
||||
const values = [...new Set(categories.filter(Boolean))];
|
||||
return [option('', 'All categories'), ...values.map(value => option(value, value))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
|
||||
* value after a select change or, debounced, after typing; `onFilters(trigger)` opens the page's "Refine this view".
|
||||
* The toolbar leaves `container` when `scope` is disposed. Returns `{element, value, setValue, setCategories}`.
|
||||
*/
|
||||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, onChange, onFilters }) {
|
||||
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
|
||||
throw new TypeError('The toolbar needs the page scope');
|
||||
}
|
||||
if (typeof onChange !== 'function' || typeof onFilters !== 'function') {
|
||||
throw new TypeError('The toolbar needs onChange and onFilters');
|
||||
}
|
||||
const root = document.createElement('search');
|
||||
root.className = 'toolbar';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'search-field';
|
||||
label.htmlFor = 'search';
|
||||
const search = document.createElement('input');
|
||||
search.id = 'search';
|
||||
search.type = 'search';
|
||||
search.placeholder = 'Search prompts, tags, filenames…';
|
||||
// The listing API rejects longer search text (py/api/images.py _workspace_filters).
|
||||
search.maxLength = 500;
|
||||
search.setAttribute('aria-label', 'Search workspace');
|
||||
label.append(createWorkspaceIcon('search'), search);
|
||||
|
||||
const category = document.createElement('select');
|
||||
category.id = 'category-filter';
|
||||
category.setAttribute('aria-label', 'Filter by category');
|
||||
category.replaceChildren(...categoryOptions(categories));
|
||||
|
||||
const sort = document.createElement('select');
|
||||
sort.id = 'sort';
|
||||
sort.setAttribute('aria-label', 'Sort results');
|
||||
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
|
||||
|
||||
const filters = createButton('Filters');
|
||||
root.append(label, category, sort, filters);
|
||||
|
||||
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
|
||||
function setValue(next) {
|
||||
if (isText(next?.q)) search.value = next.q;
|
||||
if (isText(next?.category)) category.value = next.category;
|
||||
if (isText(next?.sort)) sort.value = [...sort.options].some(node => node.value === next.sort) ? next.sort : sort.options[0].value;
|
||||
}
|
||||
function setCategories(next) {
|
||||
const selected = category.value;
|
||||
category.replaceChildren(...categoryOptions(next));
|
||||
category.value = [...category.options].some(node => node.value === selected) ? selected : '';
|
||||
}
|
||||
setValue(value);
|
||||
|
||||
const report = () => onChange(read());
|
||||
scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
|
||||
scope.listen(category, 'change', report);
|
||||
scope.listen(sort, 'change', report);
|
||||
scope.listen(filters, 'click', () => onFilters(filters));
|
||||
scope.cleanup(() => root.remove());
|
||||
|
||||
container.append(root);
|
||||
return { element: root, value: read, setValue, setCategories };
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* The concept's shared page dialogs in its one modal shell (docs/concepts/workspace/index.html `dialog#modal`:
|
||||
* `.dialog-header` > `h2#modal-title` + `×` "Close dialog", then `#modal-body`). Opening a dialog while one is open
|
||||
* swaps its content, as the concept's `dialog()` does. Closing, by ×, Escape or an action, returns focus to the
|
||||
* control that opened it.
|
||||
*
|
||||
* - "Refine this view" (a1348–1364): Source / Folder / Media / Tags, each optional per page; "Apply filters" and no
|
||||
* mock-status note because every field is a real filter, and no "Prompt evidence" field because evidence is only
|
||||
* known per file (D14). Clear also clears the toolbar category, as the concept's does; the page applies it.
|
||||
* - "Import & inspect" (a1483–1491): only the real operations, Inspect a local image and Scan an output folder →
|
||||
* Rescan; no "Import portable recipes" (D15). Rescan shows its running state (aria-disabled "Rescanning…").
|
||||
* - "Copy prompt" (a1223–1241): the select-to-copy fallback when the clipboard is unavailable (D22).
|
||||
* - One-value input (a1138 `simpleInput`): a "Category"-style field (`input#action-input`) and Cancel / a primary
|
||||
* action; used by the Images selection bar's "Set category" (#249 D8).
|
||||
*
|
||||
* Every opener takes the page `scope`: disposing it closes and removes the shell, so a dialog never outlives its page.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
|
||||
const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' });
|
||||
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||||
|
||||
let shell = null;
|
||||
let session = null;
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
function field(label, control, id) {
|
||||
const wrapper = el('div', undefined, 'field');
|
||||
const caption = el('label', label);
|
||||
caption.htmlFor = id;
|
||||
control.id = id;
|
||||
wrapper.append(caption, control);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function createShell() {
|
||||
const dialog = el('dialog');
|
||||
dialog.id = 'modal';
|
||||
dialog.setAttribute('aria-labelledby', 'modal-title');
|
||||
const header = el('div', undefined, 'dialog-header');
|
||||
const title = el('h2');
|
||||
title.id = 'modal-title';
|
||||
const close = createButton('×');
|
||||
close.id = 'modal-close';
|
||||
close.setAttribute('aria-label', 'Close dialog');
|
||||
const body = el('div');
|
||||
body.id = 'modal-body';
|
||||
header.append(title, close);
|
||||
dialog.append(header, body);
|
||||
close.addEventListener('click', () => finish());
|
||||
dialog.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
finish();
|
||||
});
|
||||
// A browser may still close a modal on its own; that is a dismissal too. `close` arrives as a later task, so it
|
||||
// is ignored once this shell was reopened or replaced.
|
||||
dialog.addEventListener('close', () => {
|
||||
if (shell?.dialog === dialog && !dialog.open) finish();
|
||||
});
|
||||
return { dialog, title, body, close };
|
||||
}
|
||||
|
||||
/** Close the open dialog, if any, and focus its trigger while that is still on the page. */
|
||||
function finish() {
|
||||
if (!session) return;
|
||||
const { trigger } = session;
|
||||
session = null;
|
||||
if (shell?.dialog.open) shell.dialog.close();
|
||||
if (trigger?.isConnected) trigger.focus();
|
||||
}
|
||||
|
||||
const boundScopes = new WeakSet();
|
||||
|
||||
function requireScope(scope) {
|
||||
if (typeof scope?.cleanup !== 'function') throw new TypeError('Workspace dialogs need the page scope');
|
||||
}
|
||||
|
||||
function requireCallbacks(callbacks) {
|
||||
for (const [name, callback] of Object.entries(callbacks)) {
|
||||
if (typeof callback !== 'function') throw new TypeError(`The dialog needs ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disposing a page scope closes the shell unless a newer page's dialog is open in it. */
|
||||
function bindScope(scope) {
|
||||
if (boundScopes.has(scope)) return;
|
||||
boundScopes.add(scope);
|
||||
scope.cleanup(() => {
|
||||
boundScopes.delete(scope);
|
||||
if (!session || session.scope === scope) closeWorkspaceDialog();
|
||||
});
|
||||
}
|
||||
|
||||
function show(heading, nodes, { scope, trigger, focusTarget }) {
|
||||
shell ??= createShell();
|
||||
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
|
||||
bindScope(scope);
|
||||
session = { scope, trigger: trigger ?? session?.trigger ?? null };
|
||||
shell.title.textContent = heading;
|
||||
shell.body.replaceChildren(...nodes);
|
||||
if (!shell.dialog.open) shell.dialog.showModal();
|
||||
(focusTarget ?? shell.close).focus();
|
||||
}
|
||||
|
||||
/** Close any open workspace dialog and remove the shell; a page calls this when it unmounts. */
|
||||
export function closeWorkspaceDialog() {
|
||||
finish();
|
||||
shell?.dialog.remove();
|
||||
shell = null;
|
||||
}
|
||||
|
||||
function fillSelect(select, key, options) {
|
||||
if (!Array.isArray(options) || options.length === 0 ||
|
||||
options.some(pair => !Array.isArray(pair) || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')) {
|
||||
throw new TypeError(`The ${key} filter needs [value, label] options`);
|
||||
}
|
||||
select.replaceChildren(...options.map(([value, label]) => {
|
||||
const node = el('option', label);
|
||||
node.value = value;
|
||||
return node;
|
||||
}));
|
||||
}
|
||||
|
||||
function selectControl(key, options) {
|
||||
const select = el('select');
|
||||
fillSelect(select, key, options);
|
||||
return select;
|
||||
}
|
||||
|
||||
/** Show `value` when the select offers it, else its first (cleared) option. */
|
||||
function choose(select, value) {
|
||||
const known = [...select.options].some(option => option.value === value);
|
||||
select.value = known ? value : select.options[0].value;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Refine this view". `fields` lists the page's filters in any subset of Source, Folder, Media and Tags, as
|
||||
* `{key, options}` with `[value, label]` options for the selects (the first is the cleared value) and no options for
|
||||
* Tags; Folder may add `optionsFor(source)`, the options of one Source, so its list follows the chosen Source and a
|
||||
* folder that Source lacks returns to the first option. `values` holds the current value per key. Apply reports the edited values; Clear resets every field and
|
||||
* reports the cleared values plus `category: ''`, so the page clears its toolbar category too. Both close first.
|
||||
*/
|
||||
export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, trigger = null }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onApply, onClear });
|
||||
if (!Array.isArray(fields) || fields.some(item => !FIELD_ORDER.includes(item?.key))) {
|
||||
throw new TypeError('Refine fields are source, folder, media and tags');
|
||||
}
|
||||
const controls = FIELD_ORDER.flatMap(key => {
|
||||
const item = fields.find(candidate => candidate.key === key);
|
||||
if (!item) return [];
|
||||
if (key === 'tags') {
|
||||
const input = el('input');
|
||||
input.placeholder = 'e.g. cinematic';
|
||||
input.value = typeof values.tags === 'string' ? values.tags : '';
|
||||
return [{ key, control: input, cleared: '' }];
|
||||
}
|
||||
const select = selectControl(key, item.options);
|
||||
choose(select, values[key]);
|
||||
return [{ key, control: select, cleared: item.options[0][0], optionsFor: item.optionsFor }];
|
||||
});
|
||||
const source = controls.find(item => item.key === 'source');
|
||||
const folder = controls.find(item => item.key === 'folder' && typeof item.optionsFor === 'function');
|
||||
const followSource = () => {
|
||||
const chosen = folder.control.value;
|
||||
fillSelect(folder.control, 'folder', folder.optionsFor(source.control.value));
|
||||
choose(folder.control, chosen);
|
||||
};
|
||||
if (source && folder) {
|
||||
followSource();
|
||||
source.control.addEventListener('change', followSource);
|
||||
}
|
||||
const read = () => Object.fromEntries(controls.map(({ key, control }) =>
|
||||
[key, key === 'tags' ? control.value.trim() : control.value]));
|
||||
|
||||
const clear = createButton('Clear filters');
|
||||
const apply = createButton('Apply filters', 'ca-primary');
|
||||
clear.addEventListener('click', () => {
|
||||
// The cleared Source's folders first, so the cleared Folder value is one of them.
|
||||
if (source && folder) { source.control.value = source.cleared; followSource(); }
|
||||
for (const { control, cleared } of controls) control.value = cleared;
|
||||
const reset = read();
|
||||
finish();
|
||||
onClear({ ...reset, category: '' });
|
||||
});
|
||||
apply.addEventListener('click', () => {
|
||||
const applied = read();
|
||||
finish();
|
||||
onApply(applied);
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(clear, apply);
|
||||
show('Refine this view', [
|
||||
...controls.map(({ key, control }) => field(FIELD_LABELS[key], control, `filter-${key}`)),
|
||||
actions,
|
||||
], { scope, trigger });
|
||||
}
|
||||
|
||||
/** One operation row; a press on its busy (aria-disabled) action is ignored and keeps the dialog open. */
|
||||
function operation(title, description, label, onAction) {
|
||||
const row = el('div', undefined, 'operation');
|
||||
const text = el('div');
|
||||
text.append(el('h3', title), el('p', description));
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const action = createButton(label);
|
||||
action.addEventListener('click', () => {
|
||||
if (action.getAttribute('aria-disabled') === 'true') return;
|
||||
finish();
|
||||
onAction();
|
||||
});
|
||||
actions.append(action);
|
||||
row.append(text, actions);
|
||||
return { row, action };
|
||||
}
|
||||
|
||||
const RESCAN_LABEL = 'Rescan';
|
||||
const RESCAN_BUSY_LABEL = 'Rescanning…';
|
||||
|
||||
/**
|
||||
* "Import & inspect" with the two real operations (D15). While `rescanBusy`, Rescan reads "Rescanning…" and is
|
||||
* aria-disabled, so a second press is visibly ignored. Returns `{setRescanBusy}` for the page to end that state.
|
||||
*/
|
||||
export function openImportDialog({ scope, onInspect, onRescan, trigger = null, rescanBusy = false }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onInspect, onRescan });
|
||||
const list = el('div', undefined, 'operation-list');
|
||||
const inspect = operation('Inspect a local image', 'Read embedded metadata before deciding to add it.', 'Open inspector', onInspect);
|
||||
const rescan = operation('Scan an output folder', 'Find files added or removed in the output folders.', RESCAN_LABEL, onRescan);
|
||||
list.append(inspect.row, rescan.row);
|
||||
function setRescanBusy(busy) {
|
||||
rescan.action.textContent = busy ? RESCAN_BUSY_LABEL : RESCAN_LABEL;
|
||||
if (busy) rescan.action.setAttribute('aria-disabled', 'true');
|
||||
else rescan.action.removeAttribute('aria-disabled');
|
||||
}
|
||||
setRescanBusy(rescanBusy);
|
||||
show('Import & inspect', [
|
||||
el('p', 'Image inspection and output scanning have different effects. Choose the workflow you want.', 'small'),
|
||||
list,
|
||||
], { scope, trigger });
|
||||
return { setRescanBusy };
|
||||
}
|
||||
|
||||
/** "Copy prompt": the text in a readonly, pre-selected textarea for a manual copy. */
|
||||
export function openCopyFallback(text, { scope, trigger = null } = {}) {
|
||||
requireScope(scope);
|
||||
if (typeof text !== 'string') throw new TypeError('Copy text must be a string');
|
||||
const area = el('textarea');
|
||||
area.readOnly = true;
|
||||
area.value = text;
|
||||
show('Copy prompt', [
|
||||
field('Select and copy this text', area, 'copy-text'),
|
||||
el('p', 'Automatic clipboard access requires a secure browser context.', 'small'),
|
||||
], { scope, trigger, focusTarget: area });
|
||||
area.select();
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
|
||||
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field.
|
||||
* A positive `maxLength` limits the field.
|
||||
*/
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, onApply, trigger = null }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onApply });
|
||||
for (const [name, text] of Object.entries({ title, label, action })) {
|
||||
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
|
||||
}
|
||||
const input = el('input');
|
||||
input.value = typeof value === 'string' ? value : '';
|
||||
if (Number.isSafeInteger(maxLength) && maxLength > 0) input.maxLength = maxLength;
|
||||
const cancel = createButton('Cancel');
|
||||
const apply = createButton(action, 'ca-primary');
|
||||
cancel.addEventListener('click', () => finish());
|
||||
apply.addEventListener('click', () => {
|
||||
const entered = input.value.trim();
|
||||
if (!entered) { input.focus(); return; }
|
||||
finish();
|
||||
onApply(entered);
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, apply);
|
||||
show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input });
|
||||
}
|
||||
@@ -54,20 +54,96 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.vocabulary-main td .row-actions { width: min-content; margin-inline-start: auto; }
|
||||
}
|
||||
|
||||
/* Images (#249 C2). Production-only continuation states the concept never shows: a failed page and a load in flight. */
|
||||
.infinite-continuation [role="alert"] { color: var(--ca-error-text); }
|
||||
.infinite-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; }
|
||||
/* #249 D14: the scope chip names real sources and folders, which can be far longer than "All output folders". */
|
||||
.filter-row span.chip { max-width: 100%; min-width: 0; overflow-wrap: anywhere; }
|
||||
/* #249 D32: at extreme zoom the 390px shell is narrower than the concept's three layout buttons; they wrap instead of
|
||||
scrolling the page sideways (they never wrap while they fit). */
|
||||
.segmented { flex-wrap: wrap; max-width: 100%; }
|
||||
/* #249 D32: likewise the continuation's text and Load more wrap instead of pushing the button out of the column. */
|
||||
.infinite-continuation { flex-wrap: wrap; }
|
||||
|
||||
/* #249 D18: the concept zooms a hovered card image (1.03 over 0.2s; its reduced-motion rule makes that instant). A touch
|
||||
device has no real hover, so a tap must not leave the zoom stuck. */
|
||||
@media (hover: none) { .image-open:hover img { transform: none; /* D18 */ } }
|
||||
|
||||
/* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every
|
||||
layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is
|
||||
absolute. Columns and gap still come from the concept's `.image-grid` tracks. */
|
||||
.image-grid { position: relative; /* D3 */ }
|
||||
.image-grid > .media-card { position: absolute; }
|
||||
/* #249 D3: a list row's info keeps its own height so it can be measured; centred, it looks as the stretched concept row.
|
||||
#249 D31: it may also shrink below a long nowrap title (the h3 ellipsis then applies, as in the grid). */
|
||||
.list-grid .media-info { align-self: center; min-width: 0; /* D3, D31 */ }
|
||||
/* #249 D31: at extreme zoom a list row is narrower than the concept's 160px art; the art yields so the info stays inside. */
|
||||
.list-grid .media-art { width: min(160px, 45%); /* D31 */ }
|
||||
/* #249 D31: at extreme zoom the subtitle wraps inside its column instead of running under the Inspect button. */
|
||||
.media-info p { overflow-wrap: anywhere; /* D31 */ }
|
||||
/* #249 D31: below the narrowest bounded card (120px, reached only at extreme zoom; images.mjs marks the grid) the info
|
||||
row stacks its text above Inspect, so neither the title nor the subtitle collapses to nothing beside the button. */
|
||||
.narrow-cards .media-info { flex-wrap: wrap; }
|
||||
.narrow-cards .media-info > div { flex: 1 0 100%; }
|
||||
/* A list row beside its art keeps room for Inspect. */
|
||||
.narrow-cards.list-grid .media-art { width: 40%; }
|
||||
.narrow-cards.list-grid .media-info { padding-inline: 4px; }
|
||||
|
||||
/* Legacy video media (the mock does not drop it) takes the concept's image rules in each layout. */
|
||||
.image-open video { display: block; width: 100%; height: auto; aspect-ratio: 1.33; object-fit: cover; }
|
||||
.masonry-grid .image-open video { aspect-ratio: var(--media-ratio); object-fit: contain; }
|
||||
.list-grid .image-open video { height: 95px; aspect-ratio: auto; }
|
||||
|
||||
/* Images viewer (#249 C6): the concept's `#viewer-dialog` shell (s1008–1058, ≤700 s1456–1474). #249 D34: real media,
|
||||
titles and states that the concept's one demo image never shows. */
|
||||
/* #249 D34: a real title can wrap (long linked titles and filenames). Above 700px the dialog stacks its header and the
|
||||
concept's `calc(100% - 72px)` layout in a column, so a taller header shrinks the layout instead of pushing the toolbar
|
||||
out of view; a one-line title gives the concept's boxes. */
|
||||
@media (min-width: 701px) {
|
||||
.ca-tools #viewer-dialog[open] { display: flex; flex-direction: column; /* D34 */ }
|
||||
}
|
||||
/* #249 D34: under 200% page zoom the concept's 94dvh outgrows the viewport; the dialog keeps within it. */
|
||||
.ca-tools #viewer-dialog { max-height: min(94dvh, 94%); /* D34 */ }
|
||||
#viewer-dialog .dialog-header > div { min-width: 0; }
|
||||
#viewer-title { overflow-wrap: anywhere; /* D34 */ }
|
||||
#viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; }
|
||||
#viewer-details { overscroll-behavior: contain; /* D34 */ }
|
||||
/* #249 D34: Viewer.js's stylesheet styles every `.viewer-toolbar` (`position: absolute; bottom: 0; left: 0; right: 0`),
|
||||
which lifts the concept's toolbar out of `.viewer-left` to span the whole dialog over the details' last lines. It stays
|
||||
in the column under the stage, where the concept's `.viewer-left` flex column and border place it. */
|
||||
#viewer-toolbar { position: static; /* D34 */ }
|
||||
/* #249 D34: Viewer.js's own toolbar stays hidden (`toolbar: false`); the concept's `.viewer-toolbar` rule would show it. */
|
||||
.viewer-container .viewer-hide { display: none; /* D34 */ }
|
||||
/* #249 D34: the stage is focusable for arrow-key navigation; a video plays with native controls, an image without
|
||||
Viewer.js shows itself (the concept hides it under the viewer), and a load failure covers the stage with Retry. */
|
||||
#viewer-stage:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; }
|
||||
#viewer-stage > :is(video, img:not([hidden])) { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: contain; visibility: visible; /* D34 */ }
|
||||
.media-failure { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; }
|
||||
/* #249 D34: Download stays the original file's link, drawn as the concept's toolbar button. */
|
||||
#viewer-toolbar > .media-download { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 4px 10px; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-control); color: var(--ca-text); font-size: 12px; white-space: nowrap; /* D34 */ }
|
||||
#viewer-toolbar > .media-download:hover:not([aria-disabled="true"]) { background: var(--ca-hover); }
|
||||
#viewer-toolbar > [aria-disabled="true"] { opacity: .55; cursor: not-allowed; }
|
||||
/* #249 D34: after Next → the position status and the unavailability reasons, each on its own centred line. */
|
||||
#viewer-toolbar > p { flex-basis: 100%; margin: 0; text-align: center; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
@media (pointer: coarse) {
|
||||
/* #249 D34: coarse pointers keep 40px square viewer targets (the concept's "−" and "+" are narrower). */
|
||||
#viewer-dialog :is(.dialog-header > button, #viewer-toolbar > button, #viewer-toolbar > a) { min-width: 40px; min-height: 40px; /* D34 */ }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
#viewer-toolbar > .media-download { font-size: 10px; /* D34: as the concept's `.viewer-toolbar button` */ }
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.panel { background: var(--ca-surface); border: 1px solid var(--line); } /* PR D: Images side panel (padding and radius in images.css) becomes `.detail-panel` */
|
||||
.ca-tools dialog.image-preview { width: min(1500px, 96%); max-height: 94%; padding: 18px; overflow: hidden; } /* PR C: viewer becomes `#viewer-dialog` */
|
||||
.ca-tools dialog.prompt-sender { width: min(760px, calc(100% - 32px)); max-width: none; max-height: calc(100dvh - 32px); padding: 22px; border-color: var(--ca-line); border-radius: 12px; } /* PR D */
|
||||
.ca-tools dialog.prompt-sender::backdrop { background: #000b; } /* PR D */
|
||||
.ca-tools dialog.recorded-run-dialog { width: min(1100px, calc(100% - 32px)); max-width: calc(100% - 32px); max-height: calc(100dvh - 32px); padding: 22px; border-radius: 7px; } /* PR F */
|
||||
pre.prompt-text { background: none; padding: 0; border: 0; border-radius: 0; max-height: none; overflow: visible; } /* PR D: prompt text becomes `p.prompt-text` */
|
||||
.viewer-container .viewer-hide { display: none; } /* PR C: the concept's own `.viewer-toolbar` would reveal Viewer.js's hidden toolbar */
|
||||
.ca-tools .prompt-actions .ca-icon-button { min-width: 30px; } /* PR D: the concept's 28px prompt action would shrink the toolkit icon button */
|
||||
@media (pointer: coarse) {
|
||||
.ca-tools .recorded-history .history-toolbar button { min-height: 44px; } /* PR F: concept coarse buttons are 40px */
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.ca-tools dialog.image-preview { overflow: auto; } /* PR C */
|
||||
.ca-tools dialog.recorded-run-dialog { padding: 16px; } /* PR F */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user