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..38535029 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,84 @@ 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'); + 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); + 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})