From 702cabded076e7e82926ddf42884cf6f48aa65ed Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Sun, 1 Nov 2026 21:25:25 +0300 Subject: [PATCH] Count uncovered code in mutation testing and cover the gaps it exposed infection --with-uncovered now gates the build; PublicVisibility off and default-parameter signature lines ignored. Adds tests for value/key mismatch, default check interval, clock ceiling saturation, chained listener failures, fake clock default and reserve rounding. --- CHANGELOG.md | 9 +++++++++ composer.json | 5 ++++- infection.json5 | 24 ++++++++++++++++++++++- src/Context.php | 6 ++++-- src/Testing/FakeTimeSource.php | 4 +++- tests/ContextDerivationTest.php | 9 +++++++++ tests/ContextLifecycleTest.php | 27 ++++++++++++++++++++++++++ tests/ContextSleepAndIterateTest.php | 29 ++++++++++++++++++++++++++++ tests/ContextTest.php | 7 +++++++ tests/Testing/TestingClocksTest.php | 5 +++++ 10 files changed, 120 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4025bbd..9aa3b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +- Mutation testing now counts code the tests execute without naming it in `#[Covers]` + (`infection --with-uncovered`), with `PublicVisibility` off and default-parameter + signature lines ignored; tests added for value/key mismatch, default check + interval, clock ceiling saturation, chained listener failures, the fake clock + default and nanosecond reserve rounding. `iterate()` and `FakeTimeSource` + constructor signatures reformatted onto several lines (no behaviour change). + ## 0.3.0 — 2026-10-15 - Added: `Testing\FakeTimeSource` and `Testing\SteppingTimeSource` (#8). diff --git a/composer.json b/composer.json index a1042d7..8861056 100644 --- a/composer.json +++ b/composer.json @@ -70,7 +70,10 @@ ], "cs": "php-cs-fixer fix --dry-run --diff --config=.php-cs-fixer.php", "cs:fix": "php-cs-fixer fix --diff --config=.php-cs-fixer.php", - "mutation": "infection --threads=max", + "mutation": [ + "Composer\\Config::disableProcessTimeout", + "infection --threads=max --with-uncovered" + ], "psalm": "psalm --no-cache", "rector": "rector process --dry-run", "rector:fix": "rector process", diff --git a/infection.json5 b/infection.json5 index 1bb8b78..62ac683 100644 --- a/infection.json5 +++ b/infection.json5 @@ -8,8 +8,30 @@ "summary": "build/infection-summary.log" }, "testFramework": "testo", + // Survivors are equivalent: `>=` vs `>` and float casts on saturation boundaries + // where both branches yield the same value, unreachable `?->`/`LogicException` + // guards, `UsleepSleeper` microsecond arithmetic that no test can observe, and + // `ContextState` bookkeeping that only changes how much is walked. "minMsi": 90, "mutators": { - "@default": true + "@default": true, + // Constructor/method parameter declarations with a default value + // (`private int $x = 100,`, `bool $strict = false,`): the mutant sits on a + // signature line with no executable opcode under pcov, so it is reported Not + // Covered whatever the tests do. Only a type-prefixed `$name = default,` line + // matches; a body line cannot (no type before the variable). Do NOT add ^ or $: + // Infection wraps the pattern as ^-\s*REGEX$ over the removed diff line. + // Verified 2026-11-01 by diffing the survivor lists with and without it: + // bulkhead 6, circuit-breaker 13, json-ld 19 mutants removed, all of them + // such signature lines; Escaped and Timed Out unchanged. + "global-ignoreSourceCodeByRegex": [ + "(?:(?:private|protected|public|readonly)\\s+)*\\??[A-Za-z_\\\\][\\w\\\\|]*\\s+\\$\\w+\\s*=\\s*[^,;]+," + ], + // Off: with --with-uncovered a PublicVisibility mutant sits on a parameterless + // method's signature line, which has no executable opcode under pcov/xdebug, + // so it is reported Not Covered and can never be killed, whatever #[Covers] + // says. Measured on pinned-http-client 2026-11-01: 4 of 4 such mutants were + // Not Covered, none killed. + "PublicVisibility": false } } diff --git a/src/Context.php b/src/Context.php index 6b53cb2..67c143b 100644 --- a/src/Context.php +++ b/src/Context.php @@ -277,8 +277,10 @@ public function sleep(float $seconds): void * * @return \Generator */ - public function iterate(iterable $items, int $every = 1): \Generator - { + public function iterate( + iterable $items, + int $every = 1, + ): \Generator { if ($every < 1) { throw new \InvalidArgumentException('Check interval must be positive'); } diff --git a/src/Testing/FakeTimeSource.php b/src/Testing/FakeTimeSource.php index 105c3cc..511d151 100644 --- a/src/Testing/FakeTimeSource.php +++ b/src/Testing/FakeTimeSource.php @@ -16,7 +16,9 @@ */ final class FakeTimeSource implements TimeSource, Sleeper { - public function __construct(private int $nowNs = 1_000_000_000) {} + public function __construct( + private int $nowNs = 1_000_000_000, + ) {} #[\Override] public function nowNs(): int diff --git a/tests/ContextDerivationTest.php b/tests/ContextDerivationTest.php index 7ab7f8d..15b1ca0 100644 --- a/tests/ContextDerivationTest.php +++ b/tests/ContextDerivationTest.php @@ -37,6 +37,15 @@ public function aReserveMovesTheParentDeadlineEarlier(): void Assert::same($work->remainingMs(), 2_500.0); } + public function aReserveRoundsToTheNearestNanosecond(): void + { + $clock = new FakeTimeSource(); + [$request] = Context::background($clock)->withTimeout(3.0); + + Assert::same($request->withReserve(1.4e-9)[0]->deadlineNs(), 4_000_000_000 - 1); + Assert::same($request->withReserve(1.6e-9)[0]->deadlineNs(), 4_000_000_000 - 2); + } + public function aReserveWithoutAParentDeadlineIsACancellableBranch(): void { [$work, $controller] = Context::background()->withReserve(0.5); diff --git a/tests/ContextLifecycleTest.php b/tests/ContextLifecycleTest.php index d412d75..6fbd805 100644 --- a/tests/ContextLifecycleTest.php +++ b/tests/ContextLifecycleTest.php @@ -115,6 +115,33 @@ public function listenersAreReleasedOnTermination(): void Assert::same($this->listenerCount($context), 0); } + public function severalFailingListenersAreChainedInOneException(): void + { + [$context, $controller] = Context::background()->withCancel(); + foreach (['first', 'second', 'third'] as $message) { + $context->onTerminate(static function () use ($message): void { + throw new \DomainException($message); + }); + } + + try { + $controller->cancel(); + } catch (\RuntimeException $error) { + Assert::same($error->getMessage(), 'One or more context termination listeners failed'); + $second = $error->getPrevious(); + Assert::same($second?->getMessage(), 'Context termination listener failed'); + $first = $second?->getPrevious(); + Assert::same($first?->getMessage(), 'Context termination listener failed'); + $last = $first?->getPrevious(); + Assert::same($last?->getMessage(), 'third'); + Assert::null($last?->getPrevious()); + + return; + } + + throw new \LogicException('an aggregated failure was expected'); + } + public function aListenerOnATerminatedContextRunsAtOnce(): void { [$context, $controller] = Context::background()->withCancel(); diff --git a/tests/ContextSleepAndIterateTest.php b/tests/ContextSleepAndIterateTest.php index d060e2d..efbf59e 100644 --- a/tests/ContextSleepAndIterateTest.php +++ b/tests/ContextSleepAndIterateTest.php @@ -280,6 +280,35 @@ public function anInvalidCheckIntervalIsRejectedBeforeIterating(): void Context::background()->iterate([1], every: 0); } + public function iteratingChecksBeforeEveryElementByDefault(): void + { + $clock = new FakeTimeSource(); + [$context] = Context::background($clock)->withTimeout(0.0025); + $processed = []; + + try { + foreach ($context->iterate([1, 2, 3, 4, 5, 6]) as $item) { + $processed[] = $item; + $clock->advanceMs(1); + } + } catch (ContextExpiredException) { + Assert::same($processed, [1, 2, 3]); + + return; + } + + throw new \LogicException('an expiry was expected'); + } + + public function aSleepLongerThanTheRoomLeftSaturatesAtTheClockCeiling(): void + { + $clock = new FakeTimeSource(intdiv(\PHP_INT_MAX, 2)); + + Context::background($clock)->sleep(6.0e9); + + Assert::same($clock->nowNs(), \PHP_INT_MAX); + } + public function aCheckIntervalSkipsChecksBetweenElements(): void { $clock = new FakeTimeSource(); diff --git a/tests/ContextTest.php b/tests/ContextTest.php index f33fd89..7044ba8 100644 --- a/tests/ContextTest.php +++ b/tests/ContextTest.php @@ -9,6 +9,7 @@ use Rasuvaeff\Context\ContextExpiredException; use Rasuvaeff\Context\Testing\FakeTimeSource; use Rasuvaeff\Context\Tests\Support\RequestId; +use Rasuvaeff\Context\Tests\Support\TenantId; use Rasuvaeff\PropertyTesting\ArbitraryInterface; use Rasuvaeff\PropertyTesting\Classify; use Rasuvaeff\PropertyTesting\Gen; @@ -218,6 +219,12 @@ public function valuesAreNearestAndTyped(): void Assert::same($child->value(RequestId::class)?->value, 'child'); } + public function aValueThatIsNotAnInstanceOfItsKeyIsRejected(): void + { + Expect::exception(\InvalidArgumentException::class); + Context::background()->withValue(RequestId::class, new TenantId('acme')); + } + #[DataProvider('invalidTimeoutProvider')] public function invalidTimeoutsAreRejected(float $seconds): void { diff --git a/tests/Testing/TestingClocksTest.php b/tests/Testing/TestingClocksTest.php index 680cb02..c9e7823 100644 --- a/tests/Testing/TestingClocksTest.php +++ b/tests/Testing/TestingClocksTest.php @@ -30,6 +30,11 @@ public function aFakeClockMovesOnlyWhenAdvanced(): void Assert::same($clock->nowNs(), 5_000 + 10 + 1_500_000 + 7); } + public function aFakeClockStartsOneSecondIn(): void + { + Assert::same((new FakeTimeSource())->nowNs(), 1_000_000_000); + } + public function fractionalMillisecondsRoundToTheNearestNanosecond(): void { $clock = new FakeTimeSource(0);