"""Applying a unified diff. Pure unit tests, no server and no database: this is where the behaviour that makes `file_edit` usable by a real model lives, and every case here is one that a real model produces. """ from __future__ import annotations import pytest from lembas.services.agent import patch def _apply(text: str, diff: str) -> str: return patch.apply(text, patch.parse(diff)) FILE = "\n".join(f"line {n}" for n in range(1, 21)) + "\n" # --- The ordinary case ---------------------------------------------------------- def test_a_hunk_at_the_line_it_says_applies(): result = _apply( FILE, "@@ -4,3 +4,3 @@\n line 3\n-line 4\n+LINE FOUR\n line 5\n", ) assert "LINE FOUR" in result assert "line 4\n" not in result assert result.count("\n") == FILE.count("\n"), "no lines gained or lost" def test_headers_are_tolerated(): """Models emit them by habit. Refusing costs a round trip to say so.""" result = _apply( FILE, "diff --git a/x.py b/x.py\nindex 1234567..89abcde 100644\n" "--- a/x.py\n+++ b/x.py\n@@ -4,3 +4,3 @@\n line 3\n-line 4\n+LINE FOUR\n line 5\n", ) assert "LINE FOUR" in result def test_several_hunks_apply_in_order(): result = _apply( FILE, "@@ -2,3 +2,3 @@\n line 1\n-line 2\n+TWO\n line 3\n" "@@ -15,3 +15,3 @@\n line 14\n-line 15\n+FIFTEEN\n line 16\n", ) assert "TWO" in result and "FIFTEEN" in result def test_a_pure_insertion_needs_no_context(): """And names the line it goes *after*, so it is not off by one the way every other hunk is.""" result = _apply("a\nb\n", "@@ -1,0 +2,1 @@\n+inserted\n") assert result == "a\ninserted\nb\n" # --- Line numbers drift, context does not --------------------------------------- def test_a_hunk_whose_line_numbers_are_wrong_still_applies(): """The single highest-value behaviour here. Models count from a truncated read or from the file as it was three edits ago and get the numbers wrong; they get the context right.""" result = _apply( FILE, "@@ -1,3 +1,3 @@\n line 11\n-line 12\n+TWELVE\n line 13\n", ) assert "TWELVE" in result assert "line 12\n" not in result def test_a_hunk_that_matches_nowhere_is_refused_and_names_what_is_there(): with pytest.raises(patch.PatchError) as caught: _apply(FILE, "@@ -4,3 +4,3 @@\n nothing\n-like this\n+new\n at all\n") message = caught.value.message assert "Hunk 1 did not apply" in message assert "Nothing was written" in message assert "Read the file again" in message def test_ambiguous_context_is_refused_rather_than_guessed_at(): """The one failure that silently corrupts a file. Two identical blocks and a hint pointing at neither: there is no way to tell which was meant.""" text = "start\nsame\nsame\nsame\nmiddle\nsame\nsame\nsame\nend\n" with pytest.raises(patch.PatchError) as caught: _apply(text, "@@ -50,3 +50,3 @@\n same\n-same\n+CHANGED\n same\n") assert "appear" in caught.value.message assert "more unchanged lines" in caught.value.message.lower() def test_drift_beyond_the_ceiling_is_not_searched(): long = "\n".join(f"line {n}" for n in range(1, 1000)) + "\n" with pytest.raises(patch.PatchError): _apply(long, "@@ -1,3 +1,3 @@\n line 900\n-line 901\n+NINE\n line 902\n") def test_nothing_is_written_when_a_later_hunk_fails(): """Atomic. A half-applied file is worse than a refused one, and the model cannot tell the difference without reading it again.""" with pytest.raises(patch.PatchError) as caught: _apply( FILE, "@@ -2,3 +2,3 @@\n line 1\n-line 2\n+TWO\n line 3\n" "@@ -15,3 +15,3 @@\n bogus\n-nope\n+x\n also bogus\n", ) assert caught.value.hunk == 2 def test_hunks_out_of_order_are_refused(): """Otherwise a duplicated hunk applies the same change twice.""" with pytest.raises(patch.PatchError): _apply( FILE, "@@ -15,3 +15,3 @@\n line 14\n-line 15\n+FIFTEEN\n line 16\n" "@@ -2,3 +2,3 @@\n line 1\n-line 2\n+TWO\n line 3\n", ) # --- The things that break on real files ------------------------------------------ def test_a_crlf_file_round_trips_as_crlf(): """Without normalising in and restoring out, every hunk on a Windows file fails on context that looks identical in the error message.""" text = "alpha\r\nbeta\r\ngamma\r\n" result = _apply(text, "@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n") assert result == "alpha\r\nBETA\r\ngamma\r\n" assert "\n\n" not in result.replace("\r\n", "\n\n").replace("\n\n", "\r\n") def test_a_blank_context_line_with_no_leading_space_applies(): """Trailing whitespace is stripped by half the things a model's output passes through, so this is the normal case rather than a malformed one.""" text = "alpha\n\ngamma\n" result = _apply(text, "@@ -1,3 +1,3 @@\n alpha\n\n-gamma\n+GAMMA\n") assert result == "alpha\n\nGAMMA\n" def test_a_file_with_no_trailing_newline_keeps_none(): result = _apply("alpha\nbeta", "@@ -1,2 +1,2 @@\n alpha\n-beta\n+BETA\n") assert result == "alpha\nBETA" def test_the_no_newline_marker_on_the_new_side_removes_the_trailing_newline(): result = _apply( "alpha\nbeta\n", "@@ -1,2 +1,2 @@\n alpha\n-beta\n+BETA\n\\ No newline at end of file\n", ) assert result == "alpha\nBETA" def test_the_no_newline_marker_on_the_old_side_is_not_an_instruction(): """git emits it for the old side too. Reading that as an instruction would strip a newline the patch never touched.""" result = _apply( "alpha\nbeta\n", "@@ -1,2 +1,2 @@\n alpha\n-beta\n\\ No newline at end of file\n+BETA\n", ) assert result == "alpha\nBETA\n" # --- Refusing the unusable --------------------------------------------------------- def test_a_patch_with_no_hunks_says_what_one_looks_like(): with pytest.raises(patch.PatchError) as caught: patch.parse("just change line four please") assert "@@" in caught.value.message def test_too_many_hunks_is_refused_and_points_at_file_write(): diff = "".join( f"@@ -{n},1 +{n},1 @@\n-line {n}\n+LINE {n}\n" for n in range(1, patch.MAX_HUNKS + 5) ) with pytest.raises(patch.PatchError) as caught: patch.parse(diff) assert "file_write" in caught.value.message # --- Rendering ----------------------------------------------------------------------- def test_render_produces_a_diff_of_the_change(): diff = patch.render("alpha\nbeta\n", "alpha\nBETA\n", "x.py") assert "-beta" in diff assert "+BETA" in diff assert "a/x.py" in diff def test_render_is_bounded(): """It goes on the message row forever and is re-parsed on every page load, and a generated file's diff can be larger than the file.""" before = "\n".join(str(n) for n in range(500)) after = "\n".join(f"x{n}" for n in range(500)) diff = patch.render(before, after, "big.txt", max_lines=20) assert len(diff.split("\n")) <= 21 assert "more lines" in diff