* feat(views): saved-views store and API
Add a saved_views table (page images|library, trimmed 1-100 character
name, whitelisted JSON state of at most 4 KB, position, timestamps)
installed through the same validated-savepoint migration as image
favorites. It is optional in old backups and required in a migrated
restore stage; restore rejects a tampered definition, extra indexes and
any row that would fail write validation.
Routes under /prompt_manager/saved-views: GET lists views by position,
POST {page,name,state} returns 201 {success, view}, PATCH /{id} {name}
renames and DELETE /{id} removes, both 404 for an unknown id. The state
must hold exactly the page's toolbar and Refine-this-view keys (Images:
q, category, sort, source, folder, media, tags; Library: q, category,
sort, tags) with their types and sort/media values; anything else is a
400.
Refs #249
* feat(views): Save view, the SAVED VIEWS sidebar and opening a view
The shared toolbar gains the concept's Save view between sort and Filters
on Images and the Prompt library. It opens the concept's simpleInput
("Save this view", "View name", default "Search: <q>" or "My workspace
view", Cancel / Save); an empty name is "Saved view". A save posts the
page's full whitelisted state (toolbar value plus Refine filters) and
toasts "Saved view added to the sidebar.".
The shell adds the concept's SAVED VIEWS label and nav#saved-nav, loaded
with the shell and re-read after every save. Rows use the concept's
links() markup (search icon, name, no count) and link #/saved/<id>.
Opening one mounts its page with the saved state through the page's own
filters, so the toolbar and "Filters · N" (D44) show it; the h1,
breadcrumb and title are the view's name. A removed Source or Folder is
dropped to its default after discovery, so no listing read is rejected;
an unknown id re-reads the list once, then opens Images; a failed list
read offers Retry.
D16 is retired: images-concept and library-concept no longer map Save
view out. New deviations: D49 (no PROPOSED badge) and D50 (no Needs
review row); shell-concept maps Favorites out until Task 3 and compares
a saved row against the concept's. Browser fixtures now wipe
saved_views.
* feat(views): Edit rename/delete and the Favorites saved view
- Favorites is the first SAVED VIEWS row (star icon, name, real count).
The count is the Favorites listing's own limit=1 total, follows the
nav-count rules and is re-read after every confirmed star or unstar.
- #/favorites opens Images filtered to favorites. The workspace listing
takes favorites=1: each selected root's favorites are read through the
image_favorites primary key and matched against the cached scan with the
toggle's case folding; a failed read is an error, never an unfiltered
page. The cursor scope includes the flag.
- Saved Images state gains a required favorites boolean, so Save view on
Favorites reopens the favorites.
- An open saved view's heading leads with Edit: "Edit saved view" with
View name, Delete... (danger), Cancel and Save. Delete... confirms in the
same dialog (never window.confirm). Toasts "Saved view renamed." and
"Saved view deleted."; a delete returns to the view's plain page.
- shell-concept no longer maps Favorites out (Needs review stays, D50).
Recorded as D51 in TESTING.md.
* fix(views): open stale saved views safely and finish the review fixes
Startup and restore now check only the saved_views structure (table,
columns, types, sizes, a JSON object state) and never the value rules,
so a row saved under older rules no longer stops the database from
opening. Reads normalize each view: missing keys take their defaults,
unknown keys are dropped, invalid values default and the name is made
displayable. POST and PATCH still validate strictly.
Only request validation (SavedViewInvalid) maps to 400; database and
internal errors are a logged generic 500. A saved view no longer
replaces the plain Images or Library return state, a toolbar change
while sources load keeps a saved Source and Folder, a state over 4 KB
is refused in the browser with its own toast, the delete confirmation
skips its failure toast after the page is gone, the Edit dialog reuses
the shared field helper, and quick star toggles coalesce into one
Favorites count read.
* test(views): cover leaving a pending saved view and add its coverage floor
A saved view left while the list or a rename is still pending leaves
the next page alone; the shell drops two unreachable heading guards.
workspace/saved-views.mjs gets its frontend coverage baseline entry;
no existing floor changes.
* fix(views): a corrupt saved-view state no longer blocks startup
At startup a saved_views row whose state is not a JSON object is
logged as a warning by id only and left in place. The listing and
rename skip it, so its #/saved/<id> link behaves like a missing view.
Restore stays strict and still rejects a backup holding such a row.
341 lines
23 KiB
JavaScript
341 lines
23 KiB
JavaScript
import {writeFile} from 'node:fs/promises';
|
||
import {test,expect} from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,countCopy,reflow} from './concept-inventory.mjs';
|
||
|
||
// Task E3 of #249: the production Prompt library shelf against the immutable concept (app.js renderLibrary a572–586,
|
||
// style.css s648–708), on the concept-shaped real fixture (the concept's six prompts with their negatives, categories,
|
||
// tags and ratings, four images each, run_count 4 and last_used_at now). Heading, toolbar, caption, grid and the six
|
||
// cards are compared at 1600x1100 and 390x844 in both themes and motion settings, the grid and cards also at 1800x1100
|
||
// (3 columns), and the shelf's behaviors in both documents. The topbar and sidebar are the shell spec's.
|
||
// Paired captures are artifacts only; there are no PNG baselines.
|
||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||
|
||
const CARDS='.library-grid .prompt-card';
|
||
const CONCEPT_PROMPTS=6;
|
||
|
||
/** Recorded Library deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||
export function mapLibraryConcept(reference) {
|
||
const library=reference.library;
|
||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||
const toolbar={...library.toolbar};
|
||
toolbar.items=toolbar.items.map(item=>{
|
||
// D14: the Library appends its Oldest first after the concept's three sorts.
|
||
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
|
||
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
|
||
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
|
||
return item;
|
||
});
|
||
// A shorter toolbar moves what follows it up by as much.
|
||
const rise=library.toolbar.height-toolbar.height;
|
||
const cards=library.cards.map((card,index)=>({...card,
|
||
// D23: "− N negative variants" uses the singular for one; that row is the concept's own, measured so (`oracle`).
|
||
meta:card.meta.items[0]?.text===PLURAL_ONE?reference.oracle.meta[index]:card.meta,
|
||
// D23: a long title wraps inside the title row, so the title button may shrink below its text (`min-width: 0`).
|
||
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
|
||
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
||
tags:sortedTags(card.tags),
|
||
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
||
// run_count (4) and last_used_at (now) give the concept's own "4 runs" and "Used today". The two-line clamp is
|
||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
|
||
return {...reference,library:{...library,toolbar,
|
||
// D35: the caption uses the singular for one.
|
||
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
|
||
...(({grid,cards:clamped})=>({grid:{...grid,top:grid.top-rise},cards:clamped}))(clampedCards({grid:library.grid,cards})),
|
||
// D40: the production-only continuation follows the grid (here the end of the shelf); the concept has none.
|
||
trailing:[...library.trailing,{tag:'div',className:'library-continuation',text:'You’re all caught up.'}]}};
|
||
}
|
||
|
||
/** D23's clamp, as workspace.css states it: the concept's own cards with it applied at runtime are the clamp oracle. */
|
||
const CLAMP_CSS='.prompt-card .prompt-text { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden }';
|
||
const CLAMP_LINES=2;
|
||
/**
|
||
* D23: the positive prompt is clamped to two lines. Where a concept text wraps to more (its samples do at 390px), the
|
||
* text box loses the extra lines, and its section, content and card shrink by as much, as does every box below it; the
|
||
* grid's rows then close up by their tallest card's loss.
|
||
*/
|
||
export function clampedCards({grid,cards}) {
|
||
const shrunk=cards.map(card=>{
|
||
const text=card.section.text;
|
||
if(text.lines<=CLAMP_LINES)return {card,loss:0};
|
||
const loss=text.height-Math.round(CLAMP_LINES*parseFloat(text.lineHeight));
|
||
return {loss,card:{...card,height:card.height-loss,
|
||
content:{...card.content,height:card.content.height-loss},
|
||
section:{...card.section,height:card.section.height-loss,text:{...text,height:text.height-loss,lines:CLAMP_LINES}},
|
||
meta:{...card.meta,top:card.meta.top-loss},tags:{...card.tags,top:card.tags.top-loss},
|
||
footer:{...card.footer,top:card.footer.top-loss}}};
|
||
});
|
||
// Rows are the cards sharing a top; a row is as tall as its tallest card, and each row moves up by the rows above.
|
||
const tops=[...new Set(cards.map(card=>card.top))].sort((a,b)=>a-b);
|
||
const rowLoss=top=>{
|
||
const row=cards.flatMap((card,index)=>card.top===top?[index]:[]);
|
||
return Math.max(...row.map(index=>cards[index].height))-Math.max(...row.map(index=>shrunk[index].card.height));
|
||
};
|
||
const above=top=>tops.filter(other=>other<top).reduce((sum,other)=>sum+rowLoss(other),0);
|
||
return {grid:{...grid,height:grid.height-tops.reduce((sum,top)=>sum+rowLoss(top),0)},
|
||
cards:shrunk.map(({card},index)=>({...card,top:card.top-above(cards[index].top)}))};
|
||
}
|
||
|
||
/** D43: a `.tags` row with its chips in alphabetical order, laid out again from the concept's chip boxes. */
|
||
export function sortedTags(tags) {
|
||
return reflow(tags,[...tags.items].sort((a,b)=>nocase(a.text,b.text)));
|
||
}
|
||
|
||
const SECTIONS=['heading','library','noPageScroll'];
|
||
function compare(reference,actual,{sections=SECTIONS,library=null}={}) {
|
||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,key==='library'&&library
|
||
?Object.fromEntries(library.map(part=>[part,inventory.library[part]])):inventory[key]]));
|
||
return diffInventory(stripExact(only(mapLibraryConcept(reference))),stripExact(only(actual)));
|
||
}
|
||
|
||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||
function assertLibraryMatch(reference,actual) {
|
||
const diff=compare(reference,actual);
|
||
if(diff.length)throw new Error(`the Prompt library differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||
}
|
||
|
||
// Two frames, then every running transition finished.
|
||
const settle=page=>page.evaluate(async()=>{
|
||
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
|
||
await Promise.race([Promise.all(document.getAnimations().map(animation=>animation.finished.catch(()=>{}))),
|
||
new Promise(resolve=>setTimeout(resolve,2000))]);
|
||
});
|
||
// Every card image decoded, so nothing moves while it is measured.
|
||
const decoded=page=>page.evaluate(async()=>{
|
||
await document.fonts.ready;
|
||
await Promise.all([...document.querySelectorAll('.library-grid img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});}));
|
||
});
|
||
|
||
const PLURAL_ONE='− 1 negative variants',SINGULAR_ONE='− 1 negative variant';
|
||
/**
|
||
* The concept inventory, with `oracle.meta`: every card's `.card-meta` measured once the concept's hard-coded plural
|
||
* reads "− 1 negative variant" (D23 "singular for one"), so the shorter chip and what follows it are the browser's own
|
||
* layout. The concept page is restored afterwards.
|
||
*/
|
||
async function conceptInventory(page) {
|
||
const reference=await inventoryOf(page,'#main');
|
||
await page.evaluate(({plural,singular})=>{
|
||
window.__conceptPlurals=[...document.querySelectorAll('.prompt-card .card-meta > .chip.red')].filter(chip=>chip.textContent===plural);
|
||
for(const chip of window.__conceptPlurals)chip.textContent=singular;
|
||
},{plural:PLURAL_ONE,singular:SINGULAR_ONE});
|
||
const meta=(await inventoryOf(page,'#main')).library.cards.map(card=>card.meta);
|
||
await page.evaluate(plural=>{for(const chip of window.__conceptPlurals)chip.textContent=plural;delete window.__conceptPlurals;},PLURAL_ONE);
|
||
return {...reference,oracle:{meta}};
|
||
}
|
||
|
||
/** The concept page with each card's tag chips in alphabetical order (the D43 oracle); returns the undo. */
|
||
async function sortConceptTags(page) {
|
||
await page.evaluate(()=>{
|
||
const nocase=(a,b)=>{const x=a.textContent.toLowerCase(),y=b.textContent.toLowerCase();return x<y?-1:x>y?1:0;};
|
||
window.__conceptTagOrder=[...document.querySelectorAll('.prompt-card .tags')].map(row=>{
|
||
const chips=[...row.children];row.append(...[...chips].sort(nocase));return [row,chips];});
|
||
});
|
||
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
|
||
}
|
||
|
||
async function openConceptLibrary(page,theme,motion) {
|
||
await openConcept(page,'library',theme,motion);
|
||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||
}
|
||
async function openProductionLibrary(page,theme,motion) {
|
||
await openProduction(page,'library',theme,motion);
|
||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||
await expect(page.locator('#workspace-main .section-caption')).toBeVisible();
|
||
// The category list is read once per visit; it is part of the toolbar inventory.
|
||
await expect(page.locator('#category-filter option')).toHaveCount(5);
|
||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||
}
|
||
|
||
const titlesOf=page=>page.locator(`${CARDS} .card-title > button`).allTextContents();
|
||
const captionOf=page=>page.locator('.section-caption > span').first().textContent();
|
||
|
||
/** A shelf filter: the card titles and the caption it leaves, then the filter undone. */
|
||
async function filteredOf(page,apply,undo) {
|
||
const before=await captionOf(page);
|
||
await apply();
|
||
await expect.poll(()=>captionOf(page)).not.toBe(before);
|
||
await expect.poll(()=>page.locator(CARDS).count()).toBeLessThan(CONCEPT_PROMPTS);
|
||
const result={caption:await captionOf(page),titles:await titlesOf(page)};
|
||
await undo();
|
||
await expect.poll(()=>captionOf(page)).toBe(before);
|
||
return result;
|
||
}
|
||
|
||
/** Click `control` on the first card, record the history page's title, and come back to a settled shelf. */
|
||
async function historyFrom(page,control,back) {
|
||
const title=(await page.locator(CARDS).first().locator('.card-title > button').textContent()).trim();
|
||
await page.locator(CARDS).first().locator(control).click();
|
||
const h1=page.locator('h1#page-title');
|
||
await expect(h1).toHaveText(title);
|
||
const result={title,h1:(await h1.textContent()).trim()};
|
||
await back();
|
||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||
return result;
|
||
}
|
||
|
||
/** The behaviors §7 and Task E3 list, with identical selectors in both documents. `sent` reads what a send carried. */
|
||
async function behaviorsOf(page,{back,sent}) {
|
||
const first=page.locator(CARDS).first();
|
||
const title=await historyFrom(page,'.card-title > button',back);
|
||
const explore=await historyFrom(page,'.card-footer .row-actions > button:not(.send)',back);
|
||
// The footer send opens the sender with the positive prompt only.
|
||
const positive=(await first.locator('.prompt-section.positive .prompt-text').textContent()).trim();
|
||
await page.locator(CARDS).first().locator('.card-footer .row-actions > button.send').click();
|
||
const send={positive,roles:await sent(page)};
|
||
// Copy writes the full positive and shows the toast.
|
||
await page.locator(CARDS).first().locator('.prompt-section.positive .prompt-actions > button').first().click();
|
||
await expect(page.locator('.toast')).not.toHaveText('');
|
||
const copy={toast:(await page.locator('.toast').textContent()).trim(),copied:await page.evaluate(()=>window.copied.at(-1))};
|
||
const search=await filteredOf(page,()=>page.locator('#search').fill('neon'),()=>page.locator('#search').fill(''));
|
||
const category=await filteredOf(page,()=>page.locator('#category-filter').selectOption('Landscapes'),()=>page.locator('#category-filter').selectOption(''));
|
||
return {title,explore,send,copy,search,category};
|
||
}
|
||
|
||
/** The concept's demo sender: the roles its preview lists, with their text. */
|
||
async function conceptSent(page) {
|
||
const modal=page.locator('dialog#modal[open]');
|
||
await expect(modal.locator('#modal-title')).toHaveText('Send to ComfyUI');
|
||
const roles=await modal.locator('.send-preview').evaluateAll(nodes=>Object.fromEntries(nodes.map(node=>
|
||
[node.querySelector('h3').textContent.startsWith('+')?'positive':'negative',node.querySelector('p').textContent])));
|
||
await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);
|
||
return roles;
|
||
}
|
||
|
||
/** Production's acknowledged sender against a bridge host page: the roles its preview asked the host to change. */
|
||
function productionSent(host) {
|
||
return async page=>{
|
||
const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});
|
||
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Concept ComfyUI'});
|
||
await sender.getByRole('combobox',{name:'Destination node',exact:true}).selectOption({label:'Concept destination'});
|
||
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
|
||
await expect.poll(()=>host.evaluate(()=>window.previewed.length)).toBe(1);
|
||
const roles=await host.evaluate(()=>window.previewed.pop());
|
||
await page.keyboard.press('Escape');await expect(sender).toHaveCount(0);
|
||
return roles;
|
||
};
|
||
}
|
||
async function openHost(context) {
|
||
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
|
||
await host.evaluate(async()=>{
|
||
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
|
||
const target={id:'pair-1',label:'Concept destination',type:'PromptManagerPair',roles:['positive','negative']};
|
||
window.previewed=[];
|
||
window.conceptHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Concept ComfyUI',adapter:{
|
||
listTargets:()=>[target],
|
||
preview:(_id,roles)=>{window.previewed.push(roles);return {preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:`Previous ${role}`,after})),
|
||
composition:{prepend:'',append:'',prependConnected:false,appendConnected:false,before:'Previous positive',after:roles.positive??'Previous positive'}},snapshot:roles};},
|
||
apply:roles=>({targetId:target.id,values:roles}),
|
||
}});
|
||
});
|
||
return host;
|
||
}
|
||
|
||
test.beforeEach(async({page,request},testInfo)=>{
|
||
testInfo.manifest=await seedConcept(request);
|
||
// Both documents copy through the clipboard; the stub records what they wrote.
|
||
await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.copied.push(text);}}});});
|
||
});
|
||
|
||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geometry, tokens, controls and behaviors match the concept ${width}x${height} ${theme}`,async({page,context},testInfo)=>{
|
||
test.setTimeout(240_000);
|
||
await page.setViewportSize({width,height});
|
||
const narrow=width<=700;
|
||
const host=await openHost(context);
|
||
try {
|
||
const result={};
|
||
for(const motion of MOTIONS) {
|
||
const capture=motion==='no-preference';
|
||
await openConceptLibrary(page,theme,motion);
|
||
const reference=await conceptInventory(page);
|
||
if(capture)await captureCanonicalState(page,testInfo,`concept-library-${width}-${theme}`);
|
||
// The browser as the re-layout's oracle: the concept page itself with the D23 clamp applied to its cards.
|
||
const restoreClamp=await mutate(page,{css:CLAMP_CSS});
|
||
const clamped=(await inventoryOf(page,'#main')).library;
|
||
await restoreClamp();
|
||
// ...and with every card's tag chips put in alphabetical order (D43).
|
||
const restoreTags=await sortConceptTags(page);
|
||
const sorted=(await inventoryOf(page,'#main')).library.cards.map(card=>card.tags);
|
||
await restoreTags();
|
||
const referenceBehaviors=capture?await behaviorsOf(page,{back:()=>page.goBack(),sent:conceptSent}):null;
|
||
|
||
await openProductionLibrary(page,theme,motion);
|
||
const actual=await inventoryOf(page,'#workspace-main');
|
||
if(capture)await captureCanonicalState(page,testInfo,`production-library-${width}-${theme}`);
|
||
const actualBehaviors=capture?await behaviorsOf(page,{back:()=>page.getByRole('button',{name:'← Prompt library',exact:true}).click(),sent:productionSent(host)}):null;
|
||
result[motion]={reference,sorted,clamped,actual,diff:compare(reference,actual),referenceBehaviors,actualBehaviors};
|
||
}
|
||
const path=testInfo.outputPath('library-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||
await testInfo.attach('library-geometry',{path,contentType:'application/json'});
|
||
|
||
for(const [motion,{reference,sorted,clamped,actual,diff,referenceBehaviors:concept,actualBehaviors:production}] of Object.entries(result)) {
|
||
const {grid,cards}=reference.library,card=cards[0];
|
||
// The concept itself is what this comparison assumes (style.css s648–708, 1424–1428, 1475–1483).
|
||
expect.soft(grid,`${motion} concept grid`).toMatchObject({columns:narrow?1:2,columnGap:'18px',rowGap:'18px',cardCount:CONCEPT_PROMPTS});
|
||
expect.soft(card,`${motion} concept card`).toMatchObject({borderTopLeftRadius:'8px',borderTopWidth:'1px',overflow:'hidden'});
|
||
const art=narrow?150:170;
|
||
expect.soft(card.art,`${motion} concept art`).toMatchObject({height:art,display:'grid',columnGap:'3px'});
|
||
const [major,minor]=card.art.gridTemplateColumns.split(' ').map(parseFloat);
|
||
expect.soft(Math.abs(major-2*minor),`${motion} concept art 2fr/1fr`).toBeLessThan(1);
|
||
expect.soft(card.art.items.map(item=>item.height),`${motion} concept art images`).toEqual([art,art]);
|
||
const pad=narrow?'15px':'18px';
|
||
expect.soft(card.content,`${motion} concept content padding`).toMatchObject({paddingTop:pad,paddingLeft:pad,paddingRight:pad,paddingBottom:pad});
|
||
expect.soft(card.title.items[0],`${motion} concept title`).toMatchObject({tag:'button',fontSize:'16px',fontWeight:'600'});
|
||
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
|
||
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
|
||
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
|
||
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
|
||
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
|
||
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
|
||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||
expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true);
|
||
if(!concept)continue;
|
||
// Title and Explore history open the prompt's history; the footer send carries the positive only; copy writes
|
||
// the full positive with the concept's toast; search and category narrow the shelf alike.
|
||
expect.soft(concept.title,`${motion} concept title opens history`).toEqual({title:card.title.items[0].text,h1:card.title.items[0].text});
|
||
expect.soft(concept.send.roles,`${motion} concept send`).toEqual({positive:concept.send.positive});
|
||
expect.soft(concept.copy,`${motion} concept copy`).toEqual({toast:'Prompt copied.',copied:concept.send.positive});
|
||
expect.soft(concept.search.titles,`${motion} concept search`).toEqual(['After the rain']);
|
||
// D35: the shelf caption uses the singular for one.
|
||
const counted=filter=>({...filter,caption:countCopy(filter.caption)});
|
||
expect.soft(production,`${motion} behaviors`).toEqual({...concept,search:counted(concept.search),category:counted(concept.category)});
|
||
}
|
||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||
});
|
||
|
||
// The grid and cards at the concept's three-column breakpoint (style.css 1274–1276).
|
||
for(const theme of THEMES)test(`library grid and cards match the concept 1800x1100 ${theme}`,async({page},testInfo)=>{
|
||
await page.setViewportSize({width:1800,height:1100});
|
||
await openConceptLibrary(page,theme,'no-preference');
|
||
const reference=await conceptInventory(page);
|
||
await captureCanonicalState(page,testInfo,`concept-library-1800-${theme}`);
|
||
await openProductionLibrary(page,theme,'no-preference');
|
||
const actual=await inventoryOf(page,'#workspace-main');
|
||
await captureCanonicalState(page,testInfo,`production-library-1800-${theme}`);
|
||
expect(reference.library.grid.columns,'concept columns').toBe(3);
|
||
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||
});
|
||
|
||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the footer
|
||
// padding) and one DOM mutation (the negative-variants chip removed), each applied after an exact match.
|
||
test('library comparison detects mutations',async({page})=>{
|
||
await page.setViewportSize({width:1600,height:1100});
|
||
await openConceptLibrary(page,'dark','no-preference');
|
||
const reference=await conceptInventory(page);
|
||
await openProductionLibrary(page,'dark','no-preference');
|
||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||
assertLibraryMatch(reference,await measure());
|
||
|
||
const restoreCss=await mutate(page,{css:'.card-footer { padding: 2px }'});
|
||
const cssMutated=await measure();
|
||
expect(()=>assertLibraryMatch(reference,cssMutated)).toThrow(/library\.cards\.0\.footer\.paddingTop/);
|
||
await restoreCss();await settle(page);
|
||
assertLibraryMatch(reference,await measure());
|
||
|
||
await mutate(page,{remove:['.card-meta .chip.red']});
|
||
const domMutated=await measure();
|
||
expect(()=>assertLibraryMatch(reference,domMutated)).toThrow(/library\.cards\.0\.meta\.items\.length/);
|
||
});
|