From c765b5f1c5439042c3e97ab283db4f253ebc2901 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 2 Mar 2026 16:27:48 +0900 Subject: [PATCH 1/5] feat(phpunit): add ConsoleOutput enum and ConsoleCoverageRenderer Introduce ConsoleOutput string-backed enum with DEFAULT, ALL, and UNCOVERED_ONLY cases plus a resolve() factory that handles env var > parameter > default priority. Add ConsoleCoverageRenderer with a static render() method that produces console coverage output supporting the three display modes. Ref #27 --- src/PHPUnit/ConsoleCoverageRenderer.php | 92 +++++++++++++++++++++++++ src/PHPUnit/ConsoleOutput.php | 40 +++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/PHPUnit/ConsoleCoverageRenderer.php create mode 100644 src/PHPUnit/ConsoleOutput.php diff --git a/src/PHPUnit/ConsoleCoverageRenderer.php b/src/PHPUnit/ConsoleCoverageRenderer.php new file mode 100644 index 0000000..1ee7974 --- /dev/null +++ b/src/PHPUnit/ConsoleCoverageRenderer.php @@ -0,0 +1,92 @@ + $results + */ + public static function render(array $results, ConsoleOutput $consoleOutput = ConsoleOutput::DEFAULT): string + { + if ($results === []) { + return ''; + } + + $output = "\n\n"; + $output .= "OpenAPI Contract Test Coverage\n"; + $output .= str_repeat('=', 50) . "\n"; + + foreach ($results as $spec => $result) { + $percentage = self::percentage($result['coveredCount'], $result['total']); + $uncoveredCount = $result['total'] - $result['coveredCount']; + + $output .= "\n[{$spec}] {$result['coveredCount']}/{$result['total']} endpoints ({$percentage}%)\n"; + $output .= str_repeat('-', 50) . "\n"; + + $output .= self::renderCovered($result, $consoleOutput, $uncoveredCount); + $output .= self::renderUncovered($result, $consoleOutput, $uncoveredCount); + } + + $output .= "\n"; + + return $output; + } + + /** + * @param CoverageResult $result + */ + private static function renderCovered(array $result, ConsoleOutput $consoleOutput, int $uncoveredCount): string + { + if ($result['covered'] === []) { + return ''; + } + + if ($consoleOutput === ConsoleOutput::UNCOVERED_ONLY) { + return "Covered: {$result['coveredCount']} endpoints\n"; + } + + $output = "Covered:\n"; + + foreach ($result['covered'] as $endpoint) { + $output .= " ✓ {$endpoint}\n"; + } + + return $output; + } + + /** + * @param CoverageResult $result + */ + private static function renderUncovered(array $result, ConsoleOutput $consoleOutput, int $uncoveredCount): string + { + if ($uncoveredCount <= 0) { + return ''; + } + + if ($consoleOutput === ConsoleOutput::DEFAULT) { + return "Uncovered: {$uncoveredCount} endpoints\n"; + } + + $output = "Uncovered:\n"; + + foreach ($result['uncovered'] as $endpoint) { + $output .= " ✗ {$endpoint}\n"; + } + + return $output; + } + + private static function percentage(int $covered, int $total): float|int + { + return $total > 0 ? round($covered / $total * 100, 1) : 0; + } +} diff --git a/src/PHPUnit/ConsoleOutput.php b/src/PHPUnit/ConsoleOutput.php new file mode 100644 index 0000000..92b1292 --- /dev/null +++ b/src/PHPUnit/ConsoleOutput.php @@ -0,0 +1,40 @@ + parameter > DEFAULT. + */ + public static function resolve(?string $parameterValue): self + { + $envValue = getenv('OPENAPI_CONSOLE_OUTPUT'); + + if ($envValue !== false && trim($envValue) !== '') { + $resolved = self::tryFrom(mb_strtolower(trim($envValue))); + + return $resolved ?? self::DEFAULT; + } + + if ($parameterValue !== null && trim($parameterValue) !== '') { + $resolved = self::tryFrom(mb_strtolower(trim($parameterValue))); + + return $resolved ?? self::DEFAULT; + } + + return self::DEFAULT; + } +} From 3e1cca80393f662f7d9d8cedcc2a6c21d1ac5d82 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 2 Mar 2026 16:28:16 +0900 Subject: [PATCH 2/5] refactor(phpunit): delegate console output to ConsoleCoverageRenderer Integrate ConsoleOutput enum and ConsoleCoverageRenderer into OpenApiCoverageExtension, replacing inline printReport() and percentage() methods. Parse console_output parameter and pass ConsoleOutput to the subscriber. Refs: #27 --- src/PHPUnit/OpenApiCoverageExtension.php | 49 ++++-------------------- 1 file changed, 7 insertions(+), 42 deletions(-) diff --git a/src/PHPUnit/OpenApiCoverageExtension.php b/src/PHPUnit/OpenApiCoverageExtension.php index 662ea8a..a995ad6 100644 --- a/src/PHPUnit/OpenApiCoverageExtension.php +++ b/src/PHPUnit/OpenApiCoverageExtension.php @@ -23,8 +23,6 @@ use function fwrite; use function getcwd; use function getenv; -use function round; -use function str_repeat; use function str_starts_with; final class OpenApiCoverageExtension implements Extension @@ -59,15 +57,20 @@ public function bootstrap(Configuration $configuration, Facade $facade, Paramete } } + $consoleOutput = ConsoleOutput::resolve( + $parameters->has('console_output') ? $parameters->get('console_output') : null, + ); + $githubSummaryPath = getenv('GITHUB_STEP_SUMMARY') ?: null; - $facade->registerSubscriber(new class ($specs, $outputFile, $githubSummaryPath) implements ExecutionFinishedSubscriber { + $facade->registerSubscriber(new class ($specs, $outputFile, $consoleOutput, $githubSummaryPath) implements ExecutionFinishedSubscriber { /** * @param string[] $specs */ public function __construct( private readonly array $specs, private readonly ?string $outputFile, + private readonly ConsoleOutput $consoleOutput, private readonly ?string $githubSummaryPath, ) {} @@ -80,7 +83,7 @@ public function notify(ExecutionFinished $event): void return; } - $this->printReport($results); + echo ConsoleCoverageRenderer::render($results, $this->consoleOutput); if ($this->outputFile !== null || $this->githubSummaryPath !== null) { $this->writeMarkdownReport($results); @@ -122,39 +125,6 @@ private function computeAllResults(): array return $results; } - /** - * @param array $results - */ - private function printReport(array $results): void - { - echo "\n\n"; - echo "OpenAPI Contract Test Coverage\n"; - echo str_repeat('=', 50) . "\n"; - - foreach ($results as $spec => $result) { - $percentage = self::percentage($result['coveredCount'], $result['total']); - - echo "\n[{$spec}] {$result['coveredCount']}/{$result['total']} endpoints ({$percentage}%)\n"; - echo str_repeat('-', 50) . "\n"; - - if ($result['covered'] !== []) { - echo "Covered:\n"; - - foreach ($result['covered'] as $endpoint) { - echo " ✓ {$endpoint}\n"; - } - } - - $uncoveredCount = $result['total'] - $result['coveredCount']; - - if ($uncoveredCount > 0) { - echo "Uncovered: {$uncoveredCount} endpoints\n"; - } - } - - echo "\n"; - } - /** * @param array $results */ @@ -178,11 +148,6 @@ private function writeMarkdownReport(array $results): void } } } - - private static function percentage(int $covered, int $total): float|int - { - return $total > 0 ? round($covered / $total * 100, 1) : 0; - } }); } } From b2cfabc779e255e3fd84c210799ccee779057a0e Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 2 Mar 2026 16:28:44 +0900 Subject: [PATCH 3/5] test: add unit tests for ConsoleOutput and ConsoleCoverageRenderer --- tests/Unit/ConsoleCoverageRendererTest.php | 243 +++++++++++++++++++++ tests/Unit/ConsoleOutputTest.php | 135 ++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 tests/Unit/ConsoleCoverageRendererTest.php create mode 100644 tests/Unit/ConsoleOutputTest.php diff --git a/tests/Unit/ConsoleCoverageRendererTest.php b/tests/Unit/ConsoleCoverageRendererTest.php new file mode 100644 index 0000000..47fe714 --- /dev/null +++ b/tests/Unit/ConsoleCoverageRendererTest.php @@ -0,0 +1,243 @@ +assertSame('', ConsoleCoverageRenderer::render([])); + } + + #[Test] + public function render_default_mode_shows_covered_list_and_uncovered_count(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => ['DELETE /v1/pets/{petId}', 'GET /v1/pets/{petId}'], + 'total' => 4, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('OpenAPI Contract Test Coverage', $output); + $this->assertStringContainsString('[front] 2/4 endpoints (50%)', $output); + $this->assertStringContainsString('Covered:', $output); + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringContainsString(' ✓ POST /v1/pets', $output); + $this->assertStringContainsString('Uncovered: 2 endpoints', $output); + $this->assertStringNotContainsString(' ✗', $output); + } + + #[Test] + public function render_all_mode_shows_both_covered_and_uncovered_lists(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => ['DELETE /v1/pets/{petId}', 'GET /v1/pets/{petId}'], + 'total' => 4, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString('Covered:', $output); + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringContainsString(' ✓ POST /v1/pets', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ DELETE /v1/pets/{petId}', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets/{petId}', $output); + $this->assertStringNotContainsString('Uncovered: 2 endpoints', $output); + } + + #[Test] + public function render_uncovered_only_mode_shows_uncovered_list_and_covered_count(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => ['DELETE /v1/pets/{petId}', 'GET /v1/pets/{petId}'], + 'total' => 4, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::UNCOVERED_ONLY); + + $this->assertStringContainsString('Covered: 2 endpoints', $output); + $this->assertStringNotContainsString(' ✓', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ DELETE /v1/pets/{petId}', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets/{petId}', $output); + } + + #[Test] + public function render_full_coverage_default_mode_shows_no_uncovered_section(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => [], + 'total' => 2, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('[front] 2/2 endpoints (100%)', $output); + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } + + #[Test] + public function render_full_coverage_all_mode_shows_no_uncovered_section(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => [], + 'total' => 2, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString(' ✓ GET /v1/pets', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } + + #[Test] + public function render_zero_coverage_default_mode_shows_uncovered_count(): void + { + $results = [ + 'front' => [ + 'covered' => [], + 'uncovered' => ['GET /v1/pets', 'POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('[front] 0/2 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered:', $output); + $this->assertStringContainsString('Uncovered: 2 endpoints', $output); + } + + #[Test] + public function render_zero_coverage_uncovered_only_mode_shows_uncovered_list(): void + { + $results = [ + 'front' => [ + 'covered' => [], + 'uncovered' => ['GET /v1/pets', 'POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::UNCOVERED_ONLY); + + $this->assertStringContainsString('[front] 0/2 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered:', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets', $output); + $this->assertStringContainsString(' ✗ POST /v1/pets', $output); + } + + #[Test] + public function render_multiple_specs(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => ['POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 1, + ], + 'admin' => [ + 'covered' => ['GET /v1/users'], + 'uncovered' => [], + 'total' => 1, + 'coveredCount' => 1, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + + $this->assertStringContainsString('[front] 1/2 endpoints (50%)', $output); + $this->assertStringContainsString('[admin] 1/1 endpoints (100%)', $output); + } + + #[Test] + public function render_defaults_to_default_mode(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => ['POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 1, + ], + ]; + + $withExplicitDefault = ConsoleCoverageRenderer::render($results, ConsoleOutput::DEFAULT); + $withImplicitDefault = ConsoleCoverageRenderer::render($results); + + $this->assertSame($withExplicitDefault, $withImplicitDefault); + } + + #[Test] + public function render_header_and_separators_are_present(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => [], + 'total' => 1, + 'coveredCount' => 1, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results); + + $this->assertStringContainsString('OpenAPI Contract Test Coverage', $output); + $this->assertStringContainsString('==================================================', $output); + $this->assertStringContainsString('--------------------------------------------------', $output); + } + + #[Test] + public function render_spec_with_zero_endpoints(): void + { + $results = [ + 'empty' => [ + 'covered' => [], + 'uncovered' => [], + 'total' => 0, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString('[empty] 0/0 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } +} diff --git a/tests/Unit/ConsoleOutputTest.php b/tests/Unit/ConsoleOutputTest.php new file mode 100644 index 0000000..6883157 --- /dev/null +++ b/tests/Unit/ConsoleOutputTest.php @@ -0,0 +1,135 @@ +assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve(null)); + } + + #[Test] + public function resolve_returns_default_when_parameter_is_empty_string(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('')); + } + + #[Test] + public function resolve_returns_default_when_parameter_is_whitespace(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve(' ')); + } + + #[Test] + public function resolve_returns_all_from_parameter(): void + { + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_returns_uncovered_only_from_parameter(): void + { + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('uncovered_only')); + } + + #[Test] + public function resolve_returns_default_from_parameter(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('default')); + } + + #[Test] + public function resolve_is_case_insensitive_for_parameter(): void + { + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('ALL')); + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('All')); + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('UNCOVERED_ONLY')); + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('Uncovered_Only')); + } + + #[Test] + public function resolve_trims_whitespace_from_parameter(): void + { + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve(' all ')); + } + + #[Test] + public function resolve_returns_default_for_invalid_parameter(): void + { + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('invalid')); + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('covered_only')); + } + + #[Test] + public function resolve_env_overrides_parameter(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT=uncovered_only'); + + $this->assertSame(ConsoleOutput::UNCOVERED_ONLY, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_env_is_case_insensitive(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT=ALL'); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve(null)); + } + + #[Test] + public function resolve_env_trims_whitespace(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT= all '); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve(null)); + } + + #[Test] + public function resolve_invalid_env_falls_back_to_default(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT=invalid'); + + $this->assertSame(ConsoleOutput::DEFAULT, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_empty_env_uses_parameter(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT='); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('all')); + } + + #[Test] + public function resolve_whitespace_only_env_uses_parameter(): void + { + putenv('OPENAPI_CONSOLE_OUTPUT= '); + + $this->assertSame(ConsoleOutput::ALL, ConsoleOutput::resolve('all')); + } +} From e7113c045409bffe7a2d00f53dbfe7273fe79189 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 2 Mar 2026 16:29:10 +0900 Subject: [PATCH 4/5] docs(readme): add console_output parameter documentation Document the console_output parameter and OPENAPI_CONSOLE_OUTPUT environment variable. Add output examples for all three modes (default, all, uncovered_only) and a GitHub Actions CI example. Refs #27 --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 42b96b3..e95d2b8 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Add the coverage extension to your `phpunit.xml`: | `strip_prefixes` | No | `[]` | Comma-separated prefixes to strip from request paths (e.g., `/api`) | | `specs` | No | `front` | Comma-separated spec names for coverage tracking | | `output_file` | No | — | File path to write Markdown coverage report (relative paths resolve from `getcwd()`) | +| `console_output` | No | `default` | Console output mode: `default`, `all`, or `uncovered_only` (overridden by `OPENAPI_CONSOLE_OUTPUT` env var) | *Not required if you call `OpenApiSpecLoader::configure()` manually. @@ -163,7 +164,11 @@ For Laravel, set the `max_errors` key in `config/openapi-contract-testing.php`. ## Coverage Report -After running tests, the PHPUnit extension prints a coverage report: +After running tests, the PHPUnit extension prints a coverage report. The output format is controlled by the `console_output` parameter (or `OPENAPI_CONSOLE_OUTPUT` environment variable). + +### `default` mode (default) + +Shows covered endpoints individually and uncovered as a count: ``` OpenAPI Contract Test Coverage @@ -179,6 +184,56 @@ Covered: Uncovered: 41 endpoints ``` +### `all` mode + +Shows both covered and uncovered endpoints individually: + +``` +OpenAPI Contract Test Coverage +================================================== + +[front] 12/45 endpoints (26.7%) +-------------------------------------------------- +Covered: + ✓ GET /v1/pets + ✓ POST /v1/pets + ✓ GET /v1/pets/{petId} + ✓ DELETE /v1/pets/{petId} +Uncovered: + ✗ PUT /v1/pets/{petId} + ✗ GET /v1/owners + ... +``` + +### `uncovered_only` mode + +Shows uncovered endpoints individually and covered as a count — useful for large APIs where you want to focus on missing coverage: + +``` +OpenAPI Contract Test Coverage +================================================== + +[front] 12/45 endpoints (26.7%) +-------------------------------------------------- +Covered: 12 endpoints +Uncovered: + ✗ PUT /v1/pets/{petId} + ✗ GET /v1/owners + ... +``` + +You can set the mode via `phpunit.xml`: + +```xml + +``` + +Or via environment variable (takes priority over `phpunit.xml`): + +```bash +OPENAPI_CONSOLE_OUTPUT=uncovered_only vendor/bin/phpunit +``` + ## CI Integration ### GitHub Actions Step Summary @@ -201,6 +256,15 @@ Use the `output_file` parameter to write a Markdown report to a file. This is us ``` +You can also use the `OPENAPI_CONSOLE_OUTPUT` environment variable in CI to show uncovered endpoints in the job log: + +```yaml +- name: Run tests (show uncovered endpoints) + run: vendor/bin/phpunit + env: + OPENAPI_CONSOLE_OUTPUT: uncovered_only +``` + Example GitHub Actions workflow step to post the report as a PR comment: ```yaml From 9ae5215423c658e4a9b2f26e064608201117ee2b Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 2 Mar 2026 17:08:41 +0900 Subject: [PATCH 5/5] fix(phpunit): add warnings for invalid console_output and simplify renderer logic Add stderr warnings when invalid values are provided for the OPENAPI_CONSOLE_OUTPUT env var or console_output phpunit.xml parameter. Remove arithmetic-derived uncoveredCount in favor of count() on the actual uncovered array. Add edge-case tests for zero coverage, full coverage, and percentage rounding. --- src/PHPUnit/ConsoleCoverageRenderer.php | 14 +++--- src/PHPUnit/ConsoleOutput.php | 11 +++++ tests/Unit/ConsoleCoverageRendererTest.php | 56 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/PHPUnit/ConsoleCoverageRenderer.php b/src/PHPUnit/ConsoleCoverageRenderer.php index 1ee7974..e39f8ce 100644 --- a/src/PHPUnit/ConsoleCoverageRenderer.php +++ b/src/PHPUnit/ConsoleCoverageRenderer.php @@ -4,6 +4,7 @@ namespace Studio\OpenApiContractTesting\PHPUnit; +use function count; use function round; use function str_repeat; @@ -27,13 +28,12 @@ public static function render(array $results, ConsoleOutput $consoleOutput = Con foreach ($results as $spec => $result) { $percentage = self::percentage($result['coveredCount'], $result['total']); - $uncoveredCount = $result['total'] - $result['coveredCount']; $output .= "\n[{$spec}] {$result['coveredCount']}/{$result['total']} endpoints ({$percentage}%)\n"; $output .= str_repeat('-', 50) . "\n"; - $output .= self::renderCovered($result, $consoleOutput, $uncoveredCount); - $output .= self::renderUncovered($result, $consoleOutput, $uncoveredCount); + $output .= self::renderCovered($result, $consoleOutput); + $output .= self::renderUncovered($result, $consoleOutput); } $output .= "\n"; @@ -44,7 +44,7 @@ public static function render(array $results, ConsoleOutput $consoleOutput = Con /** * @param CoverageResult $result */ - private static function renderCovered(array $result, ConsoleOutput $consoleOutput, int $uncoveredCount): string + private static function renderCovered(array $result, ConsoleOutput $consoleOutput): string { if ($result['covered'] === []) { return ''; @@ -66,12 +66,14 @@ private static function renderCovered(array $result, ConsoleOutput $consoleOutpu /** * @param CoverageResult $result */ - private static function renderUncovered(array $result, ConsoleOutput $consoleOutput, int $uncoveredCount): string + private static function renderUncovered(array $result, ConsoleOutput $consoleOutput): string { - if ($uncoveredCount <= 0) { + if ($result['uncovered'] === []) { return ''; } + $uncoveredCount = count($result['uncovered']); + if ($consoleOutput === ConsoleOutput::DEFAULT) { return "Uncovered: {$uncoveredCount} endpoints\n"; } diff --git a/src/PHPUnit/ConsoleOutput.php b/src/PHPUnit/ConsoleOutput.php index 92b1292..aca7096 100644 --- a/src/PHPUnit/ConsoleOutput.php +++ b/src/PHPUnit/ConsoleOutput.php @@ -4,6 +4,9 @@ namespace Studio\OpenApiContractTesting\PHPUnit; +use const STDERR; + +use function fwrite; use function getenv; use function mb_strtolower; use function trim; @@ -26,12 +29,20 @@ public static function resolve(?string $parameterValue): self if ($envValue !== false && trim($envValue) !== '') { $resolved = self::tryFrom(mb_strtolower(trim($envValue))); + if ($resolved === null) { + fwrite(STDERR, "[OpenAPI Coverage] WARNING: Invalid OPENAPI_CONSOLE_OUTPUT value '{$envValue}'. Valid values: default, all, uncovered_only. Falling back to 'default'.\n"); + } + return $resolved ?? self::DEFAULT; } if ($parameterValue !== null && trim($parameterValue) !== '') { $resolved = self::tryFrom(mb_strtolower(trim($parameterValue))); + if ($resolved === null) { + fwrite(STDERR, "[OpenAPI Coverage] WARNING: Invalid console_output parameter '{$parameterValue}'. Valid values: default, all, uncovered_only. Falling back to 'default'.\n"); + } + return $resolved ?? self::DEFAULT; } diff --git a/tests/Unit/ConsoleCoverageRendererTest.php b/tests/Unit/ConsoleCoverageRendererTest.php index 47fe714..6967c0a 100644 --- a/tests/Unit/ConsoleCoverageRendererTest.php +++ b/tests/Unit/ConsoleCoverageRendererTest.php @@ -222,6 +222,62 @@ public function render_header_and_separators_are_present(): void $this->assertStringContainsString('--------------------------------------------------', $output); } + #[Test] + public function render_zero_coverage_all_mode_shows_uncovered_list_only(): void + { + $results = [ + 'front' => [ + 'covered' => [], + 'uncovered' => ['GET /v1/pets', 'POST /v1/pets'], + 'total' => 2, + 'coveredCount' => 0, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::ALL); + + $this->assertStringContainsString('[front] 0/2 endpoints (0%)', $output); + $this->assertStringNotContainsString('Covered', $output); + $this->assertStringContainsString('Uncovered:', $output); + $this->assertStringContainsString(' ✗ GET /v1/pets', $output); + } + + #[Test] + public function render_full_coverage_uncovered_only_mode_shows_covered_count_only(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets', 'POST /v1/pets'], + 'uncovered' => [], + 'total' => 2, + 'coveredCount' => 2, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results, ConsoleOutput::UNCOVERED_ONLY); + + $this->assertStringContainsString('Covered: 2 endpoints', $output); + $this->assertStringNotContainsString(' ✓', $output); + $this->assertStringNotContainsString('Uncovered', $output); + } + + #[Test] + public function render_percentage_rounds_to_one_decimal_place(): void + { + $results = [ + 'front' => [ + 'covered' => ['GET /v1/pets'], + 'uncovered' => ['POST /v1/pets', 'DELETE /v1/pets/{petId}'], + 'total' => 3, + 'coveredCount' => 1, + ], + ]; + + $output = ConsoleCoverageRenderer::render($results); + + $this->assertStringContainsString('[front] 1/3 endpoints (33.3%)', $output); + } + #[Test] public function render_spec_with_zero_endpoints(): void {