From ea36849e23bdff3f6c0a71e8946fb7a7011fa52a Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 21 Oct 2026 03:25:24 -0700 Subject: [PATCH] test(history): concept comparison for prompt history history-concept.spec.mjs compares the Prompt history with the concept's #prompt/alpine on the concept fixture at 1600x1100 and 390x844, both themes and motion settings, on Chromium and touch, with its behaviors in both documents and a mutation check. inventoryOf gains a history section; mapHistoryConcept applies D24, D35, D45, D46, D47 and the approved Show archived button, laying the concept out at runtime. Corrections it demonstrated: each compared negative mounts in a display: contents child of the concept's .compare-item (D25), and the concept fixture now links images oldest first and counts one pair use per image, so newest-first reads and lane run counts match the concept. history-canonical-comparison.mjs uses the concept's selectors for production and drops its closed polish entries. --- TESTING.md | 30 ++ tests/browser/concept-inventory.mjs | 84 ++++- .../browser/history-canonical-comparison.mjs | 47 ++- tests/browser/history-concept.spec.mjs | 292 ++++++++++++++++++ tests/browser/workspace_concept_fixture.py | 107 ++++--- web/js/workspace/library.mjs | 5 +- web/js/workspace/workspace.css | 3 + 7 files changed, 492 insertions(+), 76 deletions(-) create mode 100644 tests/browser/history-concept.spec.mjs diff --git a/TESTING.md b/TESTING.md index 543d494..aa432c7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -855,6 +855,36 @@ The run dot reads `GET /prompt_manager/library/{positive_id}/history/pairs/{pair (see "Chronological Library history"), and each lane's "N runs" is the pair's `run_count` (see "Workspace data fields"). +`tests/browser/history-concept.spec.mjs` (#249 PR F-b, F5) compares this page with the +immutable concept's `#prompt/alpine` on the concept fixture, at 1600×1100 and 390×844 in +both themes and motion settings, on Chromium and touch: the heading, the summary, the +variants panel, the comparison (Compare negatives pressed) and the shared gallery's +place and heading, all measured by `inventoryOf`'s `history` section with the concept's +selectors in both documents, with "Clean architecture" selected (the middle lane in +either order). The fixture links and records its images oldest first, so ids grow with +time as real runs do, and counts one pair use per image, which gives the concept's +"Quality baseline · 2 runs" and its run dot. `mapHistoryConcept` applies D24 (the four +recorded runs give the concept's own "4 tracked runs", asserted on both), D45 (the +visually hidden "3 of 3 saved negative variants" status, and its wrap rules applied to +the concept at runtime), D35 and D46 (the concept's lanes reordered newest first with +"1 run", laid out by the browser), D46's Baseline/Selected action names, #235's Show +archived button (added to the concept at runtime) and D47 (the gallery's body is the +Images collection, so its height is not compared). D25: each compared block mounts in a +`display: contents` child of the concept's `.compare-item`, so the item carries no +production class or margin. Behaviors are compared in both documents: a red dot changes +the negative block and the gallery, Compare toggles `aria-pressed`, All images opens a +dialog with the positive's four images, the run dot opens a dialog (D26: Recorded run +where the concept shows Repeat run), the heading send previews the selected pair, and +← Prompt library navigates. `history comparison detects mutations` injects +`.ca-tools .variant-dot { width: 20px }` and removes Compare negatives. The older +`history-canonical-comparison.mjs` stays for `workspace-visual.spec.mjs`'s recorded +executions (#209) checks; its production selectors are now the concept's. + +```sh +npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \ + workspace-history.spec.mjs --project chromium --project touch --project firefox --project webkit +``` + ## Workspace data fields for the concept pages (#249) These fields are additive. The legacy `/prompt_manager/images/output` listing (no diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 35efb58..1ff22b8 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -152,7 +152,8 @@ export async function mutate(page,{css=null,remove=[]}={}) { * history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) * measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while * anything is checked, its `.bulkbar` row. `library` (#249 E3) holds the Prompt library's toolbar, - * caption, grid, first six cards and whatever follows the grid; history stays null until PR F. + * caption, grid, first six cards and whatever follows the grid. `history` (#249 F5) holds the Prompt history's summary, + * negative variants panel, comparison (while Compare negatives is pressed) and the shared gallery's heading. */ export function inventoryOf(page,scope='main') { return page.evaluate(measureInventory,scope); @@ -441,6 +442,85 @@ export function measureInventory(scope) { grid:{columns:style.gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,main),cardCount:cards.length}, cards:cards.slice(0,6).map(card=>libraryCardOf(card,grid)),trailing}; } + // ---- Prompt history (#249 F5): renderHistory a587–622, style.css 1059–1179 and 1546–1567. The concept's main holds + // the summary, the variants panel, the comparison and the gallery; production's sit in `section.library-history`, so + // each is found by descendant and placed relative to `main` (vertical rhythm) or to its own section. + const spoken=node=>node.classList.contains('ca-visually-hidden'); + const outline=node=>css(node,'outlineStyle','outlineWidth','outlineColor','outlineOffset'); + const pseudo=(node,which)=>{const style=getComputedStyle(node,which);return Object.fromEntries(['content','position','top','left','width','height', + 'borderLeftWidth','borderBottomWidth','borderLeftColor','borderBottomColor','borderBottomLeftRadius','backgroundColor','color'].map(key=>[key,style[key]]));}; + // What a section shows, in order: each shown child as `tag.classes`, visually hidden statuses apart (`spoken`). + const structureOf=node=>({children:boxedChildren(node).filter(child=>shown(child)&&!spoken(child)).map(child=>`${child.tagName.toLowerCase()}.${classes(child)}`), + spoken:[...node.querySelectorAll('.ca-visually-hidden')].filter(shown).map(child=>({role:child.getAttribute('role'),text:child.textContent}))}); + function promptSectionOf(section,origin) { + const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'); + const actions=heading?.querySelector('.prompt-actions'),text=section.querySelector(':scope > .prompt-text'); + return {className:classes(section),...relative(section,origin),...edges(section), + label:when(label,()=>({text:label.textContent,...font(label)})), + actions:when(actions,()=>({...layout(actions),...relative(actions,section),buttons:[...actions.children].map(node=>controlOf(node,actions))})), + text:when(text,()=>({text:text.textContent,...font(text),...edges(text),...relative(text,section), + lines:Math.round(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))}; + } + function panelHeadingOf(heading,origin) { + const h2=heading.querySelector('h2'),note=heading.querySelector('.small'),actions=heading.querySelector(':scope > .row-actions'); + return {...layout(heading),...edges(heading),...relative(heading,origin), + h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2),...relative(h2,heading)})), + note:when(note,()=>({tag:note.tagName.toLowerCase(),text:note.textContent,...font(note),...edges(note),...relative(note,heading)})), + actions:when(actions,()=>({...layout(actions),...relative(actions,heading),buttons:[...actions.children].filter(shown).map(node=>controlOf(node,actions))}))}; + } + function laneOf(lane,timeline) { + const dot=lane.querySelector(':scope > .variant-dot'),label=lane.querySelector(':scope > span'),run=lane.querySelector(':scope > .run-dot'); + return {className:classes(lane),offset:lane.style.getPropertyValue('--lane-offset'),...relative(lane,timeline),...edges(lane),...css(lane,'minWidth','position'), + before:pseudo(lane,'::before'), + dot:when(dot,()=>({...controlOf(dot,lane),...outline(dot),...css(dot,'position','top','left','width','height','color')})), + label:when(label,()=>({text:label.textContent,...font(label),...relative(label,lane),...css(label,'position','top','left')})), + run:shown(run)?{...controlOf(run,lane),...css(run,'position','top','left','width','height')}:null}; + } + function timelineOf(timeline,origin) { + const positive=timeline.querySelector(':scope > .positive-lane'),span=positive?.querySelector('span'); + return {...edges(timeline),...css(timeline,'overflowX','position'),...relative(timeline,origin), + positive:when(positive,()=>({...edges(positive),...css(positive,'height','minWidth'),...relative(positive,timeline),before:pseudo(positive,'::before'), + span:when(span,()=>({text:span.textContent,...font(span),...relative(span,positive)}))})), + lanes:[...timeline.querySelectorAll(':scope > .variant-lane')].map(lane=>laneOf(lane,timeline)), + structure:structureOf(timeline)}; + } + function variantsOf(panel) { + const heading=panel.querySelector(':scope > .panel-heading'),timeline=panel.querySelector(':scope > .timeline'),lower=panel.querySelector(':scope > .timeline-lower'); + return {className:classes(panel),...edges(panel),...relative(panel,main),structure:structureOf(panel), + heading:when(heading,()=>panelHeadingOf(heading,panel)),timeline:when(timeline,()=>timelineOf(timeline,panel)), + lower:when(lower,()=>({...layout(lower),...edges(lower),...relative(lower,panel), + items:[...lower.children].filter(shown).map(node=>({...controlOf(node,lower),...font(node)}))}))}; + } + function compareOf(section) { + const h2=section.querySelector(':scope > h2'),intro=section.querySelector(':scope > p:not(.small)'),grid=section.querySelector(':scope > .compare-grid'); + return {...edges(section),...relative(section,main),structure:structureOf(section), + h2:when(h2,()=>({text:h2.textContent,...font(h2),...edges(h2)})), + intro:when(intro,()=>({text:intro.textContent,...font(intro),...edges(intro),...relative(intro,section)})), + grid:when(grid,()=>({columns:getComputedStyle(grid).gridTemplateColumns.split(/\s+/).length,...css(grid,'columnGap','rowGap'),...relative(grid,section), + items:[...grid.children].map(item=>({className:classes(item),...edges(item),...relative(item,grid), + sections:[...item.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,item))}))})), + notes:[...section.querySelectorAll(':scope > p.small')].map(note=>({text:note.textContent,...font(note),...edges(note),...relative(note,section)})), + added:[...section.querySelectorAll(':scope > .diff-added')].map(chip=>({text:chip.textContent,...font(chip),...edges(chip),...css(chip,'display'),...relative(chip,section)}))}; + } + function historyOf() { + const panel=main?.querySelector('section.timeline-panel'); + if(!panel)return null; + const summary=main.querySelector('article.surface'),gallery=main.querySelector('section.history-gallery'); + const compare=[...main.querySelectorAll('section.surface:not(.timeline-panel)')].find(shown); + const chip=summary?.querySelector(':scope > .chip'),meta=summary?.querySelector(':scope > .card-meta'); + const galleryHeading=gallery?.querySelector(':scope > .panel-heading'); + return { + summary:when(summary,()=>({...edges(summary),...relative(summary,main),structure:structureOf(summary), + chip:when(chip,()=>({text:chip.textContent,...font(chip),...edges(chip),...relative(chip,summary)})), + sections:[...summary.querySelectorAll('.prompt-section')].map(node=>promptSectionOf(node,summary)), + meta:when(meta,()=>({...layout(meta),...edges(meta),...font(meta),...relative(meta,summary), + items:[...meta.children].filter(shown).map(node=>({text:node.textContent,...font(node),...relative(node,meta)}))}))})), + variants:variantsOf(panel), + compare:when(compare,compareOf), + gallery:when(gallery,()=>({...edges(gallery),...relative(gallery,main), + heading:when(galleryHeading,()=>panelHeadingOf(galleryHeading,gallery))})), + }; + } return {chrome:chromeOf(),heading:headingOf(), toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf), filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf), @@ -450,7 +530,7 @@ export function measureInventory(scope) { cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null, continuation:when(continuation,continuationOf), panel:when(gallery,panelOf), - library:libraryOf(),history:null,noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; + library:libraryOf(),history:historyOf(),noPageScroll:document.documentElement.scrollWidth<=innerWidth+1}; } /** diff --git a/tests/browser/history-canonical-comparison.mjs b/tests/browser/history-canonical-comparison.mjs index 6f201f4..0433d91 100644 --- a/tests/browser/history-canonical-comparison.mjs +++ b/tests/browser/history-canonical-comparison.mjs @@ -14,9 +14,10 @@ export const canonicalDifferences = { 'Recorded run exposes exact frozen evidence; Repeat run is illustrative', 'Current-file candidates disclose unknown identity and context membership', ], + // Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the + // exact history comparison is tests/browser/history-concept.spec.mjs. remainingApplicationPolish: [ - 'Gallery card select checkbox (#249 D8 follow-up) and tag button (#249 D9, image-annotations slice) remain outside #209', - 'Shared role-block spacing, padding and glyph styling remain outside #209; exact role order/actions and evidence disclosure are covered', + 'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209', 'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced', ], }; @@ -33,29 +34,27 @@ export async function captureCanonicalState(page, testInfo, name, selector) { await testInfo.attach(name, { path, contentType: 'image/png' }); } +const CONCEPT_HISTORY_SELECTORS = Object.freeze({ + surface: 'article.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane', + label: '.variant-lane > span', dot: '.variant-dot[aria-pressed="true"]', role: 'article.surface .prompt-section', + positiveLabel: '.positive-lane span', + prompt: 'article.surface .prompt-text', gallery: '.history-gallery', + heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small', + footer: '.timeline-lower', all: '.timeline-lower button', + repeat: '.timeline .run-dot:not([hidden])', +}); +const RECORDED_HISTORY_SELECTORS = Object.freeze({ + recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch', + recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas', + recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label', + recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)', + recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column', +}); + export async function historyGeometry(page, production) { - const selectors = production ? { - surface: '.library-history article.surface', timeline: '.library-history .timeline', positive: '.library-history .positive-lane', - branch: '.library-history .variant-lane', label: '.library-history .variant-lane > span', dot: '.library-history .variant-dot[aria-pressed="true"]', - positiveLabel: '.library-history .positive-lane span', - role: '[aria-label="Saved prompt texts"] .prompt-section', - prompt: '[aria-label="Saved prompt texts"] .prompt-text', gallery: '.library-history .history-gallery', - heading: '.library-history .timeline-panel h2', intro: '.library-history .timeline-panel .panel-heading .small', - footer: '.library-history .timeline-lower', all: '.library-history .timeline-lower button', - recordedPositiveLabel: '.history-positive-lane span', recordedBranch: '.history-context-branch', - recordedHeading: '.recorded-history h2', recordedTimeline: '.history-timeline', recordedCanvas: '.history-canvas', - recordedPositive: '.history-positive-lane', recordedLabel: '.history-context-label', - recordedAnchor: '.history-context-anchor', recordedRepeat: '.history-run-marker:not(.history-context-anchor)', - recordedMarker: '.history-run-marker', recordedColumn: '.history-run-column', - } : { - surface: '.surface', timeline: '.timeline', positive: '.positive-lane', branch: '.variant-lane', - label: '.variant-lane span', dot: '.variant-dot[aria-pressed="true"]', role: '.surface .prompt-section', - positiveLabel: '.positive-lane span', - prompt: '.surface .prompt-text', gallery: '.history-gallery', - heading: '.timeline-panel h2', intro: '.timeline-panel .panel-heading .small', - footer: '.timeline-lower', all: '.timeline-lower button', - repeat: '.timeline .run-dot', - }; + // Production renders the concept's history classes (#249 PR F), so both documents use the concept's selectors; + // production adds the recorded executions (#209), which the concept does not have. + const selectors = production ? { ...CONCEPT_HISTORY_SELECTORS, ...RECORDED_HISTORY_SELECTORS } : CONCEPT_HISTORY_SELECTORS; return page.evaluate(selectors => ({ pointerCoarse: matchMedia('(pointer: coarse)').matches, ...Object.fromEntries(Object.entries(selectors).map(([key, selector]) => { const nodes = [...document.querySelectorAll(selector)]; diff --git a/tests/browser/history-concept.spec.mjs b/tests/browser/history-concept.spec.mjs new file mode 100644 index 0000000..604da17 --- /dev/null +++ b/tests/browser/history-concept.spec.mjs @@ -0,0 +1,292 @@ +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} from './concept-inventory.mjs'; + +// Task F5 of #249: the production Prompt history (#/library/) against the immutable concept's (#prompt/alpine, +// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real +// fixture: Alpine observatory with its three saved negatives, four images linked to their pairs, four recorded runs and +// real pair run counts (2, 1, 1). Heading, summary, the negative variants panel, the comparison and the shared gallery's +// heading are compared at 1600x1100 and 390x844 in both themes and motion settings, with the page's behaviors in both +// documents. Both documents are measured with "Clean architecture" selected: it is the middle lane in either order +// (D46). The topbar and sidebar are the shell spec's, the gallery's cards the Images spec's (D47). Paired captures are +// artifacts only; there are no PNG baselines. +test.use({locale:'en-US',timezoneId:'UTC'}); + +const TITLE='Alpine observatory'; +const SELECTED='Clean architecture'; +const LANES=3; +const SHOW_ARCHIVED='Show archived (0)'; +const SECTIONS=['heading','history','noPageScroll']; + +/** + * Recorded history deviations only (spec §8, TESTING.md D24–D26, D35, D45–D47); everything else must equal production. + * `reference.oracle` is the concept page itself, measured once its lanes list newest first with the singular for one + * (D46, D35) and once the approved Show archived button (#235) follows + Add negative: the browser lays out what moves. + */ +export function mapHistoryConcept(reference) { + const {history}=reference.oracle; + return {...reference,history:{...history, + // D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so + // the concept's "4 tracked runs" holds by construction (asserted on both documents below). + summary:history.summary, + variants:{...history.variants, + // D45: the bounded "N of M saved negative variants" count stays a visually hidden status. + structure:{...history.variants.structure,spoken:[{role:'status',text:`${LANES} of ${LANES} saved negative variants`}]}}, + compare:history.compare&&{...history.compare,grid:{...history.compare.grid,items:history.compare.grid.items.map((item,index)=>({...item, + // D46: the two compared blocks are named Baseline and Selected, so no action name repeats on the page. + sections:item.sections.map(section=>({...section,actions:{...section.actions,buttons:section.actions.buttons.map(button=>({...button, + label:button.label.replace(/^(Copy|Send) negative/,`$1 ${COMPARED[index]} negative`)}))}}))}))}}, + // D47: the gallery's body is the Images collection (layout row, cards, side panel), so only its place, frame and + // heading are compared here; the cards are the Images comparison's. + gallery:history.gallery&&withoutHeight(history.gallery), + }}; +} +const COMPARED=['baseline','selected']; +const withoutHeight=({height,...gallery})=>gallery; + +function compare(reference,actual) { + const only=inventory=>Object.fromEntries(SECTIONS.map(key=>[key,key==='history'&&inventory.history?.gallery + ?{...inventory.history,gallery:withoutHeight(inventory.history.gallery)}:inventory[key]])); + return diffInventory(stripExact(only(mapHistoryConcept(reference))),stripExact(only(actual))); +} + +/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */ +function assertHistoryMatch(reference,actual) { + const diff=compare(reference,actual); + if(diff.length)throw new Error(`the Prompt history 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))]); +}); +const decoded=page=>page.evaluate(async()=>{ + await document.fonts.ready; + await Promise.all([...document.querySelectorAll('.history-gallery img')].map(img=>{img.loading='eager';return img.decode().catch(()=>{});})); +}); + +const dot=(page,name)=>page.getByRole('button',{name:`Select negative ${name}`,exact:true}); +const compareButton=page=>page.getByRole('button',{name:'Compare negatives',exact:true}); +const galleryCards=page=>page.locator('.history-gallery .media-card'); + +/** Press a variant dot and wait until the summary and the gallery follow it. */ +async function selectVariant(page,name) { + await dot(page,name).click(); + await expect(dot(page,name)).toHaveAttribute('aria-pressed','true'); + await expect(page.locator('.history-gallery .panel-heading .small')).toHaveText(`${name} · same positive prompt`); + await expect(galleryCards(page).first()).toBeVisible(); + await page.mouse.move(0,0);await decoded(page);await settle(page); +} +async function setCompare(page,pressed) { + const button=compareButton(page); + if(await button.getAttribute('aria-pressed')!==String(pressed))await button.click(); + await expect(compareButton(page)).toHaveAttribute('aria-pressed',String(pressed)); + await page.mouse.move(0,0);await settle(page); +} + +async function openConceptHistory(page,theme,motion) { + await openConcept(page,'prompt/alpine',theme,motion); + await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES); + await selectVariant(page,SELECTED); +} +async function openProductionHistory(page,theme,motion,positiveId) { + await openProduction(page,`library/${positiveId}`,theme,motion); + await expect(page.locator('.timeline .variant-lane')).toHaveCount(LANES); + await expect(page.locator('article.surface .card-meta > span').first()).toBeVisible(); + await selectVariant(page,SELECTED); +} + +// D45: the recorded production rules that lay the panel out (library.css): the panel heading and the legend row wrap, +// All images wraps its label, and a lane's text wraps before a shown repeat-run dot. Applied to the concept so the browser +// lays out what follows; at 1600px nothing wraps. +const D45_LAYOUT=`.timeline-panel > .panel-heading, .timeline-lower { flex-wrap: wrap; } +.variant-lane:has(> .run-dot:not([hidden])) > span { max-inline-size: 139px; overflow-wrap: anywhere; } +.ca-tools .timeline-lower > button { max-inline-size: 100%; white-space: normal; }`; + +/** + * The concept laid out as production lists it: lanes newest first with their offsets (D46), "1 run" in the singular + * (D35), the approved Show archived button after + Add negative (#235) and D45's layout rules. Returns the undo. + */ +async function conceptAsRecorded(page) { + const restoreLayout=await mutate(page,{css:D45_LAYOUT}); + await page.evaluate(label=>{ + const timeline=document.querySelector('.timeline'),lanes=[...timeline.querySelectorAll(':scope > .variant-lane')]; + const texts=lanes.map(lane=>[lane.querySelector(':scope > span'),lane.querySelector(':scope > span').textContent]); + lanes.reverse().forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);}); + for(const [span,text] of texts)span.textContent=text.replace(/(^|\D)1 runs$/,'$11 run'); + const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative'); + const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt'); + archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived); + window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived}; + },SHOW_ARCHIVED); + await settle(page); + return ()=>page.evaluate(()=>{ + const {timeline,order,texts,archived}=window.__historyOracle; + order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);}); + for(const [span,text] of texts)span.textContent=text; + archived.remove();delete window.__historyOracle; + }).then(restoreLayout); +} + +/** The concept inventory, with `oracle`: the same page laid out as recorded (`conceptAsRecorded`). */ +async function conceptInventory(page) { + const reference=await inventoryOf(page,'#main'); + const restore=await conceptAsRecorded(page); + const oracle=await inventoryOf(page,'#main'); + await restore();await settle(page); + return {...reference,oracle}; +} + +const negativeText=page=>page.locator('article.surface .prompt-section.negative .prompt-text').textContent(); +const galleryOf=async page=>({context:await page.locator('.history-gallery .panel-heading .small').textContent(), + cards:await galleryCards(page).locator('.image-open').evaluateAll(nodes=>nodes.map(node=>node.getAttribute('aria-label')))}); + +/** A dialog the page opens: its accessible name and how many media cards it shows once loaded; then closed. */ +async function dialogOf(page,trigger,{name,cards}) { + await trigger.click(); + const dialog=page.getByRole('dialog',{name,exact:true}); + await expect(dialog).toBeVisible(); + if(cards)await expect(dialog.locator('.media-card')).toHaveCount(cards); + const result=cards?{name,cards:await dialog.locator('.media-card').count()}:{name}; + await page.keyboard.press('Escape');await expect(dialog).toHaveCount(0); + return result; +} + +/** The behaviors Task F5 lists, with identical selectors in both documents. */ +async function behaviorsOf(page,{sent,runDialog}) { + await selectVariant(page,'Quality baseline'); + const before={negative:await negativeText(page),gallery:await galleryOf(page)}; + await selectVariant(page,SELECTED); + const after={negative:await negativeText(page),gallery:await galleryOf(page)}; + const pressed=[await compareButton(page).getAttribute('aria-pressed')]; + await setCompare(page,true);pressed.push(await compareButton(page).getAttribute('aria-pressed')); + const compared=await page.locator('section.surface:not(.timeline-panel) > h2').textContent(); + await setCompare(page,false);pressed.push(await compareButton(page).getAttribute('aria-pressed')); + const all=await dialogOf(page,page.getByRole('button',{name:'All images for this positive',exact:true}),{name:'All images for this positive',cards:4}); + const run=await dialogOf(page,page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}),{name:runDialog}); + await page.locator('.heading-actions').getByRole('button',{name:`Send prompt pair to ComfyUI: ${TITLE}`,exact:true}).click(); + const send=await sent(page); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); + await expect(page.locator('h1#page-title')).toHaveText('Prompt library'); + return {variant:{before,after},pressed,compared,all,run,send,back:await page.locator('h1#page-title').textContent()}; +} + +/** 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); + 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(`history 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 positiveId=testInfo.manifest.prompts[0].positive_id; + const host=await openHost(context); + try { + const result={}; + for(const motion of MOTIONS) { + const capture=motion==='no-preference'; + await openConceptHistory(page,theme,motion); + const reference=await conceptInventory(page); + await setCompare(page,true); + const referenceCompared=await conceptInventory(page); + if(capture)await captureCanonicalState(page,testInfo,`concept-history-${width}-${theme}`); + const referenceBehaviors=capture?await behaviorsOf(page,{sent:conceptSent,runDialog:'Repeat run'}):null; + + await openProductionHistory(page,theme,motion,positiveId); + const actual=await inventoryOf(page,'#workspace-main'); + await setCompare(page,true); + const actualCompared=await inventoryOf(page,'#workspace-main'); + if(capture)await captureCanonicalState(page,testInfo,`production-history-${width}-${theme}`); + const actualBehaviors=capture?await behaviorsOf(page,{sent:productionSent(host),runDialog:'Recorded run'}):null; + result[motion]={reference,actual,diff:compare(reference,actual),referenceCompared,actualCompared, + compareDiff:compare(referenceCompared,actualCompared),referenceBehaviors,actualBehaviors}; + } + const path=testInfo.outputPath('history-geometry.json');await writeFile(path,JSON.stringify(result,null,2)); + await testInfo.attach('history-geometry',{path,contentType:'application/json'}); + for(const [motion,{diff,compareDiff,actual,referenceCompared}] of Object.entries(result)) { + // The concept itself is what this comparison assumes (style.css 709–722, 1059–1179, 1426, 1546). + const {summary,variants,compare:compared,gallery}=referenceCompared.history; + expect.soft(summary,`${motion} concept surface`).toMatchObject({paddingTop:'22px',paddingLeft:'22px',borderTopLeftRadius:'7px',borderTopWidth:'1px'}); + // D24: the fixture's four recorded runs give the concept's own "4 tracked runs"; production reads them. + expect.soft(summary.meta.items.map(item=>item.text),`${motion} concept counts`).toEqual(['4 tracked runs','4 images','3 negative variants']); + expect.soft(actual.history.summary.meta.items.map(item=>item.text),`${motion} tracked runs`).toEqual(['4 tracked runs','4 images','3 negative variants']); + expect.soft(variants.timeline,`${motion} concept timeline`).toMatchObject({paddingTop:'18px',paddingLeft:'20px',borderTopLeftRadius:'6px'}); + variants.timeline.lanes.forEach((lane,index)=>{ + expect.soft(lane,`${motion} concept lane ${index}`).toMatchObject({offset:`${38+index*47}px`,marginLeft:`${38+index*47}px`,height:57}); + expect.soft(lane.dot,`${motion} concept dot ${index}`).toMatchObject({width:'29px',height:'29px',text:'−'}); + }); + expect.soft(variants.timeline.lanes.map(lane=>lane.run&&{width:lane.run.width,left:lane.run.left}),`${motion} concept run dots`).toEqual([{width:'12px',left:'190px'},null,null]); + expect.soft(compared.grid,`${motion} concept compare grid`).toMatchObject({columns:width>700?2:1,columnGap:'15px'}); + expect.soft(compared.added.map(chip=>chip.text),`${motion} concept added terms`).toEqual(['+ distorted architecture','+ watermark','+ text']); + expect.soft(compared.added[0],`${motion} concept diff chip`).toMatchObject({fontSize:'11px',paddingTop:'3px',paddingLeft:'7px',borderTopLeftRadius:'4px',display:'inline-block'}); + expect.soft(gallery,`${motion} concept gallery`).toMatchObject({marginTop:'24px',heading:{h2:{text:'Shared gallery',fontSize:'14px'},note:{text:`${SELECTED} · same positive prompt`}}}); + expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]); + expect.soft(compareDiff,`${motion} differences with Compare negatives pressed`).toEqual([]); + expect.soft(actual.noPageScroll,`${motion} no page-level horizontal scroll`).toBe(true); + } + const {referenceBehaviors:concept,actualBehaviors:production}=result['no-preference']; + expect.soft(production,'behaviors').toEqual({...concept,run:{...concept.run,name:'Recorded run'}}); + } finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();} +}); + +// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant +// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match. +test('history comparison detects mutations',async({page},testInfo)=>{ + await page.setViewportSize({width:1600,height:1100}); + await openConceptHistory(page,'dark','no-preference'); + const reference=await conceptInventory(page); + await openProductionHistory(page,'dark','no-preference',testInfo.manifest.prompts[0].positive_id); + const measure=()=>inventoryOf(page,'#workspace-main'); + assertHistoryMatch(reference,await measure()); + + const restoreCss=await mutate(page,{css:'.ca-tools .variant-dot { width: 20px }'}); + const cssMutated=await measure(); + expect(()=>assertHistoryMatch(reference,cssMutated)).toThrow(/history\.variants\.timeline\.lanes\.0\.dot\.width/); + await restoreCss();await settle(page); + assertHistoryMatch(reference,await measure()); + + await mutate(page,{remove:['.timeline-panel .row-actions > button[aria-pressed]:first-child']}); + const domMutated=await measure(); + expect(()=>assertHistoryMatch(reference,domMutated)).toThrow(/history\.variants\.heading\.actions\.buttons\.0\.text/); +}); diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index ef9d4a5..3818747 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -396,56 +396,67 @@ def seed_workspace_concept( rounds = math.ceil(total / len(CONCEPT_PROMPTS)) now = time.time() images, runs = [], [] - for round_index in range(rounds): - for prompt_index, prompt in enumerate(CONCEPT_PROMPTS): - index = round_index * len(CONCEPT_PROMPTS) + prompt_index - if index >= total: - break - name = concept_filename(prompt, round_index) - path = folder / name - facts = write_concept_png(path, prompt_index, round_index) - variant, negative, seed = facts["variant"], facts["negative"], facts["seed"] - # The concept lists images newest first in this order. - stamp = now - 60 - index * 60 - os.utime(path, (stamp, stamp)) - owner = prompts[prompt_index] - image_id = db.link_image_to_prompt(owner["positive_id"], str(path)) - pair_id = None if index < positive_links else owner["pair_ids"][variant] - if pair_id is not None: - db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"}) - if concept_starred(prompt_index, round_index): - db.set_image_favorite( - favorite_root, _favorite_relative_key(f"concepts/{name}") - ) - context = RecipeContext( - "3", _snapshot(prompt.positive), _snapshot(negative), "inferred" + slots = [ + (round_index, prompt_index, prompt) + for round_index in range(rounds) + for prompt_index, prompt in enumerate(CONCEPT_PROMPTS) + if round_index * len(CONCEPT_PROMPTS) + prompt_index < total + ] + # Linked and recorded oldest first, as real runs arrive, so link and run ids + # grow with time and every newest-first read (by id or by mtime) lists the + # concept's order (app.js:180-239). The manifest keeps the concept's order. + for round_index, prompt_index, prompt in reversed(slots): + index = round_index * len(CONCEPT_PROMPTS) + prompt_index + name = concept_filename(prompt, round_index) + path = folder / name + facts = write_concept_png(path, prompt_index, round_index) + variant, negative, seed = facts["variant"], facts["negative"], facts["seed"] + # The concept lists images newest first in this order. + stamp = now - 60 - index * 60 + os.utime(path, (stamp, stamp)) + owner = prompts[prompt_index] + image_id = db.link_image_to_prompt(owner["positive_id"], str(path)) + pair_id = None if index < positive_links else owner["pair_ids"][variant] + if pair_id is not None: + db.link_image_to_pair(pair_id, image_id, {"source": "concept fixture"}) + # Real pair counting: each image is one run of its pair, as the + # concept's " · N runs" counts its images (app.js a598, D26). + db.record_prompt_pair_use(pair_id) + if concept_starred(prompt_index, round_index): + db.set_image_favorite( + favorite_root, _favorite_relative_key(f"concepts/{name}") ) - reference = OutputReference( - "4", 0, "output", "concepts", name, "posix", roots.output_key - ) - runs.append( - _record_run( - api, - epoch=epoch, - item=index, - context=context, - output=reference, - observed_at=int(stamp * 1000), - ) - ) - images.append( - { - "filename": name, - "image_id": image_id, - "pair_id": pair_id, - "prompt": prompt.id, - "variant": variant, - "seed": seed, - "path": f"concepts/{name}", - "width": facts["width"], - "height": facts["height"], - } + context = RecipeContext( + "3", _snapshot(prompt.positive), _snapshot(negative), "inferred" + ) + reference = OutputReference( + "4", 0, "output", "concepts", name, "posix", roots.output_key + ) + runs.append( + _record_run( + api, + epoch=epoch, + item=index, + context=context, + output=reference, + observed_at=int(stamp * 1000), ) + ) + images.append( + { + "filename": name, + "image_id": image_id, + "pair_id": pair_id, + "prompt": prompt.id, + "variant": variant, + "seed": seed, + "path": f"concepts/{name}", + "width": facts["width"], + "height": facts["height"], + } + ) + images.reverse() + runs.reverse() seedless = [] for number in range(seedless_images): name = f"seedless_{number + 1:05}.png" diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 7fa3291..af17ffb 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -486,9 +486,10 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const disposeCompareBlocks=()=>{for(const item of compareBlocks)item.dispose();compareBlocks=[];}; content.cleanup(disposeCompareBlocks); function compareBlock(text,label) { - const item=el('div',undefined,'compare-item'); + // The concept's `.compare-item` holds the block itself; the blocks mount in a transparent child (D25). + const item=el('div',undefined,'compare-item'),mount=el('div');item.append(mount); // D46: the two blocks are named Baseline and Selected, so no region or action name repeats on the page. - compareBlocks.push(mountPromptBlocks(item,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)})); + compareBlocks.push(mountPromptBlocks(mount,{prompts:{negative:{status:'resolved',text,source:'library'}},roles:['negative'],sender,parentScope:content,includePairAction:false,contextLabel:label,title:displayTitle(detail.positive)})); compareBlocks.at(-1).setState({prompts:{negative:{status:'resolved',text,source:'library'}},actionsEnabled:savedReadState==='current'}); return item; } diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index d51b036..d9df2b9 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -219,6 +219,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; white-space /* #249 D24: the history summary's prompt blocks sit in `article.surface` as the concept's do (app.js a598); the mount point keeps its "Saved prompt texts" region name. */ .library-history article.surface > .prompt-inspector { display: contents; /* D24 */ } +/* #249 D25: each compared negative's block sits in the concept's `.compare-item` (app.js a600), so its mount point is + transparent and adds no margin. */ +.compare-item > .prompt-inspector { display: contents; /* D25 */ } /* #249 D23: a real title can be long or one unbroken word; it wraps inside the title row (the toolkit's buttons never wrap), while the category chip keeps whole words beside it and wraps only a name wider than its share of the row. */ .card-title button { min-width: 0; white-space: normal; overflow-wrap: anywhere; /* D23 */ }