diff --git a/core/Output/Terminal/Renderer/Formatter.php b/core/Output/Terminal/Renderer/Formatter.php index 38ff73ee..12a8389d 100644 --- a/core/Output/Terminal/Renderer/Formatter.php +++ b/core/Output/Terminal/Renderer/Formatter.php @@ -324,6 +324,27 @@ public static function comparisonBlock(ComparisonFailure $failure): string return \implode("\n", $lines); } + /** + * Formats a description block indented under an item at the given nesting level. Returns an empty + * string when there is no description. Used both for plain test runs and for the DataProvider + * batch node, so the description shows once at the root of the dataset tree instead of repeating + * under every dataset. + */ + public static function description(string $description, int $indentLevel, OutputFormat $format): string + { + if ($description === '' || $format === OutputFormat::Dots) { + return ''; + } + + $baseIndent = $format === OutputFormat::Verbose ? self::INDENT_VERBOSE : self::INDENT_COMPACT; + $descriptionPadding = $baseIndent . \str_repeat(self::INDENT_STEP, $indentLevel) . self::INDENT_STEP; + $descriptionStr = Style::dim( + \str_replace("\n", "\n{$descriptionPadding}", $description), + ); + + return "{$descriptionPadding}{$descriptionStr}\n"; + } + /** * Renders one row of a summary block: a dim, fixed-width label followed by its (already styled) * value. An empty label produces a continuation row aligned under the value column, so the parts @@ -369,14 +390,7 @@ private static function formatCompactRun(FormattedItem $item, OutputFormat $form : ''; $result = "{$indent}{$symbol} {$item->name}{$durationStr}\n"; - - if ($item->description !== '') { - $descriptionPadding = "{$indent} "; - $descriptionStr = Style::dim( - \str_replace("\n", "\n{$descriptionPadding}", $item->description), - ); - $result .= "{$descriptionPadding}{$descriptionStr}\n"; - } + $result .= self::description($item->description, $item->indentLevel, $format); return $result; } diff --git a/core/Output/Terminal/Renderer/TerminalLogger.php b/core/Output/Terminal/Renderer/TerminalLogger.php index ced42477..9718d4a4 100644 --- a/core/Output/Terminal/Renderer/TerminalLogger.php +++ b/core/Output/Terminal/Renderer/TerminalLogger.php @@ -123,6 +123,14 @@ public function batchStartedFromInfo(TestInfo $info): void $indent = $this->format === OutputFormat::Verbose ? ' ' : ' '; $symbol = Style::dim(Symbol::DataProvider->value); $this->write("{$indent}{$symbol} {$info->name}\n"); + + // The description belongs to the test, not to each dataset — print it once here, at the root + // of the dataset tree, so it is not repeated under every dataset (see handle*Test). + $this->write(Formatter::description( + (string) $info->testDefinition->getDescription(), + 0, + $this->format, + )); } /** @@ -326,7 +334,7 @@ private function handlePassedTest(TestResult $result, ?int $duration): void status: $result->status, duration: $duration, indentLevel: $this->currentIndentLevel, - description: (string) $result->getAttribute('description'), + description: $this->resultDescription($result), ); $this->write(Formatter::formatRun($item, $this->format)); @@ -353,7 +361,7 @@ private function handleFailedTest(TestResult $result, ?int $duration): void status: $result->status, duration: $duration, indentLevel: $this->currentIndentLevel, - description: (string) $result->getAttribute('description'), + description: $this->resultDescription($result), ); $this->write(Formatter::formatRun($item, $this->format)); @@ -361,6 +369,16 @@ private function handleFailedTest(TestResult $result, ?int $duration): void $this->currentTestName = null; } + /** + * The description to show under a test run. Inside a DataProvider batch it is already printed once + * at the batch node ({@see batchStartedFromInfo}), so it is suppressed here to avoid repeating the + * same description under every dataset. + */ + private function resultDescription(TestResult $result): string + { + return $this->currentIndentLevel > 0 ? '' : (string) $result->getAttribute('description'); + } + /** * Prints multiple test runs if available. */ diff --git a/tests/Output/Stub/JUnit/SampleTestClass.php b/tests/Output/Stub/JUnit/SampleTestClass.php index 0c993a87..6e89c913 100644 --- a/tests/Output/Stub/JUnit/SampleTestClass.php +++ b/tests/Output/Stub/JUnit/SampleTestClass.php @@ -12,4 +12,9 @@ final class SampleTestClass public function passingTest(): void {} public function failingTest(): void {} + + /** + * Sample description line. + */ + public function describedTest(): void {} } diff --git a/tests/Output/Unit/Terminal/TerminalLoggerTest.php b/tests/Output/Unit/Terminal/TerminalLoggerTest.php index 45e5c4e9..46740728 100644 --- a/tests/Output/Unit/Terminal/TerminalLoggerTest.php +++ b/tests/Output/Unit/Terminal/TerminalLoggerTest.php @@ -99,6 +99,35 @@ public function dataSetIsTreatedAsAnOrdinarySingleTest(): void Assert::string($output)->contains('single dataset output'); } + public function dataProviderDescriptionIsPrintedOnceAtTheBatchNode(): void + { + // A DataProvider test's description belongs to the method, not to each dataset — the datasets + // carry the same 'description' attribute the runner copies onto every result. It must appear + // once under the batch node and never repeat under the individual datasets. + $description = 'Sample description line.'; + $first = self::test('describedTest', Status::Passed, attributes: ['description' => $description]); + $second = self::test('describedTest', Status::Passed, attributes: ['description' => $description]); + + $output = self::renderBatch($first->info, [ + 'Dataset #0 [0]' => $first, + 'Dataset #1 [1]' => $second, + ]); + + Assert::same(\substr_count($output, $description), 1); + } + + public function regularTestStillPrintsItsDescription(): void + { + // A test without a DataProvider has no batch node, so its description must still print under + // the test itself. + $description = 'Sample description line.'; + $test = self::test('describedTest', Status::Passed, attributes: ['description' => $description]); + + $output = self::render(self::run([$test], Status::Passed), handled: [$test]); + + Assert::string($output)->contains($description); + } + protected function setUp(): void { // Strip ANSI styling so assertions match raw text regardless of TTY config. @@ -140,6 +169,35 @@ private static function render( return $output === false ? '' : $output; } + /** + * Drives the logger through a DataProvider batch the way {@see \Testo\Output\Terminal\TerminalPlugin} + * does — a batch node followed by each dataset keyed by its display name — and returns what was + * written. + * + * @param array $datasets Dataset display name => result, in order. + */ + private static function renderBatch(TestInfo $info, array $datasets): string + { + $stream = \fopen('php://memory', 'rb+'); + \assert($stream !== false); + + try { + $logger = new TerminalLogger(OutputFormat::Compact, Verbosity::Normal, $stream); + $logger->batchStartedFromInfo($info); + foreach ($datasets as $name => $result) { + $logger->testStartedFromInfo($info, $name); + $logger->handleTestResult($result, 0); + } + $logger->batchFinishedFromInfo($info); + \rewind($stream); + $output = \stream_get_contents($stream); + } finally { + \fclose($stream); + } + + return $output === false ? '' : $output; + } + /** * Wraps the given test results in a single suite/case. * @@ -154,11 +212,15 @@ private static function run(array $results, Status $status, ?Summary $summary = return new RunResult([$suite], $status, 0.0, $summary); } + /** + * @param array $attributes + */ private static function test( string $method, Status $status, ?\Throwable $failure = null, ?MessageLog $messages = null, + array $attributes = [], ): TestResult { $info = new TestInfo( name: $method, @@ -176,6 +238,7 @@ private static function test( info: $info, status: $status, failure: $failure, + attributes: $attributes, messages: $messages ?? new MessageLog(), ); }