Merge remote-tracking branch 'origin/main' into feat/247-foreign-workflows-pr2

# Conflicts:
#	TESTING.md
This commit is contained in:
Vito Sansevero
2026-10-28 15:34:09 -07:00
92 changed files with 6632 additions and 429 deletions
+31 -1
View File
@@ -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)};
+53 -2
View File
@@ -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;
@@ -676,6 +724,9 @@ test('collection viewer uses real public transforms and resets on navigation',as
async function slideshowFixture(page,request,preferences={videoAutoplay:false,videoMute:true,videoLoop:false}) {
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
await page.goto('/prompt_manager/workspace#/images');
// Install the fake clock before the fixture wraps setTimeout: the wrapped timer (which the viewer session captures)
// must be the fake one, so page.clock.runFor alone decides when the 3500 ms slideshow deadline fires.
await page.clock.install();
await page.evaluate(async preferences=>{
if(preferences) localStorage.setItem('gallerySettings',JSON.stringify(preferences));
const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([
@@ -695,7 +746,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
},preferences);
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png');
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
await page.clock.pauseAt(new Date(Date.now()+1000));
}
const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
+57 -1
View File
@@ -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)=>{
+117 -4
View File
@@ -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)=>{
+27 -6
View File
@@ -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/);
+9
View File
@@ -47,6 +47,7 @@ from tests.browser.workspace_library_editor_fixture import (
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
from tests.browser.workspace_tagging_fixture import register_tagging_commands
from tests.browser.workspace_review_fixture import register_review_commands
from tests.browser.workspace_collections_fixture import register_collections_commands
from tests.browser.workspace_diagnostics_fixture import (
register_diagnostics_commands,
repoint_logs,
@@ -75,6 +76,8 @@ class TestModel:
loaded = None
# Models the Tag image dialog workflows mark as not downloaded.
unavailable: set = set()
# Pip names per model whose backend packages "cannot be imported" (#424).
missing_packages: dict = {}
# Scored names the concept comparisons answer instead of the fixed list
# (``/__test__/tagging/suggestions``); None keeps the fixed list.
suggestions: list | None = None
@@ -82,10 +85,13 @@ class TestModel:
models_config: dict = {}
def get_models_status(self) -> dict:
from py.autotag import package_fields
return {
name: {
"downloaded": name not in self.unavailable,
"loaded": self.loaded == name,
**package_fields(name, self.missing_packages.get(name, [])),
}
for name in self.models_config
}
@@ -227,6 +233,7 @@ def create_app(root: Path) -> web.Application:
register_vocabulary_commands(routes, api, root)
register_tagging_commands(routes, api, root, model)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
reveal_stub = register_reveal_stub(routes, api, root)
register_activity_commands(app, routes, api)
register_system_commands(app, routes, api, root)
@@ -262,6 +269,8 @@ def create_app(root: Path) -> web.Application:
with target.db.model.get_connection() as conn:
for table in (
"saved_views",
"image_collection_items",
"image_collections",
"image_favorites",
"media_review_decisions",
"media_review_items",
+35 -7
View File
@@ -76,13 +76,41 @@ function savedNav(nav) {
return {...nav,rows:nav.rows.map(row=>row.text==='Needs review'?{...row,count:{...row.count,text:'0'}}:row)};
}
/**
* D136 (#331): the concept's COLLECTIONS rows (app.js a336–339) as production lists them, by name (DC1): the fixture's
* "Landscapes" and "Experiments" hold the concept's 12 and 8 images, so text and counts compare as they are and each
* place keeps the concept's row geometry. At <=950px the concept hides the nav, which would leave a phone no way to
* open a collection: production shows it as the concept lays out every other sidebar nav there (its SAVED VIEWS nav),
* its rows styled as that nav's rows. Text, widths and wrap gaps then follow the rows' own content.
*/
function collectionsNav(nav,navs) {
const byName=[...nav.rows].sort((a,b)=>a.text<b.text?-1:a.text>b.text?1:0);
if(nav.shown)return {...nav,rows:byName.map((row,index)=>({...row,gapAfter:nav.rows[index].gapAfter}))};
const saved=navs.find(item=>item.id==='saved-nav');
const {id,label,rows,...frame}=saved;
return {...nav,...frame,id:nav.id,label:nav.label,rows:byName.map(row=>({...rows[0],text:row.text,
count:row.count&&rows[0].count?{...rows[0].count,text:row.count.text}:rows[0].count,
icon:rows[0].icon&&row.icon?{...rows[0].icon,d:row.icon.d}:row.icon,
width:'(content)',hugs:'(content)',gapAfter:'(content)',current:null}))};
}
/** The production collections nav at <=950px with its content-sized values replaced, as collectionsNav maps them. */
function contentFree(navs) {
return navs.map(nav=>nav.id!=='collections-nav'||!nav.shown?nav:{...nav,
rows:nav.rows.map(row=>({...row,width:'(content)',hugs:'(content)',gapAfter:'(content)'}))});
}
/** D136: where the concept hides COLLECTIONS, the production rows' content-sized values are not compared. */
function comparableNavs(reference,navs) {
return reference.chrome.navs.find(nav=>nav.id==='collections-nav')?.shown?navs:contentFree(navs);
}
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
function mapShellConcept(reference) {
const {chrome}=reference;
const navs=chrome.navs
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
.filter(nav=>nav.id!=='collections-nav')
.map(nav=>{
if(nav.id==='collections-nav')return collectionsNav(nav,chrome.navs);
if(nav.id==='saved-nav')return savedNav(nav);
if(nav.id!=='system-nav')return nav;
const system=rowNamed(nav,'System');
@@ -99,9 +127,8 @@ function mapShellConcept(reference) {
return {...reference,chrome:{...chrome,
// D29: no prototype suffix in the document title.
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
// D49: SAVED VIEWS without its "PROPOSED" badge. D136: COLLECTIONS without it either.
labels:chrome.labels.map(label=>label.text.endsWith(' PROPOSED')?{...label,text:label.text.replace(/ PROPOSED$/,'')}:label),
navs,
connection:{...chrome.connection,
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
@@ -134,8 +161,9 @@ function headingFrame(heading) {
function compare(route,reference,actual) {
const expected=mapShellConcept(reference);
const heading=FULL_HEADING.has(route);
const chrome={...actual.chrome,navs:comparableNavs(reference,actual.chrome.navs)};
return [
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome,noPageScroll:actual.noPageScroll}),
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
.map(item=>({...item,path:`heading.${item.path}`})),
];
@@ -242,7 +270,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
await openProduction(page,'images','dark');
const actual=await save('Save','#workspace-main');
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
expect(diffInventory(mapShellConcept(reference).chrome.navs,comparableNavs(reference,actual.chrome.navs))).toEqual([]);
const referenceRows=reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows;
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
.toEqual([['Favorites',referenceRows[0].count],['Needs review',{...referenceRows[1].count,text:'0'}],['Golden hour',null]]);
@@ -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}};
}
+88 -1
View File
@@ -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([]);
});
@@ -0,0 +1,493 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
// Collections (#331): the sidebar COLLECTIONS group (D136, concept index.html 28–31, app.js 336–339), the collection
// page `#/collection/<id>` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140).
const workspace = '/prompt_manager/workspace';
const API = '/prompt_manager/collections';
const main = page => page.locator('#workspace-main');
const heading = page => main(page).getByRole('heading', { level: 1 });
const breadcrumb = page => page.locator('#workspace-breadcrumb');
const description = page => main(page).locator('.heading p.muted');
const missingNote = page => main(page).locator('.heading p.small');
const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true });
const imageCards = page => page.locator('article[data-image-key]');
const collectionsNav = page => page.getByRole('navigation', { name: 'Collections', exact: true });
const collectionsLabel = page => page.locator('aside.sidebar .sidebar-label', { hasText: 'COLLECTIONS' });
const rows = page => collectionsNav(page).locator('a[data-route^="collection/"]');
const row = (page, name) => rows(page).filter({ hasText: name });
const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true });
const editButton = page => main(page).locator('.heading-actions > button').first();
const editDialog = page => page.getByRole('dialog', { name: 'Edit collection', exact: true });
const deleteDialog = page => page.getByRole('dialog', { name: 'Delete collection', exact: true });
const nameField = page => editDialog(page).getByRole('textbox', { name: 'Collection name', exact: true });
const toast = page => page.locator('#workspace-toast');
async function seed(request) {
const response = await request.post('/__test__/collections/seed');
await expect(response).toBeOK();
return response.json();
}
async function listed(request) {
const response = await request.get(API);
await expect(response).toBeOK();
return (await response.json()).collections;
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.collectionErrors = [];
page.on('pageerror', error => page.collectionErrors.push(error.message));
// D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open.
page.on('dialog', dialog => { page.collectionErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); });
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.collectionErrors).toEqual([]);
});
test('a collection row opens its page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto(`${workspace}#/images`);
// D136: name order, the listed-member count, no "PROPOSED" badge.
await expect(collectionsLabel(page)).toBeVisible();
await expect(collectionsLabel(page)).toHaveText('COLLECTIONS');
await expect(rows(page)).toHaveCount(2);
await expect(rows(page).nth(0)).toContainText('Experiments');
await expect(rows(page).nth(1)).toContainText('Landscapes');
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('12');
await expect(row(page, 'Landscapes')).toHaveAttribute('href', `#/collection/${id}`);
await expect(row(page, 'Landscapes').locator('svg')).toHaveCount(1);
await expect(page.locator('aside.sidebar .new-label')).toHaveCount(0);
await expect(imageCards(page)).toHaveCount(21);
await row(page, 'Landscapes').click();
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
await expect(heading(page)).toHaveText('Landscapes');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(page).toHaveTitle('Collection · Prompt Manager');
await expect(row(page, 'Landscapes')).toHaveAttribute('aria-current', 'page');
await expect(description(page)).toHaveText('Your generations, their prompts, and the next idea.');
await expect(imageCards(page)).toHaveCount(12);
for (const path of manifest.files.Landscapes) {
await expect(page.locator(`article[data-image-key$=":${path}"]`)).toHaveCount(1);
}
// MC3: the member whose file is gone is not listed and is counted in the note.
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
// Every Images filter narrows the members.
await search(page).fill('landscape_03');
await expect(imageCards(page)).toHaveCount(1);
await search(page).fill('');
await expect(imageCards(page)).toHaveCount(12);
await row(page, 'Experiments').click();
await expect(heading(page)).toHaveText('Experiments');
await expect(imageCards(page)).toHaveCount(8);
await expect(missingNote(page)).toHaveCount(0);
});
test('without collections the group shows no label and no rows', async ({ page }) => {
await page.goto(`${workspace}#/images`);
await expect(imageCards(page).first()).toBeVisible();
await expect(collectionsNav(page)).toHaveCount(1);
await expect(rows(page)).toHaveCount(0);
await expect(collectionsLabel(page)).toBeHidden();
});
test('a collection page neither restores nor replaces the Images return state', async ({ page, request }) => {
const manifest = await seed(request);
await page.goto(`${workspace}#/images`);
await search(page).fill('experiment');
await expect(imageCards(page)).toHaveCount(8);
await row(page, 'Landscapes').click();
await expect(search(page)).toHaveValue('');
await expect(imageCards(page)).toHaveCount(12);
await search(page).fill('landscape_01');
await expect(imageCards(page)).toHaveCount(1);
await page.goto(`${workspace}#/images`);
await expect(search(page)).toHaveValue('experiment');
expect(manifest.collections.Landscapes.count).toBe(12);
});
test('Edit renames the open collection; a taken name stays in the dialog', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Landscapes');
await expect(editButton(page)).toHaveText('Edit');
await editButton(page).click();
await expect(nameField(page)).toHaveValue('Landscapes');
await expect(editDialog(page).locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']);
// CUJ 3: names are unique ignoring case.
await nameField(page).fill('experiments');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(editDialog(page).getByRole('alert')).toHaveText('A collection named “experiments” already exists.');
await expect(heading(page)).toHaveText('Landscapes');
await nameField(page).fill(' Landscape grids ');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(editDialog(page)).toHaveCount(0);
await expect(toast(page)).toHaveText('Collection renamed.');
await expect(heading(page)).toHaveText('Landscape grids');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(row(page, 'Landscape grids')).toHaveAttribute('aria-current', 'page');
await expect(rows(page).nth(1)).toContainText('Landscape grids');
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments', 'Landscape grids']);
});
test('a failed rename keeps the dialog; a failed delete changes nothing', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Experiments');
let failing = true;
await page.route(`**${API}/${id}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"The collection could not be changed."}' })
: route.fallback());
await editButton(page).click();
await nameField(page).fill('Tests');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('The collection could not be renamed. Try again.');
await expect(editDialog(page)).toBeVisible();
await expect(heading(page)).toHaveText('Experiments');
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await expect(deleteDialog(page).locator('p')).toHaveText(
'Delete “Experiments”? Its 8 images stay where they are; only the collection is removed.');
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('The collection could not be deleted. Try again.');
await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`));
expect(await listed(request)).toHaveLength(2);
failing = false;
});
test('Delete removes the collection only after confirming, and leaves every file', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await editButton(page).click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
// The stored count: 12 listed members and the missing one.
await expect(deleteDialog(page).locator('p')).toHaveText(
'Delete “Landscapes”? Its 13 images stay where they are; only the collection is removed.');
await deleteDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(deleteDialog(page)).toHaveCount(0);
expect(await listed(request)).toHaveLength(2);
await editButton(page).click();
await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click();
await expect(toast(page)).toHaveText('Collection deleted.');
await expect(page).toHaveURL(/#\/images$/);
await expect(heading(page)).toHaveText('Images');
await expect(rows(page)).toHaveCount(1);
await expect(imageCards(page)).toHaveCount(21);
expect((await listed(request)).map(item => item.name)).toEqual(['Experiments']);
});
test('an unknown collection says it no longer exists, with the way back to Images', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/collection/999999`);
await expect(heading(page)).toHaveText('Collection');
await expect(breadcrumb(page)).toHaveText('Collection');
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(imageCards(page)).toHaveCount(0);
await main(page).getByRole('link', { name: 'Open Images', exact: true }).click();
await expect(page).toHaveURL(/#\/images$/);
await expect(imageCards(page)).toHaveCount(21);
});
test('a failed list says so and Retry reads it again', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
let failing = true;
await page.route(`**${API}`, route => failing
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
: route.fallback());
await page.goto(`${workspace}#/collection/${id}`);
await expect(description(page)).toHaveText('Collections could not be loaded. Your saved data has not changed.');
await expect(description(page)).toHaveAttribute('role', 'alert');
await expect(rows(page)).toHaveCount(0);
failing = false;
await main(page).getByRole('button', { name: 'Retry', exact: true }).click();
await expect(heading(page)).toHaveText('Experiments');
await expect(imageCards(page)).toHaveCount(8);
await expect(rows(page)).toHaveCount(2);
});
test('a saved view keeps its collection, and says so once the collection is deleted (MC6)', async ({ page, request }) => {
const manifest = await seed(request);
const view = manifest.saved_view;
await page.goto(`${workspace}#/images`);
await savedNav(page).locator(`a[data-route="saved/${view.id}"]`).click();
await expect(heading(page)).toHaveText(view.name);
await expect(breadcrumb(page)).toHaveText(view.name);
await expect(imageCards(page)).toHaveCount(8);
// A saved view is not the collection page: no missing note.
await expect(missingNote(page)).toHaveCount(0);
// Save view on a collection page stores the collection.
await row(page, 'Landscapes').click();
await expect(imageCards(page)).toHaveCount(12);
await search(page).fill('landscape_1');
await expect(imageCards(page)).toHaveCount(3);
await main(page).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('Grid seed');
await save.getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
const views = (await (await request.get('/prompt_manager/saved-views')).json()).views;
const saved = views.find(item => item.name === 'Grid seed');
expect(saved.state).toMatchObject({ q: 'landscape_1', favorites: false, collection: String(manifest.collections.Landscapes.id) });
await expect(await request.delete(`${API}/${manifest.collections.Experiments.id}`)).toBeOK();
await page.goto(`${workspace}#/saved/${view.id}`);
await expect(heading(page)).toHaveText(view.name);
await expect(main(page).locator('.empty')).toHaveText('The collection this view uses was deleted.');
await expect(main(page).locator('.empty')).toBeVisible();
await expect(imageCards(page)).toHaveCount(0);
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
});
test('a collection deleted in another tab shows the deleted-collection page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/images`);
await expect(rows(page)).toHaveCount(2);
await expect(await request.delete(`${API}/${id}`)).toBeOK();
await row(page, 'Experiments').click();
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(rows(page)).toHaveCount(1);
});
// D136 / CUJ 15: the rows show and open at desktop and phone width in both themes. The concept's own phone rule hides
// every nav count there, so the rows keep their counts only at desktop width.
for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) {
test(`collection rows show and open at ${width}x${height} ${theme}`, async ({ page, request }) => {
const manifest = await seed(request);
await page.setViewportSize({ width, height });
await page.goto(`${workspace}#/images`);
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
for (const name of ['Experiments', 'Landscapes']) await expect(row(page, name)).toBeVisible();
const count = row(page, 'Landscapes').locator('.nav-count');
if (width > 950) await expect(count).toBeVisible();
else await expect(count).toBeHidden();
// Phone width: the label stays hidden as every sidebar label is; desktop shows it.
if (width > 950) await expect(collectionsLabel(page)).toBeVisible();
else await expect(collectionsLabel(page)).toBeHidden();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await row(page, 'Landscapes').click();
await expect(page).toHaveURL(new RegExp(`#/collection/${manifest.collections.Landscapes.id}$`));
await expect(heading(page)).toHaveText('Landscapes');
await expect(imageCards(page)).toHaveCount(12);
});
}
test('a finished restore reads the collections again', async ({ page, request }) => {
await seed(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
try {
const backup = await request.get('/prompt_manager/backup');
await expect(backup).toBeOK();
const bytes = await backup.body();
const [experiments] = await listed(request);
await expect(await request.delete(`${API}/${experiments.id}`)).toBeOK();
await page.goto(`${workspace}#/system/backups`);
await expect(rows(page)).toHaveCount(1);
const chooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Review restore…', exact: true }).click();
await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes });
await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click();
await expect(toast(page)).toHaveText(/^Database restore finished · /);
// The list was read again without a reload.
await expect(rows(page)).toHaveCount(2);
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8');
} finally {
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
}
});
test('renaming a collection deleted in another tab says so and shows the deleted-collection page', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
await page.goto(`${workspace}#/collection/${id}`);
await expect(heading(page)).toHaveText('Experiments');
await editButton(page).click();
await expect(await request.delete(`${API}/${id}`)).toBeOK();
await nameField(page).fill('Tests');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('That collection no longer exists.');
await expect(editDialog(page)).toHaveCount(0);
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(rows(page)).toHaveCount(1);
});
/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */
async function holdListReads(page, answer = route => route.fallback()) {
const held = [];
let open = false;
await page.route(`**${API}`, route => {
if (route.request().method() !== 'GET') return route.fallback();
if (open) return answer(route);
held.push(route);
return undefined;
});
return {
held,
async release() {
open = true;
for (const route of held.splice(0)) await answer(route).catch(() => {});
},
};
}
test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => {
const manifest = await seed(request);
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json();
const [file] = listing.images;
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
await expect(heading(page)).toHaveText('Landscapes');
// Another tab makes "Fresh"; this tab does not list it yet.
const added = await request.post(`${API}/items`, { data: { name: 'Fresh',
items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } });
await expect(added).toBeOK();
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
// supersedes that one while both are held.
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
await expect(imageCards(page)).toHaveCount(1);
});
test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Experiments;
let gone = true;
await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`)
? route.fulfill({ status: 404, contentType: 'application/json',
body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' })
: route.fallback());
await page.goto(`${workspace}#/images`);
await expect(rows(page)).toHaveCount(2);
// The shell's re-read is held, then fails: the page must not stay blank.
const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json',
body: '{"success":false,"error":"Collections could not be read."}' }));
await row(page, 'Experiments').click();
await expect.poll(() => hold.held.length).toBe(1);
await hold.release();
const retry = main(page).getByRole('button', { name: 'Retry', exact: true });
await expect(retry).toBeVisible();
await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.');
gone = false;
await page.unroute(`**${API}`);
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
// The list read again still lists it: the same Retry, never a blank page.
gone = true;
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await row(page, 'Experiments').click();
await expect(retry).toBeVisible();
gone = false;
await retry.click();
await expect(imageCards(page)).toHaveCount(8);
});
test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
let missing = null;
await page.route(`**${API}`, async route => {
if (route.request().method() !== 'GET' || missing === null) return route.fallback();
const response = await route.fetch();
const data = await response.json();
data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item);
return route.fulfill({ response, json: data });
});
await page.goto(`${workspace}#/collection/${id}`);
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
const rename = async name => {
await editButton(page).click();
await nameField(page).fill(name);
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
};
missing = 3;
await rename('Landscapes A');
await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.');
missing = 0;
await rename('Landscapes B');
await expect(missingNote(page)).toHaveCount(0);
missing = 1;
await rename('Landscapes C');
await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.');
});
test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', async ({ page, request }) => {
const manifest = await seed(request);
const { id } = manifest.collections.Landscapes;
await page.goto(`${workspace}#/collection/${id}`);
await expect(imageCards(page)).toHaveCount(12);
let release;
const released = new Promise(resolve => { release = resolve; });
await page.route(`**${API}/${id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await released;
return route.fallback();
});
await editButton(page).click();
await nameField(page).fill('Later');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await page.keyboard.press('Escape');
await expect(editDialog(page)).toHaveCount(0);
await main(page).getByRole('button', { name: 'Save view', exact: true }).click();
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
await expect(save).toBeVisible();
release();
await expect(heading(page)).toHaveText('Later');
await expect(save).toBeVisible();
});
test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => {
await seed(request);
await page.goto(`${workspace}#/images`);
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999999'; });
await expect(description(page)).toHaveText('This collection no longer exists.');
await expect(heading(page)).toBeFocused();
await page.route(`**${API}`, route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/images'; });
await expect(heading(page)).toHaveText('Images');
await page.evaluate(() => { window.location.hash = '#/collection/999998'; });
await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible();
await expect(heading(page)).toBeFocused();
await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
: route.fallback());
await page.evaluate(() => { window.location.hash = '#/saved/5'; });
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
await expect(heading(page)).toBeFocused();
});
+24
View File
@@ -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);}}});});
+284 -72
View File
@@ -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);
+3 -2
View File
@@ -23,7 +23,7 @@ const heading = page => main(page).getByRole('heading', { level: 1 });
const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true });
const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true });
const toast = page => page.locator('#workspace-toast');
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false };
const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' };
async function refine(page, values) {
await filtersButton(page).click();
@@ -73,7 +73,8 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row
await page.goto(`${workspace}#/images`);
await expect(imageCards(page)).toHaveCount(2);
// The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved.
await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
// D136: the COLLECTIONS label stays hidden while no collection is listed.
await expect(page.locator('aside.sidebar .sidebar-label:not([hidden])')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
await expect(viewRows(page)).toHaveCount(0);
await search(page).fill('dogs');
@@ -123,6 +123,32 @@ test('model options are the four real engines with size and the description show
await expect(actionOf(page, 'Unload')).toBeDisabled();
});
const NEEDS_LLAMA = 'GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI\'s Python: pip install llama-cpp-python. '
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python';
test('a downloaded model whose backend package is missing says so and is not offered as ready (#424)', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-vit'],
missing_packages: { gguf: ['llama-cpp-python'], 'wd14-vit': ['onnxruntime'] } } })).toBeOK();
await openTagging(page);
const line = modelRow(page).locator('p').first();
await page.locator('#tag-model').selectOption('gguf');
await expect(line).toHaveText('Downloaded. Needs llama-cpp-python: pip install llama-cpp-python. '
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python');
// Files and packages are separate: Download (again) stays offered.
await expect(actionOf(page, 'Download again')).not.toHaveAttribute('aria-disabled', 'true');
// Review explains instead of reviewing; nothing is previewed.
await expect(reviewButton(page)).toHaveAttribute('aria-disabled', 'true');
await reviewButton(page).dispatchEvent('click');
await expect(toast(page)).toHaveText(NEEDS_LLAMA);
expect(page.modelCalls.filter(call => call.endsWith('/autotag/batch/preview'))).toEqual([]);
// Not downloaded reads differently and still names what it will need.
await page.locator('#tag-model').selectOption('wd14-vit');
await expect(line).toHaveText('Not downloaded. Download size ~400 MB. Also needs onnxruntime.');
await page.locator('#tag-model').selectOption('wd14-swinv2');
await expect(line).toHaveText('Downloaded. Not loaded.');
await expect(reviewButton(page)).not.toHaveAttribute('aria-disabled', 'true');
});
test('entering the section makes no model call besides status', async ({ page }) => {
await openTagging(page);
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Integrations', exact: true }).click();
+1 -1
View File
@@ -472,7 +472,7 @@ async function openImages(page) {
test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => {
const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything',
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } });
state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' } } });
expect(created.status()).toBe(201);
const view = (await created.json()).view;
try {
@@ -186,6 +186,26 @@ test('a model that is not downloaded keeps Start disabled and says why', async (
expect(posted).toBe(0);
});
const NEEDS_LLAMA = 'GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI\'s Python: pip install llama-cpp-python. '
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python';
test('a model missing its backend package keeps Start disabled and names the install command (#424)', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [],
missing_packages: { gguf: ['llama-cpp-python'] } } })).toBeOK();
await select(page, FIRST);
await barButton(page, 'Auto-tag selected').click();
const status = modal(page).locator('#tag-generation-status');
const start = actionButtons(page).last();
await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('gguf');
await expect(status).toHaveText(NEEDS_LLAMA);
await expect(start).toHaveAttribute('aria-disabled', 'true');
await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit');
await expect(status).toHaveText('');
await expect(start).not.toHaveAttribute('aria-disabled', 'true');
await actionButtons(page).first().click();
await expect(modal(page)).toHaveCount(0);
});
test('Suggestions field prefills from the setting and caps chips at the entered count', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK();
await select(page, FIRST);
+12
View File
@@ -260,6 +260,18 @@ test('unavailable model shows inline status and never downloads', async ({ page,
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
});
test('a missing backend package is the server\'s install sentence, no traceback page (#424)', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [],
missing_packages: { 'wd14-swinv2': ['onnxruntime'] } } })).toBeOK();
await openTag(page);
const answer = await generated(page);
expect(answer.status()).toBe(409);
expect(await answer.json()).toMatchObject({ success: false, code: 'model-unavailable' });
await expect(status(page)).toHaveText('WD14 SwinV2 needs onnxruntime. Install it into ComfyUI\'s Python: '
+ 'pip install onnxruntime. Use onnxruntime-gpu for CUDA.');
await expect(chips(page)).toHaveCount(0);
});
test('video tag is manual only', async ({ page, request }) => {
await openTag(page, VIDEO);
const preview = dialog(page).locator('video.tag-image-preview');
@@ -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;});
+9 -5
View File
@@ -548,12 +548,16 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0);
const sidebar = page.locator('aside.sidebar');
expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label',
'nav#saved-nav', 'div.sidebar-bottom']);
'nav#saved-nav', 'div.sidebar-label', 'nav#collections-nav', 'div.sidebar-bottom']);
await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images');
// D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']);
// D49: SAVED VIEWS without the concept's PROPOSED badge. D136: COLLECTIONS without it either, its label hidden
// and its nav empty while no collection exists.
await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS', 'COLLECTIONS']);
await expect(sidebar.locator('.sidebar-label').nth(2)).toBeHidden();
await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views');
await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0);
await expect(page.locator('nav#collections-nav')).toHaveAttribute('aria-label', 'Collections');
await expect(page.locator('nav#collections-nav > a')).toHaveCount(0);
await expect(page.locator('.new-label')).toHaveCount(0);
const primary = page.locator('nav#primary-nav');
await expect(primary).toHaveAttribute('aria-label', 'Workspace');
for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US'));
@@ -580,7 +584,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page,
// At phone width the concept wraps the links and hides counts and the connection (concept 950px block).
await page.setViewportSize({ width: 390, height: 844 });
await expect(navCount(page, 'library')).toBeHidden();
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3);
await expect(page.locator('.sidebar-label, .connection')).toHaveCount(4);
for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden();
await expect(primary.locator('a[href="#/inspect"]')).toBeVisible();
});
@@ -0,0 +1,131 @@
"""Owned, deterministic collections for the workspace collection workflows (#331).
Twenty-one PNGs under the output source: twelve listed members of
"Landscapes", eight of "Experiments" and one loose file in neither. Landscapes
also holds one member whose file does not exist (MC3: hidden, counted as
missing). One saved view reopens "Experiments" (D140). Members are added
through ``ImageCollectionOperations`` with favorites' keys
(``_favorite_root_key``, ``_favorite_relative_key``), as the routes add them.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from PIL import Image
from tests.browser.folder_trash import replace_folder
if TYPE_CHECKING:
from py.api import PromptManagerAPI
FOLDER = "collections"
LANDSCAPES = "Landscapes"
EXPERIMENTS = "Experiments"
LANDSCAPE_FILES = tuple(f"landscape_{index:02d}.png" for index in range(1, 13))
EXPERIMENT_FILES = tuple(f"experiment_{index:02d}.png" for index in range(1, 9))
LOOSE_FILE = "loose.png"
MISSING_FILE = "gone.png"
SAVED_VIEW = "Experiments view"
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
"media_review_items",
"media_image_tags",
"media_annotation_bytes",
"media_annotation_files",
"image_prompt_pairs",
"prompt_pair_tags",
"prompt_pairs",
"generated_images",
"prompt_tags",
"tags",
"prompt_library_state",
"prompts",
)
_IMAGES_STATE = {
"q": "",
"category": "",
"sort": "newest",
"source": "all",
"folder": "",
"media": "all",
"tags": [],
"favorites": False,
}
def _write_png(path: Path, shade: int, stamp: float) -> None:
with Image.new("RGB", (64, 48), (shade, 120, 255 - shade)) as image:
image.save(path)
os.utime(path, (stamp, stamp))
def seed_collections(api: PromptManagerAPI, root: Path) -> dict:
"""Replace this server's disposable rows and files with the two collections."""
# The API package needs the host stubs the server installs before importing it.
from py.api.images import _favorite_relative_key, _favorite_root_key
db = api.db
with db.model.get_connection() as conn:
for table in _TABLES:
conn.execute(f"DELETE FROM {table}")
for name in ("output", "input"):
replace_folder(root / name)
folder = root / "output" / FOLDER
folder.mkdir()
# Newest first in this order: Landscapes, then Experiments, then the loose file.
now = time.time()
ordered = (*LANDSCAPE_FILES, *EXPERIMENT_FILES, LOOSE_FILE)
for index, name in enumerate(ordered):
_write_png(folder / name, (index * 11) % 256, now - 60 - index * 60)
root_key = _favorite_root_key(list(api._get_all_output_dirs())[0])
members = {
LANDSCAPES: (*LANDSCAPE_FILES, MISSING_FILE),
EXPERIMENTS: EXPERIMENT_FILES,
}
ids = {}
for name, files in members.items():
added = db.add_to_collection(
name=name,
items=[
(root_key, _favorite_relative_key(f"{FOLDER}/{file}")) for file in files
],
)
ids[name] = added["collection"]["id"]
view = db.create_saved_view(
"images", SAVED_VIEW, {**_IMAGES_STATE, "collection": str(ids[EXPERIMENTS])}
)
api.invalidate_gallery_cache()
return {
"collections": {
LANDSCAPES: {"id": ids[LANDSCAPES], "count": 12, "missing": 1},
EXPERIMENTS: {"id": ids[EXPERIMENTS], "count": 8, "missing": 0},
},
"files": {
LANDSCAPES: [f"{FOLDER}/{file}" for file in LANDSCAPE_FILES],
EXPERIMENTS: [f"{FOLDER}/{file}" for file in EXPERIMENT_FILES],
"loose": f"{FOLDER}/{LOOSE_FILE}",
},
"saved_view": {"id": view["id"], "name": SAVED_VIEW},
}
def register_collections_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> None:
"""Register the fixed seed command; no path or SQL comes from a request."""
@routes.post("/__test__/collections/seed")
async def seed(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
if data != {} or request.query:
raise web.HTTPBadRequest()
manifest = await api._run_in_executor(seed_collections, api, root)
return web.json_response({"success": True, **manifest})
@@ -174,6 +174,8 @@ _TABLES = (
"execution_contexts",
"execution_prompt_snapshots",
"execution_context_texts",
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -193,6 +195,10 @@ _TABLES = (
)
# The concept's collections and the slice of its images each holds (#331).
CONCEPT_COLLECTIONS = (("Landscapes", slice(0, 12)), ("Experiments", slice(12, 20)))
def concept_starred(prompt_index: int, round_index: int) -> bool:
"""The concept's favorites (app.js ``starred``): each other prompt's first image."""
return round_index == 0 and prompt_index % 2 == 0
@@ -495,6 +501,19 @@ def seed_workspace_concept(
)
images.reverse()
runs.reverse()
# The concept's COLLECTIONS rows (app.js a336–339): "Landscapes" holds the
# first 12 images in the concept's order and "Experiments" the next 8, so
# the rows and counts compare as the concept shows them (#331 D136).
collections = {}
for name, members in CONCEPT_COLLECTIONS:
added = db.add_to_collection(
name=name,
items=[
(favorite_root, _favorite_relative_key(image["path"]))
for image in images[members]
],
)
collections[name] = added["collection"]["id"]
seedless = []
for number in range(seedless_images):
name = f"seedless_{number + 1:05}.png"
@@ -531,4 +550,5 @@ def seed_workspace_concept(
"images": images,
"runs": runs,
"tags": tags,
"collections": collections,
}
@@ -26,6 +26,8 @@ EDITOR_METADATA = {
"notes": "Keep the soft light.",
}
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -78,6 +78,8 @@ LIMITED_SAMPLERS = 17
# More than one queue page (40), so Load more has a second page.
MAX_ITEMS = 45
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
+25 -2
View File
@@ -41,9 +41,23 @@ FILES = (
("loose/unlinked.png", "unlinked"),
)
MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
def _valid_missing(value) -> bool:
"""``{"<model>": ["<pip name>", ...]}`` with known models and string names."""
return isinstance(value, dict) and all(
name in MODELS
and isinstance(names, list)
and all(isinstance(pip, str) and pip for pip in names)
for name, names in value.items()
)
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
MAX_EXTRA_IMAGES = 160
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -217,6 +231,7 @@ def register_tagging_commands(
if not 0 <= extra <= MAX_EXTRA_IMAGES:
raise web.HTTPBadRequest()
model.unavailable = set()
model.missing_packages = {}
model.suggestions = None
model.loaded = None
restore_download()
@@ -235,17 +250,25 @@ def register_tagging_commands(
@routes.post("/__test__/tagging/models")
async def models(request: web.Request) -> web.Response:
"""Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
"""Mark fake models as not downloaded (``{"unavailable": [...]}``) and,
optionally, as missing backend packages (``"missing_packages":
{"<model>": ["<pip name>", ...]}``, #424)."""
data = await request.json() if request.can_read_body else None
if (
request.query
or not isinstance(data, dict)
or set(data) != {"unavailable"}
or "unavailable" not in data
or set(data) - {"unavailable", "missing_packages"}
or not isinstance(data["unavailable"], list)
or any(name not in MODELS for name in data["unavailable"])
or not _valid_missing(data.get("missing_packages", {}))
):
raise web.HTTPBadRequest()
model.unavailable = set(data["unavailable"])
model.missing_packages = {
name: list(names)
for name, names in data.get("missing_packages", {}).items()
}
return web.json_response({"success": True})
@routes.post("/__test__/tagging/loaded")
@@ -112,6 +112,8 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
"""Replace only the owned browser harness records and media roots."""
with api.db.model.get_connection() as conn:
for table in (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
+36 -3
View File
@@ -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"
@@ -79,6 +83,8 @@ UNUSED_TAG = "unused_vocabulary"
MAX_EXTRA_TAGS = 300
MAX_EXTRA_CATEGORIES = 200
_TABLES = (
"image_collection_items",
"image_collections",
"saved_views",
"image_favorites",
"media_review_decisions",
@@ -112,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
@@ -167,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()
@@ -180,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,
}
@@ -200,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)
@@ -215,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})