Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.21% covered (warning)
84.21%
48 / 57
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FuzzyTolerance
84.21% covered (warning)
84.21%
48 / 57
71.43% covered (warning)
71.43%
5 / 7
33.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
13.05
 parseRange
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 isInteger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 permits
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 isTrivial
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 describe
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\WptHarness;
6
7/**
8 * A WPT `<meta name="fuzzy">` tolerance declaration.
9 *
10 * WPT reftests are exact-match by default. A fixture that knows it
11 * cannot be pixel-exact across engines relaxes the comparison with a
12 * fuzzy annotation carrying **two** independent tolerances:
13 *
14 *     <meta name=fuzzy content="maxDifference=0-5;totalPixels=0-100">
15 *
16 *  - `maxDifference` bounds the largest per-channel absolute colour
17 *    difference (0-255) found anywhere in the image.
18 *  - `totalPixels` bounds how many pixels differ at all.
19 *
20 * Both are *ranges*, both bounds inclusive, and both are enforced —
21 * see {@see self::permits()}. Either may be written as a bare number
22 * (`maxDifference=1`), which WPT reads as the degenerate range `1-1`.
23 * The names may be omitted entirely, in which case the two ranges are
24 * positional in the order `maxDifference;totalPixels`:
25 *
26 *     <meta name=fuzzy content="0-5;0-100">
27 *
28 * Grammar and semantics are pinned to WPT's own implementation:
29 * parsing is `tools/manifest/sourcefile.py::fuzzy`, comparison is
30 * `tools/wptrunner/wptrunner/executors/base.py::_screenshot_list`.
31 *
32 * Dropping `maxDifference` — as this harness did until now — scores
33 * fixtures wrong in *both* directions: a fixture declaring a tight
34 * colour tolerance is let off with whatever the harness default fuzz
35 * happened to be, and a fixture declaring a loose one is held to a
36 * default it never asked for.
37 */
38final class FuzzyTolerance
39{
40    /**
41     * @param array{int, int} $maxDifference inclusive [min, max] per-channel colour difference (0-255)
42     * @param array{int, int} $totalPixels   inclusive [min, max] count of differing pixels
43     */
44    private function __construct(
45        public readonly array $maxDifference,
46        public readonly array $totalPixels,
47    ) {}
48
49    /**
50     * Parse a `content` attribute value. Returns `null` when the value
51     * is not a well-formed fuzzy declaration — templated placeholders
52     * (`{{ fuzzy }}`) and other malformed values fall back to the
53     * harness default rather than failing the run.
54     *
55     * Any `<ref-url>:` prefix must already have been stripped by the
56     * caller (see `parse_ref_keyed_meta` in WPT): only the tolerance
57     * expression is parsed here.
58     */
59    public static function parse(string $content): ?self
60    {
61        $ranges = explode(';', trim($content));
62        if (count($ranges) !== 2) {
63            // WPT raises "Malformed fuzzy value" here; the harness is
64            // lenient and falls back to its default threshold.
65            return null;
66        }
67
68        /** @var array<string, array{int, int}> $named */
69        $named = [];
70        /** @var list<array{int, int}> $positional */
71        $positional = [];
72        foreach ($ranges as $range) {
73            $name = null;
74            if (str_contains($range, '=')) {
75                [$name, $range] = array_map(trim(...), explode('=', $range, 2));
76                if ($name !== 'maxDifference' && $name !== 'totalPixels') {
77                    return null;
78                }
79                if (isset($named[$name])) {
80                    return null;
81                }
82            }
83            $parsed = self::parseRange($range);
84            if ($parsed === null) {
85                return null;
86            }
87            if ($name === null) {
88                $positional[] = $parsed;
89            } else {
90                $named[$name] = $parsed;
91            }
92        }
93
94        // Named arguments bind first; the positional ones fill the
95        // remaining slots in declaration order (maxDifference, then
96        // totalPixels) — exactly WPT's `positional_args.popleft()`.
97        $resolved = [];
98        foreach (['maxDifference', 'totalPixels'] as $argument) {
99            if (isset($named[$argument])) {
100                $resolved[$argument] = $named[$argument];
101                continue;
102            }
103            if ($positional === []) {
104                return null;
105            }
106            $resolved[$argument] = array_shift($positional);
107        }
108        if ($positional !== []) {
109            return null;
110        }
111
112        return new self($resolved['maxDifference'], $resolved['totalPixels']);
113    }
114
115    /**
116     * `<lo>-<hi>`, or a bare `<n>` meaning `<n>-<n>`.
117     *
118     * @return array{int, int}|null
119     */
120    private static function parseRange(string $range): ?array
121    {
122        $range = trim($range);
123        if (str_contains($range, '-')) {
124            [$min, $max] = explode('-', $range, 2);
125        } else {
126            $min = $max = $range;
127        }
128        $min = trim($min);
129        $max = trim($max);
130        if (!self::isInteger($min) || !self::isInteger($max)) {
131            return null;
132        }
133        return [(int) $min, (int) $max];
134    }
135
136    private static function isInteger(string $value): bool
137    {
138        return preg_match('~^\d+$~', $value) === 1;
139    }
140
141    /**
142     * Does an observed diff fall within the declared tolerance?
143     *
144     * Verbatim port of wptrunner's decision
145     * (`executors/base.py`):
146     *
147     *     equal = ((pixels_different == 0 and allowed_different[0] == 0) or
148     *              (max_per_channel == 0 and allowed_per_channel[0] == 0) or
149     *              (allowed_per_channel[0] <= max_per_channel <= allowed_per_channel[1] and
150     *               allowed_different[0] <= pixels_different <= allowed_different[1]))
151     *
152     * Note both LOWER bounds are live. A fixture declaring
153     * `totalPixels=7600-8700` is asserting that the engines *must*
154     * disagree over roughly that many pixels; a render that differs
155     * over 12 of them is not "better than required", it is a
156     * different rendering and WPT fails it. The two leading clauses
157     * are the escape hatch for a perfect match against a tolerance
158     * whose floor is zero.
159     *
160     * @param int $maxPerChannel   largest per-channel absolute difference, 0-255
161     * @param int $pixelsDifferent count of pixels differing at all
162     */
163    public function permits(int $maxPerChannel, int $pixelsDifferent): bool
164    {
165        if ($pixelsDifferent === 0 && $this->totalPixels[0] === 0) {
166            return true;
167        }
168        if ($maxPerChannel === 0 && $this->maxDifference[0] === 0) {
169            return true;
170        }
171        return $maxPerChannel >= $this->maxDifference[0]
172            && $maxPerChannel <= $this->maxDifference[1]
173            && $pixelsDifferent >= $this->totalPixels[0]
174            && $pixelsDifferent <= $this->totalPixels[1];
175    }
176
177    /**
178     * A `0-0;0-0` declaration is WPT's "no tolerance at all" sentinel
179     * and is treated as exact matching rather than as a fuzzy range.
180     */
181    public function isTrivial(): bool
182    {
183        return $this->maxDifference === [0, 0] && $this->totalPixels === [0, 0];
184    }
185
186    public function describe(): string
187    {
188        return sprintf(
189            'maxDifference=%d-%d;totalPixels=%d-%d',
190            $this->maxDifference[0],
191            $this->maxDifference[1],
192            $this->totalPixels[0],
193            $this->totalPixels[1],
194        );
195    }
196}