diff --git a/TESTING.md b/TESTING.md index c1f60a29..5f486c1c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -564,7 +564,7 @@ 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 file's own image +chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image tags first, D88, then the linked tags; image tags and the linked positive's own tags 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 @@ -1256,22 +1256,17 @@ Recorded deviations on this page (spec §8): inspector's own "Download metadata JSON" envelopes in listing order); the button is `aria-disabled` and ignores a second press while it runs, the selection is kept, and the toast names the unread images (none read: no download); leaving Images aborts the - reads with no download and no toast. Set category opens the - concept's `simpleInput` dialog ("Categorize selected images", `input#action-input` - labelled Category, Cancel / Apply) and posts the selection's distinct linked positive - ids to `/prompt_manager/bulk/category` (images have no category; the filter matches - the linked positive's). With no linked image checked it posts nothing and opens no - dialog; an empty value keeps the dialog open; the field stops at the server's 100 - characters; success reloads the categories and the listing together (clearing the - selection, focus to the heading, and, as any replaced collection does, the inspector - moves to the first listed image); failure keeps the selection. The write carries no - page signal: leaving Images while it runs lets it finish without a toast, and the return - loads the collection afresh instead of the saved visit. Three deviations from the - concept's `simpleInput`: the field starts empty (the concept pre-fills its demo - "Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the - nothing-linked check runs on the Set category press, so no dialog opens. The route - rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range - (deduplicated) and a category that fails `validate_category`. `images selection bar matches + reads with no download and no toast. Set category (amended by D140, #332) sets the + checked images' own categories: it opens the concept's `simpleInput` dialog + ("Categorize selected images", `input#action-input` labelled Category) and writes + `PUT`/`DELETE /prompt_manager/images/category`, never a prompt (see D140). Prompt + categories are changed in the Prompt library editor and on the legacy admin page's + "Set Category"; `POST /prompt_manager/bulk/category` is unchanged and serves that + legacy page only. The write carries no page signal: leaving Images while it runs lets + it finish without a toast, and the return loads the collection afresh instead of the + saved visit. Its success reloads the categories and the listing together (clearing + the selection, focus to the heading, and, as any replaced collection does, the + inspector moves to the first listed image); failure keeps the selection. `images selection bar matches the concept` compares the bar and the checked card with one card checked; the omitted actions flow again from the concept's own boxes, checked against the concept page with them removed at runtime. @@ -1338,6 +1333,9 @@ Recorded deviations on this page (spec §8): filters, while recipe, recorded-run and embedded tags stay plain `span.chip`. Amended by D88 (#249 PR C): the file's own current image tags lead the same row and are filters too; a linked tag already shown (case-insensitive) is left out. + Amended by D146 (#332): the category chip also names its origin in visible text + after the category (" · this image", " · prompt", " · recipe"), and an image's own + category shows for an unlinked or ambiguous file too. - **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. @@ -1400,8 +1398,9 @@ Recorded deviations on this page (spec §8): The category filter matches the same inherited category as the panel (D142, #332), and an image's own category overrides both (D141, D146). The inspection response names each field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or - null); the category chip's visually hidden prefix (D21) says "Recipe category: " or - "Positive prompt category: " from it, and tag chips are Tags filter buttons only when + null, or `image` for an own category, #332); the category chip's visually hidden + prefix (D21) says "Recipe category: ", "Positive prompt category: " or "Image category: " + from it, and its visible origin (D146) " · recipe", " · prompt" or " · this image", and tag chips are Tags filter buttons only when they are the positive's (#260 D2). History's media inspector uses the same rule. The concept shows the prompt's category and tags for every image and the concept fixture's recipes have neither, so the inspector comparison needs no mapping for it. @@ -1419,7 +1418,11 @@ Recorded deviations on this page (spec §8): files whose own category is the value by path, so their rating and positive count. `test_filter_budget` seeds 2,000 own categories on its 50,000-file fixture. The option list adds the image categories of listed files - (`GET /prompt_manager/images/categories`, PR 3). + (`GET /prompt_manager/images/categories`, PR 3), merged with the Library's and sorted + (D36); if only that read fails, the prompt categories show and the categories alert is + raised. `images category options and Set category dialog match the concept` sets one + image's own category ("Portraits") through the real route and compares the toolbar + with it sorted into the concept's options; unmapped, only the options differ. - **D142** (#332 MC4, amends D39's filter note): the inherited category is one rule, `inherited_category` in `utils/inspection_response.py`: a linked recipe's own category when it is not blank, else the linked positive's. Before #332 the filter @@ -1435,7 +1438,53 @@ Recorded deviations on this page (spec §8): output sources, as Images keys it. A temp output or a file no source holds has none; a failed read hides the chip (DC5). The outputs list and media requests read nothing new. The chip's hidden prefix reads "Image category: " for an own category; the - visible origin text and its comparison are D146 (PR 3). + visible origin text and its comparison are D146 (PR 3). `history media inspector + category chip matches the concept` (history-concept.spec) sets an own category on the + Quality baseline's newest output, opens its Recorded run and "View current file", and + compares the viewer's chip with the concept viewer's category chip (app.js a402) laid + out at runtime with the D146 origin and that category; its place is not compared (what + precedes it differs), only its size, tokens, text, prefix and origin. Unmapped, the two + differ only in the text, the prefix, the width and `span.chip-source`. +- **D140** (#332, amends D8): Set category sets the checked images' own categories, so + every checked file is a target (no nothing-linked check). More than 500 checked (or + "all N matching" over 500) toasts "Select at most 500 images." and opens nothing. + The dialog is the concept's `simpleInput` with three recorded differences: the field + starts empty (the concept pre-fills its demo "Landscapes") and suggests the toolbar's + current categories (`datalist#workspace-input-suggestions`, `openInputDialog`'s + `suggestions`); "Use prompt category", a plain button between Cancel and Apply + (`secondary`), closes it and opens the "Use prompt category" confirmation ("Remove the + image category from the 1 selected image? / … the N selected images? Each one then + shows its prompt's category, or none if it has no linked prompt. This cannot be + undone.", Cancel and a danger Remove, built like saved views' delete confirmation); + and the action reads "Apply" (D8). Apply sends `PUT /prompt_manager/images/category` + `{items | filter, category}`, Remove `DELETE` `{items | filter}`; Cancel sends + nothing. An empty value keeps the dialog open and the field stops at 100 characters. + Toasts: "Category “X” set on 1 image." / "… on N images." (unchanged files count as + set) plus " N could not be found." for unlisted files; "Removed the image category + from N images." or "None of the selected images had its own category."; 404 "None of + the selected images could be found. Refresh the images and try again."; 409 "The + sources changed. Refresh the images and try again." (the listing reloads); anything + else "Category could not be set. Try again.". `images category options and Set + category dialog match the concept` (images-concept.spec) checks a card, opens the + dialog in both documents and compares it (title, body tree, field, input, suggestions, + actions and every button's box and tokens) with the concept's own dialog changed at + runtime as recorded: field emptied, the datalist added after the field, a clone of + its Cancel reading "Use prompt category" after Cancel, the primary renamed "Apply". + Unmapped, the dialog differs only in the input's value and list, the body's tree, the + suggestions and the actions. +- **D146** (#332, amends D21 and D39): the category chip names its origin in visible + text. It is `span.chip` > `span.ca-visually-hidden` (the D21 prefix) + the category + + `span.chip-source` (" · this image", " · prompt" or " · recipe"; muted + `--ca-muted`, `white-space: nowrap`, so at 390px the chip may wrap only before the + origin). It shows when the inspection names a non-empty category and either the + scope is `image` (any link status, so an unlinked or ambiguous file with an own + category shows it) or the file is linked. The concept's chip shows the bare name + (app.js a402). The inspector comparison (inspector-concept.spec) measures each chip's + `span.chip-source` (text, font, color, `white-space`, box) and lays the concept's own + chip out with the recorded rule and " · prompt" added at runtime (`withChipSource` in + `concept-inventory.mjs`), in the side panel and the viewer's details; unmapped they + differ only in the chip's text, width and `span.chip-source`. Its mutation check + removes the origin. History's chip (D145) is compared the same way. - **D42**: the concept never shows a toast and a page alert together. Production keeps the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below) and, when an Import → Rescan toast leaves the Images discovery alert @@ -1460,7 +1509,8 @@ applies D3 (the `.file-inspector` class of the production-only rules), D20 (the viewer's plain tag chips, laid out by the concept page itself with its chips made plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`, `max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope -prefix) and D22 (focusable prompt text). `inspector comparison detects mutations` +prefix), D22 (focusable prompt text) and D146 (the chip's visible " · prompt", laid out +by the concept page itself). `inspector comparison detects mutations` changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`. Manual check (quick), after a browser refresh on Images: hover an image (it zooms); @@ -1795,11 +1845,13 @@ ranges include cards that bounded rendering has unmounted. Over the limit, the client toasts "Narrow the view to at most 10,000 images." and the server answers 400 `too-many-images`. No match is 400 `no-images`. Auto-tag in this mode does not offer Review each image, because the review walks images in the - browser. Set category posts `{images_filter, category}` to `/prompt_manager/bulk/category`. - The server sets the category on the distinct linked positives and answers - `prompt_count` and `skipped`. A listing whose `positive_count` is 0 toasts the - nothing-linked sentence and opens no dialog, and over 10,000 matches it toasts the - limit sentence. Export metadata reads every image in + browser. Set category (D140, #332) sends `{filter, category}` to + `PUT /prompt_manager/images/category` (Use prompt category `{filter}` to `DELETE`), + which sets or removes each matching image's own category; over 500 matches the client + toasts "Select at most 500 images." and opens no dialog, and the server answers 400 + `too-many-images`. (`POST /prompt_manager/bulk/category` still accepts + `{images_filter, category}` and sets the linked positives' category, but no workspace + control sends it any more.) Export metadata reads every image in the browser, so it is `aria-disabled` in this mode, and its `title` says why. Tests: `tests/browser/workspace-images.spec.mjs` ("Shift+click selects a range across @@ -4565,6 +4617,21 @@ category; without one it shows the category its link supplies. appears at the same path shows it. The real-host check is the last test in `tests/comfyui/image-attribution.spec.mjs`: PUT, list, DELETE on a generated output. +- Interface (PR 3, a browser refresh; no restart beyond PR 1-2's): the Images + selection bar's Set category writes these routes (D140, which amends D8); the toolbar + category options add the listed images' own categories (D141); the inspector's + category chip names its origin, " · this image", " · prompt" or " · recipe" (D146), in + the side panel, the viewer and History's media inspector (D145); Tags & categories + counts images on each category row and lists image-only values (D143), and Manage + category previews and renames image rows too (D144). Prompt categories are changed in + the Prompt library editor and on the legacy admin page's "Set Category". Concept + comparisons, at 1600x1100 and 390x844 in both themes: `images-concept.spec.mjs` (the + Set category dialog and the options), `inspector-concept.spec.mjs` (the chip), + `history-concept.spec.mjs` (History's chip) and `vocabulary-visual.spec.mjs` (rows and + preview, with `vocabulary-canonical-comparison.mjs`). Behavior: `workspace-images`, + `full-inspector`, `workspace-history` and `workspace-vocabulary` specs, and + `tests/js/workspace-images-category.test.js`, `workspace-file-inspector.test.js`, + `workspace-vocabulary-state.test.js`, `workspace-toolbar.test.js`. Image annotations schema (#249, `database/annotation_schema.py`): the group `media_annotation_metadata` (one row: schema version, `image_index_revision`), @@ -5543,13 +5610,18 @@ Deploying this backend needs a ComfyUI restart (Python and route changes). longer hands off to the legacy tag manager. That page stays reachable from Overview's "Open existing tag manager" card for tag search and sort, the untagged entry and multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the -legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` routes +legacy admin page. A prompt's category is changed in the Prompt library editor or with +the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged); +the Images selection bar's Set category sets images' own categories only (D140, #332), +and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough. Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts, Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and 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 +category row (Review). A category row reads "N positive prompts", "N positive prompts · +M images" when images hold the value as their own category too, or "M images" for a +value only images hold (D143, #332). 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 are the Library Browse totals. @@ -5592,7 +5664,33 @@ 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 -fixture-only tags after the concept's sample tags. Paired concept/production captures +fixture-only tags after the concept's sample tags. + +- **D143** (#332): category rows count the images whose own category is the value + (`image_count`, the stored rows, missing files included): "N positive prompts · M + images" beside prompts, "M images" for an image-only value, which follows every + prompt-backed row (by value) before Manage category. Browse on an image-only row opens + Images with exactly that Category filter (its Library would be empty); every other + row opens the Library as before. Manage category's select lists image-only values + last, so past 1,000 categories they are the first left out (the dialog says the list is + capped). +- **D144** (#332): Manage category's preview counts the images holding the value ("“X” + is used by N positive prompts and M images. Apply renames it to “Y”."; D9's sentence + with "M images" added when above 0), an image-only value can be renamed, and Apply + renames the image rows in the same transaction; an image row gaining or losing the + value between Preview and Apply is 409 `vocabulary-conflict` with nothing written. +- `category rows and the Manage category preview match the concept with image + categories` (vocabulary-visual.spec, 1600x1100 and 390x844, both themes) seeds the + fixture with `image_categories: true` ("Landscapes" on two images, "Portraits" on one + image only, rows keyed as Images keys them) and compares the page with D143 mapped + (`mapVocabularyConcept(reference, imageCategories)`, whose row model is checked against + the concept page with those rows added at runtime) and the Manage dialog after + Preview with D9 and D144 (`mapVocabularyDialog`'s `preview`). Before the preview is + measured the pointer leaves the pressed Apply and focus returns to ×, the state the + opening comparison in workspace-vocabulary.spec measures. Unmapped, the page differs + in the Landscapes row's text and the added Portraits row, the dialog only in the + sentence. The default fixture seeds no image category, so the other vocabulary + comparisons are unchanged. Paired concept/production captures for both themes and widths are attached as test artifacts; no PNG baseline is added until acceptance. Not compared: hover states and the dialog focus ring (the page-level keyboard ring and link ring offsets are asserted). No coverage floor was lowered; diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index d04d2fd3..3a3ed3ee 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -187,6 +187,33 @@ export async function mutate(page,{css=null,remove=[],clone=[]}={}) { },token); } +/** + * #332 D146: the category chip names its origin after the category, in a muted `span.chip-source` that never splits + * (workspace.css `.chip .chip-source`). The concept's chip shows the bare name (app.js a402), so the recorded rule and + * the span are added to the concept page at runtime and the browser lays the chip out: the first `.tags` row's + * non-badge chip under `root`. `category` also replaces the chip's text (an own category the concept never shows). + * Returns the undo. + */ +export const D146_CHIP_SOURCE='.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }'; +export async function withChipSource(page,root,origin,category=null) { + const restoreRule=await mutate(page,{css:D146_CHIP_SOURCE}); + const token=await page.evaluate(({root,origin,category})=>{ + const chip=document.querySelector(root)?.querySelector('.tags')?.querySelector(':scope > .chip:not(.blue)'); + if(!chip)throw new Error(`withChipSource: no category chip under ${root}`); + const text=chip.textContent,source=document.createElement('span'); + source.className='chip-source';source.textContent=` · ${origin}`; + if(category!==null)chip.textContent=category; + chip.append(source); + const token=`chip-source-${Math.random().toString(36).slice(2)}`; + (window.__chipSources??=new Map()).set(token,()=>{chip.textContent=text;}); + return token; + },{root,origin,category}); + return async()=>{ + await page.evaluate(token=>{window.__chipSources.get(token)();window.__chipSources.delete(token);},token); + await restoreRule(); + }; +} + /** * Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library, * history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) @@ -615,9 +642,12 @@ export function measureInspector(selector) { primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node), minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)}; } + // #332 D146: a category chip's visible origin (`span.chip-source`), null on every other chip. function chipOf(node) { + const source=node.querySelector(':scope > .chip-source'); return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node), - minHeight:getComputedStyle(node).minHeight}; + minHeight:getComputedStyle(node).minHeight, + source:when(source,()=>({text:source.textContent,...font(source),...css(source,'whiteSpace'),...box(source)}))}; } function rowOf(row) { return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)}; diff --git a/tests/browser/history-concept.spec.mjs b/tests/browser/history-concept.spec.mjs index 353b25d4..b58499e1 100644 --- a/tests/browser/history-concept.spec.mjs +++ b/tests/browser/history-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,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,measureInspector,withChipSource} from './concept-inventory.mjs'; // Task F5 of #249: the production Prompt history (#/library/) against the immutable concept's (#prompt/alpine, // app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real @@ -272,6 +272,62 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geomet } finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();} }); +/** + * D145 (#332): the History media inspector (a recorded run's current file, `recorded-run.mjs`) shows the file's own + * category with the chip rule of the Images side panel and viewer (D146). The concept has no recorded-run inspector, so + * its chip is the concept viewer's own category chip (app.js a402, imageDetails(i, true)) with the recorded D146 origin + * and the own category added at runtime (`withChipSource`). The chip's own place depends on what precedes it in either + * inspector, so its box is compared by size only. + */ +const OWN_CATEGORY='Portraits'; +const chipOnly=chip=>{const {left,top,source,...rest}=chip;return {...rest,source:source&&(({left,top,...own})=>own)(source)};}; +const categoryChipOf=async(page,root)=>(await page.evaluate(measureInspector,root)).badges.chips.find(chip=>chip.source); + +async function conceptViewerChip(page,theme) { + await openConcept(page,'images',theme);await page.mouse.move(0,0); + await page.locator('.gallery-body > .image-grid > .media-card').first().locator('.image-open').click(); + await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE); + const restore=await withChipSource(page,'#viewer-details','this image',OWN_CATEGORY);await settle(page); + const chip=await categoryChipOf(page,'#viewer-details'); + await restore(); + return chip; +} + +/** Recorded run for the baseline's newest run, its one reported output opened in the viewer. */ +async function productionRunChip(page,theme,positiveId) { + await openProductionHistory(page,theme,'no-preference',positiveId); + await page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click(); + const run=page.getByRole('dialog',{name:'Recorded run',exact:true}); + await expect(run).toBeVisible(); + const view=run.getByRole('button',{name:'View current file',exact:true}); + await expect(view).toBeEnabled();await view.click(); + const details=page.locator('#viewer-dialog[open] #viewer-details'); + await expect(details.locator('.chip-source')).toHaveText(' · this image'); + await page.mouse.move(0,0);await settle(page); + return categoryChipOf(page,'#viewer-dialog[open] #viewer-details'); +} + +async function setOwnCategory(request,path,category) { + const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json(); + const root_scope=listing.images.find(item=>item.root_index===0).root_scope; + const response=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope,path}],category}}); + await expect(response).toBeOK(); +} + +for(const [width,height] of SIZES)for(const theme of THEMES)test(`history media inspector category chip matches the concept ${width}x${height} ${theme} (D145)`,async({page,request},testInfo)=>{ + await page.setViewportSize({width,height}); + const {manifest}=testInfo; + // The baseline's newest image: the output of the run its dot opens (the fixture records one run per image). + const baseline=manifest.images.find(image=>image.prompt==='alpine'&&image.variant===0); + await setOwnCategory(request,baseline.path,OWN_CATEGORY); + const reference=await conceptViewerChip(page,theme); + const actual=await productionRunChip(page,theme,manifest.prompts[0].positive_id); + await captureCanonicalState(page,testInfo,`production-history-run-chip-${width}-${theme}`,'#viewer-dialog[open]'); + expect.soft(reference,'concept chip').toMatchObject({tag:'span',className:'chip',text:`${OWN_CATEGORY} · this image`,hidden:null}); + // D21: the hidden prefix names the scope; D146 as above. + expect.soft(diffInventory({...chipOnly(reference),hidden:'Image category: '},chipOnly(actual)),'chip differences after D21, D145, D146').toEqual([]); +}); + // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant // dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match. test('history comparison detects mutations',async({page},testInfo)=>{ diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 08479b3d..d361642f 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -85,15 +85,17 @@ function listInfo(card,actionsWidth) { /** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */ const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return xy?1:ab?1:0;}; -export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000)}) { +export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000),imageCategories=[]}) { const paths=new Map(manifest.images.map(image=>[image.filename,image.path])); // D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired). const toolbar={...reference.toolbar}; toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item); // D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category // COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped. + // D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`), + // sorted in with the Library's. toolbar.items=toolbar.items.map(item=>item.id==='category-filter' - ?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item); + ?{...item,options:[item.options[0],...[...item.options.slice(1),...imageCategories].sort(nocase)]}:item); // D29: no "Illustrative previews" sample tag. const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag')); filterRow.items=filterRow.items.map(item=>{ @@ -163,9 +165,9 @@ const pick=inventory=>stripExact(inventory); // The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders. const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration']; -function compare(reference,actual,manifest,sections=SECTIONS) { +function compare(reference,actual,manifest,sections=SECTIONS,imageCategories=[]) { const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]])); - return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual))); + return diffInventory(pick(only(mapImagesConcept(reference,{manifest,imageCategories}))),pick(only(actual))); } /** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ @@ -469,6 +471,117 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); }); +/** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */ +const IMAGE_CATEGORY='Portraits'; +async function setImageCategory(request,path,category) { + const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json(); + const root_scope=listing.images.find(item=>item.root_index===0).root_scope; + const response=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope,path}],category}}); + await expect(response).toBeOK(); + expect(await response.json()).toMatchObject({success:true,category,updated:1}); +} + +/** + * Runs in the page: the open Set category dialog (the concept's `simpleInput`, app.js 1384-1390 and 1138): its title, + * the body's tree, the field, the input and its suggestions (`datalist`), and the actions with every button's box, + * all relative to the dialog, with the concept's geometry and tokens. + */ +function measureCategoryDialog() { + const dialog=document.querySelector('dialog#modal[open]'); + const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null; + const name=node=>[node.localName,...[...node.classList].sort()].join('.'); + const text=node=>node?.textContent.trim()??null; + const origin=dialog.getBoundingClientRect(); + const box=node=>{const rect=node.getBoundingClientRect();return {left:Math.round(rect.left-origin.left),top:Math.round(rect.top-origin.top), + width:Math.round(rect.width),height:Math.round(rect.height)};}; + const body=dialog.querySelector('#modal-body'); + const field=body.querySelector(':scope > .field'); + const label=field?.querySelector(':scope > label'),input=field?.querySelector(':scope > input'); + const list=body.querySelector(':scope > datalist'); + const actions=body.querySelector(':scope > .dialog-actions'); + return { + title:text(dialog.querySelector('#modal-title')), + tree:[...body.children].map(name), + field:field&&{tree:[...field.children].map(name),...css(field,'display','flexDirection','rowGap','marginBottom'),...box(field)}, + label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')}, + input:input&&{id:input.id,type:input.type,value:input.value,list:input.getAttribute('list'), + ...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius'),...box(input)}, + suggestions:list&&{id:list.id,shown:list.getClientRects().length>0,options:[...list.options].map(option=>option.value)}, + actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','rowGap','flexWrap','marginTop'),...box(actions), + buttons:[...actions.children].map(button=>({text:text(button),className:button.className, + ...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor','minHeight'),...box(button)}))}, + width:Math.round(origin.width),height:Math.round(origin.height), + }; +} + +/** + * The concept's own open dialog as production records it, changed at runtime so the browser lays it out: D140 (#332) + * empties the field (the concept pre-fills its demo "Landscapes"), lists the toolbar's categories as the field's + * suggestions (`datalist#workspace-input-suggestions`) and adds "Use prompt category", a plain button of the concept's + * own Cancel, between Cancel and the primary action; D8 names the primary action "Apply" ("Apply in prototype"). + */ +async function categoryDialogAsRecorded(page,suggestions) { + await mutate(page,{clone:[{selector:'dialog#modal[open] .dialog-actions > button:first-child',text:'Use prompt category'}]}); + await page.evaluate(suggestions=>{ + const body=document.querySelector('dialog#modal[open] #modal-body'); + const input=body.querySelector('#action-input');input.value='';input.setAttribute('list','workspace-input-suggestions'); + const list=document.createElement('datalist');list.id='workspace-input-suggestions'; + for(const value of suggestions){const option=document.createElement('option');option.value=value;list.append(option);} + body.querySelector(':scope > .field').after(list); + body.querySelector('.dialog-actions > .ca-primary').textContent='Apply'; + },suggestions); + await settle(page); + return page.evaluate(measureCategoryDialog); +} + +async function openCategoryDialog(page,scope) { + await page.locator(scope).locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check(); + await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected'); + await page.locator('#bulkbar').getByRole('button',{name:'Set category',exact:true}).click(); + await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Categorize selected images'); + await page.mouse.move(0,0);await settle(page); + return page.evaluate(measureCategoryDialog); +} + +// D140 and D141 (#332): with one image's own category set, the toolbar's category options and the Set category dialog +// against the concept's (app.js 348 and 1384-1390). The dialog is compared with the concept page itself changed at +// runtime as recorded (`categoryDialogAsRecorded`); unmapped, it differs only in the recorded parts. +for(const [width,height] of SIZES)for(const theme of THEMES)test(`images category options and Set category dialog match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{ + await page.setViewportSize({width,height}); + const manifest=testInfo.manifest; + await setImageCategory(request,manifest.images.at(-1).path,IMAGE_CATEGORY); + await openConceptLoaded(page,theme,'no-preference'); + const reference=await inventoryOf(page,'#main'); + const referenceDialog=await openCategoryDialog(page,'#main'); + await captureCanonicalState(page,testInfo,`concept-images-category-${width}-${theme}`,'dialog#modal'); + const suggestions=[...CONCEPT_CATEGORIES.slice(1),IMAGE_CATEGORY].sort(nocase); + const oracle=await categoryDialogAsRecorded(page,suggestions); + await page.keyboard.press('Escape');await expect(page.locator('dialog#modal[open]')).toHaveCount(0); + + await openProductionLoaded(page,theme,'no-preference'); + await expect(page.locator('#category-filter option')).toHaveText(['All categories',...suggestions]); + const actual=await inventoryOf(page,'#workspace-main'); + const actualDialog=await openCategoryDialog(page,'#workspace-main'); + await captureCanonicalState(page,testInfo,`production-images-category-${width}-${theme}`,'dialog#modal'); + const path=testInfo.outputPath('images-category-geometry.json'); + await writeFile(path,JSON.stringify({referenceDialog,oracle,actualDialog,reference:reference.toolbar,actual:actual.toolbar},null,2)); + await testInfo.attach('images-category-geometry',{path,contentType:'application/json'}); + + // The concept itself: its sample categories and its pre-filled simpleInput with Cancel / Apply in prototype. + expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,'concept categories').toEqual(CONCEPT_CATEGORIES); + expect.soft(referenceDialog,'concept dialog').toMatchObject({title:'Categorize selected images',label:{text:'Category'}, + input:{id:'action-input',value:'Landscapes',list:null},suggestions:null}); + expect.soft(referenceDialog.actions.buttons.map(button=>button.text),'concept actions').toEqual(['Cancel','Apply in prototype']); + // D141: the toolbar options add the image's own category, sorted in (D36). + expect.soft(compare(reference,actual,manifest,['toolbar'],[IMAGE_CATEGORY]),'toolbar differences after D14, D36, D141').toEqual([]); + // D140 and D8: the dialog equals the concept page laid out as recorded. + expect.soft(diffInventory(oracle,actualDialog),'dialog differences after D8, D140').toEqual([]); + // Unmapped, the concept differs only in the field's value and suggestions, the body's datalist and the actions. + const recorded=/^(input\.(value|list)|tree\.|suggestions|actions\.)/; + expect.soft(diffInventory(referenceDialog,actualDialog).map(item=>item.path).filter(item=>!recorded.test(item)),'unrecorded dialog differences').toEqual([]); + expect.soft(actualDialog.suggestions,'suggestions').toEqual({id:'workspace-input-suggestions',shown:false,options:suggestions}); +}); + // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the // `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match. test('images comparison detects mutations',async({page},testInfo)=>{ diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 0a43d7f1..4244166d 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,saveImageTags} from './concept-inventory.mjs'; +import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} 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. @@ -13,6 +13,8 @@ test.use({locale:'en-US',timezoneId:'UTC'}); const CARDS='.gallery-body > .image-grid > .media-card'; const TITLE='Alpine observatory'; +// D146 (#332): alpine_00001.png's recipe has no category, so its chip shows the positive's, " · prompt" (D39). +const ORIGIN='prompt'; const FIXTURE_IMAGES=24; // The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel). const PANEL_INSET=40; @@ -38,14 +40,18 @@ function withFocusablePrompts(inventory) { return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))}; } -/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */ +/** + * D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). Its + * visible origin (D146, #332: " · prompt") is the concept page's own chip with `span.chip-source` added at runtime + * (`withChipSource`), so `reference.panel` and `viewerPlain` already carry it. + */ function withCategoryScope(inventory) { const [badge,category,...rest]=inventory.badges.chips; return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}}; } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must * equal production. `reference.viewerPlain` is the concept's own full details * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. @@ -112,6 +118,7 @@ async function openProductionPanel(page,theme) { */ async function plainViewerChips(page) { const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS}); + const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN); await page.evaluate(()=>{ window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{ const span=document.createElement('span');span.className='chip';span.textContent=button.textContent; @@ -126,7 +133,7 @@ async function plainViewerChips(page) { return async()=>{ await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; window.__summaryButton.remove();delete window.__summaryButton;}); - await restoreTargets(); + await restoreSource();await restoreTargets(); }; } @@ -162,7 +169,11 @@ async function viewerOf(page,concept,mutation=null) { return {viewerDetails,viewerPlain,fileControls}; } async function inventory(page,concept=false) { + // D146: the concept panel's category chip with its visible origin, laid out by the concept page itself. + const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null; + if(concept)await settle(page); const {panel}=await inspectorOf(page); + await restoreSource?.(); const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept); return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls}; } @@ -287,7 +298,8 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color); expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'}); expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'}); - expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']); + // The concept's bare "Landscapes" with the D146 origin added at runtime (`withChipSource`). + expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']); expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']); expect.soft(diff,'differences after the recorded deviations').toEqual([]); @@ -321,8 +333,10 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry'); const rows=panelOf(page).locator('.detail-inner > .tags'); await expect(rows).toHaveCount(2); - await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']); + await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']); await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: '); + // D146 (#332): the visible origin names where the value came from. + await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt'); await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']); await expect(rows.nth(1).locator('span.chip')).toHaveCount(0); await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); @@ -380,6 +394,13 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); + // D146: the visible origin is part of the mapped concept, so losing it is a difference. + const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']}); + const sourceMutated=await measure(); + expect(()=>assertInspectorMatch(reference,sourceMutated,viewport)).toThrow(/panel\.badges\.chips\.1\.source/); + await restoreSource();await settle(page); + assertInspectorMatch(reference,await measure(),viewport); + await mutate(page,{remove:['#image-inspector > .detail-footer']}); const domMutated=await measure(); expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/); diff --git a/tests/browser/vocabulary-canonical-comparison.mjs b/tests/browser/vocabulary-canonical-comparison.mjs index 5e7d2cf4..b8e86b27 100644 --- a/tests/browser/vocabulary-canonical-comparison.mjs +++ b/tests/browser/vocabulary-canonical-comparison.mjs @@ -1,6 +1,7 @@ // Concept-vs-production inventory for the native Tags & categories screen (concept renderTags, app.js:719-729). // pageInventoryOf runs in the page; dialogs reuse the editor's inventoryOf. The concept is never rewritten: // mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling. +import {diffInventory} from './library-editor-canonical-comparison.mjs'; export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs'; export function pageInventoryOf(main) { @@ -61,23 +62,51 @@ export function pageInventoryOf(main) { }; } +const countOf=(count,noun)=>`${count} ${noun}${count===1?'':'s'}`; + +/** + * D143 (#332): a category row counts the images whose own category it is: "N positive prompts · M images" when + * prompts hold it too, "M images" for a value only images hold. Image-only rows follow every prompt-backed row, before + * the closing Manage category row, each drawn as the concept's last category row with its own text (vocabulary-visual + * checks this against the concept page with those rows added at runtime). `images` is `{value: count}`; `only` lists + * the image-only values in their order. + */ +export function withImageCounts(operations,{images={},only=[]}={}) { + const rows=operations.map(row=>images[row.h3.text]&&!only.includes(row.h3.text) + ?{...row,p:{...row.p,text:`${row.p.text} · ${countOf(images[row.h3.text],'image')}`}}:row); + const manage=rows.at(-1),last=rows.at(-2); + const added=only.map(value=>({...last,h3:{...last.h3,text:value},p:{...last.p,text:countOf(images[value],'image')}})); + return [...rows.slice(0,-1),...added,manage]; +} + // Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal. -export function mapVocabularyConcept(reference) { +// `imageCategories` (#332 D143) is the fixture's own image categories, none by default. +export function mapVocabularyConcept(reference,imageCategories={}) { + const operations=reference.operations.map(row=>({...row,p:{...row.p, + // Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243). + text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})); return {...reference, table:{...reference.table,rows:reference.table.rows.map(row=>({...row, // 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')}})), + operations:withImageCounts(operations,imageCategories), }; } +/** The page comparison: concept rows are the concept's sample tags; production appends fixture-only tags (D4). */ +export function compareVocabularyPage(reference,actual,imageCategories={}) { + const expected=mapVocabularyConcept(reference,imageCategories); + const shared=expected.table.rows.length; + return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}), + extra:actual.table.rows.slice(shared).map(row=>row.tag)}; +} + // Concept dialogs are simpleInput (app.js:1138-1143). donors are concept inventories for controls the concept // shows elsewhere: a `.field select` and a `p.small` (Filters dialog, app.js:1351) and a danger dialog action // (Archive… in the editor, app.js:976), so added controls are held to concept tokens, never to production values. -export function mapVocabularyDialog(mode,reference,{filters,editor,categories}) { +// `preview` is the Manage categories sentence once Preview has been pressed (D9; D144, #332, adds its images). +export function mapVocabularyDialog(mode,reference,{filters,editor,categories,preview=null}) { const fields=reference.fields.map(item=>({...item, // Inventory rows 27-29: the name is `required` (blank Apply is blocked natively). required:true})); @@ -98,9 +127,9 @@ export function mapVocabularyDialog(mode,reference,{filters,editor,categories}) // D9 / Task 8: the proposed name offers a category datalist (no trailing commas: one value). ...fields.map(item=>({...item,listed:true,commaOptions:false}))], // D9: the preview sentence is a `p.small`, empty and hidden until Preview. - small:[{...filters.small[0],text:''}], + small:[{...filters.small[0],text:preview??''}], // D9: the primary is `Preview` until a preview is shown, then `Apply`. - actions:{...reference.actions,buttons:buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item)}}; + actions:{...reference.actions,buttons:preview===null?buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item):buttons}}; } return {...reference,fields,actions:{...reference.actions,buttons}}; } diff --git a/tests/browser/vocabulary-visual.spec.mjs b/tests/browser/vocabulary-visual.spec.mjs index 2d837952..45b47a4a 100644 --- a/tests/browser/vocabulary-visual.spec.mjs +++ b/tests/browser/vocabulary-visual.spec.mjs @@ -1,11 +1,14 @@ +import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; +import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,compareVocabularyPage} from './vocabulary-canonical-comparison.mjs'; // Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories // preview, loading and 1,000-category cap notes, tag conflict and restore notes, the Delete tag confirmation (D14), // empty lists (D12), a failed read with Retry, and Load more (D5). The page and the three concept dialogs are captured // as concept/production pairs by workspace-vocabulary.spec.mjs. There are deliberately no pixel assertions or -// baselines: the maintainer accepts these captures visually before any baseline is added. +// baselines: the maintainer accepts these captures visually before any baseline is added. The last test (#332 D143, +// D144) compares the category rows with image counts and the Manage category preview against the concept. test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); const route='/prompt_manager/workspace#/tags'; const main=page=>page.locator('#workspace-main'); @@ -101,3 +104,87 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark',' await testInfo.attach(`production-load-more-${width}-${theme}`,{path,contentType:'image/png'}); await expect(rows(page)).toHaveCount(100); }); + +// D143 and D144 (#332): with own image categories seeded (`image_categories`: "Landscapes" on two images, "Portraits" +// on one image only), the Categories panel and the Manage category preview against the concept (renderTags, app.js +// 719-729; the Manage dialog is its simpleInput, 1138-1143). The page is compared with D143 mapped, a model checked +// against the concept page with those rows added at runtime; the preview with D9's sentence and D144's images. +const concept='/__test__/canonical/workspace'; +const IMAGE_CATEGORIES=Object.freeze({images:{Landscapes:2,Portraits:1},only:['Portraits']}); +// D9's sentence; D144 adds the images holding the value ("and 2 images"). +const PREVIEW='“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Scenery”.'; +const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf); +async function openConceptRoute(page,route,theme) { + await page.goto('about:blank');await page.goto(`${concept}#${route}`); + await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); +} +/** The concept page as D143 records it: image counts after the prompt counts, image-only rows before Manage category. */ +function conceptWithImageCounts(page) { + return page.evaluate(({images,only})=>{ + const surface=document.querySelectorAll('#main .two-col > section.surface')[1]; + const rows=[...surface.querySelectorAll('.operation')]; + const count=value=>`${images[value]} image${images[value]===1?'':'s'}`; + for(const row of rows) { + const value=row.querySelector('h3').textContent; + if(images[value]&&!only.includes(value))row.querySelector('p').textContent+=` · ${count(value)}`; + } + const manage=rows.at(-1),last=rows.at(-2); + for(const value of only) { + const row=last.cloneNode(true);row.querySelector('h3').textContent=value;row.querySelector('p').textContent=count(value);manage.before(row); + } + },IMAGE_CATEGORIES); +} +/** Concept controls the Manage dialog adds (as in workspace-vocabulary.spec.mjs): the Filters field select and p.small. */ +async function conceptDonors(page,theme,categories) { + await openConceptRoute(page,'images',theme); + await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible(); + const filters=await page.locator('#modal').evaluate(inventoryOf); + await openConceptRoute(page,'prompt/alpine',theme); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(page.locator('#modal')).toBeVisible(); + const editor=await page.locator('#modal').evaluate(inventoryOf); + return {filters,editor,categories}; +} + +for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`category rows and the Manage category preview match the concept with image categories ${width}x${height} ${theme} (D143, D144)`,async({page,request},testInfo)=>{ + await page.setViewportSize({width,height}); + const manifest=await seed(request,{image_categories:true}); + expect(manifest.image_categories).toEqual(IMAGE_CATEGORIES.images); + await openConceptRoute(page,'tags',theme);await expect(page.locator('#main h1')).toHaveText('Tags & categories'); + await page.mouse.move(0,0); + const reference=await pageOf(page,false); + await conceptWithImageCounts(page); + const oracle=await pageOf(page,false); + await captureCanonicalState(page,testInfo,`concept-image-categories-${width}-${theme}`,'#main'); + const donors=await conceptDonors(page,theme,['Landscapes','Architecture','Environments','Illustration','Portraits']); + await openConceptRoute(page,'tags',theme); + await page.locator('#main').getByRole('button',{name:'Review',exact:true}).click();await expect(page.locator('#modal')).toBeVisible(); + const referenceDialog=await page.locator('#modal').evaluate(inventoryOf); + + await open(page,theme,manifest.tags.length); + await expect(categoryOperation(page,'Portraits')).toHaveCount(1); + // The pointer still rests where the concept's Review was pressed: park it so no row is hovered. + await page.mouse.move(0,0); + const actual=await pageOf(page,true); + await captureCanonicalState(page,testInfo,`production-image-categories-${width}-${theme}`,'#workspace-main'); + const box=await openDialog(page,categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true})); + await expect(box.getByLabel('Category',{exact:true})).toHaveValue('Landscapes'); + await box.getByLabel('Proposed new category name',{exact:true}).fill('Scenery');await button(box,'Preview').click(); + await expect(box.locator('p.small')).toHaveText(PREVIEW); + // The pressed primary keeps the pointer and focus; the concept is measured as it opens (focus on ×, no hover), + // which workspace-vocabulary.spec.mjs compares, so this state is restored before measuring the preview. + await page.mouse.move(0,0);await box.locator('.dialog-header button').focus(); + const actualDialog=await box.evaluate(inventoryOf); + await captureCanonicalState(page,testInfo,`production-image-categories-preview-${width}-${theme}`); + const path=testInfo.outputPath('vocabulary-image-categories.json'); + await writeFile(path,JSON.stringify({reference,oracle,actual,referenceDialog,actualDialog},null,2)); + await testInfo.attach('vocabulary-image-categories',{path,contentType:'application/json'}); + + // The D143 model reproduces the concept page with the rows added at runtime. + expect.soft(diffInventory(mapVocabularyConcept(reference,IMAGE_CATEGORIES).operations,mapVocabularyConcept(oracle).operations),'D143 model').toEqual([]); + const {diff,extra}=compareVocabularyPage(reference,actual,IMAGE_CATEGORIES); + expect.soft(actual.operations.map(row=>[row.h3.text,row.p.text]).slice(0,-1),'category rows').toEqual([['Landscapes','3 positive prompts · 2 images'], + ['Architecture','1 positive prompt'],['Environments','1 positive prompt'],['Illustration','1 positive prompt'],['Portraits','1 image']]); + expect.soft(diff,'page differences after the recorded deviations and D143').toEqual([]); + expect.soft(extra,'fixture-only rows').toEqual([manifest.pair_only_tag,manifest.archived_only_tag,manifest.negative_only_tag,manifest.unused_tag]); + expect.soft(diffInventory(mapVocabularyDialog('manage',referenceDialog,{...donors,preview:PREVIEW}),actualDialog),'preview differences after D9 and D144').toEqual([]); +}); diff --git a/tests/browser/workspace_vocabulary_fixture.py b/tests/browser/workspace_vocabulary_fixture.py index 95568762..b1f7e301 100644 --- a/tests/browser/workspace_vocabulary_fixture.py +++ b/tests/browser/workspace_vocabulary_fixture.py @@ -72,6 +72,10 @@ CONCEPT_RECIPES = ( "negative": "oversaturated, flat lighting, watermark, text", }, ) +# #332 D143/D144 (opt-in, ``image_categories``): own image categories, one on +# images only and one shared with prompts. Stored rows count whether or not the +# files exist, so the keys name files the fixture never writes. +IMAGE_CATEGORY_ROWS = (("Landscapes", 2), ("Portraits", 1)) PAIR_ONLY_TAG = "pair_only_detail" ARCHIVED_ONLY_TAG = "archived_pair_detail" NEGATIVE_ONLY_TAG = "legacy_negative_detail" @@ -112,8 +116,26 @@ _SNAPSHOT = { } +def _seed_image_categories(api: PromptManagerAPI) -> dict: + """Own image category rows keyed as Images keys them; ``{value: count}``.""" + from py.api.images import image_category_key + + sources = list(api._get_all_output_dirs()) + for category, count in IMAGE_CATEGORY_ROWS: + keys = [ + image_category_key(sources, str(Path(sources[0]) / f"{category}_{n}.png")) + for n in range(count) + ] + api.db.set_image_categories(keys, category) + return dict(IMAGE_CATEGORY_ROWS) + + def seed_vocabulary( - api: PromptManagerAPI, root: Path, extra_tags: int = 0, extra_categories: int = 0 + api: PromptManagerAPI, + root: Path, + extra_tags: int = 0, + extra_categories: int = 0, + image_categories: bool = False, ) -> dict: """Replace this server's disposable rows with the concept vocabulary.""" db = api.db @@ -167,6 +189,7 @@ def seed_vocabulary( "category": f"bulk_category_{number:04}", }, ) + images = _seed_image_categories(api) if image_categories else {} with db.model.get_connection() as conn: tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")] api.invalidate_gallery_cache() @@ -180,6 +203,7 @@ def seed_vocabulary( "archived_only_tag": ARCHIVED_ONLY_TAG, "negative_only_tag": NEGATIVE_ONLY_TAG, "unused_tag": UNUSED_TAG, + "image_categories": images, } @@ -200,7 +224,14 @@ def register_vocabulary_commands( @routes.post("/__test__/vocabulary/reset") async def reset(request: web.Request) -> web.Response: data = await request.json() if request.can_read_body else {} - if not isinstance(data, dict) or set(data) - {"extra_tags", "extra_categories"}: + if not isinstance(data, dict) or set(data) - { + "extra_tags", + "extra_categories", + "image_categories", + }: + raise web.HTTPBadRequest() + images = data.get("image_categories", False) + if not isinstance(images, bool): raise web.HTTPBadRequest() extra = data.get("extra_tags", 0) categories = data.get("extra_categories", 0) @@ -215,7 +246,7 @@ def register_vocabulary_commands( if request.query: raise web.HTTPBadRequest() manifest = await api._run_in_executor( - seed_vocabulary, api, root, extra, categories + seed_vocabulary, api, root, extra, categories, images ) return web.json_response({"success": True, **manifest})