diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ac39f..64ed09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm The report branch previously received only `report.md` and crop PNGs even when a byte-capped Markdown report directed reviewers to `report.json`. Publication now includes the generated JSON alongside the Markdown and crops. +- **Report-only path correspondence surfaces real before→after deltas across path + churn.** When a DOM-removed base path and a DOM-added head path share a unique + privacy-safe signature (tag + rect x/y/width + ownTextLength) under a meaningful + shared structural prefix, presentation rewrites the before path onto the head + path and re-runs the existing differ for the report only. Unchanged wrapper + moves collapse; true paired property deltas (e.g. font-size, height geometry) + show as restyles; ambiguous duplicate signatures stay unpaired. Raw findings, + `rawCounts` / `reviewableCounts`, exit codes, and approval gates stay on the + concrete-path certification differ. + - **Report taxonomy no longer bills added-node style inventories as restyles.** A brand-new element still emits its full resting/state inventory (raw findings and exit codes unchanged), but presentation counts and copy reserve diff --git a/README.md b/README.md index 91d36df..f7f5d58 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,14 @@ tables labelled _Style inventory (head-side — no baseline)_), but those rows a not billed as before→after restyles — so a wrapper insert or path churn reads as DOM structure, not a cascade of restyles. +When a removed base path and an added head path share a **unique** privacy-safe +signature (tag + box x/y/width + own-text length) under a shared structural +prefix, the report **corresponds** them for presentation only: pure path moves +with unchanged styles collapse, and real paired property deltas (font-size, +height geometry, …) render as ordinary before→after restyles on the head path. +Ambiguous duplicates stay unpaired — no invented provenance. Certification still +diffs concrete paths; raw counts, exit codes, and approval gates are unchanged. + Tiny changes also receive a magnified crop. Structural matching avoids painting an unchanged shifted subtree as changed, while ambiguous duplicate elements stay explicit rather than receiving invented provenance. diff --git a/docs/what-it-catches.md b/docs/what-it-catches.md index 09f28cb..ee6b7a9 100644 --- a/docs/what-it-catches.md +++ b/docs/what-it-catches.md @@ -18,6 +18,7 @@ On every **captured surface**, base vs head: | A `:hover` / `:focus` / `:active` variant dropped or changed | `state` finding | pr-surfacing ✓ | | A `::before` / `::after` style differs | `style` finding, pseudo tagged | pr-surfacing ✓ | | An element is added or removed | `dom` finding (added / removed); report shows head-side style **inventory** (value-only, no baseline) — not a restyle | pr-surfacing ✓ | +| Path churn with unique stable geometry (wrapper insert, …) | report-only correspondence: pure moves collapse; true property deltas pair as restyles — raw path diff/gates unchanged | unit + report tests | | An element is retagged (`button` → `a`) | removed + added at that position | pr-surfacing ✓ | | A nav item / route disappears | inventory guard, named, **gates** | pr-surfacing ✓ | | A surface exists on only one side | reported as a new / removed surface | pr-surfacing ✓ | diff --git a/src/path-correspondence.ts b/src/path-correspondence.ts new file mode 100644 index 0000000..b300c01 --- /dev/null +++ b/src/path-correspondence.ts @@ -0,0 +1,150 @@ +import type { ElementEntry, StyleMap } from './capture.js'; +import { diffStyleMaps, type Finding } from './diff.js'; + +/** + * Report-only structural correspondence between base and head captures. + * + * Certification still diffs by concrete structural path. Presentation may + * remap a one-sided before path onto a uniquely corresponding after path so + * path churn (wrapper insert, nth-child shift with stable geometry) collapses + * into real paired property deltas — or disappears when nothing changed — + * without touching raw findings, exit codes, or approval gates. + * + * Matching is intentionally conservative: + * - candidates are only DOM-removed (before) vs DOM-added (after) paths + * - signature is privacy-safe: tag + rect x/y/width + ownTextLength (height + * omitted so pure size changes still pair) + * - signature must be unique on both sides + * - paths must share a meaningful structural prefix + * - ambiguous or incomplete signatures stay unmatched + */ + +/** Privacy-safe correspondence key, or null when the entry cannot be paired. */ +export function correspondenceSignature(entry: ElementEntry | undefined): string | null { + if (!entry?.rect) return null; + const [x, y, width] = entry.rect; + // ownTextLength is always safe (length only). Legacy maps without it stay + // unpaired: geometry alone is not enough evidence to claim correspondence. + if (entry.ownTextLength === undefined) return null; + const textLen = entry.ownTextLength; + return JSON.stringify([entry.tag, x, y, width, textLen]); +} + +/** Longest exact leading path-segment prefix (`a > b` style). */ +export function sharedPathPrefix(beforePath: string, afterPath: string): string { + const beforeSegs = beforePath.split(' > '); + const afterSegs = afterPath.split(' > '); + const shared: string[] = []; + const limit = Math.min(beforeSegs.length, afterSegs.length); + for (let i = 0; i < limit && beforeSegs[i] === afterSegs[i]; i++) shared.push(beforeSegs[i]!); + return shared.join(' > '); +} + +/** + * Require a non-empty shared ancestor segment so unrelated subtrees that + * happen to share geometry cannot pair across the whole document. + */ +export function hasMeaningfulSharedPrefix(beforePath: string, afterPath: string): boolean { + return sharedPathPrefix(beforePath, afterPath).length > 0; +} + +function indexBySignature(map: StyleMap, paths: string[]): Map { + const bySig = new Map(); + for (const elementPath of paths) { + const sig = correspondenceSignature(map.elements[elementPath]); + if (sig === null) continue; + const list = bySig.get(sig); + if (list) list.push(elementPath); + else bySig.set(sig, [elementPath]); + } + return bySig; +} + +/** + * Conservative before→after path map among one-sided elements only. + * Values are after (head) paths; keys are before (base) paths. + */ +export function correspondElementPaths(before: StyleMap, after: StyleMap): Map { + const removed = Object.keys(before.elements).filter((p) => !(p in after.elements)); + const added = Object.keys(after.elements).filter((p) => !(p in before.elements)); + if (removed.length === 0 || added.length === 0) return new Map(); + + const removedBySig = indexBySignature(before, removed); + const addedBySig = indexBySignature(after, added); + const mapping = new Map(); + + for (const [sig, beforePaths] of removedBySig) { + if (beforePaths.length !== 1) continue; // ambiguous on before side + const afterPaths = addedBySig.get(sig); + if (!afterPaths || afterPaths.length !== 1) continue; // missing or ambiguous on after + const beforePath = beforePaths[0]!; + const afterPath = afterPaths[0]!; + if (!hasMeaningfulSharedPrefix(beforePath, afterPath)) continue; + mapping.set(beforePath, afterPath); + } + + return mapping; +} + +/** Rewrite an element path, preserving a `::pseudo` suffix when present. */ +export function remapPath(elementPath: string, beforeToAfter: Map): string { + const pseudoAt = elementPath.indexOf('::'); + if (pseudoAt === -1) return beforeToAfter.get(elementPath) ?? elementPath; + const base = elementPath.slice(0, pseudoAt); + const pseudo = elementPath.slice(pseudoAt); + return (beforeToAfter.get(base) ?? base) + pseudo; +} + +/** + * Clone a before map with matched element (and safe forced-state) paths rewritten + * onto their corresponding after paths. Unmatched paths stay put. + */ +export function remapBeforeStyleMap(before: StyleMap, beforeToAfter: Map): StyleMap { + if (beforeToAfter.size === 0) return before; + + const elements: StyleMap['elements'] = {}; + for (const [elementPath, entry] of Object.entries(before.elements)) { + elements[remapPath(elementPath, beforeToAfter)] = entry; + } + + const states: NonNullable = {}; + for (const [ownerPath, byState] of Object.entries(before.states ?? {})) { + const newOwner = remapPath(ownerPath, beforeToAfter); + const mappedByState: (typeof states)[string] = {}; + for (const [stateName, targets] of Object.entries(byState)) { + const mappedTargets: typeof targets = {}; + for (const [targetPath, props] of Object.entries(targets)) { + // Owner and target paths remap independently when each end corresponded; + // a target that did not correspond keeps its concrete before path and + // will surface as a one-sided state inventory against the real after map + // — safer than inventing a head path. + mappedTargets[remapPath(targetPath, beforeToAfter)] = props; + } + mappedByState[stateName] = mappedTargets; + } + states[newOwner] = mappedByState; + } + + return { + ...before, + elements, + states, + volatile: before.volatile?.map((p) => remapPath(p, beforeToAfter)), + liveCandidates: before.liveCandidates?.map((c) => ({ ...c, path: remapPath(c.path, beforeToAfter) })), + overlays: before.overlays?.map((o) => ({ ...o, path: remapPath(o.path, beforeToAfter) })), + }; +} + +/** Before map rewritten for presentation lookup/diff against the real after map. */ +export function presentationBeforeMap(before: StyleMap, after: StyleMap): StyleMap { + return remapBeforeStyleMap(before, correspondElementPaths(before, after)); +} + +/** + * Presentation findings: same differ as certification, but on a before map whose + * uniquely corresponded paths have been rewritten to the head path. Raw + * `diffStyleMaps(before, after)` is unchanged for gates. + */ +export function presentationDiffStyleMaps(before: StyleMap, after: StyleMap): Finding[] { + return diffStyleMaps(presentationBeforeMap(before, after), after); +} diff --git a/src/report.ts b/src/report.ts index c7cf23a..0a07165 100644 --- a/src/report.ts +++ b/src/report.ts @@ -29,6 +29,7 @@ import { type PropChange, type SurfaceDiff, } from './diff.js'; +import { presentationBeforeMap, presentationDiffStyleMaps } from './path-correspondence.js'; import { describeChange, tokenIndex, toHex, type ElementChange, type DescribeCtx } from './describe.js'; import { auditCoverage, @@ -1238,8 +1239,11 @@ type RepresentativeScore = { hasExposedChange: boolean; hasActiveModal: boolean; * ordinary page over a popup state that can leave shared chrome in the background, * then the widest width. */ function representativeScore(candidate: PreparedSurface, beforeDir: string, afterDir: string): RepresentativeScore { - const beforeMap = loadStyleMap(findCapture(beforeDir, candidate.sd.surface)); + const rawBefore = loadStyleMap(findCapture(beforeDir, candidate.sd.surface)); const afterMap = loadStyleMap(findCapture(afterDir, candidate.sd.surface)); + // Presentation findings may sit on corresponded head paths — score against the + // remapped before map so those paths resolve on both sides. + const beforeMap = presentationBeforeMap(rawBefore, afterMap); const changedPaths = [...new Set(candidate.findings.map((finding) => finding.path))]; const hasExposedChange = hasExposedChangedEntry(beforeMap, afterMap, changedPaths); const hasActiveModal = [...(beforeMap.overlays ?? []), ...(afterMap.overlays ?? [])].some( @@ -1661,8 +1665,11 @@ function renderChangeGroup( cropSeq: number, ): { md: string[]; json: Record; findingCount: number; cropSeq: number } { const { sd, findings: surfaceFindings } = cg.rep; - const mapA = loadStyleMap(findCapture(ctx.beforeDir, sd.surface)); + const rawBefore = loadStyleMap(findCapture(ctx.beforeDir, sd.surface)); const mapB = loadStyleMap(findCapture(ctx.afterDir, sd.surface)); + // Same correspondence rewrite as prepareReportSurfaces so crops/annotations + // resolve corresponded head paths on the before side too. + const mapA = presentationBeforeMap(rawBefore, mapB); // Theme-token reverse-indexes so colour changes can name `red-200` per side. const describeCtx: DescribeCtx = { tokensBefore: tokenIndex(mapA.tokens), tokensAfter: tokenIndex(mapB.tokens) }; const changedPaths = outermost([...new Set(surfaceFindings.map((f) => f.path))]); @@ -1825,20 +1832,37 @@ function comparisonForReport( }; } -/** Focus each surface on styling intent unless layout noise is requested. A - * surface whose ONLY changes are derived longhands keeps them - * (cleanFindingsForDisplay): those findings still gate, and a report that - * renders nothing for a gating change asks a reviewer to approve evidence - * that doesn't exist. */ +/** + * Focus each surface on styling intent unless layout noise is requested. + * + * Presentation findings run through report-only path correspondence first: + * uniquely paired removed→added elements are rewritten onto the head path so + * `diffStyleMaps` can emit real before→after property deltas (or collapse a + * pure path move). Raw `sd.findings`, `rawCounts`, exit codes, and approval + * gates stay on the concrete-path certification differ. + * + * A surface whose ONLY changes are derived longhands keeps them + * (cleanFindingsForDisplay): those findings still gate, and a report that + * renders nothing for a gating change asks a reviewer to approve evidence + * that doesn't exist. + */ function prepareReportSurfaces( surfaces: ReturnType['surfaces'], includeNoise: boolean, + beforeDir: string, + afterDir: string, ): PreparedSurface[] { return surfaces - .map((sd) => ({ - sd, - findings: sd.missing || includeNoise ? sd.findings : cleanFindingsForDisplay(sd.findings), - })) + .map((sd) => { + if (sd.missing) return { sd, findings: sd.findings }; + const beforeMap = loadStyleMap(findCapture(beforeDir, sd.surface)); + const afterMap = loadStyleMap(findCapture(afterDir, sd.surface)); + const corresponded = presentationDiffStyleMaps(beforeMap, afterMap); + return { + sd, + findings: includeNoise ? corresponded : cleanFindingsForDisplay(corresponded), + }; + }) .filter((p) => p.sd.missing || p.findings.length > 0); } @@ -1908,7 +1932,7 @@ export function generateStyleMapReport(opts: ReportOptions): ReportResult { // forced-state echoes of base changes, and remove non-value noise (see // cleanFindings), unless includeLayoutNoise is set. Surfaces left with no real // change are dropped. - const prepared = prepareReportSurfaces(surfaces, includeNoise); + const prepared = prepareReportSurfaces(surfaces, includeNoise, beforeDir, afterDir); const missing = prepared.filter((p) => p.sd.missing); const changeGroups = groupBySignature(prepared, beforeDir, afterDir); diff --git a/test/path-correspondence.test.mjs b/test/path-correspondence.test.mjs new file mode 100644 index 0000000..100fb5e --- /dev/null +++ b/test/path-correspondence.test.mjs @@ -0,0 +1,304 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + correspondenceSignature, + correspondElementPaths, + hasMeaningfulSharedPrefix, + presentationDiffStyleMaps, + remapBeforeStyleMap, + sharedPathPrefix, +} from '../dist/path-correspondence.js'; +import { diffStyleMaps } from '../dist/diff.js'; +import { makeMap } from './helpers.mjs'; + +test('correspondenceSignature uses tag + x/y/width + ownTextLength (not height or text)', () => { + const a = correspondenceSignature({ + tag: 'span', + cls: 'label secret-class', + rect: [10, 20, 30, 14], + ownTextLength: 5, + style: { 'font-size': '12px' }, + text: 'hello', + }); + const b = correspondenceSignature({ + tag: 'span', + cls: 'other', + rect: [10, 20, 30, 15], // height differs — still same signature + ownTextLength: 5, + style: { 'font-size': '13.3333px' }, + }); + assert.equal(a, b); + assert.ok(a && !a.includes('hello') && !a.includes('secret')); + // width change breaks the pair + assert.notEqual( + a, + correspondenceSignature({ + tag: 'span', + cls: '', + rect: [10, 20, 31, 14], + ownTextLength: 5, + style: {}, + }), + ); +}); + +test('correspondenceSignature is null without a rect or ownTextLength', () => { + assert.equal(correspondenceSignature({ tag: 'div', cls: '', style: {} }), null); + assert.equal( + correspondenceSignature({ tag: 'div', cls: '', rect: [0, 0, 100, 20], style: {} }), + null, + 'legacy maps without ownTextLength must fail closed', + ); +}); + +test('shared structural prefix requires a non-empty common ancestor segment', () => { + assert.equal(sharedPathPrefix('body > button:nth-child(1)', 'body > div:nth-child(1) > button:nth-child(1)'), 'body'); + assert.ok(hasMeaningfulSharedPrefix('body > button:nth-child(1)', 'body > div:nth-child(1) > button:nth-child(1)')); + assert.equal(sharedPathPrefix('main > span', 'aside > span'), ''); + assert.equal(hasMeaningfulSharedPrefix('main > span', 'aside > span'), false); +}); + +test('wrapper insert with identical geometry pairs the descendant uniquely', () => { + const button = { + tag: 'button', + cls: 'cta', + rect: [10, 10, 120, 40], + ownTextLength: 3, + style: { 'font-size': '12px' }, + }; + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > button:nth-child(1)': button, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > div:nth-child(1)': { + tag: 'div', + cls: 'wrap', + rect: [0, 0, 200, 80], + ownTextLength: 0, + style: { display: 'block' }, + }, + 'body > div:nth-child(1) > button:nth-child(1)': button, + }, + }); + const mapping = correspondElementPaths(before, after); + assert.equal(mapping.size, 1); + assert.equal(mapping.get('body > button:nth-child(1)'), 'body > div:nth-child(1) > button:nth-child(1)'); +}); + +test('presentation diff: wrapper with unchanged descendant collapses to wrapper DOM add only', () => { + const button = { + tag: 'button', + cls: 'cta', + rect: [10, 10, 120, 40], + ownTextLength: 3, + style: { 'background-color': 'rgb(0, 90, 252)', 'font-size': '12px' }, + }; + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > button:nth-child(1)': button, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > div:nth-child(1)': { + tag: 'div', + cls: 'wrap', + rect: [0, 0, 200, 80], + ownTextLength: 0, + style: { padding: '8px' }, + }, + 'body > div:nth-child(1) > button:nth-child(1)': { ...button }, + }, + }); + + const raw = diffStyleMaps(before, after); + assert.ok(raw.some((f) => f.kind === 'dom' && f.change === 'removed' && f.path === 'body > button:nth-child(1)')); + assert.ok(raw.filter((f) => f.kind === 'dom' && f.change === 'added').length >= 2); + + const presented = presentationDiffStyleMaps(before, after); + const dom = presented.filter((f) => f.kind === 'dom'); + assert.equal(dom.filter((f) => f.change === 'removed').length, 0, 'corresponded button removal collapses'); + assert.ok( + dom.some((f) => f.change === 'added' && f.path === 'body > div:nth-child(1)'), + 'new wrapper remains a DOM add', + ); + assert.equal( + presented.filter((f) => f.kind === 'style' && f.path.includes('button')).length, + 0, + 'unchanged corresponded button is not a restyle', + ); +}); + +test('presentation diff: wrapper with true font-size delta shows paired before→after', () => { + const baseBtn = { + tag: 'span', + cls: 'label', + rect: [40, 40, 80, 14], + ownTextLength: 4, + style: { 'font-size': '12px', height: '14px' }, + }; + const headBtn = { + ...baseBtn, + rect: [40, 40, 80, 15], // height change only — still pairs (height ignored) + style: { 'font-size': '13.3333px', height: '15px' }, + }; + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > span:nth-child(1)': baseBtn, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > div:nth-child(1)': { + tag: 'div', + cls: 'wrap', + rect: [30, 30, 100, 40], + ownTextLength: 0, + style: {}, + }, + 'body > div:nth-child(1) > span:nth-child(1)': headBtn, + }, + }); + + const presented = presentationDiffStyleMaps(before, after); + const style = presented.find( + (f) => f.kind === 'style' && f.path === 'body > div:nth-child(1) > span:nth-child(1)' && f.pseudo === null, + ); + assert.ok(style, 'paired style finding at head path'); + const font = style.props.find((p) => p.prop === 'font-size'); + assert.deepEqual(font, { prop: 'font-size', before: '12px', after: '13.3333px' }); + const height = style.props.find((p) => p.prop === 'height'); + assert.ok(height, 'height geometry effect still reported on the pair'); + assert.equal(height.before, '14px'); + assert.equal(height.after, '15px'); + assert.equal( + presented.filter((f) => f.kind === 'dom' && f.change === 'removed').length, + 0, + 'no unpaired removal for the corresponded leaf', + ); +}); + +test('ambiguous duplicate signatures remain unmatched', () => { + const twin = { + tag: 'button', + cls: 'chip', + rect: [0, 0, 64, 24], + ownTextLength: 1, + style: { color: 'rgb(0, 0, 0)' }, + }; + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 400, 200], ownTextLength: 0, style: {} }, + 'body > button:nth-child(1)': twin, + 'body > button:nth-child(2)': { ...twin }, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 400, 200], ownTextLength: 0, style: {} }, + 'body > section:nth-child(1)': { + tag: 'section', + cls: 'row', + rect: [0, 0, 400, 80], + ownTextLength: 0, + style: {}, + }, + 'body > section:nth-child(1) > button:nth-child(1)': { ...twin }, + 'body > section:nth-child(1) > button:nth-child(2)': { ...twin }, + }, + }); + assert.equal(correspondElementPaths(before, after).size, 0); + const presented = presentationDiffStyleMaps(before, after); + assert.ok(presented.some((f) => f.kind === 'dom' && f.change === 'removed')); + assert.ok(presented.some((f) => f.kind === 'dom' && f.change === 'added')); + // Inventory rows on unpaired adds are fine; invented *paired* restyles are not. + // A paired restyle sits on a path present in both maps after correspondence — + // which would mean no DOM add/remove for that path. + const oneSided = new Set( + presented.filter((f) => f.kind === 'dom' && (f.change === 'added' || f.change === 'removed')).map((f) => f.path), + ); + const pairedButtonStyles = presented.filter( + (f) => f.kind === 'style' && f.path.includes('button') && !oneSided.has(f.path), + ); + assert.equal(pairedButtonStyles.length, 0, 'duplicates must not invent paired restyles'); +}); + +test('remapBeforeStyleMap rewrites forced-state owner and target paths when safe', () => { + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 100, 100], style: {} }, + 'body > button:nth-child(1)': { + tag: 'button', + cls: 'x', + rect: [1, 1, 10, 10], + ownTextLength: 0, + style: {}, + }, + }, + states: { + 'body > button:nth-child(1)': { + hover: { + 'body > button:nth-child(1)': { color: 'rgb(0, 0, 0)' }, + 'body > button:nth-child(1)::before': { opacity: '1' }, + }, + }, + }, + }); + const mapping = new Map([['body > button:nth-child(1)', 'body > div:nth-child(1) > button:nth-child(1)']]); + const remapped = remapBeforeStyleMap(before, mapping); + assert.ok(remapped.elements['body > div:nth-child(1) > button:nth-child(1)']); + assert.equal(remapped.elements['body > button:nth-child(1)'], undefined); + assert.deepEqual(remapped.states['body > div:nth-child(1) > button:nth-child(1)'].hover, { + 'body > div:nth-child(1) > button:nth-child(1)': { color: 'rgb(0, 0, 0)' }, + 'body > div:nth-child(1) > button:nth-child(1)::before': { opacity: '1' }, + }); +}); + +test('raw certification differ is unchanged by presentation correspondence helpers', () => { + const button = { + tag: 'button', + cls: 'cta', + rect: [10, 10, 120, 40], + ownTextLength: 3, + style: { 'font-size': '12px' }, + }; + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > button:nth-child(1)': button, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 800, 600], ownTextLength: 0, style: {} }, + 'body > div:nth-child(1)': { + tag: 'div', + cls: 'wrap', + rect: [0, 0, 200, 80], + ownTextLength: 0, + style: {}, + }, + 'body > div:nth-child(1) > button:nth-child(1)': { + ...button, + style: { 'font-size': '14px' }, + }, + }, + }); + const rawA = diffStyleMaps(before, after); + const rawB = diffStyleMaps(before, after); + assert.deepEqual(rawA, rawB); + // Presentation may pair; raw still sees remove + add (+ inventories). + assert.ok(rawA.some((f) => f.kind === 'dom' && f.change === 'removed')); + assert.ok(rawA.filter((f) => f.kind === 'dom' && f.change === 'added').length >= 2); + const presented = presentationDiffStyleMaps(before, after); + assert.notDeepEqual(presented, rawA); +}); diff --git a/test/report.test.mjs b/test/report.test.mjs index 92ea4fe..09263e6 100644 --- a/test/report.test.mjs +++ b/test/report.test.mjs @@ -861,6 +861,166 @@ test('wrapper path-churn: inventory copy, not restyle differences', () => { rmTmp(root); }); +// Report-only path correspondence: when a removed base path and an added head path +// share a unique privacy-safe geometry signature (tag + x/y/width + ownTextLength) +// under a shared structural prefix, presentation pairs them. Certification raw +// findings / counts stay on concrete paths. +test('correspondence: wrapper insert with unchanged descendant collapses path move', () => { + const button = { + tag: 'button', + cls: 'cta', + rect: [10, 10, 120, 40], + ownTextLength: 3, + style: { 'background-color': 'rgb(0, 90, 252)', color: 'rgb(255, 255, 255)' }, + }; + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 1280, 800], ownTextLength: 0, style: {} }, + 'body > button:nth-child(1)': button, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 1280, 800], ownTextLength: 0, style: {} }, + 'body > div:nth-child(1)': { + tag: 'div', + cls: 'wrap', + rect: [0, 0, 200, 80], + ownTextLength: 0, + style: { padding: '8px' }, + }, + 'body > div:nth-child(1) > button:nth-child(1)': { ...button }, + }, + }); + const { beforeDir, afterDir, outDir, root } = pairFixture({ + surface: 's@1280', + before, + after, + beforePng: solidPng(1280, 800), + afterPng: solidPng(1280, 800), + }); + const res = generateStyleMapReport({ beforeDir, afterDir, outDir }); + const md = fs.readFileSync(res.reportMdPath, 'utf8'); + const json = JSON.parse(fs.readFileSync(res.reportJsonPath, 'utf8')); + // Presentation: only the new wrapper (and its inventory) — not a button remove/add restyle. + assert.equal(json.counts.style, 0, 'unchanged corresponded leaf is not a restyle'); + assert.equal(json.counts.state, 0); + assert.ok(json.counts.dom >= 1, 'wrapper remains a DOM add'); + assert.doesNotMatch(md, /element(?:s)? removed/, 'corresponded removal collapses in presentation'); + assert.doesNotMatch(md, /1 element restyled|elements restyled/); + assert.match(md, /element(?:s)? added/); + // Raw certification still sees full path churn. + assert.ok(json.rawCounts.dom >= 2, 'raw DOM still counts remove + adds'); + assert.ok(json.rawCounts.style > 0 || json.reviewableCounts.style > 0, 'raw still inventories one-sided paths'); + rmTmp(root); +}); + +test('correspondence: wrapper insert with true font-size delta shows paired before→after', () => { + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 1280, 800], ownTextLength: 0, style: {} }, + 'body > span:nth-child(1)': { + tag: 'span', + cls: 'label', + rect: [40, 40, 80, 14], + ownTextLength: 4, + style: { 'font-size': '12px', height: '14px' }, + }, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 1280, 800], ownTextLength: 0, style: {} }, + 'body > div:nth-child(1)': { + tag: 'div', + cls: 'wrap', + rect: [30, 30, 100, 40], + ownTextLength: 0, + style: {}, + }, + 'body > div:nth-child(1) > span:nth-child(1)': { + tag: 'span', + cls: 'label', + rect: [40, 40, 80, 15], + ownTextLength: 4, + style: { 'font-size': '13.3333px', height: '15px' }, + }, + }, + }); + const { beforeDir, afterDir, outDir, root } = pairFixture({ + surface: 's@1280', + before, + after, + beforePng: solidPng(1280, 800), + afterPng: solidPng(1280, 800), + }); + const res = generateStyleMapReport({ beforeDir, afterDir, outDir, includeLayoutNoise: true }); + const md = fs.readFileSync(res.reportMdPath, 'utf8'); + const json = JSON.parse(fs.readFileSync(res.reportJsonPath, 'utf8')); + assert.ok(json.counts.style >= 1, 'paired leaf contributes computed-style differences'); + assert.match(md, /computed-style difference/); + assert.match(md, /\| Property \| Before \| After \|/); + assert.match(md, /`font-size` \| `12px` \| `13\.3333px`/); + assert.match(md, /`height` \| `14px` \| `15px`/); + assert.doesNotMatch(md, /Style inventory \(head-side — no baseline\).*font-size/s); + // Gate tallies stay on the concrete-path certification differ. + assert.ok(json.rawCounts.dom >= 2, 'raw still sees structural path churn'); + assert.deepEqual(Object.keys(json.rawCounts).sort(), ['dom', 'state', 'style']); + assert.deepEqual(Object.keys(json.reviewableCounts).sort(), ['dom', 'state', 'style']); + rmTmp(root); +}); + +test('correspondence: ambiguous duplicates stay unpaired; raw/reviewable gate counts intact', () => { + const twin = { + tag: 'button', + cls: 'chip', + rect: [0, 0, 64, 24], + ownTextLength: 1, + style: { color: 'rgb(0, 0, 0)' }, + }; + const before = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 400, 200], ownTextLength: 0, style: {} }, + 'body > button:nth-child(1)': twin, + 'body > button:nth-child(2)': { ...twin }, + }, + }); + const after = makeMap({ + elements: { + body: { tag: 'body', rect: [0, 0, 400, 200], ownTextLength: 0, style: {} }, + 'body > section:nth-child(1)': { + tag: 'section', + cls: 'row', + rect: [0, 0, 400, 80], + ownTextLength: 0, + style: {}, + }, + 'body > section:nth-child(1) > button:nth-child(1)': { ...twin }, + 'body > section:nth-child(1) > button:nth-child(2)': { ...twin }, + }, + }); + const { beforeDir, afterDir, outDir, root } = pairFixture({ + surface: 's@400', + before, + after, + beforePng: solidPng(400, 200), + afterPng: solidPng(400, 200), + }); + const res = generateStyleMapReport({ beforeDir, afterDir, outDir }); + const md = fs.readFileSync(res.reportMdPath, 'utf8'); + const json = JSON.parse(fs.readFileSync(res.reportJsonPath, 'utf8')); + assert.equal(json.counts.style, 0, 'duplicates never invent paired restyles'); + assert.ok(json.counts.dom >= 3, 'unpaired removes + adds stay DOM'); + assert.match(md, /element(?:s)? removed/); + assert.match(md, /element(?:s)? added/); + assert.doesNotMatch(md, /1 element restyled|elements restyled/); + // Certification truth is independent of presentation correspondence. + assert.ok(json.rawCounts.dom >= 3); + assert.equal(typeof json.reviewableCounts.dom, 'number'); + assert.equal(json.reportConsistency.ok, true); + rmTmp(root); +}); + // Regression, seen in a downstream report: a gradient diff rendered as the same // "representative" rgba in BOTH cells — the real change (a dropped `0px` stop) // was invisible. Long values must excerpt around the differing substring.