Merge remote-tracking branch 'origin/main' into feat/331-collections-pr2
#332 PR 3 (#428) hands a Tags & categories Browse to the next Images mount; this branch mounts Images as a collection page too. Both imports are kept; a collection page neither restores the last visit nor takes the Browse hand-off (it is not the Images page), and the Images page keeps both the handed category and the collection filter.
This commit is contained in:
@@ -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)};
|
||||
|
||||
@@ -136,7 +136,8 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
const badges=panel.locator('.detail-inner > .tags').first();
|
||||
await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection');
|
||||
// #332 MC8: the chip names where its value came from, in visible text after the category.
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
|
||||
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
|
||||
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
@@ -185,6 +186,53 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']);
|
||||
});
|
||||
|
||||
// #332 CUJ 4, CUJ 5 (MC8, D146): every category chip names its origin: the image's own, the prompt's or the recipe's.
|
||||
const originChip=scope=>scope.locator('span.chip:has(> span.chip-source)');
|
||||
async function expectOrigin(scope,{prefix,category,origin}) {
|
||||
await expect(originChip(scope)).toHaveText(`${prefix}${category} · ${origin}`);
|
||||
await expect(originChip(scope).locator('.ca-visually-hidden')).toHaveText(prefix);
|
||||
expect(await originChip(scope).locator('.chip-source').evaluate(node=>node.textContent)).toBe(` · ${origin}`);
|
||||
}
|
||||
async function putOwnCategory(request,paths,category) {
|
||||
const {images}=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40')).json();
|
||||
const items=paths.map(path=>images.find(image=>image.root_index===0&&image.path===path)).map(({root_index,root_scope,path})=>({root_index,root_scope,path}));
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items,category}});await expect(response).toBeOK();
|
||||
}
|
||||
|
||||
test('the category chip names its origin in the side panel and the viewer (#332 CUJ 4, CUJ 5)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
// The positive's own category, set through the legacy prompt route; complete.png's recipe keeps "Inspection".
|
||||
const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[id],category:'Landscapes'}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');const panel=inspector(page);
|
||||
await selectFile(page,'complete.png');await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
await expectOrigin(panel,{prefix:'Recipe category: ',category:'Inspection',origin:'recipe'});
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.detail-inner > p.small')).toHaveText('workflow-only.png');
|
||||
await expectOrigin(panel,{prefix:'Positive prompt category: ',category:'Landscapes',origin:'prompt'});
|
||||
// An unlinked file shows no category until it has its own.
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(originChip(panel)).toHaveCount(0);
|
||||
// Own categories: one overrides the prompt's on a linked file, one sits on an unlinked file.
|
||||
await putOwnCategory(request,['inspector/workflow-only.png'],'Portraits');await putOwnCategory(request,['inspector/empty.png'],'Scans');
|
||||
await page.reload();
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.detail-inner > p.small')).toHaveText('workflow-only.png');
|
||||
await expectOrigin(panel,{prefix:'Image category: ',category:'Portraits',origin:'this image'});
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expectOrigin(panel,{prefix:'Image category: ',category:'Scans',origin:'this image'});
|
||||
await selectFile(page,'complete.png');await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
await expectOrigin(panel,{prefix:'Recipe category: ',category:'Inspection',origin:'recipe'});
|
||||
// The viewer's full details show the same chips.
|
||||
let details=await viewerDetails(page,'workflow-only.png');
|
||||
await expectOrigin(details,{prefix:'Image category: ',category:'Portraits',origin:'this image'});
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);
|
||||
details=await viewerDetails(page,'complete.png');
|
||||
await expectOrigin(details,{prefix:'Recipe category: ',category:'Inspection',origin:'recipe'});
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);
|
||||
details=await viewerDetails(page,'empty.png');
|
||||
await expectOrigin(details,{prefix:'Image category: ',category:'Scans',origin:'this image'});
|
||||
});
|
||||
|
||||
test('tag chip filters Images by that tag',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
|
||||
@@ -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/<id>) 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)=>{
|
||||
|
||||
@@ -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 x<y?-1:x>y?1:a<b?-1:a>b?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. */
|
||||
@@ -470,6 +472,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)=>{
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -288,7 +299,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([]);
|
||||
@@ -322,8 +334,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']);
|
||||
@@ -400,6 +414,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/);
|
||||
|
||||
@@ -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}};
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -495,6 +495,30 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
|
||||
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
|
||||
// nothing and shows no chip before.
|
||||
test('the current file shows its own category with its origin in the recorded run and its viewer',async({page,request})=>{
|
||||
const chip=scope=>scope.locator('span.chip:has(> span.chip-source)');
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
|
||||
const viewCurrent=async()=>{await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();return details;};
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
let details=await viewCurrent();await expect(details.locator('.panel-heading h2')).toBeVisible();await expect(chip(details)).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');await expect(current).toBeVisible();await expect(chip(current)).toHaveCount(0);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
const {images}=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=history&limit=40')).json();
|
||||
const file=images.find(image=>image.root_index===0&&image.path==='history/current.png');
|
||||
const put=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope:file.root_scope,path:file.path}],category:'Portraits'}});
|
||||
await expect(put).toBeOK();expect((await put.json()).updated).toBe(1);
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
details=await viewCurrent();
|
||||
await expect(chip(details)).toHaveText('Image category: Portraits · this image');
|
||||
await page.keyboard.press('Escape');await expect(current).toBeVisible();
|
||||
await expect(chip(current)).toHaveText('Image category: Portraits · this image');
|
||||
expect(await chip(current).locator('.chip-source').evaluate(node=>node.textContent)).toBe(' · this image');
|
||||
});
|
||||
|
||||
// LI-4 (D117): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary.
|
||||
test('the current file viewer copies its settings summary',async({page})=>{
|
||||
await page.addInitScript(()=>{window.__copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copied.push(text);}}});});
|
||||
|
||||
@@ -1988,70 +1988,286 @@ test('Export metadata downloads nothing when no image can be read', async ({ pag
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
});
|
||||
|
||||
// #249 D8 follow-up (image selection, Task 3): Set category sets the linked positives' category (app.js simpleInput).
|
||||
// #332 (D140, MC6, MC7): Set category writes each selected image's own category (PUT /prompt_manager/images/category);
|
||||
// "Use prompt category" removes it after a confirmation (DELETE). It never writes a prompt: Images no longer posts
|
||||
// /prompt_manager/bulk/category (#259's prompt action is retired from Images).
|
||||
const categoryButton = page => bulkbar(page).getByRole('button', { name: 'Set category', exact: true });
|
||||
const categoryDialog = page => page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
|
||||
const categoryRoute = '**/prompt_manager/bulk/category';
|
||||
/** Unlink the input root's card by deleting its prompt; returns the output card's positive id. */
|
||||
async function unlinkInputCard(request) {
|
||||
const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace')).json();
|
||||
const byKey = Object.fromEntries(images.map(image => [`${image.root_index}:${image.path}`, image]));
|
||||
const [output, input] = [byKey['0:shared/same.png'], byKey['1:shared/same.png']];
|
||||
expect([output.linked.status, input.linked.status]).toEqual(['linked', 'linked']);
|
||||
await expect(await request.delete(`/prompt_manager/delete/${input.linked.positive_id}`)).toBeOK();
|
||||
return output.linked.positive_id;
|
||||
const removeDialog = page => page.getByRole('dialog', { name: 'Use prompt category', exact: true });
|
||||
const categoryFilter = page => page.getByRole('combobox', { name: 'Filter by category', exact: true });
|
||||
const categoryRoute = '**/prompt_manager/images/category';
|
||||
const IMAGE_CATEGORY = '/prompt_manager/images/category';
|
||||
const cardOf = (page, key) => page.locator(`article[data-image-key="${key}"]`);
|
||||
const keyOf = image => `${image.root_index}:${image.path}`;
|
||||
const cardKeys = page => cards(page).evaluateAll(nodes => nodes.map(node => node.dataset.imageKey).sort());
|
||||
// The four outputs of "Cat portrait" (CUJ 1's batch); the input root's same.png is linked to "Dog landscape".
|
||||
const BATCH = ['0:bulk/0000.png', '0:bulk/0001.png', '0:bulk/0002.png', '0:shared/same.png'];
|
||||
/** Every category write the page sends, Images' own route and #259's prompt route alike. */
|
||||
function categoryWrites(page) {
|
||||
const writes = [];
|
||||
page.on('request', item => {
|
||||
const path = new URL(item.url()).pathname;
|
||||
if (item.method() === 'GET' || !['/prompt_manager/images/category', '/prompt_manager/bulk/category'].includes(path)) return;
|
||||
writes.push({ method: item.method(), path, body: item.postDataJSON() });
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
async function listedImages(request, query = '') {
|
||||
const response = await request.get(`/prompt_manager/images/output?mode=workspace&limit=40${query}`);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).images;
|
||||
}
|
||||
async function targetsOf(request, keys) {
|
||||
const images = await listedImages(request);
|
||||
return keys.map(key => {
|
||||
const image = images.find(item => keyOf(item) === key);
|
||||
return { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
|
||||
});
|
||||
}
|
||||
async function putOwnCategory(request, keys, category) {
|
||||
const response = await request.put(IMAGE_CATEGORY, { data: { items: await targetsOf(request, keys), category } });
|
||||
await expect(response).toBeOK();
|
||||
}
|
||||
const filteredKeys = async (request, category) => (await listedImages(request, `&category=${encodeURIComponent(category)}`)).map(keyOf).sort();
|
||||
async function positiveOf(request, id) {
|
||||
const response = await request.get(`/prompt_manager/library/${id}`);
|
||||
await expect(response).toBeOK();
|
||||
const { positive } = await response.json();
|
||||
return { id: positive.id, text: positive.text, category: positive.category };
|
||||
}
|
||||
/** CUJ 1's Given: four images linked to the positive "Cat portrait", whose category is "Landscapes". */
|
||||
async function seedBatch(request) {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
|
||||
const response = await request.post('/prompt_manager/save', { data: { text: 'Cat portrait', category: 'Landscapes' } });
|
||||
await expect(response).toBeOK();
|
||||
const id = (await response.json()).prompt_id;
|
||||
expect(await filteredKeys(request, 'Landscapes')).toEqual(BATCH);
|
||||
return id;
|
||||
}
|
||||
async function openSetCategory(page) {
|
||||
await categoryButton(page).click();
|
||||
await expect(categoryDialog(page)).toBeVisible();
|
||||
return categoryDialog(page);
|
||||
}
|
||||
async function applyCategory(page, category) {
|
||||
const dialog = await openSetCategory(page);
|
||||
await dialog.getByLabel('Category', { exact: true }).fill(category);
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
}
|
||||
|
||||
test('Set category applies the category to the linked positives and the category filter then matches', async ({ page, request }) => {
|
||||
const outputPositive = await unlinkInputCard(request);
|
||||
test('Set category overrides one image of a batch and never writes its prompt (#332 CUJ 1)', async ({ page, request }) => {
|
||||
const positiveId = await seedBatch(request);
|
||||
const before = await positiveOf(request, positiveId);
|
||||
const writes = categoryWrites(page);
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const opener = categoryButton(page);
|
||||
await opener.click();
|
||||
const dialog = categoryDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
// The concept's simpleInput: one field and Cancel / Apply (primary).
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await checkbox(cardOf(page, '0:bulk/0001.png'), '0001.png').check();
|
||||
const dialog = await openSetCategory(page);
|
||||
// The concept's simpleInput plus "Use prompt category" between Cancel and Apply (primary).
|
||||
await expect(dialog.locator('#modal-body > .field > label[for="action-input"]')).toHaveText('Category');
|
||||
await expect(dialog.locator('#modal-body > .field > input#action-input')).toHaveValue('');
|
||||
await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Apply']);
|
||||
await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Use prompt category', 'Apply']);
|
||||
await expect(dialog.getByRole('button', { name: 'Apply', exact: true })).toHaveClass(/ca-primary/);
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toBeFocused();
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('maxlength', '100');
|
||||
// An empty value applies nothing and keeps the dialog open.
|
||||
const posts = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/bulk/category')) posts.push(item.postDataJSON()); });
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Category', { exact: true }).fill(' Landscapes ');
|
||||
await dialog.getByLabel('Category', { exact: true }).fill(' Portraits ');
|
||||
const listings = [];
|
||||
page.on('request', item => { if (imagePages(new URL(item.url()))) listings.push(item.url()); });
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(toast(page)).toHaveText('Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
|
||||
expect(posts).toEqual([{ prompt_ids: [outputPositive], category: 'Landscapes' }]);
|
||||
await expect(toast(page)).toHaveText('Category “Portraits” set on 1 image.');
|
||||
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY,
|
||||
body: { items: await targetsOf(request, ['0:bulk/0001.png']), category: 'Portraits' } }]);
|
||||
// The listing and the category options reload; the reload clears the selection.
|
||||
await expect.poll(() => listings.length).toBeGreaterThan(0);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
const filter = page.getByRole('combobox', { name: 'Filter by category', exact: true });
|
||||
await expect(filter.locator('option')).toContainText(['Landscapes']);
|
||||
await filter.selectOption('Landscapes');
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes', 'Portraits']);
|
||||
await categoryFilter(page).selectOption('Portraits');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
|
||||
await categoryFilter(page).selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
expect(await cardKeys(page)).toEqual(BATCH.filter(key => key !== '0:bulk/0001.png'));
|
||||
// The prompt keeps its category: only the image carries "Portraits".
|
||||
expect(await positiveOf(request, positiveId)).toEqual(before);
|
||||
expect(before.category).toBe('Landscapes');
|
||||
expect(writes.filter(write => write.path === '/prompt_manager/bulk/category')).toEqual([]);
|
||||
});
|
||||
|
||||
test('Set category without a linked image posts nothing and opens no dialog', async ({ page, request }) => {
|
||||
await unlinkInputCard(request);
|
||||
test('Set category categorizes unlinked images and lists the value in the toolbar and the suggestions (#332 CUJ 2)', async ({ page, request }) => {
|
||||
// Unlink both images by deleting their prompts.
|
||||
for (const image of await listedImages(request)) {
|
||||
await expect(await request.delete(`/prompt_manager/delete/${image.linked.positive_id}`)).toBeOK();
|
||||
}
|
||||
expect((await listedImages(request)).filter(image => image.linked.status === 'linked')).toEqual([]);
|
||||
const writes = categoryWrites(page);
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
await applyCategory(page, 'Scans');
|
||||
await expect(toast(page)).toHaveText('Category “Scans” set on 2 images.');
|
||||
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY,
|
||||
body: { items: await targetsOf(request, ['0:shared/same.png', '1:shared/same.png']), category: 'Scans' } }]);
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Scans']);
|
||||
await categoryFilter(page).selectOption('Scans');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
let posted = 0;
|
||||
await page.route(categoryRoute, route => { posted += 1; return route.continue(); });
|
||||
await checkbox(cards(page).last(), 'same.png').check();
|
||||
await categoryButton(page).click();
|
||||
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
expect(await cardKeys(page)).toEqual(['0:shared/same.png', '1:shared/same.png']);
|
||||
// The field suggests the toolbar's categories through its own datalist.
|
||||
await checkbox(cards(page).first(), 'same.png').check();
|
||||
const dialog = await openSetCategory(page);
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('list', 'workspace-input-suggestions');
|
||||
expect(await dialog.locator('datalist#workspace-input-suggestions option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['Scans']);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
expect(posted).toBe(0);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Use prompt category removes the own category only after Remove in its confirmation (#332 CUJ 3)', async ({ page, request }) => {
|
||||
const positiveId = await seedBatch(request);
|
||||
await putOwnCategory(request, ['0:bulk/0001.png'], 'Portraits');
|
||||
const writes = categoryWrites(page);
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes', 'Portraits']);
|
||||
await checkbox(cardOf(page, '0:bulk/0001.png'), '0001.png').check();
|
||||
// Cancel in the confirmation sends nothing and keeps the selection.
|
||||
await (await openSetCategory(page)).getByRole('button', { name: 'Use prompt category', exact: true }).click();
|
||||
await expect(categoryDialog(page)).toHaveCount(0);
|
||||
const confirm = removeDialog(page);
|
||||
await expect(confirm).toBeVisible();
|
||||
await expect(confirm.locator('p').first()).toHaveText("Remove the image category from the 1 selected image? Each one then shows its prompt's category, or none if it has no linked prompt. This cannot be undone.");
|
||||
await expect(confirm.locator('.dialog-actions > button')).toHaveText(['Cancel', 'Remove']);
|
||||
await expect(confirm.getByRole('button', { name: 'Remove', exact: true })).toHaveClass(/danger/);
|
||||
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
|
||||
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(selectedCount(page)).toHaveText('1 selected');
|
||||
expect(writes).toEqual([]);
|
||||
expect(await filteredKeys(request, 'Portraits')).toEqual(['0:bulk/0001.png']);
|
||||
// Remove sends exactly one DELETE naming the selected image.
|
||||
await (await openSetCategory(page)).getByRole('button', { name: 'Use prompt category', exact: true }).click();
|
||||
await removeDialog(page).getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Removed the image category from 1 image.');
|
||||
expect(writes).toEqual([{ method: 'DELETE', path: IMAGE_CATEGORY, body: { items: await targetsOf(request, ['0:bulk/0001.png']) } }]);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
// The image shows its prompt's category again: "Landscapes" lists all four, and "Portraits" is gone.
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes']);
|
||||
await categoryFilter(page).selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(4);
|
||||
expect(await cardKeys(page)).toEqual(BATCH);
|
||||
expect((await positiveOf(request, positiveId)).category).toBe('Landscapes');
|
||||
});
|
||||
|
||||
test('a saved view filtered by a category lists an image categorized after it was saved (#332 CUJ 8)', async ({ page, request }) => {
|
||||
await seedBatch(request);
|
||||
await putOwnCategory(request, ['0:bulk/0001.png'], 'Portraits');
|
||||
await page.goto(workspace);
|
||||
await categoryFilter(page).selectOption('Portraits');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Portraits');
|
||||
await save.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
// Another image gets "Portraits" through Set category.
|
||||
await categoryFilter(page).selectOption('');
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await checkbox(cardOf(page, '0:bulk/0002.png'), '0002.png').check();
|
||||
await applyCategory(page, 'Portraits');
|
||||
await expect(toast(page)).toHaveText('Category “Portraits” set on 1 image.');
|
||||
// Reopening the view lists both.
|
||||
await page.getByRole('navigation', { name: 'Saved views', exact: true }).getByRole('link', { name: 'Portraits', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Portraits');
|
||||
await expect(categoryFilter(page)).toHaveValue('Portraits');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
expect(await cardKeys(page)).toEqual(['0:bulk/0001.png', '0:bulk/0002.png']);
|
||||
});
|
||||
|
||||
test('at 390px in light theme the Set category dialog and the category chip fit (#332 CUJ 10)', async ({ page, request }) => {
|
||||
const LONG = 'Portraits taken on a phone at the end of a long walk through the old harbour at dusk';
|
||||
await page.addInitScript(() => { try { localStorage.setItem('pm_workspace_theme', 'light'); } catch { /* the default theme */ } });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await seedBatch(request);
|
||||
await putOwnCategory(request, ['0:bulk/0001.png'], LONG);
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
|
||||
const card = cardOf(page, '0:bulk/0001.png');
|
||||
// The chip wraps inside the side panel; its origin never splits.
|
||||
await card.getByRole('button', { name: 'Inspect 0001.png', exact: true }).click();
|
||||
const chip = details(page).locator('span.chip', { has: page.locator('span.chip-source') });
|
||||
await expect(chip).toHaveText(`Image category: ${LONG} · this image`);
|
||||
const fit = await chip.evaluate(node => {
|
||||
const panel = node.closest('aside, [role="complementary"]').getBoundingClientRect(), box = node.getBoundingClientRect();
|
||||
const source = node.querySelector('.chip-source');
|
||||
const line = parseFloat(getComputedStyle(source).lineHeight) || source.getBoundingClientRect().height;
|
||||
return { inside: box.left >= panel.left - 0.5 && box.right <= panel.right + 0.5, sourceLines: source.getClientRects().length,
|
||||
wrapped: box.height > line * 1.5, pageScroll: document.documentElement.scrollWidth - window.innerWidth };
|
||||
});
|
||||
expect(fit).toEqual({ inside: true, sourceLines: 1, wrapped: true, pageScroll: 0 });
|
||||
// The dialog fits the width with no horizontal scroll, and its three buttons wrap without overlap.
|
||||
await checkbox(card, '0001.png').check();
|
||||
const dialog = await openSetCategory(page);
|
||||
const geometry = await dialog.evaluate(node => {
|
||||
const box = node.getBoundingClientRect();
|
||||
const buttons = [...node.querySelectorAll('.dialog-actions > button')].map(button => button.getBoundingClientRect())
|
||||
.map(({ left, right, top, bottom }) => ({ left, right, top, bottom }));
|
||||
const overlaps = buttons.some((a, i) => buttons.some((b, j) => j > i && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom));
|
||||
const scrollers = [node, ...node.querySelectorAll('*')].filter(item => item.scrollWidth > item.clientWidth + 1 && getComputedStyle(item).overflowX !== 'visible');
|
||||
return { fits: box.left >= 0 && box.right <= window.innerWidth, scrollers: scrollers.length, count: buttons.length, overlaps,
|
||||
inside: buttons.every(b => b.left >= box.left && b.right <= box.right), pageScroll: document.documentElement.scrollWidth - window.innerWidth };
|
||||
});
|
||||
expect(geometry).toEqual({ fits: true, scrollers: 0, count: 3, overlaps: false, inside: true, pageScroll: 0 });
|
||||
});
|
||||
|
||||
test('Set category of more than 500 images says so before any dialog or request (#332)', async ({ page, request }) => {
|
||||
await page.route(url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('limit') !== '1', async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
await route.fulfill({ response, json: { ...body, total: 501 } });
|
||||
});
|
||||
await bulkReset(page, request);
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 501 matching', exact: true }).click();
|
||||
await expect(selectedCount(page)).toHaveText('All 501 matching selected');
|
||||
const writes = categoryWrites(page);
|
||||
await categoryButton(page).click();
|
||||
await expect(toast(page)).toHaveText('Select at most 500 images.');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
expect(writes).toEqual([]);
|
||||
await expect(selectedCount(page)).toHaveText('All 501 matching selected');
|
||||
});
|
||||
|
||||
test('a changed source says so and reloads the listing (#332)', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
await page.route(categoryRoute, route => route.fulfill({ status: 409,
|
||||
json: { success: false, code: 'source-changed', error: 'The output sources changed. Refresh the images and try again.' } }));
|
||||
const reloaded = page.waitForRequest(item => imagePages(new URL(item.url())));
|
||||
await applyCategory(page, 'Landscapes');
|
||||
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
|
||||
await reloaded;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// The reload hides the bar with Set category in it: focus moves to the page heading, as after a success.
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
});
|
||||
|
||||
test('a recipe-linked image is listed under its recipe\'s category (#332 D142)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { native_library: true } })).toBeOK();
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Animals', 'Experiments', 'Portraits']);
|
||||
// The output same.png is linked to recipe "Portrait variant 1" (Portraits), the input copy to variant 2 (Experiments);
|
||||
// only the image without a recipe shows its positive's "Animals".
|
||||
for (const [category, keys] of [['Portraits', ['0:shared/same.png']], ['Experiments', ['1:shared/same.png']], ['Animals', ['0:legacy-unassigned.png']]]) {
|
||||
await categoryFilter(page).selectOption(category);
|
||||
await expect(cards(page)).toHaveCount(keys.length);
|
||||
expect(await cardKeys(page)).toEqual(keys);
|
||||
}
|
||||
});
|
||||
|
||||
test('Set category reports a failure, keeps the selection and ignores a second press while it runs', async ({ page }) => {
|
||||
@@ -2059,9 +2275,7 @@ test('Set category reports a failure, keeps the selection and ignores a second p
|
||||
await checkBoth(page);
|
||||
const held = [];
|
||||
await page.route(categoryRoute, route => { held.push(route); });
|
||||
await categoryButton(page).click();
|
||||
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await applyCategory(page, 'Landscapes');
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await expect(categoryButton(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
// Playwright waits on an aria-disabled control, so the second press is a dispatched click.
|
||||
@@ -2098,14 +2312,12 @@ test('leaving Images during Set category finishes the write, and the return relo
|
||||
await checkBoth(page);
|
||||
const held = [];
|
||||
await page.route(categoryRoute, route => { held.push(route); });
|
||||
await categoryButton(page).click();
|
||||
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await applyCategory(page, 'Landscapes');
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// The write is not aborted with the page: the server still receives and answers it.
|
||||
const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/bulk/category'));
|
||||
const answered = page.waitForResponse(response => new URL(response.url()).pathname === IMAGE_CATEGORY);
|
||||
await held[0].continue();
|
||||
expect((await answered).ok()).toBe(true);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
@@ -2116,23 +2328,25 @@ test('leaving Images during Set category finishes the write, and the return relo
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true }).locator('option')).toContainText(['Landscapes']);
|
||||
await expect(categoryFilter(page).locator('option')).toContainText(['Landscapes']);
|
||||
});
|
||||
|
||||
test('Set category limits the field to the server length, shows the server count, and a rejection keeps the selection', async ({ page }) => {
|
||||
test('Set category shows the server counts and coded refusals, and a rejection keeps the selection', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const answers = [
|
||||
{ status: 400, json: { success: false, error: 'Category cannot exceed 100 characters' } },
|
||||
{ status: 200, json: { success: true, updated_count: 0 } },
|
||||
{ status: 404, json: { success: false, code: 'files-not-found', error: 'None of the images could be found.' } },
|
||||
{ status: 200, json: { success: true, category: 'Landscapes', updated: 1, unchanged: 0, not_found: 1 } },
|
||||
];
|
||||
await page.route(categoryRoute, route => route.fulfill(answers.shift()));
|
||||
for (const expected of ['Category could not be set. Try again.', 'Category set on 0 prompts.']) {
|
||||
await categoryButton(page).click();
|
||||
const field = categoryDialog(page).getByLabel('Category', { exact: true });
|
||||
await expect(field).toHaveAttribute('maxlength', '100');
|
||||
await field.fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
for (const expected of ['Category could not be set. Try again.',
|
||||
'None of the selected images could be found. Refresh the images and try again.',
|
||||
'Category “Landscapes” set on 1 image. 1 could not be found.']) {
|
||||
const dialog = await openSetCategory(page);
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('maxlength', '100');
|
||||
await dialog.getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(expected);
|
||||
if (answers.length) await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
}
|
||||
@@ -2381,24 +2595,20 @@ test('all matching sets the category through the listing filter and survives a r
|
||||
// Export metadata waits for loaded images: a press does nothing.
|
||||
const exportButton = bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true });
|
||||
await expect(exportButton).toHaveAttribute('title', 'Export metadata reads each image in the browser, so it works on loaded images only.');
|
||||
// No match linked (a stubbed answer): the nothing-linked sentence, and the selection stays.
|
||||
await page.route('**/prompt_manager/bulk/category', route => route.fulfill({ json: { success: true, updated_count: 0, prompt_count: 0, skipped: 202 } }), { times: 1 });
|
||||
const categorize = page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
|
||||
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392');
|
||||
await categorize.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
// No match listed any more (a stubbed answer): the not-found sentence, and the selection stays.
|
||||
await page.route(categoryRoute, route => route.fulfill({ status: 404, json: { success: false, code: 'files-not-found', error: 'None of the images could be found.' } }), { times: 1 });
|
||||
await applyCategory(page, 'Bulk392');
|
||||
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
// The real write: one request with the filter, every matching image's positive changes.
|
||||
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392');
|
||||
const posted = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/bulk/category');
|
||||
await categorize.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
expect((await posted).postDataJSON()).toEqual({ images_filter: { sort: 'newest' }, category: 'Bulk392' });
|
||||
await expect(toast(page)).toHaveText(/^Category set on \d+ prompts?\.$/);
|
||||
// The real write: one PUT with the listing filter sets every matching image's own category; no prompt changes.
|
||||
const writes = categoryWrites(page);
|
||||
await applyCategory(page, 'Bulk392');
|
||||
await expect(toast(page)).toHaveText('Category “Bulk392” set on 202 images.');
|
||||
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY, body: { filter: { sort: 'newest' }, category: 'Bulk392' } }]);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
const filtered = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&category=Bulk392')).json();
|
||||
expect(filtered.total).toBe(202);
|
||||
expect((await (await request.get('/prompt_manager/library/options')).json()).categories).not.toContain('Bulk392');
|
||||
// A return visit keeps "all matching" as the loaded images, and the offer shows again.
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
@@ -2426,10 +2636,12 @@ test('all matching over 10,000 images says so before any dialog or request', asy
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 20,002 matching', exact: true }).click();
|
||||
await expect(selectedCount(page)).toHaveText('All 20,002 matching selected');
|
||||
let posted = 0;
|
||||
page.on('request', item => { if (item.method() === 'POST') posted += 1; });
|
||||
for (const action of ['Set category', 'Add tags', 'Auto-tag selected']) {
|
||||
page.on('request', item => { if (item.method() !== 'GET') posted += 1; });
|
||||
// Set category names at most 500 images (#332); the tagging jobs take up to 10,000.
|
||||
for (const [action, sentence] of [['Set category', 'Select at most 500 images.'], ['Add tags', 'Narrow the view to at most 10,000 images.'],
|
||||
['Auto-tag selected', 'Narrow the view to at most 10,000 images.']]) {
|
||||
await bulkbar(page).getByRole('button', { name: action, exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Narrow the view to at most 10,000 images.');
|
||||
await expect(toast(page)).toHaveText(sentence);
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
expect(posted).toBe(0);
|
||||
|
||||
@@ -383,6 +383,62 @@ test('manage categories previews real impact before applying',async({page,reques
|
||||
expect(after.prompts.filter(row=>row[2]==='Scenery')).toHaveLength(3);
|
||||
});
|
||||
|
||||
// #332 MC9, MC10: image categories (their own, set on Images) count beside the prompts. The vocabulary fixture has no
|
||||
// image files, so these tests add two committed files to the output folder and set their categories through the API.
|
||||
async function imageCategories(request,category) {
|
||||
for(const name of ['f02.png','f03.png']) await expect(await request.post('/__test__/add-local-inspect-file',{data:{name}})).toBeOK();
|
||||
const listing=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=local-inspect&limit=40')).json();
|
||||
const items=listing.images.map(({root_index,root_scope,path})=>({root_index,root_scope,path}));expect(items).toHaveLength(2);
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items,category}});await expect(response).toBeOK();
|
||||
expect((await response.json()).updated).toBe(2);
|
||||
}
|
||||
const ownCategories=async request=>(await(await request.get('/prompt_manager/images/categories')).json()).categories;
|
||||
const IMAGE_KEYS=['0:local-inspect/f02.png','0:local-inspect/f03.png'];
|
||||
|
||||
test('manage categories renames the images\' own categories too and the preview counts them (#332 CUJ 6)',async({page,request})=>{
|
||||
await imageCategories(request,'Landscapes');
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
await expect(categoryRow(page,'Landscapes').locator('p')).toHaveText('3 positive prompts · 2 images');
|
||||
const box=await openManage(page);
|
||||
await expect(field(box,'Category')).toHaveValue('Landscapes');
|
||||
await field(box,'Proposed new category name').fill('Landscape');await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Landscape”.');
|
||||
await button(box,'Apply').click();
|
||||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Category renamed.');
|
||||
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/apply',body:{source:'Landscapes',target:'Landscape'}});
|
||||
await expect(categoryRow(page,'Landscapes')).toHaveCount(0);
|
||||
await expect(categoryRow(page,'Landscape').locator('p')).toHaveText('3 positive prompts · 2 images');
|
||||
// The prompts and both images carry the new name; nothing keeps the old one.
|
||||
expect((await snapshot(request)).prompts.filter(row=>row[2]==='Landscape')).toHaveLength(3);
|
||||
expect(await ownCategories(request)).toEqual(['Landscape']);
|
||||
for(const [category,total] of [['Landscape',2],['Landscapes',0]]) {
|
||||
const listing=await(await request.get(`/prompt_manager/images/output?mode=workspace&limit=40&category=${category}`)).json();
|
||||
expect(listing.images.map(image=>`${image.root_index}:${image.path}`).sort()).toEqual(total?IMAGE_KEYS:[]);
|
||||
}
|
||||
});
|
||||
|
||||
test('a category only images hold is listed after the prompt-backed ones and Browse opens Images (#332 CUJ 7)',async({page,request})=>{
|
||||
await imageCategories(request,'Scans');
|
||||
await openPage(page);
|
||||
const operations=surfaces(page).nth(1).locator('.operation');
|
||||
await expect(operations.locator('h3')).toHaveText([...CATEGORIES.map(([name])=>name),'Scans','Manage category']);
|
||||
await expect(categoryRow(page,'Scans').locator('p')).toHaveText('2 images');
|
||||
for(const [name,count] of CATEGORIES) await expect(categoryRow(page,name).locator('p')).toHaveText(`${count} positive prompt${count===1?'':'s'}`);
|
||||
// Manage category can rename it: its select lists it last.
|
||||
const box=await openManage(page);
|
||||
await expect(field(box,'Category').locator('option')).toHaveText([...CATEGORIES.map(([name])=>name),'Scans']);
|
||||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||||
// Browse opens Images filtered by it (its Library would be empty).
|
||||
await categoryRow(page,'Scans').getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Images');
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('Scans');
|
||||
const cards=page.locator('article[data-image-key]');await expect(cards).toHaveCount(2);
|
||||
expect((await cards.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).sort()).toEqual(IMAGE_KEYS);
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('');
|
||||
await expect(page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('an edit during a pending preview needs a new Preview; Apply never sends the old values',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
|
||||
@@ -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"
|
||||
@@ -114,8 +118,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
|
||||
@@ -169,6 +191,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()
|
||||
@@ -182,6 +205,7 @@ def seed_vocabulary(
|
||||
"archived_only_tag": ARCHIVED_ONLY_TAG,
|
||||
"negative_only_tag": NEGATIVE_ONLY_TAG,
|
||||
"unused_tag": UNUSED_TAG,
|
||||
"image_categories": images,
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +226,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)
|
||||
@@ -217,7 +248,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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user