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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 23 additions & 1 deletion infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
6 changes: 4 additions & 2 deletions src/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@
$nowNs = $this->timeSource->nowNs();
// round(), not (int): 1.015 * 1e9 is 1014999999.99…, and truncating
// made remainingMs() report 1014 right after asking for 1015.
$durationNs = round(max(0.0, $seconds) * 1_000_000_000.0);

Check warning on line 69 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "RoundingFamily": @@ @@ $nowNs = $this->timeSource->nowNs(); // round(), not (int): 1.015 * 1e9 is 1014999999.99…, and truncating // made remainingMs() report 1014 right after asking for 1015. - $durationNs = round(max(0.0, $seconds) * 1_000_000_000.0); + $durationNs = ceil(max(0.0, $seconds) * 1_000_000_000.0); $roomNs = \PHP_INT_MAX - $nowNs; $deadline = $durationNs >= (float) $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs;
$roomNs = \PHP_INT_MAX - $nowNs;
$deadline = $durationNs >= (float) $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs;

Check warning on line 71 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "GreaterThanOrEqualTo": @@ @@ // made remainingMs() report 1014 right after asking for 1015. $durationNs = round(max(0.0, $seconds) * 1_000_000_000.0); $roomNs = \PHP_INT_MAX - $nowNs; - $deadline = $durationNs >= (float) $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs; + $deadline = $durationNs > (float) $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs; return $this->withDeadline($deadline); }

Check warning on line 71 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ // made remainingMs() report 1014 right after asking for 1015. $durationNs = round(max(0.0, $seconds) * 1_000_000_000.0); $roomNs = \PHP_INT_MAX - $nowNs; - $deadline = $durationNs >= (float) $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs; + $deadline = $durationNs >= $roomNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs; return $this->withDeadline($deadline); }

return $this->withDeadline($deadline);
}
Expand Down Expand Up @@ -104,7 +104,7 @@
// round(), not (int): 0.892 * 1e9 is 891999999.99…, and truncating
// would leave the deadline a nanosecond later than asked.
$reserveNs = round($seconds * 1_000_000_000.0);
$deadline = $reserveNs >= (float) $this->deadlineNs ? 0 : $this->deadlineNs - (int) $reserveNs;

Check warning on line 107 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "GreaterThanOrEqualTo": @@ @@ // round(), not (int): 0.892 * 1e9 is 891999999.99…, and truncating // would leave the deadline a nanosecond later than asked. $reserveNs = round($seconds * 1_000_000_000.0); - $deadline = $reserveNs >= (float) $this->deadlineNs ? 0 : $this->deadlineNs - (int) $reserveNs; + $deadline = $reserveNs > (float) $this->deadlineNs ? 0 : $this->deadlineNs - (int) $reserveNs; return $this->withDeadline($deadline); }

Check warning on line 107 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ // round(), not (int): 0.892 * 1e9 is 891999999.99…, and truncating // would leave the deadline a nanosecond later than asked. $reserveNs = round($seconds * 1_000_000_000.0); - $deadline = $reserveNs >= (float) $this->deadlineNs ? 0 : $this->deadlineNs - (int) $reserveNs; + $deadline = $reserveNs >= $this->deadlineNs ? 0 : $this->deadlineNs - (int) $reserveNs; return $this->withDeadline($deadline); }

return $this->withDeadline($deadline);
}
Expand Down Expand Up @@ -254,7 +254,7 @@
$this->assertActive();
$nowNs = $this->timeSource->nowNs();
$durationNs = round(max(0.0, $seconds) * 1_000_000_000.0);
$endNs = $durationNs >= (float) (\PHP_INT_MAX - $nowNs) ? \PHP_INT_MAX : $nowNs + (int) $durationNs;

Check warning on line 257 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "GreaterThanOrEqualTo": @@ @@ $this->assertActive(); $nowNs = $this->timeSource->nowNs(); $durationNs = round(max(0.0, $seconds) * 1_000_000_000.0); - $endNs = $durationNs >= (float) (\PHP_INT_MAX - $nowNs) ? \PHP_INT_MAX : $nowNs + (int) $durationNs; + $endNs = $durationNs > (float) (\PHP_INT_MAX - $nowNs) ? \PHP_INT_MAX : $nowNs + (int) $durationNs; $targetNs = $this->deadlineNs === null ? $endNs : min($endNs, $this->deadlineNs); // A sleeper may return early: re-check the clock until the target.

Check warning on line 257 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "CastFloat": @@ @@ $this->assertActive(); $nowNs = $this->timeSource->nowNs(); $durationNs = round(max(0.0, $seconds) * 1_000_000_000.0); - $endNs = $durationNs >= (float) (\PHP_INT_MAX - $nowNs) ? \PHP_INT_MAX : $nowNs + (int) $durationNs; + $endNs = $durationNs >= \PHP_INT_MAX - $nowNs ? \PHP_INT_MAX : $nowNs + (int) $durationNs; $targetNs = $this->deadlineNs === null ? $endNs : min($endNs, $this->deadlineNs); // A sleeper may return early: re-check the clock until the target.
$targetNs = $this->deadlineNs === null ? $endNs : min($endNs, $this->deadlineNs);

// A sleeper may return early: re-check the clock until the target.
Expand All @@ -277,8 +277,10 @@
*
* @return \Generator<TKey, TValue, mixed, void>
*/
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');
}
Expand Down Expand Up @@ -349,7 +351,7 @@

// A deadline always has an owner; terminating an already terminated
// owner is a no-op.
$this->deadlineOwner?->terminate(TerminationCause::expired($this->deadlineNs));

Check warning on line 354 in src/Context.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "NullSafeMethodCall": @@ @@ // A deadline always has an owner; terminating an already terminated // owner is a no-op. - $this->deadlineOwner?->terminate(TerminationCause::expired($this->deadlineNs)); + $this->deadlineOwner->terminate(TerminationCause::expired($this->deadlineNs)); } /**
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/Testing/FakeTimeSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions tests/ContextDerivationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions tests/ContextLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions tests/ContextSleepAndIterateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions tests/ContextTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down
5 changes: 5 additions & 0 deletions tests/Testing/TestingClocksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading