(** Tests for the line-level diff algorithm and hunk generation. *) let test_line_diff () = let open Ogit.Resolvers.Diff in match of_contents "first\nold\nlast\n" "first\nnew\nlast\n" with | [ ctx1; del; add; ctx2 ] -> Alcotest.(check string) "ctx1 text" "first" ctx1.text; Alcotest.(check string) "del text" "old" del.text; Alcotest.(check string) "add text" "new" add.text; Alcotest.(check string) "ctx2 text" "last" ctx2.text | lines -> Alcotest.failf "expected 4 lines, got %d" (List.length lines) let test_empty_vs_empty () = let open Ogit.Resolvers.Diff in let lines = of_contents "" "" in Alcotest.(check int) "no lines" 0 (List.length lines); let hunks = hunks lines in Alcotest.(check int) "no hunks" 0 (List.length hunks) let test_identical_content () = let open Ogit.Resolvers.Diff in let content = "line1\nline2\nline3\n" in let lines = of_contents content content in List.iter (fun line -> Alcotest.(check bool) "all context" true (line.kind = Context)) lines; let hunks = hunks lines in Alcotest.(check int) "no hunks for identical" 0 (List.length hunks) let test_entirely_new_file () = let open Ogit.Resolvers.Diff in let lines = of_contents "" "new1\nnew2\n" in Alcotest.(check int) "2 additions" 2 (List.length lines); List.iter (fun line -> Alcotest.(check bool) "all additions" true (line.kind = Addition)) lines let test_entirely_deleted_file () = let open Ogit.Resolvers.Diff in let lines = of_contents "old1\nold2\n" "" in Alcotest.(check int) "2 deletions" 2 (List.length lines); List.iter (fun line -> Alcotest.(check bool) "all deletions" true (line.kind = Deletion)) lines let test_trailing_newline_handling () = let open Ogit.Resolvers.Diff in let with_newline = of_contents "a\n" "a\n" in let without_newline = of_contents "a" "a" in Alcotest.(check int) "with newline: 1 line" 1 (List.length with_newline); Alcotest.(check int) "without newline: 1 line" 1 (List.length without_newline); Alcotest.(check string) "text matches" "a" (List.hd with_newline).text; Alcotest.(check string) "text matches" "a" (List.hd without_newline).text let test_no_trailing_newline_diff () = let open Ogit.Resolvers.Diff in let lines = of_contents "a\nb" "a\nc" in Alcotest.(check int) "3 lines" 3 (List.length lines); let kinds = List.map (fun l -> l.kind) lines in Alcotest.(check bool) "context+del+add" true (kinds = [ Context; Deletion; Addition ]) let test_hunks () = let open Ogit.Resolvers.Diff in let old_content = List.init 12 (fun i -> string_of_int (i + 1)) in let new_content = List.mapi (fun i l -> if i = 5 then "changed" else l) old_content in match hunks (of_contents (String.concat "\n" old_content) (String.concat "\n" new_content)) with | [ hunk ] -> Alcotest.(check int) "old_start" 3 hunk.old_start; Alcotest.(check int) "old_count" 7 hunk.old_count; Alcotest.(check int) "new_start" 3 hunk.new_start; Alcotest.(check int) "new_count" 7 hunk.new_count | hunks -> Alcotest.failf "expected 1 hunk, got %d" (List.length hunks) let test_multiple_hunks () = let open Ogit.Resolvers.Diff in let old_content = List.init 20 (fun i -> string_of_int (i + 1)) in let new_content = List.mapi (fun i l -> if i = 2 || i = 17 then "changed" else l) old_content in let result = hunks (of_contents (String.concat "\n" old_content) (String.concat "\n" new_content)) in Alcotest.(check int) "2 hunks" 2 (List.length result) let test_large_file_fallback () = let open Ogit.Resolvers.Diff in (* Every line differs after trimming: 2000 edits exceed the 1024-difference cap, so the diff degrades to wholesale replacement. *) let old_content = String.concat "\n" (List.init 2001 string_of_int) in let new_content = String.concat "\n" (List.init 2001 (fun i -> string_of_int (i + 3000))) in let lines = of_contents old_content new_content in let has_deletions = List.exists (fun l -> l.kind = Deletion) lines in let has_additions = List.exists (fun l -> l.kind = Addition) lines in Alcotest.(check bool) "has deletions" true has_deletions; Alcotest.(check bool) "has additions" true has_additions; (* No context lines in fallback mode *) let has_context = List.exists (fun l -> l.kind = Context) lines in Alcotest.(check bool) "no context in fallback" false has_context (* Cost tracks the number of differences, not file size: two edits in a 3000-line file must produce an exact diff, which the old quadratic-matrix implementation could not afford at this size. *) let test_small_change_in_large_file () = let open Ogit.Resolvers.Diff in let numbered = List.init 3000 (fun i -> "line " ^ string_of_int (i + 1)) in let changed = List.mapi (fun i line -> if i = 100 || i = 2900 then line ^ " changed" else line) numbered in let lines = of_contents (String.concat "\n" numbered ^ "\n") (String.concat "\n" changed ^ "\n") in let count kind = List.length (List.filter (fun l -> l.kind = kind) lines) in Alcotest.(check int) "two deletions" 2 (count Deletion); Alcotest.(check int) "two additions" 2 (count Addition); Alcotest.(check int) "rest is context" 2998 (count Context); (match List.rev lines with | last :: _ -> Alcotest.(check (option int)) "last old number" (Some 3000) last.old_number; Alcotest.(check (option int)) "last new number" (Some 3000) last.new_number | [] -> Alcotest.fail "no lines") (* Whatever script the search picks, replaying it must reproduce both inputs: the old file is the context and deleted lines in order, the new file the context and added lines. *) let test_reconstruction () = let open Ogit.Resolvers.Diff in let reconstruct_old lines = List.filter_map (fun l -> match l.kind with Context | Deletion -> Some l.text | Addition -> None) lines in let reconstruct_new lines = List.filter_map (fun l -> match l.kind with Context | Addition -> Some l.text | Deletion -> None) lines in let check name old_lines new_lines = let content lines = match lines with [] -> "" | _ -> String.concat "\n" lines ^ "\n" in let diff = of_contents (content old_lines) (content new_lines) in Alcotest.(check (list string)) (name ^ ": old reconstructed") old_lines (reconstruct_old diff); Alcotest.(check (list string)) (name ^ ": new reconstructed") new_lines (reconstruct_new diff) in check "replace middle" [ "a"; "b"; "c" ] [ "a"; "x"; "c" ]; check "empty old" [] [ "a"; "b" ]; check "empty new" [ "a"; "b" ] []; check "moved block" [ "a"; "b"; "c"; "d" ] [ "c"; "d"; "a"; "b" ]; check "interleaved" [ "1"; "2"; "3"; "4"; "5"; "6" ] [ "1"; "x"; "3"; "y"; "5"; "z" ]; check "repeated lines" [ "a"; "a"; "b"; "a" ] [ "a"; "b"; "a"; "a" ]; (* Past the difference cap the coarse fallback must still reconstruct. *) check "beyond the cap" (List.init 600 (fun i -> "old " ^ string_of_int i)) (List.init 700 (fun i -> "new " ^ string_of_int i)) let suite = ( "diff", [ Alcotest.test_case "basic line diff" `Quick test_line_diff; Alcotest.test_case "empty vs empty" `Quick test_empty_vs_empty; Alcotest.test_case "identical content" `Quick test_identical_content; Alcotest.test_case "entirely new file" `Quick test_entirely_new_file; Alcotest.test_case "entirely deleted file" `Quick test_entirely_deleted_file; Alcotest.test_case "trailing newline" `Quick test_trailing_newline_handling; Alcotest.test_case "no trailing newline diff" `Quick test_no_trailing_newline_diff; Alcotest.test_case "single hunk" `Quick test_hunks; Alcotest.test_case "multiple hunks" `Quick test_multiple_hunks; Alcotest.test_case "large file fallback" `Quick test_large_file_fallback; Alcotest.test_case "small change in large file" `Quick test_small_change_in_large_file; Alcotest.test_case "reconstruction" `Quick test_reconstruction; ] )