diff --git a/bridge/rector/composer.json b/bridge/rector/composer.json index 671d72f2..3a5ef1a6 100644 --- a/bridge/rector/composer.json +++ b/bridge/rector/composer.json @@ -25,7 +25,7 @@ "require": { "php": ">=8.2", "internal/path": "^1.2", - "rector/rector": "^2.0" + "rector/rector": "^2.6.4" }, "require-dev": { "testo/assert": "^0.1.13", diff --git a/bridge/rector/src/Testing/Internal/Middleware/RectorFixtureInterceptor.php b/bridge/rector/src/Testing/Internal/Middleware/RectorFixtureInterceptor.php index 8881160f..16d95abd 100644 --- a/bridge/rector/src/Testing/Internal/Middleware/RectorFixtureInterceptor.php +++ b/bridge/rector/src/Testing/Internal/Middleware/RectorFixtureInterceptor.php @@ -52,7 +52,6 @@ public function runTest(TestInfo $info, callable $next): TestResult $results = []; $status = Status::Passed; - $error = null; try { // Resolved inside the try so a path-containment violation surfaces as a test error @@ -96,15 +95,25 @@ public function runTest(TestInfo $info, callable $next): TestResult } } } catch (\Throwable $e) { - $status = Status::Error; - $error = $e; + # The rule failed before a single fixture ran (path resolution or the Rector container + # build). Report it as one errored data set so every reporter surfaces it the way it does + # a failing fixture: the batch node itself is only opened and closed, never re-reported, + # so a failure left on it alone would vanish into a bare "error" count. + $num = \count($results); + $dsInfo = $info->with(identity: $info->identity->toDataSet(dataProvider: 0, dataSet: $num)); + $this->eventDispatcher->dispatch(new TestDataSetStarting($dsInfo, 'setup', null, $num)); + $result = new TestResult(info: $dsInfo, status: Status::Error, failure: $e); + $this->eventDispatcher->dispatch(new TestDataSetFinished($dsInfo, $result, 'setup', null, $num)); + $results[] = $result; + $status = Status::Failed; } if ($results === []) { + # No fixture matched: a risky no-op carrying an explanatory reason. $final = new TestResult( info: $info, - status: $status->isFailure() ? $status : Status::Risky, - result: $error ?? new \RuntimeException('No fixtures were found for this rule.'), + status: Status::Risky, + result: new \RuntimeException('No fixtures were found for this rule.'), ); } else { $multiple = new MultipleResult($results); diff --git a/bridge/rector/src/Testing/Internal/RectorRunner.php b/bridge/rector/src/Testing/Internal/RectorRunner.php index b46a37d0..3cd06ccb 100644 --- a/bridge/rector/src/Testing/Internal/RectorRunner.php +++ b/bridge/rector/src/Testing/Internal/RectorRunner.php @@ -55,10 +55,8 @@ public function __construct(Messenger $messenger, array $rules) } # Mirror AbstractRectorTestCase: hand the freshly-registered rules to the traverser. - # `tagged()` returns a Traversable (Illuminate RewindableGenerator); avoid naming the - # php-scoper-prefixed class and just iterate it. - $tagged = $rectorConfig->tagged(RectorInterface::class); - $rectors = \is_iterable($tagged) ? \iterator_to_array($tagged, false) : []; + # `findByContract()` returns them as a plain, 0-indexed list. + $rectors = $rectorConfig->findByContract(RectorInterface::class); $rectorConfig->make(RectorNodeTraverser::class)->refreshPhpRectors($rectors); $this->fileProcessor = $rectorConfig->make(ApplicationFileProcessor::class); diff --git a/bridge/rector/tests/Unit/Fixture/EscapingFixturesRule.php b/bridge/rector/tests/Unit/Fixture/EscapingFixturesRule.php new file mode 100644 index 00000000..24af1e99 --- /dev/null +++ b/bridge/rector/tests/Unit/Fixture/EscapingFixturesRule.php @@ -0,0 +1,18 @@ + new TestResult(info: $i, status: Status::Passed); + + $result = $interceptor->runTest($info, $next); + + Assert::same($result->status, Status::Failed); + + $multiple = $result->getAttribute(MultipleResult::class); + Assert::true($multiple instanceof MultipleResult); + Assert::count($multiple->results, 1); + + $errored = $multiple->results[0]; + Assert::same($errored->status, Status::Error); + Assert::instanceOf($errored->failure, \LogicException::class); + + # The failure rode a data set node, which is what makes every reporter render it. + $started = \array_filter( + $dispatcher->dispatched, + static fn(object $e): bool => $e instanceof TestDataSetStarting, + ); + Assert::count($started, 1); + } + + private static function infoFor(string $ruleClass): TestInfo + { + $class = new \ReflectionClass($ruleClass); + $caseDefinition = new CaseDefinition( + name: $class->getShortName(), + type: 'rector-fixture', + file: Path::create((string) $class->getFileName()), + reflection: $class, + ); + $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Bridge/Rector'), definition: $caseDefinition); + $testDefinition = new TestDefinition(reflection: new \ReflectionMethod($ruleClass, 'fixture')); + + return new TestInfo(name: 'fixture', caseInfo: $caseInfo, testDefinition: $testDefinition); + } + + /** + * @return EventDispatcherInterface&object{dispatched: list} + */ + private static function createDispatcher(): EventDispatcherInterface + { + return new class() implements EventDispatcherInterface { + /** @var list */ + public array $dispatched = []; + + #[\Override] + public function dispatch(object $event): object + { + $this->dispatched[] = $event; + return $event; + } + }; + } + + /** A messenger that is never touched on the setup-failure path; any call is a test bug. */ + private static function createMessenger(): Messenger + { + return new class() implements Messenger { + #[\Override] + public function log(string $channel, string $content, Level $level = Level::Info, array $context = []): void + { + throw new \LogicException('messenger must not be used on the setup-failure path'); + } + + #[\Override] + public function channel(string $name): Channel + { + throw new \LogicException('messenger must not be used on the setup-failure path'); + } + + #[\Override] + public function scope(\Closure $scope, ?TestIdentity $identity = null): mixed + { + throw new \LogicException('messenger must not be used on the setup-failure path'); + } + + #[\Override] + public function fork(\Closure $fork, bool $holdEvents = false): mixed + { + throw new \LogicException('messenger must not be used on the setup-failure path'); + } + + #[\Override] + public function getMessages(): MessageLog + { + return new MessageLog(); + } + }; + } +} diff --git a/composer.json b/composer.json index 0df8314b..521c12e0 100644 --- a/composer.json +++ b/composer.json @@ -59,7 +59,7 @@ "llm/skills": "^1.3", "php-vcr/php-vcr": "^1.6", "roxblnfk/unpoly": "1.8.2", - "rector/rector": "^2.5.2", + "rector/rector": "^2.6.4", "testo/bridge-infection": "^0.1.8", "testo/bridge-mockery": "^0.1.2", "testo/bridge-rector": "^0.2.4", diff --git a/plugin/assert/tests/Self/AssertContains.php b/plugin/assert/tests/Self/AssertContains.php new file mode 100644 index 00000000..1734d508 --- /dev/null +++ b/plugin/assert/tests/Self/AssertContains.php @@ -0,0 +1,40 @@ +withMessageContaining('`3` not found'); + Assert::contains([1, 2], 3); + } + + public function looseMatchIsNotEnough(): never + { + // Search is strict (===), so a numeric string does not match an int. + Expect::exception(AssertionException::class) + ->withMessageContaining('`"2"` not found'); + Assert::contains([1, 2, 3], '2'); + } +} diff --git a/plugin/assert/tests/Self/AssertFail.php b/plugin/assert/tests/Self/AssertFail.php new file mode 100644 index 00000000..eae74583 --- /dev/null +++ b/plugin/assert/tests/Self/AssertFail.php @@ -0,0 +1,30 @@ +withMessageContaining('deliberate failure'); + Assert::fail('deliberate failure'); + } +} diff --git a/plugin/assert/tests/Self/AssertFalse.php b/plugin/assert/tests/Self/AssertFalse.php new file mode 100644 index 00000000..e68d9e8e --- /dev/null +++ b/plugin/assert/tests/Self/AssertFalse.php @@ -0,0 +1,38 @@ +withMessageContaining('expected `false`, got `true`'); + Assert::false(true); + } + + public function falsyButNotFalseFails(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('expected `false`, got `0`'); + Assert::false(0); + } +} diff --git a/plugin/assert/tests/Self/AssertJson.php b/plugin/assert/tests/Self/AssertJson.php index 3a1081b1..77a6d452 100644 --- a/plugin/assert/tests/Self/AssertJson.php +++ b/plugin/assert/tests/Self/AssertJson.php @@ -53,6 +53,46 @@ public function matchesType(string $json, string $type): void Assert::json($json)->matchesType($type); } + /** + * One matching JSON value per distinct type arm the matcher supports. + */ + public static function typeArms(): iterable + { + yield 'true literal' => ['true', 'true']; + yield 'false literal' => ['false', 'false']; + yield 'mixed accepts anything' => ['[1, 2]', 'mixed']; + yield 'scalar int' => ['42', 'scalar']; + yield 'scalar null' => ['null', 'scalar']; + yield 'numeric int' => ['42', 'numeric']; + yield 'numeric float' => ['3.14', 'numeric']; + yield 'numeric string' => ['"42"', 'numeric']; + yield 'class-string' => ['"AnyClass"', 'class-string']; + + yield 'plain array on object' => ['{"a": 1}', 'array']; + yield 'plain array on list' => ['[1, 2]', 'array']; + yield 'plain array empty' => ['{}', 'array']; + yield 'non-empty-array on object' => ['{"a": 1}', 'non-empty-array']; + yield 'non-empty-array on list' => ['[1]', 'non-empty-array']; + yield 'plain list' => ['[1, 2]', 'list']; + yield 'non-empty plain list' => ['[1, 2]', 'non-empty-list']; + + yield 'generic map key+value' => ['{"a": 1, "b": 2}', 'array']; + yield 'generic array value only' => ['[1, 2]', 'array']; + yield 'non-empty-list generic' => ['[1, 2]', 'non-empty-list']; + yield 'non-empty generic map' => ['{"a": 1}', 'non-empty-array']; + + yield 'empty shape' => ['{}', 'array{}']; + yield 'integer shape key on list' => ['[42]', 'array{0: int}']; + yield 'non-empty shape' => ['{"a": 1}', 'non-empty-array{a: int}']; + } + + #[Test] + #[DataProvider('typeArms')] + public function matchesTypeArms(string $json, string $type): void + { + Assert::json($json)->matchesType($type); + } + /** * @param non-empty-string $json * @param non-empty-string $type @@ -61,12 +101,45 @@ public function matchesType(string $json, string $type): void #[DataSet(['42', 'string'], 'int is not string')] #[DataSet(['"hello"', 'int'], 'string is not int')] #[DataSet(['{"id": 1}', 'array{id: non-empty-string}'], 'shape value type mismatch')] + #[DataSet(['true', 'int|string'], 'union all arms fail')] + #[DataSet(['{"a": 1}', 'list'], 'object is not a list')] + #[DataSet(['[]', 'non-empty-list'], 'empty non-empty-list')] + #[DataSet(['["a"]', 'list'], 'list element type mismatch')] + #[DataSet(['42', 'array'], 'scalar is not a map')] + #[DataSet(['[]', 'non-empty-array'], 'empty non-empty map')] + #[DataSet(['{"a": 1}', 'array'], 'generic key type mismatch')] + #[DataSet(['[1]', 'array'], 'generic value type mismatch')] + #[DataSet(['42', 'array{id: int}'], 'scalar is not a shape')] + #[DataSet(['{}', 'non-empty-array{id?: int}'], 'empty non-empty shape')] + #[DataSet(['{}', 'array{id: int}'], 'shape required key missing')] + #[DataSet(['42', 'array'], 'scalar is not array')] + #[DataSet(['{"a": 1}', 'list'], 'object is not a plain list')] + #[DataSet(['[]', 'non-empty-list'], 'empty non-empty plain list')] + #[DataSet(['[]', 'non-empty-array'], 'empty array is not non-empty')] + #[DataSet(['{}', 'non-empty-array'], 'empty object is not non-empty')] public function matchesTypeFails(string $json, string $type): never { Expect::exception(AssertionException::class); Assert::json($json)->matchesType($type); } + /** + * Malformed type expressions are a programmer error: the parser rejects them + * with an {@see \InvalidArgumentException}, not an assertion failure. + */ + #[Test] + #[DataSet(['int)'], 'trailing characters')] + #[DataSet(['unknown'], 'unknown type name')] + #[DataSet(['intx'], 'keyword without word boundary')] + #[DataSet(['array{?: int}'], 'missing shape key')] + #[DataSet(['int'], 'non-numeric range bound')] + #[DataSet(['int<0 5>'], 'missing comma in range')] + public function invalidTypeExpression(string $type): never + { + Expect::exception(\InvalidArgumentException::class); + Assert::json('42')->matchesType($type); + } + /** * @see \Testo\Assert\Api\Json\JsonStructure * @see \Testo\Assert\Api\Json\JsonArray @@ -285,4 +358,115 @@ public function matchesTypeOptionalShape(): void Assert::json('{"id": 1, "name": "test"}') ->matchesType('array{id: int, name?: string}'); } + + /** + * Schema validation is an unimplemented stub: it raises a plain + * {@see \LogicException}, not an assertion failure. + */ + #[Test] + public function matchesSchemaNotImplemented(): never + { + Expect::exception(\LogicException::class) + ->withMessageContaining('Not implemented yet'); + Assert::json('{}')->matchesSchema('{"type": "object"}'); + } + + /** + * An empty key list is a vacuous requirement: the call is a no-op that + * stays chainable. + */ + #[Test] + public function hasKeysEmptyIsNoop(): void + { + Assert::json('{"id": 1}') + ->hasKeys([]) + ->hasKeys('id'); + } + + /** + * Keys can be checked against the numeric indices of a JSON array. + */ + #[Test] + public function hasKeysOnArray(): void + { + Assert::json('["x", "y"]')->hasKeys(['0', '1']); + } + + /** + * A primitive has no keys, so any requested key is reported missing. + */ + #[Test] + public function hasKeysOnPrimitiveFails(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('missing key'); + Assert::json('42')->hasKeys('id'); + } + + #[Test] + public function assertPathUnclosedBracket(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('unclosed bracket in path'); + Assert::json('[1, 2, 3]') + ->assertPath('$[0', static fn(JsonAbstract $json) => $json->isPrimitive()); + } + + /** + * Bracketed keys may be quoted with single or double quotes; the quotes + * are stripped before lookup, allowing keys with spaces. + */ + #[Test] + public function assertPathQuotedKey(): void + { + $json = '{"first name": "Alice"}'; + + Assert::json($json) + ->assertPath("$['first name']", static fn(JsonAbstract $json) => $json + ->matchesType('non-empty-string')); + + Assert::json($json) + ->assertPath('$["first name"]', static fn(JsonAbstract $json) => $json + ->isPrimitive()); + } + + #[Test] + public function assertPathEmptyProperty(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('empty property name in path'); + Assert::json('{"a": 1}') + ->assertPath('$.', static fn(JsonAbstract $json) => $json->isPrimitive()); + } + + #[Test] + public function assertPathUnexpectedCharacter(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining("unexpected character 'x' in path"); + Assert::json('{"a": 1}') + ->assertPath('$x', static fn(JsonAbstract $json) => $json->isPrimitive()); + } + + #[Test] + public function assertPathArrayIndexMissing(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('not found in array'); + Assert::json('[1, 2, 3]') + ->assertPath('$[5]', static fn(JsonAbstract $json) => $json->isPrimitive()); + } + + /** + * Navigating a property off a primitive value is a path error, not a + * silent null. + */ + #[Test] + public function assertPathIntoPrimitive(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('cannot access property on int'); + Assert::json('{"a": 1}') + ->assertPath('$.a.b', static fn(JsonAbstract $json) => $json->isPrimitive()); + } } diff --git a/plugin/assert/tests/Self/AssertNotSame.php b/plugin/assert/tests/Self/AssertNotSame.php new file mode 100644 index 00000000..baa632b6 --- /dev/null +++ b/plugin/assert/tests/Self/AssertNotSame.php @@ -0,0 +1,33 @@ +withMessageContaining('both values are identical'); + Assert::notSame(1, 1); + } +} diff --git a/plugin/assert/tests/Self/AssertNull.php b/plugin/assert/tests/Self/AssertNull.php new file mode 100644 index 00000000..3e4b4e38 --- /dev/null +++ b/plugin/assert/tests/Self/AssertNull.php @@ -0,0 +1,38 @@ +withMessageContaining('expected `null`, got `42`'); + Assert::null(42); + } + + public function falsyButNotNullFails(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('expected `null`, got `0`'); + Assert::null(0); + } +} diff --git a/plugin/assert/tests/Self/AssertSame.php b/plugin/assert/tests/Self/AssertSame.php new file mode 100644 index 00000000..7644b830 --- /dev/null +++ b/plugin/assert/tests/Self/AssertSame.php @@ -0,0 +1,41 @@ +withMessageContaining('is the same as `2`'); + Assert::same(1, 2); + } + + public function looseEqualButNotIdenticalFails(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('expected `2`, got `"2"`'); + Assert::same('2', 2); + } +} diff --git a/plugin/assert/tests/Self/AssertTrue.php b/plugin/assert/tests/Self/AssertTrue.php new file mode 100644 index 00000000..b798659f --- /dev/null +++ b/plugin/assert/tests/Self/AssertTrue.php @@ -0,0 +1,38 @@ +withMessageContaining('expected `true`, got `false`'); + Assert::true(false); + } + + public function truthyButNotTrueFails(): never + { + Expect::exception(AssertionException::class) + ->withMessageContaining('expected `true`, got `1`'); + Assert::true(1); + } +} diff --git a/plugin/bench/src/Dto/Report/LowIterTime.php b/plugin/bench/src/Dto/Report/LowIterTime.php index 9c9a7a31..ad3c0963 100644 --- a/plugin/bench/src/Dto/Report/LowIterTime.php +++ b/plugin/bench/src/Dto/Report/LowIterTime.php @@ -7,7 +7,7 @@ use Testo\Bench\Dto\Report; /** - * Iter time < 10μs and RStDev✓ ≤ 10% + * Iter time < 10μs and RStDev✓ < 10% */ final readonly class LowIterTime extends Report { diff --git a/plugin/bench/src/Internal/Explanator.php b/plugin/bench/src/Internal/Explanator.php index b5844ba4..d29febbd 100644 --- a/plugin/bench/src/Internal/Explanator.php +++ b/plugin/bench/src/Internal/Explanator.php @@ -119,7 +119,7 @@ private static function explain(CaseResult $caseResult, CaseSet $caseSet): array }; // Preventive: low iter time with acceptable RStDev - $iterTime < 10.0 && $frstdev <= 10.0 + $iterTime < 10.0 && $frstdev < 10.0 and $result[] = new Report\LowIterTime($iterTime); return $result; diff --git a/plugin/bench/tests/Unit/BenchRecommendationsTest.php b/plugin/bench/tests/Unit/BenchRecommendationsTest.php index 895519c2..18f2eec0 100644 --- a/plugin/bench/tests/Unit/BenchRecommendationsTest.php +++ b/plugin/bench/tests/Unit/BenchRecommendationsTest.php @@ -54,6 +54,16 @@ public function distinctAdviceStaysSeparate(): void Assert::same(\substr_count($out, 'Increase iterations or isolate side effects.'), 1); } + public function identicalCausesAppearOnce(): void + { + $out = Renderer::recommendations(self::resultWith( + new Report\InsufficientIterTime(15.0, 5.0), + new Report\InsufficientIterTime(20.0, 6.0), + )); + + Assert::same(\substr_count($out, 'Insufficient iter time'), 1); + } + public function noReportsRenderNothing(): void { Assert::same(Renderer::recommendations(self::resultWith()), ''); diff --git a/plugin/bench/tests/Unit/BenchTableTest.php b/plugin/bench/tests/Unit/BenchTableTest.php index 82fc52f4..3bd24a55 100644 --- a/plugin/bench/tests/Unit/BenchTableTest.php +++ b/plugin/bench/tests/Unit/BenchTableTest.php @@ -9,10 +9,12 @@ use Testo\Bench\Dto\CaseResult; use Testo\Bench\Dto\CaseSet; use Testo\Bench\Dto\Line; +use Testo\Bench\Dto\Report; use Testo\Bench\Dto\Snap; use Testo\Bench\Dto\ValueRel; use Testo\Bench\Internal\Renderer; use Testo\Codecov\Covers; +use Testo\Data\DataSet; use Testo\Test; #[Test] @@ -38,6 +40,151 @@ public function callsAreAttributedPerCaseNotByName(): void Assert::string($slowRow)->contains('100'); } + public function noLinesRenderNothing(): void + { + Assert::same(Renderer::table(new BenchResult(cases: [], results: [], lines: [])), ''); + } + + public function rejectedResultsAddFilteredColumns(): void + { + $value = new ValueRel(100.0, 0.0); + $filtered = new ValueRel(90.0, 0.0); + $cases = [ + new CaseSet('alpha', [new Snap(10, 0, 1000.0), new Snap(10, 0, 1000.0)]), + new CaseSet('beta', [new Snap(10, 0, 1000.0), new Snap(10, 0, 1000.0)]), + ]; + $lines = [ + new Line( + place: 3, + name: 'alpha', + avg: $value, + med: $value, + rstdev: 5.0, + favg: $filtered, + frstdev: 2.0, + rejected: 7, + reports: [], + ), + new Line( + place: 4, + name: 'beta', + avg: $value, + med: $value, + rstdev: 5.0, + favg: $filtered, + frstdev: 2.0, + rejected: 0, + reports: [], + ), + ]; + + $out = Renderer::table(new BenchResult(cases: $cases, results: [], lines: $lines)); + $rows = \explode("\n", $out); + $alpha = self::rowContaining($rows, 'alpha'); + $beta = self::rowContaining($rows, 'beta'); + + Assert::string($out)->contains('FILTERED RESULTS'); + Assert::string($out)->contains('Rej.'); + Assert::string($out)->contains('Mean*'); + Assert::string($out)->contains('RStDev*'); + # Filtered mean and rstdev columns render for the rejected line. + Assert::string($alpha)->contains('90.00µs'); + Assert::string($alpha)->contains('±2.00%'); + Assert::string($alpha)->contains('7'); + # A line with no rejected outliers leaves its Rej. cell blank. + Assert::same(\substr_count($beta, '90.00µs'), 1); + # Ordinals for the rd and default-th suffix arms. + Assert::string($alpha)->contains('3rd'); + Assert::string($beta)->contains('4th'); + } + + public function warningAndDangerReportsAddSummaryColumns(): void + { + $value = new ValueRel(100.0, 0.0); + $cases = [new CaseSet('alpha', [new Snap(10, 0, 100.0)])]; + $line = new Line( + place: 1, + name: 'alpha', + avg: $value, + med: $value, + rstdev: 0.0, + favg: $value, + frstdev: 0.0, + rejected: 0, + reports: [ + new Report\InsufficientIterTime(15.0, 5.0), + new Report\NoisyEnvironment(15.0, 0), + new Report\HeavilySkewed(40.0), + new Report\LowIterTime(5.0), + ], + ); + + $out = Renderer::table(new BenchResult(cases: $cases, results: [], lines: [$line])); + + Assert::string($out)->contains('SUMMARY'); + Assert::string($out)->contains('Warnings'); + Assert::string($out)->contains('Dangers'); + # Same-severity reasons join with '. ' in their column. + Assert::string($out)->contains('Insufficient iter time. Noisy environment'); + Assert::string($out)->contains('Heavily skewed'); + } + + public function roundsWithNoCasesRenderNothing(): void + { + Assert::same(Renderer::rounds(new BenchResult(cases: [], results: [])), ''); + } + + public function roundsRendersEveryIterationWithNameOnFirstRowOnly(): void + { + $case = new CaseSet('bench', [ + new Snap(4, 2048, 20.0), + new Snap(4, 4096, 40.0), + ]); + + $out = Renderer::rounds(new BenchResult(cases: [$case], results: [])); + $rows = \explode("\n", $out); + + Assert::string($out)->contains('Iter'); + Assert::string($out)->contains('Time avg'); + Assert::string($out)->contains('Memory'); + # The case name labels only its first iteration row. + Assert::same(\count(\array_filter($rows, static fn(string $l): bool => \str_contains($l, 'bench'))), 1); + # Time avg is per-call: 20/4 and 40/4 microseconds. + Assert::string($out)->contains('5.00µs'); + Assert::string($out)->contains('10.00µs'); + Assert::string($out)->contains('2.00 KB'); + Assert::string($out)->contains('4.00 KB'); + } + + public function roundsRendersZeroTimeAndMemoryAsBareZero(): void + { + $case = new CaseSet('z', [new Snap(1, 0, 0.0)]); + + $out = Renderer::rounds(new BenchResult(cases: [$case], results: [])); + $row = self::rowContaining(\explode("\n", $out), 'z'); + + # Time, Time avg and Memory each collapse to a bare '0'. + Assert::same(\substr_count($row, '| 0 '), 3); + } + + #[DataSet([0.5, 512, '500.00ns', '512 B'], 'sub-microsecond -> ns, sub-kibibyte -> bytes')] + #[DataSet([5.0, 2048, '5.00µs', '2.00 KB'], 'microseconds and kibibytes')] + #[DataSet([5000.0, 3145728, '5.00ms', '3.00 MB'], 'milliseconds and mebibytes')] + #[DataSet([2000000.0, 2147483648, '2.00s', '2.00 GB'], 'seconds and gibibytes')] + public function roundsFormatsTimeAndMemoryMagnitudes( + float $time, + int $memory, + string $timeToken, + string $memoryToken, + ): void { + $case = new CaseSet('m', [new Snap(1, $memory, $time)]); + + $out = Renderer::rounds(new BenchResult(cases: [$case], results: [])); + + Assert::string($out)->contains($timeToken); + Assert::string($out)->contains($memoryToken); + } + /** * @param list $lines */ diff --git a/plugin/bench/tests/Unit/BenchToCaseSetsTest.php b/plugin/bench/tests/Unit/BenchToCaseSetsTest.php new file mode 100644 index 00000000..1c0bc461 --- /dev/null +++ b/plugin/bench/tests/Unit/BenchToCaseSetsTest.php @@ -0,0 +1,65 @@ +name, 'current'); + Assert::same([$sets[0]->iterations[0]->time, $sets[0]->iterations[1]->time], [10.0, 11.0]); + + Assert::same($sets[1]->name, 'alt'); + Assert::same([$sets[1]->iterations[0]->time, $sets[1]->iterations[1]->time], [20.0, 21.0]); + } + + public function aMissingNameFallsBackToTheStringifiedIndex(): void + { + $sets = self::toCaseSets( + [new IterationSet(1, [self::snap(10.0), self::snap(20.0)])], + ['current'], + ); + + Assert::same($sets[0]->name, 'current'); + Assert::same($sets[1]->name, '1'); + } + + /** + * @param list $iterations + * @param list $names + * @return list + */ + private static function toCaseSets(array $iterations, array $names): array + { + /** @var list */ + return (new \ReflectionMethod(BenchHandler::class, 'toCaseSets')) + ->invoke(null, $iterations, $names); + } + + private static function snap(float $time): Snap + { + return new Snap(calls: 1, memory: 0, time: $time); + } +} diff --git a/plugin/bench/tests/Unit/BenchVerdictTest.php b/plugin/bench/tests/Unit/BenchVerdictTest.php index 88125437..bf4def54 100644 --- a/plugin/bench/tests/Unit/BenchVerdictTest.php +++ b/plugin/bench/tests/Unit/BenchVerdictTest.php @@ -43,6 +43,29 @@ public function infiniteTolerancePassesEvenWhenCurrentIsSlowest(): void Assert::true($record->isSuccess()); } + public function currentExactlyAtTheToleranceBoundaryPasses(): void + { + $record = BenchHandler::benchmarkVerdict(self::results(1.1, 1.0), ['current', 'alt'], 0.1); + + Assert::true($record->isSuccess()); + } + + public function currentJustOverTheToleranceBoundaryFails(): void + { + $record = BenchHandler::benchmarkVerdict(self::results(1.11, 1.0), ['current', 'alt'], 0.1); + + Assert::false($record->isSuccess()); + Assert::string((string) $record)->contains("'alt' is 11.0% faster"); + } + + public function aZeroFilteredMeanForTheFastestReportsInfinitePercent(): void + { + $record = BenchHandler::benchmarkVerdict(self::results(1.0, 0.0), ['current', 'alt'], 0.02); + + Assert::false($record->isSuccess()); + Assert::string((string) $record)->contains("'alt' is INF% faster"); + } + /** * @return list Case results carrying only the filtered mean the verdict reads. */ diff --git a/plugin/bench/tests/Unit/ExplanatorTest.php b/plugin/bench/tests/Unit/ExplanatorTest.php new file mode 100644 index 00000000..3112e982 --- /dev/null +++ b/plugin/bench/tests/Unit/ExplanatorTest.php @@ -0,0 +1,164 @@ +> $expected + */ + #[DataSet([25.0, 5.0, 1, 0, 10, 5.0, [Report\UnreliableLowIterTime::class, Report\InsufficientIterTime::class]], 'rstdev>20 & low iter -> unreliable + insufficient')] + #[DataSet([25.0, 50.0, 1, 0, 10, 50.0, [Report\VeryHighVariance::class, Report\NoisyEnvironment::class]], 'rstdev>20 & normal iter -> very-high + noisy')] + #[DataSet([15.0, 5.0, 1, 0, 10, 5.0, [Report\HighVarianceLowIterTime::class, Report\InsufficientIterTime::class]], 'rstdev 10-20 & low iter -> high-low + insufficient')] + #[DataSet([15.0, 50.0, 1, 0, 10, 50.0, [Report\HighVariance::class, Report\NoisyEnvironment::class]], 'rstdev 10-20 & normal iter -> high + noisy')] + #[DataSet([20.0, 50.0, 1, 0, 10, 50.0, [Report\HighVariance::class, Report\NoisyEnvironment::class]], 'rstdev exactly 20 stays in the 10-20 band')] + #[DataSet([10.0, 50.0, 1, 0, 10, 50.0, [Report\HighVariance::class]], 'rstdev exactly 10 -> high variance, noisy needs >10 so it stays off')] + #[DataSet([10.0, 5.0, 1, 0, 10, 5.0, [Report\HighVarianceLowIterTime::class]], 'rstdev exactly 10 & low iter -> high-low only, low-iter-time notice stays off')] + #[DataSet([15.0, 50.0, 1, 3, 10, 50.0, [Report\HighVariance::class, Report\ExtremeOutlierRate::class]], 'outliers suppress the noisy-environment arm')] + #[DataSet([1.0, 50.0, 1, 3, 10, 50.0, [Report\ExtremeOutlierRate::class]], 'outliers>=3 & rate>20 -> extreme')] + #[DataSet([1.0, 50.0, 1, 3, 20, 50.0, [Report\TooManyOutliers::class]], 'outliers>=3 & rate 15 -> too-many')] + #[DataSet([1.0, 50.0, 1, 4, 20, 50.0, [Report\TooManyOutliers::class]], 'rate exactly 20 stays too-many, not extreme')] + #[DataSet([1.0, 50.0, 1, 3, 30, 50.0, [Report\TooManyOutliers::class]], 'rate exactly 10 is inclusive -> too-many')] + #[DataSet([1.0, 50.0, 1, 2, 4, 50.0, []], 'fewer than 3 absolute outliers never flags even at 50% rate')] + #[DataSet([1.0, 50.0, 1, 0, 10, 40.0, [Report\HeavilySkewed::class]], 'skew>10 -> heavily skewed')] + #[DataSet([1.0, 50.0, 1, 0, 10, 47.0, [Report\SkewedDistribution::class]], 'skew 5-10 -> skewed distribution')] + #[DataSet([1.0, 52.5, 1, 0, 10, 50.0, [Report\SkewedDistribution::class]], 'skew exactly 5 is inclusive -> skewed')] + #[DataSet([1.0, 55.0, 1, 0, 10, 50.0, [Report\SkewedDistribution::class]], 'skew exactly 10 stays skewed, not heavily')] + #[DataSet([1.0, 60.0, 1, 5, 20, 50.0, [Report\ExtremeOutlierRate::class, Report\HeavilySkewed::class, Report\BimodalBehavior::class]], 'extreme outliers + heavy skew -> bimodal compound')] + #[DataSet([1.0, 55.0, 1, 4, 20, 50.0, [Report\TooManyOutliers::class, Report\SkewedDistribution::class, Report\BimodalBehavior::class]], 'moderate outliers + skew -> bimodal compound')] + #[DataSet([1.0, 55.0, 1, 3, 30, 50.0, [Report\TooManyOutliers::class, Report\SkewedDistribution::class]], 'outlier rate exactly 10 keeps bimodal off (needs >10)')] + #[DataSet([5.0, 5.0, 1, 0, 10, 5.0, [Report\LowIterTime::class]], 'low iter time with acceptable variance -> notice')] + #[DataSet([5.0, 4.0, 2, 0, 10, 4.0, [Report\LowIterTime::class]], 'iter time is favg*calls: 4*2=8 stays under 10')] + #[DataSet([5.0, 4.0, 3, 0, 10, 4.0, []], 'iter time is favg*calls: 4*3=12 clears the threshold')] + #[DataSet([5.0, 50.0, 1, 0, 10, 50.0, []], 'healthy result -> no reports')] + #[DataSet([5.0, 50.0, 1, 0, 10, 0.0, []], 'zero median short-circuits skew to 0')] + public function explainEmitsExpectedReports( + float $frstdev, + float $favg, + int $calls, + int $rejected, + int $total, + float $med, + array $expected, + ): void { + $actual = \array_map( + static fn(Report $r): string => $r::class, + self::reportsFor($frstdev, $favg, $calls, $rejected, $total, $med), + ); + + Assert::same($actual, $expected); + } + + public function prepareLinesSortsByTimeRanksPlacesAndPairsNamesToTheRightCase(): void + { + # Declaration order is current, alpha, beta; favg makes alpha fastest and beta slowest. + $cases = [ + new CaseSet('current', [new Snap(1, 0, 0.0)]), + new CaseSet('alpha', [new Snap(1, 0, 0.0)]), + new CaseSet('beta', [new Snap(1, 0, 0.0)]), + ]; + $results = [ + new CaseResult(mean: 100.0, med: 100.0, rstdev: 2.0, rejected: 0, favg: 100.0, frstdev: 1.0), + new CaseResult(mean: 40.0, med: 50.0, rstdev: 3.0, rejected: 0, favg: 50.0, frstdev: 1.0), + new CaseResult(mean: 220.0, med: 200.0, rstdev: 4.0, rejected: 0, favg: 200.0, frstdev: 1.0), + ]; + + $lines = Explanator::prepareLines($cases, $results); + + # Lines come back in declaration order (key preserved), names paired to their own case. + Assert::same($lines[0]->name, 'current'); + Assert::same($lines[1]->name, 'alpha'); + Assert::same($lines[2]->name, 'beta'); + + # Place reflects the favg ranking, not declaration order. + Assert::same($lines[0]->place, 2); + Assert::same($lines[1]->place, 1); + Assert::same($lines[2]->place, 3); + + # Diffs are relative to the baseline (declaration index 0 = current), not the fastest. + Assert::same($lines[0]->avg->value, 100.0); + Assert::same($lines[0]->avg->diff, 0.0); + Assert::same($lines[0]->med->diff, 0.0); + Assert::same($lines[0]->favg->diff, 0.0); + + Assert::same($lines[1]->avg->value, 40.0); + Assert::same($lines[1]->avg->diff, -60.0); + Assert::same($lines[1]->med->diff, -50.0); + Assert::same($lines[1]->favg->diff, -50.0); + + Assert::same($lines[2]->avg->value, 220.0); + Assert::same($lines[2]->avg->diff, 120.0); + Assert::same($lines[2]->med->diff, 100.0); + Assert::same($lines[2]->favg->diff, 100.0); + + # Passthrough fields stay with their case. + Assert::same($lines[1]->rstdev, 3.0); + Assert::same($lines[2]->rstdev, 4.0); + Assert::same($lines[0]->reports, []); + Assert::same($lines[1]->reports, []); + Assert::same($lines[2]->reports, []); + } + + public function prepareLinesZeroBaselineYieldsZeroDiffsInsteadOfDividingByZero(): void + { + $cases = [ + new CaseSet('current', [new Snap(1, 0, 0.0)]), + new CaseSet('other', [new Snap(1, 0, 0.0)]), + ]; + $results = [ + new CaseResult(mean: 0.0, med: 0.0, rstdev: 0.0, rejected: 0, favg: 0.0, frstdev: 0.0), + new CaseResult(mean: 80.0, med: 80.0, rstdev: 1.0, rejected: 0, favg: 80.0, frstdev: 1.0), + ]; + + $lines = Explanator::prepareLines($cases, $results); + + # Baseline (current) is all zero, so every relative diff falls back to 0.0. + Assert::same($lines[1]->avg->value, 80.0); + Assert::same($lines[1]->avg->diff, 0.0); + Assert::same($lines[1]->med->diff, 0.0); + Assert::same($lines[1]->favg->diff, 0.0); + Assert::same($lines[0]->avg->diff, 0.0); + } + + /** + * @return list + */ + private static function reportsFor( + float $frstdev, + float $favg, + int $calls, + int $rejected, + int $total, + float $med, + ): array { + $result = new CaseResult( + mean: $favg, + med: $med, + rstdev: 0.0, + rejected: $rejected, + favg: $favg, + frstdev: $frstdev, + ); + $set = new CaseSet('probe', \array_fill(0, $total, new Snap($calls, 0, 0.0))); + + return Explanator::prepareLines([$set], [$result])[0]->reports; + } +} diff --git a/plugin/codecov/src/Report/CoberturaReport.php b/plugin/codecov/src/Report/CoberturaReport.php index a55af617..37529a36 100644 --- a/plugin/codecov/src/Report/CoberturaReport.php +++ b/plugin/codecov/src/Report/CoberturaReport.php @@ -255,9 +255,7 @@ private static function buildLineBranchMap(FileCoverage $fileCoverage): array $total = \count($branch->outHit); $covered = \count(\array_filter($branch->outHit)); - if (!isset($map[$line])) { - $map[$line] = [0, 0]; - } + $map[$line] ??= [0, 0]; $map[$line][0] += $total; $map[$line][1] += $covered; diff --git a/plugin/data/tests/Unit/Fixture/CombinatorTarget.php b/plugin/data/tests/Unit/Fixture/CombinatorTarget.php new file mode 100644 index 00000000..01fe2eb3 --- /dev/null +++ b/plugin/data/tests/Unit/Fixture/CombinatorTarget.php @@ -0,0 +1,53 @@ + */ + public static function letters(): array + { + return ['a' => [10], 'b' => [20]]; + } + + /** @return array */ + public static function numbers(): array + { + return ['x' => [1], 'y' => [2]]; + } + + /** @return array */ + public static function noRows(): array + { + return []; + } + + #[DataCross(new DataProvider('noRows'), new DataProvider('numbers'))] + public function crossedWithEmpty(int $a, int $b): void {} + + #[DataCross] + public function crossedNothing(): void {} + + #[DataZip(new DataProvider('letters'), new DataProvider('numbers'))] + public function zipped(int $a, int $b): void {} + + #[DataCross(new DataProvider('letters'), new DataProvider('numbers'))] + public function crossed(int $a, int $b): void {} + + #[DataUnion(new DataProvider('letters'), new DataProvider('numbers'))] + public function unioned(int $value): void {} +} diff --git a/plugin/data/tests/Unit/Fixture/InvalidProviderTarget.php b/plugin/data/tests/Unit/Fixture/InvalidProviderTarget.php new file mode 100644 index 00000000..6d0678da --- /dev/null +++ b/plugin/data/tests/Unit/Fixture/InvalidProviderTarget.php @@ -0,0 +1,37 @@ +runTest($info, static fn(TestInfo $i): TestResult => new TestResult(info: $i, status: Status::Passed)); } + /** + * A DataPointer selects one provider by index; the others are skipped entirely. + */ + public function dataPointerRunsOnlyTheSelectedProvider(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + // Provider #1 of MultiProviderTarget is secondProvider(): [[3], [4], [5]]. + $info = self::createTestInfo([DataPointer::class => new DataPointer(1, null)]); + $seen = []; + $next = static function (TestInfo $info) use (&$seen): TestResult { + $seen[] = $info->arguments; + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($seen, [[3], [4], [5]]); + Assert::same($result->status, Status::Passed); + $multiple = $result->getAttribute(MultipleResult::class); + Assert::true($multiple instanceof MultipleResult); + Assert::same(\count($multiple->results), 3); + } + + /** + * A DataPointer with a dataset index narrows the selected provider to a single data set. + */ + public function dataPointerRunsOnlyTheSelectedDataSet(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + // Provider #1, data set #1 → the second entry of [[3], [4], [5]] == [4]. + $info = self::createTestInfo([DataPointer::class => new DataPointer(1, 1)]); + $seen = []; + $next = static function (TestInfo $info) use (&$seen): TestResult { + $seen[] = $info->arguments; + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($seen, [[4]]); + $multiple = $result->getAttribute(MultipleResult::class); + Assert::true($multiple instanceof MultipleResult); + Assert::same(\count($multiple->results), 1); + } + + /** + * An exception thrown by the test body is caught per data set and turned into an + * Error result, without aborting the sibling data sets or propagating out of the batch. + */ + public function errorsFromTheTestBodyBecomePerDataSetErrorResults(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createTestInfo(); + $boom = new \RuntimeException('boom'); + $next = static function (TestInfo $info) use ($boom): TestResult { + throw $boom; + }; + + $result = $interceptor->runTest($info, $next); + + // All 6 data sets still ran and each produced an Error result carrying the throwable. + Assert::same($result->status, Status::Failed); + $multiple = $result->getAttribute(MultipleResult::class); + Assert::true($multiple instanceof MultipleResult); + Assert::same(\count($multiple->results), 6); + foreach ($multiple->results as $r) { + Assert::same($r->status, Status::Error); + Assert::same($r->failure, $boom); + } + } + + /** + * DataZip pairs the providers position by position, yielding as many data sets as the + * shorter provider and merging one argument from each into a single set. + */ + public function zipsProvidersInParallel(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(CombinatorTarget::class, 'zipped'); + $seen = []; + $next = static function (TestInfo $info) use (&$seen): TestResult { + $seen[] = $info->arguments; + return new TestResult(info: $info, status: Status::Passed); + }; + + $interceptor->runTest($info, $next); + + // letters [a=>[10], b=>[20]] zipped with numbers [x=>[1], y=>[2]]. + Assert::same($seen, [[10, 1], [20, 2]]); + } + + /** + * DataCross yields the cartesian product of the providers. + */ + public function crossesProvidersCartesian(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(CombinatorTarget::class, 'crossed'); + $seen = []; + $next = static function (TestInfo $info) use (&$seen): TestResult { + $seen[] = $info->arguments; + return new TestResult(info: $info, status: Status::Passed); + }; + + $interceptor->runTest($info, $next); + + Assert::same($seen, [[10, 1], [10, 2], [20, 1], [20, 2]]); + } + + /** + * A DataCross where one provider is empty yields no combinations, so the batch runs + * nothing and is reported as Risky. + */ + public function crossWithAnEmptyProviderExpandsToNothing(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(CombinatorTarget::class, 'crossedWithEmpty'); + $callCount = 0; + $next = static function (TestInfo $info) use (&$callCount): TestResult { + ++$callCount; + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($callCount, 0); + Assert::same($result->status, Status::Risky); + Assert::null($result->getAttribute(MultipleResult::class)); + } + + /** + * A DataCross with no providers yields no combinations either. + */ + public function crossWithNoProvidersExpandsToNothing(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(CombinatorTarget::class, 'crossedNothing'); + $callCount = 0; + $next = static function (TestInfo $info) use (&$callCount): TestResult { + ++$callCount; + return new TestResult(info: $info, status: Status::Passed); + }; + + $result = $interceptor->runTest($info, $next); + + Assert::same($callCount, 0); + Assert::same($result->status, Status::Risky); + } + + /** + * DataUnion concatenates the providers into one sequence. + */ + public function unionsProvidersSequentially(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(CombinatorTarget::class, 'unioned'); + $seen = []; + $next = static function (TestInfo $info) use (&$seen): TestResult { + $seen[] = $info->arguments; + return new TestResult(info: $info, status: Status::Passed); + }; + + $interceptor->runTest($info, $next); + + Assert::same($seen, [[10], [20], [1], [2]]); + } + + /** + * A DataProvider naming a method that does not exist (and is not a callable) is rejected. + */ + public function throwsWhenProviderMethodIsUnknown(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(InvalidProviderTarget::class, 'unknownMethod'); + $next = static fn(TestInfo $i): TestResult => new TestResult(info: $i, status: Status::Passed); + + $caught = null; + try { + $interceptor->runTest($info, $next); + } catch (\InvalidArgumentException $e) { + $caught = $e; + } + + Assert::notNull($caught); + Assert::same($caught->getMessage(), 'DataProvider provider must be a callable or method name string.'); + } + + /** + * A DataProvider whose method returns a non-iterable is rejected. + */ + public function throwsWhenProviderReturnsNonIterable(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(InvalidProviderTarget::class, 'scalarReturn'); + $next = static fn(TestInfo $i): TestResult => new TestResult(info: $i, status: Status::Passed); + + $caught = null; + try { + $interceptor->runTest($info, $next); + } catch (\InvalidArgumentException $e) { + $caught = $e; + } + + Assert::notNull($caught); + Assert::same($caught->getMessage(), 'Data provider must return an iterable of data sets.'); + } + + /** + * A data set that is not an array of arguments is rejected while iterating the provider. + */ + public function throwsWhenDataSetIsNotAnArray(): void + { + $interceptor = new DataProviderInterceptor(self::createDispatcher()); + $info = self::createInfoFor(InvalidProviderTarget::class, 'nonArrayDataSet'); + $next = static fn(TestInfo $i): TestResult => new TestResult(info: $i, status: Status::Passed); + + $caught = null; + try { + $interceptor->runTest($info, $next); + } catch (\InvalidArgumentException $e) { + $caught = $e; + } + + Assert::notNull($caught); + Assert::same($caught->getMessage(), 'Each data set must be an array of arguments.'); + } + private static function createDispatcher(): EventDispatcherInterface { return new class() implements EventDispatcherInterface { @@ -117,17 +347,31 @@ public function dispatch(object $event): object }; } - private static function createTestInfo(): TestInfo + /** + * @param array $attributes + */ + private static function createTestInfo(array $attributes = []): TestInfo + { + return self::createInfoFor(MultiProviderTarget::class, 'target', $attributes); + } + + /** + * @param class-string $class + * @param non-empty-string $method + * @param array $attributes + */ + private static function createInfoFor(string $class, string $method, array $attributes = []): TestInfo { - $reflection = new \ReflectionMethod(MultiProviderTarget::class, 'target'); + $reflection = new \ReflectionMethod($class, $method); $caseDefinition = new CaseDefinition(name: 'TestCase', type: 'test', file: Path::create(__FILE__)); $caseInfo = new CaseInfo(suiteIdentity: new SuiteIdentity('Data/Unit'), definition: $caseDefinition); $testDefinition = new TestDefinition(reflection: $reflection); return new TestInfo( - name: 'target', + name: $method, caseInfo: $caseInfo, testDefinition: $testDefinition, + attributes: $attributes, ); } diff --git a/tests/Application/Unit/Config/Internal/ConfigInflectorTest.php b/tests/Application/Unit/Config/Internal/ConfigInflectorTest.php new file mode 100644 index 00000000..bab481f3 --- /dev/null +++ b/tests/Application/Unit/Config/Internal/ConfigInflectorTest.php @@ -0,0 +1,381 @@ + 'changed']))->inflect($config, new ObjectContainer()); + + Assert::same($result, $config); + Assert::same($config->x, 'orig'); + } + + public function propertiesWithoutAConfigAttributeAreSkipped(): void + { + $config = new EnvConfig(); + + (new ConfigInflector(env: ['STR' => 'hello']))->inflect($config, new ObjectContainer()); + + Assert::same($config->plain, 'plain'); + Assert::same($config->str, 'hello'); + } + + public function missingSourceValueLeavesTheDefault(): void + { + $config = new EnvConfig(); + + (new ConfigInflector(env: []))->inflect($config, new ObjectContainer()); + + Assert::same($config->str, 'default'); + } + + #[DataSet(['string', 'S', 'hello', 'hello'], 'string keeps the raw value')] + #[DataSet(['int', 'I', '42', 42], 'int is cast')] + #[DataSet(['float', 'F', '3.5', 3.5], 'float is cast')] + #[DataSet(['bool', 'B', '1', true], 'bool is validated (truthy)')] + #[DataSet(['bool', 'B', 'off', false], 'bool is validated (falsy)')] + #[DataSet(['array', 'A', 'x,y,z', ['x', 'y', 'z']], 'string splits into an array')] + #[DataSet(['intEnum', 'IE', '2', IntEnum::Two], 'int-backed enum from backing value')] + #[DataSet(['stringEnum', 'SE', 'beta', StringEnum::Beta], 'string-backed enum from backing value')] + #[DataSet(['pureEnum', 'UE', 'bar', PureEnum::Bar], 'pure enum matched by case name')] + #[DataSet(['nullable', 'N', '', null], 'empty string becomes null for a nullable type')] + #[DataSet(['union', 'U', 'xyz', 'xyz'], 'union type keeps the raw value')] + public function envValueIsCoercedToThePropertyType(string $prop, string $env, string $raw, mixed $expected): void + { + $config = new TypedConfig(); + + (new ConfigInflector(env: [$env => $raw]))->inflect($config, new ObjectContainer()); + + Assert::same($config->$prop, $expected); + } + + public function inputOptionSource(): void + { + $config = new InputConfig(); + + (new ConfigInflector(inputOptions: ['opt' => 'from-option']))->inflect($config, new ObjectContainer()); + + Assert::same($config->option, 'from-option'); + } + + public function inputArgumentSource(): void + { + $config = new InputConfig(); + + (new ConfigInflector(inputArguments: ['arg' => 'from-argument']))->inflect($config, new ObjectContainer()); + + Assert::same($config->argument, 'from-argument'); + } + + public function phpIniSourceReadsAnExistingOption(): void + { + $config = new IniConfig(); + + (new ConfigInflector())->inflect($config, new ObjectContainer()); + + Assert::same($config->precision, \ini_get('precision')); + } + + public function phpIniSourceLeavesDefaultForAnUnknownOption(): void + { + $config = new IniConfig(); + + (new ConfigInflector())->inflect($config, new ObjectContainer()); + + Assert::same($config->unknown, 'untouched'); + } + + public function customConfigAttributeYieldsNoValue(): void + { + $config = new CustomAttrConfig(); + + (new ConfigInflector())->inflect($config, new ObjectContainer()); + + Assert::same($config->value, 'untouched'); + } + + public function xPathReadsAMatchingNode(): void + { + $xml = ''; + $config = new XPathConfig(); + + (new ConfigInflector(xml: $xml))->inflect($config, new ObjectContainer()); + + Assert::count($config->tag, 1); + Assert::same((string) $config->tag[0], 'hello'); + } + + public function xPathReturnsNothingWhenTheKeyIsOutOfRange(): void + { + $xml = ''; + $config = new XPathConfig(); + + (new ConfigInflector(xml: $xml))->inflect($config, new ObjectContainer()); + + Assert::same($config->missing, []); + } + + public function xPathEmbedHydratesANestedObject(): void + { + $xml = ''; + $config = new EmbedConfig(); + + (new ConfigInflector(xml: $xml))->inflect($config, new ObjectContainer()); + + Assert::instanceOf($config->server, ServerFixture::class); + Assert::same((string) $config->server->host, 'localhost'); + } + + public function xPathEmbedYieldsNullWhenNoXmlIsConfigured(): void + { + $config = new EmbedConfig(); + + (new ConfigInflector())->inflect($config, new ObjectContainer()); + + Assert::null($config->server); + } + + public function xPathEmbedYieldsNullWhenNoElementMatches(): void + { + $config = new EmbedConfig(); + + (new ConfigInflector(xml: ''))->inflect($config, new ObjectContainer()); + + Assert::null($config->server); + } + + public function xPathEmbedListHydratesEachMatchingElement(): void + { + $xml = ''; + $config = new EmbedListConfig(); + + (new ConfigInflector(xml: $xml))->inflect($config, new ObjectContainer()); + + Assert::count($config->plugins, 2); + Assert::same((string) $config->plugins[0]->name, 'a'); + Assert::same((string) $config->plugins[1]->name, 'b'); + } + + public function xPathEmbedListYieldsEmptyWhenNoXmlIsConfigured(): void + { + $config = new EmbedListConfig(); + + (new ConfigInflector())->inflect($config, new ObjectContainer()); + + Assert::same($config->plugins, []); + } + + public function xPathEmbedListWithAnInvalidExpressionIsSwallowed(): void + { + $config = new BadEmbedListConfig(); + + (new ConfigInflector(xml: ''))->inflect($config, new ObjectContainer()); + + Assert::same($config->plugins, []); + } + + public function injectionFailureIsSwallowedWhenNoReporterIsAvailable(): void + { + $config = new EnumConfig(); + + // Container has no ErrorReporter binding: resolving it fails, and the failure is ignored. + (new ConfigInflector(env: ['UE' => 'not-a-case']))->inflect($config, new ObjectContainer()); + + Assert::same($config->pureEnum, PureEnum::Foo); + } + + public function injectionFailureIsReportedThroughTheContainerErrorReporter(): void + { + $dispatcher = new class implements EventDispatcherInterface { + public function dispatch(object $event): object + { + return $event; + } + }; + $hub = new MessengerHub($dispatcher); + $container = new ObjectContainer(); + $container->set(new ErrorReporter($hub), ErrorReporter::class); + + $config = new EnumConfig(); + (new ConfigInflector(env: ['UE' => 'not-a-case']))->inflect($config, $container); + + Assert::same($config->pureEnum, PureEnum::Foo); + Assert::true($hub->getMessages()->all() !== [], 'the failure is reported into the messenger'); + } +} + +enum IntEnum: int +{ + case Zero = 0; + case Two = 2; +} + +enum StringEnum: string +{ + case Alpha = 'alpha'; + case Beta = 'beta'; +} + +enum PureEnum +{ + case Foo; + case Bar; +} + +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final class CustomAttr implements ConfigAttribute {} + +final class PlainConfig +{ + #[Env('X')] + public string $x = 'orig'; +} + +#[InflectableConfig] +final class EnvConfig +{ + #[Env('STR')] + public string $str = 'default'; + + public string $plain = 'plain'; +} + +#[InflectableConfig] +final class TypedConfig +{ + #[Env('S')] + public string $string = 'orig'; + + #[Env('I')] + public int $int = -1; + + #[Env('F')] + public float $float = -1.0; + + #[Env('B')] + public bool $bool = false; + + #[Env('A')] + public array $array = []; + + #[Env('IE')] + public IntEnum $intEnum = IntEnum::Zero; + + #[Env('SE')] + public StringEnum $stringEnum = StringEnum::Alpha; + + #[Env('UE')] + public PureEnum $pureEnum = PureEnum::Foo; + + #[Env('N')] + public ?string $nullable = 'orig'; + + #[Env('U')] + public int|string $union = 'orig'; +} + +#[InflectableConfig] +final class InputConfig +{ + #[InputOption('opt')] + public string $option = 'orig'; + + #[InputArgument('arg')] + public string $argument = 'orig'; +} + +#[InflectableConfig] +final class IniConfig +{ + #[PhpIni('precision')] + public string $precision = 'untouched'; + + #[PhpIni('testo.no.such.ini.option')] + public string $unknown = 'untouched'; +} + +#[InflectableConfig] +final class CustomAttrConfig +{ + #[CustomAttr] + public string $value = 'untouched'; +} + +#[InflectableConfig] +final class XPathConfig +{ + #[XPath('//item/@name')] + public array $tag = []; + + #[XPath('//item/@name', key: 5)] + public array $missing = []; +} + +#[InflectableConfig] +final class ServerFixture +{ + #[XPath('@host')] + public mixed $host = null; +} + +#[InflectableConfig] +final class EmbedConfig +{ + #[XPathEmbed('//server', ServerFixture::class)] + public ?ServerFixture $server = null; +} + +#[InflectableConfig] +final class PluginFixture +{ + #[XPath('@name')] + public mixed $name = null; +} + +#[InflectableConfig] +final class EmbedListConfig +{ + #[XPathEmbedList('//plugin', PluginFixture::class)] + public array $plugins = []; +} + +#[InflectableConfig] +final class BadEmbedListConfig +{ + #[XPathEmbedList('//plugin[', PluginFixture::class)] + public array $plugins = []; +} + +#[InflectableConfig] +final class EnumConfig +{ + #[Env('UE')] + public PureEnum $pureEnum = PureEnum::Foo; +} diff --git a/tests/Core/Value/StatusTest.php b/tests/Core/Value/StatusTest.php new file mode 100644 index 00000000..f2aae819 --- /dev/null +++ b/tests/Core/Value/StatusTest.php @@ -0,0 +1,55 @@ +isCompleted(), $expected); + } + + #[DataSet([Status::Passed, true], 'Passed is successful')] + #[DataSet([Status::Flaky, true], 'Flaky is successful')] + #[DataSet([Status::Failed, false], 'Failed is not successful')] + #[DataSet([Status::Error, false], 'Error is not successful')] + #[DataSet([Status::Risky, false], 'Risky is not successful')] + #[DataSet([Status::Skipped, false], 'Skipped is not successful')] + #[DataSet([Status::Cancelled, false], 'Cancelled is not successful')] + #[DataSet([Status::Aborted, false], 'Aborted is not successful')] + public function isSuccessfulOnlyForPassedAndFlaky(Status $status, bool $expected): void + { + Assert::same($status->isSuccessful(), $expected); + } + + #[DataSet([Status::Failed, true], 'Failed is a failure')] + #[DataSet([Status::Error, true], 'Error is a failure')] + #[DataSet([Status::Passed, false], 'Passed is not a failure')] + #[DataSet([Status::Flaky, false], 'Flaky is not a failure')] + #[DataSet([Status::Risky, false], 'Risky is not a failure')] + #[DataSet([Status::Skipped, false], 'Skipped is not a failure')] + #[DataSet([Status::Cancelled, false], 'Cancelled is not a failure')] + #[DataSet([Status::Aborted, false], 'Aborted is not a failure')] + public function isFailureOnlyForFailedAndError(Status $status, bool $expected): void + { + Assert::same($status->isFailure(), $expected); + } +} diff --git a/tests/Output/Unit/Html/DocumentBuilderTest.php b/tests/Output/Unit/Html/DocumentBuilderTest.php index 3a6f9eae..33d1e57e 100644 --- a/tests/Output/Unit/Html/DocumentBuilderTest.php +++ b/tests/Output/Unit/Html/DocumentBuilderTest.php @@ -8,6 +8,7 @@ use Testo\Application\Config\RunConfiguration; use Testo\Application\Internal\EventDispatcher; use Testo\Assert; +use Testo\Bench\Dto\BenchResult; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; @@ -32,6 +33,7 @@ use Testo\Output\Html\Internal\DocumentBuilder; use Testo\Output\Html\Internal\Json; use Testo\Output\Html\Internal\Recorder; +use Testo\Retry; use Testo\Test; use Tests\Output\Stub\Html\SampleTestClass; @@ -207,6 +209,244 @@ public function channelsAreListedWithCountsAndNoPresentation(): void ]); } + public function aNonFailureStatusTakesItsReasonFromTheFailureMessage(): void + { + $recorder = new Recorder(); + $info = self::start(self::dispatcher($recorder), 'passingTest'); + + // A skip is neither passed nor failed, so its reason lives only on the carried failure's message. + $test = new TestResult( + info: $info, + status: Status::Skipped, + failure: new \RuntimeException('database not reachable'), + summary: Summary::forTest(Status::Skipped), + ); + + $rendered = self::documentOf([$test], $recorder)['suites'][0]['cases'][0]['tests'][0]; + Assert::same($rendered['status'], 'skipped'); + Assert::same($rendered['statusReason'], 'database not reachable'); + } + + public function anEmptyReasonMessageIsOmittedRatherThanShownBlank(): void + { + $recorder = new Recorder(); + $info = self::start(self::dispatcher($recorder), 'passingTest'); + + // A skip whose failure carries no message has no reason to state; the section is dropped, not blank. + $test = new TestResult( + info: $info, + status: Status::Skipped, + failure: new \RuntimeException(''), + summary: Summary::forTest(Status::Skipped), + ); + + $rendered = self::documentOf([$test], $recorder)['suites'][0]['cases'][0]['tests'][0]; + Assert::same($rendered['status'], 'skipped'); + Assert::false(isset($rendered['statusReason'])); + } + + public function theRetryPolicyIsReadFromASingleRetryAttribute(): void + { + // A test carries its retry policy as one attribute instance. + $policy = self::retryPolicyFor(new Retry(maxAttempts: 5, markFlaky: false)); + + Assert::same($policy, ['maxAttempts' => 5, 'markFlaky' => false]); + } + + public function theRetryPolicyIsReadWhenTheAttributeRepeats(): void + { + // The pipeline groups a repeatable attribute into a list; the first Retry still wins. + $policy = self::retryPolicyFor([new Retry(maxAttempts: 2)]); + + Assert::same($policy, ['maxAttempts' => 2, 'markFlaky' => true]); + } + + public function messageContextRendersUnserialisableValuesAsLabels(): void + { + $recorder = new Recorder(); + $info = self::start(self::dispatcher($recorder), 'passingTest'); + + // A value that cannot survive JSON as itself is stated as a printed label rather than dropped. + $test = new TestResult( + info: $info, + status: Status::Passed, + messages: new MessageLog([ + new Message(\microtime(true), 'stdout', Level::Info, 'query', ['connection' => new \stdClass()]), + ]), + summary: Summary::forTest(Status::Passed), + ); + + $rendered = self::documentOf([$test], $recorder)['suites'][0]['cases'][0]['tests'][0]; + Assert::same($rendered['messages'][0]['context']['connection'], '\\stdClass'); + } + + public function aRunWithoutRecordedStartsHasNoExecutionWindow(): void + { + $recorder = new Recorder(); + self::dispatcher($recorder); // Session start stamps the origin, but no test announces a start. + + $test = new TestResult( + info: self::makeTestInfo('passingTest'), + status: Status::Passed, + summary: Summary::forTest(Status::Passed, 0.01), + ); + + // With no interval to union there is no window to divide the declared work by. + $run = self::documentOf([$test], $recorder)['run']; + Assert::same($run['execution'], 0.0); + Assert::same($run['boost'], null); + } + + public function twoNonOverlappingTestsCountAsTheSumOfTheirWindows(): void + { + $recorder = new Recorder(); + $dispatcher = self::dispatcher($recorder); + $first = self::start($dispatcher, 'passingTest'); + \usleep(2000); + $second = self::start($dispatcher, 'failingTest'); + + $tests = [ + new TestResult(info: $first, status: Status::Passed, summary: Summary::forTest(Status::Passed)), + new TestResult(info: $second, status: Status::Passed, summary: Summary::forTest(Status::Passed, 0.01)), + ]; + + // The windows do not overlap, so execution is their sum — the gap between them is not counted. + $run = self::documentOf($tests, $recorder)['run']; + Assert::true($run['execution'] >= 0.01 - 1e-9, "execution = {$run['execution']}"); + Assert::true($run['execution'] < 0.0105, "execution = {$run['execution']}"); + } + + public function aBenchmarkResultIsMappedOntoTheTest(): void + { + $recorder = new Recorder(); + $info = self::start(self::dispatcher($recorder), 'passingTest'); + + // The benchmark's structured result is the test's return value, mapped into the document as data. + $test = new TestResult( + info: $info, + status: Status::Passed, + result: new BenchResult(cases: [], results: [], lines: []), + summary: Summary::forTest(Status::Passed, 0.02), + ); + + $rendered = self::documentOf([$test], $recorder)['suites'][0]['cases'][0]['tests'][0]; + Assert::same($rendered['bench']['iterations'], 0); + Assert::same($rendered['bench']['cases'], []); + Assert::same($rendered['bench']['diagnostics'], []); + } + + public function dataSetsCarryTheirFailuresAndBenchmarks(): void + { + $recorder = new Recorder(); + $dispatcher = self::dispatcher($recorder); + $info = self::start($dispatcher, 'datasetTest'); + + $failed = $info->with( + arguments: ['x', 1], + identity: $info->identity->toDataSet(dataProvider: 0, dataSet: 0), + ); + $dispatcher->dispatch(new TestDataSetStarting($failed, 'first', 0, 0)); + $failedResult = new TestResult( + info: $failed, + status: Status::Failed, + failure: new \RuntimeException('set boom'), + summary: Summary::forTest(Status::Failed, 0.01), + ); + + $benched = $info->with( + arguments: ['y', 2], + identity: $info->identity->toDataSet(dataProvider: 0, dataSet: 1), + ); + $dispatcher->dispatch(new TestDataSetStarting($benched, 'second', 0, 1)); + $benchResult = new TestResult( + info: $benched, + status: Status::Passed, + result: new BenchResult(cases: [], results: [], lines: []), + summary: Summary::forTest(Status::Passed, 0.02), + ); + + $multiple = new MultipleResult([$failedResult, $benchResult]); + $test = new TestResult( + info: $info, + status: Status::Failed, + result: $multiple, + attributes: [MultipleResult::class => $multiple], + summary: Summary::combine([$failedResult->summary, $benchResult->summary]), + ); + + // A data set states its own failure and its own benchmark, each on the set it belongs to. + $sets = self::documentOf([$test], $recorder)['suites'][0]['cases'][0]['tests'][0]['dataSets']; + Assert::same($sets[0]['failure']['message'], 'set boom'); + Assert::false(isset($sets[0]['bench'])); + Assert::same($sets[1]['bench']['iterations'], 0); + Assert::false(isset($sets[1]['failure'])); + } + + public function outputPastTheLimitIsCountedButNoLongerEmitted(): void + { + $recorder = new Recorder(); + $info = self::start(self::dispatcher($recorder), 'passingTest'); + + $test = new TestResult( + info: $info, + status: Status::Passed, + messages: new MessageLog([ + new Message(\microtime(true), 'stdout', Level::Info, 'ab'), + new Message(\microtime(true), 'stdout', Level::Info, 'cdef'), + new Message(\microtime(true), 'stdout', Level::Info, 'gh'), + ]), + summary: Summary::forTest(Status::Passed), + ); + + // Once the cap is crossed every later message is still counted for the total but no longer emitted. + $rendered = self::documentOf([$test], $recorder, messageLimit: 4)['suites'][0]['cases'][0]['tests'][0]; + Assert::same($rendered['truncated']['messages']['total'], 3); + Assert::same($rendered['truncated']['messages']['shown'], 1); + Assert::same(\count($rendered['messages']), 1); + } + + /** + * @return array{maxAttempts: int, markFlaky: bool} + */ + private static function retryPolicyFor(mixed $attribute): array + { + $recorder = new Recorder(); + $info = self::makeTestInfo('flakyTest')->withAttribute(Retry::class, $attribute); + self::dispatcher($recorder)->dispatch(new TestPipelineStarting($info)); + + $test = new TestResult( + info: $info, + status: Status::Passed, + summary: Summary::forTest(Status::Passed, 0.01), + ); + + /** @var array{maxAttempts: int, markFlaky: bool} */ + return self::documentOf([$test], $recorder)['suites'][0]['cases'][0]['tests'][0]['retryPolicy']; + } + + /** + * @param list $tests + * @param int<0, max> $messageLimit + * @return array + */ + private static function documentOf(array $tests, Recorder $recorder, int $messageLimit = 65536): array + { + $summary = Summary::combine(\array_map( + static fn(TestResult $r): Summary => $r->summary, + $tests, + )); + $case = new CaseResult($tests, status: Status::Failed, summary: $summary); + $suite = new SuiteResult([$case], status: Status::Failed, summary: $summary); + $result = new RunResult( + [$suite], + status: Status::Failed, + summary: $summary, + timing: new RunTiming(startup: 0.1, discovery: 0.2, tests: 0.15, teardown: 0.05), + ); + + return self::builder($recorder, $messageLimit)->build($result); + } + /** * @param int<0, max> $messageLimit * @return array diff --git a/tests/Output/Unit/Html/FailureMapperTest.php b/tests/Output/Unit/Html/FailureMapperTest.php new file mode 100644 index 00000000..bc5c1fbb --- /dev/null +++ b/tests/Output/Unit/Html/FailureMapperTest.php @@ -0,0 +1,164 @@ +map($failure); + + Assert::same($data['class'], \RuntimeException::class); + Assert::same($data['message'], 'kaboom'); + Assert::string($data['file'])->contains('FailureMapperTest.php'); + Assert::true($data['line'] > 0, "line = {$data['line']}"); + // The line the failure points at, read back from the file — the one thing a stack frame cannot say. + Assert::string($data['sourceLine'])->contains('kaboom'); + Assert::true($data['trace'] !== []); + // A plain throwable knows neither a comparison nor a prior cause, so neither key appears. + Assert::false(\array_key_exists('diff', $data)); + Assert::false(\array_key_exists('causedBy', $data)); + } + + public function aComparisonFailureCarriesBothSidesAndTheEditScript(): void + { + $failure = new ComparisonFailure( + expected: "line1\nline2\nline3", + actual: "line1\nCHANGED\nline3", + value: 'value', + assertion: 'is the same', + context: '', + reason: 'values differ', + ); + + $diff = (new FailureMapper())->map($failure)['diff']; + + Assert::same($diff['expected'], "line1\nline2\nline3"); + Assert::same($diff['actual'], "line1\nCHANGED\nline3"); + // A middle line changed: the shared lines are context, the old one deleted, the new one added. + $ops = \array_column($diff['lines'], 'op'); + Assert::true(\in_array('ctx', $ops, true), 'has context op'); + Assert::true(\in_array('del', $ops, true), 'has delete op'); + Assert::true(\in_array('add', $ops, true), 'has add op'); + Assert::true(\in_array(['op' => 'del', 'text' => 'line2'], $diff['lines'], true), 'deletes the old line'); + Assert::true(\in_array(['op' => 'add', 'text' => 'CHANGED'], $diff['lines'], true), 'adds the new line'); + } + + public function thePreviousChainIsFlattenedIntoCausedByWithLocationsOnly(): void + { + $inner = new \LogicException('inner'); + $mid = new \RuntimeException('mid', 0, $inner); + $outer = new \RuntimeException('outer', 0, $mid); + + $causedBy = (new FailureMapper())->map($outer)['causedBy']; + + // Outermost is the failure itself; only its ancestors are listed, nearest first. + Assert::same(\count($causedBy), 2); + Assert::same($causedBy[0]['class'], \RuntimeException::class); + Assert::same($causedBy[0]['message'], 'mid'); + Assert::string($causedBy[0]['file'])->contains('FailureMapperTest.php'); + Assert::true($causedBy[0]['line'] > 0); + Assert::same($causedBy[1]['class'], \LogicException::class); + Assert::same($causedBy[1]['message'], 'inner'); + } + + #[DataSet(['line'], 'a line number below one has nothing to read')] + #[DataSet(['file'], 'a file that is not there has nothing to read')] + #[DataSet(['oversized'], 'a file too large to page in has nothing to read')] + public function aFailureWithNoReadableSourceOmitsTheSourceLine(string $mode): void + { + $failure = new \RuntimeException('x'); + $cleanup = null; + + switch ($mode) { + case 'line': + self::setProperty($failure, 'line', 0); + break; + case 'file': + self::setProperty($failure, 'file', \sys_get_temp_dir() . '/' . \uniqid('gone-', true) . '.php'); + break; + case 'oversized': + $path = \tempnam(\sys_get_temp_dir(), 'big'); + \file_put_contents($path, \str_repeat("x\n", 1_100_000)); // > 2 MiB + self::setProperty($failure, 'file', $path); + self::setProperty($failure, 'line', 1); + $cleanup = $path; + break; + } + + try { + $data = (new FailureMapper())->map($failure); + } finally { + $cleanup === null or @\unlink($cleanup); + } + + Assert::false(\array_key_exists('sourceLine', $data)); + } + + public function aBlankSourceLineIsOmittedRatherThanReportedEmpty(): void + { + // The failure is pointed at the blank line directly below this assignment. + $blankLine = __LINE__ + 1; + + $failure = new \RuntimeException('x'); + self::setProperty($failure, 'file', __FILE__); + self::setProperty($failure, 'line', $blankLine); + + $data = (new FailureMapper())->map($failure); + + Assert::false(\array_key_exists('sourceLine', $data)); + } + + public function theSourceFileIsReadOnceAndReusedAcrossFailures(): void + { + $path = \tempnam(\sys_get_temp_dir(), 'src'); + \file_put_contents($path, "first line\nsecond line\n"); + + $mapper = new FailureMapper(); + + $firstFailure = new \RuntimeException('x'); + self::setProperty($firstFailure, 'file', $path); + self::setProperty($firstFailure, 'line', 1); + $first = $mapper->map($firstFailure); + + // Delete the file after the first read: a second read of the same path would find nothing, + // so a source line from line 2 can only come from a cached read of the file. + @\unlink($path); + + $secondFailure = new \RuntimeException('x'); + self::setProperty($secondFailure, 'file', $path); + self::setProperty($secondFailure, 'line', 2); + $second = $mapper->map($secondFailure); + + Assert::same($first['sourceLine'], 'first line'); + Assert::same($second['sourceLine'], 'second line'); + } + + private static function boom(): \RuntimeException + { + return new \RuntimeException('kaboom'); + } + + private static function setProperty(\Throwable $throwable, string $name, mixed $value): void + { + $property = new \ReflectionProperty(\Exception::class, $name); + $property->setValue($throwable, $value); + } +} diff --git a/tests/Output/Unit/Html/ValuePrinterTest.php b/tests/Output/Unit/Html/ValuePrinterTest.php new file mode 100644 index 00000000..b511688c --- /dev/null +++ b/tests/Output/Unit/Html/ValuePrinterTest.php @@ -0,0 +1,159 @@ + 1], "['a' => 1]"], 'an assoc array shows key and value')] + #[DataSet([[1 => 'a', 0 => 'b'], "[1 => 'a', 0 => 'b']"], 'out-of-order int keys read as assoc')] + #[DataSet([[1, 2, 3, 4, 5], '[1, 2, 3, 4, 5]'], 'five elements are all shown')] + #[DataSet([[1, 2, 3, 4, 5, 6], '[1, 2, 3, 4, 5, …]'], 'the sixth element collapses to an ellipsis')] + #[DataSet([[[[1]]], '[[array(1)]]'], 'an array past max depth becomes its count')] + public function anArrayIsFlattenedToAShallowDepth(mixed $value, string $expected): void + { + Assert::same(ValuePrinter::print($value), $expected); + } + + public function anEnumIsNamedByItsClassAndCase(): void + { + Assert::same(ValuePrinter::print(ValuePrinterColor::Red), '\\' . ValuePrinterColor::class . '::Red'); + } + + public function aStringableObjectShowsWhatItSaysAboutItself(): void + { + Assert::same( + ValuePrinter::print(new ValuePrinterGreeter()), + '\\' . ValuePrinterGreeter::class . "('hello')", + ); + } + + public function aStringableObjectPastMaxDepthIsNamedByItsClassAlone(): void + { + // Nested twice, the object sits at max depth, where dumping its rendered form would be neither + // short nor safe, so only the class name remains. + Assert::same( + ValuePrinter::print([[new ValuePrinterGreeter()]]), + '[[\\' . ValuePrinterGreeter::class . ']]', + ); + } + + public function aDateTimeShowsItselfInAtomFormat(): void + { + $date = new \DateTimeImmutable('2020-01-02T03:04:05+00:00'); + + Assert::same( + ValuePrinter::print($date), + '\\DateTimeImmutable(' . $date->format(\DATE_ATOM) . ')', + ); + } + + public function aPlainObjectIsNamedByItsClass(): void + { + Assert::same(ValuePrinter::print(new \stdClass()), '\\stdClass'); + } + + public function anOpenResourceStatesItsType(): void + { + $handle = \fopen('php://memory', 'r'); + + try { + Assert::same(ValuePrinter::print($handle), 'resource(stream)'); + } finally { + \is_resource($handle) and \fclose($handle); + } + } + + public function aClosedResourceFallsBackToItsDebugType(): void + { + $handle = \fopen('php://memory', 'r'); + \fclose($handle); + + // A closed resource is no longer a live resource, so it drops through to the debug type. + Assert::same(ValuePrinter::print($handle), 'resource (closed)'); + } + + #[DataSet(['x', 'string'], 'a string')] + #[DataSet([42, 'int'], 'an int')] + #[DataSet([[1], 'array'], 'an array')] + public function theTypeIsReadTheWayAReaderExpects(mixed $value, string $expected): void + { + Assert::same(ValuePrinter::type($value), $expected); + } + + public function theTypeOfAnObjectIsItsClassName(): void + { + Assert::same(ValuePrinter::type(new \stdClass()), 'stdClass'); + } +} + +enum ValuePrinterColor +{ + case Red; + case Blue; +} + +final class ValuePrinterGreeter implements \Stringable +{ + public function __toString(): string + { + return 'hello'; + } +} diff --git a/tests/Output/Unit/Teamcity/FormatterTest.php b/tests/Output/Unit/Teamcity/FormatterTest.php index 62c16bd4..36211cb8 100644 --- a/tests/Output/Unit/Teamcity/FormatterTest.php +++ b/tests/Output/Unit/Teamcity/FormatterTest.php @@ -6,13 +6,16 @@ use Internal\Path; use Testo\Assert; +use Testo\Codecov\Covers; use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\Identity\TestIdentity; use Testo\Core\Value\Status; +use Testo\Data\DataSet; use Testo\Output\Teamcity\Teamcity\Formatter; use Testo\Test; #[Test] +#[Covers(Formatter::class)] final class FormatterTest { public function aCaseHangsUnderItsSuiteRatherThanUnderWhateverOpenedLast(): void @@ -302,6 +305,123 @@ public function testMetadataWithoutAnIdentityClaimsNoFlow(): void Assert::string($msg)->notContains('flowId='); } + public function testIgnoredCarriesTheSkipReasonWhenGiven(): void + { + $msg = Formatter::testIgnored('itWorks', 'no database configured'); + + Assert::same($msg, "##teamcity[testIgnored name='itWorks' message='no database configured']"); + } + + public function testIgnoredOmitsTheMessageAttributeWhenEmpty(): void + { + // An empty value would read as a blank reason rather than "no reason given". + $msg = Formatter::testIgnored('itWorks'); + + Assert::same($msg, "##teamcity[testIgnored name='itWorks']"); + } + + public function testStdOutNamesTheTestAndCarriesItsOutput(): void + { + $msg = Formatter::testStdOut('itWorks', 'hello world'); + + Assert::same($msg, "##teamcity[testStdOut name='itWorks' out='hello world']"); + } + + public function testStdOutPassesExtraAttributesThroughForConsumersThatUnderstandThem(): void + { + $msg = Formatter::testStdOut('itWorks', 'line', ['channel' => 'app', 'level' => 'info']); + + Assert::string($msg)->contains("out='line'"); + Assert::string($msg)->contains("channel='app'"); + Assert::string($msg)->contains("level='info'"); + } + + public function testStdErrNamesTheTestAndCarriesItsErrorOutput(): void + { + $msg = Formatter::testStdErr('itWorks', 'boom'); + + Assert::same($msg, "##teamcity[testStdErr name='itWorks' out='boom']"); + } + + #[DataSet(['progressMessage', 'working'], 'a bare progress line')] + #[DataSet(['progressStart', 'begin'], 'the opening of a progress block')] + #[DataSet(['progressFinish', 'end'], 'the closing of a progress block')] + public function aProgressMessageCarriesItsTextUnderTheMatchingName(string $method, string $text): void + { + $msg = Formatter::$method($text); + + Assert::same($msg, "##teamcity[{$method} text='{$text}']"); + } + + public function buildProblemCarriesItsDescription(): void + { + $msg = Formatter::buildProblem('the build broke'); + + Assert::same($msg, "##teamcity[buildProblem description='the build broke']"); + } + + public function buildProblemCarriesAnIdentityForDeduplicationWhenGiven(): void + { + // TeamCity collapses problems that share an identity, so a repeated failure reports once. + $msg = Formatter::buildProblem('the build broke', 'compile-error'); + + Assert::same($msg, "##teamcity[buildProblem description='the build broke' identity='compile-error']"); + } + + public function buildStatusCarriesItsText(): void + { + $msg = Formatter::buildStatus('all green'); + + Assert::same($msg, "##teamcity[buildStatus text='all green']"); + } + + public function buildStatusCarriesTheStatusWhenGiven(): void + { + $msg = Formatter::buildStatus('something broke', 'FAILURE'); + + Assert::same($msg, "##teamcity[buildStatus text='something broke' status='FAILURE']"); + } + + #[DataSet(['blockOpened'], 'the opening of an output block')] + #[DataSet(['blockClosed'], 'the closing of an output block')] + public function aBlockMessageNamesTheGroupItBrackets(string $method): void + { + $msg = Formatter::$method('setup'); + + Assert::same($msg, "##teamcity[{$method} name='setup']"); + } + + public function buildParameterSetsANamedValueUnderSetParameter(): void + { + // The wire name is `setParameter`, the verb TeamCity reads to bind a build parameter. + $msg = Formatter::buildParameter('env.FOO', 'bar'); + + Assert::same($msg, "##teamcity[setParameter name='env.FOO' value='bar']"); + } + + public function aMessageCarriesItsTextAndStatus(): void + { + $msg = Formatter::message('careful', 'WARNING'); + + Assert::same($msg, "##teamcity[message text='careful' status='WARNING']"); + } + + public function aMessageDefaultsToNormalStatus(): void + { + $msg = Formatter::message('just so you know'); + + Assert::same($msg, "##teamcity[message text='just so you know' status='NORMAL']"); + } + + #[DataSet(['compilationStarted'], 'the start of a compilation')] + #[DataSet(['compilationFinished'], 'the end of a compilation')] + public function aCompilationMessageNamesTheCompiler(string $method): void + { + $msg = Formatter::$method('phpc'); + + Assert::same($msg, "##teamcity[{$method} compiler='phpc']"); + } + private static function test(): TestIdentity { return (new SuiteIdentity('Core/Unit')) diff --git a/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php b/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php index 77731d9a..0079237a 100644 --- a/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php +++ b/tests/Output/Unit/Teamcity/TeamcityLoggerTest.php @@ -15,8 +15,10 @@ use Testo\Assert\State\Assertion\ComparisonFailure; use Testo\Codecov\Covers; use Testo\Core\Context\CaseInfo; +use Testo\Core\Context\CaseResult; use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\SuiteInfo; +use Testo\Core\Context\SuiteResult; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; @@ -31,6 +33,7 @@ use Testo\Core\Value\Summary; use Testo\Core\Report\ReportInfo; use Testo\Output\Teamcity\Teamcity\TeamcityLogger; +use Testo\Output\Terminal\Renderer\Style; use Testo\Test; use Tests\Output\Stub\Teamcity\ConcreteSampleTestCase; use Tests\Output\Stub\Teamcity\SampleTestClass; @@ -413,6 +416,275 @@ public function distinctTestsGetDistinctFlowIds(): void Assert::string($b)->contains("flowId='{$second->identity->pipelineId}'"); } + public function logEnvironmentEmitsAnEnvironmentBlockWithRuntimeDetails(): void + { + $previous = Style::areColorsEnabled(); + try { + Style::setColorsEnabled(false); + $plain = self::capture(static fn(TeamcityLogger $logger) => $logger->logEnvironment()); + Style::setColorsEnabled(true); + $colored = self::capture(static fn(TeamcityLogger $logger) => $logger->logEnvironment()); + } finally { + Style::setColorsEnabled($previous); + } + + // The runtime facts sit inside a named block an IDE can fold, one labelled line per subsystem. + Assert::string($plain)->contains("##teamcity[blockOpened name='Environment']"); + Assert::string($plain)->contains("##teamcity[blockClosed name='Environment']"); + Assert::string($plain)->contains('PHP: '); + Assert::string($plain)->contains('XDebug: '); + Assert::string($plain)->contains('OPcache: '); + + // With colours on the labels carry the cyan ANSI prefix; the plain run leaves them bare. + Assert::string($colored)->contains("\033[36;1mPHP:\033[0m"); + Assert::string($plain)->notContains("\033[36;1m"); + } + + public function suiteFinishedFromInfoClosesTheSuiteNodeWithItsStatus(): void + { + $info = new SuiteInfo(name: 'Output/Unit', testCases: CaseDefinitions::fromArray()); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->suiteFinishedFromInfo($info, Status::Passed), + ); + + Assert::string($output)->contains('##teamcity[testSuiteFinished'); + Assert::string($output)->contains("name='Output/Unit'"); + Assert::string($output)->contains("status='passed'"); + } + + public function batchStartedFromInfoOpensADataProviderBatchAsASuite(): void + { + $info = self::makeInfo('passingTest'); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->batchStartedFromInfo($info)); + + // A DataProvider test becomes a nested suite so its data sets can report as children. + Assert::string($output)->contains('##teamcity[testSuiteStarted'); + Assert::string($output)->contains("name='passingTest'"); + } + + public function batchFinishedFromInfoClosesTheDataProviderBatchWithItsStatus(): void + { + $info = self::makeInfo('passingTest'); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->batchFinishedFromInfo($info, Status::Failed), + ); + + Assert::string($output)->contains('##teamcity[testSuiteFinished'); + Assert::string($output)->contains("status='failed'"); + } + + public function handleSuiteResultReportsTheFailureOnStdErrThenClosesTheSuite(): void + { + $info = new SuiteInfo(name: 'Output/Unit', testCases: CaseDefinitions::fromArray()); + $result = new SuiteResult(results: [], status: Status::Failed, summary: new Summary(counts: ['Failed' => 2])); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->handleSuiteResult($info, $result)); + + // The failure is reported on the still-open suite node, so it has to precede the close. + Assert::string($output)->contains('##teamcity[testStdErr'); + Assert::string($output)->contains('Test suite failed: 2 test(s) failed'); + Assert::string($output)->contains('##teamcity[testSuiteFinished'); + Assert::string($output)->contains("status='failed'"); + Assert::true(\strpos($output, 'testStdErr') < \strpos($output, 'testSuiteFinished')); + } + + public function handleSuiteResultOfAPassingSuiteJustClosesItWithoutAnyStdErr(): void + { + $info = new SuiteInfo(name: 'Output/Unit', testCases: CaseDefinitions::fromArray()); + $result = new SuiteResult(results: [], status: Status::Passed); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->handleSuiteResult($info, $result)); + + Assert::string($output)->notContains('testStdErr'); + Assert::string($output)->contains('##teamcity[testSuiteFinished'); + Assert::string($output)->contains("status='passed'"); + } + + public function caseStartedFromInfoOpensTheCaseAsASuite(): void + { + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->caseStartedFromInfo(self::makeCaseInfo()), + ); + + // A test case is a class of tests, which TeamCity models as a suite. + Assert::string($output)->contains('##teamcity[testSuiteStarted'); + Assert::string($output)->contains(SampleTestClass::class); + } + + public function caseFinishedFromInfoClosesTheCaseWithItsStatus(): void + { + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->caseFinishedFromInfo(self::makeCaseInfo(), Status::Passed), + ); + + Assert::string($output)->contains('##teamcity[testSuiteFinished'); + Assert::string($output)->contains("status='passed'"); + } + + public function handleCaseResultReportsTheFailureOnStdErrThenClosesTheCase(): void + { + $result = new CaseResult(results: [], status: Status::Error, summary: new Summary(counts: ['Error' => 1])); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->handleCaseResult(self::makeCaseInfo(), $result), + ); + + Assert::string($output)->contains('##teamcity[testStdErr'); + Assert::string($output)->contains('Test case failed: 1 test(s) failed'); + Assert::string($output)->contains('##teamcity[testSuiteFinished'); + Assert::string($output)->contains("status='error'"); + Assert::true(\strpos($output, 'testStdErr') < \strpos($output, 'testSuiteFinished')); + } + + public function testFinishedFromInfoClosesTheTestNodeWithItsDuration(): void + { + $info = self::makeInfo('passingTest'); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->testFinishedFromInfo($info, 42)); + + Assert::string($output)->contains('##teamcity[testFinished'); + Assert::string($output)->contains("name='passingTest'"); + Assert::string($output)->contains("duration='42'"); + } + + public function testFailedFromResultWithoutAFailureUsesAGenericMessageAndEmptyDetails(): void + { + $result = new TestResult( + info: self::makeInfo('failingTest'), + status: Status::Failed, + failure: null, + attributes: ['duration' => 0], + ); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->testFailedFromResult($result)); + + // No throwable means nothing to render: a generic message and empty details, no comparison shape. + Assert::string($output)->contains('##teamcity[testFailed'); + Assert::string($output)->contains("message='Test failed'"); + Assert::string($output)->contains("details=''"); + Assert::string($output)->notContains("type='comparisonFailure'"); + } + + public function handleSingleTestResultFailedWithoutAFailureUsesAGenericMessageAndEmptyDetails(): void + { + $result = new TestResult( + info: self::makeInfo('failingTest'), + status: Status::Failed, + failure: null, + attributes: ['duration' => 0], + ); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->handleSingleTestResult($result)); + + Assert::string($output)->contains('##teamcity[testFailed'); + Assert::string($output)->contains("message='Test failed'"); + Assert::string($output)->contains("details=''"); + Assert::string($output)->contains('##teamcity[testFinished'); + } + + public function anAbortedTestWithAFailureRendersTheThrowableInDetails(): void + { + $result = self::makeResult(Status::Aborted, new \RuntimeException('interceptor exploded')); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->handleSingleTestResult($result)); + + Assert::string($output)->contains('##teamcity[testFailed'); + Assert::string($output)->contains("message='Test aborted'"); + Assert::string($output)->contains('RuntimeException: interceptor exploded'); + Assert::string($output)->contains('##teamcity[testFinished'); + Assert::string($output)->contains("status='aborted'"); + } + + public function testIgnoredFromInfoEmitsAnIgnoredMessageCarryingTheReason(): void + { + $info = self::makeInfo('passingTest'); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->testIgnoredFromInfo($info, 'not applicable on windows'), + ); + + Assert::string($output)->contains('##teamcity[testIgnored'); + Assert::string($output)->contains("name='passingTest'"); + Assert::string($output)->contains("message='not applicable on windows'"); + } + + public function logMessageRoutesTheStderrChannelToTeamcityStdErr(): void + { + $info = self::makeInfo('passingTest'); + $message = new Message(time: 0.0, channel: 'stderr', level: Level::Error, content: 'boom on stderr'); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->logMessage('someTest', $message, $info->identity), + ); + + // The dedicated stderr channel maps to TeamCity's stderr stream, not stdout. + Assert::string($output)->contains('##teamcity[testStdErr'); + Assert::string($output)->contains('boom on stderr'); + Assert::string($output)->contains("channel='stderr'"); + } + + public function logMessageWithEmptyContentEmitsNothing(): void + { + $info = self::makeInfo('passingTest'); + $message = new Message(time: 0.0, channel: 'stdout', level: Level::Info, content: ''); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->logMessage('someTest', $message, $info->identity), + ); + + // Empty output is not worth a service message that would open an empty node on the consumer. + Assert::same($output, ''); + } + + public function logMessageEscapesTeamcitySpecialCharactersInContent(): void + { + $info = self::makeInfo('passingTest'); + $message = new Message(time: 0.0, channel: 'stdout', level: Level::Info, content: "a|b 'q' [x]\nend"); + + $output = self::capture( + static fn(TeamcityLogger $logger) => $logger->logMessage('someTest', $message, $info->identity), + ); + + // TeamCity value escaping: | -> ||, ' -> |', [ -> |[, ] -> |], newline -> |n. A missed escape here + // corrupts the whole message on the parser. + Assert::string($output)->contains("out='a||b |'q|' |[x|]|nend'"); + } + + public function logStandaloneMessageOnTheStderrChannelReportsAnErrorMessage(): void + { + $message = new Message(time: 0.0, channel: 'stderr', level: Level::Error, content: 'bootstrap failed'); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->logStandaloneMessage($message)); + + // A fault with no test to attribute it to still surfaces, as a standalone ERROR message. + Assert::string($output)->contains('##teamcity[message'); + Assert::string($output)->contains("text='bootstrap failed'"); + Assert::string($output)->contains("status='ERROR'"); + } + + public function logStandaloneMessageOnAnyOtherChannelReportsANormalMessage(): void + { + $message = new Message(time: 0.0, channel: 'stdout', level: Level::Info, content: 'just a note'); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->logStandaloneMessage($message)); + + Assert::string($output)->contains('##teamcity[message'); + Assert::string($output)->contains("text='just a note'"); + Assert::string($output)->contains("status='NORMAL'"); + } + + public function logStandaloneMessageWithEmptyContentEmitsNothing(): void + { + $message = new Message(time: 0.0, channel: 'stderr', level: Level::Error, content: ''); + + $output = self::capture(static fn(TeamcityLogger $logger) => $logger->logStandaloneMessage($message)); + + Assert::same($output, ''); + } + /** * Runs the callback against a logger writing to an in-memory stream and returns what it wrote. * @@ -511,6 +783,22 @@ private static function makeCase(string ...$methods): CaseDefinition ); } + /** + * A {@see CaseInfo} for {@see SampleTestClass}, used to drive the case-level logger methods. + */ + private static function makeCaseInfo(): CaseInfo + { + return new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), + definition: new CaseDefinition( + name: SampleTestClass::class, + type: 'test', + file: Path::create(__FILE__), + reflection: new \ReflectionClass(SampleTestClass::class), + ), + ); + } + /** * @param non-empty-string $method Method of {@see SampleTestClass} backing the test definition. */ diff --git a/tests/Output/Unit/Terminal/FormatterTest.php b/tests/Output/Unit/Terminal/FormatterTest.php index 9e360398..1f71e21e 100644 --- a/tests/Output/Unit/Terminal/FormatterTest.php +++ b/tests/Output/Unit/Terminal/FormatterTest.php @@ -6,11 +6,22 @@ use Testo\Assert; use Testo\Assert\State\Assertion\ComparisonFailure; +use Testo\Codecov\Covers; +use Testo\Common\Info; +use Testo\Core\Context\CaseResult; +use Testo\Core\Context\SuiteResult; +use Testo\Core\Value\RunTiming; use Testo\Core\Value\Status; +use Testo\Core\Value\Summary; +use Testo\Data\DataSet; +use Testo\Output\Terminal\Renderer\FormattedItem; use Testo\Output\Terminal\Renderer\Formatter; +use Testo\Output\Terminal\Renderer\OutputFormat; +use Testo\Output\Terminal\Renderer\Style; use Testo\Test; #[Test] +#[Covers(Formatter::class)] final class FormatterTest { public function comparisonBlockHasExpectedAndActualHeaders(): void @@ -102,6 +113,287 @@ public function emptyBannerReadsNoTests(): void Assert::string(Formatter::emptyBanner())->contains('NO TESTS'); } + public function runHeaderShowsNameAndVersion(): void + { + $out = self::withoutColors(static fn(): string => Formatter::runHeader()); + + Assert::string($out)->contains(Info::NAME)->contains('v' . Info::version()); + } + + #[DataSet([OutputFormat::Verbose, "\n Suite: MySuite\n"], 'verbose keeps a leading space')] + #[DataSet([OutputFormat::Compact, "\nSuite: MySuite\n"], 'compact has no leading space')] + #[DataSet([OutputFormat::Dots, "\nSuite: MySuite\n"], 'dots matches compact')] + public function suiteHeaderRendersPerFormat(OutputFormat $format, string $expected): void + { + $out = self::withoutColors(static fn(): string => Formatter::suiteHeader('MySuite', $format)); + + Assert::same($out, $expected); + } + + #[DataSet([OutputFormat::Verbose, "\n Case: MyCase\n"], 'verbose labels the case')] + #[DataSet([OutputFormat::Compact, " MyCase\n"], 'compact shows the bare name')] + #[DataSet([OutputFormat::Dots, " MyCase "], 'dots keeps it on one line')] + public function caseHeaderRendersPerFormat(OutputFormat $format, string $expected): void + { + $out = self::withoutColors(static fn(): string => Formatter::caseHeader('MyCase', $format)); + + Assert::same($out, $expected); + } + + #[DataSet([OutputFormat::Dots, "\n"], 'dots closes the line')] + #[DataSet([OutputFormat::Compact, ''], 'compact emits nothing')] + #[DataSet([OutputFormat::Verbose, ''], 'verbose emits nothing')] + public function caseFooterOnlyClosesInDotsMode(OutputFormat $format, string $expected): void + { + Assert::same(Formatter::caseFooter($format), $expected); + } + + public function caseSummaryListsEveryStatusInVerboseMode(): void + { + $result = self::caseResult(self::allStatusesSummary()); + + $out = self::withoutColors(static fn(): string => Formatter::caseSummary($result, OutputFormat::Verbose)); + + Assert::string($out) + ->contains('Summary:') + ->contains('1 passed') + ->contains('1 failed') + ->contains('1 error') + ->contains('1 skipped') + ->contains('1 risky') + ->contains('1 cancelled') + ->contains('1 flaky'); + } + + public function caseSummaryReadsNoTestsWhenSummaryEmpty(): void + { + $result = self::caseResult(new Summary()); + + $out = self::withoutColors(static fn(): string => Formatter::caseSummary($result, OutputFormat::Verbose)); + + Assert::string($out)->contains('Summary: no tests'); + } + + public function caseSummaryIsEmptyOutsideVerboseMode(): void + { + $result = self::caseResult(self::allStatusesSummary()); + + Assert::same(Formatter::caseSummary($result, OutputFormat::Compact), ''); + } + + public function suiteSummaryListsEveryStatusWithTotals(): void + { + $result = new SuiteResult(results: [], status: Status::Passed, summary: self::allStatusesSummary()); + + $out = self::withoutColors(static fn(): string => Formatter::suiteSummary($result)); + + Assert::string($out) + ->contains('8 tests · 9 assertions') + ->contains('1 passed') + ->contains('1 failed') + ->contains('1 error') + ->contains('1 skipped') + ->contains('1 risky') + ->contains('1 cancelled') + ->contains('1 flaky') + ->contains('1 aborted'); + } + + public function suiteSummaryIsEmptyWhenNoTests(): void + { + $result = new SuiteResult(results: [], status: Status::Passed, summary: new Summary()); + + Assert::same(Formatter::suiteSummary($result), ''); + } + + public function progressReportsCompletedOverTotal(): void + { + $out = self::withoutColors(static fn(): string => Formatter::progress(3, 10)); + + Assert::string($out)->contains('Progress: 3/10 tests completed'); + } + + public function failuresHeaderReadsFailures(): void + { + $out = self::withoutColors(static fn(): string => Formatter::failuresHeader()); + + Assert::string($out)->contains('Failures:'); + } + + public function failureDetailIncludesDurationLocationAndIndentedDetails(): void + { + $out = self::withoutColors(static fn(): string => Formatter::failureDetail( + index: 1, + testName: 'MyTest', + message: 'boom', + details: "line a\n\nline b", + duration: 12, + location: 'file.php:10', + )); + + Assert::string($out) + ->contains('1) MyTest') + ->contains('(12ms)') + ->contains('file.php:10') + ->contains('boom') + ->contains(' line a') + ->contains(' line b'); + } + + public function failureDetailOmitsDurationLocationAndDetailsWhenAbsent(): void + { + $out = self::withoutColors(static fn(): string => Formatter::failureDetail( + index: 2, + testName: 'Other', + message: 'msg', + details: '', + duration: null, + )); + + Assert::string($out) + ->contains('2) Other') + ->contains('msg') + ->notContains('ms)') + ->notContains('file.php'); + } + + public function summaryRendersOverheadAndListsEveryStatus(): void + { + $summary = self::allStatusesSummary(); + $timing = new RunTiming(startup: 0.1, discovery: 0.2, tests: 3.0, teardown: 0.1); + + $out = self::withoutColors(static fn(): string => Formatter::summary($summary, $timing)); + + Assert::string($out) + ->contains('8 tests · 9 assertions') + ->contains('2.50s tests') + ->contains('500ms overhead') + ->contains('3.40s total') + ->contains('1 passed') + ->contains('1 aborted'); + } + + public function summaryRendersWallWhenTestsOverlapped(): void + { + $summary = new Summary(counts: [Status::Passed->name => 1], duration: 5.0); + $timing = new RunTiming(tests: 2.0); + + $out = self::withoutColors(static fn(): string => Formatter::summary($summary, $timing)); + + Assert::string($out)->contains('2.00s wall'); + } + + public function summaryFormatsSubMillisecondDurationsWithTwoDecimals(): void + { + $summary = new Summary(counts: [Status::Passed->name => 1], duration: 0.0004); + $timing = new RunTiming(tests: 0.001); + + $out = self::withoutColors(static fn(): string => Formatter::summary($summary, $timing)); + + Assert::string($out)->contains('0.40ms tests'); + } + + public function summaryReadsNoTestsWhenEmpty(): void + { + $out = self::withoutColors(static fn(): string => Formatter::summary(new Summary(), new RunTiming())); + + Assert::string($out)->contains('no tests'); + } + + #[DataSet([true, 'PASSED'], 'success banner')] + #[DataSet([false, 'FAILED'], 'failure banner')] + public function finalBannerReflectsOutcome(bool $success, string $expected): void + { + $out = self::withoutColors(static fn(): string => Formatter::finalBanner($success)); + + Assert::string($out)->contains($expected); + } + + #[DataSet([Status::Passed, '.'], 'passed dot')] + #[DataSet([Status::Failed, 'F'], 'failed dot')] + #[DataSet([Status::Skipped, '-'], 'skipped dot')] + #[DataSet([Status::Error, 'E'], 'error dot')] + #[DataSet([Status::Aborted, 'A'], 'aborted dot')] + #[DataSet([Status::Risky, 'R'], 'risky dot')] + #[DataSet([Status::Flaky, '.'], 'flaky reuses the passed dot')] + #[DataSet([Status::Cancelled, '-'], 'cancelled reuses the skipped dot')] + public function formatRunInDotsModeRendersStatusSymbol(Status $status, string $expected): void + { + $item = new FormattedItem(name: 'MyTest', status: $status); + + $out = self::withoutColors(static fn(): string => Formatter::formatRun($item, OutputFormat::Dots)); + + Assert::same($out, $expected); + } + + #[DataSet([Status::Passed, '✓'], 'passed symbol')] + #[DataSet([Status::Failed, '✗'], 'failed symbol')] + #[DataSet([Status::Skipped, '○'], 'skipped symbol')] + #[DataSet([Status::Error, 'E'], 'error symbol')] + #[DataSet([Status::Aborted, 'A'], 'aborted symbol')] + #[DataSet([Status::Risky, '?'], 'risky symbol')] + #[DataSet([Status::Flaky, '~'], 'flaky symbol')] + #[DataSet([Status::Cancelled, '○'], 'cancelled reuses the skipped symbol')] + public function formatRunInCompactModeShowsStatusSymbolAndName(Status $status, string $symbol): void + { + $item = new FormattedItem(name: 'MyTest', status: $status); + + $out = self::withoutColors(static fn(): string => Formatter::formatRun($item, OutputFormat::Compact)); + + Assert::string($out)->contains("{$symbol} MyTest"); + } + + public function formatRunInVerboseModeShowsSymbolIndentAndDescription(): void + { + $item = new FormattedItem(name: 'VTest', status: Status::Passed, description: 'note'); + + $out = self::withoutColors(static fn(): string => Formatter::formatRun($item, OutputFormat::Verbose)); + + Assert::string($out)->contains('✓ VTest')->contains('note'); + } + + public function formatRunShowsDurationWhenPresent(): void + { + $item = new FormattedItem(name: 'Timed', status: Status::Passed, duration: 12); + + $out = self::withoutColors(static fn(): string => Formatter::formatRun($item, OutputFormat::Compact)); + + Assert::string($out)->contains('(12ms)'); + } + + #[DataSet(['', OutputFormat::Verbose], 'blank description yields nothing')] + #[DataSet(['some text', OutputFormat::Dots], 'dots mode has no room for descriptions')] + public function descriptionIsEmptyWhenBlankOrDots(string $description, OutputFormat $format): void + { + Assert::same(Formatter::description($description, 0, $format), ''); + } + + public function descriptionIndentsUnderItemInVerboseMode(): void + { + $out = self::withoutColors(static fn(): string => Formatter::description('note', 0, OutputFormat::Verbose)); + + // INDENT_VERBOSE (5) + one INDENT_STEP (2) = 7 spaces + Assert::same($out, " note\n"); + } + + public function descriptionUsesCompactIndentInCompactMode(): void + { + $out = self::withoutColors(static fn(): string => Formatter::description('note', 0, OutputFormat::Compact)); + + // INDENT_COMPACT (3) + one INDENT_STEP (2) = 5 spaces + Assert::same($out, " note\n"); + } + + public function descriptionIndentsWrappedLinesOfMultilineText(): void + { + $out = self::withoutColors( + static fn(): string => Formatter::description("first\nsecond", 1, OutputFormat::Verbose), + ); + + // level 1: INDENT_VERBOSE (5) + INDENT_STEP*1 (2) + INDENT_STEP (2) = 9 spaces on every line + Assert::same($out, " first\n second\n"); + } + /** * Count diff body lines that begin with the given marker (`-` or `+`). * Header lines (`--- Expected`, `+++ Actual`) are skipped. @@ -132,4 +424,50 @@ private static function makeFailure(mixed $expected, mixed $actual): ComparisonF reason: 'values differ', ); } + + /** + * A summary carrying exactly one test of every status plus an assertion metric, so every + * status branch of the summary formatters has something to render. + */ + private static function allStatusesSummary(): Summary + { + return new Summary( + counts: [ + Status::Passed->name => 1, + Status::Failed->name => 1, + Status::Error->name => 1, + Status::Skipped->name => 1, + Status::Risky->name => 1, + Status::Cancelled->name => 1, + Status::Flaky->name => 1, + Status::Aborted->name => 1, + ], + metrics: ['assertions' => 9], + duration: 2.5, + ); + } + + private static function caseResult(Summary $summary): CaseResult + { + return new CaseResult(results: [], status: Status::Passed, summary: $summary); + } + + /** + * Renders with colorization forced off so assertions read plain text. Colorization is a + * process-global flag in {@see Style}, so the previous value is restored to avoid leaking + * into whatever case runs next. + * + * @param \Closure(): string $render + */ + private static function withoutColors(\Closure $render): string + { + $colors = Style::areColorsEnabled(); + Style::setColorsEnabled(false); + + try { + return $render(); + } finally { + Style::setColorsEnabled($colors); + } + } } diff --git a/tests/Output/Unit/Terminal/HelperTest.php b/tests/Output/Unit/Terminal/HelperTest.php new file mode 100644 index 00000000..2a055c20 --- /dev/null +++ b/tests/Output/Unit/Terminal/HelperTest.php @@ -0,0 +1,151 @@ +contains('RuntimeException: boom') + ->contains('File: ') + ->contains(':' . $throwable->getLine()) + ->contains('Stack trace:') + ->contains('#0 ') + // frame carrying a class renders as Class::method() + ->contains(self::class . '::boundaryMarker()') + // a zero code must not emit a "(Code: N)" segment + ->notContains('(Code:'); + } + + public function includesCodeSegmentWhenNonZero(): void + { + $throwable = self::throwViaBoundary(7); + + $out = Helper::formatException($throwable, self::boundaryReflection()); + + Assert::string($out)->contains('RuntimeException: boom (Code: 7)'); + } + + public function omitsStackTraceBlockWhenTraceIsEmpty(): void + { + $throwable = self::withEmptyTrace(new \RuntimeException('no-frames')); + + // strlen never appears in the trace, so the boundary leaves the (empty) trace as-is. + $out = Helper::formatException($throwable, new \ReflectionFunction('strlen')); + + Assert::string($out) + ->contains('RuntimeException: no-frames') + ->contains('File: ') + ->notContains('Stack trace:'); + } + + public function rendersInternalFunctionAndBareFunctionFramesWithNumbering(): void + { + $throwable = self::throwViaArrayMap(); + + // strlen is absent from the trace, so both native frames survive the cut. + $out = Helper::formatException($throwable, new \ReflectionFunction('strlen')); + + Assert::string($out) + ->contains('LogicException: via-map') + // a frame without a file renders "[internal function]" as its location + ->contains('#0 [internal function]: ') + // a class-less frame renders as a bare function() call + ->contains('array_map()') + // frames are numbered sequentially + ->contains('#1 '); + } + + #[DataSet([0, 0], 'depth 0 stops before any previous')] + #[DataSet([1, 1], 'depth 1 includes the deepest allowed link')] + #[DataSet([2, 2], 'depth 2 walks the full chain')] + #[DataSet([99, 2], 'a limit beyond the chain stops at its real end')] + public function walksPreviousChainUpToDepth(int $maxPreviousDepth, int $expectedCausedBy): void + { + $chain = new \Exception('outer', 0, new \Exception('mid', 0, new \Exception('inner'))); + + $out = Helper::formatException($chain, new \ReflectionFunction('strlen'), $maxPreviousDepth); + + Assert::same(\substr_count($out, 'Caused by:'), $expectedCausedBy); + } + + public function deepestAllowedPreviousLinkIsIncludedAtItsLimit(): void + { + $chain = new \Exception('outer', 0, new \Exception('mid', 0, new \Exception('inner'))); + + // depth 1 reaches "mid" (the first previous) but not "inner" (the second). + $out = Helper::formatException($chain, new \ReflectionFunction('strlen'), 1); + + Assert::string($out) + ->contains('Exception: mid') + ->notContains('Exception: inner'); + } + + /** + * Boundary method that appears in the trace of {@see throwViaBoundary}. Kept private so + * Testo's finder does not mistake it for a test method; {@see StackTrace::cutStackTrace} + * still matches it by class + name regardless of visibility. + */ + private static function boundaryMarker(callable $callback): mixed + { + return $callback(); + } + + private static function boundaryReflection(): \ReflectionMethod + { + return new \ReflectionMethod(self::class, 'boundaryMarker'); + } + + private static function throwViaBoundary(int $code = 0): \Throwable + { + try { + self::boundaryMarker(static fn(): never => throw new \RuntimeException('boom', $code)); + } catch (\Throwable $e) { + return $e; + } + + throw new \LogicException('unreachable'); + } + + /** + * Produces a trace whose first frame is a native `[internal function]` call (the closure + * invoked by `array_map`) and whose next frame is the class-less `array_map` function. + */ + private static function throwViaArrayMap(): \Throwable + { + try { + \array_map(static fn(): never => throw new \LogicException('via-map'), [1]); + } catch (\Throwable $e) { + return $e; + } + + throw new \LogicException('unreachable'); + } + + /** + * Clears a throwable's captured trace, reproducing an exception created at `{main}` scope + * (empty trace) so the "no stack trace" branch of the formatter is reachable from a test. + */ + private static function withEmptyTrace(\Throwable $throwable): \Throwable + { + $trace = new \ReflectionProperty(\Exception::class, 'trace'); + $trace->setValue($throwable, []); + + return $throwable; + } +} diff --git a/tests/Output/Unit/Terminal/StyleTest.php b/tests/Output/Unit/Terminal/StyleTest.php new file mode 100644 index 00000000..aaf3ce0a --- /dev/null +++ b/tests/Output/Unit/Terminal/StyleTest.php @@ -0,0 +1,117 @@ + Style::colorize('hi', Color::Green)), + Color::Green->value . 'hi' . Color::Reset->value, + ); + } + + public function colorizeReturnsRawTextWhenDisabled(): void + { + Assert::same( + self::withColors(false, static fn(): string => Style::colorize('hi', Color::Green)), + 'hi', + ); + } + + public function boldWrapsWhenEnabledAndPassesThroughWhenDisabled(): void + { + Assert::same( + self::withColors(true, static fn(): string => Style::bold('x')), + Color::Bold->value . 'x' . Color::Reset->value, + ); + Assert::same(self::withColors(false, static fn(): string => Style::bold('x')), 'x'); + } + + public function dimWrapsWhenEnabledAndPassesThroughWhenDisabled(): void + { + Assert::same( + self::withColors(true, static fn(): string => Style::dim('x')), + Color::Dim->value . 'x' . Color::Reset->value, + ); + Assert::same(self::withColors(false, static fn(): string => Style::dim('x')), 'x'); + } + + public function bannerPadsAndWrapsWhenEnabled(): void + { + Assert::same( + self::withColors(true, static fn(): string => Style::banner('OK', Color::Green, Color::White)), + Color::White->value . Color::Green->value . Color::Bold->value . ' OK ' . Color::Reset->value, + ); + } + + public function bannerJustPadsWhenDisabled(): void + { + Assert::same( + self::withColors(false, static fn(): string => Style::banner('OK', Color::Green)), + ' OK ', + ); + } + + public function semanticHelpersColorizeWithTheirColor(): void + { + Assert::same( + self::withColors(true, static fn(): string => Style::success('a')), + Color::Green->value . 'a' . Color::Reset->value, + ); + Assert::same( + self::withColors(true, static fn(): string => Style::error('a')), + Color::Red->value . 'a' . Color::Reset->value, + ); + Assert::same( + self::withColors(true, static fn(): string => Style::warning('a')), + Color::Yellow->value . 'a' . Color::Reset->value, + ); + Assert::same( + self::withColors(true, static fn(): string => Style::info('a')), + Color::Cyan->value . 'a' . Color::Reset->value, + ); + } + + /** + * Runs $fn with colors forced to $enabled, restoring the global flag afterwards so the test + * leaves no global state behind. + */ + private static function withColors(bool $enabled, \Closure $fn): string + { + $saved = Style::areColorsEnabled(); + Style::setColorsEnabled($enabled); + + try { + return $fn(); + } finally { + Style::setColorsEnabled($saved); + } + } +} diff --git a/tests/Output/Unit/Terminal/TerminalLoggerTest.php b/tests/Output/Unit/Terminal/TerminalLoggerTest.php index 47213378..fb82b38a 100644 --- a/tests/Output/Unit/Terminal/TerminalLoggerTest.php +++ b/tests/Output/Unit/Terminal/TerminalLoggerTest.php @@ -6,26 +6,34 @@ use Internal\Path; use Testo\Assert; +use Testo\Assert\State\Assertion\ComparisonFailure; use Testo\Codecov\Covers; +use Testo\Common\Info; use Testo\Core\Context\CaseInfo; use Testo\Core\Context\CaseResult; use Testo\Core\Context\RunResult; +use Testo\Core\Context\SuiteInfo; use Testo\Core\Context\SuiteResult; use Testo\Core\Context\Identity\SuiteIdentity; use Testo\Core\Context\TestInfo; use Testo\Core\Context\TestResult; use Testo\Core\Definition\CaseDefinition; +use Testo\Core\Definition\CaseDefinitions; use Testo\Core\Definition\TestDefinition; use Testo\Common\Messenger; use Testo\Core\Log\Level; use Testo\Core\Log\Message; use Testo\Core\Log\MessageLog; +use Testo\Core\Report\ReportInfo; use Testo\Core\Value\RunTiming; use Testo\Core\Value\Status; use Testo\Core\Value\Summary; use Testo\Core\Value\Verbosity; +use Testo\Data\MultipleResult; use Testo\Output\Terminal\Renderer\OutputFormat; +use Testo\Output\Terminal\Renderer\Style; use Testo\Output\Terminal\Renderer\TerminalLogger; +use Testo\Data\DataSet; use Testo\Test; use Tests\Output\Stub\JUnit\SampleTestClass; @@ -242,6 +250,282 @@ public function aTestIsNotIndentedByAnInterleavedBatch(): void Assert::same(self::lastLine($besideBatch), self::lastLine($alone)); } + public function suiteAndCaseLifecycleRendersHeadersAndSummaries(): void + { + $test = self::test('passingTest', Status::Passed); + $caseInfo = $test->info->caseInfo; + $suiteInfo = new SuiteInfo('Output/Unit', CaseDefinitions::fromArray()); + $summary = new Summary(['Passed' => 1]); + $caseResult = new CaseResult([$test], Status::Passed, $summary); + $suiteResult = new SuiteResult([$caseResult], Status::Passed, $summary); + + // Verbose is the only mode where the case footer/summary carry visible text, so the lifecycle + // writes are all observable in one pass. + $output = self::capture( + static function (TerminalLogger $logger) use ($suiteInfo, $caseInfo, $caseResult, $suiteResult): void { + $logger->suiteStartedFromInfo($suiteInfo); + $logger->caseStartedFromInfo($caseInfo); + $logger->handleCaseResult($caseInfo, $caseResult); + $logger->handleSuiteResult($suiteInfo, $suiteResult); + }, + format: OutputFormat::Verbose, + ); + + Assert::string($output) + ->contains('Suite: Output/Unit') + ->contains('Case:') + ->contains('Summary:') + ->contains('1 passed'); + } + + #[DataSet([Status::Skipped, '○'], 'skipped renders the open circle')] + #[DataSet([Status::Cancelled, '○'], 'cancelled reuses the skipped circle')] + #[DataSet([Status::Risky, '?'], 'risky renders the question mark')] + public function finishedTestRendersItsStatusSymbol(Status $status, string $symbol): void + { + $test = self::test('passingTest', $status); + + $output = self::withoutColors(static fn(): string => self::capture( + static fn(TerminalLogger $logger) => $logger->handleTestResult($test, 0), + )); + + Assert::string($output)->contains("{$symbol} passingTest"); + } + + public function verboseRunStreamsAnOwnedTestsChannelOutputLive(): void + { + $test = self::test('passingTest', Status::Passed); + $id = $test->info->identity->pipelineId; + + $output = self::capture( + static fn(TerminalLogger $logger) => $logger->logMessage( + new Message(0.0, 'stdout', Level::Info, "streamed live\n"), + $id, + ), + verbosity: Verbosity::Verbose, + ); + + Assert::string($output)->contains('[stdout]')->contains('streamed live'); + } + + public function verboseRunStreamsUnownedChannelOutputThroughTheSharedGroup(): void + { + $output = self::capture( + static fn(TerminalLogger $logger) => $logger->logMessage( + new Message(0.0, 'stdout', Level::Info, "unowned line\n"), + null, + ), + verbosity: Verbosity::Verbose, + ); + + Assert::string($output)->contains('[stdout]')->contains('unowned line'); + } + + public function channelOutputStaysSilentWhenThereIsNothingToStream(): void + { + // Empty content is dropped before anything is written. + $empty = self::capture( + static fn(TerminalLogger $logger) => $logger->logMessage( + new Message(0.0, 'stdout', Level::Info, ''), + 1, + ), + verbosity: Verbosity::Verbose, + ); + Assert::same($empty, ''); + + // A non-empty channel message is suppressed below Verbose (nothing streams live at Normal). + $normal = self::capture( + static fn(TerminalLogger $logger) => $logger->logMessage( + new Message(0.0, 'stdout', Level::Info, 'noise'), + 1, + ), + ); + Assert::same($normal, ''); + } + + public function resetChannelsReopensTheChannelHeaderForTheNextMessage(): void + { + $test = self::test('passingTest', Status::Passed); + $info = $test->info; + $id = $info->identity->pipelineId; + + $output = self::capture( + static function (TerminalLogger $logger) use ($info, $id): void { + $logger->logMessage(new Message(0.0, 'stdout', Level::Info, "first\n"), $id); + // Without the reset the second same-channel message would append without a header. + $logger->resetChannels($info); + $logger->logMessage(new Message(0.0, 'stdout', Level::Info, "second\n"), $id); + }, + verbosity: Verbosity::Verbose, + ); + + Assert::same(\substr_count($output, '[stdout]'), 2); + } + + public function regularTestStartClearsAPreviouslyRecordedOverrideName(): void + { + $test = self::test('passingTest', Status::Passed); + + $output = self::capture(static function (TerminalLogger $logger) use ($test): void { + $logger->testStartedFromInfo($test->info, 'Dataset #0 [x]'); + // A regular (non-dataset) start carries no override and must clear the recorded one. + $logger->testStartedFromInfo($test->info); + $logger->handleTestResult($test, 0); + }); + + Assert::string($output)->contains('passingTest')->notContains('Dataset #0 [x]'); + } + + public function batchStartInDotsModeEmitsNoBatchNode(): void + { + $test = self::test('describedTest', Status::Passed, attributes: ['description' => 'batch note']); + + $output = self::capture( + static fn(TerminalLogger $logger) => $logger->batchStartedFromInfo($test->info), + format: OutputFormat::Dots, + ); + + // The single-character Dots layout has no room for a batch node or its description. + Assert::same($output, ''); + } + + public function multipleRunsAreListedUnderThePassingTest(): void + { + $runA = self::test('passingTest', Status::Passed); + $runB = self::test('passingTest', Status::Failed); + $test = self::test('passingTest', Status::Passed, attributes: [ + MultipleResult::class => new MultipleResult(['first' => $runA, 'second' => $runB]), + ]); + + $output = self::capture(static fn(TerminalLogger $logger) => $logger->handleTestResult($test, 0)); + + Assert::string($output)->contains('Run #1')->contains('Run #2'); + } + + public function multipleRunsAreOmittedInDotsMode(): void + { + $runA = self::test('passingTest', Status::Passed); + $test = self::test('passingTest', Status::Passed, attributes: [ + MultipleResult::class => new MultipleResult(['only' => $runA]), + ]); + + $output = self::capture( + static fn(TerminalLogger $logger) => $logger->handleTestResult($test, 0), + format: OutputFormat::Dots, + ); + + // Dots collapses each test to a single character; per-run rows would break that layout. + Assert::same($output, '.'); + } + + public function printReportStatesTheNamePathAndFormat(): void + { + $path = new class implements \Stringable { + #[\Override] + public function __toString(): string + { + return 'build/report.xml'; + } + }; + $report = new ReportInfo('junit', 'JUnit', $path); + + $output = self::capture(static fn(TerminalLogger $logger) => $logger->printReport($report)); + + Assert::string($output) + ->contains('JUnit:') + ->contains('build/report.xml') + ->contains('(junit)'); + } + + public function ensureHeaderPrintsTheRunHeader(): void + { + $output = self::capture(static fn(TerminalLogger $logger) => $logger->ensureHeader()); + + Assert::string($output)->contains(Info::NAME); + } + + public function printEnvironmentReportsTheHostFacts(): void + { + $output = self::capture(static fn(TerminalLogger $logger) => $logger->printEnvironment()); + + Assert::string($output) + ->contains('OS:') + ->contains('PHP:') + ->contains('XDebug:') + ->contains('OPcache:'); + } + + public function emptyRunPrintsTheNoTestsBanner(): void + { + $run = self::run([], Status::Passed, summary: new Summary([])); + + $output = self::render($run, handled: []); + + Assert::string($output)->contains('NO TESTS'); + } + + public function failedTestWithoutAThrowableFallsBackToAGenericMessage(): void + { + $failed = self::test('failingTest', Status::Failed); + $run = self::run([$failed], Status::Failed, summary: new Summary(['Failed' => 1])); + + $output = self::render($run, handled: [$failed]); + + Assert::string($output)->contains('Failures:')->contains('Test failed'); + } + + public function comparisonFailureRendersADiffBlockInTheFailureDetail(): void + { + $failure = new ComparisonFailure( + expected: 'foo', + actual: 'bar', + value: 'value', + assertion: 'is the same', + context: '', + reason: 'values differ', + ); + $failed = self::test('failingTest', Status::Failed, failure: $failure); + $run = self::run([$failed], Status::Failed, summary: new Summary(['Failed' => 1])); + + $output = self::render($run, handled: [$failed]); + + Assert::string($output) + ->contains('--- Expected') + ->contains('+++ Actual') + ->contains('- foo') + ->contains('+ bar'); + } + + public function failureOfAFilelessTestDefinitionStillRenders(): void + { + // An internal function reflection reports no file and no line, so the failure detail carries no + // location header — the block must still render the name and message. + $info = new TestInfo( + name: 'internalFn', + caseInfo: new CaseInfo( + suiteIdentity: new SuiteIdentity('Output/Unit'), + definition: new CaseDefinition( + name: SampleTestClass::class, + type: 'test', + file: Path::create(__FILE__), + reflection: new \ReflectionClass(SampleTestClass::class), + ), + ), + testDefinition: new TestDefinition(new \ReflectionFunction('strlen')), + ); + $failed = self::test( + 'failingTest', + Status::Failed, + failure: new \RuntimeException('no file here'), + info: $info, + ); + $run = self::run([$failed], Status::Failed, summary: new Summary(['Failed' => 1])); + + $output = self::render($run, handled: [$failed]); + + Assert::string($output)->contains('internalFn')->contains('no file here'); + } + /** * Last non-empty line of the rendered output, for comparing one report line exactly. */ @@ -260,13 +544,16 @@ private static function lastLine(string $output): string * * @param \Closure(TerminalLogger): void $scenario */ - private static function capture(\Closure $scenario): string - { + private static function capture( + \Closure $scenario, + OutputFormat $format = OutputFormat::Compact, + Verbosity $verbosity = Verbosity::Normal, + ): string { $stream = \fopen('php://memory', 'rb+'); \assert($stream !== false); try { - $scenario(new TerminalLogger(OutputFormat::Compact, Verbosity::Normal, $stream)); + $scenario(new TerminalLogger($format, $verbosity, $stream)); \rewind($stream); $output = \stream_get_contents($stream); } finally { @@ -276,6 +563,25 @@ private static function capture(\Closure $scenario): string return $output === false ? '' : $output; } + /** + * Renders with colorization forced off so exact-structure assertions read plain text. The flag is + * process-global in {@see Style}, so its previous value is restored to avoid leaking into whatever + * case runs next. + * + * @param \Closure(): string $render + */ + private static function withoutColors(\Closure $render): string + { + $colors = Style::areColorsEnabled(); + Style::setColorsEnabled(false); + + try { + return $render(); + } finally { + Style::setColorsEnabled($colors); + } + } + /** * Feeds each handled result through {@see TerminalLogger::handleTestResult()} (as the plugin does * for every test / data set), then prints the summary into an in-memory stream and returns what