diff --git a/src/ApiTestCase.php b/src/ApiTestCase.php index 0410334..ea3d24d 100644 --- a/src/ApiTestCase.php +++ b/src/ApiTestCase.php @@ -13,6 +13,7 @@ namespace ApiTestCase; +use ApiTestCase\Renderer\ContextualDiffRenderer; use Coduo\PHPMatcher\Factory\MatcherFactory; use Coduo\PHPMatcher\Matcher; use Doctrine\Common\DataFixtures\Purger\ORMPurger; @@ -169,8 +170,9 @@ protected function assertHeader(Response $response, string $contentType): void protected function assertResponseContent(string $actualResponse, string $filename, string $mimeType): void { $responseSource = $this->getExpectedResponsesFolder(); + $expectedFilePath = PathBuilder::build($responseSource, sprintf('%s.%s', $filename, $mimeType)); - $contents = file_get_contents(PathBuilder::build($responseSource, sprintf('%s.%s', $filename, $mimeType))); + $contents = file_get_contents($expectedFilePath); Assert::string($contents); $expectedResponse = trim($contents); @@ -180,9 +182,18 @@ protected function assertResponseContent(string $actualResponse, string $filenam $result = $matcher->match($actualResponse, $expectedResponse); if (!$result) { - $diff = new \Diff(explode(\PHP_EOL, $expectedResponse), explode(\PHP_EOL, $actualResponse), []); - - self::fail($matcher->getError() . \PHP_EOL . $diff->render(new \Diff_Renderer_Text_Unified())); + $diff = new \Diff( + explode(\PHP_EOL, $expectedResponse), + explode(\PHP_EOL, $actualResponse), + ['context' => 3] + ); + + $renderer = new ContextualDiffRenderer([ + 'expectedFilePath' => $expectedFilePath, + 'matcher' => $matcher, + ]); + + self::fail($diff->render($renderer)); } } diff --git a/src/Renderer/ContextualDiffRenderer.php b/src/Renderer/ContextualDiffRenderer.php new file mode 100644 index 0000000..ef8b3aa --- /dev/null +++ b/src/Renderer/ContextualDiffRenderer.php @@ -0,0 +1,209 @@ + $options Optional configuration: + * - 'expectedFilePath' (string): Path to expected response file + * - 'matcher' (Matcher): PHPMatcher for pattern-aware diffing + */ + public function __construct(array $options = []) + { + parent::__construct($options); + $this->expectedFilePath = $options['expectedFilePath'] ?? null; + $this->matcher = $options['matcher'] ?? null; + } + + /** + * Render and return a contextual diff with color highlighting. + * + * @return string The contextual diff output + */ + public function render(): string + { + $output = ''; + + $opCodes = $this->diff->getGroupedOpcodes(); + + foreach ($opCodes as $group) { + $lastItem = count($group) - 1; + $i1 = $group[0][1]; + $i2 = $group[$lastItem][2]; + $j1 = $group[0][3]; + $j2 = $group[$lastItem][4]; + + if ($i1 == 0 && $i2 == 0) { + $i1 = -1; + $i2 = -1; + } + + $output .= sprintf("@@ -%d,%d +%d,%d @@\n", $i1 + 1, $i2 - $i1, $j1 + 1, $j2 - $j1); + + foreach ($group as $code) { + [$tag, $i1, $i2, $j1, $j2] = $code; + + if ($tag === 'equal') { + // Context lines (unchanged) + $lines = $this->diff->GetA($i1, $i2); + foreach ($lines as $line) { + $output .= ' ' . $line . "\n"; + } + } elseif ($tag === 'replace' && $this->matcher !== null) { + $expectedLines = $this->diff->GetA($i1, $i2); + $actualLines = $this->diff->GetB($j1, $j2); + $maxLines = max(count($expectedLines), count($actualLines)); + + for ($lineIndex = 0; $lineIndex < $maxLines; $lineIndex++) { + $expectedLine = $expectedLines[$lineIndex] ?? ''; + $actualLine = $actualLines[$lineIndex] ?? ''; + + if ($this->linesMatchViaPattern($actualLine, $expectedLine)) { + $output .= ' ' . $actualLine . "\n"; + } else { + if (isset($expectedLines[$lineIndex])) { + $output .= $this->colorRed('-' . $expectedLine) . "\n"; + } + if (isset($actualLines[$lineIndex])) { + $output .= $this->colorGreen('+' . $actualLine) . "\n"; + } + } + } + } else { + // Handle deletions (expected but not found in actual) + if ($tag === 'replace' || $tag === 'delete') { + $lines = $this->diff->GetA($i1, $i2); + foreach ($lines as $line) { + $output .= $this->colorRed('-' . $line) . "\n"; + } + } + + // Handle insertions (unexpected in actual) + if ($tag === 'replace' || $tag === 'insert') { + $lines = $this->diff->GetB($j1, $j2); + foreach ($lines as $line) { + $output .= $this->colorGreen('+' . $line) . "\n"; + } + } + } + } + } + + // Add expected file path at the end if provided + if ($this->expectedFilePath !== null) { + $absolutePath = realpath($this->expectedFilePath) ?: $this->expectedFilePath; + $output .= sprintf("\nExpected file: %s\n", $absolutePath); + } + + return $output; + } + + /** + * Check if actual line matches expected line via phpmatcher patterns. + * + * @param string $actualLine The actual line from response + * @param string $expectedLine The expected line (may contain patterns) + * @return bool True if lines match via patterns + */ + private function linesMatchViaPattern(string $actualLine, string $expectedLine): bool + { + if ($this->matcher === null) { + return false; + } + + $result = $this->matcher->match($actualLine, $expectedLine); + + return $result; + } + + /** + * Apply red ANSI color code to a string (for expected but missing values). + * + * @param string $text Text to colorize + * @return string Colorized text + */ + private function colorRed(string $text): string + { + if ($this->isColorSupported()) { + return "\033[31m" . $text . "\033[0m"; + } + + return $text; + } + + /** + * Apply green ANSI color code to a string (for unexpected actual values). + * + * @param string $text Text to colorize + * @return string Colorized text + */ + private function colorGreen(string $text): string + { + if ($this->isColorSupported()) { + return "\033[32m" . $text . "\033[0m"; + } + + return $text; + } + + /** + * Check if ANSI color codes are supported in the current environment. + * + * @return bool True if colors are supported + */ + private function isColorSupported(): bool + { + // Check if explicitly disabled + if (isset($_SERVER['NO_COLOR']) || getenv('NO_COLOR') !== false) { + return false; + } + + // Check if output is to a terminal + if (function_exists('posix_isatty') && defined('STDOUT')) { + return @posix_isatty(STDOUT); + } + + // For Windows, check for ANSICON or ConEmu or Windows 10+ + if (DIRECTORY_SEPARATOR === '\\') { + return (getenv('ANSICON') !== false || getenv('ConEmuANSI') === 'ON' || + (function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(STDOUT))); + } + + // Default to true for Unix-like systems + return true; + } +} diff --git a/test/src/Tests/Controller/SampleControllerJsonTest.php b/test/src/Tests/Controller/SampleControllerJsonTest.php index 77de954..549e36e 100644 --- a/test/src/Tests/Controller/SampleControllerJsonTest.php +++ b/test/src/Tests/Controller/SampleControllerJsonTest.php @@ -157,4 +157,22 @@ public function testProductCreateResponse(): void $this->assertResponse($response, 'create_product', Response::HTTP_CREATED); } + + public function testDiffVisualizationShowsContextAndColors(): void + { + try { + $this->client->request('GET', '/'); + $response = $this->client->getResponse(); + $this->assertResponse($response, 'incorrect_hello_world'); + $this->fail('Expected assertion to fail but it passed'); + } catch (AssertionFailedError $e) { + $message = $e->getMessage(); + + $this->assertStringContainsString('Expected file:', $message); + $this->assertStringContainsString('incorrect_hello_world.json', $message); + $this->assertStringContainsString('-', $message); + $this->assertStringContainsString('+', $message); + $this->assertStringContainsString('message', $message); + } + } } diff --git a/test/src/Tests/Controller/SampleControllerXmlTest.php b/test/src/Tests/Controller/SampleControllerXmlTest.php index 4abd230..a03ed56 100644 --- a/test/src/Tests/Controller/SampleControllerXmlTest.php +++ b/test/src/Tests/Controller/SampleControllerXmlTest.php @@ -91,4 +91,21 @@ public function testProductShowResponse(): void $this->assertResponse($response, 'get_product'); } + + public function testDiffVisualizationShowsContextAndColors(): void + { + try { + $this->client->request('GET', '/'); + $response = $this->client->getResponse(); + $this->assertResponse($response, 'incorrect_hello_world'); + $this->fail('Expected assertion to fail but it passed'); + } catch (AssertionFailedError $e) { + $message = $e->getMessage(); + + $this->assertStringContainsString('Expected file:', $message); + $this->assertStringContainsString('incorrect_hello_world.xml', $message); + $this->assertStringContainsString('-', $message); + $this->assertStringContainsString('+', $message); + } + } } diff --git a/test/src/Tests/Renderer/ContextualDiffRendererTest.php b/test/src/Tests/Renderer/ContextualDiffRendererTest.php new file mode 100644 index 0000000..2c73d2c --- /dev/null +++ b/test/src/Tests/Renderer/ContextualDiffRendererTest.php @@ -0,0 +1,245 @@ + 3]); + $output = $diff->render($renderer); + + $this->assertStringContainsString('line 2', $output); + $this->assertStringContainsString('line 3', $output); + $this->assertStringContainsString('line 4', $output); + $this->assertStringContainsString('line 5 expected', $output); + $this->assertStringContainsString('line 5 actual', $output); + $this->assertStringContainsString('line 6', $output); + $this->assertStringContainsString('line 7', $output); + $this->assertStringContainsString('line 8', $output); + } + + public function testRenderIncludesExpectedFilePath(): void + { + $expected = ['line 1', 'line 2']; + $actual = ['line 1', 'line 3']; + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer([ + 'context' => 3, + 'expectedFilePath' => '/path/to/expected.json', + ]); + $output = $diff->render($renderer); + + $this->assertStringContainsString('Expected file: /path/to/expected.json', $output); + } + + public function testRenderHandlesInsertions(): void + { + $expected = ['line 1', 'line 2']; + $actual = ['line 1', 'line 2', 'line 3']; + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer(['context' => 1]); + $output = $diff->render($renderer); + + $this->assertStringContainsString('+line 3', $output); + } + + public function testRenderHandlesDeletions(): void + { + $expected = ['line 1', 'line 2', 'line 3']; + $actual = ['line 1', 'line 2']; + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer(['context' => 1]); + $output = $diff->render($renderer); + + $this->assertStringContainsString('-line 3', $output); + } + + public function testRenderHandlesMultipleDifferences(): void + { + $expected = [ + 'line 1', + 'line 2 expected', + 'line 3', + 'line 4', + 'line 5', + 'line 6 expected', + 'line 7', + ]; + + $actual = [ + 'line 1', + 'line 2 actual', + 'line 3', + 'line 4', + 'line 5', + 'line 6 actual', + 'line 7', + ]; + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer(['context' => 1]); + $output = $diff->render($renderer); + + $this->assertStringContainsString('line 2 expected', $output); + $this->assertStringContainsString('line 2 actual', $output); + $this->assertStringContainsString('line 6 expected', $output); + $this->assertStringContainsString('line 6 actual', $output); + } + + public function testRenderWithJsonDifferences(): void + { + $expected = [ + '{', + ' "name": "John Doe",', + ' "age": 30,', + ' "email": "john@example.com"', + '}', + ]; + + $actual = [ + '{', + ' "name": "Jane Doe",', + ' "age": 30,', + ' "email": "jane@example.com"', + '}', + ]; + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer(['context' => 2]); + $output = $diff->render($renderer); + + $this->assertStringContainsString('"name": "John Doe"', $output); + $this->assertStringContainsString('"name": "Jane Doe"', $output); + $this->assertStringContainsString('"email": "john@example.com"', $output); + $this->assertStringContainsString('"email": "jane@example.com"', $output); + $this->assertStringContainsString('"age": 30', $output); + } + + public function testRenderWithNoChanges(): void + { + $lines = ['line 1', 'line 2', 'line 3']; + + $diff = new \Diff($lines, $lines); + $renderer = new ContextualDiffRenderer(['context' => 3]); + $output = $diff->render($renderer); + + $this->assertEmpty(trim($output)); + } + + public function testColorOutputCanBeDisabled(): void + { + $_SERVER['NO_COLOR'] = '1'; + + $expected = ['line 1']; + $actual = ['line 2']; + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer(['context' => 1]); + $output = $diff->render($renderer); + + $this->assertStringNotContainsString("\033[", $output); + + unset($_SERVER['NO_COLOR']); + } + + public function testDefaultContextIsThreeLines(): void + { + $expected = []; + $actual = []; + + for ($i = 1; $i <= 20; $i++) { + $expected[] = "line $i"; + $actual[] = "line $i"; + } + + $expected[9] = 'line 10 expected'; + $actual[9] = 'line 10 actual'; + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer(); + $output = $diff->render($renderer); + + $this->assertStringContainsString('line 7', $output); + $this->assertStringContainsString('line 8', $output); + $this->assertStringContainsString('line 9', $output); + $this->assertStringContainsString('line 11', $output); + $this->assertStringContainsString('line 12', $output); + $this->assertStringContainsString('line 13', $output); + } + + public function testPatternAwareDiffDoesNotShowMatchingPatterns(): void + { + $expected = [ + '{', + ' "message": @string@,', + ' "path": @string@,', + ' "count": 42', + '}', + ]; + + $actual = [ + '{', + ' "message": "Hello World",', + ' "path": "/p/a/t/h",', + ' "count": 99', + '}', + ]; + + $matcherFactory = new MatcherFactory(); + $matcher = $matcherFactory->createMatcher(new VoidBacktrace()); + + $diff = new \Diff($expected, $actual); + $renderer = new ContextualDiffRenderer(['matcher' => $matcher]); + $output = $diff->render($renderer); + + $this->assertStringNotContainsString('"message": @string@', $output); + $this->assertStringNotContainsString('"path": @string@', $output); + $this->assertStringContainsString('- "count": 42', $output); + $this->assertStringContainsString('+ "count": 99', $output); + } +}