Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 169 additions & 1 deletion php-transformer/src/VisualParity/StaticStyleParityComparator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -64,18 +73,38 @@ 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;

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);
Expand Down Expand Up @@ -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);
Expand All @@ -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(),
);

Expand Down Expand Up @@ -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<int, array<string, mixed>> $targetProbes
* @return array<int, array{style: array<string, string>, 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<string, mixed> $sourceProbe
* @param array<int, array{style: array<string, string>, 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<string, string> $sourceStyle Normalized, non-empty values.
* @param array<string, string> $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<string, mixed> $source
* @param array<string, mixed> $target
Expand Down Expand Up @@ -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<string, mixed> $sourceProbe
* @param array<string, mixed> $targetProbe
* @return array<string, mixed>
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -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": "<style>body{font-family:Inter,sans-serif;color:#111}.lead{font-size:18px}.banner{background-color:#e91e63;color:#fff}</style><body><div class=\"col\"><p class=\"lead\">Hello world</p></div><div class=\"banner\">Sale</div></body>",
"target_content": "<style>body{font-family:Inter,sans-serif;color:#111}.lead{font-size:18px}.banner{background-color:#e91e63;color:#fff}</style><body><p class=\"lead\">Hello world</p></body>"
},
"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)" }
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading