* test(browser): one test server per Playwright worker The page workflows started one global webServer and ran with one worker. Each worker now owns its own tests/browser/server.py on an OS-chosen port, with its own temp root and database, through a shared `test` in tests/browser/fixtures.mjs. baseURL, and so page, request and browser.newContext, point at that worker's server. A server that dies fails the running test with its stderr tail and the next test gets a fresh one. The worker count comes from PM_TEST_WORKERS, else 1 under CI, else a quarter of the CPUs up to 8. Chromium workflows in the pinned image: 510 tests in 606 s with one worker on main, 511 (one new isolation spec) in 119 s with eight. * test(browser): test servers bind an OS-chosen port themselves tests/browser/server.py accepts --port 0: it binds 127.0.0.1:0 before building the app, holds that socket for its whole life and prints PM_TEST_SERVER_PORT=<n> to stdout. startBrowserServer defaults to port 0 and reads the announced port, so no port is reserved and released before the server binds and two launchers can no longer race for one. The "address already in use" retry and free-port.mjs are gone; --port N still works and is checked against the announcement. * ci(coverage): shard and merge modes for the frontend coverage run frontend-coverage.mjs now plans its steps from the command line. No arguments still runs everything, then reports and checks. --shard I/N runs one slice of the chromium+touch workflows into coverage/js/raw without wiping it or reporting; shard 1 also runs the node units and the viewer suite. --merge-only reports and checks over raw coverage the shards wrote, with --update-baseline as before, and refuses an empty or missing raw dir. npm scripts: test:coverage:shard and coverage:merge. * ci(viewerjs): run browser suites with workers and shards Frontend coverage runs as three shards (PM_TEST_WORKERS=3 each) that upload their raw V8 files as coverage-raw-N; a merge job downloads them, fails unless all three arrived with raw files and no clashing names, then runs coverage:merge. Firefox and WebKit page workflows run as two shards each with three workers. The required viewerjs context keeps its name and now needs the shards, the merge and the browser shards, failing unless every one succeeded. * ci(viewerjs): cache npm downloads and size timeouts from the first sharded run The first sharded run lost a webkit shard to its 12-minute limit after all 267 tests passed, and npm ci stalled for 5 minutes in six of eight jobs. Each npm ci now restores npm's download cache keyed on the vendor lockfile, and browser job timeouts are the measured times plus at least 50%, never under 20 minutes. The workflow comment records the measurements. * test(browser): wait for rendering frames, not timers or luck, under load Two Library workflows failed in CI's webkit shard 2/2 (run 39714840809) under parallel load: - "library infinite loading ..." counted cards right after the last page hid "Load more". That render queues the window update for the next frame, so a slow frame left the 120 cards of the all-rows layout in place (received 120, expected < 80). It now waits for the next frame, which runs after every callback the page queued. - "row measurement preserves anchor ..." settled with a 60 ms timer. A scroll event is delivered in a rendering frame, so on a starved renderer the timer ended before the scroll's frame and no card was in view (null anchor). settleLibrary now waits for two consecutive native frames with no Library frame queued, checked inside the frame callback. Before/after: a scratch copy that delays every frame by 120 ms (as a starved runner) failed the old helpers 19 of 40 runs, at the CI lines, and passed the new ones 40 of 40 on chromium and webkit. Throttled to 1 CPU with 4 workers, the original spec failed 1 of 42 for each test. The Tags viewer workflow closed the viewer as soon as its image was visible, before ViewerJS rendered the title, so CI's coverage never ran the page's title callback (tags-page.js functions 26/46 = 56.52%, one function above the floor of 56; locally 27/46). The test now asserts the title names the shown file, which always runs the callback. * test(browser): stable under parallel workers Document how to run the browser suites in parallel and sharded, with the measured times, and what keeps specs stable beside each other: wait for a rendering frame, a response or a visible state, never for time. Withb665aa6the full workflows suite passed on chromium, touch, firefox and webkit with 8 workers, each test 3 times (6132 passed, 0 failed, 24 minutes), and once at CI size (docker --cpus=4, 3 workers: 2044 passed, 25.5 minutes). The npm download cache removed the npm ci stall: 0 to 2 s in every job of run 39722172529, against about 300 s in most jobs before. The workflow's timing comment now records that run; the whole workflow took 8m54s against about 33 minutes before sharding. Shard imbalance (shard 1 carries the concept-comparison specs) is documented, not tuned. * test(browser): server stderr on failures, no orphan servers, waits on state Final review fixes for the per-worker test servers (#274): - A failed test gets the stderr its server wrote during that test attached as `server-stderr`, so an aiohttp traceback behind a 500 reaches the CI report. The launcher keeps the newest 256 KB with a stream position per test (stderrMark/stderrSince), and the fixture attaches it only when the test did not end as expected. - The port announcement counts only as a complete line; a chunk ending mid-number was read as a shorter port. - server.py --exit-on-stdin-eof shuts down gracefully (database closed, temp root removed) when stdin closes. The launcher holds that pipe, so a SIGKILLed worker leaves no server behind, on every OS. - settled() gives up after 600 frames with the observation stats instead of hanging to the test timeout. - The Classic text-sync test waits for the page's 50 ms timer to install serializeValue instead of sleeping 60 ms. With that timer run 100 ms late (a scratch copy), the old wait failed 20/20 and the poll passed 20/20; the spec passed 240/240 at 8 workers x10 and 240/240 at 1 CPU with 4 workers. - The workflow test checks each npm cache key names its job's pinned image tag. Every new unit test failed before its change (missing export, the old regex timing out on the split announcement, an unknown server flag, a hung settle, a mismatched cache key). * docs(testing): server lifetime, stderr attachments and the final measured CI times TESTING.md describes the stdin-EOF shutdown and the server-stderr attachment, states that the npm cache key names the job's image tag (now tested), quotes run 39725176320 atcbc8a61(8m38s) and explains the 20-minute browser job limit. The workflow timing comment quotes the same run. * fix(browser): server shutdown and its tests on Windows, Python < 3.13 and CI Three CI failures oned809f7: - Windows: server.py exited 1 on stdin EOF because create_app works with the temp root as its cwd, and Windows cannot remove a process's cwd. main() now restores the original cwd before the root is removed. - Python 3.10 to 3.12: the lifetime test's cleanup called communicate() after closing stdin, which flushes the closed pipe (ValueError); 3.13 tolerates it. It now waits and closes the pipes itself, and the exit assertion shows the server's stderr. Under 3.12: errors=2 before, OK after. - Tooling (CI container): the SIGKILLed-worker tests treated an exited server as alive, because the container's PID 1 does not reap orphans and kill(pid, 0) succeeds on a zombie. Reproduced locally with docker without --init (2 failed); a zombie now counts as exited (2 passed).
564 lines
40 KiB
JavaScript
564 lines
40 KiB
JavaScript
import {writeFile} from 'node:fs/promises';
|
||
import {test,expect} from './fixtures.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 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; grid, cards and panel also at 1200x900 and 1800x1100 (2 and 4
|
||
// columns, the 275px and 310px panel). The topbar and sidebar (D28, D29, D52, D53) are the shell spec's.
|
||
// Paired captures are artifacts only; there are no PNG baselines.
|
||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||
|
||
// 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;
|
||
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]]);
|
||
|
||
/** 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)}`;
|
||
}
|
||
|
||
/** 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}"]`));
|
||
|
||
/**
|
||
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
|
||
* measured from the border box as the inventory records them. The concept's border is one width on every side
|
||
* (`border: 1px solid`), so the right border stands for the left one.
|
||
*/
|
||
function withoutPaddedItems(row,isRemoved) {
|
||
const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'],
|
||
['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0));
|
||
const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y,
|
||
exact:item.exact&&{...item.exact,left:item.exact.left+x}};
|
||
const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))};
|
||
const flowed=withoutItems(inner,isRemoved);
|
||
return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))};
|
||
}
|
||
|
||
/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */
|
||
function mapBulkbar(bulkbar,rise) {
|
||
if(!bulkbar)return {bulkbar,shrink:0};
|
||
if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0};
|
||
const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text));
|
||
const shrink=bulkbar.bar.height-bar.height;
|
||
return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink};
|
||
}
|
||
|
||
/**
|
||
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
|
||
* - 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;
|
||
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. */
|
||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||
|
||
export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||
const toolbar={...reference.toolbar};
|
||
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=>{
|
||
// 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+$/,'')},
|
||
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 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.
|
||
// 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);
|
||
const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise);
|
||
return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}};
|
||
}
|
||
|
||
/** 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 real operations, with the ruled intro and the Rescan wording. D55: the concept's own "Scan an
|
||
* output folder → Review scan" row follows Rescan as "Scan for metadata", opening the concept's "Review metadata scan".
|
||
*/
|
||
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').flatMap(row=>row.title==='Scan an output folder'
|
||
?[{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']},{...row,title:'Scan for metadata'}]:[row])};
|
||
}
|
||
|
||
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
|
||
const pick=inventory=>stripExact(inventory);
|
||
|
||
// 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'];
|
||
|
||
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. */
|
||
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 (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 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()=>{
|
||
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);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.
|
||
// Where the first batch leaves the row in view (1800px, four columns), that observer has already loaded it.
|
||
if(before<FIXTURE_IMAGES)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),loadingRow};
|
||
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 0.2s transition (instant under reduced motion) has started and finished.
|
||
await settle(page);
|
||
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]:[]));
|
||
|
||
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 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;
|
||
}
|
||
|
||
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(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),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;
|
||
}
|
||
|
||
/** 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,'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);});
|
||
|
||
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}`);
|
||
// The browser as the re-layout's oracle: the concept page itself without the sample tag.
|
||
const removed={};
|
||
for(const [key,selector] of [['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');
|
||
|
||
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,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.
|
||
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();
|
||
const loadingRow=(await inventoryOf(page,'#workspace-main')).continuation,loadingCount=await countText(page);
|
||
// A dispatched press, as on the concept side: a pointer click scrolls the row into view first, and the page's
|
||
// own observer (rootMargin 250px) can append the final batch and hide the button before the click lands
|
||
// (WebKit, run 39512528039). The button must still be the enabled one the user would press.
|
||
const more=page.getByRole('button',{name:'Load more images',exact:true});
|
||
await expect(more).toBeVisible();await expect(more).toHaveAttribute('aria-disabled','false');
|
||
await more.dispatchEvent('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:{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: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, and the concept page itself once 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.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([]);
|
||
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 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);
|
||
}
|
||
// 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');
|
||
}
|
||
});
|
||
|
||
// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the
|
||
// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the
|
||
// oracle for the re-laid-out bar.
|
||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||
await page.setViewportSize({width,height});
|
||
const manifest=testInfo.manifest;
|
||
const check=async()=>{
|
||
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
|
||
await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page);
|
||
};
|
||
await openConceptLoaded(page,theme,'no-preference');
|
||
const empty=await inventoryOf(page,'#main');
|
||
await check();
|
||
const reference=await inventoryOf(page,'#main');
|
||
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
|
||
const removed=(await inventoryOf(page,'#main')).bulkbar;
|
||
await restore();
|
||
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
|
||
await openProductionLoaded(page,theme,'no-preference');
|
||
const emptyActual=await inventoryOf(page,'#workspace-main');
|
||
await check();
|
||
const actual=await inventoryOf(page,'#workspace-main');
|
||
await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`);
|
||
// The concept itself: an empty bar, then "1 selected" and its six actions.
|
||
expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null});
|
||
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
|
||
'Add to collection','Export metadata','Remove links…','Clear']);
|
||
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
|
||
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
|
||
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
|
||
expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
|
||
// The bar comparison notices a change to the production bar.
|
||
await mutate(page,{css:'.bulkbar { padding: 4px }'});
|
||
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||
});
|
||
|
||
// 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/);
|
||
});
|
||
|
||
// 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`);
|
||
});
|
||
|
||
// The same singular copy when the page's own observer loads the one image: the user only scrolls (no press).
|
||
test('one image appended by scrolling reads in the singular',async({page,request})=>{
|
||
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('#result-count')).toHaveText(new RegExp(`^${PRODUCTION_BATCH} of ${total} images`));
|
||
await page.locator('.infinite-continuation').scrollIntoViewIfNeeded();
|
||
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}.`));
|
||
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);
|
||
});
|