From a1a6c2554f35d44962f6dbbb84eb87bf31531a41 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sat, 15 Aug 2026 06:54:36 +0300 Subject: [PATCH] Reach core 0.3's edge-case switch from the chain and the environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numeric generators return an in-range edge value roughly one draw in five. That is right until the edges are what a property cannot use — a body discarding 0, a range end that violates a precondition — and then one run in five is spent producing a value the property throws away. Core 0.3 added the switch; nothing here could set it. PROPERTY_EDGE_CASES follows the rule the rest of the table already follows — the environment dials the suite, the code pins the property — so the variable wins over edgeCases(). An unknown value throws instead of falling back: a suite that quietly kept the bias it was told to drop would spend exactly the discard budget it was trying to save. Two mutants earned their tests on the way. One of the new tests was named without the `test` prefix and therefore never ran, which the mutation report noticed and the green suite did not; and the message's trim() was only exercised by a value that had nothing to trim, so the unknown-value test now passes one with spaces. --- AGENTS.md | 1 + CHANGELOG.md | 10 +++++ README.md | 2 + README.ru.md | 2 + composer.json | 2 +- infection.json5 | 2 +- llms.txt | 2 + src/PhpUnit/PropertyCheck.php | 44 ++++++++++++++++++ tests/EnvironmentParityTest.php | 79 +++++++++++++++++++++++++++++++++ tests/Support/Env.php | 1 + 10 files changed, 143 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c0e326a..31e26be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ parity is golden rule 3. | `PROPERTY_PHASES` | Always (`false`/`''` = unset) | Comma-separated phase names, case-insensitive: `examples`, `corpus`, `random`, `shrink` | Stages of every run, in run order — **overrides** `phases()` | `InvalidArgumentException` naming the accepted values | | `PROPERTY_DERANDOMIZE` | Always | Any value except `''` and `'0'` enables | Derives every unset seed from the property id — **overrides** `derandomize()` | n/a (falsy values disable) | | `PROPERTY_PATH` | Only when `path()` was not called (explicit path wins) | A recorded `CounterExample::$path` | Replays that shrink descent instead of searching for it; needs the seed of the run that produced it | engine rejects a path that would be a silent no-op | +| `PROPERTY_EDGE_CASES` | Always (`false`/`''` = unset) | `mixin` or `none`, case-insensitive, trimmed | Numeric boundary bias for every run — **overrides** `edgeCases()` | `InvalidArgumentException` naming the accepted values | The split is deliberate and worth stating: **the environment dials the suite, the code pins the property.** `PROPERTY_RUNS`, `PROPERTY_PHASES` and diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f2a6a7..cf3521a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Added `PropertyCheck::edgeCases()` and `PROPERTY_EDGE_CASES` (`mixin` or + `none`), reaching core 0.3's switch for the numeric boundary bias. Turning it + off stops a property that cannot use `0`, `±1` or a range's ends from + spending one run in five on a value it discards. The variable overrides the + chain, like every other CI-facing knob, and an unknown value throws rather + than silently keeping the bias it was told to drop. +- **Requires `rasuvaeff/property-testing-core` `^0.3`.** + ## 0.2.0 — 2026-08-14 - Added the 0.2 run knobs, as fluent setters and as environment variables: diff --git a/README.md b/README.md index b276024..e545d74 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Reproduce the exact run by pinning the reported seed: `->seed(7382910)`. | `phases(array)` | Stages to perform (`Phase::Examples`, `Corpus`, `Random`, `Shrink`) — a subset trades coverage for time on purpose | | `derandomize(bool)` | Derives an unset seed from the property id instead of drawing one; an explicit `seed()` still wins | | `path(string)` | Replays a recorded shrink descent instead of searching for it; needs the seed that produced it | +| `edgeCases(EdgeCases)` | `None` turns off the numeric boundary bias — for a property the edges only cost runs | | `output($stdout, $stderr)` | Redirects the distribution report, discard warning and verbose trace (used by this package's own tests) | ### Naming a property (`id()`) @@ -162,6 +163,7 @@ Byte-for-byte parity with the Testo adapter — one contract across adapters: | `PROPERTY_PHASES` | Comma-separated stage list (`examples,corpus,random,shrink`, case-insensitive) that overrides `phases()` — an unknown name throws rather than skipping a stage. `examples,corpus` is the fast pull-request gate | | `PROPERTY_DERANDOMIZE` | Any value except `''`/`'0'` derives every unset seed from the property id, making a whole suite reproducible without editing it | | `PROPERTY_PATH` | A recorded shrink descent (`CounterExample::$path`) replayed instead of searched for. Needs the seed that produced it; an explicit `path()` wins | +| `PROPERTY_EDGE_CASES` | `mixin` or `none` (case-insensitive) — the numeric boundary bias for the whole suite, overriding `edgeCases()`. An unknown value throws | The corpus format is exactly the one `rasuvaeff/property-testing` 2.8 wrote — a corpus recorded under Testo (or under 2.x) replays here and vice versa. On diff --git a/README.ru.md b/README.ru.md index 20d8902..1f6048b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -110,6 +110,7 @@ Property falsified after 12 successful run(s); seed=7382910 | `phases(array)` | Какие стадии выполнять (`Phase::Examples`, `Corpus`, `Random`, `Shrink`); подмножество осознанно обменивает покрытие на время прогона | | `derandomize(bool)` | Выводит незаданный seed из id property вместо случайного; явный `seed()` всё равно побеждает | | `path(string)` | Воспроизводит записанный спуск shrink вместо повторного поиска; нужен seed того прогона | +| `edgeCases(EdgeCases)` | `None` выключает граничное смещение числовых генераторов — для property, которой края стоят только прогонов | | `output($stdout, $stderr)` | Перенаправляет отчёт распределения, предупреждение о discard'ах и verbose-трассу (используется тестами самого пакета) | ### Имя property (`id()`) @@ -162,6 +163,7 @@ $this->forAll(['values' => Gen::arrayOf(Gen::int())]) | `PROPERTY_PHASES` | Список стадий через запятую (`examples,corpus,random,shrink`, регистр не важен), перекрывающий `phases()`; неизвестное имя — исключение, а не пропуск стадии. `examples,corpus` — быстрый гейт для pull request | | `PROPERTY_DERANDOMIZE` | Любое значение, кроме `''`/`'0'`, выводит каждый незаданный seed из id property: весь сьют становится воспроизводимым без правки кода | | `PROPERTY_PATH` | Записанный спуск shrink (`CounterExample::$path`) воспроизводится вместо повторного поиска. Нужен seed того прогона; явный `path()` побеждает | +| `PROPERTY_EDGE_CASES` | `mixin` или `none` (регистр не важен) — граничное смещение для всего сьюта, перекрывает `edgeCases()`. Неизвестное значение — исключение | Формат корпуса — ровно тот, что писал `rasuvaeff/property-testing` 2.8: корпус, записанный под Testo (или под 2.x), реплеится здесь, и наоборот. При diff --git a/composer.json b/composer.json index 42d15d7..6fcd58e 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "require": { "php": "8.3 - 8.5", "phpunit/phpunit": "^11.5 || ^12.0 || ^13.0", - "rasuvaeff/property-testing-core": "^0.2" + "rasuvaeff/property-testing-core": "^0.3" }, "require-dev": { "ergebnis/composer-normalize": "^2.51", diff --git a/infection.json5 b/infection.json5 index 24f7135..a8738ea 100644 --- a/infection.json5 +++ b/infection.json5 @@ -7,7 +7,7 @@ "text": "php://stderr", "summary": "build/infection-summary.log" }, - // 146 of 154 mutants are killed; the remaining eight are equivalent + // 160 of 168 mutants are killed; the remaining eight are equivalent // (report wording and ordering that no observable behaviour distinguishes), // so 100 is unreachable without ignores — which are forbidden here. "minMsi": 90, diff --git a/llms.txt b/llms.txt index d249ce4..d31f152 100644 --- a/llms.txt +++ b/llms.txt @@ -64,6 +64,7 @@ public function shrinkBudgetMs(int $ms): self; // wall-clock budget for th public function phases(array $phases): self; // list: Examples, Corpus, Random, Shrink public function derandomize(bool $on = true): self; // unset seed derived from the property id public function path(string $path): self; // replay a recorded shrink descent; needs its seed +public function edgeCases(EdgeCases $mode): self; // Mixin (default) | None = no numeric boundary bias public function seed(int $seed): self; // pin the random phase; disables corpus replay public function maxShrinks(int $maxShrinks): self; // cap accepted shrink steps; 0 = no shrinking public function maxDiscards(int $maxDiscards): self; // discard budget; default runs * 10 @@ -112,6 +113,7 @@ final class SortPropertyTest extends TestCase | `PROPERTY_PHASES` | `examples,corpus,random,shrink` (comma-separated, case-insensitive) | Stages to run; OVERRIDES `phases()`; unknown name throws | | `PROPERTY_DERANDOMIZE` | anything but `''`/`'0'` | Unset seeds derived from the property id; `seed()`/`PROPERTY_SEED` still win | | `PROPERTY_PATH` | recorded `CounterExample::$path` | Replays that shrink descent; needs the seed of that run; explicit `path()` wins | +| `PROPERTY_EDGE_CASES` | `mixin` \| `none` (case-insensitive) | Numeric boundary bias; OVERRIDES `edgeCases()`; unknown value throws | ### Classification output diff --git a/src/PhpUnit/PropertyCheck.php b/src/PhpUnit/PropertyCheck.php index 3a81a19..647e9d2 100644 --- a/src/PhpUnit/PropertyCheck.php +++ b/src/PhpUnit/PropertyCheck.php @@ -11,6 +11,7 @@ use Rasuvaeff\PropertyTesting\PropertyListener; use Rasuvaeff\PropertyTesting\Runner\CallableTrialExecutor; use Rasuvaeff\PropertyTesting\Runner\CoverageFailed; +use Rasuvaeff\PropertyTesting\Runner\EdgeCases; use Rasuvaeff\PropertyTesting\Runner\FilesystemCorpus; use Rasuvaeff\PropertyTesting\Runner\GaveUp; use Rasuvaeff\PropertyTesting\Runner\Passed; @@ -70,6 +71,7 @@ final class PropertyCheck private ?ShrinkMode $shrink = null; private ?int $shrinkBudgetMs = null; private ?string $path = null; + private ?EdgeCases $edgeCases = null; private ?bool $derandomize = null; /** @var ?list */ @@ -234,6 +236,22 @@ public function derandomize(bool $derandomize = true): self return $this; } + /** + * Whether the numeric generators keep biasing toward their boundary values + * ({@see EdgeCases::Mixin}, the default) or generate uniformly + * ({@see EdgeCases::None}). + * + * Turn them off when the edges are what this property cannot use — a body + * discarding `0`, a range end that violates a precondition — so the + * discard budget stops paying for one run in five. + */ + public function edgeCases(EdgeCases $edgeCases): self + { + $this->edgeCases = $edgeCases; + + return $this; + } + /** * Replays the shrink descent of an earlier failure, as reported by * `CounterExample::$path`, instead of searching for it again. It needs the @@ -318,6 +336,7 @@ public function check(\Closure $property): void phases: $this->envPhases() ?? $this->phases, derandomize: $this->envDerandomize() ?? $this->derandomize ?? false, path: $this->path ?? $this->envPath(), + edgeCases: $this->envEdgeCases() ?? $this->edgeCases ?? EdgeCases::Mixin, ), examples: $this->examples, replayRegressions: $this->seed === null, @@ -463,6 +482,31 @@ private function envPhases(): ?array return $phases; } + /** + * `PROPERTY_EDGE_CASES` selects the numeric boundary bias for the whole + * suite: `mixin` (the default) or `none`, case-insensitive. It overrides + * the chain, like every other CI-facing knob, and an unknown value is an + * error rather than a silent fallback — a suite that quietly kept the bias + * it was told to drop would spend the discard budget it was trying to save. + */ + private function envEdgeCases(): ?EdgeCases + { + $env = getenv('PROPERTY_EDGE_CASES'); + + if ($env === false || $env === '') { + return null; + } + + return match (strtolower(trim($env))) { + 'mixin' => EdgeCases::Mixin, + 'none' => EdgeCases::None, + default => throw new \InvalidArgumentException(sprintf( + 'PROPERTY_EDGE_CASES must be one of mixin, none, got "%s"', + trim($env), + )), + }; + } + /** * `PROPERTY_DERANDOMIZE` (any value except '' and '0') derives every * unset seed from the property id, which makes a whole suite reproducible diff --git a/tests/EnvironmentParityTest.php b/tests/EnvironmentParityTest.php index d98bdbd..6b654f7 100644 --- a/tests/EnvironmentParityTest.php +++ b/tests/EnvironmentParityTest.php @@ -18,6 +18,7 @@ use Rasuvaeff\PropertyTesting\PhpUnit\Tests\Support\RecordingListener; use Rasuvaeff\PropertyTesting\PropertyViolationException; use Rasuvaeff\PropertyTesting\RegressionViolationException; +use Rasuvaeff\PropertyTesting\Runner\EdgeCases; use Rasuvaeff\PropertyTesting\Runner\Phase; /** @@ -473,6 +474,84 @@ public function testANamedPropertyWarnsAboutNothing(): void self::assertSame('', (string) stream_get_contents($stderr)); } + public function testEdgeCasesOffKeepsBoundaryValuesOutOfTheRun(): void + { + // The knob's whole purpose: a property that cannot use the edges would + // otherwise throw away one run in five. + self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::None))); + } + + public function testByDefaultTheBoundaryValuesAreStillGenerated(): void + { + self::assertGreaterThan(10, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check)); + } + + public function testPropertyEdgeCasesOverridesTheChain(): void + { + putenv('PROPERTY_EDGE_CASES=none'); + + self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::Mixin))); + } + + public function testPropertyEdgeCasesIgnoresSpacingAndCase(): void + { + putenv('PROPERTY_EDGE_CASES= NONE '); + + self::assertSame(0, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check)); + } + + public function testPropertyEdgeCasesCanTurnThemBackOn(): void + { + putenv('PROPERTY_EDGE_CASES=mixin'); + + self::assertGreaterThan(10, $this->edgesSeen(static fn(PropertyCheck $check): PropertyCheck => $check->edgeCases(EdgeCases::None))); + } + + public function testPropertyEdgeCasesRejectsAnUnknownValue(): void + { + // Spaces on purpose: the message quotes the trimmed value, so a + // reader sees what was compared rather than what was typed. + putenv('PROPERTY_EDGE_CASES= sometimes '); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PROPERTY_EDGE_CASES must be one of mixin, none, got "sometimes"'); + + $this->forAll(['value' => Gen::intBetween(0, 10)]) + ->check(static function (int $value): void { + self::assertGreaterThanOrEqual(0, $value); + }); + } + + /** + * How many runs generated an in-range boundary value. + * + * @param \Closure(PropertyCheck): PropertyCheck $configure + */ + private function edgesSeen(\Closure $configure): int + { + $listener = new RecordingListener(); + + $configure( + $this->forAll(['value' => Gen::intBetween(-1_000_000, 1_000_000)]) + ->id('parity::edge-cases') + ->runs(200) + ->seed(5) + ->listeners($listener), + )->check(static function (int $value): void { + self::assertIsInt($value); + }); + + $edges = 0; + + foreach ($listener->events as $event) { + if ($event instanceof RunStarted && in_array($event->arguments['value'] ?? null, [0, 1, -1, -1_000_000, 1_000_000], true)) { + ++$edges; + } + } + + return $edges; + } + /** * The seed the engine actually ran with, as the PropertyStarted event * reports it — the one observable that says what a seed knob decided. diff --git a/tests/Support/Env.php b/tests/Support/Env.php index 7955155..4fe89c2 100644 --- a/tests/Support/Env.php +++ b/tests/Support/Env.php @@ -72,6 +72,7 @@ public static function isolateProperty(): \Closure 'PROPERTY_PHASES' => null, 'PROPERTY_DERANDOMIZE' => null, 'PROPERTY_PATH' => null, + 'PROPERTY_EDGE_CASES' => null, ]); } }