From daf9410b933f1e4d659d0611de6b9659049bd8f4 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 15:45:28 +0100 Subject: [PATCH 1/5] feat(testing): add ExpectTestStatus, ExpectAssertionsCount, ExpectTestResultAttribute (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three PHP attributes allow a test that uses TestRunner::runTest() to assert properties of the inner stub TestResult without writing explicit assertion code: - #[ExpectTestStatus(Status::X)] — validates stub.status - #[ExpectAssertionsCount(N)] — validates stub.summary.metric('assertions') - #[ExpectTestResultAttribute(K)] — validates stub.getAttribute(K) is not null (repeatable) ExpectInterceptor reads these from the test method's reflection, runs the test, extracts outerResult->result as the stub TestResult, and converts any mismatches into a single Status::Failed with a combined message. Pre-existing outer failures are preserved untouched. Registered automatically via InjectPlugin. Co-Authored-By: Claude Sonnet 4.6 --- .../Attribute/ExpectAssertionsCount.php | 24 ++ .../Attribute/ExpectTestResultAttribute.php | 24 ++ core/Testing/Attribute/ExpectTestStatus.php | 24 ++ core/Testing/InjectPlugin.php | 2 + core/Testing/Internal/ExpectInterceptor.php | 98 ++++++++ .../Testing/Unit/ExpectInterceptorTest.php | 209 ++++++++++++++++++ 6 files changed, 381 insertions(+) create mode 100644 core/Testing/Attribute/ExpectAssertionsCount.php create mode 100644 core/Testing/Attribute/ExpectTestResultAttribute.php create mode 100644 core/Testing/Attribute/ExpectTestStatus.php create mode 100644 core/Testing/Internal/ExpectInterceptor.php create mode 100644 tests/Core/Testing/Unit/ExpectInterceptorTest.php diff --git a/core/Testing/Attribute/ExpectAssertionsCount.php b/core/Testing/Attribute/ExpectAssertionsCount.php new file mode 100644 index 00000000..1b68552b --- /dev/null +++ b/core/Testing/Attribute/ExpectAssertionsCount.php @@ -0,0 +1,24 @@ + $count Expected number of assertions. */ + public function __construct(public int $count) {} +} diff --git a/core/Testing/Attribute/ExpectTestResultAttribute.php b/core/Testing/Attribute/ExpectTestResultAttribute.php new file mode 100644 index 00000000..e691ad32 --- /dev/null +++ b/core/Testing/Attribute/ExpectTestResultAttribute.php @@ -0,0 +1,24 @@ +get(InterceptorCollector::class)->addInterceptor(InjectInterceptor::class); + $container->get(InterceptorCollector::class)->addInterceptor(new ExpectInterceptor()); } } diff --git a/core/Testing/Internal/ExpectInterceptor.php b/core/Testing/Internal/ExpectInterceptor.php new file mode 100644 index 00000000..f5985537 --- /dev/null +++ b/core/Testing/Internal/ExpectInterceptor.php @@ -0,0 +1,98 @@ +testDefinition->reflection; + + $statusAttrs = Reflection::fetchFunctionAttributes($reflection, attributeClass: ExpectTestStatus::class); + $countAttrs = Reflection::fetchFunctionAttributes($reflection, attributeClass: ExpectAssertionsCount::class); + $attrAttrs = Reflection::fetchFunctionAttributes($reflection, attributeClass: ExpectTestResultAttribute::class); + + if ($statusAttrs === [] && $countAttrs === [] && $attrAttrs === []) { + return $next($info); + } + + $outerResult = $next($info); + + // Pre-existing failure or non-terminal status — preserve as-is so the original error is + // not obscured by a misleading "expected TestResult" message. + if (!$outerResult->status->isCompleted() || $outerResult->status->isFailure()) { + return $outerResult; + } + + $stubResult = $outerResult->result; + if (!$stubResult instanceof TestResult) { + return $outerResult + ->with(status: Status::Failed) + ->withFailure(new \LogicException( + 'Test must return the TestResult from TestRunner::runTest() when using Expect* attributes, got ' + . \get_debug_type($stubResult), + )); + } + + $failures = []; + + if ($statusAttrs !== []) { + /** @var ExpectTestStatus $expect */ + $expect = $statusAttrs[0]->newInstance(); + if ($stubResult->status !== $expect->status) { + $failures[] = "Expected stub status {$expect->status->name}, got {$stubResult->status->name}"; + } + } + + if ($countAttrs !== []) { + /** @var ExpectAssertionsCount $expect */ + $expect = $countAttrs[0]->newInstance(); + $actual = $stubResult->summary->metric('assertions'); + if ($actual !== $expect->count) { + $failures[] = "Expected {$expect->count} assertion(s), got {$actual}"; + } + } + + foreach ($attrAttrs as $attr) { + /** @var ExpectTestResultAttribute $expect */ + $expect = $attr->newInstance(); + if ($stubResult->getAttribute($expect->name) === null) { + $failures[] = "Expected TestResult attribute '{$expect->name}' to be present"; + } + } + + if ($failures === []) { + return $outerResult; + } + + return $outerResult + ->with(status: Status::Failed) + ->withFailure(new \RuntimeException(\implode("\n", $failures))); + } +} diff --git a/tests/Core/Testing/Unit/ExpectInterceptorTest.php b/tests/Core/Testing/Unit/ExpectInterceptorTest.php new file mode 100644 index 00000000..262cde2e --- /dev/null +++ b/tests/Core/Testing/Unit/ExpectInterceptorTest.php @@ -0,0 +1,209 @@ + $inner; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same($result, $inner); + } + + public function passesWhenStubStatusMatchesExpected(): void + { + $info = self::createTestInfoFor('fixtureExpectFailed'); + $stub = new TestResult(info: $info, status: Status::Failed); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Passed, $result->status); + } + + public function failsWhenStubStatusDoesNotMatchExpected(): void + { + $info = self::createTestInfoFor('fixtureExpectFailed'); + $stub = new TestResult(info: $info, status: Status::Passed); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Failed, $result->status); + Assert::instanceOf($result->failure, \RuntimeException::class); + } + + public function passesWhenAssertionCountMatches(): void + { + $info = self::createTestInfoFor('fixtureExpectThreeAssertions'); + $stub = new TestResult( + info: $info, + status: Status::Passed, + summary: new Summary(metrics: ['assertions' => 3]), + ); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Passed, $result->status); + } + + public function failsWhenAssertionCountDoesNotMatch(): void + { + $info = self::createTestInfoFor('fixtureExpectThreeAssertions'); + $stub = new TestResult( + info: $info, + status: Status::Passed, + summary: new Summary(metrics: ['assertions' => 2]), + ); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Failed, $result->status); + } + + public function passesWhenExpectedResultAttributeIsPresent(): void + { + $info = self::createTestInfoFor('fixtureExpectFooAttribute'); + $stub = (new TestResult(info: $info, status: Status::Passed)) + ->withAttribute('foo', 'bar'); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Passed, $result->status); + } + + public function failsWhenExpectedResultAttributeIsAbsent(): void + { + $info = self::createTestInfoFor('fixtureExpectFooAttribute'); + $stub = new TestResult(info: $info, status: Status::Passed); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Failed, $result->status); + } + + public function repeatableAttributeChecksAllKeys(): void + { + $info = self::createTestInfoFor('fixtureExpectTwoAttributes'); + // Only 'alpha' present; 'beta' is missing → should fail + $stub = (new TestResult(info: $info, status: Status::Passed)) + ->withAttribute('alpha', 1); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Failed, $result->status); + } + + public function preservesOuterFailureWithoutRunningValidation(): void + { + $info = self::createTestInfoFor('fixtureExpectFailed'); + $outer = new TestResult(info: $info, status: Status::Failed); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same($result, $outer); + } + + public function failsWithLogicExceptionWhenResultIsNotATestResult(): void + { + $info = self::createTestInfoFor('fixtureExpectFailed'); + $outer = new TestResult(info: $info, status: Status::Passed, result: 'not-a-test-result'); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Failed, $result->status); + Assert::instanceOf($result->failure, \LogicException::class); + } + + public function combinesMultipleViolationsIntoSingleFailure(): void + { + $info = self::createTestInfoFor('fixtureExpectPassedWithThreeAssertions'); + // Stub is Failed with 1 assertion — both status and count fail + $stub = new TestResult( + info: $info, + status: Status::Failed, + summary: new Summary(metrics: ['assertions' => 1]), + ); + $outer = new TestResult(info: $info, status: Status::Passed, result: $stub); + $next = static fn(TestInfo $i): TestResult => $outer; + + $result = (new ExpectInterceptor())->runTest($info, $next); + + Assert::same(Status::Failed, $result->status); + } + + // ── Attribute fixtures ──────────────────────────────────────────────────── + + private function noAttributes(): void {} + + #[ExpectTestStatus(Status::Failed)] + private function fixtureExpectFailed(): void {} + + #[ExpectAssertionsCount(3)] + private function fixtureExpectThreeAssertions(): void {} + + #[ExpectTestResultAttribute('foo')] + private function fixtureExpectFooAttribute(): void {} + + #[ExpectTestResultAttribute('alpha')] + #[ExpectTestResultAttribute('beta')] + private function fixtureExpectTwoAttributes(): void {} + + #[ExpectTestStatus(Status::Passed)] + #[ExpectAssertionsCount(3)] + private function fixtureExpectPassedWithThreeAssertions(): void {} + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static function createTestInfoFor(string $method): TestInfo + { + $reflection = new \ReflectionMethod(self::class, $method); + $caseDefinition = new CaseDefinition(name: 'ExpectInterceptorTest', type: 'test'); + $caseInfo = new CaseInfo(definition: $caseDefinition); + $testDefinition = new TestDefinition(reflection: $reflection); + + return new TestInfo( + name: $method, + caseInfo: $caseInfo, + testDefinition: $testDefinition, + ); + } +} From 1b25f440f4595b6e9f00270610928c356bac2ce1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:46:14 +0000 Subject: [PATCH 2/5] style(cs): apply php-cs-fixer --- core/Testing/Attribute/ExpectAssertionsCount.php | 4 +++- core/Testing/Attribute/ExpectTestResultAttribute.php | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/Testing/Attribute/ExpectAssertionsCount.php b/core/Testing/Attribute/ExpectAssertionsCount.php index 1b68552b..682af4cc 100644 --- a/core/Testing/Attribute/ExpectAssertionsCount.php +++ b/core/Testing/Attribute/ExpectAssertionsCount.php @@ -19,6 +19,8 @@ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] final readonly class ExpectAssertionsCount { - /** @param int<0, max> $count Expected number of assertions. */ + /** + * @param int<0, max> $count Expected number of assertions. + */ public function __construct(public int $count) {} } diff --git a/core/Testing/Attribute/ExpectTestResultAttribute.php b/core/Testing/Attribute/ExpectTestResultAttribute.php index e691ad32..bf323219 100644 --- a/core/Testing/Attribute/ExpectTestResultAttribute.php +++ b/core/Testing/Attribute/ExpectTestResultAttribute.php @@ -19,6 +19,8 @@ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)] final readonly class ExpectTestResultAttribute { - /** @param non-empty-string $name Attribute key to look up, typically a class-string. */ + /** + * @param non-empty-string $name Attribute key to look up, typically a class-string. + */ public function __construct(public string $name) {} } From 84da943d23100adc7dd5af5541993fe12e22c5d6 Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 15:55:33 +0100 Subject: [PATCH 3/5] fix(output): widen Style::dim to string; fix ChannelRenderer::formatTime return type Style::dim() worked correctly with any string (empty or not) but declared @param non-empty-string, which Psalm flagged at every call site where a plain string was passed. Removed the over-restrictive annotation. ChannelRenderer::formatTime() claimed @return non-empty-string with a /** @var non-empty-string */ inline cast, which Psalm 7 does not accept. Replaced date() with integer arithmetic + sprintf so the implementation is cleaner, and removed the annotation since Psalm 7 does not narrow sprintf to non-empty-string for this version. Co-Authored-By: Claude Sonnet 4.6 --- core/Output/Rendering/ChannelRenderer.php | 12 ++++++------ core/Output/Terminal/Renderer/Style.php | 2 -- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/core/Output/Rendering/ChannelRenderer.php b/core/Output/Rendering/ChannelRenderer.php index 23007ecc..9dbfd820 100644 --- a/core/Output/Rendering/ChannelRenderer.php +++ b/core/Output/Rendering/ChannelRenderer.php @@ -91,15 +91,15 @@ private static function header(string $channel, float $time): string /** * Formats a {@see \microtime()} timestamp as `HH:MM:SS.mmm` wall-clock time. - * - * @return non-empty-string */ private static function formatTime(float $time): string { - $seconds = (int) $time; - $millis = \min(999, (int) \round(($time - (float) $seconds) * 1000.0)); + $totalSeconds = (int) $time; + $millis = \min(999, (int) \round(($time - (float) $totalSeconds) * 1000.0)); + $s = $totalSeconds % 60; + $m = (int) ($totalSeconds / 60) % 60; + $h = (int) ($totalSeconds / 3600) % 24; - /** @var non-empty-string */ - return \date('H:i:s', $seconds) . \sprintf('.%03d', $millis); + return \sprintf('%02d:%02d:%02d.%03d', $h, $m, $s, $millis); } } diff --git a/core/Output/Terminal/Renderer/Style.php b/core/Output/Terminal/Renderer/Style.php index b1e03b3d..46b4cc97 100644 --- a/core/Output/Terminal/Renderer/Style.php +++ b/core/Output/Terminal/Renderer/Style.php @@ -59,8 +59,6 @@ public static function bold(string $text): string /** * Makes text dim (less visible). - * - * @param non-empty-string $text */ public static function dim(string $text): string { From 27c0ee39374f7eef0f063d4bddba6c63d599e4aa Mon Sep 17 00:00:00 2001 From: Ross Addison Date: Mon, 6 Jul 2026 18:39:18 +0100 Subject: [PATCH 4/5] fix(phpunit-mirror): add .placeholder.php so EmptyRun stub directory is mirrored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/Application/Stub/EmptyRun/ is intentionally empty — it is the test fixture for EmptyRunTest, which asserts that a Testo run over an empty directory yields Status::Risky with zero tests collected. Git does not track empty directories, and bin/build-phpunit.php only copies *.php files when populating the tests/PhpUnit/ mirror, so the mirror never contained tests/PhpUnit/Application/Stub/EmptyRun/. The mirrored EmptyRunTest resolved __DIR__ . '/../../Stub/EmptyRun' to that missing path and threw InvalidArgumentException: File or directory not found — aborting Infection's initial PHPUnit test run on every CI push to 1.x. Add .placeholder.php (no namespace, no classes, no tests) to the source directory. The build script copies it verbatim into the mirror, which creates the required directory. Testo's FinderConfig still discovers zero tests there, so Status::Risky is reported and the assertion holds. Co-Authored-By: Claude Sonnet 5 --- tests/Application/Stub/EmptyRun/.placeholder.php | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/Application/Stub/EmptyRun/.placeholder.php diff --git a/tests/Application/Stub/EmptyRun/.placeholder.php b/tests/Application/Stub/EmptyRun/.placeholder.php new file mode 100644 index 00000000..72680edf --- /dev/null +++ b/tests/Application/Stub/EmptyRun/.placeholder.php @@ -0,0 +1,10 @@ + Date: Mon, 6 Jul 2026 18:56:45 +0100 Subject: [PATCH 5/5] test(codecov): attribute Expect* constructors to ExpectInterceptorTest coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExpectAssertionsCount, ExpectTestStatus, and ExpectTestResultAttribute each had 0% coverage per Codecov, despite ExpectInterceptorTest exercising every constructor via newInstance(). Testo's codecov plugin scopes coverage per test to the classes named in #[Covers(...)] on that test, so lines executed by a test are only credited to files the test explicitly declares — and this test only declared #[Covers(ExpectInterceptor::class)]. Add #[Covers(...)] for the three attribute classes so their already-exercised constructors are credited. --- tests/Core/Testing/Unit/ExpectInterceptorTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Core/Testing/Unit/ExpectInterceptorTest.php b/tests/Core/Testing/Unit/ExpectInterceptorTest.php index 262cde2e..5004a2fd 100644 --- a/tests/Core/Testing/Unit/ExpectInterceptorTest.php +++ b/tests/Core/Testing/Unit/ExpectInterceptorTest.php @@ -21,6 +21,9 @@ #[Test] #[Covers(ExpectInterceptor::class)] +#[Covers(ExpectTestStatus::class)] +#[Covers(ExpectAssertionsCount::class)] +#[Covers(ExpectTestResultAttribute::class)] final class ExpectInterceptorTest { public function passesThroughWhenNoExpectAttributesPresent(): void