* feat(annotations): image tagging controller with staged suggestions Add the UI-agnostic Tag image dialog controller (#249 C1): observe the file into a capture, prefill the saved tags and the server's tagging defaults, stage the top suggestions as removable chips, and save manual tags plus the remaining chips with case-insensitive dedupe (manual spelling first). Every read is serial-guarded; saves are sent once and a lost answer is resent identically only by Retry. Conflict, storage, file and capture outcomes keep the draft and gate Save on a reopen. ApiError now carries a validated copy of a tag-conflict body's current tag set so the dialog can show it. * feat(annotations): Tag image dialog beside Info on every image card Every Images and Library-embedded gallery card shows the concept's Tag after Info. Tag opens the concept's Tag image dialog (image-tagging.mjs over the C1 controller) in the shared modal shell with the sticky tagging-modal footer: capture preview, manual tags, the four legacy engines with real defaults, explicit Generate, removable suggestion chips (D83), legacy model labels (D84), a Suggestions count (D91) and Retry where the controller allows it. The capture is released on close, route change and pagehide. The Tags page gains the concept's Auto-tag images for the image selected in Images (aria-disabled without one, D115) and real Images counts that follow every save. Retires D9, #245 D2 and #245 D3. The toast now uses the mock's copy verbatim ("tags" even for one), C1's D60/D61/D68 markers are renumbered to D83/D84/D91, and at extreme zoom a card's info row stacks (and wraps Info and Tag) when its row is narrower than the narrowest bounded card, list rows included (D31). * feat(annotations): show image tags in the inspector and viewer The inspector parses the B3 envelope's image_tags facet (strictly: exact keys, current/none/unavailable, at most 100 vocabulary names) and draws one chip row after the prompt blocks: the file's current image tags first, then D21's linked tags not already shown (case-insensitive). In the Images panel image tags are Tags filter buttons and linked tags keep D21's rule; in the viewer and wherever no Tags filter exists every chip is plain. none and unavailable add no chips (D88, plan D65; D21 amended). The tagging fixture reset now drops leftover captures, so a long run of tagging tests in one worker stays below the capture store's limit. * fix(annotations): re-read a tagged file in place after a save After a tag save the selected file's inspection is re-read quietly: the panel and the viewer keep the current details until the new answer lands, with no "Loading file details…" in between. While a Tags filter or a search is active, Images re-reads its loaded range in place (same query, from the first page up to the loaded count, published once), keeping the cards, the scroll position and the selection while the selected file still matches; when it no longer matches, the panel follows the collection. * fix(annotations): give suggestion chips the concept chip corner The D83 chips took the later `.tag-suggestion` row rule's 5px corner; the concept `.chip` has 4px. The concept comparison of the Tag dialog found it. * test(annotations): compare the Tag dialog with the approved concept The Tag image dialog's inventory (order, text, field attributes, tokens, preview box, chips, status, sticky actions) must equal the concept's at 1600x1100 and 390x844, dark and light, opened, generated with WD14 and with a language model, for an empty result and in the review scope, and the card's Info and Tag group must equal the concept's. Production runs on the concept fixture with the concept image's tags saved and the fake model answering the concept's own sample; only D29, D83, D84 and D91 are mapped, and a mutation check shows the comparison notices a change. The D3 footer check again asserts a strictly shorter footer, with short seedless subtitles that stay on one line beside Info and Tag. * fix(annotations): final review round for the Tag image dialog - Option fields show the server defaults that Generate sends: the controller bumps an options revision on open and on a model change, and the panel re-renders only then (I1). - A tag conflict shows the current set at once ("Saved now: ...") and puts it ahead of the draft in the manual field, so the next Save sends what is shown (I2). - The opening sentence is the concept's on-screen copy; "Remove anything you do not want to add." leads the chips only while chips exist; the comparison's opened mapping is gone (I3). - ApiClient gains an additive keepalive option; the capture release and the Save PUT use it, and pagehide closes the dialog (I4). - Closing during a save keeps the capture until the PUT settles, then releases it and still reports the outcome (toast, page refresh) (I5). - A refresh asked during a page load is pending and runs once it settles; re-reads use pages up to 200 files; a failed re-read toasts; Load more waits for a refresh (I6, M2, M3). - The selection bar drops checked files a refreshed result no longer lists (I7). - D116 records Retry; after a failed Generate it takes Generate's own path, and after a changed storage, file or expired capture it observes again keeping the draft (I9, M4, M5). - A save made from Tags marks a filtered Images visit stale (M6). - images.mjs drops under 800 lines: the tagging wiring moves to images-tagging.mjs and the favorites star to images-favorites.mjs (unchanged), and reset() reuses dropSelection() (M1). - Restore the activity-scan.mjs coverage floors; drop the duplicate paired captures test (I8, M9). * fix(annotations): coverage floors for the Tag dialog round Floors for the new images-favorites.mjs and images-tagging.mjs, and the measured raises of image-tagging-state.mjs and image-tagging.mjs. activity-scan.mjs keeps main's floors.
304 lines
19 KiB
JavaScript
304 lines
19 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 as openConceptPage,openProduction,inventoryOf,diffInventory,focusRingOf,hoverStateOf,mutate} from './concept-inventory.mjs';
|
||
|
||
// Task A6 of #249: the production shell (sidebar, topbar, heading, footer) against the immutable concept, with the
|
||
// concept-shaped real fixture so the sidebar counts are the concept's own (24 images, 6 prompts, 15 tags). Both themes,
|
||
// 1600x1100 and 390x844, normal and reduced motion. Paired captures are artifacts only; there are no PNG baselines.
|
||
test.use({locale:'en-US',timezoneId:'UTC'});
|
||
|
||
// Routes whose shell is compared. The concept and production share route names for these three pages.
|
||
const ROUTES=Object.freeze([['images','Images'],['library','Prompt library'],['tags','Tags']]);
|
||
// The heading's content (description copy, actions) belongs to each page's own PR (C1, E1, F1) and is compared there;
|
||
// Tags & categories is already rebuilt (#245), so its heading is compared in full.
|
||
const FULL_HEADING=new Set(['tags']);
|
||
|
||
const rowNamed=(nav,text)=>nav.rows.find(row=>row.text===text);
|
||
|
||
const removedItem=item=>String(item.className).includes('concept-badge');
|
||
|
||
/**
|
||
* The concept with its Activity count hidden (D52): production counts running jobs only and hides the count at 0, as
|
||
* the comparison fixture has none. The concept's own CSS then lays out its button without the count.
|
||
*/
|
||
async function openConcept(page,route,theme,motion) {
|
||
await openConceptPage(page,route,theme,motion);
|
||
await mutate(page,{css:'#activity-count { display: none }'});
|
||
}
|
||
|
||
/** Left offsets of flex items packed along a row of `width` by its `justify-content` and `gap`. */
|
||
function packedLefts(items,width,justify,gap) {
|
||
const total=items.reduce((sum,item)=>sum+item.width,0)+gap*(items.length-1);
|
||
let start=0,spacing=gap;
|
||
if(justify==='flex-end'||justify==='end')start=width-total;
|
||
else if(justify==='center')start=(width-total)/2;
|
||
else if(justify==='space-between'&&items.length>1)spacing=gap+(width-total)/(items.length-1);
|
||
let x=start;
|
||
return items.map(item=>{const left=Math.round(x);x+=item.width+spacing;return left;});
|
||
}
|
||
|
||
/** The top-actions row once removed items are gone: a centered flex row, so its size and item offsets follow. */
|
||
function remainingTopActions(actions) {
|
||
const kept=actions.items.filter(item=>!removedItem(item));
|
||
const shown=kept.filter(item=>item.shown);
|
||
const height=Math.max(...shown.map(item=>item.height));
|
||
const gap=parseFloat(actions.columnGap);
|
||
// A full-width row (<=700px) keeps its width; otherwise it shrinks to its remaining items.
|
||
const width=actions.fullWidth?actions.width:shown.reduce((sum,item)=>sum+item.width,0)+gap*(shown.length-1);
|
||
// At <=700px the concept spreads its shown items with `space-between`; the hidden pill (D53) takes no place.
|
||
// Offsets come from the concept's own sizes.
|
||
const lefts=new Map(shown.map((item,index)=>[item,packedLefts(shown,width,actions.justifyContent,gap)[index]]));
|
||
return {...actions,height,width,
|
||
// D52: the full-width row (<=700px) wraps its items when a zoomed phone is narrower than they are.
|
||
flexWrap:actions.fullWidth?'wrap':actions.flexWrap,
|
||
items:kept.map(item=>item.shown?{...item,top:Math.round((height-item.height)/2),left:lefts.get(item)}:item)};
|
||
}
|
||
|
||
/** The footer once its button is gone: paddings plus the remaining spans along the footer's flex direction. */
|
||
function remainingFooter(footer,spans) {
|
||
const content=footer.flexDirection==='column'
|
||
?spans.reduce((sum,span)=>sum+span.height,0)+parseFloat(footer.rowGap)*(spans.length-1)
|
||
:Math.max(...spans.map(span=>span.height));
|
||
return `${parseFloat(footer.paddingTop)+parseFloat(footer.paddingBottom)+content}px`;
|
||
}
|
||
|
||
// D28 ruling (final review A, Minor 1): Overview gets a distinct outline home glyph. The concept has no home icon, and
|
||
// its `activity` pulse belongs to the Activity row, so the two entries never share one glyph.
|
||
const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6';
|
||
|
||
/**
|
||
* The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the
|
||
* saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21).
|
||
*/
|
||
function savedNav(nav) {
|
||
const rows=nav.rows.filter(row=>row.text!=='Needs review');
|
||
return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)};
|
||
}
|
||
|
||
/** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */
|
||
function mapShellConcept(reference) {
|
||
const {chrome}=reference;
|
||
const navs=chrome.navs
|
||
// D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered.
|
||
.filter(nav=>nav.id!=='collections-nav')
|
||
.map(nav=>{
|
||
if(nav.id==='saved-nav')return savedNav(nav);
|
||
if(nav.id!=='system-nav')return nav;
|
||
const system=rowNamed(nav,'System');
|
||
return {...nav,rows:[
|
||
// D52: the Activity row counts running jobs only, so with none it has no count.
|
||
{...rowNamed(nav,'Activity'),count:null},
|
||
// D28: no Design review (prototype page).
|
||
system,
|
||
// D28: the production Overview hub follows System, as a concept nav row without a count. Its icon is
|
||
// the ruling's own outline home glyph (OVERVIEW_GLYPH), never the concept's `activity` pulse.
|
||
{...system,text:'Overview',current:null,gapAfter:null,icon:{...system.icon,d:OVERVIEW_GLYPH}},
|
||
]};
|
||
});
|
||
return {...reference,chrome:{...chrome,
|
||
// D29: no prototype suffix in the document title.
|
||
title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'),
|
||
// D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge.
|
||
labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED')
|
||
.map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label),
|
||
navs,
|
||
connection:{...chrome.connection,
|
||
// D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0".
|
||
small:'Your library, on your server',
|
||
// D28: no fake connection state, so the dot is the neutral muted token (the concept's label color).
|
||
dot:{...chrome.connection.dot,backgroundColor:chrome.labels[0].color}},
|
||
// D29: no "Interactive concept" badge. D53: the progress pill before the Activity button, hidden with no running
|
||
// job. D52: the button's count is hidden at 0. The row's size and the remaining items' centered offsets follow
|
||
// from the concept's own item sizes.
|
||
topActions:remainingTopActions({...chrome.topActions,items:chrome.topActions.items.flatMap(item=>
|
||
item.tag==='button'&&item.text==='Activity'?[{tag:'a',className:'activity-pill',shown:false},{...item,count:'0'}]:[item])}),
|
||
footer:(()=>{
|
||
// D29: production footer copy (one line, as the concept's own copy is), and no "What changes & why"
|
||
// (a prototype review page); the footer then holds only its span.
|
||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||
})(),
|
||
}};
|
||
}
|
||
|
||
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
|
||
function headingFrame(heading) {
|
||
if(!heading)return heading;
|
||
const {height,description,actions,...frame}=heading;
|
||
const {text:copy,...descriptionTokens}=description??{};
|
||
const {buttons,stacked,...actionTokens}=actions??{};
|
||
return {...frame,description:description?descriptionTokens:null,actions:actions?actionTokens:null};
|
||
}
|
||
|
||
function compare(route,reference,actual) {
|
||
const expected=mapShellConcept(reference);
|
||
const heading=FULL_HEADING.has(route);
|
||
return [
|
||
...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}),
|
||
...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading))
|
||
.map(item=>({...item,path:`heading.${item.path}`})),
|
||
];
|
||
}
|
||
|
||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||
function assertShellMatches(route,reference,actual) {
|
||
const diff=compare(route,reference,actual);
|
||
if(diff.length)throw new Error(`the shell differs from the concept at ${diff.map(item=>item.path).join(', ')}`);
|
||
}
|
||
|
||
/** Keyboard focus rings on the first WORKSPACE nav link and the first heading button. */
|
||
async function ringsOf(page,mainSelector) {
|
||
// Any key press makes the next programmatic focus keyboard-modal (`:focus-visible`); WebKit does not Tab to links.
|
||
await page.locator('#page-title').focus();await page.keyboard.press('Tab');
|
||
const button=page.locator(`${mainSelector} .heading-actions button`).first();
|
||
await expect(button).toBeFocused();
|
||
const headingButton=await focusRingOf(button);
|
||
const link=page.locator('#primary-nav a').first();await link.focus();
|
||
const navLink=await focusRingOf(link);
|
||
await link.blur();
|
||
return {navLink,headingButton};
|
||
}
|
||
|
||
/** Hover colors of the first non-current WORKSPACE row, the Theme select and the ComfyUI link. */
|
||
async function hoversOf(page) {
|
||
return {
|
||
navRow:await hoverStateOf(page,page.locator('#primary-nav a:not([aria-current])').first()),
|
||
theme:await hoverStateOf(page,page.getByRole('combobox',{name:'Theme',exact:true})),
|
||
canvasLink:await hoverStateOf(page,page.locator('.topbar .canvas-link')),
|
||
};
|
||
}
|
||
|
||
/** A viewport capture with the footer (and, at 1600, the sidebar's bottom group) in view. */
|
||
async function captureFooter(page,testInfo,name) {
|
||
await page.locator('.app-footer').scrollIntoViewIfNeeded();
|
||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||
await captureCanonicalState(page,testInfo,name);
|
||
}
|
||
|
||
test.beforeEach(async({request})=>{await seedConcept(request);});
|
||
|
||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`shell geometry, tokens and controls match the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
|
||
await page.setViewportSize({width,height});const result={};
|
||
for(const motion of MOTIONS)for(const [route,breadcrumb] of ROUTES) {
|
||
await openConcept(page,route,theme,motion);await page.mouse.move(0,0);
|
||
const reference=await inventoryOf(page,'#main');
|
||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`concept-shell-${route}-${width}-${theme}`);
|
||
const referenceRings=FULL_HEADING.has(route)?await ringsOf(page,'#main'):null;
|
||
const referenceHovers=await hoversOf(page);
|
||
if(motion==='no-preference')await captureFooter(page,testInfo,`concept-footer-${route}-${width}-${theme}`);
|
||
await openProduction(page,route,theme,motion);await page.mouse.move(0,0);
|
||
const actual=await inventoryOf(page,'#workspace-main');
|
||
if(motion==='no-preference')await captureCanonicalState(page,testInfo,`production-shell-${route}-${width}-${theme}`);
|
||
const actualRings=FULL_HEADING.has(route)?await ringsOf(page,'#workspace-main'):null;
|
||
const actualHovers=await hoversOf(page);
|
||
if(motion==='no-preference')await captureFooter(page,testInfo,`production-footer-${route}-${width}-${theme}`);
|
||
result[`${motion} ${route}`]={route,breadcrumb,reference,actual,diff:compare(route,reference,actual),referenceRings,actualRings,
|
||
referenceHovers,actualHovers};
|
||
}
|
||
const path=testInfo.outputPath('shell-geometry.json');await writeFile(path,JSON.stringify(result,null,2));
|
||
await testInfo.attach('shell-geometry',{path,contentType:'application/json'});
|
||
for(const [key,{route,breadcrumb,reference,actual,diff,referenceRings,actualRings,referenceHovers,actualHovers}] of Object.entries(result)) {
|
||
// The concept itself is what this comparison assumes: its sample counts and its shell controls.
|
||
expect.soft(reference.chrome.navs[0].rows.map(row=>[row.text,row.count?.text??null]),`${key} concept WORKSPACE rows`)
|
||
.toEqual([['Images','24'],['Prompt library','6'],['Tags & categories','15'],['Inspect a file',null]]);
|
||
expect.soft(reference.chrome.topActions.items.map(item=>item.className||item.tag),`${key} concept top actions`)
|
||
.toEqual(['concept-badge','button','theme-label','canvas-link']);
|
||
expect.soft(reference.chrome.breadcrumbs.parts.at(-1).text,`${key} concept breadcrumb`).toBe(breadcrumb);
|
||
expect.soft(reference.chrome.title,`${key} concept title`).toBe(`${breadcrumb} · Prompt Manager concept`);
|
||
expect.soft(actual.chrome.title,`${key} production title`).toBe(`${breadcrumb} · Prompt Manager`);
|
||
expect.soft(reference.heading.h1.text,`${key} concept page title`).toBe(route==='tags'?'Tags & categories':breadcrumb);
|
||
// The concept's own nav-row hover (style.css:183-186) is what production is held to.
|
||
expect.soft(referenceHovers.navRow,`${key} concept nav-row hover`).toMatchObject({hovered:true,color:reference.chrome.body.color});
|
||
expect.soft(actualHovers,`${key} hover states`).toEqual(referenceHovers);
|
||
expect.soft(diff,`${key} differences after the recorded deviations`).toEqual([]);
|
||
expect.soft(actual.noPageScroll,`${key} no page-level horizontal scroll`).toBe(true);
|
||
if(referenceRings) {
|
||
// The toolkit's button ring (2px offset) wins over the concept's 3px for buttons, in the concept too (#245).
|
||
expect.soft(referenceRings.headingButton,`${key} concept heading-button ring`).toMatchObject({focused:true,visible:true,outlineStyle:'solid',outlineWidth:'2px'});
|
||
expect.soft(referenceRings.navLink,`${key} concept nav-link ring`).toMatchObject({focused:true,outlineStyle:'solid',outlineWidth:'2px',outlineOffset:'3px'});
|
||
expect.soft(actualRings,`${key} focus rings`).toEqual(referenceRings);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Saved views (#249 D16): a view saved from the Images toolbar adds the concept's own saved row (search icon, name, no
|
||
// count) to SAVED VIEWS. The concept's demo action reads "Apply in prototype"; production's is "Save" (D29).
|
||
for(const [width,height] of SIZES)test(`a saved view row matches the concept's ${width}x${height}`,async({page})=>{
|
||
await page.setViewportSize({width,height});
|
||
const save=async(action,mainSelector)=>{
|
||
await page.locator(`${mainSelector} .toolbar`).getByRole('button',{name:'Save view',exact:true}).click();
|
||
const dialog=page.getByRole('dialog',{name:'Save this view',exact:true});
|
||
await expect(dialog.getByRole('textbox',{name:'View name',exact:true})).toHaveValue('My workspace view');
|
||
await dialog.getByRole('textbox',{name:'View name',exact:true}).fill('Golden hour');
|
||
await dialog.getByRole('button',{name:action,exact:true}).click();
|
||
await expect(page.locator('#saved-nav').getByRole('link',{name:'Golden hour',exact:true})).toHaveCount(1);
|
||
await page.mouse.move(0,0);
|
||
return inventoryOf(page,mainSelector);
|
||
};
|
||
await openConcept(page,'images','dark');
|
||
const reference=await save('Apply in prototype','#main');
|
||
expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']);
|
||
await openProduction(page,'images','dark');
|
||
const actual=await save('Save','#workspace-main');
|
||
expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]);
|
||
expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count]))
|
||
.toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]);
|
||
});
|
||
|
||
test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{
|
||
await page.setViewportSize({width:1600,height:1100});
|
||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||
const reference=mapShellConcept(await inventoryOf(page,'#main'));
|
||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||
expect(diffInventory(reference.chrome,(await inventoryOf(page,'#workspace-main')).chrome)).toEqual([]);
|
||
await page.locator('.breadcrumbs').evaluate(node=>node.remove());
|
||
await page.locator('.connection small').evaluate(node=>node.remove());
|
||
const actual=await inventoryOf(page,'#workspace-main');
|
||
expect(actual.chrome.breadcrumbs).toBeNull();
|
||
expect(actual.chrome.connection.smallFont).toBeNull();
|
||
const paths=diffInventory(reference.chrome,actual.chrome).map(item=>item.path);
|
||
expect(paths).toContain('breadcrumbs');
|
||
expect(paths).toContain('connection.smallFont');
|
||
expect(paths).toContain('connection.small');
|
||
});
|
||
|
||
test('the concept fixture renders the concept inspector facts',async({page})=>{
|
||
await page.setViewportSize({width:1600,height:1100});
|
||
await openProduction(page,'images','dark');
|
||
// The first image is the concept's first sample, selected into the panel on first load (D19).
|
||
const facts=page.locator('#workspace-main #image-inspector .meta-grid');
|
||
await expect(facts).toBeVisible();
|
||
const pairs=await facts.evaluate(list=>Object.fromEntries([...list.querySelectorAll('dt')].map(term=>[term.textContent.trim(),term.nextElementSibling.textContent.trim()])));
|
||
// app.js:404-408: the concept's facts for alpine_00001.png.
|
||
expect(pairs).toEqual({Seed:'42001',Steps:'28',CFG:'6.5',Sampler:'dpmpp_2m',Scheduler:'karras',Model:'illustrative-model.safetensors'});
|
||
// D21: execution evidence is the concept's "Execution recorded" badge.
|
||
await expect(page.locator('#image-inspector span.chip.blue')).toHaveText('Execution recorded');
|
||
});
|
||
|
||
// Task A7: the comparison is only worth something if it notices a change. One CSS mutation (a nav-row height the
|
||
// concept never uses) and one DOM mutation (the breadcrumbs removed) are applied to production after an exact match,
|
||
// and each must make the comparison fail with the path of what changed.
|
||
for(const [width,height] of SIZES)test(`shell comparison detects mutations ${width}x${height}`,async({page})=>{
|
||
await page.setViewportSize({width,height});
|
||
await openConcept(page,'tags','dark');await page.mouse.move(0,0);
|
||
const reference=await inventoryOf(page,'#main');
|
||
await openProduction(page,'tags','dark');await page.mouse.move(0,0);
|
||
const measure=()=>inventoryOf(page,'#workspace-main');
|
||
const clean=await measure();
|
||
assertShellMatches('tags',reference,clean);
|
||
|
||
const restoreCss=await mutate(page,{css:'.sidebar nav a { min-height: 30px }'});
|
||
const cssMutated=await measure();
|
||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.minHeight/);
|
||
expect(()=>assertShellMatches('tags',reference,cssMutated)).toThrow(/navs\.0\.rows\.0\.height/);
|
||
await restoreCss();
|
||
assertShellMatches('tags',reference,await measure());
|
||
|
||
await mutate(page,{remove:['.breadcrumbs']});
|
||
const domMutated=await measure();
|
||
expect(domMutated.chrome.breadcrumbs).toBeNull();
|
||
// The missing section is reported by its own path (A6: absent sections become null), not by a measuring error.
|
||
expect(()=>assertShellMatches('tags',reference,domMutated)).toThrow(/(at |, )chrome\.breadcrumbs(,|$)/);
|
||
});
|