From a14ad227e7c8b4800202769521da318cd89e3f5a Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Fri, 13 Nov 2026 14:23:15 +0100 Subject: [PATCH] test: Expand test suite to perl and python peers I looked into the test suites of Perl and python diff libraries to look for edge cases we might want to cover. At least the MappedDiff looks like it's broken --- src/MappedDiff.php | 16 +- src/NativeEngine.php | 11 +- src/ThreeWay.php | 4 +- test/ColoredRendererTest.php | 166 ++++++++++++++++ test/DiffApiTest.php | 371 +++++++++++++++++++++++++++++++++++ test/EngineTest.php | 361 ++++++++++++++++++++++++++++++++++ test/MappedDiffTest.php | 249 +++++++++++++++++++++++ test/RendererTest.php | 367 ++++++++++++++++++++++++++++++++++ test/ThreeWayTest.php | 116 +++++++++++ 9 files changed, 1646 insertions(+), 15 deletions(-) create mode 100644 test/ColoredRendererTest.php create mode 100644 test/DiffApiTest.php create mode 100644 test/MappedDiffTest.php diff --git a/src/MappedDiff.php b/src/MappedDiff.php index 310c0b2..a912700 100644 --- a/src/MappedDiff.php +++ b/src/MappedDiff.php @@ -53,17 +53,15 @@ public function __construct( parent::__construct($edits); $xi = $yi = 0; - for ($i = 0; $i < count($this->edits); $i++) { - $orig = &$this->edits[$i]->orig; - if (is_array($orig)) { - $orig = array_slice($from_lines, $xi, count($orig)); - $xi += count($orig); + foreach ($this->edits as $edit) { + if (is_array($edit->orig)) { + $edit->orig = array_slice($from_lines, $xi, count($edit->orig)); + $xi += count($edit->orig); } - $final = &$this->edits[$i]->final; - if (is_array($final)) { - $final = array_slice($to_lines, $yi, count($final)); - $yi += count($final); + if (is_array($edit->final)) { + $edit->final = array_slice($to_lines, $yi, count($edit->final)); + $yi += count($edit->final); } } } diff --git a/src/NativeEngine.php b/src/NativeEngine.php index f4b6af9..b6d57ac 100644 --- a/src/NativeEngine.php +++ b/src/NativeEngine.php @@ -106,9 +106,10 @@ public function diff(): OperationList $this->xchanged = $this->ychanged = []; $this->xv = $this->yv = []; $this->xind = $this->yind = []; - unset($this->seq); - unset($this->in_seq); - unset($this->lcs); + // In modern PHP we cannot unset as NativeEngine may be subclassed and subclasses may have hooks + $this->seq = null; + $this->in_seq = null; + $this->lcs = null; // Skip leading common lines. for ($skip = 0; $skip < $n_from && $skip < $n_to; $skip++) { @@ -243,6 +244,8 @@ protected function _diag($xoff, $xlim, $yoff, $ylim, $nchunks) $numer = $xlim - $xoff + $nchunks - 1; $x = $xoff; + // $k should be defined by the first iteration reaching an empty inseq(y) + $k = null; for ($chunk = 0; $chunk < $nchunks; $chunk++) { if ($chunk > 0) { for ($i = 0; $i <= $this->lcs; $i++) { @@ -263,7 +266,7 @@ protected function _diag($xoff, $xlim, $yoff, $ylim, $nchunks) assert($k > 0); $ymids[$k] = $ymids[$k - 1]; break; - } elseif ($y > $this->seq[$k - 1]) { + } elseif (is_int($k) && ($k > 0) && ($y > $this->seq[$k - 1])) { /* * Disable this assertion for now until we know why it * causes https://github.com/maintaina-com/Text_Diff/issues/12 diff --git a/src/ThreeWay.php b/src/ThreeWay.php index 39aa8ac..0dac0b2 100644 --- a/src/ThreeWay.php +++ b/src/ThreeWay.php @@ -107,10 +107,10 @@ protected function _diff3($edits1, $edits2) $ncopy = min($e1->norig(), $e2->norig()); assert($ncopy > 0); - $edits[] = new ThreeWayCopyOperation(array_slice($e1->getOrig(), 0, $ncopy)); + $edits[] = new ThreeWayCopyOperation(array_slice($e1->orig, 0, $ncopy)); if ($e1->norig() > $ncopy) { - array_splice($e1->getOrig(), 0, $ncopy); + array_splice($e1->orig, 0, $ncopy); array_splice($e1->final, 0, $ncopy); } else { $e1 = next($edits1); diff --git a/test/ColoredRendererTest.php b/test/ColoredRendererTest.php new file mode 100644 index 0000000..ec4c1c4 --- /dev/null +++ b/test/ColoredRendererTest.php @@ -0,0 +1,166 @@ +color('lightgreen', ...), + * deleted lines in $cli->color('lightred', ...) and block headers in + * $cli->color('lightmagenta', ...). Context (unchanged) lines are NOT + * color-wrapped. They pass through the parent UnifiedRenderer unchanged. + * + * The renderer requires a Horde/Cli instance via $params['cli']. + * Tests use an anonymous-class stand-in that wraps text in predictable + * "[COLOR]text[/COLOR]" markers instead of real ANSI/HTML codes + **/ + +namespace Horde\Text\Diff\Test; + +use BadMethodCallException; +use Horde\Text\Diff\Diff; +use Horde\Text\Diff\NativeEngine; +use Horde\Text\Diff\UnifiedColoredRenderer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * @requires PHP >= 8.1 + */ +#[CoversClass(UnifiedColoredRenderer::class)] +class ColoredRendererTest extends TestCase +{ + /** Returns a minimal Horde_Cli stand-in that wraps text in [COLOR]...[/COLOR]. */ + private function mockCli(): object + { + return new class { + public function color(string $name, string $text): string + { + return "[{$name}]{$text}[/{$name}]"; + } + }; + } + + // ------------------------------------------------------------------------- + // Constructor guard + // ------------------------------------------------------------------------- + + /** + * The constructor must throw BadMethodCallException when the 'cli' key + * is absent from $params. + * + * @see src/UnifiedColoredRenderer.php __construct() + */ + public function testConstructorThrowsWithoutCliParam(): void + { + $this->expectException(BadMethodCallException::class); + new UnifiedColoredRenderer([]); + } + + // ------------------------------------------------------------------------- + // Color wrapping for added / deleted / header lines + // ------------------------------------------------------------------------- + + /** + * Added lines must be wrapped via $cli->color('lightgreen', ...). + * + * Input: pure insertion (from = [], to = ['new line\n']). + * The "+" content line must be inside "[lightgreen]...[/lightgreen]". + */ + public function testColoredRendererAddedLinesColor(): void + { + $renderer = new UnifiedColoredRenderer(['cli' => $this->mockCli()]); + $diff = Diff::fromFileLineArrays([], ["new line\n"], NativeEngine::class); + $output = $renderer->render($diff); + + $this->assertStringContainsString( + '[lightgreen]', + $output, + 'Added lines must be wrapped in the lightgreen color marker' + ); + $this->assertStringContainsString('new line', $output); + } + + /** + * Deleted lines must be wrapped via $cli->color('lightred', ...). + * + * Input: pure deletion (from = ['old line\n'], to = []). + * The "-" content line must be inside "[lightred]...[/lightred]". + */ + public function testColoredRendererRemovedLinesColor(): void + { + $renderer = new UnifiedColoredRenderer(['cli' => $this->mockCli()]); + $diff = Diff::fromFileLineArrays(["old line\n"], [], NativeEngine::class); + $output = $renderer->render($diff); + + $this->assertStringContainsString( + '[lightred]', + $output, + 'Deleted lines must be wrapped in the lightred color marker' + ); + $this->assertStringContainsString('old line', $output); + } + + /** + * Block headers (@@ ... @@) must be wrapped via $cli->color('lightmagenta', ...). + * + * Any diff that produces at least one hunk will have a block header. + */ + public function testColoredRendererBlockHeaderColor(): void + { + $renderer = new UnifiedColoredRenderer(['cli' => $this->mockCli()]); + $diff = Diff::fromFileLineArrays( + ["This line is the same.\n", "This line is different in 1.txt\n", "This line is the same.\n"], + ["This line is the same.\n", "This line is different in 2.txt\n", "This line is the same.\n"], + NativeEngine::class + ); + $output = $renderer->render($diff); + + $this->assertStringContainsString( + '[lightmagenta]', + $output, + 'Block headers must be wrapped in the lightmagenta color marker' + ); + $this->assertStringContainsString( + '@@', + $output, + 'The @@ hunk header text must still be present inside the color marker' + ); + } + + /** + * Unchanged context lines must NOT be wrapped in any color marker. + * UnifiedColoredRenderer does not override _context(), so context lines + * are emitted by the parent UnifiedRenderer as plain text. + * + * Input: 1-line context sandwiching the change (3-line files, default context=4). + * The "This line is the same." lines appear in context must not be colorized. + */ + public function testColoredRendererContextLinesUncolored(): void + { + $renderer = new UnifiedColoredRenderer(['cli' => $this->mockCli()]); + $from = ["unchanged\n", "old value\n", "unchanged\n"]; + $to = ["unchanged\n", "new value\n", "unchanged\n"]; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + $output = $renderer->render($diff); + + // Check that the context line "unchanged" appears outside any color wrapper + // by verifying it is not immediately preceded by "]" from a color marker close. + // A simpler proxy: the raw text appears in the output at all. + $this->assertStringContainsString( + 'unchanged', + $output, + 'Context line content must appear in the output' + ); + + // Context lines are prefixed with " " (space) in unified format. + // They should NOT be wrapped in [lightgreen] or [lightred]. + $this->assertStringNotContainsString('[lightgreen] unchanged', $output); + $this->assertStringNotContainsString('[lightred] unchanged', $output); + } +} diff --git a/test/DiffApiTest.php b/test/DiffApiTest.php new file mode 100644 index 0000000..85c4031 --- /dev/null +++ b/test/DiffApiTest.php @@ -0,0 +1,371 @@ += 8.1 + */ +#[CoversClass(Diff::class)] +#[CoversClass(NativeEngine::class)] +class DiffApiTest extends TestCase +{ + protected array $_lines = []; + protected string $fixtureDir = ''; + + public function setUp(): void + { + $this->fixtureDir = dirname(__FILE__, 1) . '/fixtures/'; + $this->_lines = [ + 1 => file($this->fixtureDir . '1.txt'), + 2 => file($this->fixtureDir . '2.txt'), + ]; + } + + // ------------------------------------------------------------------------- + // Identical sequences / no-difference detection + // + // Peers: + // Perl t/base.t diff() on identical arrays returns empty hunk list + // Python TestSFbugs::test_comparing_empty_lists + // Java DiffRowGeneratorTest::testGeneratorUnchanged + // ------------------------------------------------------------------------- + + /** + * Diff::isEmpty() must return true when both input sequences are identical. + * + * Fixture: test/fixtures/1.txt diffed against itself. + * Expected: every op is a CopyOperation so that isEmpty() returns true. + */ + public function testIsEmptyReturnsTrueForIdenticalInput(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[1], NativeEngine::class); + + $this->assertTrue( + $diff->isEmpty(), + 'isEmpty() should return true when original and revised sequences are identical' + ); + } + + /** + * Diff::isEmpty() must return false when there is at least one non-copy op. + * + * Fixture: test/fixtures/1.txt vs test/fixtures/2.txt (line 2 differs). + * Expected: the diff contains a ChangeOperation so that isEmpty() returns false. + */ + public function testIsEmptyReturnsFalseForDifferentInput(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $this->assertFalse( + $diff->isEmpty(), + 'isEmpty() should return false when original and revised sequences differ' + ); + } + + // ------------------------------------------------------------------------- + // LCS length computation + // + // Peers: + // Perl t/base.t LCS_length() on identical, empty, standard example + // Python SequenceMatcher internal LCS length assertions + // ------------------------------------------------------------------------- + + /** + * lcs() must equal the full line count when both sequences are identical. + * + * Fixture: test/fixtures/1.txt (3 lines) diffed against itself. + * Expected: 3 copy lines so that lcs() == 3. + */ + public function testLcsOnIdenticalLines(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[1], NativeEngine::class); + + $this->assertSame( + 3, + $diff->lcs(), + 'lcs() should equal the total line count for identical sequences' + ); + } + + /** + * lcs() must return 0 when the two sequences share no common elements. + * + * Input: ['alpha', 'beta', 'gamma'] vs ['one', 'two', 'three'] + * Expected: no CopyOperations so that lcs() == 0. + */ + public function testLcsOnCompletelyDifferentLines(): void + { + $from = ['alpha', 'beta', 'gamma']; + $to = ['one', 'two', 'three']; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + + $this->assertSame( + 0, + $diff->lcs(), + 'lcs() should return 0 when sequences share no common elements' + ); + } + + /** + * lcs() must return the exact count of lines shared between the two sequences. + * + * Fixture: test/fixtures/1.txt vs test/fixtures/2.txt. + * 1.txt: ["This line is the same.\r\n", + * "This line is different in 1.txt\r\n", + * "This line is the same.\r\n"] + * 2.txt: ["This line is the same.\r\n", + * "This line is different in 2.txt\r\n", + * "This line is the same.\r\n"] + * + */ + public function testLcsOnPartialMatch(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $this->assertSame( + 2, + $diff->lcs(), + 'lcs() should return 2: lines 1 and 3 are common, line 2 differs' + ); + } + + // ------------------------------------------------------------------------- + // reverse() iterates over $this->edits and calls $edit->reverse() on each: + // CopyOperation::reverse() so that CopyOperation($final, $orig) (swaps sides) + // AddOperation::reverse() so that DeleteOperation($final) + // DeleteOperation::reverse() so that AddOperation($orig) + // ChangeOperation::reverse() so that ChangeOperation($final, $orig) (swaps sides) + // ------------------------------------------------------------------------- + + /** + * reverse() must swap orig and final across all ops. + * After reversal: getOriginal() == original getFinal(), and vice versa. + * + * Note: NativeEngine strips trailing newlines from the content stored in + * ops, so getOriginal()/getFinal() never round-trip the raw file() lines + * verbatim. The invariant under test is the swap, so we compare the + * reversed diff against the original diff's (already-stripped) output. + * + * Fixture: 1.txt vs 2.txt. + */ + public function testReverseSwapsOrigAndFinal(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + $rev = $diff->reverse(); + + $this->assertEquals( + $diff->getFinal(), + $rev->getOriginal(), + 'Reversed diff: getOriginal() must equal the original getFinal()' + ); + $this->assertEquals( + $diff->getOriginal(), + $rev->getFinal(), + 'Reversed diff: getFinal() must equal the original getOriginal()' + ); + } + + /** + * Reversing a diff twice must produce the same ops as the original. + * reverse(reverse($diff)).getOriginal() === $diff.getOriginal() + */ + public function testReverseOfReverseIsIdentity(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $this->assertEquals( + $diff->getOriginal(), + $diff->reverse()->reverse()->getOriginal(), + 'Double-reverse must restore the original sequence' + ); + $this->assertEquals( + $diff->getFinal(), + $diff->reverse()->reverse()->getFinal(), + 'Double-reverse must restore the revised sequence' + ); + } + + /** + * Reversing a diff of identical lines must still produce only CopyOperations. + * CopyOperation::reverse() returns CopyOperation($final, $orig) with swapped sides, + * but since orig === final for copy ops, all ops remain CopyOperation. + */ + public function testReverseOnCopyOpOnly(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[1], NativeEngine::class); + $rev = $diff->reverse(); + + foreach ($rev->getDiff() as $op) { + $this->assertInstanceOf( + CopyOperation::class, + $op, + 'Reversing a no-change diff must still produce only CopyOperations' + ); + } + } + + // ------------------------------------------------------------------------- + // countAddedLines() sums nfinal() of AddOperation and ChangeOperation ops. + // countDeletedLines() sums norig() of DeleteOperation and ChangeOperation ops. + // ------------------------------------------------------------------------- + + /** + * Fixture: 1.txt vs 2.txt 1 ChangeOperation with 1 final line. + * Expected: countAddedLines() == 1 + */ + public function testCountAddedLines(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $this->assertSame( + 1, + $diff->countAddedLines(), + 'countAddedLines() should return 1 for a single-line change (1 final line)' + ); + } + + /** + * Fixture: 1.txt vs 2.txt 1 ChangeOperation with 1 orig line. + * Expected: countDeletedLines() == 1 + */ + public function testCountDeletedLines(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $this->assertSame( + 1, + $diff->countDeletedLines(), + 'countDeletedLines() should return 1 for a single-line change (1 orig line)' + ); + } + + /** + * Identical input: no AddOperation or ChangeOperation ops. + * Expected: countAddedLines() == 0 + */ + public function testCountAddedLinesZeroForIdentical(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[1], NativeEngine::class); + + $this->assertSame( + 0, + $diff->countAddedLines(), + 'countAddedLines() should return 0 when sequences are identical' + ); + } + + /** + * Identical input: no DeleteOperation or ChangeOperation ops. + * Expected: countDeletedLines() == 0 + */ + public function testCountDeletedLinesZeroForIdentical(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[1], NativeEngine::class); + + $this->assertSame( + 0, + $diff->countDeletedLines(), + 'countDeletedLines() should return 0 when sequences are identical' + ); + } + + /** + * For a pure change (1 line replaced by 1 line): + * countAddedLines() === countDeletedLines() (symmetry of the rename). + */ + public function testCountLinesSymmetryForPureChange(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $this->assertSame( + $diff->countDeletedLines(), + $diff->countAddedLines(), + 'For a pure line-replace, added and deleted counts must be equal' + ); + } + + // ------------------------------------------------------------------------- + // Diff::getOriginal() and Diff::getFinal() + // + // getOriginal() collects orig arrays from CopyOperation, ChangeOperation, + // and DeleteOperation (all ops where $edit->orig is truthy). + // getFinal() collects final arrays from CopyOperation, AddOperation, + // and ChangeOperation. + // ------------------------------------------------------------------------- + + /** + * getOriginal() must reconstruct the $fromLines passed to the engine. + * + * Fixture: 1.txt vs 2.txt via NativeEngine. + * Note: NativeEngine strips trailing newlines from op content, so the + * reconstruction equals the newline-trimmed input, not the raw file() lines. + */ + public function testGetOriginalReconstructsFromLines(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $expected = $this->_lines[1]; + array_walk($expected, [Diff::class, 'trimNewlines']); + + $this->assertEquals( + $expected, + $diff->getOriginal(), + 'getOriginal() must reconstruct the original line array (newlines trimmed)' + ); + } + + /** + * getFinal() must reconstruct the $toLines passed to the engine. + * + * Fixture: 1.txt vs 2.txt via NativeEngine. + * Note: NativeEngine strips trailing newlines from op content, so the + * reconstruction equals the newline-trimmed input, not the raw file() lines. + */ + public function testGetFinalReconstructsToLines(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $expected = $this->_lines[2]; + array_walk($expected, [Diff::class, 'trimNewlines']); + + $this->assertEquals( + $expected, + $diff->getFinal(), + 'getFinal() must reconstruct the revised line array (newlines trimmed)' + ); + } + + /** + * When original and revised are identical, both getOriginal() and getFinal() + * must return the same array (all ops are CopyOperation; both sides equal). + */ + public function testGetOriginalAndFinalOnIdenticalInput(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[1], NativeEngine::class); + + $this->assertEquals( + $diff->getOriginal(), + $diff->getFinal(), + 'For identical input, getOriginal() and getFinal() must return equal arrays' + ); + } +} diff --git a/test/EngineTest.php b/test/EngineTest.php index 30a8c1f..56fbdfd 100644 --- a/test/EngineTest.php +++ b/test/EngineTest.php @@ -10,9 +10,12 @@ namespace Horde\Text\Diff\Test; +use Horde\Text\Diff\AddOperation; use Horde\Text\Diff\ChangeOperation; use Horde\Text\Diff\CopyOperation; +use Horde\Text\Diff\DeleteOperation; use Horde\Text\Diff\Diff; +use Horde\Text\Diff\DiffEngineFactory; use Horde\Text\Diff\Exception; use Horde\Text\Diff\NativeEngine; use Horde\Text\Diff\StringEngine; @@ -23,7 +26,11 @@ /** * @requires PHP >= 8.1 */ +#[CoversClass(AddOperation::class)] +#[CoversClass(ChangeOperation::class)] +#[CoversClass(DeleteOperation::class)] #[CoversClass(Diff::class)] +#[CoversClass(DiffEngineFactory::class)] #[CoversClass(NativeEngine::class)] #[CoversClass(StringEngine::class)] #[CoversClass(XdiffEngine::class)] @@ -98,4 +105,358 @@ public function testXdiffEngine() $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], XdiffEngine::class); $this->_testDiff($diff); } + + // ------------------------------------------------------------------------- + // Empty-sequence edge cases + // ------------------------------------------------------------------------- + + public function testDiffOnEmptyOriginal(): void + { + $diff = Diff::fromFileLineArrays([], $this->_lines[1], NativeEngine::class); + $edits = $diff->getDiff()->toArray(); + + $this->assertCount(1, $edits); + $this->assertInstanceOf(AddOperation::class, $edits[0]); + // AddOperation sets orig = false + $this->assertFalse($edits[0]->orig); + // NativeEngine strips trailing newlines from op content. + $expected = $this->_lines[1]; + array_walk($expected, [Diff::class, 'trimNewlines']); + $this->assertEquals($expected, $edits[0]->final); + } + + public function testDiffOnEmptyRevised(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], [], NativeEngine::class); + $edits = $diff->getDiff()->toArray(); + + $this->assertCount(1, $edits); + $this->assertInstanceOf(DeleteOperation::class, $edits[0]); + // NativeEngine strips trailing newlines from op content. + $expected = $this->_lines[1]; + array_walk($expected, [Diff::class, 'trimNewlines']); + $this->assertEquals($expected, $edits[0]->orig); + // DeleteOperation sets final = false + $this->assertFalse($edits[0]->final); + } + + public function testDiffOnBothEmpty(): void + { + $diff = Diff::fromFileLineArrays([], [], NativeEngine::class); + $edits = $diff->getDiff()->toArray(); + + $this->assertCount(0, $edits); + } + + // ------------------------------------------------------------------------- + // Identical sequences (engine-level assertion) + // ------------------------------------------------------------------------- + + public function testDiffOnIdenticalLines(): void + { + $diff = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[1], NativeEngine::class); + + foreach ($diff->getDiff() as $op) { + $this->assertInstanceOf( + CopyOperation::class, + $op, + 'Diff of identical sequences must only produce CopyOperation ops' + ); + } + } + + // ------------------------------------------------------------------------- + // Input validation / exception coverage + // ------------------------------------------------------------------------- + + /** + * StringEngine::diff() throws when mode is not 'context', 'unified', or 'autodetect'. + * Message: 'Type of diff is unsupported' + */ + public function testStringEngineRejectsUnsupportedMode(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Type of diff is unsupported'); + + Diff::fromString( + file_get_contents($this->fixtureDir . 'unified.patch'), + StringEngine::class, + ['mode' => 'bogus'] + ); + } + + /** + * An empty string has neither '***' nor '---' and autodetect cannot + * determine the format. + * Message: 'Type of diff could not be detected' + */ + public function testStringEngineRejectsEmptyString(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Type of diff could not be detected'); + + Diff::fromString('', StringEngine::class); + } + + /** + * unified2.patch starts directly with '@@ -1 +1 @@' no '---' or '***' + * header lines. Autodetect cannot determine the format. + * + * Note: the existing testStringEngine() already calls expectException() for + * this fixture but then continues with unreachable post-exception assertions. + * This test isolates that scenario correctly. + */ + public function testStringEngineRejectsHeaderlessUnifiedPatch(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Type of diff could not be detected'); + + Diff::fromString( + file_get_contents($this->fixtureDir . 'unified2.patch'), + StringEngine::class + ); + } + + // ------------------------------------------------------------------------- + // Patch round-trip (parse to reconstruct to verify) + // ------------------------------------------------------------------------- + + /** + * Parse a unified patch, reconstruct the revised file by walking the ops, + * and verify the result matches the known content of 2.txt. + * + * Reconstruction rules: + * CopyOperation append orig lines (unchanged, present in revised) + * AddOperation append final lines (new in revised) + * ChangeOperation append final lines (replacement in revised) + * DeleteOperation skip (removed from original / absent in revised) + * + * StringEngine strips newlines when splitting on line boundaries, so the + * expected values are compared without trailing newline characters. + */ + public function testUnifiedPatchRoundtrip(): void + { + $patch = file_get_contents($this->fixtureDir . 'unified.patch'); + $diff = Diff::fromString($patch, StringEngine::class); + + $revised = []; + foreach ($diff->getDiff() as $op) { + if ($op instanceof CopyOperation) { + foreach ($op->orig as $line) { + $revised[] = $line; + } + } elseif ($op instanceof AddOperation) { + foreach ($op->final as $line) { + $revised[] = $line; + } + } elseif ($op instanceof ChangeOperation) { + foreach ($op->final as $line) { + $revised[] = $line; + } + } + // DeleteOperation: intentionally skipped + } + + $this->assertEquals( + [ + 'This line is the same.', + 'This line is different in 2.txt', + 'This line is the same.', + ], + $revised + ); + } + + /** + * Same round-trip verification using a context-format patch. + * The expected reconstructed content is identical to testUnifiedPatchRoundtrip + * because both patches describe the same 1.txt to 2.txt transformation. + */ + public function testContextPatchRoundtrip(): void + { + $patch = file_get_contents($this->fixtureDir . 'context.patch'); + $diff = Diff::fromString($patch, StringEngine::class); + + $revised = []; + foreach ($diff->getDiff() as $op) { + if ($op instanceof CopyOperation) { + foreach ($op->orig as $line) { + $revised[] = $line; + } + } elseif ($op instanceof AddOperation) { + foreach ($op->final as $line) { + $revised[] = $line; + } + } elseif ($op instanceof ChangeOperation) { + foreach ($op->final as $line) { + $revised[] = $line; + } + } + } + + $this->assertEquals( + [ + 'This line is the same.', + 'This line is different in 2.txt', + 'This line is the same.', + ], + $revised + ); + } + + // ------------------------------------------------------------------------- + // DiffEngineFactory auto-selection + // ------------------------------------------------------------------------- + + /** + * With 'auto' and the xdiff extension absent, DiffEngineFactory falls back + * to NativeEngine. The result must be structurally identical to an explicit + * NativeEngine call. + * + * Source: DiffEngineFactory::fromFileLineArrays(), line 37: + * extension_loaded('xdiff') ? XdiffEngine::class : NativeEngine::class + */ + public function testAutoEngineProducesSameResultAsNativeWhenXdiffAbsent(): void + { + if (extension_loaded('xdiff')) { + $this->markTestSkipped('xdiff extension is loaded; auto selects XdiffEngine instead of NativeEngine'); + } + + $auto = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], 'auto'); + $native = Diff::fromFileLineArrays($this->_lines[1], $this->_lines[2], NativeEngine::class); + + $autoEdits = $auto->getDiff()->toArray(); + $nativeEdits = $native->getDiff()->toArray(); + + $this->assertCount( + count($nativeEdits), + $autoEdits, + 'auto and NativeEngine should produce the same number of ops' + ); + + foreach ($autoEdits as $i => $op) { + $this->assertInstanceOf( + get_class($nativeEdits[$i]), + $op, + "Op $i: auto result class should match NativeEngine result class" + ); + $this->assertEquals( + $nativeEdits[$i]->orig, + $op->orig, + "Op $i: orig content should match" + ); + $this->assertEquals( + $nativeEdits[$i]->final, + $op->final, + "Op $i: final content should match" + ); + } + } + + /** + * With 'auto' and the xdiff extension present the DiffEngineFactory selects + * XdiffEngine. XdiffEngine does not implement ChangeOperation (see + * testXdiffEngine which is marked incomplete). + */ + public function testAutoEngineUsesXdiffWhenAvailable(): void + { + if (!extension_loaded('xdiff')) { + $this->markTestSkipped('xdiff extension is not loaded'); + } + + // Pure addition: No ChangeOperation needed and XdiffEngine can handle this + $from = ['line one', 'line two']; + $to = ['line one', 'line two', 'line three']; + + $diff = Diff::fromFileLineArrays($from, $to, 'auto'); + $edits = $diff->getDiff()->toArray(); + + $this->assertNotEmpty($edits, 'auto engine should produce at least one op'); + + // At minimum a copy of the two shared lines and an add of the new one + $hasAdd = false; + foreach ($edits as $op) { + if ($op instanceof AddOperation) { + $hasAdd = true; + } + } + $this->assertTrue($hasAdd, 'auto engine (XdiffEngine) should produce an AddOperation for the appended line'); + } + + // ------------------------------------------------------------------------- + // XdiffEngine partial coverage (Option B) + // + // testXdiffEngine() is permanently markTestIncomplete because XdiffEngine + // does not produce ChangeOperation. These tests cover the insert-only + // and delete-only paths, which XdiffEngine handles correctly. + // + // XdiffEngine::diff() (src/XdiffEngine.php lines 33-35) throws + // Horde\Text\Diff\Exception when the xdiff PECL extension is absent. + // Both tests are skipped in that case. + // ------------------------------------------------------------------------- + + /** + * XdiffEngine on a pure insertion (from = [], to = lines) must produce + * only AddOperation ops. + * + * XdiffEngine uses xdiff_string_diff() and parses unified-format output. + * There are no "-" or " " lines when from is empty. + */ + public function testXdiffEngineInsertOnly(): void + { + if (!extension_loaded('xdiff')) { + $this->markTestSkipped('xdiff extension is not loaded'); + } + + $from = []; + $to = ['alpha', 'beta', 'gamma']; + + $diff = Diff::fromFileLineArrays($from, $to, XdiffEngine::class); + $edits = $diff->getDiff()->toArray(); + + $this->assertNotEmpty( + $edits, + 'Insert-only diff must produce at least one op' + ); + + foreach ($edits as $op) { + $this->assertInstanceOf( + AddOperation::class, + $op, + 'Insert-only diff via XdiffEngine must produce only AddOperation ops' + ); + } + } + + /** + * XdiffEngine on a pure deletion (from = lines, to = []) must produce + * only DeleteOperation ops. + * + * "-" prefixed lines in the xdiff output -> DeleteOperation([substr($line,1)]). + */ + public function testXdiffEngineDeleteOnly(): void + { + if (!extension_loaded('xdiff')) { + $this->markTestSkipped('xdiff extension is not loaded'); + } + + $from = ['alpha', 'beta', 'gamma']; + $to = []; + + $diff = Diff::fromFileLineArrays($from, $to, XdiffEngine::class); + $edits = $diff->getDiff()->toArray(); + + $this->assertNotEmpty( + $edits, + 'Delete-only diff must produce at least one op' + ); + + foreach ($edits as $op) { + $this->assertInstanceOf( + DeleteOperation::class, + $op, + 'Delete-only diff via XdiffEngine must produce only DeleteOperation ops' + ); + } + } } diff --git a/test/MappedDiffTest.php b/test/MappedDiffTest.php new file mode 100644 index 0000000..6f16af9 --- /dev/null +++ b/test/MappedDiffTest.php @@ -0,0 +1,249 @@ +edits[$i]) + * to iterate over the OperationList. OperationList does not implement + * ArrayAccess. + * + * This causes a fatal error in PHP 8+: + * "Cannot use object of type Horde\Text\Diff\OperationList as array" + */ + +namespace Horde\Text\Diff\Test; + +use Horde\Text\Diff\ChangeOperation; +use Horde\Text\Diff\CopyOperation; +use Horde\Text\Diff\MappedDiff; +use Horde\Text\Diff\NativeEngine; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +/** + * @requires PHP >= 8.1 + */ +#[CoversClass(MappedDiff::class)] +#[CoversClass(NativeEngine::class)] +class MappedDiffTest extends TestCase +{ + // ------------------------------------------------------------------------- + // Helper: build a MappedDiff from original line arrays and a mapping callable + // ------------------------------------------------------------------------- + + /** + * Constructs a MappedDiff by: + * 1. Mapping both line arrays with $mapFn. + * 2. Running NativeEngine on the mapped versions to get the ops. + * 3. Creating MappedDiff($ops, $from, $to) so that ops carry original content. + * + * @param string[] $from Original lines (un-mapped) + * @param string[] $to Revised lines (un-mapped) + * @param callable $mapFn Mapping function, e.g. 'strtolower', 'trim' + */ + private function buildMappedDiff(array $from, array $to, callable $mapFn): MappedDiff + { + $mappedFrom = array_map($mapFn, $from); + $mappedTo = array_map($mapFn, $to); + + $engine = new NativeEngine($mappedFrom, $mappedTo); + $edits = $engine->diff(); + + return new MappedDiff($edits, $from, $to); + } + + // ------------------------------------------------------------------------- + // Custom key / comparator mapping + // ------------------------------------------------------------------------- + + /** + * Lines that differ only in character case must produce a CopyOperation + * (treated as equal) when mapped with strtolower(). + * + * Input: + * from: ["Hello World\n"] + * to: ["HELLO WORLD\n"] + * map: strtolower / both become ["hello world\n"] + * + * Expected: 1 CopyOperation whose orig holds the original-case from-line + * and whose final holds the original-case to-line. + */ + public function testCaseInsensitiveDiff(): void + { + $from = ["Hello World\n"]; + $to = ["HELLO WORLD\n"]; + + $diff = $this->buildMappedDiff($from, $to, 'strtolower'); + $ops = $diff->getDiff()->toArray(); + + $this->assertCount( + 1, + $ops, + 'A single-line case-only change should produce exactly one op' + ); + $this->assertInstanceOf( + CopyOperation::class, + $ops[0], + 'Lines differing only in case should be treated as equal (CopyOperation)' + ); + + // Original (un-mapped) content must be preserved in the op + $this->assertSame( + "Hello World\n", + $ops[0]->orig[0], + 'orig should contain the original from-line, not the mapped version' + ); + $this->assertSame( + "HELLO WORLD\n", + $ops[0]->final[0], + 'final should contain the original to-line, not the mapped version' + ); + } + + /** + * Lines that differ only in surrounding whitespace must produce a + * CopyOperation when mapped with trim(). + * + * Input: + * from: [" This line is the same. \n"] + * to: ["This line is the same.\n"] + * map: trim, both become ["This line is the same."] + * + * Expected: 1 CopyOperation with original (un-trimmed) content preserved. + */ + public function testWhitespaceIgnoringDiff(): void + { + $from = [" This line is the same. \n"]; + $to = ["This line is the same.\n"]; + + $diff = $this->buildMappedDiff($from, $to, 'trim'); + $ops = $diff->getDiff()->toArray(); + + $this->assertCount( + 1, + $ops, + 'A single-line whitespace-only change should produce exactly one op' + ); + $this->assertInstanceOf( + CopyOperation::class, + $ops[0], + 'Lines differing only in whitespace should be treated as equal (CopyOperation)' + ); + + $this->assertSame( + " This line is the same. \n", + $ops[0]->orig[0], + 'orig should retain leading/trailing whitespace from the original from-line' + ); + $this->assertSame( + "This line is the same.\n", + $ops[0]->final[0], + 'final should retain the original to-line without extra whitespace' + ); + } + + /** + * A genuine content change (different even after mapping) must still + * produce a ChangeOperation, and the op must carry the un-mapped originals. + * + * Input: + * from: ["Hello World\n"] + * to: ["Goodbye World\n"] + * + * Expected: 1 ChangeOperation with original-case content in orig and final. + */ + public function testMappedDiffDetectsRealChanges(): void + { + $from = ["Hello World\n"]; + $to = ["Goodbye World\n"]; + + $diff = $this->buildMappedDiff($from, $to, 'strtolower'); + $ops = $diff->getDiff()->toArray(); + + $this->assertCount( + 1, + $ops, + 'A genuine single-line change should produce exactly one op' + ); + $this->assertInstanceOf( + ChangeOperation::class, + $ops[0], + 'A line whose mapped value also differs should produce a ChangeOperation' + ); + + $this->assertSame( + "Hello World\n", + $ops[0]->orig[0], + 'orig should contain the original from-line' + ); + $this->assertSame( + "Goodbye World\n", + $ops[0]->final[0], + 'final should contain the original to-line' + ); + } + + /** + * Mixed scenario: A multi-line sequence where one pair of lines differs only + * in case (should merge as equal) and another pair has a genuine change. + * + * Input: + * from: ["Same Line\n", "Old Content\n"] + * to: ["SAME LINE\n", "New Content\n"] + * map: strtolower + * + * Mapped: + * from: ["same line\n", "old content\n"] + * to: ["same line\n", "new content\n"] + * + * Expected ops (in order): + * [0] CopyOperation orig="Same Line\n" final="SAME LINE\n" + * [1] ChangeOperation orig="Old Content\n" final="New Content\n" + */ + public function testMixedCaseInsensitiveAndRealChange(): void + { + $from = ["Same Line\n", "Old Content\n"]; + $to = ["SAME LINE\n", "New Content\n"]; + + $diff = $this->buildMappedDiff($from, $to, 'strtolower'); + $ops = $diff->getDiff()->toArray(); + + $this->assertCount( + 2, + $ops, + 'Should produce two ops: one copy (case-only) and one change (genuine)' + ); + + $this->assertInstanceOf( + CopyOperation::class, + $ops[0], + 'First op: case-only line should be CopyOperation' + ); + $this->assertSame("Same Line\n", $ops[0]->orig[0]); + $this->assertSame("SAME LINE\n", $ops[0]->final[0]); + + $this->assertInstanceOf( + ChangeOperation::class, + $ops[1], + 'Second op: genuinely different line should be ChangeOperation' + ); + $this->assertSame("Old Content\n", $ops[1]->orig[0]); + $this->assertSame("New Content\n", $ops[1]->final[0]); + } +} diff --git a/test/RendererTest.php b/test/RendererTest.php index 5d96f4d..75a6f8c 100644 --- a/test/RendererTest.php +++ b/test/RendererTest.php @@ -10,11 +10,14 @@ namespace Horde\Text\Diff\Test; +use Horde\Text\Diff\ChangeOperation; use Horde\Text\Diff\ContextRenderer; +use Horde\Text\Diff\CopyOperation; use Horde\Text\Diff\Diff; use Horde\Text\Diff\InlineRenderer; use Horde\Text\Diff\NativeEngine; use Horde\Text\Diff\Renderer; +use Horde\Text\Diff\StringEngine; use Horde\Text\Diff\UnifiedRenderer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -25,6 +28,7 @@ #[CoversClass(ContextRenderer::class)] #[CoversClass(InlineRenderer::class)] #[CoversClass(Renderer::class)] +#[CoversClass(StringEngine::class)] #[CoversClass(UnifiedRenderer::class)] class RendererTest extends TestCase { @@ -335,4 +339,367 @@ public function testGithubPullRequest86() $renderer = new InlineRenderer(['split_characters' => true]); $this->assertEquals($patch, $renderer->render($diff)); } + + // ------------------------------------------------------------------------- + // Unified diff output format edge cases + // ------------------------------------------------------------------------- + + /** + * POSIX rule: when a hunk spans exactly 1 line, the ",count" suffix is omitted. + * UnifiedRenderer::_blockHeader checks ($xlen != 1) before appending ",$xlen". + * For a single-line change with no context, both sides have 1 line. "@@ -1 +1 @@". + * Confirmed by test/fixtures/unified2.patch which contains "@@ -1 +1 @@". + * + * @see src/UnifiedRenderer.php _blockHeader() + */ + public function testUnifiedRendererSingleLineHunk(): void + { + $renderer = new UnifiedRenderer(['leading_context_lines' => 0, 'trailing_context_lines' => 0]); + $from = ["old line\n"]; + $to = ["new line\n"]; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + $output = $renderer->render($diff); + + // The hunk header must not carry ",1" on either side + $this->assertStringContainsString('@@ -1 +1 @@', $output); + $this->assertStringNotContainsString('@@ -1,1', $output); + $this->assertStringNotContainsString('+1,1 @@', $output); + $this->assertStringContainsString('-old line', $output); + $this->assertStringContainsString('+new line', $output); + } + + /** + * Pure insertion (original side has 0 lines). + * _blockHeader(xbeg=1, xlen=0, ybeg=1, ylen=3). "@@ -1,0 +1,3 @@" + * The ",0" suffix on the origin side marks an empty range. + * Output must contain only "+" lines, no "-" lines. + */ + public function testUnifiedRendererPureInsertion(): void + { + $renderer = new UnifiedRenderer(['leading_context_lines' => 0, 'trailing_context_lines' => 0]); + $from = []; + $to = ["This line is the same.\n", "This line is different in 2.txt\n", "This line is the same.\n"]; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + $output = $renderer->render($diff); + + $this->assertStringContainsString(',0', $output, 'Hunk header must carry ",0" for the empty origin side'); + $this->assertStringContainsString('+This line is the same.', $output); + $this->assertStringNotContainsString("\n-", $output, 'Pure insertion must contain no deleted lines'); + } + + /** + * Pure deletion (revised side has 0 lines). + * _blockHeader(xbeg=1, xlen=3, ybeg=1, ylen=0). "@@ -1,3 +1,0 @@" + * Output must contain only "-" lines, no "+" lines. + */ + public function testUnifiedRendererPureDeletion(): void + { + $renderer = new UnifiedRenderer(['leading_context_lines' => 0, 'trailing_context_lines' => 0]); + $from = ["This line is the same.\n", "This line is different in 1.txt\n", "This line is the same.\n"]; + $to = []; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + $output = $renderer->render($diff); + + $this->assertStringContainsString(',0', $output, 'Hunk header must carry ",0" for the empty revised side'); + $this->assertStringContainsString('-This line is the same.', $output); + $this->assertStringNotContainsString("\n+", $output, 'Pure deletion must contain no inserted lines'); + } + + // ------------------------------------------------------------------------- + // Context diff output format edge cases + // ------------------------------------------------------------------------- + + /** + * POSIX: single-line hunk range omits ",1". + * ContextRenderer::_blockHeader appends ",$xlen" only when $xlen != 1. + * For a 1-line file diffed against a 1-line file: "*** 1 ****" / "--- 1 ----". + * + * @see src/ContextRenderer.php _blockHeader() + */ + public function testContextRendererSingleLineHunk(): void + { + $renderer = new ContextRenderer(['leading_context_lines' => 0, 'trailing_context_lines' => 0]); + $from = ["old\n"]; + $to = ["new\n"]; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + $output = $renderer->render($diff); + + $this->assertStringContainsString( + '*** 1 ****', + $output, + 'Single-line hunk must omit ",1" on the original-side range' + ); + $this->assertStringContainsString( + '--- 1 ----', + $output, + 'Single-line hunk must omit ",1" on the revised-side range' + ); + $this->assertStringNotContainsString( + ',1', + $output, + 'No ",1" count suffix should appear for a single-line hunk' + ); + } + + /** + * All-deletions: revised side is empty. + * _blockHeader(1, 3, 1, 0) sets the second-block header to "--- 1,0 ----". + * The "***" section has the deleted lines; the "---" section is present but empty. + */ + public function testContextRendererAllDeletions(): void + { + $renderer = new ContextRenderer(['leading_context_lines' => 0, 'trailing_context_lines' => 0]); + $from = ["line one\n", "line two\n", "line three\n"]; + $to = []; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + $output = $renderer->render($diff); + + // Origin section must be present and contain deleted lines + $this->assertStringContainsString('****', $output); + $this->assertStringContainsString('- line one', $output); + $this->assertStringContainsString('- line three', $output); + + // Revised section must be present with a ",0" range but no added content + $this->assertStringContainsString('----', $output); + $this->assertStringContainsString( + ',0', + $output, + 'Revised-side range must carry ",0" when revised file is empty' + ); + $this->assertStringNotContainsString( + '+ ', + $output, + 'All-deletions diff must contain no added lines in output' + ); + } + + // ------------------------------------------------------------------------- + // Unified diff parsing (StringEngine) edge cases + // ------------------------------------------------------------------------- + + /** + * Lines starting with "Binary files ... differ" begin with 'B', which hits + * the default branch in parseUnifiedDiff(). silently skipped (no op created). + * The text hunks before/after the binary marker must still parse correctly. + */ + public function testStringEngineSkipsBinaryMarkerLines(): void + { + $patch = "--- a/text.txt\n" + . "+++ b/text.txt\n" + . "@@ -1,3 +1,3 @@\n" + . " This line is the same.\n" + . "-This line is different in 1.txt\n" + . "+This line is different in 2.txt\n" + . " This line is the same.\n" + . "Binary files a/image.png and b/image.png differ\n"; + + $diff = Diff::fromString($patch, StringEngine::class); + + // Binary marker produces no op; the text hunk still gives the canonical 3-op structure + $edits = $diff->getDiff()->toArray(); + $this->assertCount( + 3, + $edits, + 'Binary marker line must be skipped, leaving the 3-op text hunk' + ); + $this->assertInstanceOf(CopyOperation::class, $edits[0]); + $this->assertInstanceOf(ChangeOperation::class, $edits[1]); + $this->assertInstanceOf(CopyOperation::class, $edits[2]); + $this->assertSame('This line is the same.', $edits[0]->orig[0]); + $this->assertSame('This line is different in 1.txt', $edits[1]->orig[0]); + $this->assertSame('This line is different in 2.txt', $edits[1]->final[0]); + $this->assertSame('This line is the same.', $edits[2]->orig[0]); + } + + /** + * "\ No newline at end of file" lines start with '\', which also hits the + * default branch. silently skipped. The change op's content must be unaffected. + */ + public function testStringEngineSkipsNoNewlineMarker(): void + { + $patch = "--- a/file.txt\n" + . "+++ b/file.txt\n" + . "@@ -1,3 +1,3 @@\n" + . " This line is the same.\n" + . "-This line is different in 1.txt\n" + . "+This line is different in 2.txt\n" + . " This line is the same.\n" + . "\\ No newline at end of file\n"; + + $diff = Diff::fromString($patch, StringEngine::class); + $edits = $diff->getDiff()->toArray(); + + $this->assertCount(3, $edits); + $this->assertInstanceOf(ChangeOperation::class, $edits[1]); + + // The "\" line must NOT have been appended to the last change's content + $this->assertSame('This line is different in 2.txt', $edits[1]->final[0]); + } + + /** + * A patch with 3 separate "@@ ... @@" hunks must produce 3 independent ops. + * Each "@@ ... @@" line starts with '@'. default branch (skip). + * The "-"/"+" pairs in each hunk produce ChangeOperations. + */ + public function testStringEngineMultiHunkPatch(): void + { + $patch = "--- original\n" + . "+++ revised\n" + . "@@ -1 +1 @@\n" + . "-line A\n" + . "+line X\n" + . "@@ -5 +5 @@\n" + . "-line B\n" + . "+line Y\n" + . "@@ -9 +9 @@\n" + . "-line C\n" + . "+line Z\n"; + + $diff = Diff::fromString($patch, StringEngine::class); + $edits = $diff->getDiff()->toArray(); + + $this->assertCount( + 3, + $edits, + 'Three "@@ ... @@" hunks should yield exactly 3 ops' + ); + + $this->assertInstanceOf(ChangeOperation::class, $edits[0]); + $this->assertInstanceOf(ChangeOperation::class, $edits[1]); + $this->assertInstanceOf(ChangeOperation::class, $edits[2]); + + $this->assertSame('line A', $edits[0]->orig[0]); + $this->assertSame('line X', $edits[0]->final[0]); + $this->assertSame('line B', $edits[1]->orig[0]); + $this->assertSame('line Y', $edits[1]->final[0]); + $this->assertSame('line C', $edits[2]->orig[0]); + $this->assertSame('line Z', $edits[2]->final[0]); + } + + // ------------------------------------------------------------------------- + // Inline HTML renderer boundary cases + // ------------------------------------------------------------------------- + + /** + * Identical input. All ops are CopyOperation, so the diff is empty and the + * InlineRenderer emits no block at all (GNU diff of identical files prints + * nothing). No / tags and no content are produced. + */ + public function testInlineRendererIdenticalLines(): void + { + $lines = ["This line is the same.\n", "Also the same.\n"]; + $diff = Diff::fromFileLineArrays($lines, $lines, NativeEngine::class); + + $this->assertTrue( + $diff->isEmpty(), + 'Identical input must produce an empty diff' + ); + + $renderer = new InlineRenderer(); + $output = $renderer->render($diff); + + $this->assertStringNotContainsString( + ' tags' + ); + $this->assertStringNotContainsString( + ' tags' + ); + $this->assertSame( + '', + $output, + 'An empty diff must render as an empty string (no hunks)' + ); + } + + /** + * All-inserted: original is empty. Every content line must be wrapped in . + * No tag should be present. + */ + public function testInlineRendererAllInserted(): void + { + $to = ["This line is the same.\n", "This line is different in 2.txt\n", "This line is the same.\n"]; + $diff = Diff::fromFileLineArrays([], $to, NativeEngine::class); + + $renderer = new InlineRenderer(); + $output = $renderer->render($diff); + + $this->assertStringContainsString( + '', + $output, + 'All-inserted diff must produce tags' + ); + $this->assertStringNotContainsString( + '', + $output, + 'All-inserted diff must not produce tags' + ); + } + + /** + * All deleted, revised is empty. Every content line must be wrapped in . + * No tag should be present. + */ + public function testInlineRendererAllDeleted(): void + { + $from = ["This line is the same.\n", "This line is different in 1.txt\n", "This line is the same.\n"]; + $diff = Diff::fromFileLineArrays($from, [], NativeEngine::class); + + $renderer = new InlineRenderer(); + $output = $renderer->render($diff); + + $this->assertStringContainsString( + '', + $output, + 'All-deleted diff must produce tags' + ); + $this->assertStringNotContainsString( + '', + $output, + 'All-deleted diff must not produce tags' + ); + } + + /** + * HTML special characters in unchanged (context) lines. + * + * InlineRenderer escapes content via htmlspecialchars() in _encode(), so + * "<", ">" and "&" in context lines are emitted in their escaped form. + * This test documents and locks down that (safe) behaviour. + */ + public function testInlineRendererEscapesHtmlSpecialCharsInContext(): void + { + // First line is unchanged context; second line changes one word + $from = ["same & stable\n", "changed word\n"]; + $to = ["same & stable\n", "new word\n"]; + + $diff = Diff::fromFileLineArrays($from, $to, NativeEngine::class); + $renderer = new InlineRenderer(); + $output = $renderer->render($diff); + + // Context line must appear HTML-escaped (htmlspecialchars): + // "<" -> "<", ">" -> ">", "&" -> "&" + $this->assertStringContainsString( + '<b>same &amp; stable</b>', + $output, + 'HTML special chars in context lines must be escaped via htmlspecialchars()' + ); + $this->assertStringNotContainsString( + 'same', + $output, + 'The raw, unescaped markup must not leak into the output' + ); + + // The changed word should be wrapped in del/ins + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('', $output); + } } diff --git a/test/ThreeWayTest.php b/test/ThreeWayTest.php index 69ef2e5..e2d57e1 100644 --- a/test/ThreeWayTest.php +++ b/test/ThreeWayTest.php @@ -49,4 +49,120 @@ public function testConflictingChanges() $merge = file_get_contents($this->fixtureDir . 'merge.txt'); $this->assertEquals($merge, implode("\n", $diff->mergedOutput('2.txt', '4.txt'))); } + + // ------------------------------------------------------------------------- + // Three way merge boundary cases + // ThreeWayBaseOperation::merged() returns: + // - final1 when final1 === final2 (both sides identical change) + // - final2 when final1 === orig (only theirs changed) + // - final1 when final2 === orig (only mine changed) + // - false otherwise (conflict) + // ------------------------------------------------------------------------- + + /** + * base = mine = theirs = 1.txt (no changes on either side). + * All three way ops are ThreeWayCopyOperation -> merged() = orig lines. + * No conflict markers must appear. Output equals the base content. + */ + public function testBothSidesIdenticalToBase(): void + { + $diff = ThreeWay::fromFileLineArrays( + $this->_lines[1], // base + $this->_lines[1], // mine (same as base) + $this->_lines[1], // theirs (same as base) + 'NativeEngine' + ); + $merged = implode("\n", $diff->mergedOutput('1.txt', '1.txt')); + + $this->assertStringNotContainsString('<<<<<<<', $merged, 'No conflict markers expected'); + $this->assertStringNotContainsString('=======', $merged); + $this->assertStringNotContainsString('>>>>>>>', $merged); + + // All original lines must appear in the merged output + $this->assertStringContainsString('This line is the same.', $merged); + $this->assertStringContainsString('This line is different in 1.txt', $merged); + } + + /** + * base = 1.txt, mine = 2.txt (changed line 2), theirs = 1.txt (unchanged). + * ThreeWayBaseOperation::merged(): final2 === orig returns final1 (mine's version). + * Clean merge. Result matches 2.txt content. + */ + public function testOnlyMineSideChanged(): void + { + $diff = ThreeWay::fromFileLineArrays( + $this->_lines[1], // base + $this->_lines[2], // mine (changed line 2) + $this->_lines[1], // theirs (unchanged from base) + 'NativeEngine' + ); + $merged = implode("\n", $diff->mergedOutput('2.txt', '1.txt')); + + $this->assertStringNotContainsString('<<<<<<<', $merged, 'No conflict markers expected'); + $this->assertStringContainsString( + 'This line is different in 2.txt', + $merged, + "Mine's changed line must appear in the merge" + ); + $this->assertStringNotContainsString( + 'This line is different in 1.txt', + $merged, + 'Original (base) version of the changed line must not appear' + ); + } + + /** + * base = 1.txt, mine = 1.txt (unchanged), theirs = 2.txt (changed line 2). + * ThreeWayBaseOperation::merged(): final1 === orig returns final2 (theirs's version). + * Clean merge. Result matches 2.txt content. + */ + public function testOnlyTheirsSideChanged(): void + { + $diff = ThreeWay::fromFileLineArrays( + $this->_lines[1], // base + $this->_lines[1], // mine (unchanged from base) + $this->_lines[2], // theirs (changed line 2) + 'NativeEngine' + ); + $merged = implode("\n", $diff->mergedOutput('1.txt', '2.txt')); + + $this->assertStringNotContainsString('<<<<<<<', $merged, 'No conflict markers expected'); + $this->assertStringContainsString( + 'This line is different in 2.txt', + $merged, + "Theirs's changed line must appear in the merge" + ); + $this->assertStringNotContainsString( + 'This line is different in 1.txt', + $merged, + 'Original (base) version of the changed line must not appear' + ); + } + + /** + * base = 1.txt, mine = 2.txt, theirs = 2.txt (both sides make the exact same change). + * ThreeWayBaseOperation::merged(): final1 === final2 returns final1. + * Identical changes from both sides must NOT produce a conflict. + */ + public function testBothSidesIdenticalChange(): void + { + $diff = ThreeWay::fromFileLineArrays( + $this->_lines[1], // base + $this->_lines[2], // mine (changed line 2) + $this->_lines[2], // theirs (identical change to mine) + 'NativeEngine' + ); + $merged = implode("\n", $diff->mergedOutput('2.txt', '2.txt')); + + $this->assertStringNotContainsString( + '<<<,<<<<', + $merged, + 'Identical changes on both sides must not produce a conflict' + ); + $this->assertStringContainsString( + 'This line is different in 2.txt', + $merged, + 'The shared change must appear in the merged output' + ); + } }