diff --git a/TESTING.md b/TESTING.md index 0bb7afa..f4c0d3b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -638,6 +638,39 @@ Recorded deviations on this page: filenames…" for parity with Images, although Library search matches positive text and titles and saved negative text and variant titles, not filenames or tags (Tags live in "Refine this view", D14). +- **D42**: a card's tag chips come in the store's alphabetical order (`ORDER BY t.name`, + as everywhere tags are read); the concept lists each sample's tags in authored order. + +`tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the +concept-shaped fixture, which creates the concept's prompts last to first so Recently +added lists them in the concept's order: heading, toolbar, caption, grid and the six +cards (art, title row, prompt block, meta, tags, footer) at 1600x1100 and 390x844 in +both themes and motion settings, and the grid and cards at 1800x1100 (3 columns). +Behaviors, in both documents: the title and Explore history open the prompt's history +(its `h1` is the title), the footer send carries the positive only (a bridge host page +records the roles production's preview asks for), copy writes the full positive with +"Prompt copied.", and the "neon" search and the Landscapes category leave the same cards +and caption. `mapLibraryConcept` applies D14 (Oldest first appended), D16 (no Save +view; the toolbar flows again, checked against the concept without it), D23 (the +title button's `min-width: 0`; "− 1 negative variant", measured on the concept with the +singular at runtime; the spoken "Rated N out of 5"; the two-line clamp, which the +concept's samples exceed at 390px, checked against the concept with the clamp rule +applied), D35 (singular captions), D36 (sorted categories), D40 (the continuation after +the grid) and D42 (sorted chips, checked against the concept with its chips sorted). +Nothing else follows the grid: the legacy "Current prompt library" link moved to +Overview's legacy cards, as "Current gallery" did (spec §5). `library comparison detects +mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`. + +Manual check (quick), after a browser refresh on Prompt library: each card shows the +title with its category chip on one line, two lines of the positive with copy and send, +"− N negative variants · N runs · N images" and the tags; the footer reads "★★★ · Used +today" (or the real date) with send and "Explore history"; Filters → Tags still narrows +the shelf. + +```sh +npm run --prefix tools/vendor test:workflows -- library-concept.spec.mjs \ + workspace-library.spec.mjs --project chromium --project touch --project firefox --project webkit +``` ## Workspace data fields for the concept pages (#249) diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 09597e0..2ec0aee 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -15,6 +15,62 @@ export const MOTIONS=Object.freeze(['no-preference','reduce']); export const countCopy=text=>text.replace(/(^|\D)1 positive prompts\b/g,'$11 positive prompt') .replace(/(^|\D)1 more images\b/g,'$11 more image').replace(/(^|\D)1 images\b/g,'$11 image'); +/** + * The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its + * hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits + * after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack + * from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first. + */ +export function reflow(row,items) { + const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0; + // The unrounded boxes (`exact`); rounding happens once, on the result. + const rowWidth=row.exact?.width??row.width; + const exact=item=>item.exact??{left:item.left,width:item.width}; + const grows=item=>parseFloat(item.flexGrow)>0; + const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width; + const lines=[]; + const order=row.items.filter(entry=>entry.shown); + for(const item of items.filter(entry=>entry.shown)) { + const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0; + // The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed. + if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]); + } + const placed=new Map();let top=0; + const sameLine=(a,b)=>a.toprowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1); + for(const line of lines) { + const free=freeOf(line); + const growing=line.filter(grows); + const height=Math.max(...line.map(item=>item.height)); + let x=0,previous=null; + for(const item of line) { + // A growing item keeps its concept width plus the space its line gained. + const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width; + // An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing, + // moved by however much that neighbour grew; otherwise it follows the previous box and the gap. + const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous); + const before=previous&&placed.get(previous); + const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x; + placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)}); + x=left+width+gap;previous=item; + } + top+=height+rowGap; + } + const moved=item=>{ + const box=placed.get(item); + if(!box)return item; + const grown=box.width-exact(item).width; + return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width), + input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}}; + }; + return {...row,height:Math.round(top-rowGap),items:items.map(moved)}; +} + +/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */ +export function withoutItems(row,isRemoved) { + return reflow(row,row.items.filter(item=>!isRemoved(item))); +} + /** Seed the concept-shaped real fixture (tests/browser/workspace_concept_fixture.py) and return its manifest. */ export async function seedConcept(request,data={}) { const response=await request.post('/__test__/concept/reset',{data}); @@ -95,7 +151,8 @@ export async function mutate(page,{css=null,remove=[]}={}) { * Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library, * history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) * measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while - * anything is checked, its `.bulkbar` row. Library and history stay null until PRs E and F. + * anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar, + * caption, grid, first six cards and whatever follows the grid; history stays null until PR F. */ export function inventoryOf(page,scope='main') { return page.evaluate(measureInventory,scope); @@ -333,6 +390,57 @@ export function measureInventory(scope) { function bulkbarOf(bar) { return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)}; } + // ---- Prompt library (#249 E3). The concept's main holds the toolbar, caption and grid; production's shelf wraps + // them in `section.library-shelf` and windows the cards into `.library-row`s (spec §3.4: "row windowing kept"), + // so every Library box is found by descendant and placed relative to `main` or its own card. + // A `display: contents` node has no box: its children stand in its place (the D23 prompt-block mount point). + const boxedChildren=node=>[...node.children].flatMap(child=>getComputedStyle(child).display==='contents'?boxedChildren(child):[child]); + function libraryCardOf(card,origin) { + const art=card.querySelector(':scope > .card-art'),content=card.querySelector(':scope > .card-content'),footer=card.querySelector(':scope > .card-footer'); + const title=content?.querySelector(':scope > .card-title'),meta=content?.querySelector(':scope > .card-meta'),tags=content?.querySelector(':scope > .tags'); + const section=content?.querySelector('.prompt-section'),heading=section?.querySelector(':scope > .prompt-heading'); + const text=section?.querySelector(':scope > .prompt-text'),facts=footer?.querySelector(':scope > span'),actions=footer?.querySelector(':scope > .row-actions'); + const lines=node=>Math.round(rect(node).height/parseFloat(getComputedStyle(node).lineHeight)); + const spanOf=(node,parent)=>({tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),...font(node),...edges(node),...relative(node,parent)}); + return {tag:card.tagName.toLowerCase(),className:classes(card),...relative(card,origin),...edges(card),...css(card,'overflow','display','boxShadow'), + children:[...card.children].map(classes), + art:when(art,()=>({...relative(art,card),...css(art,'display','gridTemplateColumns','columnGap','rowGap','backgroundColor'), + items:[...art.children].map(node=>({tag:node.tagName.toLowerCase(),className:classes(node),alt:node.getAttribute('alt'),...relative(node,art), + ...css(node,'objectFit')}))})), + content:when(content,()=>({...relative(content,card),...edges(content),children:boxedChildren(content).map(node=>`${node.tagName.toLowerCase()}.${classes(node)}`)})), + title:when(title,()=>({...layout(title),...edges(title),...relative(title,content), + items:[...title.children].map(node=>node.tagName==='BUTTON'?controlOf(node,title):spanOf(node,title))})), + section:when(section,()=>({className:classes(section),...relative(section,content),...edges(section), + label:when(heading?.querySelector('.role-label'),label=>({text:label.textContent,...font(label)})), + actions:when(heading?.querySelector('.prompt-actions'),group=>({...layout(group),...relative(group,section), + buttons:[...group.children].map(node=>controlOf(node,group))})), + text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section),lines:lines(text)}))})), + meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,content),items:[...meta.children].map(node=>spanOf(node,meta))})), + // A chip's unrounded place too, so a re-ordered row can be laid out again (`reflow`; `stripExact` removes it). + tags:when(tags,()=>({...layout(tags),...edges(tags),...relative(tags,content),exact:{width:rect(tags).width}, + items:[...tags.children].map(node=>({...spanOf(node,tags),shown:shown(node),exact:{left:rect(node).left-rect(tags).left,width:rect(node).width}}))})), + footer:when(footer,()=>({...layout(footer),...edges(footer),...font(footer),...relative(footer,card), + // What is seen (hidden-only text left out) and what is spoken (aria-hidden text left out). + facts:when(facts,()=>({text:textOf(facts,'.ca-visually-hidden'),spoken:accessibleText(facts),...font(facts),...relative(facts,footer)})), + actions:when(actions,()=>({...layout(actions),...relative(actions,footer),buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))}; + } + function libraryOf() { + const grid=main?.querySelector('.library-grid'); + if(!grid)return null; + const toolbar=main.querySelector('search.toolbar'),caption=main.querySelector('.section-caption'); + const cards=[...grid.querySelectorAll('.prompt-card')]; + // Whatever the page shows after the grid, in document order (the concept: nothing). + const trailing=[]; + for(let node=grid;node&&node!==main;node=node.parentElement) + for(let next=node.nextElementSibling;next;next=next.nextElementSibling)if(shown(next))trailing.push({tag:next.tagName.toLowerCase(),className:classes(next),text:next.innerText.replace(/\s+/g,' ').trim()}); + const style=getComputedStyle(grid); + return {toolbar:when(toolbar,rowOf), + caption:when(caption,()=>({...layout(caption),...edges(caption),...font(caption),...relative(caption,main), + spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),text:textOf(span),...font(span), + top:relative(span,caption).top,height:relative(span,caption).height}))})), + grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length}, + cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing}; + } return {chrome:chromeOf(),heading:headingOf(), toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf), filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf), @@ -342,5 +450,5 @@ export function measureInventory(scope) { cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null, continuation:when(continuation,continuationOf), panel:when(gallery,panelOf), - library:null,history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; + library:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; } diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index e88d417..a03c35a 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy} from './concept-inventory.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; // Task C7 of #249: the production Images page against the immutable concept, on the concept-shaped real fixture (24 // images of the concept's six prompts, its seeds, sizes and favorites). Heading, toolbar, filter row, caption, grid, @@ -30,62 +30,6 @@ function captionDay(seconds,now=new Date()) { return `${name} · ${date.toLocaleDateString('en-US',options)}`; } -/** - * The row's flex lines laid out again from the concept's own item boxes, in order: an item breaks to a new line when its - * hypothetical size (`flex-basis: 100%` → the row; a growing item → its min-width; otherwise its box) no longer fits - * after the line's items and gaps; a growing item (the search field, `flex: 1`) takes its line's free space; items pack - * from the start and centre in their line (`align-items: center`). The test proves this model on the concept rows first. - */ -export function reflow(row,items) { - const gap=parseFloat(row.columnGap)||0,rowGap=parseFloat(row.rowGap)||0; - // The unrounded boxes (`exact`); rounding happens once, on the result. - const rowWidth=row.exact?.width??row.width; - const exact=item=>item.exact??{left:item.left,width:item.width}; - const grows=item=>parseFloat(item.flexGrow)>0; - const basis=item=>item.flexBasis==='100%'?rowWidth:grows(item)?parseFloat(item.minWidth)||0:exact(item).width; - const lines=[]; - const order=row.items.filter(entry=>entry.shown); - for(const item of items.filter(entry=>entry.shown)) { - const line=lines.at(-1),used=line?line.reduce((sum,entry)=>sum+basis(entry)+gap,0):0; - // The slack is one layout unit (1/60px in Firefox), which float sums of the browser's boxes may exceed. - if(line&&used+basis(item)<=rowWidth+1/60)line.push(item);else lines.push([item]); - } - const placed=new Map();let top=0; - const sameLine=(a,b)=>a.toprowWidth-line.reduce((sum,item)=>sum+basis(item),0)-gap*(line.length-1); - for(const line of lines) { - const free=freeOf(line); - const growing=line.filter(grows); - const height=Math.max(...line.map(item=>item.height)); - let x=0,previous=null; - for(const item of line) { - // A growing item keeps its concept width plus the space its line gained. - const width=growing.includes(item)?exact(item).width+(free-freeOf(order.filter(entry=>sameLine(entry,item))))/growing.length:exact(item).width; - // An item that followed the same neighbour on the same line in the concept keeps the concept's own spacing, - // moved by however much that neighbour grew; otherwise it follows the previous box and the gap. - const adjacent=previous&&order.indexOf(item)===order.indexOf(previous)+1&&sameLine(item,previous); - const before=previous&&placed.get(previous); - const left=line.indexOf(item)===0?0:adjacent?before.left+exact(item).left-exact(previous).left+before.width-exact(previous).width:x; - placed.set(item,{left,width,top:Math.round(top+(height-item.height)/2)}); - x=left+width+gap;previous=item; - } - top+=height+rowGap; - } - const moved=item=>{ - const box=placed.get(item); - if(!box)return item; - const grown=box.width-exact(item).width; - return {...item,left:Math.round(box.left),top:box.top,width:Math.round(box.width), - input:item.input&&{...item.input,width:Math.round((item.input.exact?.width??item.input.width)+grown)}}; - }; - return {...row,height:Math.round(top-rowGap),items:items.map(moved)}; -} - -/** A flex row without the items `isRemoved` picks; the remaining items flow again from the concept's boxes. */ -function withoutItems(row,isRemoved) { - return reflow(row,row.items.filter(item=>!isRemoved(item))); -} - /** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */ const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']); const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs new file mode 100644 index 0000000..a134e19 --- /dev/null +++ b/tests/browser/library-concept.spec.mjs @@ -0,0 +1,345 @@ +import {writeFile} from 'node:fs/promises'; +import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; +import {captureCanonicalState} from './history-canonical-comparison.mjs'; +import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow,withoutItems} from './concept-inventory.mjs'; + +// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586, +// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories, +// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six +// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100 +// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's. +// Paired captures are artifacts only; there are no PNG baselines. +test.use({locale:'en-US',timezoneId:'UTC'}); + +const CARDS='.library-grid .prompt-card'; +const CONCEPT_PROMPTS=6; + +/** Recorded Library deviations only (spec §8); everything else in the concept inventory must equal production. */ +const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return xy?1:ab?1:0;}; +export function mapLibraryConcept(reference) { + const library=reference.library; + // D16 (follow-up PR): no Save view until the saved-views store ships; the remaining items flow again (`reflow`). + const toolbar=withoutItems(library.toolbar,item=>item.text==='Save view'); + toolbar.items=toolbar.items.map(item=>{ + // D14: the Library appends its Oldest first after the concept's three sorts. + if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']}; + // D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order. + if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}; + return item; + }); + // A shorter toolbar moves what follows it up by as much. + const rise=library.toolbar.height-toolbar.height; + const cards=library.cards.map((card,index)=>({...card, + // D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`). + meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta, + // D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`). + title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)}, + // D42: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips. + tags:sortedTags(card.tags), + // D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's + // run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today", and its texts are short, + // so the two-line clamp changes nothing here. + footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}})); + return {...reference,library:{...library,toolbar, + // D35: the caption uses the singular for one. + caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))}, + ...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})), + // D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none. + trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}}; +} + +/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */ +const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }'; +const CLAMP_LINES=2; +/** + * D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the + * text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the + * grid's rows then close up by their tallest card's loss. + */ +export function clampedCards({grid,cards}) { + const shrunk=cards.map(card=>{ + const text=card.section.text; + if(text.lines<=CLAMP_LINES)return {card,loss:0}; + const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight)); + return {loss,card:{...card,height:card.height-loss, + content:{...card.content,height:card.content.height-loss}, + section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}}, + meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss}, + footer:{...card.footer,top:card.footer.top-loss}}}; + }); + // Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above. + const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b); + const rowLoss=top=>{ + const row=cards.flatMap((card,index)=>card.top===top?[index]:[]); + return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height)); + }; + const above=top=>tops.filter(other=>othersum+rowLoss(other),0); + return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)}, + cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))}; +} + +/** D42: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */ +export function sortedTags(tags) { + return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text))); +} + +const SECTIONS=['heading','library','noPageScroll']; +function compare(reference,actual,{sections=SECTIONS,library=null}={}) { + const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library + ?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]])); + return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual))); +} + +/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ +function assertLibraryMatch(reference,actual) { + const diff=compare(reference,actual); + if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`); +} + +// Two frames, then every running transition finished. +const settle=page=>page.evaluate(async()=>{ + await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))), + new Promise(resolve=>setTimeout(resolve,2000))]); +}); +// Every card image decoded, so nothing moves while it is measured. +const decoded=page=>page.evaluate(async()=>{ + await document.fonts.ready; + await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});})); +}); + +const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant'; +/** + * The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural + * reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own + * layout. The concept page is restored afterwards. + */ +async function conceptInventory(page) { + const reference=await inventoryOf(page,'#main'); + await page.evaluate(({plural,singular})=>{ + window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural); + for(const chip of window.__conceptPlurals)chip.textContent=singular; + },{plural:PLURAL_ONE,singular:SINGULAR_ONE}); + const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta); + await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE); + return {...reference,oracle:{meta}}; +} + +/** The concept page with each card's tag chips in alphabetical order (the D42 oracle); returns the undo. */ +async function sortConceptTags(page) { + await page.evaluate(()=>{ + const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return xy?1:0;}; + window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{ + const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];}); + }); + return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;}); +} + +async function openConceptLibrary(page,theme,motion) { + await openConcept(page,'library',theme,motion); + await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS); + await page.mouse.move(0,0);await decoded(page);await settle(page); +} +async function openProductionLibrary(page,theme,motion) { + await openProduction(page,'library',theme,motion); + await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS); + await expect(page.locator('#workspace-main .section-caption')).toBeVisible(); + // The category list is read once per visit; it is part of the toolbar inventory. + await expect(page.locator('#category-filter option')).toHaveCount(5); + await page.mouse.move(0,0);await decoded(page);await settle(page); +} + +const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents(); +const captionOf=page=>page.locator('.section-caption > span').first().textContent(); + +/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */ +async function filteredOf(page,apply,undo) { + const before=await captionOf(page); + await apply(); + await expect.poll(()=>captionOf(page)).not.toBe(before); + await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS); + const result={caption:await captionOf(page),titles:await titlesOf(page)}; + await undo(); + await expect.poll(()=>captionOf(page)).toBe(before); + return result; +} + +/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */ +async function historyFrom(page,control,back) { + const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim(); + await page.locator(CARDS).first().locator(control).click(); + const h1=page.locator('h1#page-title'); + await expect(h1).toHaveText(title); + const result={title,h1:(await h1.textContent()).trim()}; + await back(); + await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS); + return result; +} + +/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */ +async function behaviorsOf(page,{back,sent}) { + const first=page.locator(CARDS).first(); + const title=await historyFrom(page,'.card-title > button',back); + const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back); + // The footer send opens the sender with the positive prompt only. + const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim(); + await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click(); + const send={positive,roles:await sent(page)}; + // Copy writes the full positive and shows the toast. + await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click(); + await expect(page.locator('.toast')).not.toHaveText(''); + const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))}; + const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill('')); + const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption('')); + return {title,explore,send,copy,search,category}; +} + +/** The concept's demo sender: the roles its preview lists, with their text. */ +async function conceptSent(page) { + const modal=page.locator('dialog#modal[open]'); + await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI'); + const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=> + [node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent]))); + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0); + return roles; +} + +/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */ +function productionSent(host) { + return async page=>{ + const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'}); + await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'}); + await sender.getByRole('button',{name:'Preview changes',exact:true}).click(); + await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1); + const roles=await host.evaluate(()=>window.previewed.pop()); + await page.keyboard.press('Escape');await expect(sender).toHaveCount(0); + return roles; + }; +} +async function openHost(context) { + const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview'); + await host.evaluate(async()=>{ + const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js'); + const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']}; + window.previewed=[]; + window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{ + listTargets:()=>[target], + preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})), + composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};}, + apply:roles=>({targetId:target.id,values:roles}), + }}); + }); + return host; +} + +test.beforeEach(async({page,request},testInfo)=>{ + testInfo.manifest=await seedConcept(request); + // Both documents copy through the clipboard; the stub records what they wrote. + await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});}); +}); + +for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{ + test.setTimeout(240_000); + await page.setViewportSize({width,height}); + const narrow=width<=700; + const host=await openHost(context); + try { + const result={}; + for(const motion of MOTIONS) { + const capture=motion==='no-preference'; + await openConceptLibrary(page,theme,motion); + const reference=await conceptInventory(page); + if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`); + // The browser as the re-layout's oracle: the concept page itself without Save view (D16). + const restore=await mutate(page,{remove:['.toolbar > [data-action="save-view"]']}); + const removed=(await inventoryOf(page,'#main')).library.toolbar; + await restore(); + // ...and with the D23 clamp applied to its cards. + const restoreClamp=await mutate(page,{css:CLAMP_CSS}); + const clamped=(await inventoryOf(page,'#main')).library; + await restoreClamp(); + // ...and with every card's tag chips put in alphabetical order (D42). + const restoreTags=await sortConceptTags(page); + const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags); + await restoreTags(); + const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null; + + await openProductionLibrary(page,theme,motion); + const actual=await inventoryOf(page,'#workspace-main'); + if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`); + const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null; + result[motion]={reference,removed,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors}; + } + const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); + await testInfo.attach('library-geometry',{path,contentType:'application/json'}); + + for(const [motion,{reference,removed,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) { + const {grid,cards,toolbar}=reference.library,card=cards[0]; + // The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483). + expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS}); + expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'}); + const art=narrow?150:170; + expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'}); + const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat); + expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1); + expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]); + const pad=narrow?'15px':'18px'; + expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad}); + expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'}); + expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'}); + expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']); + expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'}); + expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today'); + expect.soft(diffInventory(stripExact(withoutItems(toolbar,item=>item.text==='Save view')),stripExact(removed)),`${motion} toolbar without Save view`).toEqual([]); + expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]); + expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]); + expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]); + expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true); + if(!concept)continue; + // Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes + // the full positive with the concept's toast; search and category narrow the shelf alike. + expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text}); + expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive}); + expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive}); + expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']); + // D35: the shelf caption uses the singular for one. + const counted=filter=>({...filter,caption:countCopy(filter.caption)}); + expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)}); + } + } finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();} +}); + +// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276). +for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{ + await page.setViewportSize({width:1800,height:1100}); + await openConceptLibrary(page,theme,'no-preference'); + const reference=await conceptInventory(page); + await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`); + await openProductionLibrary(page,theme,'no-preference'); + const actual=await inventoryOf(page,'#workspace-main'); + await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`); + expect(reference.library.grid.columns,'concept columns').toBe(3); + expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]); +}); + +// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer +// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match. +test('library comparison detects mutations',async({page})=>{ + await page.setViewportSize({width:1600,height:1100}); + await openConceptLibrary(page,'dark','no-preference'); + const reference=await conceptInventory(page); + await openProductionLibrary(page,'dark','no-preference'); + const measure=()=>inventoryOf(page,'#workspace-main'); + assertLibraryMatch(reference,await measure()); + + const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'}); + const cssMutated=await measure(); + expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/); + await restoreCss();await settle(page); + assertLibraryMatch(reference,await measure()); + + await mutate(page,{remove:['.card-meta .chip.red']}); + const domMutated=await measure(); + expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 909588a..f0483f8 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -219,7 +219,9 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor' expect(await page.evaluate(()=>scrollY)).toBe(0); const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); await cards(page).getByRole('button',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); - await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); + // A short phone window, so the one-card shelf can scroll far enough to put the parent back at its offset (nothing + // follows the shelf's continuation since the legacy link moved to Overview, #249 E3). + await page.setViewportSize({width:390,height:600});await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape'); }); diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index 0c5f187..66c3da5 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -354,7 +354,9 @@ def seed_workspace_concept( db = api.db epoch = db.get_execution_epoch() prompts = [] - for prompt in CONCEPT_PROMPTS: + # The concept lists its prompts most recent first (renderLibrary under Recently + # added keeps the array order), so they are created last to first. + for prompt in reversed(CONCEPT_PROMPTS): first_name, first_text = prompt.negatives[0] receipt = db.create_library_recipe( prompt.positive, @@ -374,8 +376,8 @@ def seed_workspace_concept( pair_ids.append(db.save_prompt_pair(prompt.positive, text, title=name)) # Real queue-hook counting (D23): four runs, the last one now. db.record_prompt_use(positive_id, times=CONCEPT_RUN_COUNT) - prompts.append( - {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids} + prompts.insert( + 0, {"id": prompt.id, "positive_id": positive_id, "pair_ids": pair_ids} ) folder = root / "output" / "concepts" diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 3bcde73..16fe776 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -32,7 +32,6 @@ const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative varian function el(tag,text,className) { const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node; } -function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;} // Cut at 80 UTF-16 units, never inside a surrogate pair (database/library.py titled() matches). export function libraryTitle(text) { const first=text.split('\n')[0].trim(); @@ -161,7 +160,6 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const message=el('p','','library-status');message.setAttribute('role','status'); const continuation=el('div',undefined,'library-continuation'); const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; - const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link'); const actions=headingActions(main,section); const exportButton=createButton('Export');exportButton.setAttribute('aria-label','Export full Library JSON'); const newPrompt=createButton('+ New prompt','ca-primary');actions.append(exportButton,newPrompt); @@ -188,7 +186,8 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } scope.listen(exportButton,'click',exportLibrary); headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.'); - continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section); + // Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5). + continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section); const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;