diff --git a/TESTING.md b/TESTING.md
index 760b650..d7616c9 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -431,17 +431,22 @@ npm run --prefix tools/vendor test:workflows -- shell-concept.spec.mjs \
`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
-"
· ", 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.
+box, in Grid, List, Masonry and the Large and Compact sizes, at 1600x1100 and 390x844
+in both themes and motion settings; grid, cards and panel also at 1200x900 (2 columns,
+275px panel) and 1800x1100 (4 columns, 310px panel). The concept is compared once its
+own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, production
+40); the Load more row is compared before the press in both documents. Behaviors: hover
+zoom, the 2nd card opens the viewer titled " · ", Escape returns focus
+to it (production only: the concept re-renders its grid and drops focus), Info on the
+3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
+its toast. `mapImagesConcept` applies D3, D8 and D16 (follow-up PRs: no checkbox, no
+Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
+sorted alphabetically). Removed toolbar and filter-row items flow again from the
+concept's own boxes (`reflow`), checked against the concept page with those items
+removed at runtime. Masonry tops and heights are compared unrounded, within one layout
+unit per card above in the column (D3). `one footer height for the collection (D3)`
+seeds two seedless files (`seedless_images` on `/__test__/concept/reset`) and asserts
+that every row reserves the tallest footer.
## Workspace data fields for the concept pages (#249)
diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs
index 9c975fa..dcdc813 100644
--- a/tests/browser/concept-inventory.mjs
+++ b/tests/browser/concept-inventory.mjs
@@ -285,17 +285,19 @@ export function measureInventory(scope) {
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),
+ exact:{left:rect(card).left-rect(grid).left,top:rect(card).top-rect(grid).top,height:rect(card).height},
...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).
+ // An overlay item's horizontal margin may be `auto`, whose used value is the free space beside it: it is kept
+ // to the pixel, and the item's place is also recorded as its inset from the overlay's right edge.
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;})})),
+ items:[...overlays.children].map(node=>{const item=controlOf(node,card);
+ return item.shown?{...item,marginLeft:`${px(parseFloat(item.marginLeft))}px`,marginRight:`${px(parseFloat(item.marginRight))}px`,
+ 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
diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs
index 819f5f3..71ed912 100644
--- a/tests/browser/images-concept.spec.mjs
+++ b/tests/browser/images-concept.spec.mjs
@@ -6,11 +6,12 @@ import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,
// 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.
+// page's behaviors under normal and reduced motion; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
+// columns, the 275px and 310px panel). 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
+// D37: 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;
@@ -18,6 +19,11 @@ 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';
+const WIDE_SIZES=Object.freeze([[1200,900],[1800,1100]]);
+
+/** D35: production uses the singular for exactly one; the concept's plurals are hard-coded (app.js 447, 489, 517-521). */
+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');
/** D17: " · " of the newest file (the fixture's own mtime), in the test's UTC zone. */
function captionDay(seconds,now=new Date()) {
@@ -96,16 +102,19 @@ function withoutCheckbox(overlays) {
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)};
+ // D8 (interim): alone, the star is pushed to the right by `margin-left: auto`, whose used value is the overlay's
+ // free width; the checkbox PR removes that rule and this line.
+ const pushed=item=>items.length===1?{marginLeft:`${overlays.width-item.width}px`}:{};
+ return {...overlays,height,items:items.map(item=>item.shown?{...item,height,...pushed(item)}: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.
+ * - D3 (the measurable list row, `.list-grid .media-info` in the overlay): 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, and the actions
+ * end at its right padding; it 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;
+ * - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
*/
function listInfo(card,actionsWidth) {
const {info}=card,{copy,p}=info;
@@ -122,10 +131,17 @@ function listInfo(card,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 nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return xy?1:ab?1:0;};
+
+export function mapImagesConcept(reference,{manifest,now=new Date()}) {
+ const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
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);
+ // D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
+ // COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
+ toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
+ ?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item);
// D29: no "Illustrative previews" sample tag.
const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
filterRow.items=filterRow.items.map(item=>{
@@ -156,18 +172,34 @@ export function mapImagesConcept(reference,{newestModified,now=new Date()}) {
// 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}`},
+ // D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
+ h3:{...card.info.h3,title:paths.get(filename)??null},
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'};
+ // D35: the count line uses the singular for one.
+ const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1
+ ?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})};
+ const continuation=mapContinuation(reference.continuation);
return {...reference,toolbar,filterRow,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise}};
}
+/** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */
+function mapContinuation(continuation,numbers=null) {
+ return {...continuation,flexWrap:'wrap',items:continuation.items.map(item=>item.tag!=='p'?item:{...item,
+ // D37: a row with more to load counts production's own page ("N of M"), not the concept's 12-image batch.
+ text:countCopy(numbers?item.text.replace(/^\d+ of \d+/,`${numbers.loaded} of ${numbers.total}`):item.text)})};
+}
+
+/** D37: the Load more row before its press, the concept's at 12 of 24 against production's at its own page. */
+function compareLoadingRow(reference,actual,count) {
+ const [,loaded,total]=count.match(/^(\d+) of (\d+)/);
+ // The row's place follows the grid above it, whose height is the batch's (D37).
+ const unplaced=({top,...row})=>stripExact(row);
+ return diffInventory(unplaced(mapContinuation(reference,{loaded,total})),unplaced(actual));
+}
+
/** D15: the Import dialog's two real operations, with the ruled intro and the Rescan wording. */
function mapImportConcept(reference) {
return {...reference,
@@ -177,20 +209,14 @@ function mapImportConcept(reference) {
}
const SECTIONS=['heading','toolbar','filterRow','caption','grid','cards','continuation','panel','noPageScroll'];
-const pick=inventory=>stripExact(Object.fromEntries(SECTIONS.map(key=>[key,inventory[key]])));
+const pick=inventory=>stripExact(inventory);
-/**
- * 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+$/;
+// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
-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));
+function compare(reference,actual,manifest,sections=SECTIONS) {
+ const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
+ return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
}
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
@@ -200,12 +226,24 @@ function assertImagesMatch(reference,actual,manifest) {
}
/**
- * 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.
+ * D3 (masonry sub-pixel snapping): 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, 1/60px in Firefox) before measuring the next. So masonry tops and heights are compared unrounded instead,
+ * within one layout unit per card above in the column (the card's own included); the rounded fields are skipped there.
*/
-const subpixel=(layout,item)=>layout==='Masonry'&&/^cards\.\d+\.(top|height)$/.test(item.path)&&Math.abs(item.reference-item.actual)<=1;
+const MASONRY_ROUNDED=/^cards\.\d+\.(top|height)$/;
+function masonryDrift(reference,actual) {
+ return reference.cards.flatMap((card,index)=>{
+ const above=reference.cards.slice(0,index).filter(other=>Math.abs(other.exact.left-card.exact.left)<1).length;
+ const bound=(above+1)/60+1e-6,other=actual.cards[index];
+ return ['top','height'].filter(key=>!(Math.abs(card.exact[key]-other.exact[key])<=bound))
+ .map(key=>({path:`cards.${index}.exact.${key}`,reference:card.exact[key],actual:other.exact[key],bound}));
+ });
+}
+function compareLayout(layout,reference,mapped,actual) {
+ const diff=diffInventory(stripExact({grid:mapped.grid,cards:mapped.cards}),stripExact(actual));
+ return layout==='Masonry'?[...diff.filter(item=>!MASONRY_ROUNDED.test(item.path)),...masonryDrift(reference,actual)]:diff;
+}
// Two frames, then every running transition (a card image zooming back after the pointer left it) finished.
const settle=page=>page.evaluate(async()=>{
@@ -218,12 +256,14 @@ 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);
+ await openConcept(page,'images',theme,motion);await page.mouse.move(0,0);await settle(page);
const before=await page.locator(CARDS).count();
+ const loadingRow=(await inventoryOf(page,'#main')).continuation;
// 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');
+ // Where the first batch leaves the row in view (1800px, four columns), that observer has already loaded it.
+ if(before{document.activeElement?.blur();window.scrollTo(0,0);});await settle(page);
return load;
}
@@ -274,27 +314,33 @@ const activeOf=page=>page.evaluate(()=>{const node=document.activeElement;
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. */
+const LAYOUTS=['List','Masonry','Grid','Large','Compact'];
+/** The card boxes in each layout: Grid → List → Masonry → Grid, then the Large and Compact thumbnail sizes. */
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');
+ for(const layout of LAYOUTS) {
+ if(['Large','Compact'].includes(layout))await page.locator('#image-size').selectOption(layout.toLowerCase());
+ else {
+ 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};
}
+ await page.locator('#image-size').selectOption('comfortable');await settle(page);
return result;
}
-/** Search "alpine": the count line and the cards it leaves, then the search cleared again. */
-async function searchOf(page) {
+const continuationText=page=>page.locator('.infinite-continuation > p').first().textContent();
+/** A search: the count line, the continuation and the cards it leaves, then the search cleared again. */
+async function searchOf(page,term) {
const before=await countText(page);
- await page.locator('#search').fill('alpine');
+ await page.locator('#search').fill(term);
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')))};
+ const result={count:await countText(page),continuation:await continuationText(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;
@@ -317,8 +363,10 @@ async function behaviorsOf(page,scope) {
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};
+ const search=await searchOf(page,'alpine');
+ // Exactly one image (a unique filename stem): both counted nouns in the singular (D35).
+ const single=await searchOf(page,'alpine_00003');
+ return {hover,opened,viewerTitle,focusAfterViewer,selected,layouts,search,single};
}
test.beforeEach(async({request},testInfo)=>{testInfo.manifest=await seedConcept(request);});
@@ -332,6 +380,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
const conceptLoad=await openConceptLoaded(page,theme,motion);
const reference=await inventoryOf(page,'#main');
if(capture)await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
+ // The browser as the re-layout's oracle: the concept page itself without Save view, then without the sample tag.
+ const removed={};
+ for(const [key,selector] of [['toolbar','.toolbar > [data-action="save-view"]'],['filterRow','.filter-row > .sample-tag']]) {
+ const restore=await mutate(page,{remove:[selector]});
+ removed[key]=(await inventoryOf(page,'#main'))[key];
+ await restore();
+ }
const referenceImport=await importDialogOf(page);
const referenceBehaviors=await behaviorsOf(page,'#main');
@@ -340,7 +395,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
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,
+ result[motion]={conceptLoad,reference,removed,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.
@@ -349,34 +404,43 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
const total=FIXTURE_IMAGES+LOAD_MORE_EXTRA;
await openProductionLoaded(page,theme,motion,total);
const before=await page.locator(CARDS).count();
+ const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
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)};
+ result[motion].loadingDiff=compareLoadingRow(result[motion].conceptLoad.loadingRow,loadingRow,loadingCount);
}
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;
+ const {conceptLoad:{loadingRow,...conceptLoad},productionLoad,loadingDiff,reference,removed,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`});
+ // D37: production appends one listing page, with the concept's toast and count copy (D35 for one).
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`});
+ toast:countCopy(`${PRODUCTION_BATCH} more images loaded. ${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH}.`),
+ count:countCopy(`${2*PRODUCTION_BATCH} of ${2*PRODUCTION_BATCH} images · 6 positive prompts`)});
+ // D37: the row with more to load, before its press, in both documents.
+ expect.soft(loadingRow.items.map(item=>item.text),`${motion} concept loading row`).toEqual([`${CONCEPT_BATCH} of ${FIXTURE_IMAGES} images · more load as you scroll`,'Load more images']);
+ expect.soft(loadingDiff,`${motion} loading row differences after D32, D35, D37`).toEqual([]);
// 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.
+ // The row model reproduces the concept's own rows, and the concept page itself once Save view (D16) or the
+ // sample tag (D29) is removed from it at runtime: the browser is the oracle for the remainders the mapping uses.
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(diffInventory(stripExact(withoutItems(reference.toolbar,item=>item.text==='Save view')),stripExact(removed.toolbar)),
+ `${motion} toolbar without Save view`).toEqual([]);
+ expect.soft(diffInventory(stripExact(withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'))),stripExact(removed.filterRow)),
+ `${motion} filter row without the sample tag`).toEqual([]);
+ expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,`${motion} concept categories`).toEqual(CONCEPT_CATEGORIES);
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.
@@ -394,20 +458,20 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
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 layout of LAYOUTS) {
+ const mapped=mapImagesConcept({...reference,...concept.layouts[layout]},{manifest});
+ expect.soft(compareLayout(layout,concept.layouts[layout],mapped,production.layouts[layout]),`${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']);
+ // Searches leave the same images, count line and continuation in both documents (D35 for one).
+ const mappedSearch=search=>({...search,count:countCopy(search.count),continuation:countCopy(search.continuation)});
+ expect.soft(production.search,`${motion} search alpine`).toEqual(mappedSearch(concept.search));
+ expect.soft(concept.single,`${motion} concept single result`).toMatchObject({count:'1 of 1 images · 1 positive prompts',cards:['Open alpine_00003.png']});
+ expect.soft(production.single,`${motion} search for one image`).toEqual(mappedSearch(concept.single));
+ expect.soft(production.single.count,`${motion} singular count line`).toBe('1 of 1 image · 1 positive prompt');
}
});
@@ -432,3 +496,62 @@ test('images comparison detects mutations',async({page},testInfo)=>{
const domMutated=await measure();
expect(()=>assertImagesMatch(reference,domMutated,manifest)).toThrow(/cards\.0\.info\.actions\.buttons\.length/);
});
+
+// Grid, cards and panel at the other concept breakpoints: 2 columns and the 275px panel at 1200, 4 columns and the
+// 310px panel at 1800.
+for(const [width,height] of WIDE_SIZES)for(const theme of THEMES)test(`images grid, cards and panel match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
+ await page.setViewportSize({width,height});
+ const manifest=testInfo.manifest;
+ await openConceptLoaded(page,theme,'no-preference');
+ const reference=await inventoryOf(page,'#main');
+ await captureCanonicalState(page,testInfo,`concept-images-${width}-${theme}`);
+ await openProductionLoaded(page,theme,'no-preference');
+ const actual=await inventoryOf(page,'#workspace-main');
+ await captureCanonicalState(page,testInfo,`production-images-${width}-${theme}`);
+ expect(reference.grid.columns,'concept columns').toBe(width<=1250?2:4);
+ expect(reference.panel.box.width,'concept panel').toBe(width<=1250?275:310);
+ expect(compare(reference,actual,manifest,['grid','cards','panel'])).toEqual([]);
+});
+
+// D35 for the toast and the continuation: production's Load more appending exactly one image, against the concept's
+// own toast and row copy (app.js 489, 517-521) with production's numbers (D37).
+test('one appended image reads in the singular',async({page,request})=>{
+ // A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
+ test.setTimeout(120_000);
+ await page.setViewportSize({width:1600,height:1100});
+ const total=PRODUCTION_BATCH+1;
+ await seedConcept(request,{extra_images:total-FIXTURE_IMAGES});
+ await openProductionLoaded(page,'dark','no-preference',total);
+ await expect(page.locator('.infinite-continuation > p').first()).toHaveText(countCopy(`${PRODUCTION_BATCH} of ${total} images · more load as you scroll`));
+ // A dispatched press: scrolling the button into view lets the page's own observer load the one image first.
+ await page.getByRole('button',{name:'Load more images',exact:true}).dispatchEvent('click');
+ await expect(page.locator('#result-count')).toHaveText(new RegExp(`^${total} of ${total} images`));
+ await expect(page.locator('.toast')).toHaveText(countCopy(`1 more images loaded. ${total} of ${total}.`));
+ expect(countCopy('1 more images loaded. 41 of 41.')).toBe('1 more image loaded. 41 of 41.');
+ await expect(page.locator('.infinite-continuation > p').first()).toHaveText(`You’re all caught up · ${total} images`);
+});
+
+// D3 (one footer height for the collection): bounded rendering cannot measure unrendered cards, so every row reserves
+// the tallest measured footer. Two seedless files (D11 "W × H · PNG", one line) form the first row at 390px, where
+// every concept subtitle wraps: their row is as tall as the wrapped rows, and every concept row moves down by one.
+test('one footer height for the collection (D3)',async({page,request})=>{
+ // A second fixture seed (dozens of PNGs and their thumbnails) runs inside the test.
+ test.setTimeout(120_000);
+ await page.setViewportSize({width:390,height:844});
+ await openConceptLoaded(page,'dark','no-preference');
+ const reference=await inventoryOf(page,'#main');
+ expect(reference.grid.columns).toBe(2);
+ expect(reference.cards.every(card=>card.info.p.height>parseFloat(card.info.p.lineHeight))).toBe(true);
+ const manifest=await seedConcept(request,{seedless_images:2});
+ await openProductionLoaded(page,'dark','no-preference',FIXTURE_IMAGES+2);
+ const actual=await inventoryOf(page,'#workspace-main');
+ const box=card=>({left:card.left,top:card.top,width:card.width,height:card.height});
+ const pitch=reference.cards[2].top-reference.cards[0].top;
+ expect(actual.cards.map(box)).toEqual([box(reference.cards[0]),box(reference.cards[1]),
+ ...reference.cards.slice(0,4).map(card=>({...box(card),top:card.top+pitch}))]);
+ const seedless=actual.cards.slice(0,2);
+ expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
+ expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
+ // Their own footer is one line shorter than the row it reserves.
+ for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
+});
diff --git a/tests/browser/server.py b/tests/browser/server.py
index 0956dbc..26066f2 100644
--- a/tests/browser/server.py
+++ b/tests/browser/server.py
@@ -16,6 +16,7 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.workspace_concept_fixture import (
MAX_EXTRA_IMAGES as CONCEPT_MAX_EXTRA_IMAGES,
+ MAX_SEEDLESS_IMAGES as CONCEPT_MAX_SEEDLESS_IMAGES,
seed_workspace_concept,
)
from tests.browser.workspace_visual_fixture import seed_workspace_visual
@@ -389,13 +390,20 @@ def create_app(root: Path) -> web.Application:
"""Seed the concept-shaped fixture (D30); only a bounded image count."""
nonlocal history_fixture
data = await request.json() if request.can_read_body else {}
+ limits = {
+ "extra_images": CONCEPT_MAX_EXTRA_IMAGES,
+ "seedless_images": CONCEPT_MAX_SEEDLESS_IMAGES,
+ }
if (
request.query
or not isinstance(data, dict)
- or set(data) - {"extra_images"}
- or isinstance(data.get("extra_images", 0), bool)
- or not isinstance(data.get("extra_images", 0), int)
- or not 0 <= data.get("extra_images", 0) <= CONCEPT_MAX_EXTRA_IMAGES
+ or set(data) - set(limits)
+ or any(
+ isinstance(data.get(key, 0), bool)
+ or not isinstance(data.get(key, 0), int)
+ or not 0 <= data.get(key, 0) <= limit
+ for key, limit in limits.items()
+ )
):
raise web.HTTPBadRequest()
history_commands.reset()
@@ -403,7 +411,10 @@ def create_app(root: Path) -> web.Application:
history_fixture = None
manifest = await api._run_in_executor(
lambda: seed_workspace_concept(
- api, root, extra_images=data.get("extra_images", 0)
+ api,
+ root,
+ extra_images=data.get("extra_images", 0),
+ seedless_images=data.get("seedless_images", 0),
)
)
return web.json_response({"success": True, **manifest})
diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py
index 74527cf..0c5f187 100644
--- a/tests/browser/workspace_concept_fixture.py
+++ b/tests/browser/workspace_concept_fixture.py
@@ -155,6 +155,9 @@ CONCEPT_RUN_COUNT = 4
# The concept's Model fact (app.js:408).
CONCEPT_MODEL = "illustrative-model.safetensors"
MAX_EXTRA_IMAGES = 120
+# Unlinked, seedless images (D11 "W × H · PNG") newer than the concept's, for D3 checks.
+MAX_SEEDLESS_IMAGES = 2
+SEEDLESS_SIZE = (1536, 1152)
SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10"
_TABLES = (
"execution_run_outputs",
@@ -319,18 +322,34 @@ def _record_run(
return start.run_id
+def _write_seedless_png(path: Path) -> None:
+ """A plain PNG with no metadata: no prompt, no seed, no link."""
+ with Image.new("RGB", SEEDLESS_SIZE, "#2b3442") as image:
+ image.save(path)
+
+
+def _bounded(name: str, value: int, maximum: int) -> None:
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise TypeError(f"{name} must be an integer")
+ if not 0 <= value <= maximum:
+ raise ValueError(f"{name} is out of range")
+
+
def seed_workspace_concept(
- api: PromptManagerAPI, root: Path, *, extra_images: int = 0
+ api: PromptManagerAPI,
+ root: Path,
+ *,
+ extra_images: int = 0,
+ seedless_images: int = 0,
) -> dict:
"""Replace this server's disposable rows and files with the concept's data.
``extra_images`` continues the concept's rounds (same prompts, dimensions and
seed rule) after its 24 images, for bounded-rendering and Load more checks.
+ ``seedless_images`` adds unlinked PNGs without metadata, newest of all.
"""
- if isinstance(extra_images, bool) or not isinstance(extra_images, int):
- raise TypeError("extra_images must be an integer")
- if not 0 <= extra_images <= MAX_EXTRA_IMAGES:
- raise ValueError("extra_images is out of range")
+ _bounded("extra_images", extra_images, MAX_EXTRA_IMAGES)
+ _bounded("seedless_images", seedless_images, MAX_SEEDLESS_IMAGES)
_clear(api, root)
db = api.db
epoch = db.get_execution_epoch()
@@ -414,10 +433,19 @@ def seed_workspace_concept(
"prompt": prompt.id,
"variant": variant,
"seed": seed,
+ "path": f"concepts/{name}",
"width": facts["width"],
"height": facts["height"],
}
)
+ seedless = []
+ for number in range(seedless_images):
+ name = f"seedless_{number + 1:05}.png"
+ path = folder / name
+ _write_seedless_png(path)
+ stamp = now - 10 - number * 10
+ os.utime(path, (stamp, stamp))
+ seedless.append({"filename": name, "path": f"concepts/{name}"})
with db.model.get_connection() as conn:
counts = conn.execute(
"SELECT COUNT(*), MIN(run_count), MAX(run_count) FROM prompts "
@@ -431,7 +459,8 @@ def seed_workspace_concept(
return {
"epoch": epoch,
# The newest file's mtime (epoch seconds), for the D17 caption date.
- "newest_modified": now - 60,
+ "newest_modified": now - 10 if seedless_images else now - 60,
+ "seedless": seedless,
"prompts": prompts,
"images": images,
"runs": runs,