Merge remote-tracking branch 'origin/main' into feat/249-inspect-page
# Conflicts: # tests/js/workspace-file-inspector.test.js # tests/js/workspace-inspection-state.test.js
This commit is contained in:
+203
-13
@@ -292,8 +292,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
|
||||
only the concept content: cover, title, filename, the evidence badge with a category
|
||||
chip, the + and − prompt blocks with copy and send, tag chips (the linked positive's
|
||||
own tags filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
chip, the + and − prompt blocks with copy and send, tag chips (the file's own image
|
||||
tags first, D88, then the linked tags; image tags and the linked positive's own tags
|
||||
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
|
||||
content without the cover, then "File & provenance" (the honest file facts, the
|
||||
@@ -769,8 +770,12 @@ Recorded shell deviations:
|
||||
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
slice.
|
||||
On the Images cards (#249 C2) a card's info row stacks its text above Info and Tag
|
||||
once the row is narrower than the narrowest bounded card (120px; a list row's info
|
||||
is what its art leaves), and the two buttons wrap when even that row cannot hold
|
||||
them (`stacksCardInfo`, `tests/js/workspace-image-layout.test.js`).
|
||||
- **#245 D2 (retired, #249 C2)**: the Tags heading shows the concept's "Auto-tag images";
|
||||
it opens the Tag image dialog (D115 when nothing is selected).
|
||||
- **Brand name**: not a deviation. The brand link's accessible name matches the
|
||||
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
|
||||
either. The inventory compares `brand.name` and `brand.mark.hidden`.
|
||||
@@ -793,13 +798,15 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
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.
|
||||
seeds two seedless files (`seedless_images` on `/__test__/concept/reset`, with
|
||||
`short_seedless` so their "96 × 72 · PNG" subtitle stays on one line beside Info and
|
||||
Tag) and asserts that every row reserves the tallest footer and that their own footer
|
||||
is shorter than the row it reserves.
|
||||
|
||||
### Images page controls and recorded deviations (#249 PR C)
|
||||
|
||||
@@ -885,7 +892,9 @@ Recorded deviations on this page (spec §8):
|
||||
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.
|
||||
- **D9 (retired, #249 C2)**: every card shows the concept's Tag (`tagImage`, "Tag or
|
||||
auto-tag <filename>") after Info; it opens the Tag image dialog without selecting
|
||||
the card (see "Tag image dialog" below).
|
||||
- **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.
|
||||
@@ -943,6 +952,8 @@ Recorded deviations on this page (spec §8):
|
||||
history appear only with resolved prompt roles or a saved link; a video file keeps
|
||||
`video.detail-cover`; and only the linked positive's own tags are `button.chip`
|
||||
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
|
||||
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
|
||||
and are filters too; a linked tag already shown (case-insensitive) is left out.
|
||||
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
|
||||
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
|
||||
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
|
||||
@@ -1049,6 +1060,186 @@ npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Tag image dialog and recorded deviations (#249 PR C)
|
||||
|
||||
Every Images card and every Library-embedded gallery card shows Info then the
|
||||
concept's Tag (`app.js` mediaCard). Tag opens the concept's **Tag image** dialog
|
||||
(`web/js/workspace/image-tagging.mjs` over the C1 controller
|
||||
`image-tagging-state.mjs`) in the shared modal shell with the concept's
|
||||
`tagging-modal` class, so its `.dialog-actions` stay sticky while the content
|
||||
scrolls. Focus starts on × and returns to the Tag button. The preview is the
|
||||
capture's own `preview_url` (through `ApiClient.resolveUrl`): an `img`, or a
|
||||
`video preload="metadata" muted`. The capture is released on ×, Escape, Cancel,
|
||||
Save, a route change (the shell closes its dialog) and `pagehide`, which closes the
|
||||
dialog (a page restored from the back/forward cache shows no dead dialog); the
|
||||
release `DELETE` and the Save `PUT` are sent with `keepalive` (`ApiClient`'s
|
||||
additive option), so they finish while the page unloads. A save still in flight
|
||||
when the dialog closes keeps its capture until the `PUT` settles, then releases it
|
||||
and still reports the outcome: the saved toast and every page's refresh, or the
|
||||
failure as a toast. Generate is explicit and uses the server's real defaults
|
||||
(`GET /prompt_manager/autotag/models`), and the fields show exactly those values
|
||||
(they are re-rendered when the observation lands and on a model change, never while
|
||||
typing); an invalid field reports native validity and sends nothing. A tag conflict
|
||||
shows the current set at once: the status line appends "Saved now: <tags>." and the
|
||||
manual field holds the current set ahead of the draft (repeats dropped), so the next
|
||||
Save sends what is shown against the current revision. Save writes the manual
|
||||
names, then the remaining chips, deduplicated case-insensitively (first spelling
|
||||
wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." even
|
||||
for one tag, plus " Prompt association still awaits review." from Needs review.
|
||||
Tagging one file never changes another file (an equal-byte copy included), a
|
||||
prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
|
||||
changed storage or file, expired capture, unconfirmed save, unavailable model,
|
||||
video) are text in `#tag-generation-status` only; **Retry** (D116) leads the
|
||||
actions while the controller allows it, where the other workspace dialogs place
|
||||
their Retry. The Tags
|
||||
page re-reads its lists after every confirmed save, so the Images counts follow.
|
||||
|
||||
The image-tagging plan's deviation numbers were renumbered by +23 because the
|
||||
Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68 → D91).
|
||||
|
||||
- **D83** (plan D60, ruling T4): the suggestions are removable chips instead of
|
||||
checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's
|
||||
checked-row tokens (accent border, `--soft-blue`), each with the name, the
|
||||
confidence (`98%` for WD14, "Suggested" for language models) and a × "Remove
|
||||
<tag>". A chip has the concept `.chip`'s 4px corner (the later `.tag-suggestion`
|
||||
row rule would give 5px) around the row's own 12px text. At most the requested
|
||||
count, all preselected. While chips exist, ruling T4's "Remove anything you do not
|
||||
want to add." (`p.small`) leads them inside `#tag-suggestions`; removing the last
|
||||
chip shows "All suggestions removed. Generate again or enter tags manually." The
|
||||
opening sentence is the concept's on-screen copy, "Generate suggestions with the
|
||||
selected model before approving.": the concept's markup carries another ("Choose a
|
||||
model… Uncheck anything…"), but its `renderTagModelOptions` (a1062–1076) replaces it
|
||||
as soon as the dialog opens, so the opened state is compared unmapped.
|
||||
- **D84** (plan D61, rulings T1/T2): the Tagging model options are legacy's four
|
||||
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
|
||||
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
|
||||
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
|
||||
from the server's `default_prompt`.
|
||||
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
|
||||
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
|
||||
change keeps it.
|
||||
- **D88** (plan D65, ruling T7/C2): the inspector's chip row (side panel and viewer
|
||||
details, after the prompt blocks) shows the file's current image tags first (the
|
||||
B3 envelope's `image_tags`, `state: "current"`, S1), then D21's linked tags not
|
||||
already shown (case-insensitive, the image tag's spelling wins). In the Images
|
||||
panel every image tag is a Tags filter `button.chip` (the filter matches image
|
||||
tags too) and linked tags keep D21's rule; in the viewer, and wherever no Tags
|
||||
filter exists (the Library-embedded gallery, Activity and recorded runs), every
|
||||
chip is a plain `span.chip`. `none` and `unavailable` add no chips and no other
|
||||
chrome; a malformed facet fails the inspection like any malformed field. The
|
||||
concept has one row of the image's own tags (`i.tags`, app.js a402), so with the
|
||||
concept image's tags saved as its image tags production still equals the concept.
|
||||
After a Tag save the selected file's inspection is re-read, so the panel redraws
|
||||
the row without reselecting and the viewer, which shares that inspection, shows it.
|
||||
- **D115** (beyond the plan's list): the concept always has a selected image, so
|
||||
its "Auto-tag images" always opens the dialog. Production opens it for the image
|
||||
selected in Images when it was last left; with none it is `aria-disabled` and
|
||||
pressing it toasts "Select an image in Images first." (no new chrome).
|
||||
- **D116** (ruling T5): **Retry** leads `.dialog-actions` (before Cancel and the
|
||||
primary) while a failed observation, a failed Generate, an unconfirmed save or an
|
||||
answer that needs a fresh observation (changed storage or file, expired capture)
|
||||
can be retried; the concept has no failure states, so it has no Retry. After a
|
||||
failed Generate it takes Generate's own path (an invalid field reports its
|
||||
validity); an unconfirmed save resends the identical body; after a changed storage,
|
||||
file or expired capture it observes again and keeps the typed draft (when the saved
|
||||
set changed meanwhile, the current set leads the draft and the status line says
|
||||
"The tags of this image changed since the dialog opened. Saved now: <tags>.").
|
||||
Browser tests: "D116: Retry leads the actions after an unconfirmed save…", "D116:
|
||||
Retry after an expired capture observes again and keeps the typed draft (M5)" and
|
||||
the paired `production-unknown-outcome` capture.
|
||||
- **D29** (no prototype copy) in the dialog: no "Prototype · suggestions are
|
||||
simulated…" note, the status reads "Suggestions from <model>. Approve tags to save."
|
||||
(not "Sample suggestions from …"), and the toast has no " in this tab".
|
||||
|
||||
After a confirmed save the selected file's inspection is re-read quietly: the panel
|
||||
and the viewer keep the current details until the new answer lands, with no "Loading
|
||||
file details…" in between (`createInspectionController().refresh()`). While a Tags
|
||||
filter or a search is active the saved file may start or stop matching, so Images
|
||||
re-reads its loaded range in place (`createImageStream().refresh()`: the same query
|
||||
from the first page until the loaded count, published once). The cards, the scroll
|
||||
position and the selection stay while the selected file still matches; when it no
|
||||
longer does, the panel follows the collection (its first file), as after a reload,
|
||||
and the selection bar keeps only the checked files still listed, so its count is what
|
||||
Set category and Export metadata apply to. A refresh never runs beside a page load
|
||||
already in flight: asked during one, it is pending and runs once that page settles
|
||||
(success or failure; a reset drops it), and Load more waits for a running refresh
|
||||
(it neither loads nor moves focus). The re-read asks for pages as large as the rest of
|
||||
the loaded range (at most 200 files, at least one batch), one after another (each
|
||||
cursor comes from the page before); a failed re-read toasts "Images could not be
|
||||
refreshed after the tag save. Refresh image collection to retry." and keeps the
|
||||
cards. A save made while Images is left (Tags › Auto-tag images) marks a saved Images
|
||||
visit with a Tags filter or a search stale: returning lists it again with its
|
||||
filters instead of restoring the old cards. Browser tests: "the post-save re-read
|
||||
keeps the panel content until the new read lands", "a save under a Tags filter
|
||||
re-reads the listing: …", "a save under a search keeps the scroll position and the
|
||||
loaded range", "a failed listing re-read after a save says so and keeps the cards
|
||||
(M2)" and "a tag save from Tags reloads a filtered Images visit instead of restoring
|
||||
it (M6)"; the pending refresh and the page sizes are unit-tested.
|
||||
|
||||
Concept comparison (`tests/browser/tagging-concept.spec.mjs` with
|
||||
`tagging-canonical-comparison.mjs`, plan Task C4): production runs on the concept
|
||||
fixture with the concept image's own tags (`cinematic, blue_hour, architecture`)
|
||||
saved as its image tags, and the fake model answers the concept's own sample
|
||||
(`/__test__/tagging/suggestions`: mountain 0.98, observatory 0.95, snow 0.88,
|
||||
blue_hour 0.81), so the dialog's inventory (order, text, field attributes, tokens,
|
||||
preview box, chips, status, sticky actions) must equal the concept's at 1600×1100 and
|
||||
390×844, dark and light: opened, generated with WD14 and generated with a language
|
||||
model, plus the empty result (the concept above its own threshold), the review-scoped
|
||||
dialog with its scope note and the card's Info and Tag group (M1). `mapConceptTagging`
|
||||
applies only D29, D83, D84 and D91; a mutation check (the model panel's border
|
||||
removed, the primary relabelled) shows the comparison notices a change. Every
|
||||
production-versus-concept difference of PR C and its record:
|
||||
|
||||
| Difference | Record |
|
||||
| --- | --- |
|
||||
| Removable chips with × instead of checked checkbox rows; "Remove anything…" while chips exist; the all-removed copy | D83 (ruling T4) |
|
||||
| Legacy engine labels and values, GGUF in the Ollama slot, server default instructions | D84 (rulings T1/T2) |
|
||||
| Suggestions count field | D91 (ruling T3-bulk) |
|
||||
| No prototype note, "Suggestions from …", no " in this tab" | D29 |
|
||||
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
|
||||
| Auto-tag images `aria-disabled` without a selected image | D115 |
|
||||
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
|
||||
| States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
|
||||
|
||||
Native image tagging (#249 PR B routes, PR C UI): `POST
|
||||
/prompt_manager/image-annotations/observe` (capture one listed file, its saved set and
|
||||
`tag_revision`), `GET`/`DELETE /prompt_manager/image-annotations/capture/<token>`
|
||||
(the preview and its release), `POST /prompt_manager/image-annotations/suggest` (the
|
||||
gated top-N suggestions, at most the requested count) and `PUT
|
||||
/prompt_manager/image-annotations/tags` (re-hash, then save one byte record's set).
|
||||
Cheap reads follow spec amendment S1 (the verified `(size, mtime_ns)` fingerprint;
|
||||
residual risk above, under "Image annotations schema"). No automated test downloads
|
||||
or loads a model: the browser server injects `TestModel`, whose `download_model`
|
||||
raises, and every tagging spec asserts no `/autotag/download/` request. Capture
|
||||
limits are those of the capture store above (256 MiB per file, 8 completed captures,
|
||||
512 MiB reserved, 900 s idle, 1800 s hard). The browser fixtures release every capture
|
||||
on reset. PR C is HTML, CSS and JS only (no restart); the PR B routes it calls need a
|
||||
maintainer ComfyUI restart once.
|
||||
|
||||
Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is
|
||||
`tests/browser/workspace_tagging_fixture.py`: two files sharing one prompt and
|
||||
pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and
|
||||
no model files; every test asserts no download request),
|
||||
`tests/js/workspace-image-tagging.test.js`,
|
||||
`tests/js/workspace-image-tagging-state.test.js`,
|
||||
`tests/js/workspace-images-lifecycle.test.js`, and for D88
|
||||
`tests/js/workspace-file-inspector.test.js`,
|
||||
`tests/js/workspace-inspection-state.test.js` and `tests/browser/inspector-concept.spec.mjs`
|
||||
("image tags saved on the concept image keep the inspector equal to the concept"), and
|
||||
for the comparison `tests/browser/tagging-concept.spec.mjs` and
|
||||
`tests/js/workspace-images-state.test.js` (the in-place refresh), and on all four
|
||||
projects `tests/browser/workspace-tagging-lifecycle.spec.mjs` (leaving the page with
|
||||
the dialog open releases the capture with no console error; closing during a save
|
||||
releases it after the save and still reports it).
|
||||
|
||||
```sh
|
||||
node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js
|
||||
npm run --prefix tools/vendor test:workflows -- workspace-tagging.spec.mjs tagging-concept.spec.mjs \
|
||||
workspace-vocabulary.spec.mjs images-concept.spec.mjs --project chromium --project touch
|
||||
npm run --prefix tools/vendor test:workflows -- workspace-tagging-lifecycle.spec.mjs tagging-concept.spec.mjs \
|
||||
--project firefox --project webkit
|
||||
```
|
||||
|
||||
### Prompt library cards and recorded deviations (#249 PR E)
|
||||
|
||||
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
@@ -1880,8 +2071,8 @@ 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. The Images card tag
|
||||
button (#249 D9) remains outside #209; every prompt block now renders the concept's
|
||||
distinctions are documented in the comparison helper. The gallery cards carry the
|
||||
concept's Tag button beside Info (#249 C2 retired D9); every prompt block now renders the concept's
|
||||
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
|
||||
gap is closed, and only the production-only surfaces above keep their own rules
|
||||
(D48). The
|
||||
@@ -2045,7 +2236,7 @@ legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` r
|
||||
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
|
||||
|
||||
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
|
||||
Images shown as "—" with hidden "Not available yet", and Actions with Browse and
|
||||
Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and
|
||||
Rename / merge), and a Categories panel whose category rows end with the Manage
|
||||
category row (Review). Lists load 100 rows at a time, append when scrolled into view
|
||||
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
|
||||
@@ -2086,8 +2277,7 @@ npm run --prefix tools/vendor test:workflows -- \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
Recorded deviations from the concept (maintainer acceptance): no Auto-tag images
|
||||
button and an Images "—" cell until the image-annotation slice; bounded lists with
|
||||
Recorded deviations from the concept (maintainer acceptance): bounded lists with
|
||||
Load more; title "Tags"; Manage categories adds a Category select and Preview before
|
||||
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
|
||||
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
|
||||
|
||||
@@ -78,6 +78,23 @@ export async function seedConcept(request,data={}) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save `tags` as the current image tag set of the listed file at `path` (root 0) through the real observe and tag
|
||||
* routes (#249 PR C), releasing the capture afterwards; returns the receipt's saved names.
|
||||
*/
|
||||
export async function saveImageTags(request,path,tags) {
|
||||
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const target={root_index:0,root_scope:listing.images.find(item=>item.root_index===0).root_scope,path};
|
||||
const observed=await (await request.post('/prompt_manager/image-annotations/observe',{data:{target}})).json();
|
||||
expect(observed.success).toBe(true);
|
||||
try {
|
||||
const receipt=await (await request.put('/prompt_manager/image-annotations/tags',{data:{target,capture_token:observed.capture.token,
|
||||
sha256:observed.capture.sha256,expected_epoch:observed.library_epoch,expected_tag_revision:observed.tag_revision,tags}})).json();
|
||||
expect(receipt.success).toBe(true);
|
||||
return receipt.tags;
|
||||
} finally {await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);}
|
||||
}
|
||||
|
||||
async function selectTheme(page,theme) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
const inspector=page=>page.locator('.detail-panel');
|
||||
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
|
||||
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button:first-child`).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');
|
||||
@@ -460,7 +460,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('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button:first-child').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'control-heavy.png');
|
||||
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
|
||||
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
|
||||
@@ -637,7 +637,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button:first-child').click();
|
||||
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(position(page)).toContainText('of 2 loaded:');
|
||||
});
|
||||
@@ -930,7 +930,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
|
||||
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-card-actions button`).click();await openCollection(page);
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button:first-child`).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 image viewer',exact:true})).toBeInViewport();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ export const canonicalDifferences = {
|
||||
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
|
||||
// exact history comparison is tests/browser/history-concept.spec.mjs.
|
||||
remainingApplicationPolish: [
|
||||
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -59,12 +59,12 @@ function mapBulkbar(bulkbar,rise) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
|
||||
* A list row's info with its actions `actionsWidth` wide, 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.
|
||||
* - the 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;
|
||||
@@ -109,21 +109,16 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
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):{};
|
||||
const listRow=list?listInfo(card,actions.width):{};
|
||||
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)}}};
|
||||
actions:{...actions,left:listRow.actionsLeft??actions.left}}};
|
||||
});
|
||||
// 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.
|
||||
@@ -538,8 +533,9 @@ test('one image appended by scrolling reads in the singular',async({page,request
|
||||
});
|
||||
|
||||
// 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.
|
||||
// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line: a short "96 × 72" beside Info and Tag)
|
||||
// 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);
|
||||
@@ -548,7 +544,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
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});
|
||||
const manifest=await seedConcept(request,{seedless_images:2,short_seedless:true});
|
||||
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});
|
||||
@@ -557,7 +553,10 @@ test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
...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']);
|
||||
expect(seedless.map(card=>card.info.p.text)).toEqual(['96 × 72 · PNG','96 × 72 · 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);
|
||||
for(const card of seedless) {
|
||||
expect(card.info.p.height).toBeLessThanOrEqual(parseFloat(card.info.p.lineHeight)+0.5);
|
||||
expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs';
|
||||
|
||||
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
|
||||
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
|
||||
@@ -319,6 +319,27 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
});
|
||||
|
||||
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
|
||||
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
|
||||
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
|
||||
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
|
||||
test.setTimeout(120_000);
|
||||
const viewport={width,height};await page.setViewportSize(viewport);
|
||||
await openConceptPanel(page,'dark');
|
||||
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
|
||||
expect(names).toEqual(['cinematic','blue_hour','architecture']);
|
||||
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
|
||||
const reference=await inventory(page,true);
|
||||
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
|
||||
expect(await saveImageTags(request,path,names)).toEqual(names);
|
||||
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
|
||||
await openProductionPanel(page,'dark');
|
||||
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
|
||||
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
|
||||
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
|
||||
assertInspectorMatch(reference,await inventory(page),viewport);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): on the panel one CSS
|
||||
// mutation (the role label's letter spacing) and one DOM mutation (the footer removed); in the viewer (final review m5)
|
||||
// its pair actions removed and D38's Show in folder removed; each applied after an exact match.
|
||||
|
||||
+20
-3
@@ -35,6 +35,7 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
register_library_editor_commands,
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
@@ -57,11 +58,19 @@ class TestModel:
|
||||
wd14_character_threshold = 0.85
|
||||
custom_prompt = ""
|
||||
loaded = None
|
||||
# Models the Tag image dialog workflows mark as not downloaded.
|
||||
unavailable: set = set()
|
||||
# Scored names the concept comparisons answer instead of the fixed list
|
||||
# (``/__test__/tagging/suggestions``); None keeps the fixed list.
|
||||
suggestions: list | None = None
|
||||
|
||||
def get_models_status(self) -> dict:
|
||||
return {
|
||||
name: {"downloaded": True, "loaded": self.loaded == name}
|
||||
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
name: {
|
||||
"downloaded": name not in self.unavailable,
|
||||
"loaded": self.loaded == name,
|
||||
}
|
||||
for name in ("gguf", "hf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
}
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
@@ -85,6 +94,11 @@ class TestModel:
|
||||
) -> list[tuple[str, float | None]]:
|
||||
"""Fixed scored suggestions (WD14) or unscored ones (language models)."""
|
||||
scored = self.loaded is not None and self.loaded.startswith("wd14")
|
||||
if self.suggestions is not None:
|
||||
return [
|
||||
(name, confidence if scored else None)
|
||||
for name, confidence in self.suggestions[:limit]
|
||||
]
|
||||
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
|
||||
names += [f"tag_{index:02d}" for index in range(100)]
|
||||
return [
|
||||
@@ -178,6 +192,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
|
||||
@@ -471,7 +486,8 @@ def create_app(root: Path) -> web.Application:
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) - set(limits)
|
||||
or set(data) - set(limits) - {"short_seedless"}
|
||||
or not isinstance(data.get("short_seedless", False), bool)
|
||||
or any(
|
||||
isinstance(data.get(key, 0), bool)
|
||||
or not isinstance(data.get(key, 0), int)
|
||||
@@ -490,6 +506,7 @@ def create_app(root: Path) -> web.Application:
|
||||
root,
|
||||
extra_images=data.get("extra_images", 0),
|
||||
seedless_images=data.get("seedless_images", 0),
|
||||
short_seedless=data.get("short_seedless", False),
|
||||
)
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@@ -119,10 +119,7 @@ function mapShellConcept(reference) {
|
||||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||||
})(),
|
||||
},
|
||||
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
|
||||
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
|
||||
}};
|
||||
}
|
||||
|
||||
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Concept-vs-production inventory of the Tag image dialog (plan Mock inventory M2: docs/concepts/workspace/app.js
|
||||
// tagReview a1037–1061, renderTagModelOptions a1062–1076, generateTags a1077–1135; style.css s1598–1694) and of the
|
||||
// card's Info and Tag group (M1: app.js mediaCard a398, style.css s1589–1597). The *Of functions run in the page.
|
||||
// The concept is never rewritten: mapConceptTagging applies only the recorded deviations, each named with its D-number
|
||||
// or ruling; everything else must be equal.
|
||||
export {diffInventory as diffTaggingInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
/** The open Tag image dialog (`dialog#modal`), its controls in order with their concept tokens. */
|
||||
export function taggingInventory(dialog) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const px=value=>Math.round(value*2)/2;
|
||||
const box=node=>{const rect=node.getBoundingClientRect();return {width:px(rect.width),height:px(rect.height)};};
|
||||
const font=node=>css(node,'fontSize','fontWeight','lineHeight','color','marginTop','marginBottom');
|
||||
const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopWidth','borderTopStyle','borderTopColor',
|
||||
'borderTopLeftRadius','backgroundColor');
|
||||
const buttonOf=node=>({text:node.textContent.trim(),label:node.getAttribute('aria-label'),primary:node.classList.contains('ca-primary'),
|
||||
icon:node.querySelector('svg')!==null,disabled:node.getAttribute('aria-disabled')==='true'||node.disabled,
|
||||
...css(node,'minHeight','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')});
|
||||
const controlOf=node=>({tag:node.tagName.toLowerCase(),id:node.id,type:node.type??null,value:node.value,
|
||||
min:node.getAttribute('min'),max:node.getAttribute('max'),step:node.getAttribute('step'),
|
||||
describedBy:node.getAttribute('aria-describedby'),
|
||||
options:node.tagName==='SELECT'?[...node.options].map(option=>[option.value,option.textContent]):null,
|
||||
...css(node,'fontSize','minHeight','backgroundColor','color','borderTopColor','borderTopLeftRadius','paddingLeft')});
|
||||
const fieldOf=field=>{const label=field.querySelector('label'),control=field.querySelector('input,textarea,select');
|
||||
return {label:label.textContent,labelled:label.htmlFor===control.id,...css(field,'display','rowGap','marginTop','marginBottom'),control:controlOf(control)};};
|
||||
const header=dialog.querySelector('.dialog-header'),title=header.querySelector('h2'),close=header.querySelector('button');
|
||||
const body=dialog.querySelector('#modal-body');
|
||||
const heading=body.querySelector('.tag-image-heading');
|
||||
const preview=heading.querySelector('.tag-image-preview'),copy=heading.querySelector(':scope > div');
|
||||
const sizeChip=copy.querySelector('.chip');
|
||||
const note=body.querySelector('.tag-scope-note');
|
||||
const manual=body.querySelector('#manual-image-tags'),manualField=manual.closest('.field'),help=body.querySelector('#manual-tags-help');
|
||||
const panel=body.querySelector('.tag-model-panel'),options=panel.querySelector('#image-tag-model-options');
|
||||
const generate=[...panel.querySelectorAll('button')].at(-1);
|
||||
const suggestions=body.querySelector('section.tag-suggestion-panel');
|
||||
const list=suggestions.querySelector('#tag-suggestions'),status=suggestions.querySelector('#tag-generation-status');
|
||||
const count=suggestions.querySelector('#suggestion-count');
|
||||
const actions=body.querySelector('.dialog-actions');
|
||||
// A suggestion is a concept checkbox row (`label.tag-suggestion`) or a production chip (`span.chip.tag-suggestion`).
|
||||
const rows=[...list.querySelectorAll('.tag-suggestion')].map(row=>{
|
||||
const name=row.querySelector('span:not(.tag-confidence)'),confidence=row.querySelector('.tag-confidence');
|
||||
const checkbox=row.querySelector('input[type=checkbox]'),remove=row.querySelector('button');
|
||||
return {name:name.textContent,confidence:confidence.textContent,
|
||||
control:checkbox?{kind:'checkbox',checked:checkbox.checked}:remove?{kind:'remove',label:remove.getAttribute('aria-label')}:null,
|
||||
...css(row,'display','alignItems','borderTopColor','backgroundColor','borderTopWidth','borderTopStyle','borderTopLeftRadius','fontSize','paddingTop','paddingLeft'),
|
||||
confidenceToken:css(confidence,'color','fontSize')};
|
||||
});
|
||||
return {
|
||||
dialog:{...css(dialog,'paddingTop','paddingRight','paddingBottom','paddingLeft','borderTopLeftRadius','backgroundColor','color','overflowY'),
|
||||
width:px(dialog.getBoundingClientRect().width),modal:dialog.matches(':modal'),tagging:dialog.classList.contains('tagging-modal'),
|
||||
labelledBy:document.getElementById(dialog.getAttribute('aria-labelledby'))===title,
|
||||
noHorizontalOverflow:dialog.scrollWidth<=dialog.clientWidth+1},
|
||||
header:{title:{text:title.textContent,...font(title)},close:{label:close.getAttribute('aria-label'),text:close.textContent.trim()}},
|
||||
demo:body.querySelector('.demo-note')?.textContent??null,
|
||||
order:[...body.children].map(node=>node.className),
|
||||
heading:{...css(heading,'display','columnGap','alignItems','marginBottom'),
|
||||
preview:{tag:preview.tagName.toLowerCase(),alt:preview.getAttribute('alt'),...box(preview),...css(preview,'objectFit','borderTopLeftRadius','borderTopWidth','borderTopStyle','borderTopColor','flexShrink')},
|
||||
h3:{text:copy.querySelector('h3').textContent,...font(copy.querySelector('h3'))},
|
||||
filename:{text:copy.querySelector('p.small').textContent,...font(copy.querySelector('p.small'))},
|
||||
size:sizeChip?{text:sizeChip.textContent,...css(sizeChip,'marginTop','fontSize','borderTopColor','borderTopLeftRadius','paddingLeft')}:null},
|
||||
scopeNote:note?{text:note.textContent,...edges(note),...css(note,'borderLeftWidth','borderLeftStyle','borderLeftColor','fontSize','color')}:null,
|
||||
manual:{...fieldOf(manualField),help:{text:help.textContent,...font(help)},described:help.id===manual.getAttribute('aria-describedby')},
|
||||
modelPanel:{...edges(panel),...css(panel,'marginTop'),h3:{text:panel.querySelector('h3').textContent,...font(panel.querySelector('h3'))},
|
||||
fields:[...panel.querySelectorAll('.field')].map(fieldOf),
|
||||
twoCol:options.querySelector('.two-col')?{columns:getComputedStyle(options.querySelector('.two-col')).gridTemplateColumns.split(' ').length,
|
||||
...css(options.querySelector('.two-col'),'display','columnGap')}:null,
|
||||
generate:buttonOf(generate)},
|
||||
suggestions:{labelled:suggestions.getAttribute('aria-labelledby')===suggestions.querySelector('h3').id,...css(suggestions,'marginTop'),
|
||||
title:{text:suggestions.querySelector('h3').textContent,...font(suggestions.querySelector('h3'))},
|
||||
count:{text:count.textContent,...font(count)},
|
||||
copy:list.querySelector(':scope > p.small')?.textContent??null,
|
||||
rows,
|
||||
status:{text:status.textContent,role:status.getAttribute('role'),...css(status,'marginTop','fontSize','color')}},
|
||||
actions:{...css(actions,'position','bottom','marginLeft','marginRight','marginBottom','paddingTop','paddingLeft','backgroundColor',
|
||||
'borderTopWidth','borderTopStyle','borderTopColor','zIndex','display','justifyContent','columnGap'),
|
||||
buttons:[...actions.querySelectorAll('button')].map(buttonOf)},
|
||||
noPageScroll:document.documentElement.scrollWidth<=innerWidth+1,
|
||||
};
|
||||
}
|
||||
|
||||
/** The first image card's Info and Tag group (M1), with its card's filename. */
|
||||
export function cardActionsInventory(card) {
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
const info=card.querySelector('.media-info'),actions=info.querySelector(':scope > .image-card-actions');
|
||||
return {
|
||||
info:css(info,'display','justifyContent','alignItems','columnGap'),
|
||||
actions:{...css(actions,'display','alignItems','columnGap','flexShrink','minWidth'),lastInInfo:info.lastElementChild===actions},
|
||||
buttons:[...actions.querySelectorAll(':scope > button')].map(node=>{const rect=node.getBoundingClientRect();return {
|
||||
className:node.className,label:node.getAttribute('aria-label'),icon:node.querySelector('svg')?.innerHTML.replace(/\s+/g,' ').trim()??null,
|
||||
width:Math.round(rect.width),height:Math.round(rect.height),
|
||||
...css(node,'minWidth','minHeight','paddingLeft','paddingTop','borderTopLeftRadius','borderTopColor','backgroundColor','color')};}),
|
||||
};
|
||||
}
|
||||
|
||||
// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
|
||||
const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
|
||||
ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
|
||||
const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV2 · ~400 MB','WD14 ViT · visual tags':'WD14 ViT · ~400 MB',
|
||||
'Ollama · local language model':'GGUF JoyCaption (Recommended) · ~4.5 GB','HuggingFace · language model':'HuggingFace JoyCaption · ~16 GB'});
|
||||
|
||||
/**
|
||||
* The concept inventory with the recorded deviations applied:
|
||||
* - no prototype note (Global Constraints: `Prototype · suggestions are simulated…` is not shipped);
|
||||
* - D83 (ruling T4): while suggestions exist, "Remove anything you do not want to add." leads them, and every
|
||||
* suggestion is a chip with a `Remove <name>` × instead of a checked checkbox row: the concept's own `.chip` corner
|
||||
* (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
|
||||
* row's colours (accent border, `--soft-blue`);
|
||||
* - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
|
||||
* - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
|
||||
* (`numberField`: the concept's General confidence field) with the configured count (`limit`);
|
||||
* - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
|
||||
* The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
|
||||
* model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
|
||||
*/
|
||||
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
|
||||
const [select,...own]=reference.modelPanel.fields;
|
||||
const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
|
||||
value:String(limit),min:'1',max:'100',step:'1'}};
|
||||
const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
|
||||
const rows=reference.suggestions.rows.map(row=>({...row,
|
||||
control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
|
||||
borderTopLeftRadius:chip.borderTopLeftRadius}));
|
||||
const copy=rows.length&&reference.suggestions.copy===null?'Remove anything you do not want to add.':reference.suggestions.copy;
|
||||
const status=reference.suggestions.status.text.replace(/^Sample suggestions from (.+)\. Approve tags to save\.$/,
|
||||
(_,label)=>`Suggestions from ${CONCEPT_LABELS[label]??label}. Approve tags to save.`);
|
||||
return {...reference,
|
||||
demo:null,
|
||||
order:reference.order.filter(name=>name!=='demo-note'),
|
||||
modelPanel:{...reference.modelPanel,
|
||||
fields:[{...select,control:{...select.control,value:MODEL_LABELS[select.control.value]?.[0]??select.control.value,
|
||||
options:select.control.options.map(([value])=>MODEL_LABELS[value])}},...mappedOwn,suggestionsField]},
|
||||
suggestions:{...reference.suggestions,copy,rows,status:{...reference.suggestions.status,text:status}},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Production's own chip geometry beyond the concept's `.chip` (D83): the chip's padding hugs its × button. Only the
|
||||
* leaves D83 replaces are dropped from both sides; names, confidences, colours, radius and font stay compared.
|
||||
*/
|
||||
export function withoutChipGeometry(inventory) {
|
||||
return {...inventory,suggestions:{...inventory.suggestions,rows:inventory.suggestions.rows.map(({paddingTop,paddingLeft,alignItems,...row})=>row)}};
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// The Tag image dialog and the card's Info and Tag group against the approved concept (#249 PR C, plan Task C4, Mock
|
||||
// inventory M1 and M2). Production runs on the concept-shaped fixture with the concept image's own tags saved as its
|
||||
// image tags and the injected fake model answering the concept's own sample (alpine: mountain 98%, observatory 95%,
|
||||
// snow 88%, blue_hour 81%), so every remaining difference is a recorded deviation applied by mapConceptTagging
|
||||
// (D29, D83, D84, D91) or a test failure. No model is ever downloaded: every test checks no download request was sent.
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction, saveImageTags, mutate, SIZES, THEMES } from './concept-inventory.mjs';
|
||||
import { taggingInventory, cardActionsInventory, mapConceptTagging, diffTaggingInventory, withoutChipGeometry } from './tagging-canonical-comparison.mjs';
|
||||
|
||||
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
|
||||
// The concept's first image (app.js images[0]) and its sample suggestions (generateTags, scene "alpine").
|
||||
const CONCEPT_FILE = 'alpine_00001.png';
|
||||
const CONCEPT_TAGS = ['cinematic', 'blue_hour', 'architecture'];
|
||||
const ALPINE = [['mountain', 0.98], ['observatory', 0.95], ['snow', 0.88], ['blue_hour', 0.81]];
|
||||
// The fake model's server default instructions (tests/browser/server.py TestModel.default_prompt), D84.
|
||||
const INSTRUCTIONS = 'Describe the image';
|
||||
const CONCEPT_MODEL = { gguf: 'ollama' };
|
||||
|
||||
const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const firstCard = (page, side) => page.locator(side === 'concept' ? '#image-grid > article.media-card' : '#workspace-main article[data-image-key]').first();
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
const manifest = await seedConcept(request);
|
||||
expect(manifest).toBeTruthy();
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
page.downloads = [];
|
||||
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
|
||||
page.errors = [];
|
||||
page.on('pageerror', error => page.errors.push(error.message));
|
||||
});
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
await leave(page);
|
||||
expect(page.downloads).toEqual([]);
|
||||
expect(page.errors).toEqual([]);
|
||||
await request.post('/__test__/tagging/suggestions', { data: { scored: null } });
|
||||
});
|
||||
|
||||
async function conceptPath(request) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const image = listing.images.find(item => item.filename === CONCEPT_FILE);
|
||||
expect(image).toBeTruthy();
|
||||
return image.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an open Tag dialog before opening the next page, so each comparison starts from a settled page. Leaving with
|
||||
* the dialog open is covered on its own (workspace-tagging-lifecycle.spec.mjs: the keepalive release on pagehide).
|
||||
*/
|
||||
async function leave(page) {
|
||||
if (await tagDialog(page).count()) {
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
/** Open `side`'s Tag image dialog from its first card, settled (production: the capture preview and saved tags). */
|
||||
async function openTagDialog(page, side, theme) {
|
||||
await leave(page);
|
||||
if (side === 'concept') await openConcept(page, 'images', theme);
|
||||
else await openProduction(page, 'images', theme);
|
||||
await firstCard(page, side).getByRole('button', { name: `Tag or auto-tag ${CONCEPT_FILE}`, exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, side);
|
||||
}
|
||||
async function settled(page, side) {
|
||||
if (side === 'production') {
|
||||
await expect(tagDialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /\/image-annotations\/capture\//);
|
||||
await expect(tagDialog(page).locator('#manual-image-tags')).toHaveValue(CONCEPT_TAGS.join(', '));
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await Promise.all([...document.querySelectorAll('#modal img')].map(image => image.decode().catch(() => {})));
|
||||
});
|
||||
}
|
||||
async function generateWith(page, side, model) {
|
||||
const select = tagDialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
|
||||
if (model) await select.selectOption(side === 'concept' ? CONCEPT_MODEL[model] ?? model : model);
|
||||
await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
|
||||
await expect(tagDialog(page).locator('#suggestion-count')).not.toHaveText('Not generated');
|
||||
}
|
||||
// The pointer rests outside the dialog, so no control is measured in its hover state.
|
||||
const inventory = async page => {
|
||||
await page.mouse.move(0, 0);
|
||||
return page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
};
|
||||
|
||||
/** The concept and production inventories of one dialog state, the concept mapped (D29, D83, D84, D91). */
|
||||
async function compareState(page, { theme, act = async () => {} }) {
|
||||
await openTagDialog(page, 'concept', theme);
|
||||
const conceptOpened = await inventory(page);
|
||||
await act(page, 'concept');
|
||||
const concept = await inventory(page);
|
||||
await openTagDialog(page, 'production', theme);
|
||||
await act(page, 'production');
|
||||
const actual = await inventory(page);
|
||||
const numberField = conceptOpened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
|
||||
const reference = mapConceptTagging(concept, { instructions: INSTRUCTIONS, chip: concept.heading.size, numberField });
|
||||
return { reference: withoutChipGeometry(reference), actual: withoutChipGeometry(actual) };
|
||||
}
|
||||
|
||||
for (const [width, height] of SIZES) for (const theme of THEMES) {
|
||||
test(`concept and production Tag dialog match at ${width}x${height} ${theme}`, async ({ page, request }) => {
|
||||
test.setTimeout(90_000);
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width, height });
|
||||
// Opened, generated with WD14 (the concept's default), then generated with a language model.
|
||||
for (const [state, act] of [
|
||||
['opened', async () => {}],
|
||||
['generated WD14', (page, side) => generateWith(page, side)],
|
||||
['generated GGUF', (page, side) => generateWith(page, side, 'gguf')],
|
||||
]) {
|
||||
const { reference, actual } = await compareState(page, { theme, act });
|
||||
expect(diffTaggingInventory(reference, actual), `${state} at ${width}x${height} ${theme}`).toEqual([]);
|
||||
}
|
||||
// Pinned concept geometry (M2), so a mapping mistake on both sides cannot pass unseen.
|
||||
const { actual } = await compareState(page, { theme, act: (page, side) => generateWith(page, side) });
|
||||
const small = width <= 700;
|
||||
expect([actual.heading.preview.width, actual.heading.preview.height]).toEqual(small ? [86, 86] : [112, 100]);
|
||||
expect(actual.heading.columnGap).toBe('16px');
|
||||
expect([actual.modelPanel.paddingTop, actual.modelPanel.borderTopLeftRadius]).toEqual(['16px', '6px']);
|
||||
expect(actual.actions.position).toBe('sticky');
|
||||
expect(actual.suggestions.rows.map(row => [row.name, row.confidence, row.control.label])).toEqual(
|
||||
ALPINE.map(([name, confidence]) => [name, `${Math.round(confidence * 100)}%`, `Remove ${name}`]));
|
||||
const accent = await page.evaluate(() => {
|
||||
const probe = document.createElement('i');
|
||||
probe.style.cssText = 'border-top:1px solid var(--ca-accent);background:var(--soft-blue)';
|
||||
document.body.append(probe);
|
||||
const style = getComputedStyle(probe), tokens = [style.borderTopColor, style.backgroundColor];
|
||||
probe.remove();
|
||||
return tokens;
|
||||
});
|
||||
for (const row of actual.suggestions.rows) expect([row.borderTopColor, row.backgroundColor]).toEqual(accent);
|
||||
expect(actual.dialog.noHorizontalOverflow).toBe(true);
|
||||
expect(actual.noPageScroll).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('an empty result matches the concept below its threshold', async ({ page, request }) => {
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
|
||||
const { reference, actual } = await compareState(page, { theme: 'dark', act: async (page, side) => {
|
||||
// The concept filters its sample by the General confidence; above every sample nothing is shown.
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
|
||||
await generateWith(page, side);
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.35');
|
||||
} });
|
||||
expect(actual.suggestions.copy).toBe('No suggestions met this threshold. Adjust it or enter tags manually.');
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
test('review-scoped dialog shows the scope note', async ({ page, request }) => {
|
||||
const path = await conceptPath(request);
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await openConcept(page, 'review', 'dark');
|
||||
await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, 'concept');
|
||||
const opened = await inventory(page);
|
||||
// Needs review ships with PR F; until then the review source opens the same dialog from the module.
|
||||
await leave(page);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.evaluate(async ({ module, path }) => {
|
||||
const { openImageTagging } = await import(module);
|
||||
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const api = window.ApiClient.createApiClient();
|
||||
const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
|
||||
openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
|
||||
}, { module: MODULE, path });
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await settled(page, 'production');
|
||||
const actual = await inventory(page);
|
||||
expect(actual.scopeNote.text).toBe('Tags belong to this image. Resolve its prompt association separately in Needs review.');
|
||||
expect(actual.order).toEqual(['tag-image-heading', 'tag-scope-note small', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
|
||||
const numberField = opened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold');
|
||||
const reference = mapConceptTagging(opened, { instructions: INSTRUCTIONS, chip: opened.heading.size, numberField });
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
for (const [width, height] of SIZES) test(`card action group matches M1 at ${width}x${height}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await openConcept(page, 'images', 'dark');
|
||||
const reference = await firstCard(page, 'concept').evaluate(cardActionsInventory);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
const actual = await firstCard(page, 'production').evaluate(cardActionsInventory);
|
||||
expect(actual.buttons.map(button => button.label)).toEqual([`Inspect ${CONCEPT_FILE}`, `Tag or auto-tag ${CONCEPT_FILE}`]);
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the comparison notices a changed panel border or primary label', async ({ page, request }) => {
|
||||
await saveImageTags(request, await conceptPath(request), CONCEPT_TAGS);
|
||||
const { reference, actual } = await compareState(page, { theme: 'dark' });
|
||||
expect(diffTaggingInventory(reference, actual)).toEqual([]);
|
||||
const undo = await mutate(page, { css: '.tag-model-panel { border: 0 !important; }' });
|
||||
const borderless = withoutChipGeometry(await inventory(page));
|
||||
expect(diffTaggingInventory(reference, borderless).map(item => item.path)).toEqual(
|
||||
expect.arrayContaining(['modelPanel.borderTopWidth', 'modelPanel.borderTopStyle']));
|
||||
await undo();
|
||||
await tagDialog(page).locator('.dialog-actions .ca-primary').evaluate(node => { node.textContent = 'Save'; });
|
||||
const relabelled = withoutChipGeometry(await inventory(page));
|
||||
expect(diffTaggingInventory(reference, relabelled).map(item => item.path)).toEqual(['actions.buttons.1.text']);
|
||||
});
|
||||
|
||||
// Paired captures for the PR (no screenshot baselines): every production state beside the concept's nearest one. The
|
||||
// states the concept lacks (unavailable model, saving, conflict, file changed) pair with the concept's opened dialog.
|
||||
test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const path = await conceptPath(request);
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
const shot = async name => {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); });
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: 'image/png' });
|
||||
};
|
||||
const both = async (name, act) => {
|
||||
for (const side of ['concept', 'production']) {
|
||||
await openTagDialog(page, side, 'dark');
|
||||
await act(page, side);
|
||||
await shot(`${side}-${name}`);
|
||||
}
|
||||
};
|
||||
await both('opened', async () => {});
|
||||
await both('generated-wd14', (page, side) => generateWith(page, side));
|
||||
await both('generated-gguf', (page, side) => generateWith(page, side, 'gguf'));
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: [] } })).toBeOK();
|
||||
await both('empty', async (page, side) => {
|
||||
if (side === 'concept') await tagDialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('1');
|
||||
await generateWith(page, side);
|
||||
});
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: ALPINE } })).toBeOK();
|
||||
const save = () => tagDialog(page).locator('.dialog-actions .ca-primary').click();
|
||||
const status = () => tagDialog(page).locator('#tag-generation-status');
|
||||
|
||||
// Unavailable model.
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await generateWith(page, 'production').catch(() => {});
|
||||
await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await shot('production-unavailable');
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
|
||||
// Saving: the write is held until the capture is taken.
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const writes = url => url.pathname === '/prompt_manager/image-annotations/tags';
|
||||
await page.route(writes, async route => { await held; await route.continue(); });
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
const sent = page.waitForRequest(item => writes(new URL(item.url())));
|
||||
await save();
|
||||
await sent;
|
||||
await expect(tagDialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
|
||||
await shot('production-saving');
|
||||
release();
|
||||
await expect(tagDialog(page)).toHaveCount(0);
|
||||
await page.unroute(writes);
|
||||
|
||||
// Conflict: another writer saved this file's set after the dialog observed it.
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await saveImageTags(request, path, ['changed_elsewhere']);
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await shot('production-conflict');
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// File changed: the server's own envelope for bytes replaced after the preview (B1 `file-changed`).
|
||||
await page.route(writes, route => route.fulfill({ status: 409, contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, code: 'file-changed', error: 'The file changed since it was previewed. Reopen the Tag dialog.' }) }));
|
||||
await saveImageTags(request, path, CONCEPT_TAGS);
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).not.toHaveText('');
|
||||
await shot('production-file-changed');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Unconfirmed save (a 504): D116's Retry leads the actions, before Cancel and the disabled primary.
|
||||
await page.route(writes, route => route.fulfill({ status: 504, body: 'Gateway Timeout' }));
|
||||
await openTagDialog(page, 'production', 'dark');
|
||||
await save();
|
||||
await expect(status()).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
expect((await inventory(page)).actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
await shot('production-unknown-outcome');
|
||||
await page.unroute(writes);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Review scope: the concept's Needs review dialog and production's review-sourced dialog.
|
||||
await leave(page);
|
||||
await openConcept(page, 'review', 'dark');
|
||||
await page.locator('#main section.surface').getByRole('button', { name: 'Tag image', exact: true }).click();
|
||||
await expect(tagDialog(page)).toBeVisible();
|
||||
await shot('concept-review');
|
||||
await leave(page);
|
||||
await openProduction(page, 'images', 'dark');
|
||||
await page.evaluate(async ({ module, path }) => {
|
||||
const { openImageTagging } = await import(module);
|
||||
const { createScope } = await import('/prompt_manager/js/shared/toolkit/core.js');
|
||||
const api = window.ApiClient.createApiClient();
|
||||
const listing = await api.get('/prompt_manager/images/output?mode=workspace&limit=40');
|
||||
openImageTagging({ api, scope: createScope(), image: listing.images.find(item => item.path === path), source: 'review' });
|
||||
}, { module: MODULE, path });
|
||||
await expect(tagDialog(page).locator('.tag-scope-note')).toBeVisible();
|
||||
await settled(page, 'production');
|
||||
await shot('production-review');
|
||||
await leave(page);
|
||||
});
|
||||
@@ -64,12 +64,10 @@ export function pageInventoryOf(main) {
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
export function mapVocabularyConcept(reference) {
|
||||
return {...reference,
|
||||
heading:{...reference.heading,actions:{...reference.heading.actions,
|
||||
// D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
|
||||
images:'—',imagesHidden:'Not available yet'}))},
|
||||
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
|
||||
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
|
||||
images:'0'}))},
|
||||
operations:reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
|
||||
|
||||
@@ -1019,7 +1019,7 @@ test('shared gallery uses concept cards, image opens the viewer, empty copy',asy
|
||||
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
|
||||
await expect(star).toHaveText(image.favorite?'★':'☆');
|
||||
await expect(first.locator('.media-info h3')).toHaveCount(1);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn:first-child')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
// C3 activation: the image opens the viewer and Escape returns focus to it.
|
||||
const openButton=first.locator('.image-open');await openButton.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
|
||||
@@ -545,7 +545,7 @@ async function staleSourceRecovery(page, request, roots) {
|
||||
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
|
||||
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
|
||||
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
|
||||
await page.locator(`article[data-image-key="${key}"] .image-card-actions button`).click();expect((await stale).status()).toBe(409);
|
||||
await page.locator(`article[data-image-key="${key}"] .image-card-actions button:first-child`).click();expect((await stale).status()).toBe(409);
|
||||
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
await page.locator(`article[data-image-key="${key}"] .image-open`).click();
|
||||
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.');
|
||||
@@ -1028,8 +1028,7 @@ async function cardInventory(page, concept) {
|
||||
const walk = new Function(`return (${walkSource})()`)();
|
||||
return nodes.map(card => {
|
||||
const copy = card.cloneNode(true);
|
||||
// D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared.
|
||||
if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove();
|
||||
// Info then Tag (#249 C2 retired D9). The D8 checkbox and the D7 star are compared.
|
||||
const open = card.querySelector('.image-open'), img = open.querySelector('img');
|
||||
const star = card.querySelector('.image-overlays > button.star-button');
|
||||
const box = card.querySelector('.image-overlays > label > input');
|
||||
@@ -1039,7 +1038,7 @@ async function cardInventory(page, concept) {
|
||||
size: [img.getAttribute('width'), img.getAttribute('height')],
|
||||
title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
|
||||
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
|
||||
inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
|
||||
actions: [...card.querySelectorAll('.image-card-actions > button')].map(button => button.getAttribute('aria-label')),
|
||||
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
|
||||
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
|
||||
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
|
||||
@@ -1209,7 +1208,7 @@ async function zoomedGallery(page, request, { cssZoom }) {
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused();
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button:first-child`)).toBeFocused();
|
||||
await page.evaluate(() => {
|
||||
const card = document.querySelector('article[aria-posinset="1"]');
|
||||
const grid = document.querySelector('.image-grid');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// The Tag image dialog's capture lifetime when the page or the dialog goes away mid-flight (#249 PR C final review I4,
|
||||
// I5), run on all four projects: leaving the page with the dialog open releases the capture (a `keepalive` DELETE sent
|
||||
// on `pagehide`, which also closes the dialog), and closing the dialog while its save is in flight keeps the capture
|
||||
// until the save settles, then still reports the save. The fixture is tests/browser/workspace_tagging_fixture.py.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const FIRST = 'tagging/dunes_00001.png';
|
||||
const TAGS_URL = '**/prompt_manager/image-annotations/tags';
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const tagButton = page => page.locator(`article[data-image-key="0:${FIRST}"]`).getByRole('button', { name: 'Tag or auto-tag dunes_00001.png', exact: true });
|
||||
const openCaptures = async request => (await (await request.get('/__test__/tagging/captures')).json()).captures;
|
||||
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
|
||||
page.problems = [];
|
||||
page.on('pageerror', error => page.problems.push(`pageerror: ${error.message}`));
|
||||
page.on('console', message => { if (message.type() === 'error') page.problems.push(`console: ${message.text()}`); });
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.problems).toEqual([]);
|
||||
});
|
||||
|
||||
async function openTag(page) {
|
||||
await page.goto(images);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await tagButton(page).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(manual(page)).toBeEditable();
|
||||
}
|
||||
|
||||
test('leaving the page with the dialog open releases its capture without a console error (I4)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
expect(await openCaptures(request)).toBe(1);
|
||||
// Another document: the old one's pagehide closes the dialog and sends the release with keepalive.
|
||||
await page.goto('/prompt_manager/workspace?left=1#/tags');
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
|
||||
await expect.poll(() => openCaptures(request)).toBe(0);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('closing the dialog during a save releases the capture after the save and still reports it (I5)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('saved_after_close');
|
||||
const order = [];
|
||||
page.on('response', item => { if (item.url().endsWith('/image-annotations/tags')) order.push(`PUT ${item.status()}`); });
|
||||
page.on('request', item => { if (item.url().includes('/image-annotations/capture/') && item.method() === 'DELETE') order.push('DELETE'); });
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const sent = new Promise(resolve => {
|
||||
page.route(TAGS_URL, async route => { resolve(); await held; await route.continue(); });
|
||||
});
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await sent;
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect(await openCaptures(request)).toBe(1);
|
||||
expect(order).toEqual([]);
|
||||
release();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
await expect.poll(() => order).toEqual(['PUT 200', 'DELETE']);
|
||||
await expect.poll(() => openCaptures(request)).toBe(0);
|
||||
// The page followed the late save: the selected file's details show the new image tag.
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true }).getByText('saved_after_close', { exact: true })).toBeVisible();
|
||||
await page.unroute(TAGS_URL);
|
||||
});
|
||||
@@ -0,0 +1,749 @@
|
||||
// The Tag image dialog, the card Tag button and the Tags page hooks (#249 PR C, plan Task C2, Mock inventory M1, M2, M5)
|
||||
// against the real routes and the injected fake model (tests/browser/workspace_tagging_fixture.py). No model is ever
|
||||
// downloaded or loaded: the fake answers, and every test checks that no download request was sent.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { saveImageTags } from './concept-inventory.mjs';
|
||||
import { taggingInventory } from './tagging-canonical-comparison.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
|
||||
const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption (Recommended) · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
|
||||
// The concept's on-screen opening sentence (its renderTagModelOptions replaces the markup's at once, D83).
|
||||
const MODEL_CHANGED = 'Generate suggestions with the selected model before approving.';
|
||||
const CHIP_HINT = 'Remove anything you do not want to add.';
|
||||
const ALL_REMOVED = 'All suggestions removed. Generate again or enter tags manually.';
|
||||
const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
|
||||
const tagButton = (page, path) => card(page, path).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
|
||||
const VIDEO = 'tagging/clip.webm', UNLINKED = 'loose/unlinked.png';
|
||||
|
||||
let manifest;
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
const response = await request.post('/__test__/tagging/reset', { data: {} });
|
||||
await expect(response).toBeOK();
|
||||
manifest = await response.json();
|
||||
page.taggingErrors = [];
|
||||
page.on('pageerror', error => page.taggingErrors.push(error.message));
|
||||
page.downloads = [];
|
||||
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.taggingErrors).toEqual([]);
|
||||
expect(page.downloads).toEqual([]);
|
||||
});
|
||||
|
||||
/** Images, then the Tag dialog of `path`, settled on its observation (the manual field holds the saved tags). */
|
||||
async function openTag(page, path = FIRST, { goto = true } = {}) {
|
||||
if (goto) await page.goto(images);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await tagButton(page, path).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(dialog(page).locator('img.tag-image-preview, video.tag-image-preview')).toHaveAttribute('src', /\/prompt_manager\/image-annotations\/capture\//);
|
||||
}
|
||||
async function generated(page) {
|
||||
const answer = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await generate(page).click();
|
||||
return answer;
|
||||
}
|
||||
/** Read the saved tags of `path` through the real observe route, then release that capture. */
|
||||
async function savedTags(request, path) {
|
||||
const target = { root_index: 0, root_scope: await scopeOf(request), path };
|
||||
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
|
||||
expect(observed.success).toBe(true);
|
||||
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
|
||||
return observed.tags;
|
||||
}
|
||||
async function scopeOf(request) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
return listing.images[0].root_scope;
|
||||
}
|
||||
|
||||
test('tag dialog control inventory and order match M2', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await expect(page.locator('dialog#modal')).toHaveClass(/(^| )tagging-modal( |$)/);
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Tag image');
|
||||
const body = dialog(page).locator('#modal-body');
|
||||
expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(
|
||||
['tag-image-heading', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
|
||||
const heading = body.locator('.tag-image-heading');
|
||||
await expect(heading.locator('h3')).toHaveText(manifest.title);
|
||||
await expect(heading.locator('p.small')).toHaveText('dunes_00001.png');
|
||||
await expect(heading.locator('span.chip')).toHaveText('96 × 64');
|
||||
await expect(heading.locator('img.tag-image-preview')).toHaveAttribute('alt', manifest.title);
|
||||
await expect(body.locator('.tag-scope-note')).toHaveCount(0);
|
||||
await expect(manual(page)).toHaveAttribute('id', 'manual-image-tags');
|
||||
await expect(manual(page)).toHaveValue('');
|
||||
await expect(manual(page)).toHaveAccessibleDescription('Comma-separated. Generated suggestions are added only after you approve them.');
|
||||
const panel = body.locator('.tag-model-panel');
|
||||
await expect(panel.locator('h3')).toHaveText('Auto-tag this image');
|
||||
await expect(model(page).locator('option')).toHaveText(MODEL_LABELS);
|
||||
expect(await model(page).locator('option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
|
||||
await expect(model(page)).toHaveValue('wd14-swinv2');
|
||||
const options = panel.locator('#image-tag-model-options');
|
||||
const general = options.getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
const character = options.getByRole('spinbutton', { name: 'Character confidence', exact: true });
|
||||
const limit = options.getByRole('spinbutton', { name: 'Suggestions', exact: true });
|
||||
await expect(options.locator('.two-col .field')).toHaveCount(2);
|
||||
await expect(general).toHaveValue('0.35');
|
||||
await expect(character).toHaveValue('0.85');
|
||||
await expect(limit).toHaveValue('10');
|
||||
for (const [input, attributes] of [[general, ['0', '1', '0.05']], [character, ['0', '1', '0.05']], [limit, ['1', '100', '1']]]) {
|
||||
expect(await input.evaluate(node => [node.min, node.max, node.step])).toEqual(attributes);
|
||||
}
|
||||
await expect(generate(page)).toHaveClass(/ca-primary/);
|
||||
await expect(generate(page).locator('svg.icon')).toHaveCount(1);
|
||||
const suggestions = body.locator('section.tag-suggestion-panel');
|
||||
await expect(suggestions).toHaveAttribute('aria-labelledby', 'tag-suggestions-title');
|
||||
await expect(suggestions.locator('h3#tag-suggestions-title')).toHaveText('Review suggestions');
|
||||
await expect(suggestions.locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(suggestions.locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
|
||||
await expect(status(page)).toHaveAttribute('role', 'status');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(body.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
|
||||
await expect(body.locator('.dialog-actions button').last()).toHaveClass(/ca-primary/);
|
||||
await model(page).selectOption('gguf');
|
||||
await expect(options.getByRole('textbox', { name: 'Tagging instructions', exact: true })).toHaveValue('Describe the image');
|
||||
await expect(options.getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('10');
|
||||
await expect(options.locator('.two-col')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('generate shows real fixture percentages for WD14 and Suggested for GGUF, at most ten chips', async ({ page }) => {
|
||||
await openTag(page);
|
||||
const answer = await generated(page);
|
||||
expect(answer.request().postDataJSON()).toMatchObject({ model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } });
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
// D83: ruling T4's instruction leads the chips, only while chips exist.
|
||||
await expect(dialog(page).locator('#tag-suggestions > *')).toHaveCount(2);
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(CHIP_HINT);
|
||||
await expect(chips(page).first().locator(':scope > span').first()).toHaveText('sandstone');
|
||||
await expect(chips(page).locator('.tag-confidence').first()).toHaveText('98%');
|
||||
await expect(chips(page).nth(1).locator('.tag-confidence')).toHaveText('97%');
|
||||
await expect(chips(page).first().getByRole('button', { name: 'Remove sandstone', exact: true })).toBeVisible();
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('10 suggestions');
|
||||
await expect(status(page)).toHaveText('Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
for (const id of ['gguf', 'hf']) {
|
||||
await model(page).selectOption(id);
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(chips(page).locator('.tag-confidence')).toHaveText(Array(10).fill('Suggested'));
|
||||
await expect(status(page)).toHaveText(`Suggestions from ${MODEL_LABELS[id === 'gguf' ? 2 : 3]}. Approve tags to save.`);
|
||||
}
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
const fewer = await generated(page);
|
||||
expect(fewer.request().postDataJSON().options).toEqual({ instructions: 'Describe the image', suggestion_limit: 3 });
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('3 suggestions');
|
||||
});
|
||||
|
||||
test('removing chips and editing manually then approving persists across reload', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('Minimal, sky_blue');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
const remove = dialog(page).getByRole('button', { name: 'Remove desert', exact: true });
|
||||
await remove.click();
|
||||
await expect(chips(page)).toHaveCount(2);
|
||||
// Focus moves to the next chip's ×.
|
||||
await expect(dialog(page).getByRole('button', { name: 'Remove arch', exact: true })).toBeFocused();
|
||||
const saved = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
expect((await saved).request().postDataJSON().tags).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await savedTags(request, FIRST)).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await page.reload();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await expect(manual(page)).toHaveValue('Minimal, sky_blue, sandstone, arch');
|
||||
// Removing every chip shows the all-removed copy; the manual set still saves.
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2');
|
||||
await generated(page);
|
||||
for (const name of ['sandstone', 'desert']) await dialog(page).getByRole('button', { name: `Remove ${name}`, exact: true }).click();
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(ALL_REMOVED);
|
||||
await expect(generate(page)).toBeFocused();
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('cancel escape and close write nothing', async ({ page, request }) => {
|
||||
const writes = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/tags')) writes.push(item.method()); });
|
||||
await page.goto(images);
|
||||
for (const dismiss of [
|
||||
() => dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(),
|
||||
() => page.keyboard.press('Escape'),
|
||||
() => dialog(page).getByRole('button', { name: 'Close dialog', exact: true }).click(),
|
||||
]) {
|
||||
const released = page.waitForResponse(item => item.url().includes('/image-annotations/capture/') && item.request().method() === 'DELETE');
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('never_saved');
|
||||
await dismiss();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect((await released).ok()).toBe(true);
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
await expect(page.locator('dialog#modal.tagging-modal')).toHaveCount(0);
|
||||
}
|
||||
expect(writes).toEqual([]);
|
||||
expect(await savedTags(request, FIRST)).toEqual([]);
|
||||
});
|
||||
|
||||
test('model change discards suggestions and relabels primary', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('kept');
|
||||
await generated(page);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5');
|
||||
await model(page).selectOption('wd14-vit');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Save manual tags');
|
||||
await expect(manual(page)).toHaveValue('kept');
|
||||
// Like the concept, the new model's fields start from the defaults.
|
||||
await expect(dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.35');
|
||||
});
|
||||
|
||||
test('invalid threshold reports validity without request', async ({ page }) => {
|
||||
const sent = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) sent.push(item.url()); });
|
||||
await openTag(page);
|
||||
const general = dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
await general.fill('1.5');
|
||||
await generate(page).click();
|
||||
await expect(general).toBeFocused();
|
||||
expect(await general.evaluate(node => node.validity.rangeOverflow)).toBe(true);
|
||||
await general.fill('');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => general.evaluate(node => node.validationMessage)).toBe('Enter a confidence from 0 to 1.');
|
||||
await general.fill('0.35');
|
||||
expect(await general.evaluate(node => node.validity.valid)).toBe(true);
|
||||
await model(page).selectOption('gguf');
|
||||
const instructions = dialog(page).getByRole('textbox', { name: 'Tagging instructions', exact: true });
|
||||
await instructions.fill(' ');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => instructions.evaluate(node => node.validationMessage)).toBe('Enter tagging instructions of at most 4000 characters.');
|
||||
await expect(status(page)).toHaveText('');
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
test('unavailable model shows inline status and never downloads', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTag(page);
|
||||
expect((await generated(page)).status()).toBe(409);
|
||||
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
// D116: Retry leads the actions while the controller allows it, and generates again.
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Retry', 'Cancel', 'Save manual tags']);
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
const again = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await again).status()).toBe(200);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Approve selected tags']);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
|
||||
});
|
||||
|
||||
test('video tag is manual only', async ({ page, request }) => {
|
||||
await openTag(page, VIDEO);
|
||||
const preview = dialog(page).locator('video.tag-image-preview');
|
||||
await expect(preview).toHaveAttribute('preload', 'metadata');
|
||||
expect(await preview.evaluate(node => node.muted)).toBe(true);
|
||||
await expect(dialog(page).locator('.tag-image-heading span.chip')).toHaveCount(0);
|
||||
await expect(generate(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(status(page)).toHaveText('Auto-tagging is available for supported still images only.');
|
||||
let suggested = false;
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggested = true; });
|
||||
// Pressing the aria-disabled Generate sends nothing.
|
||||
await generate(page).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await manual(page).fill('clip_tag');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for clip.webm.');
|
||||
expect(suggested).toBe(false);
|
||||
expect(await savedTags(request, VIDEO)).toEqual(['clip_tag']);
|
||||
});
|
||||
|
||||
test('sticky footer reachable at 390x844 while content scrolls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openTag(page);
|
||||
await model(page).selectOption('gguf');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
const shell = page.locator('dialog#modal');
|
||||
const actions = dialog(page).locator('.dialog-actions');
|
||||
expect(await actions.evaluate(node => getComputedStyle(node).position)).toBe('sticky');
|
||||
expect(await shell.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true);
|
||||
for (const top of [0, 10_000]) {
|
||||
await shell.evaluate((node, value) => { node.scrollTop = value; }, top);
|
||||
const box = await actions.boundingBox(), frame = await shell.boundingBox();
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height + 1);
|
||||
expect(box.y).toBeGreaterThanOrEqual(frame.y);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true })).toBeInViewport();
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('focus starts on close and returns to the Tag trigger', async ({ page }) => {
|
||||
await openTag(page, SECOND);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Close dialog', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(tagButton(page, SECOND)).toBeFocused();
|
||||
});
|
||||
|
||||
test('every Images and Library gallery card has Info then Tag with mock labels', async ({ page }) => {
|
||||
const expectCards = async locator => {
|
||||
const labels = await locator.evaluateAll(nodes => nodes.map(node => {
|
||||
const buttons = [...node.querySelectorAll('.media-info > .image-card-actions > button')];
|
||||
return { file: node.querySelector('.image-open').getAttribute('aria-label').replace(/^Open /, ''),
|
||||
buttons: buttons.map(button => [button.className, button.getAttribute('aria-label'), button.querySelector('svg.icon') !== null]) };
|
||||
}));
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
for (const { file, buttons } of labels) {
|
||||
expect(buttons).toEqual([['icon-btn', `Inspect ${file}`, true], ['icon-btn', `Tag or auto-tag ${file}`, true]]);
|
||||
}
|
||||
return labels.map(({ file }) => file);
|
||||
};
|
||||
await page.goto(images);
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(manifest.files.length);
|
||||
expect(await expectCards(page.locator('article[data-image-key]'))).toEqual(manifest.files.map(file => file.filename));
|
||||
await page.goto(`/prompt_manager/workspace#/library/${manifest.positive_id}`);
|
||||
const gallery = page.locator('.library-history .history-gallery article[data-image-key]');
|
||||
await expect(gallery.first()).toBeVisible();
|
||||
await expectCards(gallery);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await gallery.first().getByRole('button', { name: /^Tag or auto-tag / }).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(gallery.first().getByRole('button', { name: /^Tag or auto-tag / })).toBeFocused();
|
||||
});
|
||||
|
||||
test('Tag opens the dialog without selecting the card', async ({ page }) => {
|
||||
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
const before = await panel.innerText();
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card(page, SECOND)).not.toHaveClass(/\bselected\b/);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
expect(await panel.innerText()).toBe(before);
|
||||
});
|
||||
|
||||
test('tagging one of two cards sharing a prompt leaves the other card, the prompt and the pair unchanged', async ({ page, request }) => {
|
||||
const before = await (await request.get('/__test__/tagging/snapshot')).json();
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
const pairBefore = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
await openTag(page, FIRST);
|
||||
await manual(page).fill('only_this_file');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['only_this_file']);
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
expect(await (await request.get('/__test__/tagging/snapshot')).json()).toEqual(before);
|
||||
const pairAfter = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
expect(pairAfter.success).toBe(true);
|
||||
expect({ ...pairAfter, library_epoch: null }).toEqual({ ...pairBefore, library_epoch: null });
|
||||
});
|
||||
|
||||
test('Tags page Auto-tag images opens the dialog for the selected image and is disabled without one', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/workspace#/tags');
|
||||
const autoTag = page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true });
|
||||
await expect(page.locator('#workspace-main .heading-actions button')).toHaveText(['+ Create tag', 'Auto-tag images']);
|
||||
await expect(autoTag).toHaveClass(/ca-primary/);
|
||||
await expect(autoTag).toHaveAttribute('aria-disabled', 'true');
|
||||
// An aria-disabled control still takes a press; it only explains.
|
||||
await autoTag.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Select an image in Images first.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await nav(page, 'Images').click();
|
||||
await card(page, SECOND).getByRole('button', { name: 'Inspect dunes_00002.png', exact: true }).click();
|
||||
await expect(card(page, SECOND)).toHaveClass(/selected/);
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(autoTag).not.toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await autoTag.click();
|
||||
expect((await observed).request().postDataJSON().target.path).toBe(SECOND);
|
||||
await expect(dialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(autoTag).toBeFocused();
|
||||
});
|
||||
|
||||
test('Images column shows image_count and updates after save', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await card(page, FIRST).getByRole('button', { name: 'Inspect dunes_00001.png', exact: true }).click();
|
||||
await nav(page, 'Tags & categories').click();
|
||||
const row = name => page.locator('#workspace-main table tbody tr').filter({ has: page.locator('td:first-child').getByText(name, { exact: true }) });
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
|
||||
await expect(manual(page)).toBeVisible();
|
||||
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
|
||||
await manual(page).fill('minimal, editorial');
|
||||
const reread = page.waitForResponse(item => item.url().includes('/prompt_manager/vocabulary/tags'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('warm_light').locator('td:nth-child(3)')).toHaveText('0');
|
||||
});
|
||||
|
||||
test('virtualized cards release listeners on unmount', async ({ page, request }) => {
|
||||
const extra = 140;
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: extra } })).toBeOK();
|
||||
const count = () => page.evaluate(async url => (await import(url)).imageTagsListenerCount(), MODULE);
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
const mounted = await count();
|
||||
// Leaving and returning to Images several times keeps one subscription per mounted page.
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
|
||||
await nav(page, 'Images').click();
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
}
|
||||
expect(await count()).toBe(mounted);
|
||||
// Cards scrolled out are unmounted; the delegated Tag still works on a card rendered again.
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
await expect(async () => {
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(continuation).toContainText('You’re all caught up', { timeout: 1000 });
|
||||
}).toPass();
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(card(page, FIRST)).toHaveCount(0);
|
||||
expect(await count()).toBe(mounted);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await count()).toBe(mounted);
|
||||
});
|
||||
|
||||
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
|
||||
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
|
||||
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
|
||||
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
|
||||
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
|
||||
|
||||
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
// D21/D39: the linked recipe's own tag, plain.
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('dunes_tag, Editorial');
|
||||
const reread = inspected(page, FIRST);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
|
||||
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
});
|
||||
|
||||
test('clicking an image tag chip filters Images to the tagged unlinked file', async ({ page, request }) => {
|
||||
expect(await saveImageTags(request, UNLINKED, ['loose_tag'])).toEqual(['loose_tag']);
|
||||
await page.goto(images);
|
||||
const read = inspected(page, UNLINKED);
|
||||
await infoButton(page, UNLINKED).click();
|
||||
await read;
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
|
||||
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
|
||||
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
|
||||
await listing;
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('viewer details show the same chips', async ({ page, request }) => {
|
||||
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await page.goto(images);
|
||||
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await card(page, FIRST).locator('.image-open').click();
|
||||
const details = page.locator('#viewer-dialog[open] #viewer-details');
|
||||
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
const row = details.locator('.tags').last();
|
||||
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
|
||||
await expect(row.locator('span.chip')).toHaveCount(3);
|
||||
await expect(row.locator('button')).toHaveCount(0);
|
||||
// A file without image tags: the viewer shows only D21's linked tags.
|
||||
await page.getByRole('button', { name: 'Next media', exact: true }).click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
|
||||
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
});
|
||||
|
||||
test('search finds an image by its image tag', async ({ page, request }) => {
|
||||
await saveImageTags(request, UNLINKED, ['Loose_Tag']);
|
||||
await page.goto(images);
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(manifest.files.length);
|
||||
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('q') === 'loose_t');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('loose_t');
|
||||
await listing;
|
||||
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
});
|
||||
|
||||
// Task C4 rulings: a save re-reads the selected file's details without a loading flash, and under a Tags filter or a
|
||||
// search the loaded listing is re-read in place (scroll and selection kept while the file still matches).
|
||||
const cards = page => page.locator('.gallery-body article[data-image-key]');
|
||||
const listingOf = (page, check) => page.waitForResponse(item => item.url().includes('/images/output?') && check(new URL(item.url()).searchParams));
|
||||
|
||||
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
await page.evaluate(() => {
|
||||
const node = document.querySelector('aside[aria-label="Selected image details"]');
|
||||
window.__loadingSeen = false;
|
||||
window.__loadingWatch = new MutationObserver(() => { if (node.textContent.includes('Loading file details')) window.__loadingSeen = true; });
|
||||
window.__loadingWatch.observe(node, { childList: true, subtree: true, characterData: true });
|
||||
});
|
||||
let release;
|
||||
const held = new Promise(resolve => { release = resolve; });
|
||||
const matcher = url => url.pathname === `/prompt_manager/images/inspect/${FIRST}`;
|
||||
await page.route(matcher, async route => { await held; await route.continue(); });
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('held_tag');
|
||||
const reread = page.waitForRequest(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${FIRST}`);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
// While the re-read is held, the panel still shows the file's details.
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
|
||||
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
|
||||
const landed = inspected(page, FIRST);
|
||||
release();
|
||||
await landed;
|
||||
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
|
||||
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
|
||||
await page.unroute(matcher);
|
||||
});
|
||||
|
||||
test('a save under a Tags filter re-reads the listing: the selection stays while it matches and follows when it leaves', async ({ page, request }) => {
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await page.goto(images);
|
||||
const read = inspected(page, SECOND);
|
||||
await infoButton(page, SECOND).click();
|
||||
await read;
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// The newest match (SECOND) is selected once the filter applies.
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
// Both matches checked: the selection bar keeps only what the refreshed result still lists (I7).
|
||||
for (const path of [SECOND, UNLINKED]) await card(page, path).getByRole('checkbox', { name: `Select ${path.split('/').pop()}`, exact: true }).check();
|
||||
const bulkbar = page.locator('#bulkbar .bulkbar');
|
||||
await expect(bulkbar.locator('strong')).toHaveText('2 selected');
|
||||
// Tagging the other match keeps both cards and SECOND selected; the listing is re-read.
|
||||
await openTag(page, UNLINKED, { goto: false });
|
||||
await manual(page).fill('shared, extra');
|
||||
let reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
await expect(tagButton(page, UNLINKED)).toBeFocused();
|
||||
// SECOND loses the filter tag: it leaves the result and the panel follows the remaining match.
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await manual(page).fill('');
|
||||
reread = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
const next = inspected(page, UNLINKED);
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await next;
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, SECOND)).toHaveCount(0);
|
||||
await expect(card(page, UNLINKED)).toHaveClass(/\bselected\b/);
|
||||
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
|
||||
await expect(page.getByText('1 of 1 image', { exact: false }).first()).toBeVisible();
|
||||
await expect(bulkbar.locator('strong')).toHaveText('1 selected');
|
||||
await expect(card(page, UNLINKED).getByRole('checkbox')).toBeChecked();
|
||||
// Export metadata acts on exactly the one file the bar counts.
|
||||
const exported = [];
|
||||
page.on('request', item => { if (item.url().includes('/prompt_manager/images/inspect/')) exported.push(new URL(item.url()).pathname); });
|
||||
const download = page.waitForEvent('download');
|
||||
await bulkbar.getByRole('button', { name: 'Export metadata', exact: true }).click();
|
||||
await download;
|
||||
expect(exported).toEqual([`/prompt_manager/images/inspect/${UNLINKED}`]);
|
||||
});
|
||||
|
||||
test('a save under a search keeps the scroll position and the loaded range', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: 140 } })).toBeOK();
|
||||
await page.goto(images);
|
||||
const searched = listingOf(page, query => query.get('q') === 'bulk');
|
||||
await page.getByRole('searchbox', { name: 'Search workspace', exact: true }).fill('bulk');
|
||||
await searched;
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
// Load at least two pages (the search lists 40 a page), then rest a third of the way down.
|
||||
const loadedCount = async () => {
|
||||
const text = await continuation.textContent();
|
||||
return /caught up/.test(text) ? 140 : Number(/^(\d+) of 140/.exec(text)?.[1] ?? 0);
|
||||
};
|
||||
await expect.poll(async () => { await continuation.scrollIntoViewIfNeeded(); return loadedCount(); }).toBeGreaterThanOrEqual(80);
|
||||
await page.evaluate(() => window.scrollTo(0, Math.round(document.documentElement.scrollHeight / 3)));
|
||||
await expect(continuation).not.toBeInViewport();
|
||||
const loaded = await continuation.textContent();
|
||||
await expect.poll(() => continuation.textContent()).toBe(loaded);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeGreaterThan(0);
|
||||
const visible = await cards(page).evaluateAll(nodes => nodes.find(node => { const box = node.getBoundingClientRect(); return box.top > 80 && box.bottom < innerHeight; })?.dataset.imageKey);
|
||||
expect(visible).toBeTruthy();
|
||||
const path = visible.slice(2);
|
||||
await openTag(page, path, { goto: false });
|
||||
await manual(page).fill('kept_in_place');
|
||||
const reread = listingOf(page, query => query.get('q') === 'bulk' && !query.has('cursor'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(tagButton(page, path)).toBeFocused();
|
||||
await expect(continuation).toHaveText(loaded);
|
||||
expect(Math.abs(await page.evaluate(() => window.scrollY) - y)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Final review round (I1, I2, I9, M5, M6).
|
||||
test('the option fields show the server defaults that Generate sends (I1)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK();
|
||||
await openTag(page);
|
||||
const field = name => dialog(page).getByRole('spinbutton', { name, exact: true });
|
||||
await expect(field('General confidence')).toHaveValue('0.5');
|
||||
await expect(field('Character confidence')).toHaveValue('0.9');
|
||||
await expect(field('Suggestions')).toHaveValue('7');
|
||||
const answer = await generated(page);
|
||||
expect(answer.request().postDataJSON().options).toEqual({ general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 });
|
||||
await expect(chips(page)).toHaveCount(7);
|
||||
// A model change re-renders the new model's fields from the server defaults, and keeps the count (D91).
|
||||
await field('Suggestions').fill('4');
|
||||
await model(page).selectOption('wd14-vit');
|
||||
await expect(field('General confidence')).toHaveValue('0.5');
|
||||
await expect(field('Suggestions')).toHaveValue('4');
|
||||
});
|
||||
|
||||
test('a tag conflict shows the other writer\'s tags before Save, and a second Save keeps them (I2)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('mine');
|
||||
// Another writer (a second tab, the legacy page) saves while the dialog is open.
|
||||
expect(await saveImageTags(request, FIRST, ['changed_elsewhere'])).toEqual(['changed_elsewhere']);
|
||||
const conflict = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await conflict).status()).toBe(409);
|
||||
await expect(status(page)).toContainText('Saved now: changed_elsewhere.');
|
||||
await expect(manual(page)).toHaveValue('changed_elsewhere, mine');
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
const second = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await second).request().postDataJSON().tags).toEqual(['changed_elsewhere', 'mine']);
|
||||
await expect(toast(page)).toHaveText('2 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['changed_elsewhere', 'mine']);
|
||||
});
|
||||
|
||||
test('D116: Retry leads the actions after an unconfirmed save and resends the same tags', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('retried');
|
||||
let lost = true;
|
||||
await page.route('**/prompt_manager/image-annotations/tags', route => (lost ? (lost = false, route.fulfill({ status: 504, body: 'Gateway Timeout' })) : route.continue()));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(status(page)).toHaveText('The save could not be confirmed. Retry sends the same tags again.');
|
||||
const inventory = await page.locator('dialog#modal').evaluate(taggingInventory);
|
||||
expect(inventory.actions.buttons.map(button => [button.text, button.primary, button.disabled])).toEqual(
|
||||
[['Retry', false, false], ['Cancel', false, false], ['Save manual tags', true, true]]);
|
||||
expect(inventory.actions.buttons[0].minHeight).toBe(inventory.actions.buttons[1].minHeight);
|
||||
const resent = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await resent).request().postDataJSON().tags).toEqual(['retried']);
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['retried']);
|
||||
await page.unroute('**/prompt_manager/image-annotations/tags');
|
||||
});
|
||||
|
||||
test('D116: Retry after an expired capture observes again and keeps the typed draft (M5)', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('kept_draft');
|
||||
// The store forgets every capture (as its idle sweep would), so Save answers capture-expired.
|
||||
await expect(await request.post('/__test__/tagging/suggestions', { data: { scored: null } })).toBeOK();
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
|
||||
await expect(manual(page)).toHaveValue('kept_draft');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['kept_draft']);
|
||||
});
|
||||
|
||||
test('a tag save from Tags reloads a filtered Images visit instead of restoring it (M6)', async ({ page, request }) => {
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
|
||||
// Tags › Auto-tag images tags the selected SECOND without the filter tag.
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
|
||||
await expect(manual(page)).toHaveValue('shared');
|
||||
await manual(page).fill('other');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00002.png.');
|
||||
const reloaded = listingOf(page, query => query.get('tags') === 'shared' && !query.has('cursor'));
|
||||
await nav(page, 'Images').click();
|
||||
await reloaded;
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(card(page, UNLINKED)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Filters · 1', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('a failed listing re-read after a save says so and keeps the cards (M2)', async ({ page, request }) => {
|
||||
await saveImageTags(request, SECOND, ['shared']);
|
||||
await saveImageTags(request, UNLINKED, ['shared']);
|
||||
await page.goto(images);
|
||||
await infoButton(page, SECOND).click();
|
||||
const filtered = listingOf(page, query => query.get('tags') === 'shared');
|
||||
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
|
||||
await filtered;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await manual(page).fill('');
|
||||
const failing = url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('tags') === 'shared';
|
||||
await page.route(failing, route => route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' }));
|
||||
const reread = page.waitForResponse(item => failing(new URL(item.url())));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
expect((await reread).status()).toBe(500);
|
||||
await expect(toast(page)).toHaveText('Images could not be refreshed after the tag save. Refresh image collection to retry.');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(page.locator('#gallery-continuation [aria-disabled="true"]')).toHaveCount(0);
|
||||
await page.unroute(failing);
|
||||
});
|
||||
@@ -107,7 +107,7 @@ 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-history .history-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button:first-child').first().click();
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
@@ -296,7 +296,7 @@ async function containedImageContent(page) {
|
||||
const failures = [];
|
||||
for (const card of nodes) {
|
||||
const outer = card.getBoundingClientRect();
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions']) {
|
||||
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 ||
|
||||
@@ -306,7 +306,7 @@ async function containedImageContent(page) {
|
||||
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
|
||||
}
|
||||
}
|
||||
const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
|
||||
const action = card.querySelector('.image-card-actions').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 &&
|
||||
@@ -351,7 +351,7 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
expect(filename).toMatch(/layered-context-.*\.png/);
|
||||
// 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 longCard.locator('.image-card-actions button:first-child').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
// D19: the card image, not the panel, opens the viewer.
|
||||
const fullPreview = longCard.locator('.image-open');
|
||||
@@ -420,7 +420,7 @@ 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-card-actions button').click(); await selected.locator('.image-open').focus();
|
||||
await selected.locator('.image-card-actions button:first-child').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(() => {
|
||||
@@ -497,7 +497,7 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(40);
|
||||
await cards.first().locator('.image-card-actions button').click();
|
||||
await cards.first().locator('.image-card-actions button:first-child').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);
|
||||
@@ -609,7 +609,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
await page.setViewportSize({width,height});
|
||||
for (const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.media-card .image-card-actions button').first().click();
|
||||
await page.locator('.media-card .image-card-actions button:first-child').first().click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
|
||||
@@ -621,7 +621,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
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 longCard.locator('.image-card-actions button:first-child').click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
|
||||
|
||||
@@ -50,9 +50,12 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator(':scope > .heading > .heading-actions button');
|
||||
await expect(actions).toHaveText(['+ Create tag']);
|
||||
// The concept's heading actions (#249 C2 retired #245 D2): Auto-tag images is primary; with no image selected in
|
||||
// Images it is aria-disabled (D115).
|
||||
await expect(actions).toHaveText(['+ Create tag','Auto-tag images']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
await expect(actions.last()).toHaveClass(/ca-primary/);
|
||||
await expect(actions.last()).toHaveAttribute('aria-disabled','true');
|
||||
await expect(surfaces(page)).toHaveCount(2);
|
||||
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
|
||||
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
|
||||
@@ -65,9 +68,9 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
expect(manifest.tags).toEqual(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
|
||||
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
|
||||
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
|
||||
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
|
||||
// Images counts the files carrying each tag (#249 C2 retired #245 D3); this fixture tags no image.
|
||||
await expect(rows(page).locator('td:nth-child(3)')).toHaveText(expected.map(()=>'0'));
|
||||
await expect(rows(page).locator('td:nth-child(3) .ca-visually-hidden')).toHaveCount(0);
|
||||
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
|
||||
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
|
||||
const operations=surfaces(page).nth(1).locator('.operation');
|
||||
|
||||
@@ -158,6 +158,9 @@ 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)
|
||||
# The same 4:3 shape with a short "96 × 72 · PNG" subtitle that stays on one line at
|
||||
# 390px beside the card's Info and Tag (the D3 footer check).
|
||||
SHORT_SEEDLESS_SIZE = (96, 72)
|
||||
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
|
||||
_TABLES = (
|
||||
"execution_run_outputs",
|
||||
@@ -326,9 +329,9 @@ def _record_run(
|
||||
return start.run_id
|
||||
|
||||
|
||||
def _write_seedless_png(path: Path) -> None:
|
||||
def _write_seedless_png(path: Path, size: tuple[int, int] = SEEDLESS_SIZE) -> None:
|
||||
"""A plain PNG with no metadata: no prompt, no seed, no link."""
|
||||
with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
|
||||
with Image.new("RGB", size, "#2b3442") as image:
|
||||
image.save(path)
|
||||
|
||||
|
||||
@@ -345,13 +348,17 @@ def seed_workspace_concept(
|
||||
*,
|
||||
extra_images: int = 0,
|
||||
seedless_images: int = 0,
|
||||
short_seedless: bool = False,
|
||||
) -> 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.
|
||||
``seedless_images`` adds unlinked PNGs without metadata, newest of all;
|
||||
``short_seedless`` makes them ``SHORT_SEEDLESS_SIZE`` instead.
|
||||
"""
|
||||
if not isinstance(short_seedless, bool):
|
||||
raise TypeError("short_seedless must be a boolean")
|
||||
_bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
|
||||
_bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
|
||||
_clear(api, root)
|
||||
@@ -459,7 +466,9 @@ def seed_workspace_concept(
|
||||
for number in range(seedless_images):
|
||||
name = f"seedless_{number + 1:05}.png"
|
||||
path = folder / name
|
||||
_write_seedless_png(path)
|
||||
_write_seedless_png(
|
||||
path, SHORT_SEEDLESS_SIZE if short_seedless else SEEDLESS_SIZE
|
||||
)
|
||||
stamp = now - 10 - number * 10
|
||||
os.utime(path, (stamp, stamp))
|
||||
seedless.append({"filename": name, "path": f"concepts/{name}"})
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Owned, deterministic files for the Tag image dialog workflows (#249 PR C).
|
||||
|
||||
Two files share one positive prompt and one saved pair, an equal-byte copy of
|
||||
the first sits at another path, and a video and an unlinked file follow. The
|
||||
injected fake model (``tests/browser/server.py`` ``TestModel``) answers
|
||||
Generate; no model file exists and nothing is ever downloaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
POSITIVE = (
|
||||
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
|
||||
"terracotta, late afternoon sun, architectural photography, quiet geometry"
|
||||
)
|
||||
NEGATIVE = "plastic, harsh shadows, noise, watermark"
|
||||
TITLE = "Quiet geometry"
|
||||
PROMPT_TAGS = ["minimal", "warm_light"]
|
||||
PAIR_TAGS = ["editorial"]
|
||||
# Newest first, so the Images order (Recently added) is the manifest's.
|
||||
FILES = (
|
||||
("tagging/dunes_00001.png", "linked"),
|
||||
("tagging/dunes_00002.png", "linked"),
|
||||
("copies/dunes_00001.png", "copy"),
|
||||
("tagging/clip.webm", "video"),
|
||||
("loose/unlinked.png", "unlinked"),
|
||||
)
|
||||
MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
# Every prompt-side owner row a Tag save could touch, for before/after
|
||||
# comparisons: tagging one image never changes a prompt, a pair or a link.
|
||||
_SNAPSHOT = {
|
||||
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
|
||||
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
|
||||
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
|
||||
" archived, updated_at FROM prompt_pairs ORDER BY id",
|
||||
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
|
||||
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
|
||||
" ORDER BY id",
|
||||
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
|
||||
" ORDER BY id",
|
||||
}
|
||||
|
||||
|
||||
def _png(path: Path, colour: tuple[int, int, int], size=(96, 64)) -> None:
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": POSITIVE}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "seed": 7},
|
||||
},
|
||||
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
||||
}
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", size, colour) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
|
||||
|
||||
def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> dict:
|
||||
"""Replace this server's disposable rows and files with the tagging set."""
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
output = root / "output"
|
||||
_png(output / FILES[0][0], (196, 140, 92))
|
||||
_png(output / FILES[1][0], (150, 110, 80))
|
||||
(output / FILES[2][0]).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(output / FILES[0][0], output / FILES[2][0])
|
||||
shutil.copyfile(
|
||||
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
|
||||
output / FILES[3][0],
|
||||
)
|
||||
_png(output / FILES[4][0], (60, 70, 90), size=(64, 64))
|
||||
now = time.time()
|
||||
for index, (relative, _) in enumerate(FILES):
|
||||
stamp = now - 60 - index * 10
|
||||
os.utime(output / relative, (stamp, stamp))
|
||||
for number in range(extra_images):
|
||||
extra = output / "bulk" / f"bulk_{number:04}.png"
|
||||
extra.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(output / FILES[4][0], extra)
|
||||
stamp = now - 600 - number
|
||||
os.utime(extra, (stamp, stamp))
|
||||
|
||||
receipt = db.create_library_recipe(
|
||||
POSITIVE,
|
||||
NEGATIVE,
|
||||
expected_epoch=db.get_execution_epoch(),
|
||||
parent_metadata={
|
||||
"title": TITLE,
|
||||
"category": "Architecture",
|
||||
"tags": list(PROMPT_TAGS),
|
||||
},
|
||||
)
|
||||
positive_id = receipt["positive"]["id"]
|
||||
pair_id = receipt["pair"]["id"]
|
||||
with db.model.get_connection() as conn:
|
||||
tag_ids = []
|
||||
for name in PAIR_TAGS:
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
|
||||
tag_ids.append(
|
||||
conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()[0]
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_pair_tags (pair_id, tag_id) VALUES (?, ?)",
|
||||
[(pair_id, tag_id) for tag_id in tag_ids],
|
||||
)
|
||||
for relative, kind in FILES:
|
||||
if kind in {"linked", "video"}:
|
||||
image_id = db.link_image_to_prompt(positive_id, str(output / relative))
|
||||
if kind == "linked":
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
"title": TITLE,
|
||||
"prompt_tags": list(PROMPT_TAGS),
|
||||
"pair_tags": list(PAIR_TAGS),
|
||||
"files": [
|
||||
{"path": relative, "filename": Path(relative).name, "kind": kind}
|
||||
for relative, kind in FILES
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tagging_snapshot(api: PromptManagerAPI) -> dict:
|
||||
"""Plain rows of every prompt, pair and link table."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
return {
|
||||
name: [list(row) for row in conn.execute(query)]
|
||||
for name, query in _SNAPSHOT.items()
|
||||
}
|
||||
|
||||
|
||||
def register_tagging_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
|
||||
) -> None:
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
|
||||
|
||||
@routes.post("/__test__/tagging/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if request.query or not isinstance(data, dict) or set(data) - {"extra_images"}:
|
||||
raise web.HTTPBadRequest()
|
||||
extra = data.get("extra_images", 0)
|
||||
if isinstance(extra, bool) or not isinstance(extra, int):
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= extra <= MAX_EXTRA_IMAGES:
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set()
|
||||
model.suggestions = None
|
||||
model.loaded = None
|
||||
_set_defaults(model, DEFAULTS)
|
||||
# A test that ends with the dialog open leaves its capture behind; drop
|
||||
# every capture so the next test starts below the store's limits.
|
||||
await api._run_in_executor(api.close_annotation_captures)
|
||||
manifest = await api._run_in_executor(seed_tagging, api, root, extra)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.get("/__test__/tagging/snapshot")
|
||||
async def snapshot(request: web.Request) -> web.Response:
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
return web.json_response(await api._run_in_executor(tagging_snapshot, api))
|
||||
|
||||
@routes.post("/__test__/tagging/models")
|
||||
async def models(request: web.Request) -> web.Response:
|
||||
"""Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"unavailable"}
|
||||
or not isinstance(data["unavailable"], list)
|
||||
or any(name not in MODELS for name in data["unavailable"])
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set(data["unavailable"])
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/tagging/suggestions")
|
||||
async def suggestions(request: web.Request) -> web.Response:
|
||||
"""Answer Generate with fixed scored names (the concept's own sample).
|
||||
|
||||
``{"scored": [[name, confidence], ...]}`` (at most 100, confidence in
|
||||
[0, 1]) or ``{"scored": null}`` for the default list. Every capture is
|
||||
released too, so a concept comparison starts below the store's limits
|
||||
whatever the previous test left open.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
scored = data.get("scored") if isinstance(data, dict) else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"scored"}
|
||||
or not (scored is None or _valid_scored(scored))
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.suggestions = (
|
||||
None if scored is None else [(name, float(value)) for name, value in scored]
|
||||
)
|
||||
model.unavailable = set()
|
||||
await api._run_in_executor(api.close_annotation_captures)
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/tagging/defaults")
|
||||
async def defaults(request: web.Request) -> web.Response:
|
||||
"""Serve other tagging defaults from ``GET /autotag/models``.
|
||||
|
||||
``{"general": g, "character": c, "limit": n}``: the WD14 thresholds
|
||||
(in [0, 1]) and the persisted suggestion count (1..100), so a test can
|
||||
tell the server's values from the dialog's built-in ones. Reset
|
||||
restores the standard 0.35 / 0.85 / 10.
|
||||
"""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if request.query or not _valid_defaults(data):
|
||||
raise web.HTTPBadRequest()
|
||||
_set_defaults(model, (data["general"], data["character"], data["limit"]))
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.get("/__test__/tagging/captures")
|
||||
async def captures(request: web.Request) -> web.Response:
|
||||
"""How many captures the store holds (a released capture is gone)."""
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
store = api._captures
|
||||
count = store.stats()["captures"] if store is not None else 0
|
||||
return web.json_response({"captures": count})
|
||||
|
||||
|
||||
# The standard tagging defaults: WD14 general and character thresholds, and
|
||||
# the suggestion count (``AutotagConfig``).
|
||||
DEFAULTS = (0.35, 0.85, 10)
|
||||
|
||||
|
||||
def _set_defaults(model, values) -> None:
|
||||
from py.config import AutotagConfig
|
||||
|
||||
general, character, limit = values
|
||||
model.wd14_general_threshold = general
|
||||
model.wd14_character_threshold = character
|
||||
AutotagConfig.SUGGESTION_LIMIT = limit
|
||||
|
||||
|
||||
def _valid_defaults(data: object) -> bool:
|
||||
unit = (
|
||||
lambda value: isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and 0 <= value <= 1
|
||||
)
|
||||
return (
|
||||
isinstance(data, dict)
|
||||
and set(data) == {"general", "character", "limit"}
|
||||
and unit(data["general"])
|
||||
and unit(data["character"])
|
||||
and type(data["limit"]) is int
|
||||
and 1 <= data["limit"] <= 100
|
||||
)
|
||||
|
||||
|
||||
def _valid_scored(scored: object) -> bool:
|
||||
return (
|
||||
isinstance(scored, list)
|
||||
and len(scored) <= 100
|
||||
and all(
|
||||
isinstance(row, list)
|
||||
and len(row) == 2
|
||||
and isinstance(row[0], str)
|
||||
and 0 < len(row[0]) <= 50
|
||||
and isinstance(row[1], (int, float))
|
||||
and not isinstance(row[1], bool)
|
||||
and 0 <= row[1] <= 1
|
||||
for row in scored
|
||||
)
|
||||
)
|
||||
@@ -296,3 +296,33 @@ test("get forwards an explicit cache mode and keeps the default request otherwis
|
||||
fetch.calls[2].resolve(jsonResponse(200, { success: true }));
|
||||
await writes;
|
||||
});
|
||||
|
||||
test("ApiError exposes only a validated current tag set from a tag-conflict body", async () => {
|
||||
const permitted = [{ tags: [], tag_revision: 0 }, { tags: ["warm", "Dune"], tag_revision: 7 }];
|
||||
const denied = [undefined, null, { tags: ["a"] }, { tag_revision: 1 }, { tags: "a", tag_revision: 1 },
|
||||
{ tags: [1], tag_revision: 1 }, { tags: ["a"], tag_revision: -1 }, { tags: ["a"], tag_revision: 1.5 },
|
||||
{ tags: ["a"], tag_revision: true }, { tags: Array(101).fill("a"), tag_revision: 1 }];
|
||||
assert.equal(new ApiError(409, "old caller").current, null);
|
||||
for (const current of permitted.concat(denied)) {
|
||||
const api = createApiClient(async () => jsonResponse(409, {
|
||||
success: false, error: "changed", code: "tag-conflict", ...(current || {}),
|
||||
}));
|
||||
await assert.rejects(api.put("/x", {}), error => {
|
||||
assert.ok(error instanceof ApiError);
|
||||
assert.deepEqual(error.current, permitted.includes(current) ? current : null);
|
||||
if (permitted.includes(current)) assert.notEqual(error.current.tags, current.tags, "server arrays must be copied");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("keepalive is forwarded only when asked, so a request can outlive its page", async () => {
|
||||
const fetch = fakeFetch();
|
||||
const api = createApiClient(fetch);
|
||||
const writes = [api.del("/x", { keepalive: true }), api.put("/x", {}, { keepalive: true }), api.post("/x", {}, { keepalive: true }),
|
||||
api.del("/x"), api.put("/x", {}), api.get("/x")];
|
||||
assert.deepEqual(fetch.calls.map(call => call.init.keepalive), [true, true, true, undefined, undefined, undefined]);
|
||||
assert.equal("keepalive" in fetch.calls[3].init, false);
|
||||
for (const call of fetch.calls) call.resolve(jsonResponse(200, { success: true }));
|
||||
await Promise.all(writes);
|
||||
});
|
||||
|
||||
@@ -153,6 +153,10 @@ test('local record renders local provenance', async () => {
|
||||
test('local record shows no image-tag chips', async () => {
|
||||
const record = await localRecord(localPng('a.png'));
|
||||
assert.equal(record.library.status, 'unavailable');
|
||||
// A local payload carries no image_tags facet (#281), so the record has none and only linked chips could show.
|
||||
assert.equal(record.imageTags, null);
|
||||
const {inspectorTagChips} = await load();
|
||||
assert.deepEqual(inspectorTagChips(record, {filterable:true}), []);
|
||||
const {f, done} = renderFull(record);
|
||||
try {
|
||||
// Only the format chip and the D21 evidence badge: no category, saved tag or image-tag chips.
|
||||
@@ -180,3 +184,34 @@ test('legacy fill applies to local records', async () => {
|
||||
assert.deepEqual(zeroed.prompts.negative, {status:'resolved', text:'', source:'zeroed-conditioning'});
|
||||
assert.notEqual(zeroed.prompts.positive.text, '');
|
||||
});
|
||||
|
||||
// #249 PR C Task C3 (D88): one chip row, the file's current image tags first, then D21's linked tags not already shown.
|
||||
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
|
||||
return {...record(), imageTags, library};
|
||||
}
|
||||
test('renders current image tags before linked tags without duplicates', async () => {
|
||||
const {inspectorTagChips} = await load();
|
||||
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
|
||||
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
|
||||
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
|
||||
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
|
||||
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
|
||||
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
|
||||
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
|
||||
});
|
||||
test('image tag chips filter in the panel and are plain in the viewer', async () => {
|
||||
const {inspectorTagChips} = await load();
|
||||
const source = tagged({state:'current', tags:['dunes']});
|
||||
assert.deepEqual(inspectorTagChips(source, {filterable:true}).map(chip => chip.filter), [true, true, true, true]);
|
||||
assert.deepEqual(inspectorTagChips(source, {filterable:false}).map(chip => chip.filter), [false, false, false, false]);
|
||||
});
|
||||
test('none and unavailable render no image chips', async () => {
|
||||
const {inspectorTagChips} = await load();
|
||||
for (const state of ['none', 'unavailable']) {
|
||||
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
|
||||
assert.deepEqual(inspectorTagChips(tagged({state, tags:[]}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), []);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,3 +149,15 @@ test('a narrow gallery caps the concept columns at the smallest readable card',
|
||||
}
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
|
||||
});
|
||||
|
||||
test('the card info stacks below the narrowest bounded card, a list row beside its art too (D31)', async () => {
|
||||
const {stacksCardInfo} = await source;
|
||||
assert.equal(typeof stacksCardInfo, 'function', 'stacksCardInfo is not implemented');
|
||||
assert.equal(stacksCardInfo(119, 'grid'), true);
|
||||
assert.equal(stacksCardInfo(120, 'grid'), false);
|
||||
assert.equal(stacksCardInfo(119, 'masonry'), true);
|
||||
// A list row's info is what its art (160px, at most 45%) leaves: 195px at 200% zoom on a phone leaves 107px.
|
||||
assert.equal(stacksCardInfo(195, 'list'), true);
|
||||
assert.equal(stacksCardInfo(280, 'list'), false);
|
||||
assert.equal(stacksCardInfo(358, 'list'), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
// Run with: node --test tests/js/workspace-image-tagging-state.test.js
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createApiClient } = require('../../web/js/api-client.js');
|
||||
|
||||
// Only an absent module is tolerated, so RED is an assertion failure, not a crash.
|
||||
const source = import('../../web/js/workspace/image-tagging-state.mjs').catch(error => {
|
||||
if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error;
|
||||
return {};
|
||||
});
|
||||
async function load() {
|
||||
const module = await source;
|
||||
assert.equal(typeof module.createImageTaggingController, 'function', 'image-tagging-state.mjs is not implemented');
|
||||
return module;
|
||||
}
|
||||
|
||||
const EPOCH = '11111111-1111-4111-8111-111111111111';
|
||||
const SCOPE = 'c'.repeat(64);
|
||||
const SHA = 'd'.repeat(64);
|
||||
const TOKEN = 'capture-token_1';
|
||||
const TOKEN2 = 'capture-token_2';
|
||||
const TARGET = Object.freeze({ root_index: 0, root_scope: SCOPE, path: 'dunes/dunes_00001.png' });
|
||||
const OBSERVE = '/prompt_manager/image-annotations/observe';
|
||||
const SUGGEST = '/prompt_manager/image-annotations/suggest';
|
||||
const TAGS = '/prompt_manager/image-annotations/tags';
|
||||
const MODELS = '/prompt_manager/autotag/models';
|
||||
const CAPTURE = '/prompt_manager/image-annotations/capture/';
|
||||
const DEFAULT_PROMPT = 'Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).';
|
||||
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
async function settle(times = 8) { for (let i = 0; i < times; i++) await flush(); }
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function response(status, body) {
|
||||
return { ok: status >= 200 && status < 300, status, statusText: `Status ${status}`, json: async () => body };
|
||||
}
|
||||
const fail = (status, code, error = `failure ${code}`, extra = {}) => response(status, { success: false, code, error, ...extra });
|
||||
|
||||
function captureInfo(overrides = {}) {
|
||||
return {
|
||||
token: TOKEN, expires_at: '2026-10-22T12:00:00+00:00', preview_url: CAPTURE + (overrides.token || TOKEN),
|
||||
sha256: SHA, size: 1234, width: 832, height: 1216, media_type: 'image',
|
||||
generate: { supported: true, reason: null }, ...overrides,
|
||||
};
|
||||
}
|
||||
function observed(overrides = {}, capture = {}) {
|
||||
return {
|
||||
success: true, library_epoch: EPOCH, target: { ...TARGET }, capture: captureInfo(capture),
|
||||
title: 'Desert pavilion', filename: 'dunes_00001.png', tags: ['Minimal', 'warm_light'], tag_revision: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const models = (overrides = {}) => ({
|
||||
success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
|
||||
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
|
||||
});
|
||||
const SANDSTONE = [
|
||||
{ name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 },
|
||||
{ name: 'sandstone', confidence: 0.981 }, { name: 'Editorial', confidence: 0.5 },
|
||||
];
|
||||
const suggested = (rows = SANDSTONE, overrides = {}) => ({ success: true, model_type: 'wd14-swinv2', options: {}, suggestions: rows, ...overrides });
|
||||
const receipt = (tags, overrides = {}) => ({ success: true, library_epoch: EPOCH, sha256: SHA, tags, tag_revision: 4, replayed: false, ...overrides });
|
||||
|
||||
/** Real createApiClient over a fetch whose answers come from routes[METHOD URL](call). */
|
||||
function harness(routes = {}) {
|
||||
const calls = [];
|
||||
const fetch = (url, init = {}) => {
|
||||
const call = { url, method: init.method, init, body: init.body === undefined ? undefined : JSON.parse(init.body), aborted: false, signal: init.signal };
|
||||
calls.push(call);
|
||||
return new Promise((resolve, reject) => {
|
||||
init.signal?.addEventListener('abort', () => {
|
||||
call.aborted = true;
|
||||
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
});
|
||||
const key = `${call.method} ${url.startsWith(CAPTURE) ? CAPTURE : url}`;
|
||||
const route = routes[key];
|
||||
Promise.resolve().then(() => {
|
||||
if (!route) return response(200, { success: true });
|
||||
return route(call);
|
||||
}).then(resolve, reject);
|
||||
});
|
||||
};
|
||||
return {
|
||||
api: createApiClient(fetch), calls,
|
||||
of: (method, url) => calls.filter(call => call.method === method && (url === CAPTURE ? call.url.startsWith(CAPTURE) : call.url === url)),
|
||||
};
|
||||
}
|
||||
function defaultRoutes(extra = {}) {
|
||||
return {
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed()),
|
||||
[`GET ${MODELS}`]: () => response(200, models()),
|
||||
[`POST ${SUGGEST}`]: () => response(200, suggested()),
|
||||
[`PUT ${TAGS}`]: call => response(200, receipt(call.body.tags)),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
async function opened(routes, options = {}) {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes(routes));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images', ...options });
|
||||
await c.open();
|
||||
await settle();
|
||||
return { c, h };
|
||||
}
|
||||
|
||||
test('MODEL_OPTIONS lists legacy engines in mock order with D84 labels', async () => {
|
||||
const { MODEL_OPTIONS } = await load();
|
||||
assert.deepEqual(MODEL_OPTIONS.map(option => [option.id, option.label]), [
|
||||
['wd14-swinv2', 'WD14 SwinV2 · ~400 MB'],
|
||||
['wd14-vit', 'WD14 ViT · ~400 MB'],
|
||||
['gguf', 'GGUF JoyCaption (Recommended) · ~4.5 GB'],
|
||||
['hf', 'HuggingFace JoyCaption · ~16 GB'],
|
||||
]);
|
||||
assert.ok(Object.isFrozen(MODEL_OPTIONS) && Object.isFrozen(MODEL_OPTIONS[0]));
|
||||
});
|
||||
|
||||
test('dedupeTags trims, drops blanks and keeps the first spelling case-insensitively', async () => {
|
||||
const { dedupeTags, parseManualTags } = await load();
|
||||
const input = [' Minimal ', 'minimal', '', 'warm_light', 'WARM_LIGHT', 7, 'dune'];
|
||||
assert.deepEqual(dedupeTags(input), ['Minimal', 'warm_light', 'dune']);
|
||||
assert.deepEqual(input[0], ' Minimal ', 'input is not mutated');
|
||||
assert.deepEqual(dedupeTags(undefined), []);
|
||||
assert.deepEqual(parseManualTags(' a, ,B , b,c '), ['a', 'B', 'c']);
|
||||
assert.deepEqual(parseManualTags(null), []);
|
||||
});
|
||||
|
||||
test('the controller rejects a malformed target, source or api', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const { api } = harness();
|
||||
assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_index: true } }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, root_scope: 'x' } }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api, target: { ...TARGET, path: '' } }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api, target: TARGET, source: 'elsewhere' }), TypeError);
|
||||
assert.throws(() => createImageTaggingController({ api: {}, target: TARGET }), TypeError);
|
||||
assert.throws(() => createImageTaggingController(), TypeError);
|
||||
});
|
||||
|
||||
test('opens with saved tags joined by comma-space', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes());
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'images' });
|
||||
const seen = [];
|
||||
const unsubscribe = c.subscribe(state => seen.push(state.phase));
|
||||
assert.equal(c.state.phase, 'observing');
|
||||
const pending = c.open();
|
||||
assert.equal(c.state.phase, 'observing');
|
||||
await pending;
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
assert.deepEqual(c.state.savedTags, ['Minimal', 'warm_light']);
|
||||
assert.equal(c.state.image.filename, 'dunes_00001.png');
|
||||
assert.equal(c.state.image.title, 'Desert pavilion');
|
||||
assert.equal(c.state.image.dimensions, '832 × 1216');
|
||||
assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN);
|
||||
assert.equal(c.state.model, 'wd14-swinv2');
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
assert.equal(c.state.primaryLabel, 'Save manual tags');
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.showScopeNote, false);
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.canGenerate, true);
|
||||
assert.ok(Object.isFrozen(c.state));
|
||||
assert.deepEqual(h.of('POST', OBSERVE)[0].body, { target: { ...TARGET } });
|
||||
assert.ok(seen.includes('ready'));
|
||||
unsubscribe();
|
||||
c.setManual('x');
|
||||
assert.equal(seen.at(-1), 'ready', 'an unsubscribed listener is not called');
|
||||
assert.throws(() => c.subscribe('nope'), TypeError);
|
||||
});
|
||||
|
||||
test('an image without known dimensions shows none and an unseen file starts empty', async () => {
|
||||
const { c } = await opened({
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed({ tags: [], tag_revision: 0, title: null }, { width: null, height: null })),
|
||||
});
|
||||
assert.equal(c.state.image.dimensions, null);
|
||||
assert.equal(c.state.manual, '');
|
||||
assert.equal(c.state.image.title, null);
|
||||
});
|
||||
|
||||
test('instructions default to the server prompt', async () => {
|
||||
const { c } = await opened({
|
||||
[`GET ${MODELS}`]: () => response(200, models({ suggestion_limit: 7, wd14_general_threshold: 0.5 })),
|
||||
});
|
||||
assert.equal(c.state.options.instructions, DEFAULT_PROMPT);
|
||||
assert.equal(c.state.options.suggestion_limit, 7);
|
||||
assert.equal(c.state.options.general_threshold, 0.5);
|
||||
assert.equal(c.state.options.character_threshold, 0.85);
|
||||
c.setModel('gguf');
|
||||
assert.equal(c.state.options.instructions, DEFAULT_PROMPT);
|
||||
c.setOption('instructions', 'only colours');
|
||||
c.setModel('hf');
|
||||
assert.equal(c.state.options.instructions, DEFAULT_PROMPT, 'a model change re-renders the defaults like the mock');
|
||||
assert.equal(c.state.options.suggestion_limit, 7, 'the suggestion count survives a model change');
|
||||
});
|
||||
|
||||
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
|
||||
for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
|
||||
const { c } = await opened({ [`GET ${MODELS}`]: route });
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
|
||||
}
|
||||
});
|
||||
|
||||
test('observe failures and malformed answers end in observe-error and retry observes again', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
let answer = () => fail(409, 'source-changed', 'The source changed. Refresh the image collection before tagging this file.');
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answer() }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
assert.equal(c.state.statusText, 'The source changed. Refresh the image collection before tagging this file.');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(c.state.canRetry, true);
|
||||
answer = () => response(200, observed({ tag_revision: -1 }));
|
||||
await c.retry();
|
||||
assert.equal(c.state.phase, 'observe-error');
|
||||
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'a malformed answer still releases its capture');
|
||||
answer = () => Promise.reject(new TypeError('Failed to fetch'));
|
||||
await c.retry();
|
||||
assert.equal(c.state.statusText, 'The image could not be opened for tagging.');
|
||||
answer = () => response(200, observed());
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(h.of('POST', OBSERVE).length, 4);
|
||||
});
|
||||
|
||||
test('model change clears suggestions count and status but keeps manual', async () => {
|
||||
const { c } = await opened();
|
||||
c.setManual('Minimal, dune');
|
||||
await c.generate();
|
||||
assert.equal(c.state.countText, '3 suggestions');
|
||||
c.setModel('wd14-vit');
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
assert.equal(c.state.statusText, '');
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.manual, 'Minimal, dune');
|
||||
assert.equal(c.state.model, 'wd14-vit');
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.model, 'wd14-vit');
|
||||
assert.throws(() => c.setModel('ollama'), TypeError);
|
||||
assert.throws(() => c.setOption('model', 1), TypeError);
|
||||
});
|
||||
|
||||
test('generate sends the model options and stages scored chips', async () => {
|
||||
const { c, h } = await opened();
|
||||
c.setOption('general_threshold', '0.4');
|
||||
c.setOption('suggestion_limit', '3');
|
||||
assert.equal(await c.generate(), true);
|
||||
const sent = h.of('POST', SUGGEST)[0].body;
|
||||
assert.deepEqual(sent, {
|
||||
target: { ...TARGET }, capture_token: TOKEN, expected_epoch: EPOCH, model_type: 'wd14-swinv2',
|
||||
options: { general_threshold: 0.4, character_threshold: 0.85, suggestion_limit: 3 },
|
||||
});
|
||||
assert.deepEqual(c.state.suggestions.map(row => [row.name, row.label]), [['minimal', '99%'], ['editorial', '97%'], ['sandstone', '98%']]);
|
||||
assert.equal(c.state.statusText, 'Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
|
||||
assert.equal(c.state.suggestionsText, '');
|
||||
c.setModel('gguf');
|
||||
await c.generate();
|
||||
assert.deepEqual(h.of('POST', SUGGEST)[1].body.options, { instructions: DEFAULT_PROMPT, suggestion_limit: 3 });
|
||||
});
|
||||
|
||||
test('language models label chips Suggested and the count is capped at the requested limit', async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({ name: `tag${i}`, confidence: null }));
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => response(200, suggested(rows, { model_type: 'gguf' })) });
|
||||
c.setModel('gguf');
|
||||
await c.generate();
|
||||
assert.equal(c.state.suggestions.length, 10);
|
||||
assert.ok(c.state.suggestions.every(row => row.label === 'Suggested' && row.confidence === null));
|
||||
assert.equal(c.state.countText, '10 suggestions');
|
||||
});
|
||||
|
||||
test('an empty result shows the threshold copy and a malformed one is a generate error', async () => {
|
||||
let answer = () => response(200, suggested([]));
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() });
|
||||
await c.generate();
|
||||
assert.equal(c.state.suggestionsText, 'No suggestions met this threshold. Adjust it or enter tags manually.');
|
||||
assert.equal(c.state.countText, '0 suggestions');
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags');
|
||||
answer = () => response(200, suggested([{ name: 4, confidence: 2 }]));
|
||||
await c.generate();
|
||||
assert.equal(c.state.phase, 'generate-error');
|
||||
assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.');
|
||||
answer = () => response(200, suggested([{ name: 'one', confidence: 1 }]));
|
||||
assert.equal(await c.retry(), true, 'retry after a generate error generates again');
|
||||
assert.equal(c.state.countText, '1 suggestion');
|
||||
assert.equal(c.state.suggestions[0].label, '100%');
|
||||
});
|
||||
|
||||
test('invalid options send nothing', async () => {
|
||||
const { c, h } = await opened();
|
||||
const { optionError } = await load();
|
||||
for (const [key, value] of [['general_threshold', '1.5'], ['general_threshold', ''], ['character_threshold', 'abc'], ['suggestion_limit', '0'], ['suggestion_limit', '2.5'], ['suggestion_limit', '101']]) {
|
||||
c.setOption('general_threshold', 0.35);
|
||||
c.setOption('character_threshold', 0.85);
|
||||
c.setOption('suggestion_limit', 10);
|
||||
c.setOption(key, value);
|
||||
assert.equal(await c.generate(), false, `${key}=${value}`);
|
||||
assert.equal(optionError(c.state.model, c.state.options).field, key);
|
||||
}
|
||||
c.setModel('gguf');
|
||||
c.setOption('instructions', ' ');
|
||||
assert.equal(await c.generate(), false);
|
||||
c.setOption('instructions', 'x'.repeat(4001));
|
||||
assert.equal(await c.generate(), false);
|
||||
assert.equal(optionError('gguf', c.state.options).field, 'instructions');
|
||||
assert.equal(h.of('POST', SUGGEST).length, 0);
|
||||
assert.equal(optionError('wd14-vit', { general_threshold: 0, character_threshold: 1, suggestion_limit: 100 }), null);
|
||||
});
|
||||
|
||||
test('late generation for an old serial, closed or expired dialog is ignored', async () => {
|
||||
const pending = [];
|
||||
const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => { const d = deferred(); pending.push(d); return d.promise; } });
|
||||
const first = c.generate();
|
||||
assert.equal(c.state.phase, 'generating');
|
||||
assert.equal(c.state.statusText, 'Generating suggestions…');
|
||||
assert.equal(c.state.canSave, false);
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(h.of('POST', SUGGEST)[0].aborted, true);
|
||||
await settle();
|
||||
pending[0].resolve(response(200, suggested()));
|
||||
assert.equal(await first, false);
|
||||
assert.equal(c.state.phase, 'ready', 'the superseded request neither stages chips nor reports its abort');
|
||||
assert.equal(c.state.statusText, '');
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
|
||||
const second = c.generate();
|
||||
c.cancel();
|
||||
await settle();
|
||||
pending[1].resolve(response(200, suggested()));
|
||||
assert.equal(await second, false);
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.closed, true);
|
||||
assert.equal(await c.generate(), false, 'a closed dialog never generates');
|
||||
|
||||
const expired = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'capture-expired', 'The preview expired. Open the Tag dialog again.') });
|
||||
await expired.c.generate();
|
||||
assert.equal(expired.c.state.phase, 'capture-expired');
|
||||
assert.equal(expired.c.state.canSave, false);
|
||||
assert.equal(expired.c.state.canGenerate, false);
|
||||
});
|
||||
|
||||
test('a reopen while observing ignores the older observation and releases its capture', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const answers = [deferred(), deferred()];
|
||||
let n = 0;
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => answers[n++].promise }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
const one = c.open();
|
||||
const two = c.open();
|
||||
answers[1].resolve(response(200, observed({}, { token: TOKEN2 })));
|
||||
await two;
|
||||
answers[0].resolve(response(200, observed()));
|
||||
await one;
|
||||
await settle();
|
||||
assert.equal(c.state.image.previewUrl, CAPTURE + TOKEN2);
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]);
|
||||
c.dispose();
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN, CAPTURE + TOKEN2]);
|
||||
});
|
||||
|
||||
test('primary label toggles Save manual tags / Approve selected tags', async () => {
|
||||
const { c } = await opened();
|
||||
assert.equal(c.state.primaryLabel, 'Save manual tags');
|
||||
await c.generate();
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags'); c.setModel('wd14-vit'); assert.equal(c.state.primaryLabel, 'Save manual tags');
|
||||
assert.equal(c.state.countText, 'Not generated');
|
||||
});
|
||||
|
||||
test('remove drops one chip and the last removal shows the all-removed copy', async () => {
|
||||
const { c } = await opened();
|
||||
await c.generate();
|
||||
c.remove('editorial');
|
||||
assert.deepEqual(c.state.suggestions.map(row => row.name), ['minimal', 'sandstone']);
|
||||
assert.equal(c.state.countText, '2 suggestions');
|
||||
c.remove('missing');
|
||||
assert.equal(c.state.suggestions.length, 2);
|
||||
c.remove('minimal');
|
||||
c.remove('sandstone');
|
||||
assert.deepEqual(c.state.suggestions, []);
|
||||
assert.equal(c.state.suggestionsText, 'All suggestions removed. Generate again or enter tags manually.');
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags');
|
||||
});
|
||||
|
||||
test('save sends manual then remaining chips deduplicated case-insensitively keeping the manual spelling', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes({
|
||||
[`POST ${SUGGEST}`]: () => response(200, suggested([
|
||||
{ name: 'minimal', confidence: 0.99 }, { name: 'editorial', confidence: 0.97 },
|
||||
{ name: 'sandstone', confidence: 0.96 }, { name: 'desert', confidence: 0.9 },
|
||||
])),
|
||||
}));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET, source: 'review' });
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.showScopeNote, true);
|
||||
await c.generate();
|
||||
c.remove('desert');
|
||||
assert.equal(c.state.primaryLabel, 'Approve selected tags');
|
||||
assert.equal(await c.save(), true);
|
||||
const sent = h.of('PUT', TAGS)[0].body;
|
||||
assert.deepEqual(sent.tags, ['Minimal', 'warm_light', 'editorial', 'sandstone']); // manual "Minimal", chip "minimal" dropped
|
||||
assert.deepEqual(sent, {
|
||||
target: { ...TARGET }, capture_token: TOKEN, sha256: SHA, expected_epoch: EPOCH,
|
||||
expected_tag_revision: 3, tags: sent.tags,
|
||||
});
|
||||
assert.equal(c.state.phase, 'saved');
|
||||
assert.deepEqual(c.state.savedTags, sent.tags);
|
||||
assert.equal(c.state.tagRevision, 4);
|
||||
assert.equal(c.state.toast, '4 tags saved for dunes_00001.png. Prompt association still awaits review.');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(await c.save(), false, 'a saved dialog does not save twice');
|
||||
});
|
||||
|
||||
test('toast matches the mock with the review suffix', async () => {
|
||||
const { c } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt(['one'])) });
|
||||
c.setManual('one');
|
||||
await c.save();
|
||||
assert.equal(c.state.toast, '1 tags saved for dunes_00001.png.', 'the mock always says "tags"');
|
||||
const many = await opened({}, { source: 'library' });
|
||||
await many.c.save();
|
||||
assert.equal(many.c.state.toast, '2 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('empty manual and no chips sends the acknowledged empty set', async () => {
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt([])) });
|
||||
c.setManual(' , ');
|
||||
await c.save();
|
||||
assert.deepEqual(h.of('PUT', TAGS)[0].body.tags, []);
|
||||
assert.equal(c.state.toast, '0 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('conflict keeps draft and exposes current saved tags', async () => {
|
||||
let answer = () => fail(409, 'tag-conflict', 'The tags of this image changed since the dialog opened; review them first.',
|
||||
{ tags: ['night', 'rain'], tag_revision: 5 });
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
|
||||
await c.generate();
|
||||
c.setManual('Minimal, dune');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'conflict');
|
||||
assert.deepEqual(c.state.currentTags, ['night', 'rain']);
|
||||
assert.equal(c.state.manual, 'night, rain, Minimal, dune');
|
||||
assert.equal(c.state.suggestions.length, 3);
|
||||
assert.equal(c.state.statusText, 'The tags of this image changed since the dialog opened; review them first. Saved now: night, rain.');
|
||||
assert.equal(c.state.canSave, true);
|
||||
answer = call => response(200, receipt(call.body.tags, { tag_revision: 6 }));
|
||||
await c.save();
|
||||
assert.equal(h.of('PUT', TAGS)[1].body.expected_tag_revision, 5, 'the second save is against the revision the user has seen');
|
||||
assert.equal(c.state.phase, 'saved');
|
||||
assert.equal(c.state.currentTags, null);
|
||||
});
|
||||
|
||||
test('lost response keeps draft and retry resends the identical body once', async () => {
|
||||
const answers = [() => Promise.reject(new TypeError('Failed to fetch')), () => fail(504, null, 'Gateway Timeout'),
|
||||
() => response(200, { success: true, tags: 'broken' })];
|
||||
for (const lost of answers) {
|
||||
let answer = lost;
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
|
||||
c.setManual('Minimal, dune');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'unknown-outcome');
|
||||
assert.equal(c.state.statusText, 'The save could not be confirmed. Retry sends the same tags again.');
|
||||
assert.equal(c.state.canRetry, true);
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.manual, 'Minimal, dune');
|
||||
c.setManual('changed meanwhile');
|
||||
const gate = deferred();
|
||||
answer = call => gate.promise.then(() => response(200, receipt(call.body.tags, { replayed: true })));
|
||||
const retried = c.retry();
|
||||
assert.equal(await c.retry(), false, 'a retry in flight is not sent twice');
|
||||
gate.resolve();
|
||||
assert.equal(await retried, true);
|
||||
const puts = h.of('PUT', TAGS);
|
||||
assert.equal(puts.length, 2);
|
||||
assert.deepEqual(puts[1].body, puts[0].body);
|
||||
assert.equal(c.state.phase, 'saved');
|
||||
assert.equal(await c.retry(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('write errors keep the draft and allow another save', async () => {
|
||||
const { c } = await opened({ [`PUT ${TAGS}`]: () => fail(400, 'tag-limit', 'An image can carry at most 100 tags.') });
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'write-error');
|
||||
assert.equal(c.state.statusText, 'An image can carry at most 100 tags.');
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.canRetry, false);
|
||||
const generic = await opened({ [`PUT ${TAGS}`]: () => fail(500, 'save-failed', '') });
|
||||
await generic.c.save();
|
||||
assert.equal(generic.c.state.phase, 'write-error');
|
||||
assert.equal(generic.c.state.statusText, 'The tags could not be saved.');
|
||||
});
|
||||
|
||||
test('storage-changed and file-changed disable save until reopen', async () => {
|
||||
for (const [code, phase] of [['storage-changed', 'storage-changed'], ['file-changed', 'file-changed'], ['source-changed', 'file-changed'], ['capture-expired', 'capture-expired']]) {
|
||||
const message = `server says ${code}`;
|
||||
let answer = () => fail(409, code, message);
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: () => answer() });
|
||||
c.setManual('kept draft');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, phase);
|
||||
assert.equal(c.state.statusText, message);
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(await c.save(), false);
|
||||
assert.equal(h.of('PUT', TAGS).length, 1);
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready', 'reopen observes again');
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1, 'reopen releases the old capture');
|
||||
}
|
||||
});
|
||||
|
||||
test('video disables generate with the still-image message', async () => {
|
||||
const reason = 'Auto-tagging is available for supported still images only.';
|
||||
const { c, h } = await opened({
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed({ filename: 'clip.mp4' }, { media_type: 'video', generate: { supported: false, reason } })),
|
||||
});
|
||||
assert.equal(c.state.image.mediaType, 'video');
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.statusText, reason);
|
||||
c.setModel('gguf');
|
||||
assert.equal(c.state.statusText, reason, 'the reason survives a model change');
|
||||
assert.equal(await c.generate(), false);
|
||||
assert.equal(h.of('POST', SUGGEST).length, 0);
|
||||
});
|
||||
|
||||
test('model-unavailable and model-busy set the status line only', async () => {
|
||||
let answer = () => response(200, suggested());
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => answer() });
|
||||
await c.generate();
|
||||
const before = c.state;
|
||||
for (const [code, message] of [
|
||||
['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'],
|
||||
['model-busy', 'A tagging model is busy with another operation. Try again when it finishes.'],
|
||||
['unsupported-media', 'Images larger than 4096 px per side or 16,777,216 px are not supported.'],
|
||||
]) {
|
||||
answer = () => fail(409, code, message);
|
||||
assert.equal(await c.generate(), false);
|
||||
assert.equal(c.state.statusText, message);
|
||||
assert.deepEqual(c.state.suggestions, before.suggestions);
|
||||
assert.equal(c.state.countText, before.countText);
|
||||
assert.equal(c.state.primaryLabel, before.primaryLabel);
|
||||
assert.equal(c.state.manual, before.manual);
|
||||
assert.equal(c.state.canSave, true);
|
||||
assert.equal(c.state.canGenerate, true);
|
||||
}
|
||||
answer = () => Promise.reject(new TypeError('Failed to fetch'));
|
||||
await c.generate();
|
||||
assert.equal(c.state.statusText, 'Auto-tagging failed. Check server logs for details.');
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
});
|
||||
|
||||
test('a storage restore during generate requires a reopen', async () => {
|
||||
const { c } = await opened({ [`POST ${SUGGEST}`]: () => fail(409, 'storage-changed', 'Prompt storage was restored; reopen the Tag dialog.') });
|
||||
c.setManual('kept');
|
||||
await c.generate();
|
||||
assert.equal(c.state.phase, 'storage-changed');
|
||||
assert.equal(c.state.canSave, false);
|
||||
assert.equal(c.state.canGenerate, false);
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready', 'Retry observes again (D116)');
|
||||
assert.equal(c.state.manual, 'kept');
|
||||
});
|
||||
|
||||
test('dispose aborts requests and releases the capture once', async () => {
|
||||
const pending = deferred();
|
||||
const { c, h } = await opened({ [`POST ${SUGGEST}`]: () => pending.promise });
|
||||
const token = TOKEN;
|
||||
const releases = [];
|
||||
const generating = c.generate();
|
||||
let notified = 0;
|
||||
c.subscribe(() => { notified += 1; });
|
||||
c.dispose();
|
||||
c.dispose();
|
||||
c.cancel();
|
||||
for (const call of h.of('DELETE', CAPTURE)) releases.push(decodeURIComponent(call.url.slice(CAPTURE.length)));
|
||||
assert.deepEqual(releases, [token]);
|
||||
assert.equal(h.of('POST', SUGGEST)[0].aborted, true);
|
||||
pending.resolve(response(200, suggested()));
|
||||
assert.equal(await generating, false);
|
||||
assert.equal(notified, 0, 'a disposed controller never notifies');
|
||||
c.setManual('ignored');
|
||||
c.remove('x');
|
||||
assert.notEqual(c.state.manual, 'ignored');
|
||||
assert.equal(await c.save(), false);
|
||||
assert.equal(await c.open(), undefined);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 1);
|
||||
});
|
||||
|
||||
test('dispose while observing aborts the models read and releases a late capture', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const late = deferred();
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => late.promise, [`GET ${MODELS}`]: () => new Promise(() => {}) }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
const opening = c.open();
|
||||
c.dispose();
|
||||
assert.equal(h.of('GET', MODELS)[0].aborted, true);
|
||||
late.resolve(response(200, observed()));
|
||||
await opening;
|
||||
await settle();
|
||||
assert.deepEqual(h.of('DELETE', CAPTURE).map(call => call.url), [CAPTURE + TOKEN]);
|
||||
assert.equal(c.state.phase, 'observing');
|
||||
});
|
||||
|
||||
test('a failing capture release is not reported as a dialog error', async () => {
|
||||
const { c, h } = await opened({ [`DELETE ${CAPTURE}`]: () => Promise.reject(new TypeError('offline')) });
|
||||
c.dispose();
|
||||
await settle();
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 1);
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
});
|
||||
|
||||
test('the opening sentence is the concept on-screen copy and the chip hint shows only with chips (D83)', async () => {
|
||||
const { createImageTaggingController } = await load();
|
||||
const h = harness(defaultRoutes());
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.chipHint, '');
|
||||
await c.open();
|
||||
await settle();
|
||||
assert.equal(c.state.suggestionsText, 'Generate suggestions with the selected model before approving.');
|
||||
assert.equal(c.state.chipHint, '');
|
||||
await c.generate();
|
||||
assert.equal(c.state.chipHint, 'Remove anything you do not want to add.');
|
||||
for (const row of [...c.state.suggestions]) c.remove(row.name);
|
||||
assert.equal(c.state.chipHint, '');
|
||||
});
|
||||
|
||||
test('the option fields follow the server defaults: open and a model change bump the revision, typing does not', async () => {
|
||||
const { c, h } = await opened({
|
||||
[`GET ${MODELS}`]: () => response(200, models({ suggestion_limit: 7, wd14_general_threshold: 0.5, wd14_character_threshold: 0.9 })),
|
||||
});
|
||||
const opening = c.state.optionsRevision;
|
||||
assert.ok(opening > 0, 'the observation that applies the server defaults bumps the revision');
|
||||
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.5, character_threshold: 0.9, instructions: DEFAULT_PROMPT, suggestion_limit: 7 });
|
||||
c.setOption('general_threshold', '0.6');
|
||||
assert.equal(c.state.optionsRevision, opening, 'typing never re-renders the fields');
|
||||
c.setModel('wd14-vit');
|
||||
assert.equal(c.state.optionsRevision, opening + 1);
|
||||
assert.equal(c.state.options.general_threshold, 0.5);
|
||||
await c.generate();
|
||||
assert.deepEqual(h.of('POST', SUGGEST)[0].body.options, { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 });
|
||||
});
|
||||
|
||||
test('a conflict shows the current set and merges it ahead of the draft, so a second Save never drops it unseen', async () => {
|
||||
let answer = () => fail(409, 'tag-conflict', 'The tags of this image changed since the dialog opened; review them first.',
|
||||
{ tags: ['changed_elsewhere', 'Minimal'], tag_revision: 5 });
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => answer(call) });
|
||||
const draft = c.state.draftRevision;
|
||||
c.setManual('minimal, dune');
|
||||
await c.save();
|
||||
assert.equal(c.state.phase, 'conflict');
|
||||
assert.equal(c.state.statusText,
|
||||
'The tags of this image changed since the dialog opened; review them first. Saved now: changed_elsewhere, Minimal.');
|
||||
assert.equal(c.state.manual, 'changed_elsewhere, Minimal, dune', 'the current set leads, the draft follows, repeats dropped');
|
||||
assert.equal(c.state.draftRevision, draft + 1, 'the dialog rewrites its field');
|
||||
answer = call => response(200, receipt(call.body.tags, { tag_revision: 6 }));
|
||||
await c.save();
|
||||
const second = h.of('PUT', TAGS)[1].body;
|
||||
assert.deepEqual(second.tags, ['changed_elsewhere', 'Minimal', 'dune']);
|
||||
assert.equal(second.expected_tag_revision, 5);
|
||||
const empty = await opened({ [`PUT ${TAGS}`]: () => fail(409, 'tag-conflict', 'Changed.', { tags: [], tag_revision: 9 }) });
|
||||
empty.c.setManual('a');
|
||||
await empty.c.save();
|
||||
assert.equal(empty.c.state.statusText, 'Changed. Saved now: no tags.');
|
||||
assert.equal(empty.c.state.manual, 'a');
|
||||
});
|
||||
|
||||
test('the capture release and the save survive leaving the page (keepalive)', async () => {
|
||||
const { c, h } = await opened();
|
||||
await c.save();
|
||||
assert.equal(h.of('PUT', TAGS)[0].init.keepalive, true);
|
||||
const other = await opened();
|
||||
other.c.dispose();
|
||||
const [release] = other.h.of('DELETE', CAPTURE);
|
||||
assert.equal(release.init.keepalive, true);
|
||||
assert.equal(h.of('POST', OBSERVE)[0].init.keepalive, undefined, 'reads are not kept alive');
|
||||
});
|
||||
|
||||
test('closing during a save releases the capture only after the PUT settles and reports the outcome', async () => {
|
||||
const outcomes = [];
|
||||
const gate = deferred();
|
||||
const { c, h } = await opened({ [`PUT ${TAGS}`]: call => gate.promise.then(() => response(200, receipt(call.body.tags))) },
|
||||
{ onLateSave: outcome => outcomes.push(outcome) });
|
||||
c.setManual('dune');
|
||||
const saving = c.save();
|
||||
c.dispose();
|
||||
await settle();
|
||||
assert.equal(h.of('DELETE', CAPTURE).length, 0, 'the capture stays while its save is in flight');
|
||||
gate.resolve();
|
||||
assert.equal(await saving, true);
|
||||
await settle();
|
||||
const order = h.calls.filter(call => call.method !== 'GET').map(call => call.method);
|
||||
assert.deepEqual(order, ['POST', 'PUT', 'DELETE']);
|
||||
assert.deepEqual(outcomes, [{ saved: true, filename: 'dunes_00001.png', tags: ['dune'], toast: '1 tags saved for dunes_00001.png.' }]);
|
||||
assert.equal(c.state.closed, true);
|
||||
|
||||
for (const [route, message] of [
|
||||
[() => fail(409, 'tag-conflict', 'Changed elsewhere.', { tags: ['x'], tag_revision: 5 }), 'Changed elsewhere.'],
|
||||
[() => fail(400, 'tag-limit', 'Too many.'), 'Too many.'],
|
||||
[() => Promise.reject(new TypeError('Failed to fetch')), 'The tag save could not be confirmed. Open Tag image again to check the saved tags.'],
|
||||
]) {
|
||||
const late = [];
|
||||
const held = deferred();
|
||||
const run = await opened({ [`PUT ${TAGS}`]: () => held.promise.then(route) }, { onLateSave: outcome => late.push(outcome) });
|
||||
const pending = run.c.save();
|
||||
run.c.cancel();
|
||||
held.resolve();
|
||||
assert.equal(await pending, false);
|
||||
await settle();
|
||||
assert.deepEqual(late, [{ saved: false, message }]);
|
||||
assert.equal(run.h.of('DELETE', CAPTURE).length, 1);
|
||||
}
|
||||
const quiet = await opened();
|
||||
quiet.c.dispose();
|
||||
assert.equal(quiet.h.of('DELETE', CAPTURE).length, 1, 'with no save in flight the capture is released at once');
|
||||
const { createImageTaggingController } = await load();
|
||||
assert.throws(() => createImageTaggingController({ api: h.api, target: TARGET, onLateSave: 'x' }), TypeError);
|
||||
});
|
||||
|
||||
test('retry after a changed storage, file or expired capture observes again and keeps the draft', async () => {
|
||||
for (const code of ['storage-changed', 'file-changed', 'capture-expired']) {
|
||||
let revision = 3;
|
||||
const { c, h } = await opened({
|
||||
[`PUT ${TAGS}`]: () => fail(409, code, `server says ${code}`),
|
||||
[`POST ${OBSERVE}`]: () => response(200, observed({ tag_revision: revision, tags: revision === 3 ? ['Minimal', 'warm_light'] : ['night'] })),
|
||||
});
|
||||
c.setManual('kept draft');
|
||||
await c.save();
|
||||
assert.equal(c.state.canRetry, true);
|
||||
const draft = c.state.draftRevision;
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.phase, 'ready');
|
||||
assert.equal(c.state.manual, 'kept draft', 'an unchanged tag set keeps the draft as typed');
|
||||
assert.equal(c.state.draftRevision, draft + 1);
|
||||
assert.equal(h.of('POST', OBSERVE).length, 2);
|
||||
revision = 4;
|
||||
await c.save();
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.manual, 'night, kept draft', 'a set changed meanwhile is shown ahead of the draft');
|
||||
assert.equal(c.state.statusText, 'The tags of this image changed since the dialog opened. Saved now: night.');
|
||||
assert.equal(c.state.tagRevision, 4);
|
||||
}
|
||||
// A first observation that failed has no draft to keep: its retry fills the saved tags.
|
||||
const { createImageTaggingController } = await load();
|
||||
let first = true;
|
||||
const h = harness(defaultRoutes({ [`POST ${OBSERVE}`]: () => (first ? (first = false, fail(500, 'observe-failed')) : response(200, observed())) }));
|
||||
const c = createImageTaggingController({ api: h.api, target: TARGET });
|
||||
await c.open();
|
||||
await c.retry();
|
||||
await settle();
|
||||
assert.equal(c.state.manual, 'Minimal, warm_light');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Guarded: before the dialog module exists every test fails on an assertion, not on the import.
|
||||
const loaded = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
|
||||
const module = () => { assert.ok(loaded, 'image-tagging.mjs is not implemented'); return loaded; };
|
||||
const SCOPE = 'a'.repeat(64);
|
||||
|
||||
test('a listed file gives its tagging target, anything else none', () => {
|
||||
const { imageTarget } = module();
|
||||
assert.deepEqual(imageTarget({ root_index: 1, root_scope: SCOPE, path: 'a/b.png', filename: 'b.png' }),
|
||||
{ root_index: 1, root_scope: SCOPE, path: 'a/b.png' });
|
||||
for (const image of [null, {}, { root_index: -1, root_scope: SCOPE, path: 'a.png' }, { root_index: 0, path: 'a.png' },
|
||||
{ root_index: 0, root_scope: 'A'.repeat(64), path: 'a.png' }, { root_index: 0, root_scope: SCOPE, path: '' },
|
||||
{ root_index: 1.5, root_scope: SCOPE, path: 'a.png' }]) {
|
||||
assert.equal(imageTarget(image), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('tag save listeners subscribe and unsubscribe once', () => {
|
||||
const { onImageTagsSaved, imageTagsListenerCount } = module();
|
||||
const before = imageTagsListenerCount();
|
||||
const off = onImageTagsSaved(() => {});
|
||||
assert.equal(imageTagsListenerCount(), before + 1);
|
||||
off(); off();
|
||||
assert.equal(imageTagsListenerCount(), before);
|
||||
assert.throws(() => onImageTagsSaved('nope'), TypeError);
|
||||
});
|
||||
@@ -21,3 +21,12 @@ test('Images mounted after its parent is aborted creates no DOM, requests or obs
|
||||
for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
|
||||
}
|
||||
});
|
||||
|
||||
test('Images mounted after its parent is aborted follows no tag saves', async () => {
|
||||
const tagging = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
|
||||
assert.ok(tagging, 'image-tagging.mjs is not implemented');
|
||||
const before = tagging.imageTagsListenerCount();
|
||||
const parent = createScope(); parent.dispose();
|
||||
mountImages({}, {get() { assert.fail('Request started after parent disposal'); }}, parent, {embedded: true, fetchPage() {}}).dispose();
|
||||
assert.equal(tagging.imageTagsListenerCount(), before);
|
||||
});
|
||||
|
||||
@@ -352,3 +352,129 @@ test('selectionLabel counts the selected images as the concept bar does', async
|
||||
assert.equal(selectionLabel(2), '2 selected');
|
||||
assert.equal(selectionLabel(1234), '1,234 selected');
|
||||
});
|
||||
|
||||
test('refresh re-reads the loaded range in place and publishes once, replacing the items', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];const states=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push([s,hint]),batchSize:2});
|
||||
assert.equal(typeof stream.refresh,'function','refresh is part of the stream');
|
||||
const first=stream.reset({q:'tag'});requests[0].resolve(page([image('a'),image('b')],5,'c1'));await first;
|
||||
const second=stream.loadMore();requests[1].resolve(page([image('c'),image('d')],5,'c2'));await second;
|
||||
const before=stream.state;const published=states.length;
|
||||
const refresh=stream.refresh();
|
||||
// The current collection stays shown while the re-read runs; Load more waits for it.
|
||||
assert.equal(stream.state,before);assert.equal(states.length,published);
|
||||
assert.equal(await stream.loadMore(),0);assert.equal(requests.length,3);
|
||||
const query=new URL(requests[2].url,'http://local').searchParams;
|
||||
// The loaded range is re-read in pages as large as it (at most 200), not in the listing's batches (M2).
|
||||
assert.equal(query.get('q'),'tag');assert.equal(query.has('cursor'),false);assert.equal(query.get('limit'),'4');
|
||||
requests[2].resolve(page([image('a'),image('new'),image('c')],6,'n1'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const next=new URL(requests[3].url,'http://local').searchParams;
|
||||
assert.equal(next.get('cursor'),'n1');assert.equal(next.get('limit'),'2','the rest, at least one batch');
|
||||
assert.equal(stream.state,before);
|
||||
requests[3].resolve(page([image('d')],6,'n2'));
|
||||
assert.equal(await refresh,4);
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:a','0:new','0:c','0:d']);
|
||||
assert.deepEqual([stream.state.total,stream.state.cursor,stream.state.hasMore,stream.state.loaded,stream.state.loading],[6,'n2',true,4,false]);
|
||||
assert.equal(states.length,published+1);assert.deepEqual(states.at(-1)[1],{replaced:true});
|
||||
// Load more continues from the refreshed cursor.
|
||||
const more=stream.loadMore();assert.equal(new URL(requests[4].url,'http://local').searchParams.get('cursor'),'n2');
|
||||
requests[4].resolve(page([image('e')],6));await more;
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:a','0:new','0:c','0:d','0:e']);
|
||||
});
|
||||
|
||||
test('refresh stops early when the result shrank, and a reset or failure leaves the collection as it was', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:2});
|
||||
assert.equal(typeof stream.refresh,'function','refresh is part of the stream');
|
||||
const first=stream.reset({tags:['x']});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
|
||||
const shrink=stream.refresh();requests[1].resolve(page([image('b')],1));
|
||||
assert.equal(await shrink,1);assert.deepEqual(stream.state.items.map(imageKey),['0:b']);assert.equal(stream.state.hasMore,false);
|
||||
const before=stream.state;
|
||||
const failing=stream.refresh();requests[2].reject(new Error('offline'));
|
||||
await assert.rejects(failing,/offline/,'a failed re-read is reported to the page');assert.equal(stream.state,before);
|
||||
const stale=stream.refresh();const fresh=stream.reset({tags:['y']});
|
||||
assert.equal(requests[3].signal.aborted,true);
|
||||
requests[4].resolve(page([image('y')]));await fresh;
|
||||
requests[3].resolve(page([image('stale')]));assert.equal(await stale,0);
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:y']);
|
||||
stream.dispose();assert.equal(await stream.refresh(),0);
|
||||
});
|
||||
|
||||
test('a refresh asked while a page loads runs once that page settles (I6)', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];const states=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push(hint),batchSize:2});
|
||||
const first=stream.reset({tags:['x']});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
|
||||
for (const settle of ['resolve','reject']) {
|
||||
const more=stream.loadMore();const sent=requests.length;
|
||||
assert.equal(await stream.refresh(),0,'nothing is read beside the page in flight');
|
||||
assert.equal(await stream.refresh(),0);
|
||||
assert.equal(requests.length,sent);
|
||||
if (settle==='resolve') requests[sent-1].resolve(page([image('c'),image('d')],4,'c2'));
|
||||
else requests[sent-1].reject(new Error('offline'));
|
||||
await more;
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,sent+1,'exactly one pending refresh follows');
|
||||
assert.equal(new URL(requests[sent].url,'http://local').searchParams.has('cursor'),false);
|
||||
requests[sent].resolve(page([image('a'),image('b'),image('c'),image('d')],6,'r1'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.deepEqual(states.at(-1),{replaced:true});
|
||||
assert.equal(states.filter(hint=>hint?.replaced).length,settle==='resolve'?1:2);
|
||||
}
|
||||
// A reset drops a pending refresh.
|
||||
const more=stream.loadMore();const sent=requests.length;
|
||||
assert.equal(await stream.refresh(),0);
|
||||
const fresh=stream.reset({tags:['y']});
|
||||
requests[sent].resolve(page([image('y')]));await fresh;
|
||||
requests[sent-1].resolve(page([image('late')],1));await more;
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,sent+1);assert.deepEqual(stream.state.items.map(imageKey),['0:y']);
|
||||
assert.equal(stream.refreshing,false);
|
||||
});
|
||||
|
||||
test('refresh reads a long loaded range in at most 200-file pages and exposes refreshing (M2, M3)', async () => {
|
||||
const {createImageStream}=await source;const requests=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:150});
|
||||
const many=(prefix,n)=>Array.from({length:n},(_,i)=>image(`${prefix}${i}`));
|
||||
const first=stream.reset({q:'x'});requests[0].resolve(page(many('a',150),1000,'c1'));await first;
|
||||
const second=stream.loadMore();requests[1].resolve(page(many('b',150),1000,'c2'));await second;
|
||||
const refresh=stream.refresh();
|
||||
assert.equal(stream.refreshing,true);
|
||||
assert.equal(new URL(requests[2].url,'http://local').searchParams.get('limit'),'200');
|
||||
requests[2].resolve(page(many('r',200),1000,'n1'));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(new URL(requests[3].url,'http://local').searchParams.get('limit'),'150');
|
||||
requests[3].resolve(page(many('s',150),1000,'n2'));
|
||||
assert.equal(await refresh,350);assert.equal(requests.length,4);
|
||||
assert.equal(stream.refreshing,false);
|
||||
});
|
||||
|
||||
test('a second refresh supersedes the first, repeats in a re-read are dropped, and dispose abandons one in flight', async () => {
|
||||
const {createImageStream,imageKey}=await source;const requests=[];const states=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:(s,hint)=>states.push(hint),batchSize:2});
|
||||
const first=stream.reset({q:'x'});requests[0].resolve(page([image('a'),image('b')],2));await first;
|
||||
const older=stream.refresh();const newer=stream.refresh();
|
||||
assert.equal(requests[1].signal.aborted,true,'the older re-read is aborted');
|
||||
requests[2].resolve(page([image('b'),image('b'),image('a')],2));
|
||||
assert.equal(await newer,2);
|
||||
requests[1].resolve(page([image('old')],1));assert.equal(await older,0);
|
||||
assert.deepEqual(stream.state.items.map(imageKey),['0:b','0:a']);
|
||||
assert.equal(states.filter(hint=>hint?.replaced).length,1);
|
||||
const pending=stream.refresh();
|
||||
stream.dispose();
|
||||
assert.equal(requests[3].signal.aborted,true);
|
||||
requests[3].resolve(page([image('late')],1));
|
||||
assert.equal(await pending,0);assert.deepEqual(stream.state.items.map(imageKey),['0:b','0:a']);
|
||||
});
|
||||
|
||||
test('a refresh deferred behind a page load is dropped when the stream is disposed meanwhile', async () => {
|
||||
const {createImageStream}=await source;const requests=[];
|
||||
const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},batchSize:2});
|
||||
const first=stream.reset({q:'x'});requests[0].resolve(page([image('a'),image('b')],4,'c1'));await first;
|
||||
const more=stream.loadMore();
|
||||
assert.equal(await stream.refresh(),0);
|
||||
stream.dispose();
|
||||
requests[1].resolve(page([image('c')],4,'c2'));await more;
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(requests.length,2,'no refresh runs after dispose');
|
||||
});
|
||||
|
||||
@@ -771,3 +771,57 @@ test('fileFor rejects selected-local-file for rooted identity', () => {
|
||||
assert.throws(() => project(envelope(entry), { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts), /Invalid inspection file/);
|
||||
assert.equal(project(data, { kind: 'local-file', key: 'local:1', path: 'a.png' }, null, extractGenerationFacts).file.observation, 'selected-local-file');
|
||||
});
|
||||
|
||||
// #249 PR C Task C3: the B3 envelope's `image_tags` facet, the file's own current tags (S1).
|
||||
test('image_tags facet is kept exactly and absent stays null', () => {
|
||||
const entry = image(), project = contract('toInspectionRecord');
|
||||
assert.equal(project(envelope(entry), entry, extractGenerationFacts).imageTags, null);
|
||||
for (const facet of [{ state: 'current', tags: ['dunes', 'warm_light'] }, { state: 'current', tags: [] }, { state: 'none', tags: [] }, { state: 'unavailable', tags: [] }]) {
|
||||
const data = { ...envelope(entry), image_tags: facet };
|
||||
const record = project(data, entry, extractGenerationFacts);
|
||||
assert.deepEqual(record.imageTags, facet);
|
||||
assert.notEqual(record.imageTags.tags, facet.tags);
|
||||
}
|
||||
const hundred = Array.from({ length: 100 }, (_, index) => `tag_${index}`);
|
||||
assert.deepEqual(project({ ...envelope(entry), image_tags: { state: 'current', tags: hundred } }, entry, extractGenerationFacts).imageTags.tags, hundred);
|
||||
});
|
||||
|
||||
test('rejects a malformed image_tags facet', () => {
|
||||
const entry = image();
|
||||
for (const facet of [null, [], 'current', { state: 'current' }, { tags: [] }, { state: 'current', tags: ['a'], extra: 1 },
|
||||
{ state: 'stale', tags: [] }, { state: 'none', tags: ['a'] }, { state: 'unavailable', tags: ['a'] }, { state: 'current', tags: 'a' },
|
||||
{ state: 'current', tags: [1] }, { state: 'current', tags: [''] }, { state: 'current', tags: [' a'] }, { state: 'current', tags: ['a,b'] },
|
||||
{ state: 'current', tags: ['a\nb'] }, { state: 'current', tags: ['x'.repeat(51)] },
|
||||
{ state: 'current', tags: Array.from({ length: 101 }, (_, index) => `tag_${index}`) }]) {
|
||||
assert.throws(() => module.toInspectionRecord({ ...envelope(entry), image_tags: facet }, entry, extractGenerationFacts), TypeError, JSON.stringify(facet));
|
||||
}
|
||||
assert.equal(module.toInspectionRecord({ ...envelope(entry), image_tags: { state: 'current', tags: ['é'.repeat(50)] } }, entry, extractGenerationFacts).imageTags.tags[0].length, 50);
|
||||
});
|
||||
|
||||
test('refresh re-reads the selected file and keeps the shown record until the new read lands', async () => {
|
||||
const { api, requests } = pendingApi(), updates = [];
|
||||
const controller = contract('createInspectionController')({ api, extractFacts: extractGenerationFacts });
|
||||
assert.equal(typeof controller.refresh, 'function', 'refresh is part of the controller');
|
||||
controller.subscribe(state => updates.push(state));
|
||||
const load = controller.select(image()); requests[0].resolve(envelope()); await load;
|
||||
const shown = controller.state, count = updates.length;
|
||||
const refresh = controller.refresh();
|
||||
assert.equal(requests.length, 2);
|
||||
// No loading state is published: the panel keeps its content.
|
||||
assert.equal(controller.state, shown); assert.equal(updates.length, count);
|
||||
requests[1].resolve({ ...envelope(), image_tags: { state: 'current', tags: ['fresh'] } }); await refresh;
|
||||
assert.deepEqual(controller.state.record.imageTags, { state: 'current', tags: ['fresh'] });
|
||||
assert.equal(controller.state.loading, false); assert.equal(updates.length, count + 1);
|
||||
// A failed quiet re-read reports the failure as a read would.
|
||||
const failing = controller.refresh(); requests[2].reject(new Error('offline')); await failing;
|
||||
assert.equal(controller.state.error, true);
|
||||
// Without a shown record a refresh is an ordinary retry (loading published).
|
||||
const again = controller.refresh(); assert.equal(controller.state.loading, true);
|
||||
requests[3].resolve(envelope()); await again; assert.equal(controller.state.error, false);
|
||||
// A newer selection supersedes a refresh in flight.
|
||||
const stale = controller.refresh(); const other = controller.select({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') });
|
||||
requests[5].resolve(envelope({ ...image(), path: 'folder/b.png', filename: 'b.png', url: image().url.replace('a%20%23%25', 'b') })); await other;
|
||||
requests[4].resolve(envelope()); await stale;
|
||||
assert.equal(controller.state.record.identity.path, 'folder/b.png');
|
||||
controller.dispose(); assert.equal(await controller.refresh(), undefined);
|
||||
});
|
||||
|
||||
+24
@@ -221,6 +221,18 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/image-tagging-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/image-tagging.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 88,
|
||||
"functions": 87,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/images-category.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -233,6 +245,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-favorites.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 85,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-selection.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
@@ -245,6 +263,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images-tagging.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/images.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 89,
|
||||
|
||||
+17
-7
@@ -15,7 +15,7 @@
|
||||
"use strict";
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(status, message, code = null, recovery = null) {
|
||||
constructor(status, message, code = null, recovery = null, current = null) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
@@ -32,6 +32,14 @@
|
||||
this.recovery = { action: "smaller-limit", limit: recovery.limit };
|
||||
}
|
||||
}
|
||||
// A tag-conflict answer carries the image's current tag set; only a
|
||||
// well-formed one is kept, copied, so the dialog can show it.
|
||||
this.current = null;
|
||||
if (current && Array.isArray(current.tags) && current.tags.length <= 100 &&
|
||||
current.tags.every(tag => typeof tag === "string") &&
|
||||
Number.isSafeInteger(current.tag_revision) && current.tag_revision >= 0) {
|
||||
this.current = { tags: current.tags.slice(), tag_revision: current.tag_revision };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +64,11 @@
|
||||
function createApiClient(fetchImpl) {
|
||||
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
|
||||
|
||||
async function request(method, url, { body, signal, cache } = {}) {
|
||||
async function request(method, url, { body, signal, cache, keepalive } = {}) {
|
||||
const init = { method, headers: {} };
|
||||
if (cache !== undefined) init.cache = cache;
|
||||
// keepalive lets a write or a release finish while its page unloads (pagehide).
|
||||
if (keepalive) init.keepalive = true;
|
||||
if (body !== undefined) {
|
||||
init.headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
@@ -76,11 +86,11 @@
|
||||
|
||||
const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery);
|
||||
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery, data);
|
||||
}
|
||||
if (!parsed) throw new ApiError(response.status, "Invalid JSON response");
|
||||
if (data && typeof data === "object" && data.success === false) {
|
||||
throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery);
|
||||
throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -88,11 +98,11 @@
|
||||
return {
|
||||
// cache is a fetch RequestCache mode such as "no-store"; omitted keeps the default.
|
||||
get: (url, { signal, cache } = {}) => request("GET", url, { signal, cache }),
|
||||
post: (url, body, { signal } = {}) => request("POST", url, { body, signal }),
|
||||
put: (url, body, { signal } = {}) => request("PUT", url, { body, signal }),
|
||||
post: (url, body, { signal, keepalive } = {}) => request("POST", url, { body, signal, keepalive }),
|
||||
put: (url, body, { signal, keepalive } = {}) => request("PUT", url, { body, signal, keepalive }),
|
||||
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
|
||||
// A DELETE body is optional; the favorites store names its file in one.
|
||||
del: (url, { signal, body } = {}) => request("DELETE", url, { body, signal }),
|
||||
del: (url, { signal, body, keepalive } = {}) => request("DELETE", url, { body, signal, keepalive }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,25 @@ function evidenceBadge(prompts) {
|
||||
function extensionOf(record) {
|
||||
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
|
||||
}
|
||||
/**
|
||||
* The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
|
||||
* shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
|
||||
* a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
|
||||
*/
|
||||
export function inspectorTagChips(record, {filterable}) {
|
||||
const shown = new Set(), chips = [];
|
||||
const add = (names, filter) => {
|
||||
for (const name of names) {
|
||||
const key = name.toLowerCase();
|
||||
if (shown.has(key)) continue;
|
||||
shown.add(key); chips.push({name, filter});
|
||||
}
|
||||
};
|
||||
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
|
||||
const {library} = record;
|
||||
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
|
||||
return chips;
|
||||
}
|
||||
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
|
||||
function prefixedChip(prefix, text) {
|
||||
const chip = el('span', undefined, 'chip');
|
||||
@@ -211,15 +230,16 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
const prompts = el('div'); inner.append(prompts);
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
|
||||
blocks.setState({prompts:record.prompts}); contentScope.cleanup(blocks.dispose);
|
||||
if (linked && library.tags.length) {
|
||||
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
|
||||
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
|
||||
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again.
|
||||
const filterable = onTagFilter && library.tags_scope === 'positive';
|
||||
// D21: a chip sets the Images Tags filter, which matches the linked positive's tags; a recipe's own tags are
|
||||
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
|
||||
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
|
||||
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
|
||||
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
|
||||
if (chips.length) {
|
||||
const tags = el('div', undefined, 'tags');
|
||||
for (const tag of library.tags) {
|
||||
if (!filterable) {tags.append(el('span', tag, 'chip')); continue;}
|
||||
const chip = createButton(tag, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(tag)); tags.append(chip);
|
||||
for (const {name, filter} of chips) {
|
||||
if (!filter) {tags.append(el('span', name, 'chip')); continue;}
|
||||
const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
|
||||
}
|
||||
inner.append(tags);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,17 @@ 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;
|
||||
// A list row's art: the concept's 160px, yielding to 45% of the row at extreme zoom (the overlay's D31).
|
||||
const LIST_ART_WIDTH = 160, LIST_ART_SHARE = 0.45;
|
||||
|
||||
/**
|
||||
* Whether a card's info row stacks its text above Info and Tag (D31): the row would be narrower than the narrowest
|
||||
* bounded card, which only extreme zoom reaches. A list row's info is what its art leaves of the row.
|
||||
*/
|
||||
export function stacksCardInfo(width, mode) {
|
||||
const info = mode === 'list' ? width - Math.min(LIST_ART_WIDTH, width * LIST_ART_SHARE) : width;
|
||||
return info < MINIMUM_CARD_WIDTH;
|
||||
}
|
||||
|
||||
/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */
|
||||
export function gridMetrics(grid) {
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Tag image dialog state: observe, staged suggestions and byte-exact saves (#249 C1).
|
||||
*
|
||||
* UI-agnostic. A dialog renders `controller.state` (a frozen snapshot replaced
|
||||
* on every change) and calls its methods. open() captures the file and reads its
|
||||
* saved tags; generate() stages the top suggestions as removable chips, written
|
||||
* only by save(). Every read is guarded by a serial: a model change, a reopen,
|
||||
* cancel() or dispose() supersedes older requests, so a late answer can never
|
||||
* stage chips or supply a token. Writes are sent once, without an abort signal
|
||||
* (aborting cannot undo a write); a lost answer is resent only by retry(), with
|
||||
* the identical body (the server answers a duplicate as `replayed`). A save in
|
||||
* flight when the dialog closes still settles: the capture is released after it,
|
||||
* and `onLateSave(outcome)` reports what happened. Writes and the capture release
|
||||
* are sent with `keepalive`, so they finish while the page unloads.
|
||||
*/
|
||||
import {noun} from './plural.mjs';
|
||||
|
||||
export const MODEL_OPTIONS = Object.freeze([
|
||||
Object.freeze({id: 'wd14-swinv2', label: 'WD14 SwinV2 · ~400 MB', classifier: true}),
|
||||
Object.freeze({id: 'wd14-vit', label: 'WD14 ViT · ~400 MB', classifier: true}),
|
||||
Object.freeze({id: 'gguf', label: 'GGUF JoyCaption (Recommended) · ~4.5 GB', classifier: false}),
|
||||
Object.freeze({id: 'hf', label: 'HuggingFace JoyCaption · ~16 GB', classifier: false}),
|
||||
]);
|
||||
|
||||
const OBSERVE_URL = '/prompt_manager/image-annotations/observe';
|
||||
const SUGGEST_URL = '/prompt_manager/image-annotations/suggest';
|
||||
const TAGS_URL = '/prompt_manager/image-annotations/tags';
|
||||
const CAPTURE_URL = '/prompt_manager/image-annotations/capture/';
|
||||
const MODELS_URL = '/prompt_manager/autotag/models';
|
||||
const SOURCES = new Set(['images', 'library', 'review', 'tags', 'bulk']);
|
||||
const OPTION_KEYS = new Set(['general_threshold', 'character_threshold', 'instructions', 'suggestion_limit']);
|
||||
const DEFAULTS = Object.freeze({general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10});
|
||||
const MAX_SUGGESTIONS = 100;
|
||||
const MAX_INSTRUCTION_CHARS = 4000;
|
||||
const EDITABLE = new Set(['ready', 'generate-error', 'write-error', 'conflict']);
|
||||
// Answers after which only a fresh observation can save (the capture or epoch is gone); Retry observes again (D116).
|
||||
const REOPEN = {'storage-changed': 'storage-changed', 'file-changed': 'file-changed', 'source-changed': 'file-changed', 'capture-expired': 'capture-expired'};
|
||||
const REOBSERVE = new Set(Object.values(REOPEN));
|
||||
const RETRYABLE = new Set(['observe-error', 'generate-error', 'unknown-outcome', ...REOBSERVE]);
|
||||
// Generic failure envelopes whose fixed sentence the dialog words itself.
|
||||
const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed', 'capture-error']);
|
||||
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
||||
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
|
||||
|
||||
const COPY = Object.freeze({
|
||||
// The concept's on-screen opening sentence (renderTagModelOptions a1062 replaces its markup's at once).
|
||||
modelChanged: 'Generate suggestions with the selected model before approving.',
|
||||
chipHint: 'Remove anything you do not want to add.', // D83
|
||||
changedElsewhere: 'The tags of this image changed since the dialog opened.',
|
||||
allRemoved: 'All suggestions removed. Generate again or enter tags manually.', // D83
|
||||
empty: 'No suggestions met this threshold. Adjust it or enter tags manually.',
|
||||
generating: 'Generating suggestions…',
|
||||
observeFailed: 'The image could not be opened for tagging.',
|
||||
generateFailed: 'Auto-tagging failed. Check server logs for details.',
|
||||
saveFailed: 'The tags could not be saved.',
|
||||
unknown: 'The save could not be confirmed. Retry sends the same tags again.',
|
||||
unknownClosed: 'The tag save could not be confirmed. Open Tag image again to check the saved tags.',
|
||||
reviewSuffix: ' Prompt association still awaits review.',
|
||||
});
|
||||
|
||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
||||
const hex64 = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
||||
const captureToken = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
const count = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const dimension = value => value === null || (Number.isSafeInteger(value) && value > 0);
|
||||
const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string');
|
||||
const unit = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
||||
const limit = value => Number.isInteger(value) && value >= 1 && value <= MAX_SUGGESTIONS;
|
||||
|
||||
function requireValue(valid, message) {if (!valid) throw new TypeError(message);}
|
||||
function freeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);}
|
||||
return value;
|
||||
}
|
||||
// Duck-typed by name: this ES module cannot import the UMD api-client.js.
|
||||
function isApiError(error) {return error instanceof Error && error.name === 'ApiError' && Number.isInteger(error.status);}
|
||||
function messageOf(error, fallback) {
|
||||
return isApiError(error) && error.code && !GENERIC_CODES.has(error.code) && error.message ? error.message : fallback;
|
||||
}
|
||||
const modelOf = id => MODEL_OPTIONS.find(option => option.id === id);
|
||||
// An input's value arrives as text; blank text is not zero.
|
||||
const numberOf = value => (typeof value === 'string' ? (value.trim() === '' ? NaN : Number(value)) : value);
|
||||
|
||||
/** Trimmed, non-blank names with case-insensitive repeats dropped; the first spelling and position win. */
|
||||
export function dedupeTags(names) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of Array.isArray(names) ? names : []) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const name = value.trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!name || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The manual field's comma-separated names. */
|
||||
export function parseManualTags(text) {
|
||||
return dedupeTags(typeof text === 'string' ? text.split(',') : []);
|
||||
}
|
||||
|
||||
/** The server's current set ahead of the user's draft: what the dialog shows after another writer saved. */
|
||||
function mergeCurrent(current, draft) {
|
||||
return dedupeTags([...current, ...parseManualTags(draft)]).join(', ');
|
||||
}
|
||||
const savedNow = tags => `Saved now: ${tags.length ? tags.join(', ') : 'no tags'}.`;
|
||||
|
||||
/** `null` when *options* are valid for *model*, else `{field, message}` (nothing is sent). */
|
||||
export function optionError(model, options) {
|
||||
const option = modelOf(model);
|
||||
requireValue(option, 'Unknown tagging model');
|
||||
if (option.classifier) {
|
||||
for (const field of ['general_threshold', 'character_threshold']) {
|
||||
if (!unit(numberOf(options[field]))) return {field, message: 'Enter a confidence from 0 to 1.'};
|
||||
}
|
||||
} else {
|
||||
const text = options.instructions;
|
||||
if (typeof text !== 'string' || !text.trim() || text.length > MAX_INSTRUCTION_CHARS) {
|
||||
return {field: 'instructions', message: `Enter tagging instructions of at most ${MAX_INSTRUCTION_CHARS} characters.`};
|
||||
}
|
||||
}
|
||||
if (!limit(numberOf(options.suggestion_limit))) {
|
||||
return {field: 'suggestion_limit', message: `Enter a whole number of suggestions from 1 to ${MAX_SUGGESTIONS}.`};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestOptions(model, options) {
|
||||
const body = {suggestion_limit: numberOf(options.suggestion_limit)};
|
||||
if (modelOf(model).classifier) {
|
||||
return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body};
|
||||
}
|
||||
return {instructions: options.instructions, ...body};
|
||||
}
|
||||
|
||||
function validateTarget(target) {
|
||||
requireValue(object(target) && Number.isSafeInteger(target.root_index) && target.root_index >= 0 &&
|
||||
hex64(target.root_scope) && typeof target.path === 'string' && target.path.length > 0 && target.path.length <= 1024,
|
||||
'Invalid image target');
|
||||
return Object.freeze({root_index: target.root_index, root_scope: target.root_scope, path: target.path});
|
||||
}
|
||||
|
||||
function validateObserved(value) {
|
||||
requireValue(object(value) && value.success === true && uuid(value.library_epoch) && object(value.capture), 'Invalid observation');
|
||||
const capture = value.capture;
|
||||
requireValue(captureToken(capture.token) && hex64(capture.sha256) && typeof capture.preview_url === 'string' &&
|
||||
capture.preview_url.startsWith(CAPTURE_URL) && dimension(capture.width) && dimension(capture.height) &&
|
||||
['image', 'video'].includes(capture.media_type) && object(capture.generate) &&
|
||||
typeof capture.generate.supported === 'boolean' &&
|
||||
(capture.generate.reason === null || typeof capture.generate.reason === 'string'), 'Invalid capture');
|
||||
requireValue(typeof value.filename === 'string' && (value.title === null || typeof value.title === 'string') &&
|
||||
strings(value.tags) && count(value.tag_revision), 'Invalid observation');
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateSuggestions(value, cap) {
|
||||
requireValue(object(value) && value.success === true && Array.isArray(value.suggestions), 'Invalid suggestions');
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const row of value.suggestions) {
|
||||
requireValue(object(row) && typeof row.name === 'string' && (row.confidence === null || unit(row.confidence)), 'Invalid suggestion');
|
||||
const name = row.name.trim();
|
||||
const key = name.toLowerCase();
|
||||
if (!name || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const label = row.confidence === null ? 'Suggested' : `${Math.round(row.confidence * 100)}%`;
|
||||
rows.push({name, confidence: row.confidence, label});
|
||||
}
|
||||
return rows.slice(0, cap);
|
||||
}
|
||||
|
||||
function validateReceipt(value) {
|
||||
requireValue(object(value) && value.success === true && strings(value.tags) && count(value.tag_revision), 'Invalid tag receipt');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */
|
||||
async function readDefaults(api, signal) {
|
||||
try {
|
||||
const value = await api.get(MODELS_URL, {signal, cache: 'no-store'});
|
||||
if (!object(value)) return DEFAULTS;
|
||||
return {
|
||||
general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
|
||||
character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
|
||||
instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
|
||||
suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
|
||||
};
|
||||
} catch {
|
||||
// The defaults keep the dialog usable; Generate reports a real failure itself.
|
||||
return DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
function initialState(source) {
|
||||
return {
|
||||
phase: 'observing', closed: false, source, showScopeNote: source === 'review',
|
||||
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
|
||||
model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [],
|
||||
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
|
||||
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
|
||||
optionsRevision: 0, draftRevision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Derived labels and permissions; never stored independently of the fields they follow. */
|
||||
function derive(state) {
|
||||
const editable = EDITABLE.has(state.phase);
|
||||
return {
|
||||
...state,
|
||||
primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags',
|
||||
countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated',
|
||||
chipHint: state.suggestions.length ? COPY.chipHint : '',
|
||||
canSave: editable,
|
||||
canGenerate: editable && state.generateSupported,
|
||||
canRetry: RETRYABLE.has(state.phase),
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}} = {}) {
|
||||
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
|
||||
const imageTarget = validateTarget(target);
|
||||
requireValue(SOURCES.has(source), 'Unknown tagging source');
|
||||
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
|
||||
|
||||
let state = freeze(derive(initialState(source)));
|
||||
const listeners = new Set();
|
||||
let closed = false;
|
||||
let capture = null;
|
||||
let epoch = null;
|
||||
let defaults = DEFAULTS;
|
||||
let openSerial = 0;
|
||||
let generateSerial = 0;
|
||||
let saveSerial = 0;
|
||||
let readAbort = null;
|
||||
let generateAbort = null;
|
||||
let lastBody = null;
|
||||
// A save in flight: closing hands the capture release to its completion.
|
||||
let saving = false;
|
||||
let observedOnce = false;
|
||||
|
||||
function set(patch) {
|
||||
if (closed) return;
|
||||
state = freeze(derive({...state, ...patch}));
|
||||
for (const listener of [...listeners]) listener(state);
|
||||
}
|
||||
|
||||
function releaseToken(token) {
|
||||
// Best effort: an unreleased capture expires on the server's idle timeout.
|
||||
api.del(CAPTURE_URL + encodeURIComponent(token), {keepalive: true}).catch(() => {});
|
||||
}
|
||||
function releaseCapture() {
|
||||
if (capture) releaseToken(capture.token);
|
||||
capture = null;
|
||||
}
|
||||
function stopGenerating() {
|
||||
generateSerial += 1;
|
||||
generateAbort?.abort();
|
||||
generateAbort = null;
|
||||
}
|
||||
function reasonText() {
|
||||
return state.generateSupported ? '' : (state.generateReason || '');
|
||||
}
|
||||
|
||||
/** Observe the file; `keepDraft` (a Retry after a changed file, storage or capture) keeps the typed draft. */
|
||||
async function open({keepDraft = false} = {}) {
|
||||
if (closed) return;
|
||||
const serial = ++openSerial;
|
||||
const draft = keepDraft ? state.manual : null;
|
||||
const seenRevision = state.tagRevision;
|
||||
stopGenerating();
|
||||
releaseCapture();
|
||||
readAbort?.abort();
|
||||
readAbort = new AbortController();
|
||||
const defaultsRead = readDefaults(api, readAbort.signal);
|
||||
lastBody = null;
|
||||
set({phase: 'observing', statusText: '', toast: null, currentTags: null, generated: false, suggestions: [], suggestionsText: COPY.modelChanged});
|
||||
let value;
|
||||
try {
|
||||
const answer = await api.post(OBSERVE_URL, {target: {...imageTarget}});
|
||||
if (closed || serial !== openSerial) {
|
||||
if (captureToken(answer?.capture?.token)) releaseToken(answer.capture.token);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
value = validateObserved(answer);
|
||||
} catch (error) {
|
||||
if (captureToken(answer?.capture?.token)) releaseToken(answer.capture.token);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (closed || serial !== openSerial) return;
|
||||
set({phase: 'observe-error', statusText: messageOf(error, COPY.observeFailed)});
|
||||
return;
|
||||
}
|
||||
capture = {token: value.capture.token, sha256: value.capture.sha256};
|
||||
epoch = value.library_epoch;
|
||||
defaults = await defaultsRead;
|
||||
if (closed || serial !== openSerial) return;
|
||||
observedOnce = true;
|
||||
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
|
||||
// A kept draft stays as typed unless another writer changed the set meanwhile: then the set leads it (I2).
|
||||
const changed = draft !== null && value.tag_revision !== seenRevision;
|
||||
const reason = generate.supported ? '' : (generate.reason || '');
|
||||
set({
|
||||
phase: 'ready',
|
||||
image: {filename: value.filename, title: value.title, width, height, mediaType, previewUrl,
|
||||
dimensions: width && height ? `${width} × ${height}` : null},
|
||||
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
|
||||
manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
|
||||
draftRevision: state.draftRevision + 1,
|
||||
options: {...defaults}, optionsRevision: state.optionsRevision + 1,
|
||||
generateSupported: generate.supported, generateReason: generate.reason,
|
||||
statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason,
|
||||
});
|
||||
}
|
||||
|
||||
function setManual(text) {
|
||||
set({manual: typeof text === 'string' ? text : ''});
|
||||
}
|
||||
|
||||
function setModel(id) {
|
||||
requireValue(modelOf(id), 'Unknown tagging model');
|
||||
if (closed || id === state.model) return;
|
||||
stopGenerating();
|
||||
const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase;
|
||||
set({
|
||||
phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(),
|
||||
optionsRevision: state.optionsRevision + 1,
|
||||
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91).
|
||||
options: {...state.options, general_threshold: defaults.general_threshold,
|
||||
character_threshold: defaults.character_threshold, instructions: defaults.instructions},
|
||||
});
|
||||
}
|
||||
|
||||
function setOption(key, value) {
|
||||
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
|
||||
set({options: {...state.options, [key]: value}});
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (closed || !state.canGenerate || optionError(state.model, state.options)) return false;
|
||||
stopGenerating();
|
||||
const serial = generateSerial;
|
||||
const controller = new AbortController();
|
||||
generateAbort = controller;
|
||||
const model = state.model;
|
||||
const options = requestOptions(model, state.options);
|
||||
const stale = () => closed || serial !== generateSerial;
|
||||
set({phase: 'generating', statusText: COPY.generating});
|
||||
let rows;
|
||||
try {
|
||||
const value = await api.post(SUGGEST_URL, {
|
||||
target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options,
|
||||
}, {signal: controller.signal});
|
||||
if (stale()) return false;
|
||||
rows = validateSuggestions(value, options.suggestion_limit);
|
||||
} catch (error) {
|
||||
if (stale()) return false;
|
||||
const phase = isApiError(error) && REOPEN[error.code];
|
||||
set(phase ? {phase, statusText: error.message} : {phase: 'generate-error', statusText: messageOf(error, COPY.generateFailed)});
|
||||
return false;
|
||||
}
|
||||
generateAbort = null;
|
||||
set({
|
||||
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty,
|
||||
statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function remove(name) {
|
||||
const suggestions = state.suggestions.filter(row => row.name !== name);
|
||||
if (closed || suggestions.length === state.suggestions.length) return;
|
||||
set({suggestions, suggestionsText: suggestions.length ? '' : COPY.allRemoved});
|
||||
}
|
||||
|
||||
async function send(body) {
|
||||
const serial = ++saveSerial;
|
||||
lastBody = body;
|
||||
saving = true;
|
||||
set({phase: 'saving', statusText: ''});
|
||||
let value = null, failure = null;
|
||||
try {
|
||||
value = validateReceipt(await api.put(TAGS_URL, body, {keepalive: true}));
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
if (serial !== saveSerial) return false;
|
||||
if (closed) return settleClosed(value, failure);
|
||||
if (failure) {
|
||||
set(failedSave(failure));
|
||||
return false;
|
||||
}
|
||||
lastBody = null;
|
||||
set({phase: 'saved', savedTags: value.tags.slice(), tagRevision: value.tag_revision, currentTags: null, statusText: '',
|
||||
toast: toastOf(value)});
|
||||
return true;
|
||||
}
|
||||
// The mock's toast verbatim (app.js:1545, without the demo's "in this tab"): "tags" even for one.
|
||||
function toastOf(value) {
|
||||
return `${value.tags.length} tags saved for ${state.image.filename}.${source === 'review' ? COPY.reviewSuffix : ''}`;
|
||||
}
|
||||
/** The dialog closed while its save was in flight: release the capture now, then report the outcome. */
|
||||
function settleClosed(value, failure) {
|
||||
releaseCapture();
|
||||
if (!failure) {
|
||||
onLateSave({saved: true, filename: state.image.filename, tags: value.tags.slice(), toast: toastOf(value)});
|
||||
return true;
|
||||
}
|
||||
const {phase, statusText} = failedSave(failure);
|
||||
// A conflict's own sentence, not its merged "Saved now" view: the dialog that would show it is gone.
|
||||
const message = phase === 'unknown-outcome' ? COPY.unknownClosed : phase === 'conflict' ? failure.message : statusText;
|
||||
onLateSave({saved: false, message});
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The state patch of a failed save. */
|
||||
function failedSave(error) {
|
||||
if (!isApiError(error) || error.status < 400 || UNCONFIRMED_STATUS.has(error.status)) {
|
||||
return {phase: 'unknown-outcome', statusText: COPY.unknown};
|
||||
}
|
||||
lastBody = null;
|
||||
if (error.code === 'tag-conflict') {
|
||||
// The current set is shown (status and field) ahead of the draft; a second Save is against its revision.
|
||||
const current = error.current;
|
||||
if (!current) return {phase: 'conflict', statusText: error.message, currentTags: null};
|
||||
return {phase: 'conflict', currentTags: current.tags.slice(), tagRevision: current.tag_revision,
|
||||
statusText: `${error.message} ${savedNow(current.tags)}`,
|
||||
manual: mergeCurrent(current.tags, state.manual), draftRevision: state.draftRevision + 1};
|
||||
}
|
||||
if (REOPEN[error.code]) return {phase: REOPEN[error.code], statusText: error.message};
|
||||
return {phase: 'write-error', statusText: messageOf(error, COPY.saveFailed)};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (closed || !state.canSave) return false;
|
||||
const tags = dedupeTags([...parseManualTags(state.manual), ...state.suggestions.map(row => row.name)]);
|
||||
return send({
|
||||
target: {...imageTarget}, capture_token: capture.token, sha256: capture.sha256,
|
||||
expected_epoch: epoch, expected_tag_revision: state.tagRevision, tags,
|
||||
});
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
if (closed || !state.canRetry) return false;
|
||||
if (state.phase === 'observe-error') return open({keepDraft: observedOnce});
|
||||
if (REOBSERVE.has(state.phase)) return open({keepDraft: true});
|
||||
if (state.phase === 'generate-error') return generate();
|
||||
return send(lastBody);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (closed) return;
|
||||
set({closed: true});
|
||||
closed = true;
|
||||
openSerial += 1;
|
||||
stopGenerating();
|
||||
readAbort?.abort();
|
||||
readAbort = null;
|
||||
// A save in flight keeps its capture until it settles (send releases it); aborting cannot undo the write.
|
||||
if (!saving) releaseCapture();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
listeners.clear();
|
||||
cancel();
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
requireValue(typeof listener === 'function', 'A listener function is required');
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {return state;},
|
||||
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* The concept's "Tag image" dialog (docs/concepts/workspace/app.js `tagReview` a1037–1061, `renderTagModelOptions`
|
||||
* a1062–1076, `generateTags` a1077–1135, `apply-tags` a1517–1547; style.css s1598–1694) over the tagging controller
|
||||
* (image-tagging-state.mjs), in the shared modal shell (workspace-dialogs.mjs) with the concept's `tagging-modal`
|
||||
* class, whose sticky `.dialog-actions` stay reachable while the content scrolls.
|
||||
*
|
||||
* Recorded deviations: D83 (#249 plan D60) the suggestions are removable chips, every one preselected, at most the
|
||||
* requested count, with "Remove anything you do not want to add." while chips exist and the all-removed copy; D84
|
||||
* (plan D61) the legacy engines' labels and sizes, GGUF in the concept's Ollama slot, instructions prefilled with the
|
||||
* server's default; D91 (plan D68) a Suggestions count field for every model. States the concept lacks (busy,
|
||||
* conflict, changed storage or file, lost answers, unavailable model, video) are only text in
|
||||
* `#tag-generation-status`; D116 (ruling T5) Retry leads the actions wherever the controller allows it, as the other
|
||||
* workspace dialogs place theirs.
|
||||
*
|
||||
* The capture is released when the dialog closes (×, Escape, Cancel, Save), when the route changes (the shell
|
||||
* closes its dialog) and on `pagehide` (which closes the dialog; the release is sent with `keepalive`). A save in
|
||||
* flight at close keeps its capture until it settles, and its outcome is still toasted and announced.
|
||||
*/
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { createImageTaggingController, MODEL_OPTIONS, optionError } from './image-tagging-state.mjs';
|
||||
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { cardTitle } from './images-state.mjs';
|
||||
|
||||
const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
|
||||
const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
|
||||
// The option inputs per controller key; the classifier keys only exist for WD14 models.
|
||||
const OPTION_IDS = Object.freeze({
|
||||
general_threshold: 'image-general-threshold',
|
||||
character_threshold: 'image-character-threshold',
|
||||
instructions: 'image-tag-instructions',
|
||||
suggestion_limit: 'image-suggestion-limit',
|
||||
});
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function setText(node, text) {
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
function setDisabled(button, disabled) {
|
||||
if (disabled) button.setAttribute('aria-disabled', 'true');
|
||||
else button.removeAttribute('aria-disabled');
|
||||
}
|
||||
const disabled = button => button.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
const savedListeners = new Set();
|
||||
/** Run `listener({target, filename, tags})` after every confirmed tag save; returns the unsubscribe. */
|
||||
export function onImageTagsSaved(listener) {
|
||||
if (typeof listener !== 'function') throw new TypeError('A listener function is required');
|
||||
savedListeners.add(listener);
|
||||
return () => savedListeners.delete(listener);
|
||||
}
|
||||
/** How many pages follow tag saves (a mounted page's subscription ends with its scope). */
|
||||
export function imageTagsListenerCount() {
|
||||
return savedListeners.size;
|
||||
}
|
||||
function announceSaved(event) {
|
||||
for (const listener of [...savedListeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
// One page's refresh failure never stops the others.
|
||||
console.error('Image tag listener failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tagButtons = new WeakSet();
|
||||
/** The concept card's Tag beside Info (app.js mediaCard a398), for Images and every Library-embedded gallery. */
|
||||
export function createTagButton(image) {
|
||||
const button = createButton('', 'icon-btn');
|
||||
button.append(createWorkspaceIcon('tagImage'));
|
||||
button.setAttribute('aria-label', `Tag or auto-tag ${image.filename}`);
|
||||
tagButtons.add(button);
|
||||
return button;
|
||||
}
|
||||
/** Whether `node` is a card's Tag button. */
|
||||
export function isTagButton(node) {
|
||||
return tagButtons.has(node);
|
||||
}
|
||||
|
||||
/** A listed file's tagging identity (`root_index`, `root_scope`, `path`), else null. */
|
||||
export function imageTarget(image) {
|
||||
const {root_index: index, root_scope: scope, path} = image ?? {};
|
||||
if (!Number.isSafeInteger(index) || index < 0 || typeof scope !== 'string' || !/^[0-9a-f]{64}$/.test(scope) ||
|
||||
typeof path !== 'string' || !path) return null;
|
||||
return {root_index: index, root_scope: scope, path};
|
||||
}
|
||||
|
||||
function numberInput(value, {min, max, step}) {
|
||||
const input = el('input');
|
||||
Object.assign(input, {type: 'number', min, max, step});
|
||||
input.value = String(value);
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate.
|
||||
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog). Returns the `element` and
|
||||
* `requestGenerate()`, Generate's own path (field validity first), for a Retry after a failed Generate.
|
||||
*/
|
||||
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) {
|
||||
const panel = el('div', undefined, 'tag-model-panel');
|
||||
const select = el('select');
|
||||
for (const option of MODEL_OPTIONS) {
|
||||
const node = el('option', option.label); // D84
|
||||
node.value = option.id;
|
||||
select.append(node);
|
||||
}
|
||||
const options = el('div');
|
||||
options.id = 'image-tag-model-options';
|
||||
const generate = createButton('', 'ca-primary');
|
||||
generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions');
|
||||
panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate);
|
||||
const inputs = new Map();
|
||||
let shownModel = null, shownOptions = null;
|
||||
|
||||
function renderOptions(state) {
|
||||
inputs.clear();
|
||||
const classifier = MODEL_OPTIONS.find(option => option.id === state.model).classifier;
|
||||
const rows = [];
|
||||
if (classifier) {
|
||||
const pair = el('div', undefined, 'two-col');
|
||||
for (const [key, label] of [['general_threshold', 'General confidence'], ['character_threshold', 'Character confidence']]) {
|
||||
const input = numberInput(state.options[key], {min: '0', max: '1', step: '0.05'});
|
||||
inputs.set(key, input);
|
||||
pair.append(field(label, input, OPTION_IDS[key]));
|
||||
}
|
||||
rows.push(pair);
|
||||
} else {
|
||||
const area = el('textarea');
|
||||
area.value = state.options.instructions;
|
||||
inputs.set('instructions', area);
|
||||
rows.push(field('Tagging instructions', area, OPTION_IDS.instructions));
|
||||
}
|
||||
// D91: the number of suggestions, for every model.
|
||||
const count = numberInput(state.options.suggestion_limit, {min: '1', max: '100', step: '1'});
|
||||
inputs.set('suggestion_limit', count);
|
||||
rows.push(field('Suggestions', count, OPTION_IDS.suggestion_limit));
|
||||
options.replaceChildren(...rows);
|
||||
for (const [key, input] of inputs) {
|
||||
input.addEventListener('input', () => {
|
||||
input.setCustomValidity('');
|
||||
controller.setOption(key, input.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
function update(state) {
|
||||
// A model change or the observation's server defaults rewrite the fields; typing never does (I1).
|
||||
if (state.model !== shownModel || state.optionsRevision !== shownOptions) {
|
||||
shownModel = state.model;
|
||||
shownOptions = state.optionsRevision;
|
||||
select.value = state.model;
|
||||
renderOptions(state);
|
||||
}
|
||||
setDisabled(generate, !state.canGenerate);
|
||||
}
|
||||
select.addEventListener('change', () => controller.setModel(select.value));
|
||||
function requestGenerate() {
|
||||
const {state} = controller;
|
||||
if (!state.canGenerate) return;
|
||||
// The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field).
|
||||
for (const input of inputs.values()) input.setCustomValidity('');
|
||||
const invalid = [...inputs.values()].find(input => !input.checkValidity());
|
||||
if (invalid) { invalid.reportValidity(); return; }
|
||||
const error = optionError(state.model, state.options);
|
||||
if (error) {
|
||||
const input = inputs.get(error.field);
|
||||
input.setCustomValidity(error.message);
|
||||
input.reportValidity();
|
||||
return;
|
||||
}
|
||||
controller.generate();
|
||||
}
|
||||
generate.addEventListener('click', () => { if (!disabled(generate)) requestGenerate(); });
|
||||
scope.cleanup(controller.subscribe(update));
|
||||
update(controller.state);
|
||||
return {element: panel, requestGenerate};
|
||||
}
|
||||
|
||||
function previewOf(image, state) {
|
||||
const video = state?.image ? state.image.mediaType === 'video' : image?.is_video === true;
|
||||
const node = el(video ? 'video' : 'img', undefined, 'tag-image-preview');
|
||||
if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
|
||||
* `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
|
||||
*/
|
||||
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) {
|
||||
const target = imageTarget(image);
|
||||
if (!target) throw new TypeError('The image has no tagging target');
|
||||
const saved = ({filename: name, tags, toast}) => {
|
||||
const event = {target, filename: name, tags, source};
|
||||
announceSaved(event);
|
||||
onSaved?.(event);
|
||||
showWorkspaceToast(toast);
|
||||
};
|
||||
// A save that settles after the dialog closed is still reported: its toast, and the pages' refresh (I5).
|
||||
const onLateSave = outcome => (outcome.saved ? saved(outcome) : showWorkspaceToast(outcome.message));
|
||||
const controller = createImageTaggingController({api, target, source, onLateSave});
|
||||
const local = createScope();
|
||||
let open = true;
|
||||
let completed = false;
|
||||
const {resolveUrl} = window.ApiClient;
|
||||
|
||||
// 2. The heading: preview, title, filename and size; the listing's values until the capture answers.
|
||||
const heading = el('div', undefined, 'tag-image-heading');
|
||||
let preview = previewOf(image, null);
|
||||
const copy = el('div');
|
||||
const title = el('h3', cardTitle(image));
|
||||
const filename = el('p', image.filename, 'small');
|
||||
const size = el('span', undefined, 'chip');
|
||||
copy.append(title, filename, size);
|
||||
heading.append(preview, copy);
|
||||
const hasSize = (width, height) => [width, height].every(value => Number.isFinite(value) && value > 0);
|
||||
if (hasSize(image.width, image.height)) size.textContent = `${image.width} × ${image.height}`;
|
||||
else size.remove();
|
||||
|
||||
// 4. The manual field.
|
||||
const manual = el('input');
|
||||
manual.setAttribute('aria-describedby', 'manual-tags-help');
|
||||
const help = el('p', MANUAL_HELP, 'small');
|
||||
help.id = 'manual-tags-help';
|
||||
const manualField = field('Image tags · edit manually', manual, 'manual-image-tags');
|
||||
manualField.append(help);
|
||||
manual.addEventListener('input', () => controller.setManual(manual.value));
|
||||
|
||||
// 5. The model panel; 6. the suggestions.
|
||||
const {element: panel, requestGenerate} = renderModelPanel({scope: local, controller});
|
||||
const suggestions = el('section', undefined, 'tag-suggestion-panel');
|
||||
suggestions.setAttribute('aria-labelledby', 'tag-suggestions-title');
|
||||
const panelHeading = el('div', undefined, 'panel-heading');
|
||||
const suggestionsTitle = el('h3', 'Review suggestions');
|
||||
suggestionsTitle.id = 'tag-suggestions-title';
|
||||
const count = el('span', 'Not generated', 'small');
|
||||
count.id = 'suggestion-count';
|
||||
panelHeading.append(suggestionsTitle, count);
|
||||
const list = el('div');
|
||||
list.id = 'tag-suggestions';
|
||||
const status = el('div', undefined, 'small');
|
||||
status.id = 'tag-generation-status';
|
||||
status.setAttribute('role', 'status');
|
||||
suggestions.append(panelHeading, list, status);
|
||||
|
||||
// 7. The actions; Retry leads them only while the controller allows it.
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const retry = createButton('Retry');
|
||||
const cancel = createButton('Cancel');
|
||||
const primary = createButton('Save manual tags', 'ca-primary');
|
||||
actions.append(cancel, primary);
|
||||
|
||||
const nodes = [heading];
|
||||
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
|
||||
nodes.push(manualField, panel, suggestions, actions);
|
||||
|
||||
let shownImage = null, shownRows = null, shownText = null, shownDraft = 0;
|
||||
function renderImage(info) {
|
||||
shownImage = info;
|
||||
const next = previewOf(null, {image: info});
|
||||
if (next.tagName !== preview.tagName) { preview.replaceWith(next); preview = next; }
|
||||
title.textContent = info.title || info.filename;
|
||||
filename.textContent = info.filename;
|
||||
preview.alt = title.textContent;
|
||||
if (info.width && info.height) { preview.width = info.width; preview.height = info.height; }
|
||||
preview.src = resolveUrl(info.previewUrl);
|
||||
if (info.dimensions) { size.textContent = info.dimensions; if (!size.isConnected) copy.append(size); }
|
||||
else size.remove();
|
||||
}
|
||||
function renderSuggestions(state) {
|
||||
// A removed chip's focus moves to its neighbour's ×, else to Generate.
|
||||
const focused = list.contains(document.activeElement) ? [...list.querySelectorAll('button')].indexOf(document.activeElement) : -1;
|
||||
shownRows = state.suggestions;
|
||||
shownText = state.suggestionsText;
|
||||
if (!state.suggestions.length) {
|
||||
list.replaceChildren(el('p', state.suggestionsText, 'small'));
|
||||
} else {
|
||||
// D83: removable chips instead of the concept's checkbox rows, under ruling T4's instruction.
|
||||
const chips = el('div', undefined, 'tags');
|
||||
for (const row of state.suggestions) {
|
||||
const chip = el('span', undefined, 'chip tag-suggestion');
|
||||
const remove = createButton('×');
|
||||
remove.setAttribute('aria-label', `Remove ${row.name}`);
|
||||
remove.addEventListener('click', () => controller.remove(row.name));
|
||||
chip.append(el('span', row.name), el('span', row.label, 'tag-confidence'), remove);
|
||||
chips.append(chip);
|
||||
}
|
||||
list.replaceChildren(el('p', state.chipHint, 'small'), chips);
|
||||
}
|
||||
if (focused >= 0) {
|
||||
const buttons = [...list.querySelectorAll('button')];
|
||||
(buttons[Math.min(focused, buttons.length - 1)] ?? panel.querySelector('.ca-primary')).focus();
|
||||
}
|
||||
}
|
||||
function complete(state) {
|
||||
completed = true;
|
||||
closeWorkspaceDialog();
|
||||
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
|
||||
}
|
||||
function render(state) {
|
||||
if (!open || completed) return;
|
||||
if (state.image && state.image !== shownImage) renderImage(state.image);
|
||||
// The observation's saved tags, a kept draft, or a conflict's current set ahead of the draft (I2).
|
||||
if (state.draftRevision !== shownDraft) {
|
||||
shownDraft = state.draftRevision;
|
||||
if (manual.value !== state.manual) manual.value = state.manual;
|
||||
}
|
||||
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
|
||||
setText(count, state.countText);
|
||||
setText(status, state.statusText);
|
||||
setText(primary, state.primaryLabel);
|
||||
setDisabled(primary, !state.canSave);
|
||||
if (state.canRetry !== retry.isConnected) {
|
||||
if (state.canRetry) actions.prepend(retry); // D116
|
||||
else {
|
||||
if (document.activeElement === retry) primary.focus();
|
||||
retry.remove();
|
||||
}
|
||||
}
|
||||
if (state.phase === 'saved') complete(state);
|
||||
}
|
||||
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
// Retry after a failed Generate takes Generate's own path, so an invalid field reports its validity (M4).
|
||||
retry.addEventListener('click', () => (controller.state.phase === 'generate-error' ? requestGenerate() : controller.retry()));
|
||||
primary.addEventListener('click', () => {
|
||||
if (disabled(primary) || !controller.state.canSave) return;
|
||||
controller.save();
|
||||
});
|
||||
// Leaving the page closes the dialog, so a page restored from the back/forward cache shows no dead dialog (I4).
|
||||
const pagehide = () => { if (open) closeWorkspaceDialog(); };
|
||||
window.addEventListener('pagehide', pagehide);
|
||||
|
||||
function release() {
|
||||
if (!open) return;
|
||||
open = false;
|
||||
window.removeEventListener('pagehide', pagehide);
|
||||
document.getElementById('modal')?.classList.remove('tagging-modal');
|
||||
local.dispose();
|
||||
controller.dispose();
|
||||
}
|
||||
openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release});
|
||||
document.getElementById('modal')?.classList.add('tagging-modal');
|
||||
local.cleanup(controller.subscribe(render));
|
||||
render(controller.state);
|
||||
controller.open();
|
||||
return {
|
||||
close() { if (open) closeWorkspaceDialog(); },
|
||||
get controller() { return controller; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* The media card's star (#249 D7 = A: the concept's `actions.favorite` against the favorites store), for the Images
|
||||
* page and every Library-embedded gallery (PR E). Its toasts are the concept's without the demo's "in this tab"; the
|
||||
* concept has no failure copy, so a failure follows the workspace "could not be …" pattern.
|
||||
*
|
||||
* One favorite request per file at a time (TESTING.md): the requests outlive the page that sent them, so the keys in
|
||||
* flight and the pages that receive each answer live here. Extracted from images.mjs unchanged.
|
||||
*/
|
||||
import { imageKey } from './images-state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
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.'});
|
||||
|
||||
const favoritePending = new Set();
|
||||
let pageTarget = null;
|
||||
// Library-embedded galleries showing media cards; keyed like the Images page, so each repaints its own copy.
|
||||
const embeddedTargets = new Set();
|
||||
let visitTarget = () => {};
|
||||
// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51).
|
||||
const favoriteListeners = new Set();
|
||||
|
||||
/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */
|
||||
export function onFavoriteChange(listener) {
|
||||
favoriteListeners.add(listener);
|
||||
return () => favoriteListeners.delete(listener);
|
||||
}
|
||||
/** Whether the favorite request of the file `key` is still in flight. */
|
||||
export function favoriteBusy(key) {
|
||||
return favoritePending.has(key);
|
||||
}
|
||||
/** `target(key, favorite)` patches the saved Images visit while no Images page is mounted. */
|
||||
export function setFavoriteVisitTarget(target) {
|
||||
visitTarget = target;
|
||||
}
|
||||
/** Receive favorite states with `setFavorite(key, favorite)` until `scope` ends: the Images page or an embedded gallery. */
|
||||
export function receiveFavorites({scope, embedded, setFavorite}) {
|
||||
if (!embedded) {
|
||||
pageTarget = setFavorite;
|
||||
scope.cleanup(() => { if (pageTarget === setFavorite) pageTarget = null; });
|
||||
} else {
|
||||
embeddedTargets.add(setFavorite);
|
||||
scope.cleanup(() => embeddedTargets.delete(setFavorite));
|
||||
}
|
||||
}
|
||||
/** Give a favorite state to the mounted Images page, else the saved visit, and to every embedded gallery. */
|
||||
function deliver(key, favorite) {
|
||||
if (pageTarget) pageTarget(key, favorite);
|
||||
else visitTarget(key, favorite);
|
||||
for (const target of embeddedTargets) target(key, favorite);
|
||||
}
|
||||
|
||||
/** A star whose request is still in flight is marked busy: pressing it again does nothing until the answer. */
|
||||
export 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');
|
||||
}
|
||||
|
||||
/** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
|
||||
export async function toggleFavorite(api, image) {
|
||||
const key = imageKey(image);
|
||||
if (favoritePending.has(key)) return;
|
||||
const favorite = !image.favorite;
|
||||
favoritePending.add(key);
|
||||
deliver(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);
|
||||
deliver(key, saved ? favorite : !favorite);
|
||||
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
|
||||
// On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51).
|
||||
if (saved) for (const listener of favoriteListeners) listener();
|
||||
}
|
||||
@@ -25,6 +25,7 @@ function el(tag, text, className) {
|
||||
* - `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.
|
||||
* - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7).
|
||||
*/
|
||||
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
|
||||
let checked = initial;
|
||||
@@ -54,6 +55,13 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
|
||||
for (const box of grid.querySelectorAll(CHECKBOX)) box.checked = false;
|
||||
render();
|
||||
}
|
||||
/** Keep only the checked files `kept(key)` still lists (a refreshed result), so the count is what the actions apply to. */
|
||||
function retain(kept) {
|
||||
const next = [...checked].filter(kept);
|
||||
if (next.length === checked.size) return;
|
||||
checked = Object.freeze(new Set(next));
|
||||
render();
|
||||
}
|
||||
function checkedImages() {
|
||||
return getItems().filter(image => checked.has(imageKey(image)));
|
||||
}
|
||||
@@ -142,6 +150,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
|
||||
isChecked: key => checked.has(key),
|
||||
checked: () => checked,
|
||||
clear,
|
||||
retain,
|
||||
/** Whether a Set category write is still in flight. */
|
||||
writing: () => categorizing,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ export function imageKey(image) {
|
||||
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']);
|
||||
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
// The largest page a refresh asks for (the listing clamps at 500).
|
||||
const REFRESH_PAGE_LIMIT = 200;
|
||||
|
||||
function queryFor(filters, batchSize) {
|
||||
const query = new URLSearchParams({ mode: 'workspace', limit: String(batchSize) });
|
||||
@@ -110,8 +112,13 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
onChange(state);
|
||||
}
|
||||
|
||||
// A refresh in flight (its abort controller): Load more waits for it, a reset or dispose cancels it. A refresh
|
||||
// asked while a page loads is pending and runs once that page settles (I6).
|
||||
let refreshing = null;
|
||||
let refreshPending = false;
|
||||
|
||||
async function loadMore() {
|
||||
if (disposed || state.loading || !state.hasMore) return 0;
|
||||
if (disposed || state.loading || !state.hasMore || refreshing) return 0;
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
const cursor = state.cursor;
|
||||
@@ -145,14 +152,73 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
publish({ loading: false, error });
|
||||
}
|
||||
return 0;
|
||||
} finally {
|
||||
runPendingRefresh(request);
|
||||
}
|
||||
}
|
||||
function runPendingRefresh(request) {
|
||||
// Only the page this load owned: a reset or dispose already dropped the pending refresh.
|
||||
if (disposed || !refreshPending || state.loading || request.signal.aborted) return;
|
||||
refreshPending = false;
|
||||
// A failure is the page's to report on the refresh it asked for; here it has no caller left.
|
||||
refresh().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the loaded range of the same query (a write may have changed which files match) and publish the new
|
||||
* collection once, with the hint `{replaced: true}`; the current items stay shown until then. Pages are as large
|
||||
* as the rest of the range (at most REFRESH_PAGE_LIMIT, at least one batch), read one after another (each page's
|
||||
* cursor comes from the one before). A read failure rejects and leaves the collection as it was; a reset or a
|
||||
* dispose abandons it. Resolves to the number of items now loaded (0 when abandoned, or deferred behind a page
|
||||
* load in flight, which runs it once settled).
|
||||
*/
|
||||
async function refresh() {
|
||||
if (disposed) return 0;
|
||||
if (state.loading) { refreshPending = true; return 0; }
|
||||
refreshing?.abort();
|
||||
const request = refreshing = new AbortController();
|
||||
const target = Math.max(state.loaded, 1);
|
||||
const items = [], keys = new Set();
|
||||
let cursor = null, total = 0, hasMore = true, positiveCount = null;
|
||||
try {
|
||||
while (items.length < target && hasMore) {
|
||||
const parameters = new URLSearchParams(query);
|
||||
parameters.set('limit', String(Math.min(REFRESH_PAGE_LIMIT, Math.max(target - items.length, batchSize))));
|
||||
if (cursor !== null) parameters.set('cursor', cursor);
|
||||
const page = await readPage({parameters, signal:request.signal});
|
||||
if (disposed || request !== refreshing) return 0;
|
||||
validatePage(page, cursor);
|
||||
for (const item of page.images) {
|
||||
const key = imageKey(item);
|
||||
if (keys.has(key)) continue;
|
||||
keys.add(key);
|
||||
items.push(Object.freeze({ ...item }));
|
||||
}
|
||||
if (cursor === null) positiveCount = isCount(page.positive_count) ? page.positive_count : null;
|
||||
({total, next_cursor: cursor, has_more: hasMore} = page);
|
||||
}
|
||||
} catch (error) {
|
||||
// A newer refresh, a reset or a dispose replaced this one (and aborted it): nothing to report.
|
||||
if (request !== refreshing) return 0;
|
||||
refreshing = null;
|
||||
throw error;
|
||||
}
|
||||
refreshing = null;
|
||||
seen = keys;
|
||||
state = Object.freeze({ ...state, items: Object.freeze(items), total, cursor, hasMore, loading: false, error: null,
|
||||
loaded: items.length, positiveCount });
|
||||
onChange(state, {replaced: true});
|
||||
return items.length;
|
||||
}
|
||||
|
||||
async function reset(filters = {}, {load = true} = {}) {
|
||||
if (disposed) return 0;
|
||||
const nextQuery = queryFor(filters, batchSize);
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
refreshing?.abort();
|
||||
refreshing = null;
|
||||
refreshPending = false;
|
||||
query = nextQuery;
|
||||
seen = new Set();
|
||||
publish({ items: Object.freeze([]), total: 0, cursor: null, hasMore: true,
|
||||
@@ -164,6 +230,9 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
get state() { return state; },
|
||||
reset,
|
||||
loadMore,
|
||||
refresh,
|
||||
/** Whether a refresh is re-reading the loaded range (Load more waits for it). */
|
||||
get refreshing() { return refreshing !== null; },
|
||||
/** 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);
|
||||
@@ -176,8 +245,11 @@ export function createImageStream({ api, onChange = () => {}, batchSize = 40, re
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
refreshPending = false;
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
refreshing?.abort();
|
||||
refreshing = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The Images page's side of image tagging (#249 PR C): a card's Tag opens the Tag image dialog, and every confirmed
|
||||
* tag save re-reads what it may have changed on a mounted page.
|
||||
*
|
||||
* Under a Tags filter or a search the saved file may start or stop matching, so the loaded range is re-read in place
|
||||
* (`createImageStream().refresh()`), keeping the scroll and the selection while it still matches; a failed re-read
|
||||
* toasts and leaves the cards as they were. The selected file's inspection is then re-read quietly (its envelope
|
||||
* carries the image tags), without a loading flash.
|
||||
*/
|
||||
import { isTagButton, onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
export const REFRESH_FAILED = 'Images could not be refreshed after the tag save. Refresh image collection to retry.';
|
||||
|
||||
/** Open Tag image for `image` when `action` is its card's Tag button (without selecting the card, as the concept's
|
||||
* `image-tag` does); false for any other control. */
|
||||
export function openCardTagging({api, scope, image, action, embedded}) {
|
||||
if (!isTagButton(action)) return false;
|
||||
openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow confirmed tag saves until `scope` ends. `filtered()` tells whether a Tags filter or a search is active,
|
||||
* `selection()` is the selected image key, and `onRefreshing(busy)` marks Load more busy while the re-read runs.
|
||||
*/
|
||||
export function followImageTagSaves({scope, stream, inspection, filtered, selection, onRefreshing}) {
|
||||
scope.cleanup(onImageTagsSaved(async ({target}) => {
|
||||
const key = `${target.root_index}:${target.path}`;
|
||||
if (filtered()) {
|
||||
onRefreshing(true);
|
||||
try {
|
||||
await stream.refresh();
|
||||
} catch {
|
||||
if (!scope.signal.aborted) showWorkspaceToast(REFRESH_FAILED);
|
||||
} finally {
|
||||
if (!scope.signal.aborted) onRefreshing(false);
|
||||
}
|
||||
}
|
||||
if (!scope.signal.aborted && selection() === key) inspection.refresh();
|
||||
}));
|
||||
}
|
||||
+52
-82
@@ -1,6 +1,6 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { mountSelectionBar } from './images-selection.mjs';
|
||||
@@ -15,6 +15,10 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { createTagButton, onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
|
||||
import { favoriteBusy, paintStar, receiveFavorites, setFavoriteVisitTarget, toggleFavorite } from './images-favorites.mjs';
|
||||
export { onFavoriteChange } from './images-favorites.mjs';
|
||||
|
||||
/** The concept's panel without an image (app.js updateImages a456–458). */
|
||||
function noSelection() {
|
||||
@@ -73,36 +77,21 @@ function rescanFinished(result) {
|
||||
if (![total, roots].every(value => Number.isSafeInteger(value) && value >= 0)) return 'Rescan finished.';
|
||||
return `Rescan finished. ${total.toLocaleString('en-US')} ${noun(total, 'file')} in ${roots.toLocaleString('en-US')} output ${noun(roots, 'folder')}.`;
|
||||
}
|
||||
// D7 = A: the concept's star (app.js actions.favorite) against the favorites store; its toasts without the demo's
|
||||
// "in this tab". The concept has no failure copy, so a failure follows the workspace "could not be …" pattern.
|
||||
const FAVORITE_URL = '/prompt_manager/images/favorite';
|
||||
const FAVORITE_DONE = Object.freeze({true:'Added to favorites.', false:'Removed from favorites.'});
|
||||
const FAVORITE_FAILED = Object.freeze({true:'Favorite could not be added. Try again.', false:'Favorite could not be removed. Try again.'});
|
||||
// The concept card's first `.media-info` reservation and list row (image-layout.mjs); real rows are measured.
|
||||
const INITIAL_METRICS = Object.freeze({columns:1, gap:13, footerHeight:54, listHeight:97});
|
||||
|
||||
let lastVisit = null;
|
||||
// One favorite request per file at a time (TESTING.md): the requests outlive the Images page that sent them, so the keys
|
||||
// in flight and the page that receives each answer live here, beside the saved visit.
|
||||
const favoritePending = new Set();
|
||||
let favoriteTarget = null;
|
||||
// Library-embedded galleries showing media cards (#249 PR E); keyed like the Images page, so each repaints its own copy.
|
||||
const embeddedFavoriteTargets = new Set();
|
||||
/**
|
||||
* Give a favorite state to the mounted Images page, or to the saved visit while none is mounted, and to every mounted
|
||||
* Library-embedded gallery.
|
||||
*/
|
||||
function deliverFavorite(key, favorite) {
|
||||
if (favoriteTarget) favoriteTarget(key, favorite);
|
||||
else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
for (const target of embeddedFavoriteTargets) target(key, favorite);
|
||||
}
|
||||
// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51).
|
||||
const favoriteListeners = new Set();
|
||||
/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */
|
||||
export function onFavoriteChange(listener) {
|
||||
favoriteListeners.add(listener);
|
||||
return () => favoriteListeners.delete(listener);
|
||||
// The star's answers patch the saved visit while no Images page is mounted (D7).
|
||||
setFavoriteVisitTarget((key, favorite) => {
|
||||
if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})};
|
||||
});
|
||||
|
||||
// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
|
||||
onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
|
||||
/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
|
||||
export function selectedImage() {
|
||||
const key = lastVisit?.selection;
|
||||
return key ? lastVisit.state?.items.find(item => imageKey(item) === key) ?? null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,10 +116,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const visit = embedded || saved || favorites ? null : lastVisit;
|
||||
let density = visit?.density || 'comfortable';
|
||||
const prior = embedded || saved || favorites ? null : lastVisit;
|
||||
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
|
||||
const visit = prior?.stale ? null : prior;
|
||||
let density = prior?.density || 'comfortable';
|
||||
let metrics = {...INITIAL_METRICS};
|
||||
let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
let layout = prior?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer;
|
||||
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 = [];
|
||||
@@ -138,7 +129,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
let refine = null;
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved,
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
|
||||
favorites: favorites || saved?.favorites === true };
|
||||
filters.tags = [...filters.tags];
|
||||
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
|
||||
@@ -332,7 +323,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (typeof image.favorite === 'boolean') {
|
||||
const star = createButton('', 'star-button');
|
||||
star.setAttribute('aria-label', `Favorite ${image.filename}`);
|
||||
paintStar(star, image.favorite, favoritePending.has(key));
|
||||
paintStar(star, image.favorite, favoriteBusy(key));
|
||||
overlays.append(star);
|
||||
}
|
||||
art.append(open, overlays);
|
||||
@@ -341,65 +332,32 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
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);
|
||||
actions.append(inspectButton, createTagButton(image));
|
||||
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));
|
||||
if (button) paintStar(button, favorite, favoriteBusy(key));
|
||||
}
|
||||
// The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E).
|
||||
if (!embedded) {
|
||||
favoriteTarget = setFavorite;
|
||||
scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; });
|
||||
} else {
|
||||
embeddedFavoriteTargets.add(setFavorite);
|
||||
scope.cleanup(() => embeddedFavoriteTargets.delete(setFavorite));
|
||||
}
|
||||
/** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */
|
||||
async function toggleFavorite(image) {
|
||||
const key = imageKey(image);
|
||||
if (favoritePending.has(key)) return;
|
||||
const favorite = !image.favorite;
|
||||
favoritePending.add(key);
|
||||
deliverFavorite(key, favorite);
|
||||
const body = {root_index: image.root_index, path: image.path};
|
||||
let saved = false;
|
||||
try {
|
||||
await (favorite ? api.put(FAVORITE_URL, body) : api.del(FAVORITE_URL, {body}));
|
||||
saved = true;
|
||||
} catch {
|
||||
// Announced below; the star returns to its previous state.
|
||||
}
|
||||
favoritePending.delete(key);
|
||||
deliverFavorite(key, saved ? favorite : !favorite);
|
||||
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
|
||||
// On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51).
|
||||
if (saved) for (const listener of favoriteListeners) listener();
|
||||
}
|
||||
receiveFavorites({scope, embedded, setFavorite});
|
||||
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 (!image) return;
|
||||
if (event.target.closest('.star-button')) toggleFavorite(image);
|
||||
const action = event.target.closest('.image-card-actions button');
|
||||
if (event.target.closest('.star-button')) toggleFavorite(api, image);
|
||||
else if (event.target.closest('.image-open')) previewImage(image);
|
||||
else if (event.target.closest('.image-card-actions button')) inspect(image);
|
||||
else if (action && !openCardTagging({api, scope, image, action, embedded})) inspect(image);
|
||||
});
|
||||
|
||||
function renderVisible() {
|
||||
@@ -487,7 +445,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
function updateGeometry() {
|
||||
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.classList.toggle('narrow-cards', stacksCardInfo(galleryWidth, layout));
|
||||
grid.style.height = `${geometry.height}px`;
|
||||
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
|
||||
}
|
||||
@@ -535,14 +493,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// 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} = {}) {
|
||||
function renderState(next, {patch, replaced = false} = {}) {
|
||||
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);
|
||||
// A refreshed collection (`replaced`) is indexed again from its start; a loaded page only appends.
|
||||
if (!next.items.length || replaced) itemIndexes.clear();
|
||||
for (let index = replaced ? 0 : state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index);
|
||||
}
|
||||
// The selected file left the refreshed result: the panel follows the collection, as after a reload; the
|
||||
// selection bar keeps only the checked files still listed (I7).
|
||||
if (replaced && selection !== null && !itemIndexes.has(selection)) dropSelection();
|
||||
if (replaced) bar?.retain(key => itemIndexes.has(key));
|
||||
state = next;
|
||||
if (!embedded) {
|
||||
setText(count, countLine(next));
|
||||
@@ -551,7 +514,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
}
|
||||
if (changed) updateLayout();
|
||||
// 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) {
|
||||
if (!embedded && changed && !replaced && 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')}.`);
|
||||
}
|
||||
@@ -566,12 +529,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scheduleLoad();
|
||||
}
|
||||
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
|
||||
followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
|
||||
onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))});
|
||||
scope.cleanup(() => {
|
||||
stream.dispose();
|
||||
for (const video of body.querySelectorAll('video')) releaseVideo(video);
|
||||
});
|
||||
function pressLoad() {
|
||||
if (recovering || state?.loading) return;
|
||||
// A refresh re-reads the loaded range: Load more waits for it (and focus stays where it is, M3).
|
||||
if (recovering || state?.loading || stream.refreshing) return;
|
||||
focusAfterLoad = state?.loaded ?? 0;
|
||||
stream.loadMore();
|
||||
}
|
||||
@@ -611,19 +577,23 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scopeText.textContent = ` ${scopeLabel(filters, roots)}`;
|
||||
toolbar?.setActiveFilters(activeRefinements(filters));
|
||||
}
|
||||
function reset() {
|
||||
mediaViewer.stopSlideshow();
|
||||
main.querySelector('#viewer-dialog[open]')?.close();
|
||||
if (!recovering) {
|
||||
/** No selected file; `keepPanel` (recovering sources) leaves the panel's details shown. */
|
||||
function dropSelection({keepPanel = false} = {}) {
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
if (!keepPanel) {
|
||||
inspection.clear();
|
||||
detailInspector?.dispose(); detailInspector = null;
|
||||
details.replaceChildren(noSelection());
|
||||
}
|
||||
selection = null;
|
||||
}
|
||||
function reset() {
|
||||
mediaViewer.stopSlideshow();
|
||||
main.querySelector('#viewer-dialog[open]')?.close();
|
||||
dropSelection({keepPanel:recovering});
|
||||
focusAfterLoad = null;
|
||||
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
|
||||
bar?.clear();
|
||||
for (const video of details.querySelectorAll('video')) releaseVideo(video);
|
||||
// A toolbar change while discovery is still pending keeps the saved Source and Folder; the settled discovery
|
||||
// then lists the view with every current value (#249 M3).
|
||||
if (savedScopePending && !discoverySettled) { updateScope(); return; }
|
||||
|
||||
@@ -167,6 +167,20 @@ function libraryFor(value) {
|
||||
return library;
|
||||
}
|
||||
|
||||
// #249 PR C (D88): the file's own current image tags (S1). `none` and `unavailable` carry no names; a name is a
|
||||
// vocabulary tag name (1–50 characters, trimmed, no comma or control character), at most 100 of them.
|
||||
const IMAGE_TAG_STATES = ['current', 'none', 'unavailable'], MAX_IMAGE_TAGS = 100, IMAGE_TAG_LENGTH = 50;
|
||||
const imageTagName = name => typeof name === 'string' && name !== '' && name === name.trim() && [...name].length <= IMAGE_TAG_LENGTH &&
|
||||
!/[,\x00-\x1f\x7f]/.test(name);
|
||||
|
||||
function imageTagsFor(value) {
|
||||
if (value === undefined) return null;
|
||||
if (!object(value) || Object.keys(value).sort().join() !== 'state,tags' || !IMAGE_TAG_STATES.includes(value.state) ||
|
||||
!Array.isArray(value.tags) || value.tags.length > MAX_IMAGE_TAGS || !value.tags.every(imageTagName) ||
|
||||
(value.state !== 'current' && value.tags.length)) throw new TypeError('Invalid inspection image tags');
|
||||
return { state: value.state, tags: [...value.tags] };
|
||||
}
|
||||
|
||||
// The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and
|
||||
// admin pages; it fills any prompt or setting the server could not decide, so this page
|
||||
// never shows less than they do. A definite server answer is never replaced.
|
||||
@@ -279,9 +293,10 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
|
||||
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false, parser = comfyMetadata()) {
|
||||
const record = {
|
||||
identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
|
||||
generation: unavailableFacts(), historyTarget: null, download,
|
||||
generation: unavailableFacts(), historyTarget: null, download, imageTags: null,
|
||||
};
|
||||
if (legacy) return record;
|
||||
record.imageTags = imageTagsFor(result.image_tags);
|
||||
const resolution = resolutionFor(result.resolution);
|
||||
record.file = fileFor(result.file, identity);
|
||||
record.metadata = metadataFor(result.metadata);
|
||||
@@ -302,11 +317,12 @@ export const rootedFileAdapter = Object.freeze({
|
||||
export function createInspectionLoader(api, onChange, { extractFacts, adapter = rootedFileAdapter } = {}) {
|
||||
let request = null, disposed = false;
|
||||
return {
|
||||
async load(image) {
|
||||
/** `quiet`: publish only the answer, so the shown record stays until it lands (a re-read after a write). */
|
||||
async load(image, { quiet = false } = {}) {
|
||||
if (disposed) return;
|
||||
request?.abort();
|
||||
const current = request = new AbortController();
|
||||
onChange({ loading: true });
|
||||
if (!quiet) onChange({ loading: true });
|
||||
try {
|
||||
// An adapter that reads by itself (a file chosen in this browser) never reaches the API.
|
||||
const result = adapter.read ? await adapter.read(image, { signal: current.signal }) :
|
||||
@@ -341,8 +357,8 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
|
||||
}
|
||||
const makeLoader = () => createInspectionLoader(api, publish, { extractFacts, adapter });
|
||||
let loader = makeLoader();
|
||||
function read(image) {
|
||||
pending = loader.load(image);
|
||||
function read(image, options) {
|
||||
pending = loader.load(image, options);
|
||||
return pending;
|
||||
}
|
||||
return {
|
||||
@@ -366,6 +382,11 @@ export function createInspectionController({ api, extractFacts, adapter = rooted
|
||||
if (disposed || !hasAttempt || state.refreshRequired) return;
|
||||
return read(attempted);
|
||||
},
|
||||
/** Re-read the selected file keeping the shown record until the answer (a retry when none is shown). */
|
||||
refresh() {
|
||||
if (disposed || !hasAttempt || state.refreshRequired) return;
|
||||
return read(attempted, { quiet: Boolean(state.record) && !state.loading && !state.error });
|
||||
},
|
||||
clear() {
|
||||
if (disposed) return;
|
||||
loader.dispose(); loader = makeLoader(); current = null; pending = null; attempted = undefined; hasAttempt = false;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Native Tags & categories screen (concept renderTags, app.js:719-729).
|
||||
*
|
||||
* Composition, labels and order follow the approved concept; the recorded
|
||||
* deviations are Auto-tag images omitted (no image-tag owner yet), Images cells
|
||||
* "—" / "Not available yet", and bounded lists with Load more. Rows are real
|
||||
* vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint
|
||||
* to the existing Library filters.
|
||||
* Composition, labels and order follow the approved concept: Auto-tag images
|
||||
* opens the Tag image dialog for the image selected in Images (aria-disabled
|
||||
* without one, D115) and the Images cells count the files carrying each tag
|
||||
* (#249 C2). The recorded deviation left is bounded lists with Load more. Rows
|
||||
* are real vocabulary pages from vocabulary-state.mjs; Browse hands the exact
|
||||
* constraint to the existing Library filters.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
|
||||
@@ -13,6 +14,11 @@ import { countOf } from './plural.mjs';
|
||||
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
|
||||
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { selectedImage } from './images.mjs';
|
||||
import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
const NO_SELECTION = 'Select an image in Images first.';
|
||||
|
||||
const COPY = {
|
||||
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
|
||||
@@ -42,10 +48,8 @@ function operation(title, description, label) {
|
||||
|
||||
function tagRow(tag) {
|
||||
const row = el('tr');
|
||||
const images = el('td');
|
||||
const dash = el('span', '—');
|
||||
dash.setAttribute('aria-hidden', 'true');
|
||||
images.append(dash, el('span', 'Not available yet', 'ca-visually-hidden'));
|
||||
// The files carrying the tag (absent from an older server, which counts none).
|
||||
const images = el('td', String(tag.image_count ?? 0));
|
||||
const cell = el('td');
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const browse = createButton('Browse');
|
||||
@@ -159,7 +163,15 @@ export function mountVocabulary(main, api, scope) {
|
||||
const { description, actions } = pageHeading(main);
|
||||
description.textContent = 'Organize once. Reuse everywhere.';
|
||||
const create = createButton('+ Create tag');
|
||||
actions.append(create);
|
||||
const autoTag = createButton('Auto-tag images', 'ca-primary');
|
||||
actions.append(create, autoTag);
|
||||
// The concept's `tag-review` for `state.selectedImage` (app.js a1511); D115: none selected, so it only explains.
|
||||
const selected = selectedImage();
|
||||
if (!selected) autoTag.setAttribute('aria-disabled', 'true');
|
||||
scope.listen(autoTag, 'click', () => {
|
||||
if (!selected) { showWorkspaceToast(NO_SELECTION); return; }
|
||||
openImageTagging({api, scope, image: selected, source: 'tags', trigger: autoTag});
|
||||
});
|
||||
|
||||
const grid = el('div', undefined, 'two-col');
|
||||
const tagSurface = el('section', undefined, 'surface');
|
||||
@@ -232,6 +244,8 @@ export function mountVocabulary(main, api, scope) {
|
||||
const tag = button && tags.itemOf(button.closest('tr'));
|
||||
if (tag) dialog.renameTag(tag, button);
|
||||
});
|
||||
// A saved image tag set changes the Images counts.
|
||||
scope.cleanup(onImageTagsSaved(() => { if (!scope.signal.aborted) reload(); }));
|
||||
reload();
|
||||
return {create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
|
||||
return {autoTag, create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
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%; }
|
||||
/* #249 D31: Info and Tag (#249 C2) wrap onto two lines when even the stacked row cannot hold them side by side. */
|
||||
.narrow-cards .media-info > .image-card-actions { flex: 0 1 auto; min-width: 0; flex-wrap: wrap; /* D31 */ }
|
||||
/* 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; }
|
||||
@@ -362,3 +364,15 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
/* Tag image dialog (#249 C2): the concept's `tagging-modal` (s1598–1694) as it stands, but D83 (ruling T4): the
|
||||
suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would
|
||||
give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`,
|
||||
s1655): a name, the confidence and a × button. */
|
||||
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
|
||||
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
|
||||
@media (pointer: coarse) {
|
||||
/* D83: the chip's × keeps the concept's 40px coarse target. */
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user