diff --git a/TESTING.md b/TESTING.md index 07bb7af..760b650 100644 --- a/TESTING.md +++ b/TESTING.md @@ -429,6 +429,20 @@ npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \ --project chromium --project touch --project firefox --project webkit ``` +`tests/browser/images-concept.spec.mjs` compares the Images page (#249 C7): heading, +toolbar, filter row, caption, grid, the first six cards, the continuation and the panel +box, in every layout, at both sizes, themes and motion settings. The concept is compared +once its own Load more has loaded all 24 fixture images (it loads 12 per batch, +production 40). Behaviors: hover zoom, the 2nd card opens the viewer titled +" · <filename>", Escape returns focus to it, Info on the 3rd card moves +`.selected`, search "alpine", and Load more (12 in the concept, one listing page in +production, both with the toast). `mapImagesConcept` applies D8 and D16 (follow-up +PRs: no checkbox, no Save view), D3, D9, D10, D14, D15, D17, D29, D31 and D32; removed +toolbar and filter-row items flow again from the concept's own boxes (`reflow`, proven +on the concept rows first). Masonry card tops and heights may differ by one pixel (D3: +layout arithmetic versus the browser's layout-unit snapping). Two mismatches wait for a +ruling and are pinned on both sides: the category order and the singular count line. + ## Workspace data fields for the concept pages (#249) These fields are additive. The legacy `/prompt_manager/images/output` listing (no diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index cc20579..9c975fa 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -88,14 +88,21 @@ export async function mutate(page,{css=null,remove=[]}={}) { } /** - * Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, panel, library, history, - * noPageScroll}. A section is null when its element is absent. Sections whose page PR has not landed yet - * (toolbar … history, PRs C–F) are null until that PR adds its measurer here. + * Inventory sections: {chrome, heading, toolbar, filterRow, 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. Library and history stay null until PRs E and F. */ export function inventoryOf(page,scope='main') { return page.evaluate(measureInventory,scope); } +/** The inventory without its `exact` (unrounded) helper values, which only a re-layout reads; compare after this. */ +export function stripExact(value) { + if(Array.isArray(value))return value.map(stripExact); + if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).filter(([key])=>key!=='exact').map(([key,item])=>[key,stripExact(item)])); + return value; +} + /** Runs in the page; self-contained. `scope` selects the page's main element. */ export function measureInventory(scope) { const px=value=>Math.round(value); @@ -234,6 +241,95 @@ export function measureInventory(scope) { buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'), ...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))}; } - return {chrome:chromeOf(),heading:headingOf(),toolbar:null,filterRow:null,caption:null,grid:null,cards:null,panel:null, + // ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ---- + const classes=node=>[...node.classList].sort().join(' '); + const sized=node=>({width:px(rect(node).width),height:px(rect(node).height)}); + /** One control or label in a toolbar-like row: what it is, says and offers, and its box in `origin`. */ + function controlOf(node,origin) { + const base={tag:node.tagName.toLowerCase(),className:classes(node),id:node.id||null,shown:shown(node)}; + if(!base.shown)return base; + const style=getComputedStyle(node),input=node.querySelector('input'); + // A group (the layout `.segmented`) is recorded button by button: its joined text says nothing about either. + const buttons=[...node.querySelectorAll(':scope > button')]; + return {...base,text:buttons.length?null:textOf(node,'select, option'),label:node.getAttribute('aria-label'),pressed:node.getAttribute('aria-pressed'), + role:buttons.length?node.getAttribute('role'):null,buttons:buttons.length?buttons.map(button=>controlOf(button,node)):null, + primary:node.classList.contains('ca-primary'),htmlFor:node.htmlFor||null,...relative(node,origin), + ...font(node),...edges(node),...layout(node),minHeight:style.minHeight,minWidth:style.minWidth,flexGrow:style.flexGrow,flexBasis:style.flexBasis, + icon:iconOf(node), + options:node.tagName==='SELECT'?[...node.options].map(option=>option.textContent):null, + input:when(input,()=>({type:input.type,id:input.id,placeholder:input.placeholder,label:input.getAttribute('aria-label'), + ...font(input),...edges(input),minHeight:getComputedStyle(input).minHeight,...relative(input,node),exact:{width:rect(input).width}})), + // Unrounded place, for a comparison that lays a row out again; `stripExact` removes it before any diff. + exact:{left:rect(node).left-rect(origin).left,width:rect(node).width}}; + } + function rowOf(row) { + return {...layout(row),...edges(row),...relative(row,main),exact:{width:rect(row).width},items:[...row.children].map(node=>controlOf(node,row))}; + } + const gallery=main?.querySelector(':scope > .gallery-layout'); + const body=gallery?.querySelector(':scope > .gallery-body'); + const grid=body?.querySelector(':scope > .image-grid'); + function captionOf(caption) { + return {...layout(caption),...edges(caption),...font(caption),...relative(caption,body), + // A span's width follows its text, so only its line box is recorded (the D17 date's text differs). + spans:[...caption.children].filter(shown).map(span=>({tag:span.tagName.toLowerCase(),id:span.id||null,text:textOf(span),...font(span), + top:relative(span,caption).top,height:relative(span,caption).height}))}; + } + function gridOf() { + const style=getComputedStyle(grid); + return {id:grid.id||null,className:classes(grid),columns:style.gridTemplateColumns.split(/\s+/).length, + ...css(grid,'columnGap','rowGap'),...relative(grid,body),cardCount:grid.querySelectorAll(':scope > .media-card').length}; + } + function cardOf(card) { + const art=card.querySelector(':scope > .media-art'),open=art?.querySelector('.image-open'),img=open?.querySelector('img'); + const overlays=art?.querySelector('.image-overlays'),info=card.querySelector(':scope > .media-info'); + const copy=info?.querySelector(':scope > div:not(.image-card-actions)'),actions=info?.querySelector('.image-card-actions'); + const h3=copy?.querySelector('h3'),p=copy?.querySelector('p'); + return {className:classes(card),ratio:card.style.getPropertyValue('--media-ratio').replace(/\s/g,''),...relative(card,grid), + ...edges(card),...css(card,'boxShadow','overflow','display'), + art:when(art,()=>({...relative(art,card),...css(art,'position')})), + open:when(open,()=>({label:open.getAttribute('aria-label'),...relative(open,card),...edges(open),...css(open,'display','overflow')})), + img:when(img,()=>({alt:img.alt,width:img.getAttribute('width'),height:img.getAttribute('height'),...sized(img), + ...css(img,'objectFit','aspectRatio','transform','transitionProperty','transitionDuration')})), + // An overlay item's horizontal margin may be `auto`, whose used value is only the free space beside it, so its + // place is recorded as its inset from the overlay's right edge instead (as the shell's pinned bottom group). + overlays:when(overlays,()=>!shown(overlays)?{display:getComputedStyle(overlays).display,shown:false}:({shown:true,...layout(overlays), + ...css(overlays,'display','pointerEvents'),...relative(overlays,card), + items:[...overlays.children].map(node=>{const {marginLeft,marginRight,...item}=controlOf(node,card); + return item.shown?{...item,rightInset:px(rect(overlays).right-rect(node).right)}:item;})})), + info:when(info,()=>({...layout(info),...edges(info),...relative(info,card), + // The text and the actions are placed in the card (what is seen), so a centred list row compares by place. + // The text's heights are the unrounded flow (the same CSS lays them out alike in both documents), so lines + // of 16.5px and 13.5px add up exactly. + copy:when(copy,()=>({left:relative(copy,card).left,top:relative(copy,card).top,height:rect(copy).height,...css(copy,'minWidth')})), + h3:when(h3,()=>({text:h3.textContent,title:h3.getAttribute('title'),...font(h3),...edges(h3), + ...css(h3,'overflowX','textOverflow','whiteSpace'),height:rect(h3).height})), + // `maxContent` is the subtitle's one-line width (an unwrapped hidden copy beside it), so a wider text column can + // be shown to fit it on one line. + p:when(p,()=>({text:p.textContent,...font(p),...edges(p),height:rect(p).height,maxContent:(()=>{ + const probe=p.cloneNode(true);probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap;width:max-content'; + p.after(probe);const width=rect(probe).width;probe.remove();return px(width);})()})), + actions:when(actions,()=>({...layout(actions),...relative(actions,card),...css(actions,'flexShrink','minWidth'), + buttons:[...actions.children].map(node=>controlOf(node,actions))}))}))}; + } + function continuationOf(continuation) { + return {id:continuation.id||null,...layout(continuation),...edges(continuation),...relative(continuation,body), + items:[...continuation.children].filter(shown).map(node=>controlOf(node,continuation))}; + } + function panelOf() { + const panel=gallery.children[1]; + // The panel's content (and so the layout's height) is the inspector's, PR D; its box is compared here. + const {height,...place}=relative(gallery,main); + return {...css(gallery,'display','gridTemplateColumns','columnGap','rowGap','alignItems'),...place, + box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})}; + } + const continuation=body?.querySelector(':scope > .infinite-continuation'); + return {chrome:chromeOf(),heading:headingOf(), + toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf), + filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf), + caption:when(body?.querySelector(':scope > .section-caption'),captionOf), + grid:when(grid,gridOf), + 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}; } diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs new file mode 100644 index 0000000..819f5f3 --- /dev/null +++ b/tests/browser/images-concept.spec.mjs @@ -0,0 +1,434 @@ +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} 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, +// the first six cards, the continuation and the panel box are compared at 1600x1100 and 390x844 in both themes, and the +// page's behaviors under normal and reduced motion. The topbar and sidebar (D5, D28, D29) are the shell spec's. +// Paired captures are artifacts only; there are no PNG baselines. +test.use({locale:'en-US',timezoneId:'UTC'}); + +// The concept loads 12 images per batch (app.js imageBatchSize) and production one listing page (40), so both are +// compared once the 24 fixture images are all loaded: the concept after its own Load more press. +const FIXTURE_IMAGES=24; +const CONCEPT_BATCH=12; +const PRODUCTION_BATCH=40; +// Load more in production needs more than one listing page: the fixture continues the concept's rounds (two pages). +const LOAD_MORE_EXTRA=2*PRODUCTION_BATCH-FIXTURE_IMAGES; +const CARDS='.gallery-body > .image-grid > .media-card'; + +/** D17: "<Today|Yesterday|weekday> · <Month D>" of the newest file (the fixture's own mtime), in the test's UTC zone. */ +function captionDay(seconds,now=new Date()) { + const day=value=>Date.UTC(value.getUTCFullYear(),value.getUTCMonth(),value.getUTCDate()); + const date=new Date(seconds*1000),days=Math.round((day(now)-day(date))/86400000); + const name=days===0?'Today':days===1?'Yesterday':date.toLocaleDateString('en-US',{weekday:'long',timeZone:'UTC'}); + const options={month:'long',day:'numeric',timeZone:'UTC',...(date.getUTCFullYear()===now.getUTCFullYear()?{}:{year:'numeric'})}; + 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.top<b.top+b.height&&b.top<a.top+a.height; + const freeOf=line=>rowWidth-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 (follow-up PR): the overlay row without its checkbox label. The concept stretches the star to the label's line box + * (`align-items: normal`); alone, the row and the star are the star's own height: its min-height, or its line plus + * padding and border when taller. Its place stays the concept's top-right (`rightInset`). + */ +function withoutCheckbox(overlays) { + // A list row hides its overlays (concept `display: none`). + if(!overlays.shown)return overlays; + const items=overlays.items.filter(item=>item.tag!=='label'); + const own=item=>Math.max(parseFloat(item.minHeight)||0,parseFloat(item.lineHeight)+['paddingTop','paddingBottom','borderTopWidth','borderBottomWidth'] + .reduce((sum,key)=>sum+parseFloat(item[key]),0)); + const height=Math.max(...items.filter(item=>item.shown).map(own)); + return {...overlays,height,items:items.map(item=>item.shown?{...item,height}:item)}; +} + +/** + * A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes: + * - D31: the info fills the row beside the art and may shrink below a long nowrap title (`min-width: 0`), so it is the + * card's inner width less the art; the actions end at its right padding; + * - the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits; + * - D3: the info keeps its own height (text or actions plus padding), centred in the row the concept stretches it + * across, so the text, centred in either, moves only by half the lines it lost. + */ +function listInfo(card,actionsWidth) { + const {info}=card,{copy,p}=info; + const width=card.width-2*parseFloat(card.borderTopWidth)-card.art.width; + const column=width-parseFloat(info.paddingLeft)-parseFloat(info.paddingRight)-parseFloat(info.columnGap)-actionsWidth; + const line=parseFloat(p.lineHeight); + const pHeight=p.height>line&&p.maxContent<=column?line:p.height; + const copyHeight=copy.height-(p.height-pHeight); + const natural=Math.max(copyHeight,info.actions.height)+parseFloat(info.paddingTop)+parseFloat(info.paddingBottom); + // The row's inner box is the stretched concept info: its top is the card border, its height the art's. + return {info:{width,top:Math.round(info.top+(info.height-natural)/2),height:Math.round(natural)}, + copy:{height:copyHeight,top:Math.round(info.top+(info.height-copyHeight)/2)},p:{height:pHeight}, + actionsLeft:info.left+width-parseFloat(info.paddingRight)-actionsWidth}; +} + +/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */ +export function mapImagesConcept(reference,{newestModified,now=new Date()}) { + const toolbar=withoutItems(reference.toolbar,item=>item.text==='Save view'); + // D16 (follow-up PR): no Save view until the saved-views store ships. D14: the sort appends production's two sorts. + toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item); + // D29: no "Illustrative previews" sample tag. + const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag')); + filterRow.items=filterRow.items.map(item=>{ + // D32: the layout buttons may wrap at extreme zoom (they never do while they fit). + if(item.className==='segmented')return {...item,flexWrap:'wrap'}; + // D14: the scope chip may shrink and wrap a long source or folder name inside itself. + if(item.tag==='span'&&item.className==='chip')return {...item,minWidth:'0px'}; + return item; + }); + // A shorter toolbar or filter row moves what follows it up by as much. + const toolbarRise=reference.toolbar.height-toolbar.height; + filterRow.top-=toolbarRise; + const rise=toolbarRise+reference.filterRow.height-filterRow.height; + const list=reference.grid.className.split(' ').includes('list-grid'); + const cards=reference.cards.map(card=>{ + const filename=card.open.label.replace(/^Open /,''); + const actions=card.info.actions; + const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag ')); + // D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap. + const freed=tag?tag.width+parseFloat(actions.columnGap):0; + // D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure + // it, centred in the row the concept stretches it across; the text and the actions keep their place. + const actionsWidth=actions.width-freed; + const listRow=list?listInfo(card,actionsWidth):{}; + return {...card, + // D29: the concept's alt adds its demo-only " · sample illustration N" to the card title. + img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')}, + // D8 (follow-up PR): no selection checkbox yet; the star keeps the concept's top-right place. + overlays:withoutCheckbox(card.overlays), + info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p}, + // D10: the title keeps the file's relative path (the fixture's `concepts/` folder) in `title`. + h3:{...card.info.h3,title:`concepts/${filename}`}, + actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed, + buttons:actions.buttons.filter(button=>button!==tag)}}}; + }); + // D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime. + const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1?{...span,text:captionDay(newestModified,now)}:span)}; + // D32: the continuation's text and Load more may wrap at extreme zoom. + const continuation={...reference.continuation,flexWrap:'wrap'}; + return {...reference,toolbar,filterRow,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise}}; +} + +/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */ +function mapImportConcept(reference) { + return {...reference, + intro:{...reference.intro,text:'Image inspection and output scanning have different effects. Choose the workflow you want.'}, + rows:reference.rows.filter(row=>row.title!=='Import portable recipes').map(row=>row.title==='Scan an output folder' + ?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)}; +} + +const SECTIONS=['heading','toolbar','filterRow','caption','grid','cards','continuation','panel','noPageScroll']; +const pick=inventory=>stripExact(Object.fromEntries(SECTIONS.map(key=>[key,inventory[key]]))); + +/** + * Mismatches waiting for a maintainer ruling (task-C7 report: proposed D35, D36). They are not deviations and are not + * mapped: each is pinned below to the exact concept and production values, so the test fails once either side changes, + * and it is left out of the inventory diff only so the diff keeps naming every other difference. + */ +const PENDING_CATEGORY_ORDER=/^toolbar\.items\.1\.options\.\d+$/; +const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration']; +const PRODUCTION_CATEGORIES=['All categories','Architecture','Environments','Illustration','Landscapes']; + +function compare(reference,actual,manifest) { + return diffInventory(pick(mapImagesConcept(reference,{newestModified:manifest.newest_modified})),pick(actual)) + .filter(item=>!PENDING_CATEGORY_ORDER.test(item.path)); +} + +/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ +function assertImagesMatch(reference,actual,manifest) { + const diff=compare(reference,actual,manifest); + if(diff.length)throw new Error(`the Images page differs from the concept at ${diff.map(item=>item.path).join(', ')}`); +} + +/** + * D3: bounded rendering places masonry cards from the layout arithmetic (media width over the file's ratio), while the + * concept's flow snaps each box to the browser's layout unit (1/64px in Chromium and WebKit) before the next is + * measured, so a card edge a fraction of a unit from .5 can round to the next pixel. Only that one pixel is forgiven, + * only for masonry card tops and heights. + */ +const subpixel=(layout,item)=>layout==='Masonry'&&/^cards\.\d+\.(top|height)$/.test(item.path)&&Math.abs(item.reference-item.actual)<=1; + +// Two frames, then every running transition (a card image zooming back after the pointer left it) 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))]); +}); +const countText=page=>page.locator('#result-count').textContent(); +const toastText=page=>page.locator('.toast').textContent(); + +/** The concept with every fixture image loaded through its own Load more button; returns what the press did. */ +async function openConceptLoaded(page,theme,motion) { + await openConcept(page,'images',theme,motion);await page.mouse.move(0,0); + const before=await page.locator(CARDS).count(); + // A dispatched press: scrolling the button into view would let the concept's own observer load the batch first. + await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click'); + await expect(page.locator(CARDS)).toHaveCount(FIXTURE_IMAGES); + const load={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)}; + await page.evaluate(()=>{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page); + return load; +} + +async function openProductionLoaded(page,theme,motion,total=FIXTURE_IMAGES) { + await openProduction(page,'images',theme,motion);await page.mouse.move(0,0); + await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${Math.min(total,PRODUCTION_BATCH)} of ${total} images`)); + await expect(page.locator(CARDS)).toHaveCount(Math.min(total,PRODUCTION_BATCH)); + await settle(page); +} + +/** The Import dialog's intro and operation rows (D15). */ +async function importDialogOf(page) { + await page.locator('.heading-actions').getByRole('button',{name:'Import',exact:true}).click(); + const dialog=page.locator('dialog#modal[open]'); + await expect(dialog).toBeVisible(); + const inventory=await dialog.evaluate(node=>{ + const css=(element,...keys)=>{const style=getComputedStyle(element);return Object.fromEntries(keys.map(key=>[key,style[key]]));}; + const font=element=>css(element,'fontSize','fontWeight','lineHeight','color'); + const intro=node.querySelector('#modal-body > p.small'); + return {title:node.querySelector('#modal-title').textContent, + intro:intro&&{text:intro.textContent,...font(intro)}, + ...css(node.querySelector('.operation-list'),'display','rowGap'), + rows:[...node.querySelectorAll('.operation-list > .operation')].map(row=>({title:row.querySelector('h3').textContent, + description:row.querySelector('p').textContent,buttons:[...row.querySelectorAll('button')].map(button=>button.textContent.trim()), + titleFont:font(row.querySelector('h3')),descriptionFont:font(row.querySelector('p')),...css(row,'display','alignItems')}))}; + }); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + return inventory; +} + +/** The hovered first card image after its transition, then the pointer parked again. */ +async function hoverOf(page) { + const open=page.locator(CARDS).first().locator('.image-open'); + await open.hover(); + // The concept's transition is 0.2s; reduced motion makes it instant. + await page.waitForTimeout(450); + const state=await open.locator('img').evaluate(img=>({hovered:img.closest('.image-open').matches(':hover'), + transform:getComputedStyle(img).transform,transitionDuration:getComputedStyle(img).transitionDuration, + transitionProperty:getComputedStyle(img).transitionProperty,hoverNone:matchMedia('(hover: none)').matches})); + await page.mouse.move(0,0); + return state; +} + +const activeOf=page=>page.evaluate(()=>{const node=document.activeElement; + return {tag:node?.tagName.toLowerCase()??null,label:node?.getAttribute('aria-label')??null, + card:node?.closest('.media-card')?[...node.closest('.image-grid').children].indexOf(node.closest('.media-card')):null};}); +const selectedOf=page=>page.locator(CARDS).evaluateAll(nodes=>nodes.flatMap((node,index)=>node.classList.contains('selected')?[index]:[])); + +/** The layouts' card boxes and media sizes: Grid → List → Masonry, then back to Grid. */ +async function layoutsOf(page,scope) { + const result={}; + for(const layout of ['List','Masonry','Grid']) { + await page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true}).click(); + await expect(page.locator('.filter-row .segmented').getByRole('button',{name:layout,exact:true})).toHaveAttribute('aria-pressed','true'); + await page.mouse.move(0,0);await settle(page);await settle(page); + const inventory=await inventoryOf(page,scope); + result[layout]={grid:inventory.grid,cards:inventory.cards}; + } + return result; +} + +/** Search "alpine": the count line and the cards it leaves, then the search cleared again. */ +async function searchOf(page) { + const before=await countText(page); + await page.locator('#search').fill('alpine'); + await expect.poll(()=>countText(page)).not.toBe(before); + await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(FIXTURE_IMAGES); + await settle(page); + const result={count:await countText(page),cards:await page.locator(`${CARDS} .image-open`).evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))}; + await page.locator('#search').fill(''); + await expect(page.locator('#result-count')).toHaveText(before); + return result; +} + +/** The behaviors §7 lists, run with identical selectors in both documents. */ +async function behaviorsOf(page,scope) { + const hover=await hoverOf(page); + const second=page.locator(CARDS).nth(1).locator('.image-open'); + const opened=await second.getAttribute('aria-label'); + await second.click(); + const viewer=page.locator('#viewer-dialog[open]'); + await expect(viewer).toBeVisible(); + const viewerTitle=await page.locator('#viewer-title').textContent(); + await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + await settle(page); + const focusAfterViewer=await activeOf(page); + await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click(); + await expect.poll(()=>selectedOf(page)).toEqual([2]); + const selected=await selectedOf(page); + const layouts=await layoutsOf(page,scope); + const search=await searchOf(page); + return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search}; +} + +test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);}); + +for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{ + test.setTimeout(240_000); + await page.setViewportSize({width,height}); + const manifest=testInfo.manifest;const result={}; + for(const motion of MOTIONS) { + const capture=motion==='no-preference'; + const conceptLoad=await openConceptLoaded(page,theme,motion); + const reference=await inventoryOf(page,'#main'); + if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`); + const referenceImport=await importDialogOf(page); + const referenceBehaviors=await behaviorsOf(page,'#main'); + + await openProductionLoaded(page,theme,motion); + const actual=await inventoryOf(page,'#workspace-main'); + if(capture)await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`); + const actualImport=await importDialogOf(page); + const actualBehaviors=await behaviorsOf(page,'#workspace-main'); + result[motion]={conceptLoad,reference,actual,diff:compare(reference,actual,manifest),referenceImport,actualImport, + importDiff:diffInventory(mapImportConcept(referenceImport),actualImport),referenceBehaviors,actualBehaviors}; + } + // Load more in production: a fixture past one listing page appends one batch and shows the concept's toast. + testInfo.manifest=await seedConcept(request,{extra_images:LOAD_MORE_EXTRA}); + for(const motion of MOTIONS) { + const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA; + await openProductionLoaded(page,theme,motion,total); + const before=await page.locator(CARDS).count(); + await page.getByRole('button',{name:'Load more images',exact:true}).click(); + await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`)); + result[motion].productionLoad={before,after:await page.locator(CARDS).count(),toast:await toastText(page),count:await countText(page)}; + } + const path=testInfo.outputPath('images-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); + await testInfo.attach('images-geometry',{path,contentType:'application/json'}); + + for(const [motion,entry] of Object.entries(result)) { + const {conceptLoad,productionLoad,reference,actual,diff,referenceImport,importDiff,referenceBehaviors:concept,actualBehaviors:production}=entry; + // The concept itself is what this comparison assumes: its first batch, its sample card and its copy. + expect.soft(conceptLoad,`${motion} concept Load more`).toEqual({before:CONCEPT_BATCH,after:FIXTURE_IMAGES, + toast:`${CONCEPT_BATCH} more images loaded. ${FIXTURE_IMAGES} of ${FIXTURE_IMAGES}.`,count:`${FIXTURE_IMAGES} of ${FIXTURE_IMAGES} images · 6 positive prompts`}); + expect.soft(productionLoad,`${motion} production Load more`).toEqual({before:PRODUCTION_BATCH,after:2*PRODUCTION_BATCH, + toast:`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`, + count:`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`}); + // D11: the concept's subtitle is the fixture's own "W × H · PNG · Seed N". + const first=manifest.images[0]; + expect.soft(reference.cards[0].info.p.text,`${motion} concept subtitle`).toBe(`${first.width} × ${first.height} · PNG · Seed ${first.seed}`); + expect.soft(reference.cards[0].info.h3.fontSize,`${motion} concept h3 size`).toBe('11px'); + expect.soft(referenceImport.rows.map(row=>row.title),`${motion} concept Import rows`).toEqual(['Inspect a local image','Scan an output folder','Import portable recipes']); + // The row model reproduces the concept's own rows before it lays out their D16/D29 remainders. + expect.soft(diffInventory(stripExact(reflow(reference.toolbar,reference.toolbar.items)),stripExact(reference.toolbar)),`${motion} toolbar model`).toEqual([]); + expect.soft(diffInventory(stripExact(reflow(reference.filterRow,reference.filterRow.items)),stripExact(reference.filterRow)),`${motion} filter row model`).toEqual([]); + expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]); + // Pending D36 (proposed): the concept lists its sample categories in prompt order, production the Library's + // categories alphabetically (/prompt_manager/library/options). + expect.soft([reference.toolbar.items[1].options,actual.toolbar.items[1].options],`${motion} category order (pending D36)`) + .toEqual([CONCEPT_CATEGORIES,PRODUCTION_CATEGORIES]); + expect.soft(importDiff,`${motion} Import dialog differences after D15`).toEqual([]); + expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true); + // Hover (C4): the concept's 1.03 zoom over 0.2s, instant under reduced motion; D18: none where there is no hover. + expect.soft(concept.hover,`${motion} concept hover`).toMatchObject({hovered:true,transform:'matrix(1.03, 0, 0, 1.03, 0, 0)', + transitionDuration:motion==='reduce'?'0s':'0.2s'}); + expect.soft(production.hover,`${motion} hover`).toEqual(production.hover.hoverNone?{...concept.hover,transform:'none'}:concept.hover); + // The 2nd card's image opens the viewer titled "<title> · <filename>". + expect.soft(concept.viewerTitle,`${motion} concept viewer title`).toBe(`${reference.cards[1].info.h3.text} · ${concept.opened.replace(/^Open /,'')}`); + expect.soft(production.viewerTitle,`${motion} viewer title`).toBe(concept.viewerTitle); + // Escape returns focus to the image that opened the viewer. + expect.soft(production.focusAfterViewer,`${motion} focus after the viewer`).toEqual({tag:'button',label:concept.opened,card:1}); + // The concept re-renders its grid when the viewer closes (app.js 1737-1743), so its focus falls to the body; + // §7 and D19 keep production's restore, which is what is asserted above. + // Info on the 3rd card moves `.media-card.selected` there. + expect.soft(production.selected,`${motion} selection after Info`).toEqual(concept.selected); + // List (160×95 art) and Masonry (--media-ratio media) as the concept lays them out. + expect.soft(concept.layouts.List.cards[0].img,`${motion} concept list art`).toMatchObject({width:160,height:95}); + for(const layout of ['List','Masonry','Grid']) { + const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{newestModified:manifest.newest_modified}); + expect.soft(diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(production.layouts[layout])).filter(item=>!subpixel(layout,item)), + `${motion} ${layout} layout`).toEqual([]); + } + for(const card of concept.layouts.Masonry.cards) { + const [w,h]=card.ratio.split('/').map(Number); + expect.soft(Math.abs(card.img.width/card.img.height-w/h),`${motion} concept masonry ratio ${card.open.label}`).toBeLessThan(0.02); + } + // Search "alpine" leaves the same images in both documents. Pending D35 (proposed): production pluralizes the + // count line ("1 positive prompt"); the concept's copy is always plural ("1 positive prompts"). + expect.soft(production.search.cards,`${motion} search alpine`).toEqual(concept.search.cards); + expect.soft([concept.search.count,production.search.count],`${motion} search count line (pending D35)`) + .toEqual(['4 of 4 images · 1 positive prompts','4 of 4 images · 1 positive prompt']); + } +}); + +// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the +// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match. +test('images comparison detects mutations',async({page},testInfo)=>{ + await page.setViewportSize({width:1600,height:1100}); + const manifest=testInfo.manifest; + await openConceptLoaded(page,'dark','no-preference'); + const reference=await inventoryOf(page,'#main'); + await openProductionLoaded(page,'dark','no-preference'); + const measure=()=>inventoryOf(page,'#workspace-main'); + assertImagesMatch(reference,await measure(),manifest); + + const restoreCss=await mutate(page,{css:'.media-info { padding: 4px }'}); + const cssMutated=await measure(); + expect(()=>assertImagesMatch(reference,cssMutated,manifest)).toThrow(/cards\.0\.info\.paddingTop/); + await restoreCss();await settle(page); + assertImagesMatch(reference,await measure(),manifest); + + await mutate(page,{remove:['[aria-label^="Inspect"]']}); + const domMutated=await measure(); + expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/); +}); diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index 8dde5ab..74527cf 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -430,6 +430,8 @@ def seed_workspace_concept( api.invalidate_gallery_cache() return { "epoch": epoch, + # The newest file's mtime (epoch seconds), for the D17 caption date. + "newest_modified": now - 60, "prompts": prompts, "images": images, "runs": runs, diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 1900f1d..6da7271 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -164,6 +164,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const grid = el('div', undefined, 'image-grid'); grid.setAttribute('role', 'list'); grid.setAttribute('aria-label', 'Image collection'); const continuation = el('div', undefined, 'infinite-continuation'); + // The concept's ids (app.js renderImages); the Library-embedded gallery has its own page around it. + if (!embedded) { grid.id = 'image-grid'; continuation.id = 'gallery-continuation'; } const message = el('p', '', 'small'); const more = createButton('Load more images'); const retry = createButton('Retry'); @@ -332,8 +334,9 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild); previous = card; } - // Natural footer measurements and absolute reservations share unzoomed CSS units. - const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').offsetHeight)); + // Natural footer measurements and absolute reservations share unzoomed CSS units. The fractional height, as the + // concept's own flow lays it out: `offsetHeight` rounds a wrapped 67.5px row up and adds half a pixel per row. + const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.media-info').getBoundingClientRect().height / scale)); if (footerHeight > metrics.footerHeight) { const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight); const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y;