diff --git a/TESTING.md b/TESTING.md index b7f56fa..e77e01e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 " · <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 @@ -1747,8 +1938,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 @@ -1912,7 +2103,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 @@ -1953,8 +2144,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 diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 0bc9dfb..862ad30 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -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); } diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index a22fb3a..f1268b1 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -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(); }); diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index e7c829c..9e1ae70 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -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', ], }; diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 309badc..e7e219c 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -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); + } }); diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 951c833..1c8f77e 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -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. diff --git a/tests/browser/server.py b/tests/browser/server.py index ae67018..4ff976d 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -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}) diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index 8249311..a4076d7 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -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. */ diff --git a/tests/browser/tagging-canonical-comparison.mjs b/tests/browser/tagging-canonical-comparison.mjs new file mode 100644 index 0000000..d9ff0fd --- /dev/null +++ b/tests/browser/tagging-canonical-comparison.mjs @@ -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)}}; +} diff --git a/tests/browser/tagging-concept.spec.mjs b/tests/browser/tagging-concept.spec.mjs new file mode 100644 index 0000000..ac15236 --- /dev/null +++ b/tests/browser/tagging-concept.spec.mjs @@ -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); +}); diff --git a/tests/browser/vocabulary-canonical-comparison.mjs b/tests/browser/vocabulary-canonical-comparison.mjs index ac8d0a3..5e7d2cf 100644 --- a/tests/browser/vocabulary-canonical-comparison.mjs +++ b/tests/browser/vocabulary-canonical-comparison.mjs @@ -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')}})), diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index ac7c336..e08a1a9 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -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(); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 63d04bf..bd07349 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -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'); diff --git a/tests/browser/workspace-tagging-lifecycle.spec.mjs b/tests/browser/workspace-tagging-lifecycle.spec.mjs new file mode 100644 index 0000000..a255a9b --- /dev/null +++ b/tests/browser/workspace-tagging-lifecycle.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs new file mode 100644 index 0000000..06d24a6 --- /dev/null +++ b/tests/browser/workspace-tagging.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 8896505..4adf0f6 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -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); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 9cc0742..4424869 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -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'); diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index ea48be8..b22c362 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -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}"}) diff --git a/tests/browser/workspace_tagging_fixture.py b/tests/browser/workspace_tagging_fixture.py new file mode 100644 index 0000000..31cbd2f --- /dev/null +++ b/tests/browser/workspace_tagging_fixture.py @@ -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 + ) + ) diff --git a/tests/js/api-client.test.js b/tests/js/api-client.test.js index 5aad215..01b45b4 100644 --- a/tests/js/api-client.test.js +++ b/tests/js/api-client.test.js @@ -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); +}); diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 16d5a17..15c512f 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -86,3 +86,34 @@ test('Show in folder outcomes use the D38 toasts',async()=>{ for(const error of [{status:403,code:null},{status:409,code:'source-changed'},{status:415,code:'unsupported-media-type'},{status:429,code:null},{status:500},new TypeError('offline')]) assert.equal(revealOutcome(error),'The folder could not be opened. Try again.'); }); + +// #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}), []); + } +}); diff --git a/tests/js/workspace-image-layout.test.js b/tests/js/workspace-image-layout.test.js index 0b3d3dc..d152121 100644 --- a/tests/js/workspace-image-layout.test.js +++ b/tests/js/workspace-image-layout.test.js @@ -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); +}); diff --git a/tests/js/workspace-image-tagging-state.test.js b/tests/js/workspace-image-tagging-state.test.js new file mode 100644 index 0000000..604716f --- /dev/null +++ b/tests/js/workspace-image-tagging-state.test.js @@ -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'); +}); diff --git a/tests/js/workspace-image-tagging.test.js b/tests/js/workspace-image-tagging.test.js new file mode 100644 index 0000000..bce3e5d --- /dev/null +++ b/tests/js/workspace-image-tagging.test.js @@ -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); +}); diff --git a/tests/js/workspace-images-lifecycle.test.js b/tests/js/workspace-images-lifecycle.test.js index a7511e4..bbca2e4 100644 --- a/tests/js/workspace-images-lifecycle.test.js +++ b/tests/js/workspace-images-lifecycle.test.js @@ -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); +}); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index f9f33c0..b17aa9b 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -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'); +}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 6fca178..ac33dae 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -702,3 +702,57 @@ test('a workflow the legacy parser throws on keeps the server result instead of assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' }); assert.equal(record.prompts.negative.status, 'ambiguous'); }); + +// #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); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index df2ed5d..d80511f 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -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, @@ -259,7 +283,7 @@ }, "workspace/inspection-state.mjs": { "lines": 98, - "branches": 93, + "branches": 94, "functions": 98, "statements": 98 }, diff --git a/web/js/api-client.js b/web/js/api-client.js index 4fe4039..df1f7a0 100644 --- a/web/js/api-client.js +++ b/web/js/api-client.js @@ -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 }), }; } diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index b7b592d..13d21b5 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -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); } diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs index a17bf1f..ab10ac3 100644 --- a/web/js/workspace/image-layout.mjs +++ b/web/js/workspace/image-layout.mjs @@ -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) { diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs new file mode 100644 index 0000000..e37b6b6 --- /dev/null +++ b/web/js/workspace/image-tagging-state.mjs @@ -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, + }; +} diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs new file mode 100644 index 0000000..8993c27 --- /dev/null +++ b/web/js/workspace/image-tagging.mjs @@ -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; }, + }; +} diff --git a/web/js/workspace/images-favorites.mjs b/web/js/workspace/images-favorites.mjs new file mode 100644 index 0000000..7767278 --- /dev/null +++ b/web/js/workspace/images-favorites.mjs @@ -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(); +} diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index ec985c6..6938e52 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -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, }; diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 4c5b84b..d133eba 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -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; }, }; } diff --git a/web/js/workspace/images-tagging.mjs b/web/js/workspace/images-tagging.mjs new file mode 100644 index 0000000..babfd78 --- /dev/null +++ b/web/js/workspace/images-tagging.mjs @@ -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(); + })); +} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 214358f..19045c0 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -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; } diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index bfb61ed..9b27a42 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -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 { const result = await api.get(adapter.inspectionPath(image), { signal: current.signal }); if (disposed || request !== current) return; @@ -339,8 +355,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 { @@ -364,6 +380,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; diff --git a/web/js/workspace/vocabulary.mjs b/web/js/workspace/vocabulary.mjs index 6fb98ea..1f6f7fd 100644 --- a/web/js/workspace/vocabulary.mjs +++ b/web/js/workspace/vocabulary.mjs @@ -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}; } diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 63c2425..7394f31 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -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; } +}