Merge remote-tracking branch 'origin/main' into feat/444-library-folder-or
# Conflicts: # TESTING.md
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {SIZES,THEMES,seedConceptAt,openConcept,openProduction,diffInventory,mutate} from './concept-inventory.mjs';
|
||||
|
||||
// #331 D138: the Images selection bar's "Add to collection" dialog against the concept's own (`bulk-collection` →
|
||||
// simpleInput, app.js a1392–1398 and a1138), on the concept-shaped fixture, at 1600x1100 and 390x844 in both themes.
|
||||
// Paired screenshots are artifacts only; there are no PNG baselines.
|
||||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
|
||||
/**
|
||||
* Runs in the page: the open "Add selected images to collection" dialog: its title, the body's tree, the field's
|
||||
* label, input and suggestions, and the actions, with the concept's geometry and tokens.
|
||||
*/
|
||||
function measureAddDialog() {
|
||||
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 body=dialog.querySelector('#modal-body');
|
||||
const field=body.querySelector(':scope > .field');
|
||||
const label=field?.querySelector(':scope > label');
|
||||
const input=field?.querySelector(':scope > input');
|
||||
const list=input?.list??null;
|
||||
const actions=body.querySelector(':scope > .dialog-actions');
|
||||
const rect=dialog.getBoundingClientRect();
|
||||
return {
|
||||
title:text(dialog.querySelector('#modal-title')),
|
||||
tree:[...body.children].map(name),
|
||||
field:field&&{tree:[...field.children].map(name),height:Math.round(field.getBoundingClientRect().height),
|
||||
...css(field,'display','flexDirection','rowGap','marginBottom')},
|
||||
label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')},
|
||||
input:input&&{id:input.id,type:input.type,value:input.value,maxLength:input.maxLength,list:input.getAttribute('list'),
|
||||
...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius')},
|
||||
suggestions:list&&{id:list.id,display:getComputedStyle(list).display,options:[...list.options].map(option=>option.value)},
|
||||
actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','marginTop'),
|
||||
buttons:[...actions.children].map(button=>({text:text(button),className:button.className,
|
||||
...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor')}))},
|
||||
width:Math.round(rect.width),height:Math.round(rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* D138: the action is "Add" (the concept's "Apply in prototype"), the field starts empty (no sample "Experiments") and
|
||||
* holds at most 100 characters, and a hidden `datalist` of the existing names (the fixture's two) follows the field:
|
||||
* the shared input dialog's suggestions list, as Set category's (#332 D140).
|
||||
*/
|
||||
function mapAddDialogConcept(reference) {
|
||||
const [cancel,apply]=reference.actions.buttons;
|
||||
const [field,...rest]=reference.tree;
|
||||
return {...reference,tree:[field,'datalist',...rest],
|
||||
input:{...reference.input,value:'',maxLength:100,list:'workspace-input-suggestions'},
|
||||
suggestions:{id:'workspace-input-suggestions',display:'none',options:['Experiments','Landscapes']},
|
||||
actions:{...reference.actions,buttons:[cancel,{...apply,text:'Add'}]}};
|
||||
}
|
||||
|
||||
async function openAddDialog(page,main) {
|
||||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
await page.locator(`${main} #bulkbar`).getByRole('button',{name:'Add to collection',exact:true}).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Add selected images to collection');
|
||||
await page.mouse.move(0,0);
|
||||
return page.evaluate(measureAddDialog);
|
||||
}
|
||||
|
||||
async function conceptAddDialog(page,theme) {
|
||||
await openConcept(page,'images',theme);
|
||||
return openAddDialog(page,'#main');
|
||||
}
|
||||
|
||||
async function productionAddDialog(page,theme) {
|
||||
await openProduction(page,'images',theme);
|
||||
await expect(page.locator(CARDS).first()).toBeVisible();
|
||||
return openAddDialog(page,'#workspace-main');
|
||||
}
|
||||
|
||||
test.beforeEach(async({page,request},testInfo)=>{testInfo.manifest=await seedConceptAt(page,request);});
|
||||
|
||||
for(const theme of THEMES)for(const [width,height] of SIZES) {
|
||||
test(`add to collection dialog matches the concept ${theme} ${width}x${height}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const concept=await conceptAddDialog(page,theme);
|
||||
await page.screenshot({path:testInfo.outputPath(`concept-add-to-collection-${theme}-${width}.png`)});
|
||||
const production=await productionAddDialog(page,theme);
|
||||
await page.screenshot({path:testInfo.outputPath(`production-add-to-collection-${theme}-${width}.png`)});
|
||||
expect(concept.input.value,'the concept pre-fills its sample').toBe('Experiments');
|
||||
expect(diffInventory(mapAddDialogConcept(concept),production)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('add to collection comparison detects mutations',async({page})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
const concept=await conceptAddDialog(page,'dark');
|
||||
const expected=mapAddDialogConcept(concept);
|
||||
const production=await productionAddDialog(page,'dark');
|
||||
expect(diffInventory(expected,production)).toEqual([]);
|
||||
// Unmapped, the concept differs only where D138 is recorded.
|
||||
expect(diffInventory(concept,production).map(item=>item.path).sort()).toEqual(expect.arrayContaining(
|
||||
['actions.buttons.1.text','tree.1','input.list','input.maxLength','input.value']));
|
||||
const paths=async()=>diffInventory(expected,await page.evaluate(measureAddDialog)).map(item=>item.path);
|
||||
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Name';});
|
||||
expect(await paths()).toEqual(['label.text']);
|
||||
await page.evaluate(()=>{document.querySelector('dialog#modal[open] label[for="action-input"]').textContent='Collection name';});
|
||||
const undoStyle=await mutate(page,{css:'dialog#modal #action-input { border-color: rgb(255, 0, 0); } dialog#modal .dialog-actions { gap: 31px; }'});
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['input.borderTopColor','actions.columnGap']));
|
||||
await undoStyle();
|
||||
await page.evaluate(()=>document.querySelector('#workspace-input-suggestions').remove());
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['suggestions']));
|
||||
});
|
||||
@@ -145,9 +145,10 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
const footer=panel.locator('.detail-footer');
|
||||
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
// #331 D139: every listed file's footer ends with Collections… (folder icon).
|
||||
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
|
||||
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(1);
|
||||
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
|
||||
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
|
||||
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
|
||||
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
|
||||
@@ -157,10 +158,11 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await selectFile(page,'ambiguous.png');await expect(panel.locator('.prompt-section.positive .prompt-text')).toHaveText('embedded positive');
|
||||
await expect(panel.locator('span.chip.blue')).toHaveText('Embedded parameters');
|
||||
await expect(panel.locator('.panel-heading .chip')).toHaveText('File extension PNG');await expect(panel.locator('.panel-heading .chip .ca-visually-hidden')).toHaveText('File extension ');
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI']);
|
||||
// Unlinked: no badge, category, tags, pair or history.
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Collections…']);
|
||||
// Unlinked: no badge, category, tags, pair or history; the footer holds only Collections… (D139).
|
||||
reads.patch=null;await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner'],
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);
|
||||
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
|
||||
inner:['div.panel-heading','p.small','section.prompt-section.positive','section.prompt-section.negative','dl.meta-grid']});
|
||||
await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
// Ambiguous saved association: the same honest absence.
|
||||
@@ -258,7 +260,7 @@ test('pair send and history appear only with real roles and link',async({page,re
|
||||
await expect(panel.getByRole('button',{name:'Send prompt pair to ComfyUI: explicit-empty.png',exact:true})).toBeVisible();
|
||||
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(panel.locator('.detail-footer')).toHaveCount(0);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
|
||||
await expect(panel.locator('.detail-footer button')).toHaveText(['Collections…']);await expect(panel.getByRole('button',{name:/^Send prompt pair/})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no provenance, raw or download controls in the side panel',async({page,request})=>{
|
||||
@@ -286,8 +288,8 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
|
||||
await page.setViewportSize({width:1600,height:1100});const reads=await patchInspection(page);
|
||||
await fullFixture(page,request);let details=await viewerDetails(page,'complete.png');
|
||||
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
|
||||
// then the pair send and history buttons (D20).
|
||||
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button'],
|
||||
// then the pair send and history buttons (D20) and D139's Collections….
|
||||
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
|
||||
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
|
||||
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
|
||||
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
|
||||
@@ -298,7 +300,7 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
|
||||
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
|
||||
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
|
||||
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
|
||||
await expect(details.locator(':scope > button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
|
||||
// File & provenance: the honest facts and the concept's "Prompt evidence" line; no per-fact source lines.
|
||||
const provenance=details.locator('details').first();await provenance.locator('summary').click();
|
||||
@@ -323,9 +325,10 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
|
||||
await expect(details.getByText('Prompt evidence: embedded parameters',{exact:true})).toBeVisible();
|
||||
await expect(details.locator('.panel-heading .chip')).toHaveText('File extension PNG');
|
||||
await expect(details.locator('details dl.meta-grid dd').nth(4)).toHaveText('PNG extension');await expect(details).not.toContainText('.PNG');
|
||||
// No badge, no line; no resolved pair or link, no buttons after the details.
|
||||
// No badge, no line; no resolved pair or link: after the details only D139's Collections….
|
||||
reads.patch=null;await page.keyboard.press('Escape');details=await viewerDetails(page,'empty.png');
|
||||
expect((await viewerStructure(details)).outer).toEqual(['div']);
|
||||
expect((await viewerStructure(details)).outer).toEqual(['div','button']);
|
||||
await expect(details.locator(':scope > button')).toHaveText(['Collections…']);
|
||||
await details.getByText('File & provenance',{exact:true}).click();await expect(details).not.toContainText('Prompt evidence');
|
||||
// The history button leaves the viewer for the prompt's history.
|
||||
await page.keyboard.press('Escape');details=await viewerDetails(page,'complete.png');
|
||||
|
||||
@@ -30,9 +30,12 @@ function captionDay(seconds,now=new Date()) {
|
||||
return `${name} · ${date.toLocaleDateString('en-US',options)}`;
|
||||
}
|
||||
|
||||
/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */
|
||||
const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
/**
|
||||
* D8 (amended by #249 PR D and #331): the selection bar's real actions only; Remove links… is left out. Add to
|
||||
* collection compares in place (D138).
|
||||
*/
|
||||
const OMITTED_BULK=Object.freeze(['Remove links…']);
|
||||
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
|
||||
/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */
|
||||
const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]);
|
||||
|
||||
@@ -464,7 +467,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
|
||||
'Set category','Export metadata','Clear']);
|
||||
'Set category','Add to collection','Export metadata','Clear']);
|
||||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||||
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||||
// The bar comparison notices a change to the production bar.
|
||||
@@ -472,6 +475,48 @@ 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');
|
||||
});
|
||||
|
||||
/**
|
||||
* #331 D137: the collection page's Edit leads the heading actions, drawn in the concept as a plain copy of its Import
|
||||
* button reading "Edit" (no icon, no action), so the browser lays out what follows it; returns an undo.
|
||||
*/
|
||||
async function drawCollectionEdit(page) {
|
||||
await page.evaluate(()=>{
|
||||
const actions=document.querySelector('#main .heading-actions');
|
||||
const edit=actions.querySelector('button').cloneNode(false);
|
||||
for(const key of Object.keys(edit.dataset))delete edit.dataset[key];
|
||||
edit.textContent='Edit';actions.prepend(edit);window.__collectionEdit=edit;
|
||||
});
|
||||
await settle(page);
|
||||
return ()=>page.evaluate(()=>{window.__collectionEdit.remove();delete window.__collectionEdit;});
|
||||
}
|
||||
|
||||
// #331 D137: `#/collection/<id>` against the concept's `#collection-landscapes` (app.js a1152–1157, a1186–1189): the
|
||||
// collection's name as the h1, "Collection" as the breadcrumb, the Images description, toolbar and filter row. The grid
|
||||
// is not compared: the concept's members come from its categories, production's from the stored collection.
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`collection page heading, toolbar and filter row match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
await openConcept(page,'collection-landscapes',theme);await page.mouse.move(0,0);await settle(page);
|
||||
await expect(page.locator('#main #page-title')).toHaveText('Landscapes');
|
||||
await expect(page.locator('#breadcrumb')).toHaveText('Collection');
|
||||
const restore=await drawCollectionEdit(page);
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
await restore();
|
||||
await openProduction(page,`collection/${manifest.collections.Landscapes}`,theme);await page.mouse.move(0,0);
|
||||
await expect(page.locator(CARDS)).toHaveCount(12);
|
||||
await expect(page.locator('#workspace-main #page-title')).toHaveText('Landscapes');
|
||||
await expect(page.locator('#workspace-breadcrumb')).toHaveText('Collection');
|
||||
await expect(page).toHaveTitle('Collection · Prompt Manager');
|
||||
await settle(page);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
expect.soft(actual.heading.actions.buttons.map(item=>item.text),'production heading actions').toEqual(['Edit','Import','New prompt']);
|
||||
expect.soft(reference.heading.actions.buttons.map(item=>item.text),'mapped concept heading actions').toEqual(['Edit','Import','New prompt']);
|
||||
expect.soft(compare(reference,actual,manifest,['heading','toolbar','filterRow']),'collection page').toEqual([]);
|
||||
// The comparison notices a lost Edit.
|
||||
await mutate(page,{remove:['#workspace-main .heading-actions > button:first-child']});
|
||||
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['heading']).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/** #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) {
|
||||
|
||||
@@ -51,10 +51,11 @@ function withCategoryScope(inventory) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 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.
|
||||
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
|
||||
* "Collections…" is drawn after Prompt history in both the concept's panel footer and its full details.
|
||||
*/
|
||||
export function mapInspectorConcept(reference,{viewport}) {
|
||||
const {panel,viewerPlain,fileControls}=reference;
|
||||
@@ -111,6 +112,22 @@ async function openProductionPanel(page,theme) {
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* #331 D139: production's "Collections…" after Prompt history in `selector`'s buttons, drawn in the concept as its own
|
||||
* Prompt history button (no action) with the concept's folder icon (its Images filter chip's); returns an undo.
|
||||
*/
|
||||
async function withCollectionsButton(page,selector) {
|
||||
await page.evaluate(selector=>{
|
||||
const history=[...document.querySelectorAll(`${selector} > button`)].find(button=>button.textContent.includes('Prompt history'));
|
||||
const button=history.cloneNode(false);
|
||||
for(const key of Object.keys(button.dataset))delete button.dataset[key];
|
||||
button.append(document.querySelector('.filter-row .chip > svg.icon').cloneNode(true),' Collections…');
|
||||
history.after(button);(window.__collectionsButtons??=[]).push(button);
|
||||
},selector);
|
||||
await settle(page);
|
||||
return async()=>{await page.evaluate(()=>{for(const button of window.__collectionsButtons??[])button.remove();delete window.__collectionsButtons;});};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
|
||||
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
|
||||
@@ -129,8 +146,9 @@ async function plainViewerChips(page) {
|
||||
summary.textContent='Copy settings summary';summary.classList.add('settings-summary');
|
||||
document.querySelector('#viewer-details > div > dl.meta-grid').after(summary);window.__summaryButton=summary;
|
||||
});
|
||||
await settle(page);
|
||||
const restoreCollections=await withCollectionsButton(page,'#viewer-details');
|
||||
return async()=>{
|
||||
await restoreCollections();
|
||||
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
|
||||
window.__summaryButton.remove();delete window.__summaryButton;});
|
||||
await restoreSource();await restoreTargets();
|
||||
@@ -169,10 +187,12 @@ 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.
|
||||
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history.
|
||||
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
|
||||
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
|
||||
if(concept)await settle(page);
|
||||
const {panel}=await inspectorOf(page);
|
||||
await restoreCollections?.();
|
||||
await restoreSource?.();
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
|
||||
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
|
||||
@@ -340,7 +360,7 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
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']);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
|
||||
});
|
||||
|
||||
// #391: a tag chip replaces the collection; until its listing answers the caption says it is loading, never that the
|
||||
@@ -414,6 +434,12 @@ test('inspector comparison detects mutations',async({page})=>{
|
||||
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
|
||||
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
||||
|
||||
// D139: Collections… is part of the mapped concept, so losing it is a difference.
|
||||
const restoreButton=await mutate(page,{remove:['#image-inspector > .detail-footer > button:last-child']});
|
||||
const withoutCollections=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,withoutCollections,viewport)).toThrow(/panel\.footer/);
|
||||
await restoreButton();await settle(page);
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -317,7 +317,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
/**
|
||||
* D148 (#437): the selection checkbox overlay of `card` and the `.bulkbar` row inside `main`, with one item checked,
|
||||
* measured alike in the concept's Images page and production's Library: the overlay's inset in its card, the checkbox,
|
||||
* and the bar's box, tokens and the two actions both bars have (Set category, Clear).
|
||||
* and the bar's box, tokens and the actions both bars have (Add tags, Set category, Clear; #442).
|
||||
*/
|
||||
const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
|
||||
const css=(node,...keys)=>{const style=getComputedStyle(node);return Object.fromEntries(keys.map(key=>[key,style[key]]));};
|
||||
@@ -327,7 +327,7 @@ const measureSelection=(page,{card,main})=>page.evaluate(({card,main})=>{
|
||||
'borderTopColor','borderTopLeftRadius','backgroundColor');
|
||||
const host=document.querySelector(card),overlay=host.querySelector('.image-overlays'),label=overlay.querySelector(':scope > label');
|
||||
const input=label.querySelector('input'),root=document.querySelector(main),bar=root.querySelector('.bulkbar');
|
||||
const shared=['Set category','Clear'];
|
||||
const shared=['Add tags','Set category','Clear'];
|
||||
return {
|
||||
overlay:{left:px(rect(overlay).left-rect(host).left),top:px(rect(overlay).top-rect(host).top),right:px(rect(host).right-rect(overlay).right),
|
||||
...css(overlay,'position','display','justifyContent','pointerEvents')},
|
||||
@@ -357,7 +357,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
|
||||
const actual=await productionSelection(page,theme);
|
||||
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
|
||||
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
|
||||
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has both actions').toEqual(['Set category','Clear']);
|
||||
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
|
||||
expect(actual).toEqual(reference);
|
||||
// The comparison notices a moved checkbox (spec §7 mutation check).
|
||||
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
|
||||
|
||||
@@ -491,3 +491,366 @@ test('a page that says a collection or view could not be opened takes focus afte
|
||||
await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.');
|
||||
await expect(heading(page)).toBeFocused();
|
||||
});
|
||||
|
||||
// #331 D138 (design §7): the selection bar's Add to collection and Remove from collection.
|
||||
const bar = page => page.locator('#bulkbar > .bulkbar');
|
||||
const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true });
|
||||
const addDialog = page => page.getByRole('dialog', { name: 'Add selected images to collection', exact: true });
|
||||
const addField = page => addDialog(page).getByRole('combobox', { name: 'Collection name', exact: true });
|
||||
const check = (page, file) => page.getByRole('checkbox', { name: `Select ${file}`, exact: true }).check();
|
||||
const card = (page, path) => page.locator(`article[data-image-key$=":${path}"]`);
|
||||
|
||||
/** Add the checked images under `name` through the bar's dialog. */
|
||||
async function addChecked(page, name) {
|
||||
await barButton(page, 'Add to collection').click();
|
||||
await expect(addDialog(page)).toBeVisible();
|
||||
await addField(page).fill(name);
|
||||
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await expect(addDialog(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
test('Add to collection creates a collection and adds to it ignoring case (CUJ 1–3)', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
for (const file of ['loose.png', 'landscape_01.png', 'experiment_01.png']) await check(page, file);
|
||||
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Export metadata', 'Clear']);
|
||||
await barButton(page, 'Add to collection').click();
|
||||
// The concept's simpleInput with Add (D138) and the existing names as suggestions.
|
||||
await expect(addDialog(page).locator('label')).toHaveText('Collection name');
|
||||
await expect(addField(page)).toHaveValue('');
|
||||
await expect(addField(page)).toHaveAttribute('maxlength', '100');
|
||||
await expect(addDialog(page).locator('datalist option')).toHaveCount(2);
|
||||
expect(await addDialog(page).locator('datalist option').evaluateAll(options => options.map(option => option.value)))
|
||||
.toEqual(['Experiments', 'Landscapes']);
|
||||
await expect(addDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Add']);
|
||||
// An empty name keeps the dialog on its field.
|
||||
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await expect(addField(page)).toBeFocused();
|
||||
await addField(page).fill(' LoRa testing ');
|
||||
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Added 3 images to “LoRa testing”.');
|
||||
await expect(bar(page).locator('strong')).toHaveText('3 selected');
|
||||
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('3');
|
||||
await expect(rows(page)).toHaveCount(3);
|
||||
|
||||
// CUJ 2: one of two already in it.
|
||||
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await check(page, 'loose.png');
|
||||
await check(page, 'landscape_02.png');
|
||||
await addChecked(page, 'LoRa testing');
|
||||
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”. 1 was already in it.');
|
||||
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('4');
|
||||
|
||||
// CUJ 3: a case variant joins the same collection.
|
||||
await bar(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await check(page, 'landscape_03.png');
|
||||
await addChecked(page, 'lora TESTING');
|
||||
await expect(toast(page)).toHaveText('Added 1 image to “LoRa testing”.');
|
||||
await expect(rows(page)).toHaveCount(3);
|
||||
await expect(row(page, 'LoRa testing').locator('.nav-count')).toHaveText('5');
|
||||
const created = (await listed(request)).find(item => item.name === 'LoRa testing');
|
||||
expect(created).toMatchObject({ count: 5, missing: 0 });
|
||||
|
||||
// Everything already in it.
|
||||
await addChecked(page, 'LoRa testing');
|
||||
await expect(toast(page)).toHaveText('All selected images were already in “LoRa testing”.');
|
||||
});
|
||||
|
||||
test('Remove from collection takes the checked images off the open collection (CUJ 5)', 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);
|
||||
await check(page, 'landscape_01.png');
|
||||
await check(page, 'landscape_02.png');
|
||||
await expect(bar(page).locator('button')).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Remove from collection', 'Export metadata', 'Clear']);
|
||||
await barButton(page, 'Remove from collection').click();
|
||||
await expect(toast(page)).toHaveText('Removed 2 images from “Landscapes”.');
|
||||
await expect(imageCards(page)).toHaveCount(10);
|
||||
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
|
||||
// The removed files left the listing, so the bar has nothing checked.
|
||||
await expect(bar(page)).toHaveCount(0);
|
||||
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('10');
|
||||
// A checked file still listed stays checked across the reload.
|
||||
await check(page, 'landscape_03.png');
|
||||
await check(page, 'landscape_04.png');
|
||||
await page.route(`**${API}/items`, async route => {
|
||||
if (route.request().method() !== 'DELETE') return route.fallback();
|
||||
// Remove only landscape_04; landscape_03 stays listed and checked.
|
||||
const body = route.request().postDataJSON();
|
||||
await route.continue({ postData: JSON.stringify({ ...body, items: body.items.filter(item => item.path.endsWith('_04.png')) }) });
|
||||
});
|
||||
await barButton(page, 'Remove from collection').click();
|
||||
await expect(toast(page)).toHaveText('Removed 1 image from “Landscapes”.');
|
||||
await expect(imageCards(page)).toHaveCount(9);
|
||||
await expect(bar(page).locator('strong')).toHaveText('1 selected');
|
||||
await expect(page.getByRole('checkbox', { name: 'Select landscape_03.png', exact: true })).toBeChecked();
|
||||
// No file changed: the plain Images page still lists all of them.
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
});
|
||||
|
||||
test('Images without a collection filter has no Remove from collection', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await check(page, 'loose.png');
|
||||
await expect(barButton(page, 'Add to collection')).toBeVisible();
|
||||
await expect(barButton(page, 'Remove from collection')).toHaveCount(0);
|
||||
// A saved view on a collection has its filter active: Remove shows (design §5).
|
||||
await page.goto(`${workspace}#/saved/${manifest.saved_view.id}`);
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await check(page, 'experiment_01.png');
|
||||
await barButton(page, 'Remove from collection').click();
|
||||
await expect(toast(page)).toHaveText('Removed 1 image from “Experiments”.');
|
||||
await expect(imageCards(page)).toHaveCount(7);
|
||||
});
|
||||
|
||||
test('a failed add keeps the selection; a stale source reloads the images', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
let reply = { status: 500, body: { success: false, error: 'The collection could not be changed.' } };
|
||||
await page.route(`**${API}/items`, route => route.fulfill({ status: reply.status, contentType: 'application/json',
|
||||
body: JSON.stringify(reply.body) }));
|
||||
await check(page, 'loose.png');
|
||||
await check(page, 'landscape_05.png');
|
||||
await addChecked(page, 'Grids');
|
||||
await expect(toast(page)).toHaveText('The images could not be added. Try again.');
|
||||
await expect(bar(page).locator('strong')).toHaveText('2 selected');
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
|
||||
reply = { status: 404, body: { success: false, code: 'files-not-found', error: 'x' } };
|
||||
await addChecked(page, 'Grids');
|
||||
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
|
||||
await expect(bar(page).locator('strong')).toHaveText('2 selected');
|
||||
|
||||
reply = { status: 409, body: { success: false, code: 'source-changed', error: 'x' } };
|
||||
const reloaded = page.waitForRequest(request => request.url().includes('/prompt_manager/gallery/subfolders'));
|
||||
await addChecked(page, 'Grids');
|
||||
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
|
||||
await reloaded;
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
await expect(bar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('more than 500 or an unloaded "all matching" selection is refused with the cap', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
// Every loaded image checked and the listing says more match: "Select all N matching" (#392, D135).
|
||||
await page.route('**/prompt_manager/images/output?*', async route => {
|
||||
// A next page never answers, so the listing stays at its loaded 21 of 900.
|
||||
if (new URL(route.request().url()).searchParams.has('cursor')) return;
|
||||
const response = await route.fetch();
|
||||
const data = await response.json();
|
||||
await route.fulfill({ response, json: { ...data, total: 900, has_more: true, next_cursor: 'more' } });
|
||||
});
|
||||
await page.reload();
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
await page.locator('#image-grid .image-open').first().focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await bar(page).getByRole('button', { name: 'Select all 900 matching', exact: true }).click();
|
||||
await expect(bar(page).locator('strong')).toHaveText('All 900 matching selected');
|
||||
await barButton(page, 'Add to collection').click();
|
||||
await expect(toast(page)).toHaveText('Select at most 500 images.');
|
||||
await expect(addDialog(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const theme of ['dark', 'light']) {
|
||||
test(`the Add to collection dialog fits 390x844 ${theme}`, async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await check(page, 'loose.png');
|
||||
await expect(barButton(page, 'Add to collection')).toBeVisible();
|
||||
await barButton(page, 'Add to collection').click();
|
||||
await expect(addDialog(page)).toBeVisible();
|
||||
const fits = await addDialog(page).evaluate(dialog => {
|
||||
const box = dialog.getBoundingClientRect();
|
||||
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
|
||||
});
|
||||
expect(fits).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await addField(page).fill('Phone');
|
||||
await addDialog(page).getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Added 1 image to “Phone”.');
|
||||
await expect(row(page, 'Phone')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
// #331 D139 (design §8): the shared inspector's Collections… in the Images side panel and in the viewer.
|
||||
const panel = page => page.locator('#image-inspector');
|
||||
const collectionsDialog = page => page.getByRole('dialog', { name: 'Collections', exact: true });
|
||||
const choice = (page, name) => collectionsDialog(page).getByRole('checkbox', { name, exact: true });
|
||||
const newCollection = page => collectionsDialog(page).getByRole('textbox', { name: 'New collection', exact: true });
|
||||
|
||||
async function membership(request, path) {
|
||||
const [image] = (await (await request.get(`/prompt_manager/images/output?mode=workspace&q=${encodeURIComponent(path)}`)).json()).images;
|
||||
const query = new URLSearchParams({ root_index: String(image.root_index), root_scope: image.root_scope, path: image.path });
|
||||
const response = await request.get(`${API}/membership?${query}`);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).collection_ids;
|
||||
}
|
||||
|
||||
test('Collections… in the side panel adds, removes and creates for one image (CUJ 6)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { Landscapes, Experiments } = manifest.collections;
|
||||
await page.goto(`${workspace}#/images`);
|
||||
// The newest image, landscape_01, is the panel's.
|
||||
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('landscape_01.png');
|
||||
const open = panel(page).getByRole('button', { name: 'Collections…', exact: true });
|
||||
await expect(panel(page).locator('.detail-footer > button').last()).toHaveText('Collections…');
|
||||
await open.click();
|
||||
await expect(collectionsDialog(page)).toBeVisible();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
await expect(choice(page, 'Experiments')).not.toBeChecked();
|
||||
await expect(collectionsDialog(page).locator('label.tag-suggestion')).toHaveText(['Experiments', 'Landscapes']);
|
||||
await expect(collectionsDialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Save']);
|
||||
await choice(page, 'Landscapes').uncheck();
|
||||
await choice(page, 'Experiments').check();
|
||||
await newCollection(page).fill('Grids');
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(collectionsDialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('Collections updated.');
|
||||
await expect(open).toBeFocused();
|
||||
await expect(row(page, 'Grids').locator('.nav-count')).toHaveText('1');
|
||||
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
|
||||
await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('9');
|
||||
const grids = (await listed(request)).find(item => item.name === 'Grids');
|
||||
expect((await membership(request, 'landscape_01')).sort()).toEqual([Experiments.id, grids.id].sort());
|
||||
expect(Landscapes.id).not.toBe(grids.id);
|
||||
// The Images page lists the same files; the dialog opened again shows the new state.
|
||||
await expect(imageCards(page)).toHaveCount(21);
|
||||
await open.click();
|
||||
await expect(choice(page, 'Grids')).toBeChecked();
|
||||
await expect(choice(page, 'Experiments')).toBeChecked();
|
||||
await expect(choice(page, 'Landscapes')).not.toBeChecked();
|
||||
// Save without a change sends nothing.
|
||||
let writes = 0;
|
||||
page.on('request', request => { if (request.url().includes(`${API}/items`)) writes++; });
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(collectionsDialog(page)).toHaveCount(0);
|
||||
expect(writes).toBe(0);
|
||||
});
|
||||
|
||||
test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`);
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
|
||||
const viewer = page.locator('#viewer-dialog[open]');
|
||||
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
|
||||
// Review L7: no listing request while the viewer is open.
|
||||
let listings = 0;
|
||||
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
|
||||
page.on('request', countListing);
|
||||
await viewer.locator('#viewer-details').getByRole('button', { name: 'Collections…', exact: true }).click();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
// Review L6: focus moves to the first checkbox once listed.
|
||||
await expect(choice(page, 'Experiments')).toBeFocused();
|
||||
await choice(page, 'Landscapes').uncheck();
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collections updated.');
|
||||
await expect(collectionsDialog(page)).toHaveCount(0);
|
||||
// The viewer stays on the image; the page keeps listing it while the viewer is open.
|
||||
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
|
||||
await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('11');
|
||||
await expect(imageCards(page)).toHaveCount(12);
|
||||
expect(listings, 'no listing request while the viewer is open').toBe(0);
|
||||
page.off('request', countListing);
|
||||
await viewer.getByRole('button', { name: 'Close image viewer', exact: true }).click();
|
||||
await expect(viewer).toHaveCount(0);
|
||||
await expect(imageCards(page)).toHaveCount(11);
|
||||
await expect(card(page, 'collections/landscape_01.png')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a failed membership read says so and Retry reads it again', async ({ page, request }) => {
|
||||
await seed(request);
|
||||
let failing = true;
|
||||
await page.route(`**${API}/membership?*`, route => failing
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' })
|
||||
: route.fallback());
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
|
||||
await expect(collectionsDialog(page).getByRole('alert').first()).toHaveText('Collections could not be loaded.');
|
||||
await expect(collectionsDialog(page).getByRole('checkbox')).toHaveCount(0);
|
||||
await expect(collectionsDialog(page).getByRole('button', { name: 'Save', exact: true })).toHaveAttribute('aria-disabled', 'true');
|
||||
failing = false;
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
await expect(collectionsDialog(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden();
|
||||
// A failed write keeps the dialog with the current membership.
|
||||
await page.route(`**${API}/items`, route => route.fulfill({ status: 500, contentType: 'application/json',
|
||||
body: '{"success":false,"error":"The collection could not be changed."}' }));
|
||||
await choice(page, 'Experiments').check();
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
|
||||
await expect(choice(page, 'Experiments')).not.toBeChecked();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
});
|
||||
|
||||
test('a file in no collection with no collections at all can start one', async ({ page }) => {
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
|
||||
await expect(collectionsDialog(page)).toContainText('No collections yet. Name one below.');
|
||||
await newCollection(page).fill('First');
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collections updated.');
|
||||
await expect(rows(page)).toHaveCount(1);
|
||||
await expect(row(page, 'First').locator('.nav-count')).toHaveText('1');
|
||||
});
|
||||
|
||||
for (const theme of ['dark', 'light']) {
|
||||
test(`the Collections dialog fits 390x844 ${theme}`, async ({ page, request }) => {
|
||||
await seed(request);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(`${workspace}#/images`);
|
||||
await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme);
|
||||
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
const fits = await collectionsDialog(page).evaluate(dialog => {
|
||||
const box = dialog.getBoundingClientRect();
|
||||
return box.left >= 0 && box.right <= window.innerWidth && dialog.scrollWidth <= dialog.clientWidth;
|
||||
});
|
||||
expect(fits).toBe(true);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Review M2: on a collection page, a Save that removed the file from the open collection and then failed keeps its
|
||||
// dialog and message; the listing is read again only once the dialog closes.
|
||||
test('a partly failed Save from the side panel keeps its dialog until it closes', async ({ page, request }) => {
|
||||
const manifest = await seed(request);
|
||||
const { Experiments, Landscapes } = manifest.collections;
|
||||
// experiment_01 is in both collections; Experiments sorts first, so its remove is sent before Landscapes'.
|
||||
const [image] = (await (await request.get('/prompt_manager/images/output?mode=workspace&q=experiment_01')).json()).images;
|
||||
const item = { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
|
||||
await expect(await request.post(`${API}/items`, { data: { collection_id: Landscapes.id, items: [item] } })).toBeOK();
|
||||
await page.route(`**${API}/items`, route => {
|
||||
const body = route.request().postDataJSON();
|
||||
return route.request().method() === 'DELETE' && body.collection_id === Landscapes.id
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' })
|
||||
: route.fallback();
|
||||
});
|
||||
await page.goto(`${workspace}#/collection/${Experiments.id}`);
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await expect(panel(page).getByRole('heading', { level: 2 })).toHaveText('experiment_01.png');
|
||||
await panel(page).getByRole('button', { name: 'Collections…', exact: true }).click();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
await choice(page, 'Experiments').uncheck();
|
||||
await choice(page, 'Landscapes').uncheck();
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(collectionsDialog(page).getByRole('alert')).toHaveText('The collections could not be updated. Try again.');
|
||||
// The remove from Experiments went through; the dialog shows it and the page has not reloaded under it.
|
||||
await expect(choice(page, 'Experiments')).not.toBeChecked();
|
||||
await expect(choice(page, 'Landscapes')).toBeChecked();
|
||||
await expect(imageCards(page)).toHaveCount(8);
|
||||
await collectionsDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(collectionsDialog(page)).toHaveCount(0);
|
||||
await expect(imageCards(page)).toHaveCount(7);
|
||||
await expect(card(page, 'collections/experiment_01.png')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -1791,7 +1791,7 @@ test('card checkboxes select images into the concept bar and Clear empties it',
|
||||
await checkbox(first, 'same.png').check();
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
// D85: Add tags (the concept's) and Auto-tag selected lead the real actions.
|
||||
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
|
||||
await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
|
||||
// Toggling neither opens the viewer, nor inspects, nor moves focus.
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(checkbox(first, 'same.png')).toBeFocused();
|
||||
|
||||
@@ -11,6 +11,14 @@ const bar=page=>page.locator('#library-bulkbar > .bulkbar');
|
||||
const dialog=page=>page.getByRole('dialog',{name:'Categorize selected prompts',exact:true});
|
||||
const toast=page=>page.locator('.toast');
|
||||
const heading=page=>page.locator('#workspace-main h1');
|
||||
const tagsDialog=page=>page.getByRole('dialog',{name:'Tag selected prompts',exact:true});
|
||||
const chips=(page,id)=>card(page,id).locator('.tags > .chip');
|
||||
async function addTags(page,value) {
|
||||
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
|
||||
await expect(tagsDialog(page)).toBeVisible();
|
||||
await tagsDialog(page).locator('#action-input').fill(value);
|
||||
await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
||||
}
|
||||
|
||||
async function shelf(request) {
|
||||
const body=await(await request.get('/prompt_manager/library?limit=100')).json();
|
||||
@@ -53,7 +61,8 @@ test('Set category writes the selected prompts on the server, reloads the shelf
|
||||
await expect(box(page,first.id)).toHaveAccessibleName(new RegExp(`^Select `));
|
||||
await box(page,first.id).check();await expect(bar(page).locator('strong')).toHaveText('1 selected');
|
||||
await box(page,second.id).check();await expect(bar(page).locator('strong')).toHaveText('2 selected');
|
||||
await expect(bar(page).getByRole('button')).toHaveText(['Set category','Clear']);
|
||||
// Two of three loaded are checked, so Select all N loaded follows the count (D151, #444 ruling).
|
||||
await expect(bar(page).getByRole('button')).toHaveText([`Select all ${items.length} loaded`,'Add tags','Set category','Clear']);
|
||||
await setCategory(page,' Studio set ');
|
||||
await expect(toast(page)).toHaveText('Category “Studio set” set on 2 prompts.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
@@ -175,3 +184,111 @@ test('an answer that may follow a committed write says so and reloads; a refused
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
await expect(card(page,items[0].id).locator('.card-title .chip')).toHaveText('Unconfirmed');
|
||||
});
|
||||
|
||||
// #442: Add tags writes prompt tags (never image tags) on the selected prompts.
|
||||
function recordTagWrites(page) {
|
||||
const writes=[];
|
||||
page.on('request',item=>{if(item.method()==='POST'&&new URL(item.url()).pathname==='/prompt_manager/bulk/tags')writes.push(item.postDataJSON());});
|
||||
return writes;
|
||||
}
|
||||
async function promptTags(request,id) {
|
||||
return (await shelf(request)).find(item=>item.id===id).tags;
|
||||
}
|
||||
|
||||
test('Add tags adds prompt tags to the selected prompts, suggests the vocabulary and reloads the shelf',async({page,request})=>{
|
||||
const items=await shelf(request);const [first,second,third]=items;const writes=recordTagWrites(page);
|
||||
const vocabulary=(await(await request.get('/prompt_manager/tags')).json()).tags;
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
||||
await box(page,first.id).check();await box(page,second.id).check();
|
||||
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
|
||||
await expect(tagsDialog(page).locator('datalist option')).toHaveCount(vocabulary.length);
|
||||
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();await expect(tagsDialog(page)).toHaveCount(0);
|
||||
await addTags(page,' studio-lit , Night ,studio-lit');
|
||||
await expect(toast(page)).toHaveText('Added “studio-lit, Night” to 2 prompts.');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
for(const item of [first,second]) {
|
||||
await expect(chips(page,item.id).filter({hasText:'studio-lit'})).toHaveCount(1);
|
||||
expect(await promptTags(request,item.id)).toEqual(expect.arrayContaining(['studio-lit','Night']));
|
||||
}
|
||||
expect(await promptTags(request,third.id)).not.toContain('studio-lit');
|
||||
const epoch=(await(await request.get('/prompt_manager/library/options')).json()).library_epoch;
|
||||
expect(writes).toEqual([{prompt_ids:[first.id,second.id],tags:['studio-lit','Night'],expected_epoch:epoch}]);
|
||||
// A second add of the same names changes nothing and says so.
|
||||
await box(page,first.id).check();await addTags(page,'Studio-Lit');
|
||||
await expect(toast(page)).toHaveText('1 prompt already had “Studio-Lit”.');
|
||||
});
|
||||
|
||||
test('the Add tags field states what it refuses, and a restore the page did not see adds nothing',async({page,request})=>{
|
||||
await armUnseenRestores(page);
|
||||
const items=await shelf(request);const writes=recordTagWrites(page);
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(items.length);
|
||||
await box(page,items[0].id).check();
|
||||
await bar(page).getByRole('button',{name:'Add tags',exact:true}).click();
|
||||
const field=tagsDialog(page).locator('#action-input');
|
||||
for(const [value,message] of [[' , ','Enter at least one tag.'],['x'.repeat(51),'A tag can have at most 50 characters.'],
|
||||
[Array.from({length:21},(_,i)=>`t${i}`).join(','),'Enter at most 20 tags.']]) {
|
||||
await field.fill(value);await tagsDialog(page).getByRole('button',{name:'Apply',exact:true}).click();
|
||||
await expect(tagsDialog(page)).toBeVisible();
|
||||
expect(await field.evaluate(node=>node.validationMessage)).toBe(message);
|
||||
}
|
||||
await tagsDialog(page).getByRole('button',{name:'Cancel',exact:true}).click();
|
||||
expect(writes).toHaveLength(0);
|
||||
const backup=await request.get('/prompt_manager/backup');await expect(backup).toBeOK();const buffer=await backup.body();
|
||||
const restore=await unseenRestore(page,()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'library.db',mimeType:'application/octet-stream',buffer}}}));
|
||||
await expect(restore).toBeOK();
|
||||
await addTags(page,'after-restore');
|
||||
await expect(toast(page)).toHaveText('Prompt storage was restored since this page loaded. No tags were added.');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
expect((await(await request.get('/prompt_manager/tags')).json()).tags).not.toContain('after-restore');
|
||||
});
|
||||
|
||||
// #444 ruling (D151): select every loaded prompt, as the legacy page's select-all checkbox did for its page.
|
||||
test('Ctrl/Cmd+A and Select all N loaded select every loaded prompt, windowed ones included',async({page,request,isMobile})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}})).toBeOK();
|
||||
if(!isMobile)await page.setViewportSize({width:1600,height:1100});
|
||||
const writes=recordTagWrites(page);
|
||||
await page.goto(route);await expect(cards(page).first()).toBeVisible();
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(let index=0;index<3;index++) {
|
||||
await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
||||
await more.evaluate(node=>node.click());await response;
|
||||
}
|
||||
await expect(more).toHaveAttribute('aria-disabled','false');
|
||||
const loaded=160;expect(await cards(page).count()).toBeLessThan(loaded);
|
||||
// In the search box Ctrl/Cmd+A keeps its text meaning: it selects the box's text, no prompt.
|
||||
await page.locator('#search').focus();await page.keyboard.press('ControlOrMeta+a');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
// One checked card offers the rest; the offer selects every loaded prompt and focus moves to the first action.
|
||||
const firstId=Number(await cards(page).first().getAttribute('data-prompt-id'));
|
||||
await box(page,firstId).check();
|
||||
const offer=bar(page).getByRole('button',{name:`Select all ${loaded} loaded`,exact:true});
|
||||
await expect(bar(page).locator(':scope > *').nth(1)).toHaveText(`Select all ${loaded} loaded`);
|
||||
await offer.click();
|
||||
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
|
||||
await expect(offer).toHaveCount(0);
|
||||
await expect(bar(page).getByRole('button',{name:'Add tags',exact:true})).toBeFocused();
|
||||
// Another batch arrives unchecked: the offer comes back with the new N.
|
||||
{
|
||||
const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});
|
||||
await more.evaluate(node=>node.click());await response;
|
||||
}
|
||||
await expect(bar(page).getByRole('button',{name:`Select all ${loaded+40} loaded`,exact:true})).toBeVisible();
|
||||
await expect(bar(page).locator('strong')).toHaveText(`${loaded} selected`);
|
||||
// Escape in the grid clears; Ctrl/Cmd+A in the grid selects them all again.
|
||||
const title=cards(page).first().locator('.card-title > button');
|
||||
await title.focus();await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();await expect(title).toBeFocused();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
const all=loaded+40;
|
||||
await expect(bar(page).locator('strong')).toHaveText(`${all} selected`);
|
||||
// While the write runs, every bar action and the offer are aria-disabled.
|
||||
await box(page,firstId).uncheck();
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
await page.route('**/prompt_manager/bulk/tags',async route=>{await held;await route.continue();},{times:1});
|
||||
await addTags(page,'everyone');
|
||||
for(const name of ['Add tags','Set category',`Select all ${all} loaded`]) await expect(bar(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true');
|
||||
release();
|
||||
await expect(toast(page)).toHaveText(`Added “everyone” to ${all-1} prompts.`);
|
||||
expect(writes).toHaveLength(1);expect(new Set(writes[0].prompt_ids).size).toBe(all-1);
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ const jobStart = page => page.waitForRequest(item => item.method() === 'POST' &&
|
||||
|
||||
test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => {
|
||||
await select(page, FIRST);
|
||||
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']);
|
||||
await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection', 'Export metadata', 'Clear']);
|
||||
});
|
||||
|
||||
test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => {
|
||||
|
||||
@@ -263,15 +263,18 @@ test.describe('public chronological history with the actual acknowledged host br
|
||||
});
|
||||
});
|
||||
|
||||
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
|
||||
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
|
||||
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
|
||||
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
|
||||
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
|
||||
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
|
||||
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
|
||||
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
|
||||
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
|
||||
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
|
||||
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
|
||||
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
|
||||
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
|
||||
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
|
||||
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
|
||||
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
|
||||
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
|
||||
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
|
||||
});
|
||||
|
||||
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
|
||||
@@ -369,7 +372,7 @@ test('successful source capture survives downstream failure and real interruptio
|
||||
}
|
||||
});
|
||||
|
||||
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
|
||||
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
|
||||
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
|
||||
const first = await submit(execution.request, graph);
|
||||
await history(execution.request, first);
|
||||
@@ -377,8 +380,9 @@ test('cached dynamic raw stays unknown while literal affixes and effective cache
|
||||
const second = await submit(execution.request, graph);
|
||||
await history(execution.request, second);
|
||||
const value = (await capture(execution, second)).contexts[0];
|
||||
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
|
||||
expect(value.evidence.positive.raw.provenance).toBe('unknown');
|
||||
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
|
||||
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
|
||||
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
|
||||
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
|
||||
});
|
||||
|
||||
|
||||
@@ -287,6 +287,14 @@ async function capturedContext(execution, id) {
|
||||
return result.captures[result.rows[0].run_id].contexts[0];
|
||||
}
|
||||
|
||||
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
|
||||
async function expectTrackedRuns(execution, promptId, expected) {
|
||||
await expect.poll(async () => {
|
||||
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
|
||||
return response.ok() ? (await response.json()).recorded_run_count : null;
|
||||
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
|
||||
}
|
||||
|
||||
async function firstRun(execution, page, testCase) {
|
||||
const prefix = `textlink/${randomUUID()}`;
|
||||
const built = await build(page, { ...testCase, prefix });
|
||||
@@ -309,6 +317,8 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
|
||||
const counts = await runCounts(execution, first.context);
|
||||
expect(counts.positive).toBe(3);
|
||||
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
|
||||
// #403: the cached jobs are tracked runs of the prompt too
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -329,6 +339,7 @@ test.describe('cached runs in one renderer (#347)', () => {
|
||||
else expect(again.cached).toContain(first.promptNode);
|
||||
}
|
||||
expect((await runCounts(execution, first.context)).positive).toBe(3);
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
|
||||
} finally { await setCache(execution, 'RAM_PRESSURE'); }
|
||||
});
|
||||
}
|
||||
@@ -352,6 +363,9 @@ test.describe('cached runs in one renderer (#347)', () => {
|
||||
const bContext = await capturedContext(execution, second.id);
|
||||
expect(bContext.positive.raw).toBe(b);
|
||||
expect((await runCounts(execution, bContext)).positive).toBe(1);
|
||||
// #403: the third job reuses A's execution, not the newest (B's)
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
|
||||
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
|
||||
} finally { await setCache(execution, 'RAM_PRESSURE'); }
|
||||
});
|
||||
|
||||
@@ -390,10 +404,14 @@ test.describe('cached runs in one renderer (#347)', () => {
|
||||
} });
|
||||
expect(rewired.cached).toContain(first.promptNode);
|
||||
expect((await runCounts(execution, first.context)).positive).toBe(1);
|
||||
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
|
||||
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
|
||||
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
|
||||
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
|
||||
expect(cachedContext.positive_prompt_id).toBeNull();
|
||||
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
|
||||
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
|
||||
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
|
||||
expect((await runCounts(execution, first.context)).positive).toBe(2);
|
||||
// The rewired job used the prompt as its negative: neither number counts it
|
||||
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,6 +384,285 @@ test('the D117 wrap rule targets only the settings summary button', async () =>
|
||||
assert.doesNotMatch(css, /#viewer-details > div > button/);
|
||||
});
|
||||
|
||||
// #331 D139 (design §8): the shared inspector's "Collections…" footer button and its checklist dialog.
|
||||
const collectionsDialog = await import('../../web/js/workspace/inspector-collections.mjs').catch(() => null);
|
||||
const dialogModule = () => { assert.ok(collectionsDialog, 'inspector-collections.mjs is not implemented'); return collectionsDialog; };
|
||||
const COLLECTION_SCOPE = 'a'.repeat(64);
|
||||
const FILE = {root_index:2, root_scope:COLLECTION_SCOPE, path:'a.png'};
|
||||
function collectionsStore({member = [3], membership, add, remove} = {}) {
|
||||
const calls = [];
|
||||
const store = {
|
||||
calls,
|
||||
collections:[{id:3, name:'LoRa testing', count:3, missing:0}, {id:5, name:'Portraits', count:1, missing:0}],
|
||||
membership: async target => { calls.push(['membership', target]); return membership ? membership() : [...member]; },
|
||||
add: async (where, items) => { calls.push(['add', where, items]); if (add) return add(where, items);
|
||||
return {collection:{id:where.collectionId ?? 9, name:where.name ?? 'x'}, created:!!where.name, added:1, already:0, notFound:0}; },
|
||||
remove: async (id, items) => { calls.push(['remove', id, items]); if (remove) return remove(id, items);
|
||||
return {collection:{id, name:'x'}, removed:1}; },
|
||||
settled: async () => ({}),
|
||||
};
|
||||
return store;
|
||||
}
|
||||
function footerButtons(f) {
|
||||
return f.walk(f.container, n => n.tagName === 'button').map(n => n.textContent.trim());
|
||||
}
|
||||
test('inspector shows Collections… only for a rooted file with a store', async () => {
|
||||
for (const variant of ['panel', 'full']) {
|
||||
const f = mountedDom(), scope = createScope();
|
||||
try {
|
||||
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, collections:collectionsStore()});
|
||||
view.setState({loading:false, error:false, record:{...record(), historyTarget:'#/library/1'}});
|
||||
const labels = footerButtons(f);
|
||||
assert.ok(labels.includes('Collections…'), `${variant}: the button is offered`);
|
||||
// After Prompt history, as the last footer action.
|
||||
assert.equal(labels.at(-1), 'Collections…');
|
||||
assert.equal(labels.at(-2), 'Prompt history');
|
||||
const button = f.walk(f.container, n => n.tagName === 'button' && n.textContent.trim() === 'Collections…')[0];
|
||||
assert.equal(button.children[0].tagName, 'svg', 'with the folder icon');
|
||||
view.dispose();
|
||||
} finally { scope.dispose(); f.restore(); }
|
||||
}
|
||||
// No store (Prompt library galleries, Inspect a file) or a file that is not a listed one: no button.
|
||||
const local = await localRecord(localPng('Fox Render.png'));
|
||||
for (const [shown, store] of [[record(), null], [local, collectionsStore()],
|
||||
[{...record(), identity:{...record().identity, root_scope:null}}, collectionsStore()]]) {
|
||||
const f = mountedDom(), scope = createScope();
|
||||
try {
|
||||
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant:'full', collections:store});
|
||||
view.setState({loading:false, error:false, record:shown});
|
||||
assert.ok(!footerButtons(f).includes('Collections…'));
|
||||
view.dispose();
|
||||
} finally { scope.dispose(); f.restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
/** Open the Collections dialog over FILE; returns its nodes by role and helpers. */
|
||||
function openCollections(store, extra = {}) {
|
||||
const {openCollectionsDialog} = dialogModule();
|
||||
const f = mountedDom(), scope = createScope(), toasts = [], changes = [];
|
||||
let dialog = null, closed = 0;
|
||||
openCollectionsDialog({scope, collections:store, target:FILE, trigger:null, notify:text => toasts.push(text),
|
||||
onChanged:change => changes.push(change), openDialog:args => { dialog = args; f.container.append(...args.nodes); },
|
||||
// As the shell does: closing runs the dialog's onClose once.
|
||||
closeDialog:() => { closed++; dialog?.onClose?.(); }, ...extra});
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const find = predicate => f.walk(f.container, predicate);
|
||||
const boxes = () => find(n => n.tagName === 'input' && n.type === 'checkbox');
|
||||
const button = text => find(n => n.tagName === 'button' && n.textContent === text)[0];
|
||||
const text = () => find(n => n.tagName === 'p').map(n => n.textContent).filter(Boolean);
|
||||
return {f, scope, toasts, changes, get dialog() { return dialog; }, get closed() { return closed; }, settle, find, boxes, button, text,
|
||||
done() { scope.dispose(); f.restore(); }};
|
||||
}
|
||||
|
||||
test('the Collections dialog loads the membership and lists every collection', async () => {
|
||||
const store = collectionsStore();
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
assert.equal(view.dialog.title, 'Collections');
|
||||
assert.ok(view.text().includes('Loading collections…'));
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
|
||||
await view.settle();
|
||||
assert.deepEqual(store.calls, [['membership', FILE]]);
|
||||
const labels = view.find(n => n.tagName === 'label' && n.className === 'tag-suggestion');
|
||||
assert.deepEqual(labels.map(label => label.textContent), ['LoRa testing', 'Portraits']);
|
||||
assert.deepEqual(view.boxes().map(box => box.checked), [true, false]);
|
||||
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
|
||||
assert.equal(field.maxLength, 100);
|
||||
assert.equal(view.find(n => n.tagName === 'label' && n.htmlFor === field.id)[0].textContent, 'New collection');
|
||||
assert.deepEqual(view.find(n => n.className === 'dialog-actions')[0].children.map(n => n.textContent), ['Cancel', 'Save']);
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('without collections the dialog says to name one', async () => {
|
||||
const store = collectionsStore({member:[]});
|
||||
store.collections = [];
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
assert.ok(view.text().includes('No collections yet. Name one below.'));
|
||||
assert.equal(view.boxes().length, 0);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('a failed membership read offers Retry', async () => {
|
||||
let failing = true;
|
||||
const store = collectionsStore({membership:() => { if (failing) throw new TypeError('offline'); return [5]; }});
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
assert.ok(view.text().includes('Collections could not be loaded.'));
|
||||
assert.equal(view.button('Retry').hidden, false);
|
||||
assert.equal(view.boxes().length, 0);
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
|
||||
failing = false;
|
||||
view.button('Retry').fire('click');
|
||||
await view.settle();
|
||||
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
|
||||
assert.equal(view.button('Retry').hidden, true);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('Save sends the new name, each added and each removed collection, in that order', async () => {
|
||||
const store = collectionsStore();
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
const [lora, portraits] = view.boxes();
|
||||
lora.checked = false; portraits.checked = true;
|
||||
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' Grids ';
|
||||
view.button('Save').fire('click');
|
||||
await view.settle(); await view.settle();
|
||||
assert.deepEqual(store.calls.slice(1), [
|
||||
['add', {name:'Grids'}, [FILE]],
|
||||
['add', {collectionId:5}, [FILE]],
|
||||
['remove', 3, [FILE]],
|
||||
]);
|
||||
assert.equal(view.closed, 1);
|
||||
assert.deepEqual(view.toasts, ['Collections updated.']);
|
||||
assert.deepEqual(view.changes, [{added:[9, 5], removed:[3]}]);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('Save without a change sends nothing and closes', async () => {
|
||||
const store = collectionsStore();
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' ';
|
||||
view.button('Save').fire('click');
|
||||
await view.settle();
|
||||
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
|
||||
assert.equal(view.closed, 1);
|
||||
assert.deepEqual(view.toasts, []);
|
||||
assert.deepEqual(view.changes, []);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('a failed Save keeps the dialog, says so and shows the current membership', async () => {
|
||||
let reads = 0;
|
||||
const store = collectionsStore({membership:() => (reads++ ? [5] : [3]),
|
||||
remove:() => { throw Object.assign(new Error('x'), {status:500}); }});
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
const [lora, portraits] = view.boxes();
|
||||
lora.checked = false; portraits.checked = true;
|
||||
view.button('Save').fire('click');
|
||||
// While it saves, × and Escape keep the dialog and Save is busy.
|
||||
assert.equal(view.dialog.canClose(), false);
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
|
||||
for (let index = 0; index < 4; index++) await view.settle();
|
||||
assert.equal(view.closed, 0);
|
||||
assert.deepEqual(view.toasts, []);
|
||||
const alert = view.find(n => n.getAttribute?.('role') === 'alert')[0];
|
||||
assert.equal(alert.textContent, 'The collections could not be updated. Try again.');
|
||||
// The add went through; the list shows what the server holds now. Review M2: the page hears about it only once
|
||||
// the dialog closes, so a listing refresh cannot dispose the dialog before its failure is read.
|
||||
assert.deepEqual(view.changes, []);
|
||||
assert.deepEqual(view.boxes().map(box => box.checked), [false, true]);
|
||||
assert.equal(view.dialog.canClose(), true);
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
|
||||
view.button('Cancel').fire('click');
|
||||
assert.deepEqual(view.changes, [{added:[5], removed:[]}]);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('a name the store cannot take is refused on the field, keeping the edits (review L5)', async () => {
|
||||
const store = collectionsStore();
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
const [lora, portraits] = view.boxes();
|
||||
lora.checked = false; portraits.checked = true;
|
||||
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
|
||||
const validity = [];
|
||||
field.setCustomValidity = text => validity.push(text);
|
||||
field.reportValidity = () => validity.push('reported');
|
||||
field.value = 'bad\u0001name';
|
||||
view.button('Save').fire('click');
|
||||
for (let index = 0; index < 3; index++) await view.settle();
|
||||
assert.deepEqual(store.calls.map(call => call[0]), ['membership'], 'nothing sent and no re-read');
|
||||
assert.deepEqual(validity, ['The collections could not be updated. Try again.', 'reported']);
|
||||
assert.deepEqual(view.boxes().map(box => box.checked), [false, true], 'the checkbox edits stay');
|
||||
assert.equal(view.closed, 0);
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
|
||||
// Editing the field clears its message.
|
||||
field.fire('input');
|
||||
assert.equal(validity.at(-1), '');
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
|
||||
test('after a failed Save, Save waits for the membership re-read (review M1)', async () => {
|
||||
let release;
|
||||
const store = collectionsStore({remove:() => { throw new Error('x'); }});
|
||||
store.settled = () => new Promise(resolve => { release = resolve; });
|
||||
const view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
view.boxes()[0].checked = false;
|
||||
view.button('Save').fire('click');
|
||||
for (let index = 0; index < 3; index++) await view.settle();
|
||||
assert.equal(typeof release, 'function', 'the store is settling');
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), 'true');
|
||||
assert.ok(view.text().includes('Loading collections…'));
|
||||
const sent = store.calls.length;
|
||||
view.button('Save').fire('click');
|
||||
await view.settle();
|
||||
assert.equal(store.calls.length, sent, 'no stale plan is sent');
|
||||
release({});
|
||||
for (let index = 0; index < 3; index++) await view.settle();
|
||||
assert.equal(view.button('Save').getAttribute('aria-disabled'), null);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('a typed name of an existing collection is that collection checkbox (review L4)', async () => {
|
||||
// "portraits" names Portraits (not a member): one add by id, no name request.
|
||||
let store = collectionsStore();
|
||||
let view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = ' PORTRAITS ';
|
||||
view.button('Save').fire('click');
|
||||
for (let index = 0; index < 3; index++) await view.settle();
|
||||
assert.deepEqual(store.calls.slice(1), [['add', {collectionId:5}, [FILE]]]);
|
||||
} finally { view.done(); }
|
||||
// Unchecking LoRa testing while typing its name keeps it: nothing to send.
|
||||
store = collectionsStore();
|
||||
view = openCollections(store);
|
||||
try {
|
||||
await view.settle();
|
||||
view.boxes()[0].checked = false;
|
||||
view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].value = 'lora testing';
|
||||
view.button('Save').fire('click');
|
||||
await view.settle();
|
||||
assert.deepEqual(store.calls.map(call => call[0]), ['membership']);
|
||||
assert.equal(view.closed, 1);
|
||||
} finally { view.done(); }
|
||||
});
|
||||
|
||||
test('focus starts on the field, moves to the first checkbox once listed, and falls back on close (review L6)', async () => {
|
||||
const trigger = {isConnected:false, focus() { assert.fail('a detached trigger is not focused'); }};
|
||||
const fallback = {focused:false, focus() { this.focused = true; }};
|
||||
const view = openCollections(collectionsStore(), {trigger, focusFallback:() => fallback});
|
||||
try {
|
||||
const field = view.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0];
|
||||
assert.equal(view.dialog.focusTarget, field);
|
||||
await view.settle();
|
||||
assert.equal(view.boxes()[0].focused, true);
|
||||
view.button('Cancel').fire('click');
|
||||
assert.equal(fallback.focused, true);
|
||||
} finally { view.done(); }
|
||||
const empty = collectionsStore({member:[]});
|
||||
empty.collections = [];
|
||||
const none = openCollections(empty);
|
||||
try {
|
||||
await none.settle();
|
||||
assert.equal(none.find(n => n.tagName === 'input' && n.type !== 'checkbox')[0].focused, true);
|
||||
} finally { none.done(); }
|
||||
});
|
||||
|
||||
test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => {
|
||||
const base = await localRecord(localPng('a.png'));
|
||||
const chipTexts = record => {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// The Images selection bar's collection actions (#331 D138, design §7): Add to collection after Set category, Remove
|
||||
// from collection beside it while a collection filter is active, the 500 cap and every toast of §7.4.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Guarded: before the module exists every test fails on an assertion, not on the import.
|
||||
const loaded = await import('../../web/js/workspace/images-collections.mjs').catch(() => null);
|
||||
const module = () => { assert.ok(loaded, 'images-collections.mjs is not implemented'); return loaded; };
|
||||
const { mountSelectionBar } = await import('../../web/js/workspace/images-selection.mjs');
|
||||
const { createScope } = await import('../../web/js/shared/toolkit/core.js');
|
||||
const strings = await import('../../web/js/workspace/collections.mjs');
|
||||
|
||||
const SCOPE = 'b'.repeat(64);
|
||||
const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra });
|
||||
const target = path => ({ root_index: 0, root_scope: SCOPE, path });
|
||||
const images = count => Array.from({ length: count }, (_, index) => image(`set/${index}.png`));
|
||||
const keyOf = item => `${item.root_index}:${item.path}`;
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const coded = (code, status = 400) => Object.assign(new Error(code), { name: 'ApiError', code, status });
|
||||
|
||||
/** A minimal DOM for the selection bar: elements with order, attributes, listeners, focus and a toast. */
|
||||
function fakeDOM() {
|
||||
const previous = globalThis.document;
|
||||
let active = null;
|
||||
class Element extends EventTarget {
|
||||
constructor(tag) {
|
||||
super();
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.attributes = {};
|
||||
this.dataset = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this._text = '';
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
get nextElementSibling() {
|
||||
const siblings = this.parentNode?.children ?? [];
|
||||
return siblings[siblings.indexOf(this) + 1] ?? null;
|
||||
}
|
||||
append(...nodes) {
|
||||
for (const node of nodes) { node.remove(); node.parentNode = this; this.children.push(node); }
|
||||
}
|
||||
after(node) {
|
||||
node.remove();
|
||||
node.parentNode = this.parentNode;
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this) + 1, 0, node);
|
||||
}
|
||||
remove() {
|
||||
if (!this.parentNode) return;
|
||||
this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1);
|
||||
this.parentNode = null;
|
||||
}
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
querySelector() { return this.children.find(child => child.tagName === 'BUTTON' && child.getAttribute('aria-disabled') !== 'true') ?? null; }
|
||||
querySelectorAll() { return []; }
|
||||
focus() { active = this; }
|
||||
click() { this.dispatchEvent(new Event('click')); }
|
||||
}
|
||||
const toast = new Element('div');
|
||||
const body = new Element('body');
|
||||
globalThis.document = {
|
||||
body,
|
||||
get activeElement() { return active ?? body; },
|
||||
createElement: tag => new Element(tag),
|
||||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||||
getElementById: id => (id === 'workspace-toast' ? toast : null),
|
||||
};
|
||||
return { Element, body, toast, restore() { globalThis.document = previous; } };
|
||||
}
|
||||
|
||||
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
|
||||
function mountBar(dom, items, checkedKeys, options = {}) {
|
||||
const scope = createScope();
|
||||
const grid = new dom.Element('div');
|
||||
const focusFallback = new dom.Element('h1');
|
||||
const bar = mountSelectionBar({ scope, api: {}, grid, getItems: () => items, focusFallback, onCategorySet() {},
|
||||
activity: options.activity ?? null, onTagged() {}, getFilter: () => ({}),
|
||||
getListing: () => ({ total: options.total ?? items.length, hasMore: false, positiveCount: null }),
|
||||
checked: Object.freeze(new Set(checkedKeys)), ...options.props });
|
||||
return { scope, bar, labels: () => bar.element.children[0]?.children.map(child => child.textContent) ?? [],
|
||||
button: text => bar.element.children[0].children.find(child => child.textContent === text) };
|
||||
}
|
||||
|
||||
function store(overrides = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
collections: [{ id: 3, name: 'Experiments', count: 8, missing: 0 }, { id: 7, name: 'Landscapes', count: 12, missing: 1 }],
|
||||
add: async (where, items) => { calls.push(['add', where, items]); return overrides.add?.(where, items); },
|
||||
remove: async (id, items) => { calls.push(['remove', id, items]); return overrides.remove?.(id, items); },
|
||||
};
|
||||
}
|
||||
|
||||
test('selection bar lists Add to collection after Set category', () => {
|
||||
const dom = fakeDOM();
|
||||
try {
|
||||
const items = images(2);
|
||||
const collections = store();
|
||||
const plain = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections } });
|
||||
assert.deepEqual(plain.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Export metadata', 'Clear']);
|
||||
plain.scope.dispose();
|
||||
// §7.1: Remove from collection beside Add, only while a collection filter is active.
|
||||
const filtered = mountBar(dom, items, [keyOf(items[0])], { activity: {}, props: { collections, collection: '7' } });
|
||||
assert.deepEqual(filtered.labels(), ['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Add to collection',
|
||||
'Remove from collection', 'Export metadata', 'Clear']);
|
||||
filtered.scope.dispose();
|
||||
// Without the client store (a page that has none) neither shows.
|
||||
const none = mountBar(dom, items, [keyOf(items[0])]);
|
||||
assert.deepEqual(none.labels(), ['1 selected', 'Set category', 'Export metadata', 'Clear']);
|
||||
none.scope.dispose();
|
||||
} finally { dom.restore(); }
|
||||
});
|
||||
|
||||
test('more than 500 checked toasts and opens nothing', async () => {
|
||||
const dom = fakeDOM();
|
||||
try {
|
||||
const items = images(501);
|
||||
const collections = store();
|
||||
const view = mountBar(dom, items, items.map(keyOf), { props: { collections, collection: '7' } });
|
||||
view.button('Add to collection').click();
|
||||
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
|
||||
assert.equal(dom.body.children.length, 0, 'no dialog opened');
|
||||
dom.toast.textContent = '';
|
||||
view.button('Remove from collection').click();
|
||||
await flush();
|
||||
assert.equal(dom.toast.textContent, 'Select at most 500 images.');
|
||||
assert.deepEqual(collections.calls, []);
|
||||
view.scope.dispose();
|
||||
} finally { dom.restore(); }
|
||||
});
|
||||
|
||||
test('Remove from collection removes the checked files and reloads the listing', async () => {
|
||||
const dom = fakeDOM();
|
||||
try {
|
||||
const items = images(3);
|
||||
let changed = 0;
|
||||
const collections = store({ remove: () => ({ collection: { id: 7, name: 'Landscapes' }, removed: 2 }) });
|
||||
const view = mountBar(dom, items, [keyOf(items[0]), keyOf(items[2])], { props: { collections, collection: '7',
|
||||
onCollectionsChanged: () => { changed++; } } });
|
||||
const remove = view.button('Remove from collection');
|
||||
remove.click();
|
||||
// A second press while the first is in flight is ignored.
|
||||
assert.equal(remove.getAttribute('aria-disabled'), 'true');
|
||||
remove.click();
|
||||
await flush();
|
||||
assert.deepEqual(collections.calls, [['remove', 7, [target('set/0.png'), target('set/2.png')]]]);
|
||||
assert.equal(dom.toast.textContent, 'Removed 2 images from “Landscapes”.');
|
||||
assert.equal(changed, 1);
|
||||
assert.equal(remove.getAttribute('aria-disabled'), null);
|
||||
view.scope.dispose();
|
||||
} finally { dom.restore(); }
|
||||
});
|
||||
|
||||
test('the collection-target rule keeps the all-matching selection to its loaded images', () => {
|
||||
const { collectionTargets } = module();
|
||||
assert.deepEqual(collectionTargets(images(2)), { targets: [target('set/0.png'), target('set/1.png')] });
|
||||
assert.deepEqual(collectionTargets(images(501)), { refusal: strings.TOO_MANY_TOAST });
|
||||
// All N matching: only when every match is loaded and checked, and at most 500.
|
||||
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 2 }), { targets: [target('set/0.png'), target('set/1.png')] });
|
||||
assert.deepEqual(collectionTargets(images(2), { allMatching: true, total: 3 }), { refusal: strings.TOO_MANY_TOAST });
|
||||
assert.deepEqual(collectionTargets(images(501), { allMatching: true, total: 501 }), { refusal: strings.TOO_MANY_TOAST });
|
||||
// A file without a listing identity cannot be named; none left is "could not be found".
|
||||
assert.deepEqual(collectionTargets([{ ...image('x.png'), root_scope: 'nope' }]), { refusal: strings.FILES_NOT_FOUND_TOAST });
|
||||
});
|
||||
|
||||
const pageScope = () => {
|
||||
const controller = new AbortController();
|
||||
return { signal: controller.signal, cleanup() {}, abort: () => controller.abort() };
|
||||
};
|
||||
|
||||
test('Add to collection opens the concept dialog with the existing names as suggestions', () => {
|
||||
const { openAddToCollection } = module();
|
||||
let dialog = null;
|
||||
const collections = store();
|
||||
openAddToCollection({ scope: pageScope(), collections, images: images(1), trigger: 'trigger', notify: assert.fail,
|
||||
openInput: args => { dialog = args; } });
|
||||
assert.equal(dialog.title, 'Add selected images to collection');
|
||||
assert.equal(dialog.label, 'Collection name');
|
||||
assert.equal(dialog.action, 'Add');
|
||||
assert.equal(dialog.maxLength, 100);
|
||||
assert.equal(dialog.trigger, 'trigger');
|
||||
assert.deepEqual(dialog.suggestions, ['Experiments', 'Landscapes']);
|
||||
assert.equal(dialog.value ?? '', '');
|
||||
});
|
||||
|
||||
/** Apply `name` in the Add dialog over `count` images; resolves with the toasts and the store's calls. */
|
||||
async function add(name, answer, { count = 1, extra = {} } = {}) {
|
||||
const { openAddToCollection } = module();
|
||||
const toasts = [];
|
||||
let dialog = null, sourceChanged = 0;
|
||||
const collections = store({ add: answer });
|
||||
openAddToCollection({ scope: pageScope(), collections, images: images(count), notify: text => toasts.push(text),
|
||||
openInput: args => { dialog = args; }, onSourceChanged: () => { sourceChanged++; }, ...extra });
|
||||
await dialog.onApply(name);
|
||||
return { toasts, calls: collections.calls, sourceChanged };
|
||||
}
|
||||
const answer = (added, already, notFound, created = false) => () => ({ collection: { id: 9, name: 'LoRa testing' }, created,
|
||||
added, already, notFound });
|
||||
|
||||
test('Add sends the normalized name and the checked files', async () => {
|
||||
const { toasts, calls } = await add(' LoRa testing ', answer(3, 0, 0, true), { count: 3 });
|
||||
assert.deepEqual(calls, [['add', { name: 'LoRa testing' }, [target('set/0.png'), target('set/1.png'), target('set/2.png')]]]);
|
||||
assert.deepEqual(toasts, ['Added 3 images to “LoRa testing”.']);
|
||||
});
|
||||
|
||||
test('Add toasts every outcome of §7.4', async () => {
|
||||
const cases = [
|
||||
[answer(1, 0, 0), 'Added 1 image to “LoRa testing”.'],
|
||||
[answer(1, 1, 0), 'Added 1 image to “LoRa testing”. 1 was already in it.'],
|
||||
[answer(2, 3, 0), 'Added 2 images to “LoRa testing”. 3 were already in it.'],
|
||||
[answer(1, 0, 1), 'Added 1 image to “LoRa testing”. 1 could not be found.'],
|
||||
[answer(1, 2, 4), 'Added 1 image to “LoRa testing”. 2 were already in it. 4 could not be found.'],
|
||||
[answer(0, 2, 0), 'All selected images were already in “LoRa testing”.'],
|
||||
[answer(0, 1, 1), 'Added 0 images to “LoRa testing”. 1 was already in it. 1 could not be found.'],
|
||||
[() => { throw coded('files-not-found', 404); }, 'None of the selected images could be found. Refresh the images and try again.'],
|
||||
[() => { throw coded('collection-not-found', 404); }, 'That collection no longer exists.'],
|
||||
[() => { throw new TypeError('offline'); }, 'The images could not be added. Try again.'],
|
||||
];
|
||||
for (const [reply, text] of cases) assert.deepEqual((await add('LoRa testing', reply)).toasts, [text], text);
|
||||
});
|
||||
|
||||
test('a stale source on Add says so and reloads the listing', async () => {
|
||||
const { toasts, sourceChanged } = await add('Grids', () => { throw coded('source-changed', 409); });
|
||||
assert.deepEqual(toasts, ['The sources changed. Refresh the images and try again.']);
|
||||
assert.equal(sourceChanged, 1);
|
||||
});
|
||||
|
||||
test('Add with a name the store cannot take sends nothing and says it failed', async () => {
|
||||
const { toasts, calls } = await add('bad\u0001name', answer(1, 0, 0));
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(toasts, ['The images could not be added. Try again.']);
|
||||
});
|
||||
|
||||
test('Add after the page left shows no toast', async () => {
|
||||
const { openAddToCollection } = module();
|
||||
const scope = pageScope();
|
||||
const toasts = [];
|
||||
let dialog = null;
|
||||
openAddToCollection({ scope, collections: store({ add: answer(1, 0, 0) }), images: images(1), notify: text => toasts.push(text),
|
||||
openInput: args => { dialog = args; } });
|
||||
scope.abort();
|
||||
await dialog.onApply('Grids');
|
||||
assert.deepEqual(toasts, []);
|
||||
});
|
||||
|
||||
test('Add refuses an over-cap or all-matching selection before any dialog', () => {
|
||||
const { openAddToCollection } = module();
|
||||
const toasts = [];
|
||||
const open = extra => openAddToCollection({ scope: pageScope(), collections: store(), notify: text => toasts.push(text),
|
||||
openInput: () => assert.fail('no dialog opens'), ...extra });
|
||||
open({ images: images(501) });
|
||||
open({ images: images(2), allMatching: true, total: 40 });
|
||||
assert.deepEqual(toasts, [strings.TOO_MANY_TOAST, strings.TOO_MANY_TOAST]);
|
||||
});
|
||||
|
||||
/** Remove `count` images from collection 7 with `reply`; resolves with the toasts and callbacks. */
|
||||
async function remove(reply, count = 1) {
|
||||
const { removeFromCollection } = module();
|
||||
const toasts = [];
|
||||
let changed = 0, sourceChanged = 0;
|
||||
const collections = store({ remove: reply });
|
||||
await removeFromCollection({ scope: pageScope(), collections, collectionId: 7, images: images(count),
|
||||
notify: text => toasts.push(text), onChanged: () => { changed++; }, onSourceChanged: () => { sourceChanged++; } });
|
||||
return { toasts, changed, sourceChanged, calls: collections.calls };
|
||||
}
|
||||
|
||||
test('Remove toasts every outcome of §7.4', async () => {
|
||||
assert.deepEqual(await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 1 })),
|
||||
{ toasts: ['Removed 1 image from “Landscapes”.'], changed: 1, sourceChanged: 0, calls: [['remove', 7, [target('set/0.png')]]] });
|
||||
assert.deepEqual((await remove(() => ({ collection: { id: 7, name: 'Landscapes' }, removed: 3 }), 3)).toasts,
|
||||
['Removed 3 images from “Landscapes”.']);
|
||||
// A failure keeps the listing; a deleted collection reloads it (the page then says the collection is gone).
|
||||
const failed = await remove(() => { throw new TypeError('offline'); });
|
||||
assert.deepEqual([failed.toasts, failed.changed], [['The images could not be removed. Try again.'], 0]);
|
||||
const gone = await remove(() => { throw coded('collection-not-found', 404); });
|
||||
assert.deepEqual([gone.toasts, gone.changed], [['That collection no longer exists.'], 1]);
|
||||
const stale = await remove(() => { throw coded('source-changed', 409); });
|
||||
assert.deepEqual([stale.toasts, stale.changed, stale.sourceChanged], [['The sources changed. Refresh the images and try again.'], 0, 1]);
|
||||
});
|
||||
@@ -42,3 +42,55 @@ test('the field refuses control characters as validate_category does', async ()
|
||||
assert.equal(categoryProblem('nul\u0000'), CONTROL_CHARACTERS);
|
||||
assert.equal(categoryProblem('unicode ✓ é'), null);
|
||||
});
|
||||
|
||||
test('Add tags uses the Images dialog words for prompts, the tag routes and the editor limits (#442)', async () => {
|
||||
const {PROMPT_TAGS_DIALOG, TAGS_URL, TAG_OPTIONS_URL, MAX_PROMPT_TAGS} = await source;
|
||||
assert.deepEqual({...PROMPT_TAGS_DIALOG}, {title:'Tag selected prompts', label:'Tags, separated by commas', action:'Apply'});
|
||||
assert.equal(TAGS_URL, '/prompt_manager/bulk/tags');
|
||||
assert.equal(TAG_OPTIONS_URL, '/prompt_manager/tags');
|
||||
assert.equal(MAX_PROMPT_TAGS, 20);
|
||||
});
|
||||
|
||||
test('the Add tags toast says what was added, already present, skipped and missing', async () => {
|
||||
const {tagsOutcome, writeOutcome, NONE_FOUND} = await source;
|
||||
assert.equal(tagsOutcome({tags:['a', 'b'], updated:2, unchanged:0, over_limit:0, not_found:0}), 'Added “a, b” to 2 prompts.');
|
||||
assert.equal(tagsOutcome({tags:['a'], updated:1, unchanged:2, over_limit:1, not_found:3}),
|
||||
'Added “a” to 1 prompt. 2 prompts already had them. 1 prompt would pass 20 tags and was skipped. 3 could not be found.');
|
||||
assert.equal(tagsOutcome({tags:['a'], updated:0, unchanged:2, over_limit:0, not_found:0}), '2 prompts already had “a”.');
|
||||
// Every prompt skipped: found, so the skip sentence, never "none found"; nothing committed.
|
||||
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:2, not_found:0}, null, 'tags'),
|
||||
{toast:'2 prompts would pass 20 tags and were skipped.', committed:false, reload:true});
|
||||
assert.deepEqual(writeOutcome({success:true, tags:['a'], updated:0, unchanged:0, over_limit:0, not_found:2}, null, 'tags'),
|
||||
{toast:NONE_FOUND, committed:false, reload:true});
|
||||
});
|
||||
|
||||
test('Add tags failures name tags, not the category', async () => {
|
||||
const {writeOutcome, TAGS_STORAGE_CHANGED, TAGS_UNCONFIRMED, TAGS_FAILED} = await source;
|
||||
assert.equal(writeOutcome(null, {status:409, code:'storage-changed'}, 'tags').toast, TAGS_STORAGE_CHANGED);
|
||||
assert.equal(writeOutcome(null, {status:503}, 'tags').toast, TAGS_UNCONFIRMED);
|
||||
assert.equal(writeOutcome(null, {status:400}, 'tags').toast, TAGS_FAILED);
|
||||
assert.equal(TAGS_STORAGE_CHANGED, 'Prompt storage was restored since this page loaded. No tags were added.');
|
||||
});
|
||||
|
||||
test('the Add tags field refuses what validate_tags refuses', async () => {
|
||||
const {tagsProblem, TAG_PROBLEMS} = await source;
|
||||
assert.equal(tagsProblem('red, blue'), null);
|
||||
assert.equal(tagsProblem(' , ,'), TAG_PROBLEMS.none);
|
||||
assert.equal(tagsProblem(Array.from({length:21}, (_, i) => `t${i}`).join(',')), TAG_PROBLEMS.many);
|
||||
assert.equal(tagsProblem(Array.from({length:21}, () => 'same').join(',')), null);
|
||||
assert.equal(tagsProblem('x'.repeat(51)), TAG_PROBLEMS.long);
|
||||
assert.equal(tagsProblem('bad\ttag'), TAG_PROBLEMS.control);
|
||||
});
|
||||
|
||||
test('Select all N loaded is offered while some loaded prompts are unchecked (#444 ruling)', async () => {
|
||||
const {loadedOffer, offersLoaded} = await source;
|
||||
assert.equal(loadedOffer(3), 'Select all 3 loaded');
|
||||
assert.equal(loadedOffer(1203), 'Select all 1,203 loaded');
|
||||
assert.equal(offersLoaded(new Set([1]), [1, 2, 3]), true);
|
||||
assert.equal(offersLoaded(new Set([1, 2, 3]), [1, 2, 3]), false);
|
||||
assert.equal(offersLoaded(new Set(), [1, 2]), false);
|
||||
assert.equal(offersLoaded(new Set([9]), [9]), false);
|
||||
// More loaded than any action accepts: never offered (Ctrl/Cmd+A still selects them).
|
||||
assert.equal(offersLoaded(new Set([1]), Array.from({length:1001}, (_, i) => i + 1)), false);
|
||||
assert.equal(offersLoaded(new Set([1]), Array.from({length:1000}, (_, i) => i + 1)), true);
|
||||
});
|
||||
|
||||
@@ -594,6 +594,9 @@ test('an input dialog offers suggestions and a secondary action', async () => {
|
||||
const [plainField, plainActions] = byId(dom, 'modal-body').children;
|
||||
assert.equal(plainField.children[1].getAttribute('list'), null);
|
||||
assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']);
|
||||
// An empty list (Add to collection with no collections yet, #331 D138) adds no datalist either.
|
||||
openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [], onApply() {} });
|
||||
assert.equal(dom.all(node => node.tagName === 'DATALIST').length, 0);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""#442: the workspace Library's fenced bulk "Add tags" (DB command and route)."""
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from database.execution_history_schema import rotate_history_epoch
|
||||
from database.library_commands import LibraryCommandError
|
||||
from database.operations import PromptDatabase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
try:
|
||||
from test_api_prompts import RouteCoverageCase
|
||||
except ImportError:
|
||||
from tests.test_api_prompts import RouteCoverageCase
|
||||
|
||||
URL = "/prompt_manager/bulk/tags"
|
||||
|
||||
|
||||
def save(db, text, **metadata):
|
||||
return db.save_prompt(text, prompt_hash=generate_prompt_hash(text), **metadata)
|
||||
|
||||
|
||||
def tags_of(db, prompt_id):
|
||||
with db.model.get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.prompt_id=? ORDER BY t.name",
|
||||
(prompt_id,),
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
def updated_at(db, prompt_id):
|
||||
with db.model.get_connection() as conn:
|
||||
return conn.execute(
|
||||
"SELECT updated_at FROM prompts WHERE id=?", (prompt_id,)
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def tag_names(db):
|
||||
with db.model.get_connection() as conn:
|
||||
return sorted(row[0] for row in conn.execute("SELECT name FROM tags"))
|
||||
|
||||
|
||||
class TestBulkLibraryTags(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "tags.db"))
|
||||
self.epoch = self.db.get_library_options()["library_epoch"]
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
self.temp.cleanup()
|
||||
|
||||
def run_bulk(self, ids, tags, epoch=None):
|
||||
return self.db.bulk_add_library_tags(
|
||||
ids, tags, expected_epoch=epoch or self.epoch
|
||||
)
|
||||
|
||||
def test_adds_only_missing_tags_and_counts_each_outcome(self):
|
||||
a = save(self.db, "Alpha", tags=["red"])
|
||||
b = save(self.db, "Beta", tags=["red", "blue"])
|
||||
c = save(self.db, "Gamma")
|
||||
before_b = updated_at(self.db, b)
|
||||
result = self.run_bulk([a, b, c, 99999], [" red ", "blue", "Blue"])
|
||||
self.assertEqual(
|
||||
result,
|
||||
{
|
||||
"library_epoch": self.epoch,
|
||||
"tags": ["red", "blue"],
|
||||
"updated": 2,
|
||||
"unchanged": 1,
|
||||
"not_found": 1,
|
||||
"over_limit": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(tags_of(self.db, a), ["blue", "red"])
|
||||
self.assertEqual(tags_of(self.db, c), ["blue", "red"])
|
||||
self.assertEqual(updated_at(self.db, b), before_b)
|
||||
self.assertTrue(updated_at(self.db, a).endswith("+00:00"))
|
||||
|
||||
def test_a_case_variant_of_a_held_tag_is_not_added_and_names_stay_exact(self):
|
||||
a = save(self.db, "Alpha", tags=["Portrait"])
|
||||
b = save(self.db, "Beta")
|
||||
result = self.run_bulk([a, b], ["portrait"])
|
||||
# Alpha already holds it (casefold); Beta gets the name exactly as typed.
|
||||
self.assertEqual((result["updated"], result["unchanged"]), (1, 1))
|
||||
self.assertEqual(tags_of(self.db, a), ["Portrait"])
|
||||
self.assertEqual(tags_of(self.db, b), ["portrait"])
|
||||
self.assertEqual(tag_names(self.db), ["Portrait", "portrait"])
|
||||
c = save(self.db, "Gamma", tags=["STRASSE"])
|
||||
self.assertEqual(self.run_bulk([c], ["straße"])["unchanged"], 1)
|
||||
|
||||
def test_names_nothing_receives_are_never_inserted(self):
|
||||
full = save(self.db, "Full", tags=[f"t{i}" for i in range(20)])
|
||||
self.run_bulk([full, 99999], ["unused"])
|
||||
self.assertNotIn("unused", tag_names(self.db))
|
||||
|
||||
def test_a_prompt_already_over_twenty_that_gains_nothing_is_unchanged(self):
|
||||
over = save(self.db, "Over", tags=[f"t{i}" for i in range(22)])
|
||||
result = self.run_bulk([over], ["t1"])
|
||||
self.assertEqual((result["unchanged"], result["over_limit"]), (1, 0))
|
||||
|
||||
def test_image_and_pair_tags_are_untouched(self):
|
||||
pair = self.db.save_prompt_pair("Cat", "Blur", tags=["pairtag"])
|
||||
positive = self.db.get_prompt_pair(pair)["positive_prompt_id"]
|
||||
with self.db.model.get_connection() as conn:
|
||||
before = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
|
||||
self.run_bulk([positive], ["pairtag", "own"])
|
||||
self.assertEqual(tags_of(self.db, positive), ["own", "pairtag"])
|
||||
with self.db.model.get_connection() as conn:
|
||||
after = conn.execute("SELECT COUNT(*) FROM prompt_pair_tags").fetchone()[0]
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_a_prompt_that_would_pass_twenty_tags_is_skipped_untouched(self):
|
||||
full = save(self.db, "Full", tags=[f"t{i}" for i in range(19)])
|
||||
other = save(self.db, "Other")
|
||||
before = updated_at(self.db, full)
|
||||
result = self.run_bulk([full, other], ["x", "y"])
|
||||
self.assertEqual((result["updated"], result["over_limit"]), (1, 1))
|
||||
self.assertEqual(len(tags_of(self.db, full)), 19)
|
||||
self.assertEqual(updated_at(self.db, full), before)
|
||||
self.assertEqual(tags_of(self.db, other), ["x", "y"])
|
||||
# Exactly twenty is allowed.
|
||||
self.assertEqual(self.run_bulk([full], ["x"])["updated"], 1)
|
||||
|
||||
def test_changed_prompts_become_registered_library_parents(self):
|
||||
a = save(self.db, "Alpha")
|
||||
self.run_bulk([a], ["new"])
|
||||
with self.db.model.get_connection() as conn:
|
||||
self.assertIsNotNone(
|
||||
conn.execute(
|
||||
"SELECT 1 FROM prompt_library_state WHERE prompt_id=?", (a,)
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def test_negative_only_prompts_are_not_found(self):
|
||||
pair = self.db.save_prompt_pair("Cat", "Blur")
|
||||
negative = self.db.get_prompt_pair(pair)["negative_prompt_id"]
|
||||
result = self.run_bulk([negative], ["x"])
|
||||
self.assertEqual((result["updated"], result["not_found"]), (0, 1))
|
||||
self.assertEqual(tags_of(self.db, negative), [])
|
||||
|
||||
def test_restored_storage_writes_nothing_not_even_a_tag_name(self):
|
||||
a = save(self.db, "Alpha")
|
||||
rotate_history_epoch(self.db.model.get_connection())
|
||||
with self.assertRaises(LibraryCommandError) as caught:
|
||||
self.run_bulk([a], ["fresh"])
|
||||
self.assertEqual(caught.exception.code, "storage-changed")
|
||||
self.assertEqual(tags_of(self.db, a), [])
|
||||
self.assertNotIn("fresh", tag_names(self.db))
|
||||
|
||||
def test_a_failure_mid_write_leaves_every_prompt_as_it_was(self):
|
||||
ids = [save(self.db, f"P{i}") for i in range(3)]
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"CREATE TRIGGER fail_third BEFORE INSERT ON prompt_tags "
|
||||
f"WHEN NEW.prompt_id={ids[2]} BEGIN SELECT RAISE(ABORT, 'boom'); END"
|
||||
)
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.run_bulk(ids, ["x"])
|
||||
self.assertEqual([tags_of(self.db, pid) for pid in ids], [[], [], []])
|
||||
self.assertNotIn("x", tag_names(self.db))
|
||||
|
||||
def test_entries_are_counted_after_dedupe_and_the_limit_names_prompts(self):
|
||||
a = save(self.db, "Alpha")
|
||||
self.assertEqual(self.run_bulk([a], ["dup"] * 30 + ["Dup"])["updated"], 1)
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
self.run_bulk([a], [f"t{i}" for i in range(101)])
|
||||
self.assertEqual(str(caught.exception), "At most 20 tags can be added at once")
|
||||
|
||||
def test_many_ids_are_read_in_chunks(self):
|
||||
ids = [save(self.db, f"Many {i}") for i in range(1000)]
|
||||
self.assertEqual(self.run_bulk(ids, ["bulk"])["updated"], 1000)
|
||||
|
||||
def test_rejects_invalid_arguments(self):
|
||||
a = save(self.db, "Alpha")
|
||||
for ids, tags, epoch in (
|
||||
([a], [], self.epoch),
|
||||
([a], [" "], self.epoch),
|
||||
([a], "red", self.epoch),
|
||||
([a], ["x" * 51], self.epoch),
|
||||
([a], ["bad\x01"], self.epoch),
|
||||
([a], ["a,b"], self.epoch),
|
||||
([a], [f"t{i}" for i in range(21)], self.epoch),
|
||||
([a], ["ok"], "not-a-uuid"),
|
||||
([], ["ok"], self.epoch),
|
||||
):
|
||||
with self.subTest(tags=tags, epoch=epoch, ids=ids):
|
||||
with self.assertRaises(ValueError):
|
||||
self.run_bulk(ids, tags, epoch)
|
||||
|
||||
|
||||
class TestBulkLibraryTagsRoute(RouteCoverageCase):
|
||||
def epoch(self):
|
||||
return self.api.db.get_library_options()["library_epoch"]
|
||||
|
||||
async def test_fenced_form_adds_tags_and_reports_counts(self):
|
||||
ids = [self._save_prompt(f"route {i}") for i in range(2)]
|
||||
status, body = await self._json(
|
||||
"POST",
|
||||
URL,
|
||||
json={"prompt_ids": ids, "tags": ["Fresh"], "expected_epoch": self.epoch()},
|
||||
)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(
|
||||
body,
|
||||
{
|
||||
"success": True,
|
||||
"library_epoch": self.epoch(),
|
||||
"tags": ["Fresh"],
|
||||
"updated": 2,
|
||||
"unchanged": 0,
|
||||
"not_found": 0,
|
||||
"over_limit": 0,
|
||||
},
|
||||
)
|
||||
|
||||
async def test_restored_storage_answers_409_storage_changed(self):
|
||||
pid = self._save_prompt("route restore")
|
||||
stale = self.epoch()
|
||||
new_epoch = rotate_history_epoch(self.api.db.model.get_connection())
|
||||
status, body = await self._json(
|
||||
"POST",
|
||||
URL,
|
||||
json={"prompt_ids": [pid], "tags": ["x"], "expected_epoch": stale},
|
||||
)
|
||||
self.assertEqual((status, body["code"]), (409, "storage-changed"))
|
||||
self.assertEqual(body["library_epoch"], new_epoch)
|
||||
|
||||
async def test_fenced_form_validates_its_body(self):
|
||||
pid = self._save_prompt("route validate")
|
||||
epoch = self.epoch()
|
||||
for body in (
|
||||
{"prompt_ids": [pid], "tags": [], "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "tags": "x", "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "tags": ["x" * 51], "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": "nope"},
|
||||
{"prompt_ids": [True], "tags": ["ok"], "expected_epoch": epoch},
|
||||
{
|
||||
"prompt_ids": list(range(1, 1002)),
|
||||
"tags": ["ok"],
|
||||
"expected_epoch": epoch,
|
||||
},
|
||||
{"prompt_ids": [pid], "expected_epoch": epoch},
|
||||
{"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": epoch, "x": 1},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
status, answer = await self._json("POST", URL, json=body)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertFalse(answer["success"])
|
||||
self.assertEqual(tags_of(self.api.db, pid), [])
|
||||
|
||||
async def test_fenced_form_reports_an_unexpected_failure(self):
|
||||
pid = self._save_prompt("route failure")
|
||||
self._break("bulk_add_library_tags")
|
||||
status, body = await self._json(
|
||||
"POST",
|
||||
URL,
|
||||
json={"prompt_ids": [pid], "tags": ["ok"], "expected_epoch": self.epoch()},
|
||||
)
|
||||
self.assertEqual(status, 500)
|
||||
self.assertFalse(body["success"])
|
||||
|
||||
async def test_legacy_form_refuses_a_body_that_is_not_an_object(self):
|
||||
for raw in ("[1, 2]", "not json"):
|
||||
with self.subTest(raw=raw):
|
||||
status, body = await self._json(
|
||||
"POST", URL, data=raw, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertFalse(body["success"])
|
||||
|
||||
async def test_legacy_form_is_unchanged(self):
|
||||
pid = self._save_prompt("route legacy")
|
||||
status, body = await self._json(
|
||||
"POST", URL, json={"prompt_ids": [pid], "tags": ["Legacy"]}
|
||||
)
|
||||
self.assertEqual((status, body["updated_count"]), (200, 1))
|
||||
self.assertNotIn("library_epoch", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+193
-1
@@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase):
|
||||
return counts
|
||||
|
||||
|
||||
class TestReceipts(unittest.TestCase):
|
||||
class ReceiptCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.context = ("j1", None)
|
||||
self.runs = CachedRuns(
|
||||
@@ -686,6 +686,8 @@ class TestReceipts(unittest.TestCase):
|
||||
def keys(self):
|
||||
return list(self.runs._receipts)
|
||||
|
||||
|
||||
class TestReceipts(ReceiptCase):
|
||||
def test_least_recently_used_run_keys_are_dropped(self):
|
||||
self.begin(1)
|
||||
for name in ("a", "b", "c"):
|
||||
@@ -726,6 +728,196 @@ class TestReceipts(unittest.TestCase):
|
||||
self.assertEqual(list(self.runs._attempt.nodes), ["b"])
|
||||
|
||||
|
||||
class TestRecordedFields(ReceiptCase):
|
||||
"""The texts a receipt keeps for the history capture of a cached job (#403)."""
|
||||
|
||||
F = (("raw a", " pre ", ""),)
|
||||
|
||||
def record(self, item_id, name="a", output="x", fields=F, facts=None):
|
||||
self.begin(item_id)
|
||||
self.runs.record_item(name, (output,), facts or Facts("h"), fields)
|
||||
self.runs.end_attempt(item_id)
|
||||
|
||||
def lookup(self, name="a", values=(("x",),), prompt_id="j9", item_id=9):
|
||||
self.begin(item_id, prompt_id)
|
||||
return self.runs.recorded_fields(prompt_id, name, values)
|
||||
|
||||
def test_a_cached_job_reads_the_fields_of_the_execution_it_reuses(self):
|
||||
self.record(1)
|
||||
self.assertEqual(self.lookup(), [self.F])
|
||||
|
||||
def test_list_items_return_their_own_fields_in_order(self):
|
||||
self.begin(1)
|
||||
for index, text in enumerate(("x", "y")):
|
||||
self.context = ("j1", index)
|
||||
self.runs.record_item("a", (text,), Facts("h"), ((text, "", ""),))
|
||||
self.runs.end_attempt(1)
|
||||
self.assertEqual(
|
||||
self.lookup(values=(("x", "y"),)), [(("x", "", ""),), (("y", "", ""),)]
|
||||
)
|
||||
|
||||
def test_fails_closed(self):
|
||||
self.record(1)
|
||||
for case, kwargs in (
|
||||
("other output", dict(values=(("changed",),))),
|
||||
("other node", dict(name="b")),
|
||||
("unknown node", dict(name="zzz")),
|
||||
("no values", dict(values=())),
|
||||
("malformed values", dict(values="x")),
|
||||
):
|
||||
with self.subTest(case=case):
|
||||
self.assertIsNone(self.lookup(**kwargs))
|
||||
with self.subTest(case="another job"):
|
||||
self.begin(9, "j9")
|
||||
self.assertIsNone(self.runs.recorded_fields("j8", "a", (("x",),)))
|
||||
with self.subTest(case="no job"):
|
||||
self.runs.end_attempt(9)
|
||||
self.assertIsNone(self.runs.recorded_fields("j9", "a", (("x",),)))
|
||||
|
||||
def test_an_execution_without_fields_gives_none(self):
|
||||
for fields in (None, (("a", "b"),), ((1, "", ""),), (), "abc"):
|
||||
with self.subTest(fields=fields):
|
||||
self.runs = CachedRuns(context=lambda: self.context)
|
||||
self.record(1, fields=fields)
|
||||
self.assertIsNone(self.lookup())
|
||||
|
||||
def test_a_lookup_never_counts_or_marks_the_node_replayed(self):
|
||||
self.record(1)
|
||||
self.lookup()
|
||||
self.assertFalse(self.runs.replayed("a"))
|
||||
|
||||
def test_equal_outputs_with_other_raw_text_conflict_and_still_count(self):
|
||||
key = prompt_node_key(self.g, "a")
|
||||
self.record(1, fields=(("x ", "", ""),))
|
||||
self.record(2, fields=(("x", "", ""),))
|
||||
# Counting dedupes by outputs as before; the fields cannot name either
|
||||
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
|
||||
self.assertIsNone(self.lookup())
|
||||
self.assertIsNotNone(self.runs._match(key, (("x",),)))
|
||||
# The conflict stays: a later execution cannot tell which text it was
|
||||
self.record(3, fields=(("x", "", ""),))
|
||||
self.assertIsNone(self.lookup())
|
||||
|
||||
def test_equal_outputs_with_other_facts_still_count_the_newest(self):
|
||||
key = prompt_node_key(self.g, "a")
|
||||
self.record(1, facts=Facts("old"), fields=(("a x", "", ""),))
|
||||
self.record(2, facts=Facts("new"), fields=(("x", "a ", ""),))
|
||||
items = self.runs._match(key, (("x",),))
|
||||
self.assertEqual([item.facts for item in items], [Facts("new")])
|
||||
|
||||
def test_equal_outputs_and_fields_still_replace(self):
|
||||
key = prompt_node_key(self.g, "a")
|
||||
self.record(1)
|
||||
self.record(2)
|
||||
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
|
||||
self.assertEqual(self.lookup(), [self.F])
|
||||
|
||||
def test_fields_dropped_by_the_budget_do_not_conflict(self):
|
||||
self.runs = CachedRuns(context=lambda: self.context, max_field_bytes=0)
|
||||
self.record(1)
|
||||
self.runs._max_field_bytes = 100
|
||||
self.record(2)
|
||||
self.assertEqual(self.lookup(), [self.F])
|
||||
|
||||
def test_oversized_or_invalid_fields_are_not_kept(self):
|
||||
for raw in ("x" * (module.MAX_FIELD_BYTES + 1), "\ud800"):
|
||||
with self.subTest(size=len(raw)):
|
||||
self.runs = CachedRuns(context=lambda: self.context)
|
||||
self.record(1, fields=((raw, "", ""),))
|
||||
self.assertIsNone(self.lookup())
|
||||
self.assertEqual(self.runs._field_bytes, 0)
|
||||
|
||||
def test_old_receipts_give_up_their_fields_to_new_ones(self):
|
||||
self.runs = CachedRuns(
|
||||
context=lambda: self.context, max_keys=3, max_field_bytes=10
|
||||
)
|
||||
self.record(1, "a", fields=(("123456", "", ""),))
|
||||
self.record(2, "b", fields=(("123456", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 6)
|
||||
self.assertIsNone(self.lookup("a"))
|
||||
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
|
||||
# "a" still counts
|
||||
self.assertIsNotNone(self.runs._match(prompt_node_key(self.g, "a"), (("x",),)))
|
||||
# Larger than the whole budget: not kept, nothing else given up
|
||||
self.record(3, "c", fields=(("12345678901", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 6)
|
||||
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
|
||||
|
||||
def test_one_receipt_holds_at_most_one_runs_history(self):
|
||||
with mock.patch.object(module, "MAX_RECEIPT_FIELD_BYTES", 8):
|
||||
self.begin(1)
|
||||
for index, raw in enumerate(("12345", "12345")):
|
||||
self.context = ("j1", index)
|
||||
self.runs.record_item("a", (raw,), Facts("h"), ((raw, "", ""),))
|
||||
self.runs.end_attempt(1)
|
||||
self.assertEqual(self.runs._field_bytes, 5)
|
||||
self.assertIsNone(self.lookup(values=(("12345", "12345"),)))
|
||||
|
||||
def test_the_byte_count_follows_replacement_and_eviction(self):
|
||||
self.begin(1)
|
||||
self.runs.record_item("a", ("x",), Facts("h"), (("12345", "", ""),))
|
||||
self.runs.record_item("a", ("x",), Facts("h"), (("123", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 3)
|
||||
self.runs.end_attempt(1)
|
||||
for item_id, raw in ((2, "1"), (3, "22"), (4, "333")):
|
||||
self.record(item_id, output=raw, fields=((raw, "", ""),))
|
||||
# max_receipts=2: only the receipts of jobs 4 and 3 remain
|
||||
self.assertEqual(self.runs._field_bytes, 5)
|
||||
for item_id, name in ((5, "b"), (6, "c")): # max_keys=2: "a" is dropped
|
||||
self.record(item_id, name, fields=(("1", "", ""),))
|
||||
self.assertEqual(self.runs._field_bytes, 2)
|
||||
self.assertEqual(
|
||||
self.runs._field_bytes,
|
||||
sum(module._bytes(r) for r in self.runs._receipts.values()),
|
||||
)
|
||||
|
||||
|
||||
class TestCachedJobsRecordFields(JobCase):
|
||||
"""The real nodes keep the texts they gave the history capture (#403)."""
|
||||
|
||||
def lookup(self, g, node_id="pm", prompt_id="job-x"):
|
||||
self.item_id += 1
|
||||
self.runs.begin_attempt(
|
||||
((self.item_id, prompt_id, g, {}, ["save"], {}), self.item_id)
|
||||
)
|
||||
values = cached_string_outputs(self.executor, node_id, g[node_id]["class_type"])
|
||||
found = self.runs.recorded_fields(prompt_id, node_id, values)
|
||||
self.runs.end_attempt(self.item_id)
|
||||
return found
|
||||
|
||||
def test_each_node_kind_keeps_its_runtime_fields(self):
|
||||
for kind in (
|
||||
"PromptManager",
|
||||
"PromptManagerFocused",
|
||||
"PromptManagerSplit",
|
||||
"PromptManagerText",
|
||||
):
|
||||
with self.subTest(kind=kind):
|
||||
g = graph(kind)
|
||||
self.first(g, text=f" {LLM} ")
|
||||
self.assertEqual(
|
||||
self.lookup(graph(kind, seed=2)), [((f" {LLM} ", "", ""),)]
|
||||
)
|
||||
|
||||
def test_pair_keeps_positive_and_negative_fields(self):
|
||||
g = graph("PromptManagerPair", negative_text="typed negative")
|
||||
self.first(g, negative="typed negative")
|
||||
self.assertEqual(
|
||||
self.lookup(
|
||||
graph("PromptManagerPair", negative_text="typed negative", seed=2)
|
||||
),
|
||||
[((LLM, "", ""), ("typed negative", "", ""))],
|
||||
)
|
||||
|
||||
def test_a_disabled_node_keeps_no_fields(self):
|
||||
g = graph()
|
||||
g["pm"]["inputs"]["save_to_library"] = False
|
||||
self.first(g)
|
||||
g2 = graph(seed=2)
|
||||
g2["pm"]["inputs"]["save_to_library"] = False
|
||||
self.assertIsNone(self.lookup(g2))
|
||||
|
||||
|
||||
class TestCacheReader(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patcher = mock.patch.dict(sys.modules, host_modules())
|
||||
|
||||
@@ -100,14 +100,19 @@ class HostTests(unittest.TestCase):
|
||||
self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache())
|
||||
|
||||
def begin(
|
||||
self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post "
|
||||
self,
|
||||
kind="PromptManager",
|
||||
raw=" raw ",
|
||||
prepend=" pre ",
|
||||
append=" post ",
|
||||
negative="negative",
|
||||
):
|
||||
g = {
|
||||
"p": {
|
||||
"class_type": kind,
|
||||
"inputs": {
|
||||
"text": raw,
|
||||
"negative_text": "negative",
|
||||
"negative_text": negative,
|
||||
"prepend_text": prepend,
|
||||
"append_text": append,
|
||||
},
|
||||
@@ -125,7 +130,7 @@ class HostTests(unittest.TestCase):
|
||||
},
|
||||
},
|
||||
}
|
||||
if type(raw) is list or type(prepend) is list:
|
||||
if any(type(value) is list for value in (raw, prepend, negative)):
|
||||
g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}}
|
||||
g["stateful"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
@@ -445,3 +450,138 @@ class HostTests(unittest.TestCase):
|
||||
ref = install_and_remove()
|
||||
gc.collect()
|
||||
self.assertIsNone(ref())
|
||||
|
||||
# -- #403: a cached runtime text takes its fields from the reused execution
|
||||
|
||||
class Receipts:
|
||||
"""A fake ``CachedRuns.recorded_fields``: fixed fields, calls recorded."""
|
||||
|
||||
def __init__(self, fields):
|
||||
self.fields, self.calls = fields, []
|
||||
|
||||
def recorded_fields(self, prompt_id, node_id, values):
|
||||
self.calls.append((prompt_id, node_id, values))
|
||||
return self.fields
|
||||
|
||||
def observe_with(self, receipts, outputs, **begin):
|
||||
self.begin(**begin)
|
||||
self.executor.caches.outputs.entries["p"] = Entry(None, outputs)
|
||||
self.observe("execution_start")
|
||||
host.observe_cached_context(
|
||||
self.executor,
|
||||
"execution_cached",
|
||||
{"prompt_id": JOB, "nodes": ["p"]},
|
||||
receipts=receipts,
|
||||
)
|
||||
return self.seal()
|
||||
|
||||
def test_cached_runtime_raw_comes_from_the_matching_receipt(self):
|
||||
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
|
||||
result = self.observe_with(
|
||||
receipts, [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertEqual(receipts.calls, [(JOB, "p", (("cached",),))])
|
||||
self.assertEqual(result.raw.value, "LLM raw")
|
||||
self.assertEqual(
|
||||
(result.raw.provenance, result.raw.source), ("inferred", "graph_semantics")
|
||||
)
|
||||
self.assertEqual(result.effective.value, "cached")
|
||||
self.assertEqual(result.effective.source, "host_cache_output")
|
||||
# Fields the graph already knows are never replaced
|
||||
self.assertEqual(result.prepend.value, " pre ")
|
||||
self.assertEqual(result.prepend.source, "graph_literal")
|
||||
self.assertEqual(result.append.value, " post ")
|
||||
|
||||
def test_cached_runtime_prepend_comes_from_the_matching_receipt(self):
|
||||
receipts = self.Receipts([((" raw ", "LLM pre", " post "),)])
|
||||
result = self.observe_with(
|
||||
receipts, [object(), ["cached"]], prepend=["dynamic", 0]
|
||||
).positive
|
||||
self.assertEqual(result.prepend.value, "LLM pre")
|
||||
self.assertEqual(result.prepend.source, "graph_semantics")
|
||||
self.assertEqual(result.raw.value, " raw ")
|
||||
self.assertEqual(result.raw.source, "graph_literal")
|
||||
|
||||
def test_a_receipt_disagreeing_with_a_known_field_fills_nothing(self):
|
||||
receipts = self.Receipts([(("LLM raw", "other pre", " post "),)])
|
||||
result = self.observe_with(
|
||||
receipts, [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertIsNone(result.raw.value)
|
||||
self.assertEqual(result.prepend.value, " pre ")
|
||||
|
||||
def test_pair_runtime_negative_takes_the_negative_fields(self):
|
||||
receipts = self.Receipts(
|
||||
[((" raw ", " pre ", " post "), ("LLM negative", "", ""))]
|
||||
)
|
||||
result = self.observe_with(
|
||||
receipts,
|
||||
[object(), object(), ["cached positive"], ["LLM negative"]],
|
||||
kind="PromptManagerPair",
|
||||
negative=["dynamic", 0],
|
||||
)
|
||||
self.assertEqual(result.negative.raw.value, "LLM negative")
|
||||
self.assertEqual(result.negative.raw.source, "graph_semantics")
|
||||
self.assertEqual(result.positive.raw.value, " raw ")
|
||||
self.assertEqual(result.positive.raw.source, "graph_literal")
|
||||
|
||||
def test_known_typed_fields_are_never_replaced_by_a_receipt(self):
|
||||
receipts = self.Receipts([(("other", "other", "other"),)])
|
||||
result = self.observe_with(receipts, [object(), ["cached"]]).positive
|
||||
self.assertEqual(
|
||||
(result.raw.value, result.prepend.value, result.append.value),
|
||||
(" raw ", " pre ", " post "),
|
||||
)
|
||||
self.assertEqual(result.raw.source, "graph_literal")
|
||||
|
||||
def test_no_matching_receipt_keeps_the_cached_raw_unknown(self):
|
||||
for fields in (None, [None], [], "bad", [(("a", "b"),)], [((1, "", ""),)]):
|
||||
with self.subTest(fields=fields):
|
||||
result = self.observe_with(
|
||||
self.Receipts(fields), [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertIsNone(result.raw.value)
|
||||
self.assertEqual(result.effective.value, "cached")
|
||||
|
||||
def test_a_failing_receipt_lookup_keeps_the_capture(self):
|
||||
class Broken:
|
||||
def recorded_fields(self, *args):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
result = self.observe_with(
|
||||
Broken(), [object(), ["cached"]], raw=["dynamic", 0]
|
||||
).positive
|
||||
self.assertIsNone(result.raw.value)
|
||||
self.assertEqual(result.effective.value, "cached")
|
||||
|
||||
def test_pair_slots_take_their_own_receipt_fields(self):
|
||||
receipts = self.Receipts(
|
||||
[(("LLM positive", " pre ", " post "), ("LLM negative", "", ""))]
|
||||
)
|
||||
result = self.observe_with(
|
||||
receipts,
|
||||
[object(), object(), ["cached positive"], ["cached negative"]],
|
||||
kind="PromptManagerPair",
|
||||
raw=["dynamic", 0],
|
||||
)
|
||||
self.assertEqual(
|
||||
receipts.calls[0], (JOB, "p", (("cached positive",), ("cached negative",)))
|
||||
)
|
||||
self.assertEqual(result.positive.raw.value, "LLM positive")
|
||||
self.assertEqual(result.positive.effective.value, "cached positive")
|
||||
# The negative editor is typed: the graph's value stays
|
||||
self.assertEqual(result.negative.raw.value, "negative")
|
||||
self.assertEqual(result.negative.raw.source, "graph_literal")
|
||||
|
||||
def test_default_receipts_are_the_process_cached_runs(self):
|
||||
from utils import cached_runs
|
||||
|
||||
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
|
||||
with patch.object(cached_runs, "CACHED_RUNS", receipts):
|
||||
self.begin(raw=["dynamic", 0])
|
||||
self.executor.caches.outputs.entries["p"] = Entry(
|
||||
None, [object(), ["cached"]]
|
||||
)
|
||||
self.observe("execution_start")
|
||||
self.observe("execution_cached", nodes=["p"])
|
||||
self.assertEqual(self.seal().positive.raw.value, "LLM raw")
|
||||
|
||||
@@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB):
|
||||
return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})])
|
||||
|
||||
|
||||
class HistoryHooksTests(unittest.TestCase):
|
||||
class HooksCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
@@ -122,6 +122,8 @@ class HistoryHooksTests(unittest.TestCase):
|
||||
def drain(self, registration):
|
||||
drain_writer(registration.writer, clock=time.monotonic)
|
||||
|
||||
|
||||
class HistoryHooksTests(HooksCase):
|
||||
def test_package_history_initializes_when_monitor_and_usage_setup_fail(self):
|
||||
try:
|
||||
from tests.test_init_package import (
|
||||
@@ -767,3 +769,150 @@ class HistoryHooksTests(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
calls, ["execution_start", "execution_cached", "execution_cached"]
|
||||
)
|
||||
|
||||
|
||||
class CachedRuntimeHistoryTests(HooksCase):
|
||||
"""A cached runtime-linked prompt is a tracked run, like an executed one (#403).
|
||||
|
||||
The real history hook and writer, a real ``CachedRuns`` and the real host
|
||||
adapter over fakes of ComfyUI's executor, cache and executing context.
|
||||
"""
|
||||
|
||||
TEXT = "a lighthouse the LLM wrote"
|
||||
GRAPH = {
|
||||
"str": {"class_type": "PrimitiveString", "inputs": {"value": "seed"}},
|
||||
"llm": {"class_type": "PreviewAny", "inputs": {"source": ["str", 0]}},
|
||||
"p": {"class_type": "PromptManager", "inputs": {"text": ["llm", 0]}},
|
||||
"s": {"class_type": "KSampler", "inputs": {"positive": ["p", 0]}},
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from utils import cached_runs
|
||||
from utils.cached_runs import CachedRuns
|
||||
|
||||
self.context = None
|
||||
self.runs = CachedRuns(context=lambda: self.context or (None, None))
|
||||
self.cache_entries = {}
|
||||
entries = self.cache_entries
|
||||
execution = types.ModuleType("execution")
|
||||
caching = types.ModuleType("comfy_execution.caching")
|
||||
package = types.ModuleType("comfy_execution")
|
||||
utils_module = types.ModuleType("comfy_execution.utils")
|
||||
entry_type = namedtuple("CacheEntry", "ui outputs")
|
||||
|
||||
class PromptExecutor:
|
||||
def add_message(self, event, data, broadcast):
|
||||
return None
|
||||
|
||||
class HierarchicalCache:
|
||||
def get_local(self, node_id):
|
||||
return entries.get(node_id)
|
||||
|
||||
execution.PromptExecutor, execution.CacheEntry = PromptExecutor, entry_type
|
||||
caching.HierarchicalCache = HierarchicalCache
|
||||
package.caching, package.utils = caching, utils_module
|
||||
utils_module.get_executing_context = lambda: (
|
||||
SimpleNamespace(prompt_id=self.context[0], node_id="p", list_index=0)
|
||||
if self.context
|
||||
else None
|
||||
)
|
||||
self.entry_type = entry_type
|
||||
modules = patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"execution": execution,
|
||||
"comfy_execution": package,
|
||||
"comfy_execution.caching": caching,
|
||||
"comfy_execution.utils": utils_module,
|
||||
},
|
||||
)
|
||||
modules.start()
|
||||
self.addCleanup(modules.stop)
|
||||
swap = patch.object(cached_runs, "CACHED_RUNS", self.runs)
|
||||
swap.start()
|
||||
self.addCleanup(swap.stop)
|
||||
self.executor = PromptExecutor()
|
||||
self.executor.server = self.server
|
||||
self.executor.caches = SimpleNamespace(outputs=HierarchicalCache())
|
||||
self.prompt_id = self.db.save_prompt(text=self.TEXT)
|
||||
self.item_id = 0
|
||||
|
||||
def job(self, cached):
|
||||
"""One job; the node executes unless ``cached`` (ComfyUI's cache serves it)."""
|
||||
from uuid import uuid4
|
||||
|
||||
from utils.cached_runs import stored_facts
|
||||
from utils.execution_context_capture import record_runtime_context
|
||||
|
||||
self.item_id += 1
|
||||
job = str(uuid4())
|
||||
result = ((0, job, self.GRAPH, {}, ["s"]), self.item_id)
|
||||
self.queue.results.append(result)
|
||||
self.queue.get()
|
||||
self.runs.begin_attempt(result)
|
||||
self.context = (job, 0)
|
||||
self.executor.add_message("execution_start", {"prompt_id": job}, False)
|
||||
if cached:
|
||||
self.executor.add_message(
|
||||
"execution_cached", {"prompt_id": job, "nodes": ["p"]}, False
|
||||
)
|
||||
else:
|
||||
# What PromptManager.encode_prompt records for this text
|
||||
record_runtime_context("PromptManager", self.TEXT, "", "", self.TEXT)
|
||||
self.runs.record_item(
|
||||
"p", (self.TEXT,), stored_facts(self.TEXT), ((self.TEXT, "", ""),)
|
||||
)
|
||||
self.cache_entries["p"] = self.entry_type(None, [object(), [self.TEXT]])
|
||||
self.context = None
|
||||
self.queue.task_done(self.item_id, {}, status(job=job))
|
||||
self.runs.end_attempt(self.item_id)
|
||||
|
||||
def tracked_runs(self):
|
||||
with self.db.recorded_history_snapshot():
|
||||
return self.db.list_history_window(self.prompt_id)["recorded_run_count"]
|
||||
|
||||
def test_three_jobs_of_runtime_text_are_three_tracked_runs(self):
|
||||
registration = self.register()
|
||||
for cached in (False, True, True):
|
||||
self.job(cached)
|
||||
self.drain(registration)
|
||||
self.assertEqual(self.tracked_runs(), 3)
|
||||
rows = self.rows()
|
||||
for row in rows:
|
||||
contexts = self.db.get_execution_contexts(row["run_id"])["contexts"]
|
||||
self.assertEqual(contexts[0]["positive"]["raw"], self.TEXT)
|
||||
self.assertEqual(contexts[0]["positive_prompt_id"], self.prompt_id)
|
||||
cached = self.db.get_execution_contexts(rows[1]["run_id"])["contexts"][0]
|
||||
self.assertEqual(
|
||||
cached["evidence"]["positive"]["raw"],
|
||||
{"provenance": "inferred", "source": "graph_semantics", "reason": None},
|
||||
)
|
||||
self.assertEqual(
|
||||
cached["evidence"]["positive"]["effective"]["source"], "host_cache_output"
|
||||
)
|
||||
# The stored history passes the whole-database check older builds run too
|
||||
from database.execution_history_schema import history_schema_error
|
||||
|
||||
self.assertIsNone(history_schema_error(self.db.model.get_connection()))
|
||||
# An image of a cached job can now be attributed through its run (#342)
|
||||
from database.run_attribution import named_prompt
|
||||
|
||||
self.assertEqual(
|
||||
[named_prompt(self.db, row["run_id"]) for row in rows],
|
||||
[self.prompt_id] * 3,
|
||||
)
|
||||
|
||||
def test_without_a_receipt_a_cached_job_stays_untracked(self):
|
||||
registration = self.register()
|
||||
self.job(False)
|
||||
self.runs._receipts.clear() # as after a restart with a provider entry
|
||||
self.job(True)
|
||||
self.drain(registration)
|
||||
self.assertEqual(self.tracked_runs(), 1)
|
||||
cached = self.db.get_execution_contexts(self.rows()[1]["run_id"])["contexts"]
|
||||
self.assertIsNone(cached[0]["positive"]["raw"])
|
||||
self.assertIsNone(cached[0]["positive_prompt_id"])
|
||||
from database.run_attribution import named_prompt
|
||||
|
||||
self.assertIsNone(named_prompt(self.db, self.rows()[1]["run_id"]))
|
||||
|
||||
@@ -99,7 +99,10 @@ class TestPairUsageTracking(unittest.TestCase):
|
||||
unique_id="1",
|
||||
)
|
||||
node.cached_runs.record_item.assert_called_once_with(
|
||||
"1", ("cat", ""), Facts(usage_tracking.generate_prompt_hash("cat"), None)
|
||||
"1",
|
||||
("cat", ""),
|
||||
Facts(usage_tracking.generate_prompt_hash("cat"), None),
|
||||
(("cat", "", ""), ("", "", "")),
|
||||
)
|
||||
|
||||
def test_validation_rejection_never_counts_or_saves(self):
|
||||
|
||||
@@ -308,7 +308,11 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
|
||||
"typed text", prepend_text="pre", prompt=self.GRAPH, unique_id="1"
|
||||
)
|
||||
node.cached_runs.record_item.assert_called_once_with(
|
||||
"1", ("pre typed text",), Facts(generate_prompt_hash("typed text"))
|
||||
"1",
|
||||
("pre typed text",),
|
||||
Facts(generate_prompt_hash("typed text")),
|
||||
# #403: the texts the history capture got, for a cached job's history
|
||||
(("typed text", "pre", ""),),
|
||||
)
|
||||
# Hook-visible text: the node counts nothing itself
|
||||
row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text"))
|
||||
@@ -319,7 +323,9 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
|
||||
|
||||
node = self._node()
|
||||
node.process_text(" ", prompt=self.GRAPH, unique_id="1")
|
||||
node.cached_runs.record_item.assert_called_once_with("1", (" ",), Facts(None))
|
||||
node.cached_runs.record_item.assert_called_once_with(
|
||||
"1", (" ",), Facts(None), ((" ", "", ""),)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user