diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index d04d2fd3..3a3ed3ee 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -187,6 +187,33 @@ export async function mutate(page,{css=null,remove=[],clone=[]}={}) { },token); } +/** + * #332 D146: the category chip names its origin after the category, in a muted `span.chip-source` that never splits + * (workspace.css `.chip .chip-source`). The concept's chip shows the bare name (app.js a402), so the recorded rule and + * the span are added to the concept page at runtime and the browser lays the chip out: the first `.tags` row's + * non-badge chip under `root`. `category` also replaces the chip's text (an own category the concept never shows). + * Returns the undo. + */ +export const D146_CHIP_SOURCE='.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }'; +export async function withChipSource(page,root,origin,category=null) { + const restoreRule=await mutate(page,{css:D146_CHIP_SOURCE}); + const token=await page.evaluate(({root,origin,category})=>{ + const chip=document.querySelector(root)?.querySelector('.tags')?.querySelector(':scope > .chip:not(.blue)'); + if(!chip)throw new Error(`withChipSource: no category chip under ${root}`); + const text=chip.textContent,source=document.createElement('span'); + source.className='chip-source';source.textContent=` · ${origin}`; + if(category!==null)chip.textContent=category; + chip.append(source); + const token=`chip-source-${Math.random().toString(36).slice(2)}`; + (window.__chipSources??=new Map()).set(token,()=>{chip.textContent=text;}); + return token; + },{root,origin,category}); + return async()=>{ + await page.evaluate(token=>{window.__chipSources.get(token)();window.__chipSources.delete(token);},token); + await restoreRule(); + }; +} + /** * Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library, * history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) @@ -615,9 +642,12 @@ export function measureInspector(selector) { primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node), minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)}; } + // #332 D146: a category chip's visible origin (`span.chip-source`), null on every other chip. function chipOf(node) { + const source=node.querySelector(':scope > .chip-source'); return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node), - minHeight:getComputedStyle(node).minHeight}; + minHeight:getComputedStyle(node).minHeight, + source:when(source,()=>({text:source.textContent,...font(source),...css(source,'whiteSpace'),...box(source)}))}; } function rowOf(row) { return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)}; diff --git a/tests/browser/inspector-concept.spec.mjs b/tests/browser/inspector-concept.spec.mjs index 0a43d7f1..4244166d 100644 --- a/tests/browser/inspector-concept.spec.mjs +++ b/tests/browser/inspector-concept.spec.mjs @@ -1,7 +1,7 @@ import {writeFile} from 'node:fs/promises'; import {test,expect} from './fixtures.mjs'; import {captureCanonicalState} from './history-canonical-comparison.mjs'; -import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs'; +import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs'; // Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i, // true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes. @@ -13,6 +13,8 @@ test.use({locale:'en-US',timezoneId:'UTC'}); const CARDS='.gallery-body > .image-grid > .media-card'; const TITLE='Alpine observatory'; +// D146 (#332): alpine_00001.png's recipe has no category, so its chip shows the positive's, " · prompt" (D39). +const ORIGIN='prompt'; const FIXTURE_IMAGES=24; // The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel). const PANEL_INSET=40; @@ -38,14 +40,18 @@ function withFocusablePrompts(inventory) { return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))}; } -/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */ +/** + * D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). Its + * visible origin (D146, #332: " · prompt") is the concept page's own chip with `span.chip-source` added at runtime + * (`withChipSource`), so `reference.panel` and `viewerPlain` already carry it. + */ function withCategoryScope(inventory) { const [badge,category,...rest]=inventory.badges.chips; return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}}; } /** - * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must + * Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must * equal production. `reference.viewerPlain` is the concept's own full details * with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at * runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. @@ -112,6 +118,7 @@ async function openProductionPanel(page,theme) { */ async function plainViewerChips(page) { const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS}); + const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN); await page.evaluate(()=>{ window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{ const span=document.createElement('span');span.className='chip';span.textContent=button.textContent; @@ -126,7 +133,7 @@ async function plainViewerChips(page) { return async()=>{ await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips; window.__summaryButton.remove();delete window.__summaryButton;}); - await restoreTargets(); + await restoreSource();await restoreTargets(); }; } @@ -162,7 +169,11 @@ async function viewerOf(page,concept,mutation=null) { return {viewerDetails,viewerPlain,fileControls}; } async function inventory(page,concept=false) { + // D146: the concept panel's category chip with its visible origin, laid out by the concept page itself. + const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null; + if(concept)await settle(page); const {panel}=await inspectorOf(page); + await restoreSource?.(); const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept); return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls}; } @@ -287,7 +298,8 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color); expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'}); expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'}); - expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']); + // The concept's bare "Landscapes" with the D146 origin added at runtime (`withChipSource`). + expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']); expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']); expect.soft(diff,'differences after the recorded deviations').toEqual([]); @@ -321,8 +333,10 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry'); const rows=panelOf(page).locator('.detail-inner > .tags'); await expect(rows).toHaveCount(2); - await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']); + await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']); await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: '); + // D146 (#332): the visible origin names where the value came from. + await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt'); await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']); await expect(rows.nth(1).locator('span.chip')).toHaveCount(0); await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']); @@ -380,6 +394,13 @@ test('inspector comparison detects mutations',async({page})=>{ expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/); assertInspectorMatch(reference,await viewerMutated(),viewport); + // D146: the visible origin is part of the mapped concept, so losing it is a difference. + const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']}); + const sourceMutated=await measure(); + expect(()=>assertInspectorMatch(reference,sourceMutated,viewport)).toThrow(/panel\.badges\.chips\.1\.source/); + await restoreSource();await settle(page); + assertInspectorMatch(reference,await measure(),viewport); + await mutate(page,{remove:['#image-inspector > .detail-footer']}); const domMutated=await measure(); expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);