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 { diff --git a/core/Testing/Attribute/ExpectAssertionsCount.php b/core/Testing/Attribute/ExpectAssertionsCount.php new file mode 100644 index 00000000..682af4cc --- /dev/null +++ b/core/Testing/Attribute/ExpectAssertionsCount.php @@ -0,0 +1,26 @@ + $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..bf323219 --- /dev/null +++ b/core/Testing/Attribute/ExpectTestResultAttribute.php @@ -0,0 +1,26 @@ +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/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 @@ + $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, + ); + } +}