diff --git a/TESTING.md b/TESTING.md index eac950a..f17f55c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1629,7 +1629,13 @@ pending count (`GET /prompt_manager/review/items?limit=1`, its `total`). queue" chip and "sample" wording are prototype copy (D29). When a re-read (below) finds the open item gone, the next one opens with " was decided elsewhere, so the next image is open."; a failed re-read keeps the queue and says "The review queue - could not be read again. Reload the page to see the latest images." + could not be read again. Reload the page to see the latest images." A queue card's + title is its file name and its line the extension only (an undecided item names no + prompt, and D11 omits the size and seed a queue item does not carry); its image's alt + is the file name; the panel's Copy and Send buttons carry no prompt title in their + names; the Tag image dialog opened from the queue heads with the file name. An + unavailable file's empty media button keeps the concept's 1.33 media area, so its + card lines up with the others. **Live queue (F2).** An open Needs review page holds one server-sent event stream, `GET /prompt_manager/review/events` (`py/api/review_events.py`): `event: ready` when it @@ -1664,16 +1670,73 @@ Pinned by `tests/test_api_review_events.py`, `tests/js/workspace-review-state.te queue once …", "a live re-read keeps the loaded depth and the scroll position"). Leaving the page mid-decision releases that decision's capture (the server may never see the request). +**Concept comparison (F3).** `tests/browser/review-concept.spec.mjs` measures the whole +page (`reviewInventory` in `tests/browser/tagging-canonical-comparison.mjs`: every shown +box below `main` with its place, fonts, edges and layout, a leaf's text, a button's name, +a select's options) in the concept at `#review` and in production on the fixture's +concept-shaped queue (`POST /__test__/review/reset {items: 3, concept: true}`: the +concept's first three image names and sizes, and the concept's first prompt on the open +item), and `mapConceptReview` applies only: D29 (no "Sample review queue" chip, no +"sample" in the panel heading and Apply), D119 (above), D3 (the grid's +`position: relative`), production's hooks and accessible names (`review-queue`, +`review-panel`, the list roles, the region names) and sample data (the candidate options +except Leave unresolved; widths and heights that follow from the changed copy, moved by +exactly that much). It then matches at 1600×1100 and 390×844 in both themes on all four +projects; the gallery layout follows the concept at 1600, 1200, 900 and 390px (310px, +275px with a 15px gap, 280px, one column); the SAVED VIEWS rows match with Needs review +after Favorites; the queue's Tag image dialog matches the concept's needs-review dialog +(`needs-review-tagging.png`) under the C4 mapping; and the comparison is shown to notice +a dropped notice border and reordered actions. The paired captures (populated queue in +both themes and widths, conflict, limited item, unavailable file, empty queue) are test +artifacts; no screenshot baseline is added. The comparison found one gap, the collapsed +media area of an unavailable file's card, fixed above (`workspace-review.spec.mjs` "a +limited item and an unavailable file …" pins it). + - **D3** and **D20** apply to the queue grid (its cards stay in the grid's flow; it is not virtualized) and to the panel's prompt blocks (their mount point is transparent). -Tests: `tests/js/workspace-review-state.test.js` (controller), +Tests: `tests/browser/review-concept.spec.mjs` (concept comparison), +`tests/js/workspace-review-state.test.js` (controller), `tests/js/workspace-state.test.js` (route and count), `tests/browser/workspace-review.spec.mjs` (queue, count, apply, leave, Tag, empty queue, Load more, Activity's Review metadata; items recorded by the real recorder in `tests/browser/workspace_review_fixture.py`), and the SAVED VIEWS rows in `shell-concept.spec.mjs` and `workspace-saved-views.spec.mjs`. +### Native image tagging and Needs review (#249 PRs A–F) + +One place for the whole image-tagging slice; the details live in the sections named. + +- **Routes.** Tagging: `POST /prompt_manager/image-annotations/observe`, `GET`/`DELETE + /prompt_manager/image-annotations/capture/`, `POST + /prompt_manager/image-annotations/suggest`, `PUT /prompt_manager/image-annotations/tags` + ("Tag image dialog and recorded deviations"). Needs review: `GET + /prompt_manager/review/items?limit=1..40&cursor=` (answers `library_epoch`, + `review_revision`, `total`), `GET /prompt_manager/review/items/`, `POST + /prompt_manager/review/items//decision` and the change stream `GET + /prompt_manager/review/events` ("Needs review decisions and queue reads", "Needs review + page and recorded deviations"). Every legacy route keeps its payload; the only legacy + change is `409 model-busy` from the legacy Auto-tag routes while another model + operation holds the shared gate. +- **S1 (fingerprint-current reads, ruled 2026-10-22).** The Images Tags filter, search and + the inspector/viewer chips use a byte record only while the file's `(st_size, + st_mtime_ns)` equals its verified fingerprint; writes hash the bytes. Residual risk + under "Image annotations schema". +- **Capture limits.** 256 MiB per file, 8 completed captures, 512 MiB reserved, 2 + capture/verify workers, 900 s idle, 1800 s hard; the browser fixtures release every + capture on reset, and leaving the Needs review page mid-decision releases its capture. +- **The record-changing job.** Bulk Add tags and Auto-tag selected run as the + `image-tags` Activity job (PR D); like the metadata scan it is a record-changing job, so + a start while another runs is refused (`busy`). Needs review items come from that scan + and from the live monitor, and a decision changes links only when compatible + (`association-conflict` otherwise, with zero writes). +- **No downloads.** No automated test downloads or loads a model: the browser server + injects `TestModel` (its `download_model` raises), native routes and jobs never + download, and every tagging spec asserts no `/autotag/download/` request. +- **Restart.** PRs A, B, D and E and PR F (the review change stream, the queue version) + change Python: the maintainer restarts ComfyUI once after merging. HTML, CSS and JS + changes need only a browser refresh. + ## Workspace data fields for the concept pages (#249) These fields are additive. The legacy `/prompt_manager/images/output` listing (no diff --git a/tests/browser/review-concept.spec.mjs b/tests/browser/review-concept.spec.mjs new file mode 100644 index 0000000..19365e9 --- /dev/null +++ b/tests/browser/review-concept.spec.mjs @@ -0,0 +1,212 @@ +// Needs review against the approved concept (#249 PR F, plan Task F3, Mock inventory M4: app.js renderReview +// a749–760, style.css s407–415, s796–814 and their breakpoints, screenshot needs-review-tagging.png). Production runs +// on the review fixture's concept-shaped queue (workspace_review_fixture.py `concept: true`: the concept's first three +// image names and sizes, and the concept's first prompt on the open item), so every remaining difference is a recorded +// deviation applied by mapConceptReview (D3, D29, D119) or a test failure. Paired captures of every state are attached +// as artifacts; no screenshot baseline is added. +import { test, expect } from './fixtures.mjs'; +import { openConcept, openProduction, inventoryOf, mutate, saveImageTags, diffInventory, SIZES, THEMES } from './concept-inventory.mjs'; +import { reviewInventory, mapConceptReview, taggingInventory, mapConceptTagging, diffTaggingInventory } from './tagging-canonical-comparison.mjs'; + +const CONCEPT_FILE = 'alpine_00001.png'; +const CONCEPT_TAGS = ['cinematic', 'blue_hour', 'architecture']; +const SURFACE = ['h2', 'p.small', 'div.row-actions.spaced-15', 'div.field', 'section.positive.prompt-section', + 'section.negative.prompt-section', 'div.dialog-actions']; +// The fake model's server default instructions (tests/browser/server.py TestModel.default_prompt), D84. +const INSTRUCTIONS = 'Describe the image'; + +const panel = page => page.locator('#workspace-main section.review-panel'); +const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true }); + +async function seed(request, data = {}) { + await expect(await request.post('/__test__/review/reset', { data: { items: 3, concept: true, ...data } })).toBeOK(); +} +/** Production's Needs review page with its first item's sampler paths loaded. */ +async function openReview(page, theme) { + await openProduction(page, 'review', theme); + await expect(panel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled(); + await settle(page); +} +async function settle(page) { + await page.mouse.move(0, 0); + await page.evaluate(async () => { + await document.fonts.ready; + await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); + }); +} +const conceptInventory = page => page.evaluate(reviewInventory, '#main'); +const productionInventory = page => page.evaluate(reviewInventory, '#workspace-main'); +const node = (tree, prefix) => tree.tag?.startsWith(prefix) ? tree : tree.children?.map(child => node(child, prefix)).find(Boolean) ?? null; +/** Computed values of CSS tokens, read through a probe element in the open page. */ +const tokens = (page, declarations) => page.evaluate(text => { + const probe = document.createElement('i'); + probe.style.cssText = text; + document.body.append(probe); + const style = getComputedStyle(probe), values = [style.borderTopColor, style.backgroundColor]; + probe.remove(); + return values; +}, declarations); + +async function compareAt(page, request, width, height, theme) { + await seed(request); + await page.setViewportSize({ width, height }); + await openConcept(page, 'review', theme); + await settle(page); + const concept = await conceptInventory(page); + await openReview(page, theme); + const actual = await productionInventory(page); + return { concept, actual, reference: mapConceptReview(concept, actual) }; +} + +test.beforeEach(async ({ page }) => { + page.errors = []; + page.on('pageerror', error => page.errors.push(error.message)); +}); +test.afterEach(async ({ page, request }) => { + expect(page.errors).toEqual([]); + await request.post('/__test__/jobs/restart'); +}); + +for (const [width, height] of SIZES) for (const theme of THEMES) { + test(`concept and production Metadata review match at ${width}x${height} ${theme}`, async ({ page, request }) => { + const { actual, reference } = await compareAt(page, request, width, height, theme); + expect(diffInventory(reference, actual), `${width}x${height} ${theme}`).toEqual([]); + // Pinned concept values (M4), so a mapping mistake on both sides cannot pass unseen. + const main = actual.main; + expect(main.children.map(child => child.tag)).toEqual(['div.heading', 'div.notice', 'div.gallery-layout']); + const notice = node(main, 'div.notice'); + const [accent, soft] = await tokens(page, 'border-top:1px solid var(--ca-accent);background:var(--soft-blue)'); + expect([notice.borderTopColor, notice.borderLeftColor, notice.backgroundColor]).toEqual([accent, accent, soft]); + expect([notice.borderTopWidth, notice.borderTopLeftRadius, notice.paddingTop, notice.paddingLeft, notice.marginBottom]) + .toEqual(['1px', '6px', '15px', '18px', '22px']); + expect(notice.flexDirection).toBe(width <= 700 ? 'column' : 'row'); + expect(notice.children.map(child => child.children.map(item => [item.tag, item.text]))) + .toEqual([[['h2', '3 images need a decision'], ['p', 'Multiple sampler paths were found. The existing image links were preserved. Choose the correct context or leave them unresolved.']]]); + const layout = node(main, 'div.gallery-layout'); + expect(layout.gridTemplateColumns).toBe(width <= 700 ? `${layout.width}px` : `${layout.width - 22 - 310}px 310px`); + const surface = node(main, 'section.review-panel'); + expect(surface.children.map(child => child.tag)).toEqual(SURFACE); + expect(surface.children[0].text).toBe('Resolve the association'); + expect(surface.children[1].text).toBe(CONCEPT_FILE); + const actions = surface.children.at(-1); + expect(actions.children.map(button => [button.text, button.primary])).toEqual([['Leave for later', false], ['Apply association', true]]); + expect(node(surface, 'select').options.at(-1)).toEqual(['Leave unresolved', false]); + expect(node(main, 'div.image-grid').children.map(card => node(card, 'h3').text)) + .toEqual(['alpine_00001.png', 'dunes_00001.png', 'forest_00001.png']); + expect(actual.noPageScroll).toBe(true); + }); +} + +test('the gallery layout follows the concept at every breakpoint', async ({ page, request }) => { + await seed(request); + // 310px beside the queue, 275px and a 15px gap at ≤1250px, 280px at ≤950px, one column at ≤700px. + for (const [width, columns, gap] of [[1600, '310px', '22px'], [1200, '275px', '15px'], [900, '280px', '15px'], [390, null, '15px']]) { + await page.setViewportSize({ width, height: 1000 }); + await openConcept(page, 'review', 'dark'); + const concept = node((await conceptInventory(page)).main, 'div.gallery-layout'); + await openReview(page, 'dark'); + const actual = node((await productionInventory(page)).main, 'div.gallery-layout'); + expect([actual.gridTemplateColumns, actual.columnGap], `${width}px`).toEqual([concept.gridTemplateColumns, concept.columnGap]); + expect([actual.gridTemplateColumns.split(' ').at(-1), actual.columnGap], `${width}px`).toEqual([columns ?? `${actual.width}px`, gap]); + expect(actual.gridTemplateColumns.split(' ').length, `${width}px`).toBe(width <= 700 ? 1 : 2); + } +}); + +test('SAVED VIEWS rows match the concept with Needs review after Favorites', async ({ page, request }) => { + await seed(request); + await page.setViewportSize({ width: 1600, height: 1100 }); + await openConcept(page, 'review', 'dark'); + const concept = (await inventoryOf(page, '#main')).chrome.navs.find(nav => nav.id === 'saved-nav'); + await openReview(page, 'dark'); + const actual = (await inventoryOf(page, '#workspace-main')).chrome.navs.find(nav => nav.id === 'saved-nav'); + // Sample data: the concept stars three images; this queue's fixture stars none. Needs review counts the queue (3). + const reference = { ...concept, rows: concept.rows.map(row => row.text === 'Favorites' ? { ...row, count: { ...row.count, text: '0' } } : row) }; + expect(diffInventory(reference, actual)).toEqual([]); + expect(actual.rows.map(row => [row.text, row.count?.text, row.current])).toEqual([['Favorites', '0', null], ['Needs review', '3', 'page']]); + // The count's auto start margin follows the row's own text; every other count token is shared. + const { text: favoriteCount, marginInlineStart: favoriteMargin, ...favoriteStyle } = actual.rows[0].count; + const { text: reviewCount, marginInlineStart: reviewMargin, ...reviewStyle } = actual.rows[1].count; + expect(reviewStyle).toEqual(favoriteStyle); + expect(actual.rows[1].icon.d).toBe('M4 3h16v18H4zM8 7h8M8 12h8M8 17h5'); +}); + +test('review Tag dialog matches the needs-review-tagging reference', async ({ page, request }) => { + await seed(request); + await saveImageTags(request, `review/${CONCEPT_FILE}`, 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 settle(page); + const opened = await page.locator('dialog#modal').evaluate(taggingInventory); + await page.keyboard.press('Escape'); + await openReview(page, 'dark'); + await panel(page).getByRole('button', { name: 'Tag image', exact: true }).click(); + 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 settle(page); + const actual = await page.locator('dialog#modal').evaluate(taggingInventory); + const numberField = opened.modelPanel.fields.find(field => field.control.id === 'image-general-threshold'); + const mapped = mapConceptTagging(opened, { instructions: INSTRUCTIONS, chip: opened.heading.size, numberField }); + // D119: an undecided queue item names no prompt, so the heading and the preview's alt are the file name. + const reference = { ...mapped, heading: { ...mapped.heading, h3: { ...mapped.heading.h3, text: CONCEPT_FILE }, + preview: { ...mapped.heading.preview, alt: CONCEPT_FILE } } }; + expect(actual.scopeNote.text).toBe('Tags belong to this image. Resolve its prompt association separately in Needs review.'); + expect(diffTaggingInventory(reference, actual)).toEqual([]); + await page.keyboard.press('Escape'); + await expect(tagDialog(page)).toHaveCount(0); +}); + +test('the comparison notices a dropped notice border and reordered actions', async ({ page, request }) => { + const { actual, reference } = await compareAt(page, request, 1600, 1100, 'dark'); + expect(diffInventory(reference, actual)).toEqual([]); + const undoBorder = await mutate(page, { css: '#workspace-main .notice { border: 0 !important; }' }); + const borderless = await productionInventory(page); + expect(diffInventory(reference, borderless).map(item => item.path)).toEqual(expect.arrayContaining([ + 'main.children.1.borderTopWidth', 'main.children.1.borderTopStyle'])); + await undoBorder(); + await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild)); + const reordered = await productionInventory(page); + expect(diffInventory(reference, reordered).map(item => item.path)).toEqual(expect.arrayContaining([ + 'main.children.2.children.1.children.6.children.0.text', 'main.children.2.children.1.children.6.children.0.primary'])); + await panel(page).locator('.dialog-actions').evaluate(row => row.prepend(row.lastElementChild)); + expect(diffInventory(reference, await productionInventory(page))).toEqual([]); +}); + +// Paired captures for the PR (no baselines): the populated queue beside the concept in both themes and widths, then +// the states the concept lacks (conflict, limited item, unavailable file, empty queue) as production captures. +test('paired Needs review captures', async ({ page, request }, testInfo) => { + test.setTimeout(120_000); + const shot = async name => { + await settle(page); + const file = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: file, fullPage: true }); + await testInfo.attach(name, { path: file, contentType: 'image/png' }); + }; + await seed(request); + for (const [width, height] of SIZES) for (const theme of THEMES) { + await page.setViewportSize({ width, height }); + await openConcept(page, 'review', theme); + await shot(`concept-review-${width}-${theme}`); + await openReview(page, theme); + await shot(`production-review-${width}-${theme}`); + } + await page.setViewportSize({ width: 1600, height: 1100 }); + await seed(request, { items: 0, conflict: true, limited: true, unavailable: true }); + await openReview(page, 'dark'); + await panel(page).getByRole('combobox', { name: 'Sampler context', exact: true }).selectOption({ index: 0 }); + const decided = page.waitForResponse(item => /\/decision$/.test(item.url())); + await panel(page).getByRole('button', { name: 'Apply association', exact: true }).click(); + expect((await decided).status()).toBe(409); + await expect(panel(page).locator('.warning-note')).toBeVisible(); + await shot('production-review-conflict'); + for (const [index, name] of [[1, 'limited'], [2, 'unavailable']]) { + await page.locator('#workspace-main .review-queue article').nth(index).getByRole('button', { name: /^Inspect / }).click(); + await expect(panel(page).locator('.warning-note')).toBeVisible(); + await shot(`production-review-${name}`); + } + await seed(request, { items: 0 }); + await openProduction(page, 'review', 'dark'); + await expect(page.locator('#workspace-main .notice h2')).toHaveText('No images need a decision'); + await shot('production-review-empty'); +}); diff --git a/tests/browser/tagging-canonical-comparison.mjs b/tests/browser/tagging-canonical-comparison.mjs index d9ff0fd..8878424 100644 --- a/tests/browser/tagging-canonical-comparison.mjs +++ b/tests/browser/tagging-canonical-comparison.mjs @@ -140,3 +140,152 @@ export function mapConceptTagging(reference,{instructions,limit=10,chip,numberFi export function withoutChipGeometry(inventory) { return {...inventory,suggestions:{...inventory.suggestions,rows:inventory.suggestions.rows.map(({paddingTop,paddingLeft,alignItems,...row})=>row)}}; } + +/** + * Needs review (plan Task F3, Mock inventory M4: app.js renderReview a749–760, style.css s407–415, s796–814 and their + * breakpoints): the page's main element as a tree, runs in the page. Every shown box below `main` to `depth` levels is + * recorded with its tag and classes, its box relative to its parent, its fonts, edges and layout, a leaf's text, a + * button's label and icon, a select's options; a `display: contents` node is replaced by its children (D20). A flex + * row of actions also records the gaps between its children and its right inset, so a row whose button copy differs + * (D29) still compares by order, spacing and alignment. + */ +export function reviewInventory(selector) { + const main=document.querySelector(selector); + // Half pixels: the concept's 13.5px line heights add up exactly, so a box that loses one line moves by 13.5px. + const px=value=>Math.round(value*2)/2; + const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));}; + const rect=node=>node.getBoundingClientRect(); + const shown=node=>node.getClientRects().length>0&&getComputedStyle(node).visibility!=='hidden'; + const relative=(node,origin)=>{const a=rect(node),b=rect(origin);return {left:px(a.left-b.left),top:px(a.top-b.top),width:px(a.width),height:px(a.height)};}; + const font=node=>css(node,'fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color','textTransform','textAlign'); + const edges=node=>css(node,'paddingTop','paddingRight','paddingBottom','paddingLeft','marginTop','marginRight','marginBottom','marginLeft', + 'borderTopWidth','borderTopStyle','borderTopColor','borderRightWidth','borderRightStyle','borderRightColor','borderBottomWidth', + 'borderBottomStyle','borderBottomColor','borderLeftWidth','borderLeftStyle','borderLeftColor','borderTopLeftRadius','backgroundColor', + 'boxShadow','outlineStyle'); + const layout=node=>css(node,'display','position','flexDirection','flexWrap','justifyContent','alignItems','columnGap','rowGap', + 'gridTemplateColumns','overflowX','overflowY','objectFit','minHeight','minWidth'); + const tagOf=node=>`${node.localName}${[...node.classList].sort().map(name=>`.${name}`).join('')}`; + const textOf=node=>node.textContent.replace(/\s+/g,' ').trim(); + const boxed=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxed(child):[child]); + const LEAVES=new Set(['button','select','input','img','svg','textarea','progress']); + function nodeOf(node,origin,depth) { + const base={tag:tagOf(node),...relative(node,origin),...font(node),...edges(node),...layout(node)}; + const name=node.localName; + if(name==='svg')return {tag:base.tag,width:base.width,height:base.height,d:node.querySelector('path')?.getAttribute('d')??null, + ...css(node,'strokeWidth','color','verticalAlign')}; + if(name==='button')return {...base,text:textOf(node),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'), + disabled:node.getAttribute('aria-disabled')==='true'||node.disabled,primary:node.classList.contains('ca-primary'), + children:boxed(node).filter(shown).map(child=>nodeOf(child,node,0))}; + if(name==='select')return {...base,id:node.id,options:[...node.options].map(option=>[option.textContent,option.disabled])}; + if(name==='input')return {...base,id:node.id,type:node.type,label:node.getAttribute('aria-label')}; + if(name==='img')return {...base,alt:node.getAttribute('alt')}; + const children=boxed(node).filter(shown); + const item={...base,id:node.id||null,role:node.getAttribute('role'),label:node.getAttribute('aria-label'), + text:children.length?null:textOf(node),htmlFor:node.htmlFor||null}; + if(/(^|\s)(dialog-actions|row-actions|image-card-actions)(\s|$)/.test(node.className)&&children.length) { + const boxes=children.map(rect); + item.gaps=boxes.slice(1).map((box,index)=>px(box.left-boxes[index].right)); + item.rightInset=px(rect(node).right-boxes.at(-1).right); + } + if(depth>0&&!LEAVES.has(name))item.children=children.map(child=>nodeOf(child,node,depth-1)); + return item; + } + return {main:nodeOf(main,main,8),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; +} + +/** + * `tree` with `edit(node)` applied to every node `match` picks (a tag prefix or a predicate), depth first, new objects + * only. + */ +function editNodes(tree,match,edit) { + const children=tree.children?.map(child=>editNodes(child,match,edit)); + const node=children?{...tree,children}:tree; + const picked=typeof match==='function'?match(node):node.tag?.startsWith(match); + return picked?edit(node):node; +} +const findNode=(tree,prefix)=>tree.tag?.startsWith(prefix)?tree:tree.children?.map(child=>findNode(child,prefix)).find(Boolean)??null; + +/** + * The concept's Metadata review inventory (reviewInventory) with the recorded deviations applied, the remaining sample + * data taken from production (`actual`) only where the concept's own copy cannot say it: + * - D29: no "Sample review queue" chip in the notice; "Resolve the association" and "Apply association" without the + * prototype's "sample"; + * - D119: queue cards carry no selection checkbox and no star (the overlay row stays, empty); a card's title is its + * file name (an undecided item names no prompt) and its line is the extension only (D11 omits the unknown size and + * seed, which a queue item does not carry); the image's alt is the file name; the prompt blocks' Copy and Send names + * carry no prompt title; + * - D3: the grid keeps the Images grid's `position: relative` (its cards stay in its flow); + * - production's hooks and names: the `review-queue` / `review-panel` classes, the queue's list roles and name, the + * panel heading's id (the region's name) and the prompt sections' region names; + * - sample data: the Sampler context's candidate options (the file's own evidence; the last option, Leave unresolved, + * stays compared) and the widths that follow from copy the deviations above change (a card's text column, the + * panel's action buttons; their gaps and right inset stay compared). + * Where that copy changes the height of a box, the boxes after it move by exactly that much: the stacked notice (390) + * loses the chip and its row gap; a card whose concept line wraps (390: "1536 × 1152 · PNG · Seed 42001") takes + * production's one-line height, its row of the grid shrinks by the most any of its cards lost, and in one column the + * panel below the grid moves up by the grid's loss. The actions keep production's centred place in the card's info. + */ +export function mapConceptReview(reference,actual) { + const named=(node,id)=>({...node,id}); + const actualCards=findNode(actual.main,'div.image-grid')?.children??[]; + const actualActions=findNode(actual.main,'div.dialog-actions'); + let cardIndex=0; + let main={...reference.main,id:actual.main.id}; + let noticeLoss=0; + main=editNodes(main,'div.notice',notice=>{ + const chip=notice.children.find(child=>child.tag==='span.chip'); + if(chip&¬ice.flexDirection==='column')noticeLoss=chip.height+parseFloat(notice.rowGap); + return {...notice,height:notice.height-noticeLoss,children:notice.children.filter(child=>child!==chip)}; + }); + const noticeIndex=main.children.findIndex(child=>child.tag==='div.notice'); + main={...main,height:main.height-noticeLoss,children:main.children.map((child,index)=>index>noticeIndex?{...child,top:child.top-noticeLoss}:child)}; + const losses=[]; + main=editNodes(main,'div.image-grid',grid=>({...grid,tag:'div.image-grid.review-queue',position:'relative',role:'list',label:'Review queue'})); + main=editNodes(main,'article.media-card',card=>{ + const twin=actualCards[cardIndex++]; + const filename=findNode(card,'button.image-open').label.replace(/^Open /,''); + const art=editNodes(card.children[0],'div.image-overlays',overlays=>({...overlays,height:0,text:'',children:[]})); + const withAlt=editNodes(art,'img',img=>({...img,alt:filename})); + const info=card.children[1]; + const [copy,...rest]=info.children; + const twinCopy=twin?.children[1]?.children[0]; + const [h3,p]=copy.children; + const twinLine=twinCopy?.children[1]; + const loss=twinLine?p.height-twinLine.height:0; + losses.push({top:card.top,loss}); + const mappedCopy={...copy,width:twinCopy?.width,height:copy.height-loss,children:[{...h3,text:filename,width:twinCopy?.children[0]?.width}, + {...p,text:'PNG',width:twinLine?.width,height:p.height-loss}]}; + const [actions,...after]=rest; + const twinActions=twin?.children[1]?.children[1]; + const mappedActions=actions&&loss?{...actions,top:twinActions?.top}:actions; + return {...card,role:'listitem',height:card.height-loss, + children:[withAlt,{...info,height:info.height-loss,children:[mappedCopy,...(actions?[mappedActions]:[]),...after]},...card.children.slice(2)]}; + }); + // Each grid row loses what its most shortened card lost; later rows move up by the rows before them. + const rowLoss=new Map(); + for(const {top,loss} of losses)rowLoss.set(top,Math.max(rowLoss.get(top)??0,loss)); + const lossBefore=top=>[...rowLoss].filter(([rowTop])=>rowTopsum+loss,0); + const gridLoss=[...rowLoss.values()].reduce((sum,loss)=>sum+loss,0); + main=editNodes(main,'div.image-grid',grid=>({...grid,height:grid.height-gridLoss, + children:grid.children.map(card=>({...card,top:card.top-lossBefore(card.top)}))})); + main=editNodes(main,'div.gallery-layout',layout=>{ + if(!gridLoss||layout.gridTemplateColumns.trim().split(/\s+/).length>1)return layout; + return {...layout,height:layout.height-gridLoss,children:layout.children.map((child,index)=>index?{...child,top:child.top-gridLoss}:child)}; + }); + if(gridLoss&&findNode(main,'div.gallery-layout').gridTemplateColumns.trim().split(/\s+/).length===1)main={...main,height:main.height-gridLoss}; + main=editNodes(main,'section.surface',surface=>{ + const [h2,...rest]=surface.children; + return {...surface,tag:'section.review-panel.surface',children:[named({...h2,text:h2.text.replace('Resolve the sample association','Resolve the association')},'review-panel-title'),...rest]}; + }); + main=editNodes(main,'select',select=>{ + const twin=findNode(actual.main,'select'); + return select.id==='review-context'?{...select,options:[...twin.options.slice(0,-1),select.options.at(-1)]}:select; + }); + main=editNodes(main,node=>/^section\..*\bprompt-section\b/.test(node.tag),section=>({...section,label:section.tag.includes('positive')?'Positive prompt':'Negative prompt'})); + main=editNodes(main,node=>node.tag?.startsWith('button')&&/^(Copy|Send) (positive|negative) prompt/.test(node.label??''), + button=>({...button,label:button.label.replace(/: .+$/,'')})); + main=editNodes(main,'div.dialog-actions',actions=>({...actions,children:actions.children.map((button,index)=>({...button, + text:button.text.replace('Apply sample association','Apply association'), + left:actualActions?.children[index]?.left,width:actualActions?.children[index]?.width}))})); + return {...reference,main}; +} diff --git a/tests/browser/workspace-review.spec.mjs b/tests/browser/workspace-review.spec.mjs index 02eabaf..917f056 100644 --- a/tests/browser/workspace-review.spec.mjs +++ b/tests/browser/workspace-review.spec.mjs @@ -142,6 +142,13 @@ test('a limited item and an unavailable file can only be left unresolved', async const unavailable = cards(page).nth(1); await expect(unavailable.locator('.image-open')).toHaveAttribute('aria-disabled', 'true'); await expect(unavailable.locator('.image-card-actions button').last()).toHaveAttribute('aria-disabled', 'true'); + // F3: its empty media button keeps the concept's 1.33 media area, so the card lines up with its neighbours. + await expect(cards(page).first().locator('.image-open img')).toBeVisible(); + const sizes = await cards(page).evaluateAll(nodes => nodes.map(node => { + const art = node.querySelector('.image-open').getBoundingClientRect(); + return [Math.round(art.width), Math.round(art.height), Math.round(node.getBoundingClientRect().height)]; + })); + expect(sizes[1]).toEqual(sizes[0]); await unavailable.getByRole('button', { name: 'Inspect unavailable.png', exact: true }).click(); await expect(panel(page).locator('p.small')).toHaveText('unavailable.png'); await expect(warning(page)).toHaveText('This file is not under a current image source, so it cannot be opened. You can leave it unresolved.'); @@ -382,6 +389,17 @@ test('a visit reads its queue once, and a change reads it once more', async ({ p expect(reads.filter(search => search === '?limit=1')).toHaveLength(1); }); +test('a ready whose version cannot be read re-reads the queue once', async ({ page, request }) => { + // The stream's ready names no readable version (an older server, a proxy that rewrote it): the page reads again. + await seed(request, 1); + const reads = reviewReads(page); + await page.route(url => url.pathname === '/prompt_manager/review/events', route => route.fulfill({ + status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store' }, body: 'event: ready\ndata: not json\n\n' })); + await open(page); + await expect.poll(() => reads.filter(search => search === '?limit=40').length).toBeGreaterThanOrEqual(2); + await expect(cards(page)).toHaveCount(1); +}); + test('a live re-read keeps the loaded depth and the scroll position', async ({ page, request }) => { // F3 ruling: a queue change re-reads as many items as Load more had shown, and the page does not jump. await seed(request, 45); diff --git a/tests/browser/workspace_review_fixture.py b/tests/browser/workspace_review_fixture.py index efce5c2..5d64a48 100644 --- a/tests/browser/workspace_review_fixture.py +++ b/tests/browser/workspace_review_fixture.py @@ -18,6 +18,12 @@ after the plain ones in this order: - ``unavailable``: a file recorded under the ``temp`` directory while it was a gallery root; the root is then removed again, so no current root lists it. +``concept: true`` (F3, the concept comparison) names and sizes the plain +files as the concept's first three images (app.js ``images``) and gives +their first sampler the concept's first prompt (``alpine``: its positive and +its first negative, the one the concept's panel shows), so the panel's text +wraps as the concept's does. + ``POST /__test__/review/add {kind}`` adds one file while a page is open: ``monitor`` runs the monitor pipeline on a new ambiguous output (recorded), ``file`` only writes one (a metadata scan records it). ``GET @@ -47,6 +53,17 @@ NEGATIVE = "Soft negative, blur" # The monitor's fallback: the most recent saved prompt. FALLBACK = "Misty forest path" SPECIAL = ("conflict", "limited", "unavailable") +# The concept's first three images (concept app.js `images`) and first prompt. +CONCEPT_IMAGES = ( + ("alpine_00001.png", (1536, 1152)), + ("dunes_00001.png", (1024, 1536)), + ("forest_00001.png", (1280, 1280)), +) +CONCEPT_POSITIVE = ( + "A remote alpine observatory at blue hour, warm light through glass, dramatic " + "mountain peaks, cinematic composition, atmospheric mist, fine detail" +) +CONCEPT_NEGATIVE = "blurry, low quality, low resolution" # More than one queue page (40), so Load more has a second page. MAX_ITEMS = 45 _TABLES = ( @@ -68,12 +85,16 @@ _TABLES = ( ) -def _graph(encoder_class: str = "CLIPTextEncode") -> dict: +def _graph( + encoder_class: str = "CLIPTextEncode", + positives: tuple = POSITIVES, + negative: str = NEGATIVE, +) -> dict: graph = { "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}, - "2": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}}, } - for index, text in enumerate(POSITIVES): + for index, text in enumerate(positives): encoder, sampler = str(10 + 2 * index), str(11 + 2 * index) graph[encoder] = {"class_type": encoder_class, "inputs": {"text": text}} graph[sampler] = { @@ -93,6 +114,7 @@ def _png( *, graph: dict | None = None, parameters: str | None = None, + size: tuple[int, int] = (96, 64), ) -> None: info = PngInfo() if parameters is not None: @@ -100,7 +122,7 @@ def _png( else: info.add_text("prompt", json.dumps(graph or _graph())) path.parent.mkdir(parents=True, exist_ok=True) - with Image.new("RGB", (96, 64), colour) as image: + with Image.new("RGB", size, colour) as image: image.save(path, pnginfo=info) @@ -165,6 +187,7 @@ def seed_review( items: int, special: tuple = (), monitor: _Monitor | None = None, + concept: bool = False, ) -> dict: """Replace this server's disposable rows and files with ``items`` queued files.""" with api.db.model.get_connection() as conn: @@ -179,7 +202,15 @@ def seed_review( files = [] for number in range(items): relative = f"review/review_{number:04}.png" - _png(output / relative, (40 + number * 4, 90, 140)) + options = {} + if concept: + name, size = CONCEPT_IMAGES[number % len(CONCEPT_IMAGES)] + relative = f"review/{name}" if number < len(CONCEPT_IMAGES) else relative + graph = _graph( + positives=(CONCEPT_POSITIVE, POSITIVES[1]), negative=CONCEPT_NEGATIVE + ) + options = {"graph": graph, "size": size} + _png(output / relative, (40 + number * 4, 90, 140), **options) _stamp(output / relative, 60 + number) _record(api, output / relative, "recorded") files.append({"path": relative, "filename": Path(relative).name}) @@ -267,7 +298,7 @@ def register_review_commands( if ( request.query or not isinstance(data, dict) - or set(data) - {"items", *SPECIAL} + or set(data) - {"items", "concept", *SPECIAL} ): raise web.HTTPBadRequest() items = data.get("items", 3) @@ -275,11 +306,20 @@ def register_review_commands( raise web.HTTPBadRequest() if not 0 <= items <= MAX_ITEMS: raise web.HTTPBadRequest() - if any(not isinstance(data.get(kind, False), bool) for kind in SPECIAL): + if any( + not isinstance(data.get(kind, False), bool) + for kind in (*SPECIAL, "concept") + ): raise web.HTTPBadRequest() special = tuple(kind for kind in SPECIAL if data.get(kind)) manifest = await api._run_in_executor( - seed_review, api, root, items, special, monitor() + seed_review, + api, + root, + items, + special, + monitor(), + data.get("concept", False), ) return web.json_response({"success": True, **manifest}) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index e40c8d1..d72e321 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -391,7 +391,7 @@ }, "workspace/review-state.mjs": { "lines": 95, - "branches": 82, + "branches": 84, "functions": 98, "statements": 95 }, diff --git a/web/js/workspace/review.mjs b/web/js/workspace/review.mjs index c15dca1..1dade98 100644 --- a/web/js/workspace/review.mjs +++ b/web/js/workspace/review.mjs @@ -75,7 +75,7 @@ export function readyVersion(event) { * time the pending total the page shows changes (the shell's Needs review count); `controller` and `connect` (the * change stream's EventSource) are injectable for tests. */ -export function mountReview(main, { api, scope, onDecision = () => {}, onTotal = () => {}, +export function mountReview(main, { api, scope, onDecision = () => {}, onTotal, connect = openEvents, controller = createReviewController({ api }) }) { scope.cleanup(() => controller.dispose()); const heading = pageHeading(main); @@ -264,7 +264,7 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal = const reportTotal = state => { if (state.status !== 'ready' || state.phase === 'done' || state.total === shownTotal) return; shownTotal = state.total; - onTotal(state.total); + onTotal?.(state.total); }; scope.cleanup(controller.subscribe(render)); scope.cleanup(controller.subscribe(reportTotal)); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 7367d69..34db835 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -383,6 +383,7 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc /* #249 D20: the prompt blocks' mount point is transparent, so the chosen path's sections sit in the panel as the concept's do. */ .review-panel > .prompt-inspector { display: contents; /* D20 */ } -/* D119: a file that cannot be served (no current root) has an inert, empty media button. */ -.review-queue .image-open[aria-disabled="true"] { cursor: not-allowed; } +/* D119: a file that cannot be served (no current root) has an inert, empty media button that keeps the concept's + 1.33 media area (app.js `.image-open img`), so its card lines up with the queue's other cards. */ +.review-queue .image-open[aria-disabled="true"] { cursor: not-allowed; aspect-ratio: 1.33; /* D119 */ } .review-panel > p.small { min-inline-size: 0; overflow-wrap: anywhere; }