feat(history): compare negative variants
Compare negatives now opens the concept's comparison (app.js a599-616, style.css 1155-1179) as a section.surface right after the timeline panel: h2 "Compare negative variants", p "Baseline compared with <name>. Choose another red dot to change the comparison.", a .compare-grid of two .compare-item negative blocks (baseline, selected), p.small "Added terms", then span.diff-added "+ term" per added term or p.small "No added terms.". Choosing another red dot updates the same section in place; pressing Compare negatives again removes it. D25: the baseline is the oldest active variant; with no selection the selected side falls back to it, as the concept's v does. The pure addedTerms(base, selected) in history-state.mjs is the concept's rule: comma split, trim, drop empty terms, exact case-sensitive difference that keeps the selected order and repeats. D46 (new deviations): - lanes are newest first, so the baseline is the lowest-id active variant, not the first lane; while the variant page has more, the oldest is not loaded and the section says "Load more negative variants to reach the baseline." instead of guessing; - the two blocks are named "Baseline" and "Selected" (regions and copy/send actions), so no name repeats on the page; - Compare negatives is disabled when there is no active variant. Selector mapping: none changed; the section is new (.library-history .timeline-panel + section.surface). Tests: unit "added terms split, trim and exact-diff like the concept"; workspace-history "compare toggles, follows the selected dot and stacks at 390" and "compare waits for the page holding the oldest active negative" (all failed before the change).
This commit is contained in:
@@ -837,3 +837,62 @@ test('all images dialog lists every image for the positive',async({page,request}
|
||||
await page.evaluate(()=>{location.hash='#/overview';});await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);await expect(page.locator('#modal')).toHaveCount(0);
|
||||
});
|
||||
test('compare toggles, follows the selected dot and stacks at 390',async({page})=>{
|
||||
const f=page.fixture;await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
const compare=panel.getByRole('button',{name:'Compare negatives',exact:true});
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section).toHaveCount(0);
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','true');
|
||||
// The concept section (app.js a599-616) follows the timeline panel; the baseline is the oldest active lane (D25).
|
||||
await expect(section).toHaveCount(1);
|
||||
expect(await section.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.className?'.'+child.className:'')))).toEqual(['h2','p','div.compare-grid','p.small','p.small']);
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with No negative. Choose another red dot to change the comparison.');
|
||||
const items=section.locator(':scope > .compare-grid > .compare-item');await expect(items).toHaveCount(2);
|
||||
await expect(items.nth(0).locator('.prompt-section.negative .prompt-text')).toHaveText('blurred edges, watermark');
|
||||
await expect(items.nth(1).locator('.prompt-section.negative .prompt-text')).toHaveText('No negative prompt (explicitly empty)');
|
||||
await expect(items.nth(0).locator('.prompt-section.positive')).toHaveCount(0);
|
||||
// D46: the two blocks are named apart, so no region or action name repeats on the page.
|
||||
await expect(section.getByRole('region',{name:'Baseline negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.getByRole('region',{name:'Selected negative prompt',exact:true})).toBeVisible();
|
||||
await expect(section.locator(':scope > p.small').first()).toHaveText('Added terms');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await expect(section.locator('.diff-added')).toHaveCount(0);
|
||||
// Another dot updates the same section in place.
|
||||
const node=await section.elementHandle();
|
||||
await panel.getByRole('button',{name:'Select negative Clean geometry',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Clean geometry. Choose another red dot to change the comparison.');
|
||||
await expect(items.nth(1).locator('.prompt-text')).toHaveText('flat light');
|
||||
await expect(section.locator(':scope > span.diff-added')).toHaveText(['+ flat light']);
|
||||
await expect(section.locator(':scope > p.small')).toHaveCount(1);
|
||||
expect(await node.evaluate(element=>element.isConnected)).toBe(true);
|
||||
await expect(section.locator('.diff-added')).toHaveCSS('display','inline-block');
|
||||
await expect(section.locator('.compare-item').first()).toHaveCSS('padding','15px');
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
let boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(boxes[1].x).toBeGreaterThan(boxes[0].x+boxes[0].width-1);expect(Math.abs(boxes[1].y-boxes[0].y)).toBeLessThan(1);
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
boxes=await items.evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().toJSON()));
|
||||
expect(Math.abs(boxes[1].x-boxes[0].x)).toBeLessThan(1);expect(boxes[1].y).toBeGreaterThanOrEqual(boxes[0].y+boxes[0].height);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await panel.getByRole('button',{name:'Select negative Quality baseline',exact:true}).click();
|
||||
await expect(section.locator(':scope > p').first()).toHaveText('Baseline compared with Quality baseline. Choose another red dot to change the comparison.');
|
||||
await expect(section.locator(':scope > p.small').nth(1)).toHaveText('No added terms.');
|
||||
await compare.click();await expect(compare).toHaveAttribute('aria-pressed','false');await expect(section).toHaveCount(0);
|
||||
});
|
||||
test('compare waits for the page holding the oldest active negative',async({page})=>{
|
||||
const f=page.fixture;
|
||||
// A bounded first page that does not reach the oldest saved pair: the baseline is not guessed from the newest lanes (D46).
|
||||
await page.route(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`),async route=>{
|
||||
const url=new URL(route.request().url());if(url.searchParams.has('variant_cursor') || url.searchParams.get('archive')) return route.continue();
|
||||
const response=await route.fetch(),body=await response.json();
|
||||
await route.fulfill({response,json:{...body,variants:body.variants.slice(0,2),variants_has_more:true,variants_next_cursor:'held'}});
|
||||
});
|
||||
await open(page,f.canonical_id);const panel=variantsPanel(page);
|
||||
await panel.getByRole('button',{name:'Compare negatives',exact:true}).click();
|
||||
const section=page.locator('.library-history .timeline-panel + section.surface');
|
||||
await expect(section.locator(':scope > h2')).toHaveText('Compare negative variants');
|
||||
await expect(section.locator(':scope > p.small')).toHaveText('Load more negative variants to reach the baseline.');
|
||||
await expect(section.locator('.compare-grid')).toHaveCount(0);
|
||||
await page.unroute(new RegExp(`/prompt_manager/library/${f.canonical_id}\\?`));
|
||||
});
|
||||
|
||||
@@ -276,3 +276,17 @@ test('a resumed page success or error cannot replace a later run choice',async()
|
||||
await p;assert.equal(c.state.selection.run_id,id(41));assert.equal(c.state.window.items.length,40);assert.equal(c.state.refreshRequired,null);assert.equal(c.state.actionsEnabled,true);c.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('added terms split, trim and exact-diff like the concept',()=>{
|
||||
// The concept's rule (app.js a600-614): split on commas, trim, drop empty terms, keep what the baseline lacks exactly.
|
||||
assert.equal(typeof m.addedTerms,'function','addedTerms is exported');
|
||||
assert.deepEqual(m.addedTerms('blurry, lowres','lowres, watermark , text'),['watermark','text']);
|
||||
assert.deepEqual(m.addedTerms('',''),[]);assert.deepEqual(m.addedTerms('blurry',''),[]);assert.deepEqual(m.addedTerms('','blurry'),['blurry']);
|
||||
assert.deepEqual(m.addedTerms('a','a, ,\n, b\t'),['b']);
|
||||
// Duplicates in the selected negative stay, as the concept lists every added term in order.
|
||||
assert.deepEqual(m.addedTerms('a','c, c, a'),['c','c']);
|
||||
// Case and inner whitespace are exact.
|
||||
assert.deepEqual(m.addedTerms('Blurry, low res','blurry, Blurry, low res'),['blurry','low res']);
|
||||
assert.deepEqual(m.addedTerms('same, text','text, same'),[]);
|
||||
assert.throws(()=>m.addedTerms(null,'a'),TypeError);assert.throws(()=>m.addedTerms('a',undefined),TypeError);
|
||||
});
|
||||
|
||||
@@ -114,6 +114,16 @@ function validateDetail(value, runId, epoch) {
|
||||
value.contexts.forEach(validateContext);
|
||||
requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length);
|
||||
}
|
||||
/**
|
||||
* Compare negatives (#249 D25): the selected negative's terms the baseline lacks, by the concept's rule (app.js a600-614):
|
||||
* split on commas, trim, drop empty terms, then an exact, case-sensitive difference that keeps the selected order and repeats.
|
||||
*/
|
||||
export function addedTerms(base, selected) {
|
||||
if (typeof base !== 'string' || typeof selected !== 'string') throw new TypeError('Negative texts must be strings');
|
||||
const terms = text => text.split(',').map(term => term.trim());
|
||||
const baseline = new Set(terms(base));
|
||||
return terms(selected).filter(term => term && !baseline.has(term));
|
||||
}
|
||||
export function frozenPromptRoles(context, mode) {
|
||||
requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context);
|
||||
return Object.fromEntries(['positive', 'negative'].map(role => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './libr
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
import {addedTerms} from './history-state.mjs';
|
||||
import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
@@ -474,11 +475,43 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const variants=el('section',undefined,'surface timeline-panel');variants.setAttribute('aria-label','Saved negative variants');
|
||||
const panelHeading=el('div',undefined,'panel-heading'),panelCopy=el('div'),rowActions=el('div',undefined,'row-actions');
|
||||
panelCopy.append(el('h2','Negative variants'),el('p','Choose a red dot to change the negative and filter this gallery.','small'));
|
||||
// Compare negatives (aria-pressed) opens the comparison section (Task F3); Show archived is the approved #235 addition.
|
||||
// Compare negatives (aria-pressed) opens the comparison section below; Show archived is the approved #235 addition.
|
||||
const compare=createButton('Compare negatives'),addNegative=createButton('+ Add negative'),showArchived=createButton('');
|
||||
compare.setAttribute('aria-pressed','false');showArchived.setAttribute('aria-pressed','false');
|
||||
rowActions.append(compare,addNegative,showArchived);panelHeading.append(panelCopy,rowActions);variants.append(panelHeading);
|
||||
content.listen(compare,'click',()=>compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true')));
|
||||
content.listen(compare,'click',()=>{compare.setAttribute('aria-pressed',String(compare.getAttribute('aria-pressed')!=='true'));updateCompare();});
|
||||
// The concept's comparison (app.js a599-616, style.css 1155-1179): after the timeline panel, the baseline (the oldest
|
||||
// active variant, D25) beside the selected negative, then its added terms. Another dot updates it in place.
|
||||
const compareSection=el('section',undefined,'surface');let compareBlocks=[],compareKey=null;
|
||||
const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];};
|
||||
content.cleanup(disposeCompareBlocks);
|
||||
function compareBlock(text,label) {
|
||||
const item=el('div',undefined,'compare-item');
|
||||
// D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page.
|
||||
compareBlocks.push(mountPromptBlocks(item,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)}));
|
||||
compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'});
|
||||
return item;
|
||||
}
|
||||
function updateCompare() {
|
||||
if(detail.variant_total===0) compare.setAttribute('aria-pressed','false');
|
||||
compare.disabled=detail.variant_total===0;
|
||||
if(compare.getAttribute('aria-pressed')!=='true') {compareSection.remove();disposeCompareBlocks();compareKey=null;return;}
|
||||
if(variants.nextSibling!==compareSection) variants.after(compareSection);
|
||||
// D46: the oldest active variant may sit on an unloaded page; the baseline is never guessed from the newest lanes.
|
||||
const baseline=detail.variants_has_more?null:detail.variants.reduce((oldest,variant)=>!oldest || variant.id<oldest.id?variant:oldest,null);
|
||||
const lane=[...lanes.values()].find(item=>item.entry.variant.id===activePairId) || [...lanes.values()].find(item=>item.entry.variant.id===baseline?.id);
|
||||
const selected=(activePairId!==null && activePairId!==undefined && recordFor(activePairId)) || baseline;
|
||||
const name=lane?laneName(lane.entry):'';
|
||||
const key=JSON.stringify([baseline?.id,baseline?.negative_text,selected?.id,selected?.negative_text,name,displayTitle(detail.positive),savedReadState]);
|
||||
if(key===compareKey) return;
|
||||
compareKey=key;disposeCompareBlocks();compareSection.replaceChildren(el('h2','Compare negative variants'));
|
||||
if(!baseline || !selected) {compareSection.append(el('p','Load more negative variants to reach the baseline.','small'));return;}
|
||||
const grid=el('div',undefined,'compare-grid');grid.append(compareBlock(baseline.negative_text,'Baseline'),compareBlock(selected.negative_text,'Selected'));
|
||||
compareSection.append(el('p',`Baseline compared with ${name}. Choose another red dot to change the comparison.`),grid,el('p','Added terms','small'));
|
||||
const terms=addedTerms(baseline.negative_text,selected.negative_text);
|
||||
if(terms.length) for(const term of terms) compareSection.append(el('span',`+ ${term}`,'diff-added'));
|
||||
else compareSection.append(el('p','No added terms.','small'));
|
||||
}
|
||||
const branches=el('div',undefined,'timeline');const positiveLane=el('div',undefined,'positive-lane');positiveLane.append(el('span','Positive prompt · unchanged'));branches.append(positiveLane);
|
||||
const lanes=new Map();const emptyNote=el('p','No saved negative variants. Existing image associations remain available.','muted');
|
||||
// D45: the bounded page count stays a status for assistive technology; the concept shows no count line.
|
||||
@@ -564,6 +597,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
variantError.hidden=!loadError;variantError.textContent=!loadError?'':state.variantsError?'Negative variants could not be loaded. Retry to continue.':'Archived negative variants could not be loaded. Retry to continue.';
|
||||
const archivedCount=Number.isSafeInteger(detail.positive.archived_variant_count)?detail.positive.archived_variant_count:0;
|
||||
showArchived.textContent=`Show archived (${archivedCount})`;showArchived.setAttribute('aria-pressed',String(archivedShown));
|
||||
updateCompare();
|
||||
};
|
||||
const updateMeta=()=>{imagesMeta.textContent=countOf(detail.positive.image_count ?? 0,'image');variantsMeta.textContent=countOf(detail.positive.variant_count ?? 0,'negative variant');};
|
||||
updateParent=()=>{
|
||||
@@ -644,7 +678,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
galleryScope?.dispose();galleryScope=createScope();images.replaceChildren();
|
||||
// An archived pair's images are read in the archived scope; the gallery stays authoritative.
|
||||
mountImages(images,api,galleryScope,{embedded:true,fetchPage:({parameters,signal})=>{const query=new URLSearchParams(parameters);if(pairId!==null) query.set('pair_id',String(pairId));if(archived) query.set('archive','archived');return api.get(`/prompt_manager/library/${positiveId}/images?${query}`,{signal});}});
|
||||
updateSend();
|
||||
updateSend();updateCompare();
|
||||
}
|
||||
/** A metadata receipt names no pair, so the page re-reads its own selection through the exact-ID route. */
|
||||
async function rereadSelectedPair() {
|
||||
|
||||
Reference in New Issue
Block a user