diff --git a/php-transformer/src/VisualParity/StaticStyleParityComparator.php b/php-transformer/src/VisualParity/StaticStyleParityComparator.php index 98af21a6..ae0a31a6 100644 --- a/php-transformer/src/VisualParity/StaticStyleParityComparator.php +++ b/php-transformer/src/VisualParity/StaticStyleParityComparator.php @@ -27,6 +27,15 @@ * 3. class — same tag, same sorted class set * 4. structural — same class-free structural path (tag + nth-of-type chain) * + * COVERAGE then accounts for the transformer's intentional collapse of + * presentational wrappers. A source element that owns no 1:1 candidate is not + * automatically parity loss: if some candidate carries every one of its + * effective tracked-style values and subsumes its content, the wrapper was + * faithfully absorbed under collapse and earns coverage credit (no findings). + * Only genuinely dropped or restyled elements — whose style is absent from every + * candidate, or whose content has no home — stay counted as loss, so the score + * still falls for real divergence. Coverage = (matched + absorbed) / source. + * * Same inputs -> byte-identical report on every run. No screenshots, no * dimensions, no scroll/animation flakiness, no OOM. */ @@ -64,11 +73,13 @@ public function compare(array $source, array $target): array $sourceProbes = $this->probes($source); $targetProbes = $this->probes($target); $available = array_fill_keys(array_keys($targetProbes), true); + $normalizedTargets = $this->normalizedTargets($targetProbes); $matches = array(); $findings = array(); $recommendations = array(); $unmatchedSource = array(); + $absorbedSource = array(); $comparedProperties = 0; $agreedProperties = 0; @@ -76,6 +87,24 @@ public function compare(array $source, array $target): array foreach ( $sourceProbes as $sourceProbe ) { [$targetIndex, $tier] = $this->bestTargetIndex($sourceProbe, $targetProbes, $available); if ( null === $targetIndex ) { + // No 1:1 candidate remained. Before counting this as parity loss, + // test for collapsed-wrapper equivalence: the transformer + // intentionally merges presentational wrappers, so a source + // element that owns no 1:1 candidate is faithfully preserved when + // some candidate carries every one of its effective tracked-style + // values AND subsumes its content. Such an element lost no visual + // signal under collapse, so it earns coverage credit rather than + // being scored as a drop. Non-consuming: the wrapper legitimately + // shares the merged element with the content match. A genuinely + // dropped/regressed element (its style absent from every candidate + // or its content gone) finds no absorbing candidate and stays a + // counted loss, so real divergence still lowers the score. + $absorbIndex = $this->absorbingTargetIndex($sourceProbe, $normalizedTargets); + if ( null !== $absorbIndex ) { + $absorbedSource[] = $this->absorbedSummary($sourceProbe, $targetProbes[$absorbIndex]); + continue; + } + $unmatchedSource[] = $this->candidateSummary($sourceProbe); $finding = $this->missingElementFinding($sourceProbe, count($findings) + 1); $recommendation = $this->recommendationFor($finding, count($recommendations) + 1); @@ -129,8 +158,14 @@ public function compare(array $source, array $target): array $sourceTotal = count($sourceProbes); $matchedTotal = count($matches); + $absorbedTotal = count($absorbedSource); + // Coverage measures whether each source element's visual signal found a + // home in the candidate, either as a 1:1 style comparison (matched) or as + // a faithfully-preserved collapsed wrapper (absorbed). Only genuinely + // dropped/regressed elements are excluded from the numerator. + $coveredTotal = $matchedTotal + $absorbedTotal; $propertyParity = $comparedProperties > 0 ? $agreedProperties / $comparedProperties : 1.0; - $coverage = $sourceTotal > 0 ? $matchedTotal / $sourceTotal : 1.0; + $coverage = $sourceTotal > 0 ? $coveredTotal / $sourceTotal : 1.0; $score = round($propertyParity * $coverage, 4); $status = $this->status($score, array() === $findings); @@ -157,12 +192,15 @@ public function compare(array $source, array $target): array 'source_total' => $sourceTotal, 'target_total' => count($targetProbes), 'matched_total' => $matchedTotal, + 'absorbed_source_total' => $absorbedTotal, + 'covered_total' => $coveredTotal, 'unmatched_source_total' => count($unmatchedSource), 'compared_properties' => $comparedProperties, 'agreed_properties' => $agreedProperties, 'finding_total' => count($findings), ), 'unmatched_source' => $unmatchedSource, + 'absorbed_source' => $absorbedSource, 'diagnostics' => array(), ); @@ -198,6 +236,116 @@ private function bestTargetIndex(array $sourceProbe, array $targetProbes, array return array(null, ''); } + /** + * Pre-normalize every candidate's tracked style and text once so the + * absorption pass is a cheap, deterministic lookup. Keyed by the same indices + * as $targetProbes; iteration order is document order. + * + * @param array> $targetProbes + * @return array, text: string}> + */ + private function normalizedTargets(array $targetProbes): array + { + $normalized = array(); + foreach ( $targetProbes as $index => $targetProbe ) { + $style = array(); + foreach ( $this->style($targetProbe) as $property => $value ) { + $normalizedValue = $this->normalizeValue((string) $property, (string) $value); + if ( '' !== $normalizedValue ) { + $style[(string) $property] = $normalizedValue; + } + } + $normalized[$index] = array( + 'style' => $style, + 'text' => $this->text($targetProbe), + ); + } + + return $normalized; + } + + /** + * Collapsed-wrapper equivalence test. + * + * Returns the first candidate (document order) that faithfully absorbs the + * source element, or null when none does. A candidate absorbs the source when: + * + * - STYLE is preserved: every declared, non-empty tracked-style value on the + * source equals the candidate's normalized value for that property + * (candidate is a style superset), and + * - CONTENT has a home: the source has no text, or one of the two texts + * contains the other (the merged candidate subsumes the wrapper's content; + * the bidirectional check tolerates the probe's 80-char text truncation). + * + * This credits presentational wrappers the transformer intentionally merges + * without crediting drops: if any declared style value is missing from every + * candidate (a real style regression) or the content is gone (a real drop), + * no candidate qualifies and the element remains a counted loss. Non-consuming + * by design — the wrapper and its content legitimately share one merged + * candidate element. + * + * @param array $sourceProbe + * @param array, text: string}> $normalizedTargets + */ + private function absorbingTargetIndex(array $sourceProbe, array $normalizedTargets): ?int + { + $sourceStyle = array(); + foreach ( $this->style($sourceProbe) as $property => $value ) { + $normalizedValue = $this->normalizeValue((string) $property, (string) $value); + if ( '' !== $normalizedValue ) { + $sourceStyle[(string) $property] = $normalizedValue; + } + } + $sourceText = $this->text($sourceProbe); + + foreach ( $normalizedTargets as $index => $target ) { + if ( ! $this->styleIsSuperset($sourceStyle, $target['style']) ) { + continue; + } + if ( $this->contentSubsumed($sourceText, $target['text']) ) { + return $index; + } + } + + return null; + } + + /** + * True when every declared source value is reproduced on the candidate. + * + * @param array $sourceStyle Normalized, non-empty values. + * @param array $candidateStyle Normalized, non-empty values. + */ + private function styleIsSuperset(array $sourceStyle, array $candidateStyle): bool + { + foreach ( $sourceStyle as $property => $value ) { + if ( ($candidateStyle[$property] ?? null) !== $value ) { + return false; + } + } + + return true; + } + + /** + * True when the absorbing candidate carries the source element's content. + * Empty source text (a layout wrapper or an SVG shape) carries no textual + * content to lose and is always subsumed. Otherwise the merged candidate must + * contain the source's full text — a directional check, so a short candidate + * (e.g. a one-letter icon) can never spuriously "absorb" a text-bearing + * wrapper merely because its single character appears somewhere in the source. + * Equal texts satisfy containment, which also covers the probe's shared + * 80-char truncation boundary. + */ + private function contentSubsumed(string $sourceText, string $candidateText): bool + { + if ( '' === $sourceText ) { + return true; + } + + return str_contains($candidateText, $sourceText); + } + /** * @param array $source * @param array $target @@ -404,4 +552,24 @@ private function candidateSummary(array $probe): array 'text' => $probe['text'] ?? null, ), static fn ($value): bool => null !== $value && '' !== $value); } + + /** + * Transparency record for a source element whose styling and content the + * transform faithfully preserved on a merged candidate (collapsed wrapper). + * Names the absorbing candidate selector so the credit is auditable. + * + * @param array $sourceProbe + * @param array $targetProbe + * @return array + */ + private function absorbedSummary(array $sourceProbe, array $targetProbe): array + { + return array_filter(array( + 'id' => $sourceProbe['id'] ?? null, + 'tag' => $sourceProbe['tag'] ?? null, + 'source_selector' => $this->selector($sourceProbe), + 'absorbed_into_selector' => $this->selector($targetProbe), + 'text' => $sourceProbe['text'] ?? null, + ), static fn ($value): bool => null !== $value && '' !== $value); + } } diff --git a/php-transformer/tests/fixtures/parity/static-style-parity-compare-collapsed-wrapper.json b/php-transformer/tests/fixtures/parity/static-style-parity-compare-collapsed-wrapper.json new file mode 100644 index 00000000..004894e5 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/static-style-parity-compare-collapsed-wrapper.json @@ -0,0 +1,39 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "static-style-parity-compare-collapsed-wrapper", + "description": "Coverage must be honest about the transformer's intentional collapse of presentational wrappers. The source has a wrapper div.col whose styling is purely inherited and whose paragraph content the candidate preserves on a single merged element, plus a div.banner whose distinctive fill is genuinely dropped. The comparator credits the faithfully-absorbed wrapper as coverage (no finding) while still counting the dropped banner as parity loss with a presence finding, so the score reflects real divergence rather than the matching artifact of having no 1:1 candidate for a collapsed wrapper.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/static-style-parity-compare-collapsed-wrapper.json", + "notes": "Locks in collapsed-wrapper equivalence: a preserved wrapper is not loss, a dropped styled element is." + }, + "legacy_comparison": { + "skip": true, + "reason": "Static style parity comparison emits the shared visual-parity report contract." + }, + "operation": "static_style_parity.compare", + "input": { + "source_content": "

Hello world

Sale
", + "target_content": "

Hello world

" + }, + "expect": [ + { "path": "schema", "assert": "equals", "value": "blocks-engine/php-transformer/visual-parity-report/v1" }, + { "path": "status", "assert": "equals", "value": "fail" }, + { "path": "severity", "assert": "equals", "value": "error" }, + { "path": "parity.property_parity", "assert": "equals", "value": 1.0 }, + { "path": "parity.coverage", "assert": "equals", "value": 0.6667 }, + { "path": "parity.score", "assert": "equals", "value": 0.6667 }, + { "path": "summary.source_total", "assert": "equals", "value": 3 }, + { "path": "summary.matched_total", "assert": "equals", "value": 1 }, + { "path": "summary.absorbed_source_total", "assert": "equals", "value": 1 }, + { "path": "summary.covered_total", "assert": "equals", "value": 2 }, + { "path": "summary.unmatched_source_total", "assert": "equals", "value": 1 }, + { "path": "summary.finding_total", "assert": "equals", "value": 1 }, + { "path": "absorbed_source.0.tag", "assert": "equals", "value": "div" }, + { "path": "absorbed_source.0.source_selector", "assert": "equals", "value": "div.col" }, + { "path": "absorbed_source.0.absorbed_into_selector", "assert": "equals", "value": "p.lead" }, + { "path": "unmatched_source.0.selector", "assert": "equals", "value": "div.banner:nth-of-type(2)" }, + { "path": "findings.0.property", "assert": "equals", "value": "presence" }, + { "path": "findings.0.selector", "assert": "equals", "value": "div.banner:nth-of-type(2)" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/static-style-parity-compare-match.json b/php-transformer/tests/fixtures/parity/static-style-parity-compare-match.json index 807bd24a..7ee4d31b 100644 --- a/php-transformer/tests/fixtures/parity/static-style-parity-compare-match.json +++ b/php-transformer/tests/fixtures/parity/static-style-parity-compare-match.json @@ -24,6 +24,8 @@ { "path": "parity.property_parity", "assert": "equals", "value": 1.0 }, { "path": "parity.coverage", "assert": "equals", "value": 1.0 }, { "path": "summary.matched_total", "assert": "equals", "value": 3 }, + { "path": "summary.absorbed_source_total", "assert": "equals", "value": 0 }, + { "path": "summary.covered_total", "assert": "equals", "value": 3 }, { "path": "summary.compared_properties", "assert": "equals", "value": 11 }, { "path": "summary.agreed_properties", "assert": "equals", "value": 11 }, { "path": "summary.finding_total", "assert": "equals", "value": 0 }, diff --git a/php-transformer/tests/fixtures/parity/static-style-parity-compare-mismatch.json b/php-transformer/tests/fixtures/parity/static-style-parity-compare-mismatch.json index aed3a0cb..67a3d3db 100644 --- a/php-transformer/tests/fixtures/parity/static-style-parity-compare-mismatch.json +++ b/php-transformer/tests/fixtures/parity/static-style-parity-compare-mismatch.json @@ -23,6 +23,7 @@ { "path": "parity.score", "assert": "equals", "value": 0.5455 }, { "path": "parity.coverage", "assert": "equals", "value": 1.0 }, { "path": "summary.matched_total", "assert": "equals", "value": 3 }, + { "path": "summary.absorbed_source_total", "assert": "equals", "value": 0 }, { "path": "summary.finding_total", "assert": "equals", "value": 5 }, { "path": "findings.0.property", "assert": "equals", "value": "background-color" }, { "path": "findings.0.selector", "assert": "equals", "value": "section.hero" },