From 4b1a4563a5bf33a28f4647788758db5e3f4cf30c Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Fri, 17 Apr 2026 10:04:51 -0500 Subject: [PATCH] Re-add reversal tracking tests (#54169) Reversal tracking tests were removed in https://github.com/zed-industries/zed/pull/53912. This PR re-adds them Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A or Added/Fixed/Improved ... --- .../edit_prediction_metrics/src/reversal.rs | 1378 +++++++++++++++++ 1 file changed, 1378 insertions(+) diff --git a/crates/edit_prediction_metrics/src/reversal.rs b/crates/edit_prediction_metrics/src/reversal.rs index a1fff663d554f398d900a1b553f4321034a9661f..d6263d84c3fce53888ac96689c4c64513bb96c80 100644 --- a/crates/edit_prediction_metrics/src/reversal.rs +++ b/crates/edit_prediction_metrics/src/reversal.rs @@ -646,3 +646,1381 @@ pub fn compute_prediction_reversal_ratio_from_history( let overlap = compute_reversal_overlap(&original_content, current_content, predicted_content); overlap.ratio() } + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use zeta_prompt::udiff::apply_diff_to_string; + use zeta_prompt::{ExcerptRanges, ZetaPromptInput}; + + fn compute_prediction_reversal_ratio( + prompt_inputs: &ZetaPromptInput, + predicted_content: &str, + cursor_path: &Path, + ) -> f32 { + compute_prediction_reversal_ratio_from_history( + prompt_inputs.cursor_excerpt.as_ref(), + &prompt_inputs.events, + prompt_inputs.excerpt_start_row, + predicted_content, + cursor_path, + ) + } + + fn make_test_prompt_inputs( + content: &str, + events: Vec>, + excerpt_start_row: Option, + ) -> ZetaPromptInput { + ZetaPromptInput { + cursor_path: Arc::from(Path::new("src/test.rs")), + cursor_excerpt: content.into(), + cursor_offset_in_excerpt: 0, + excerpt_start_row, + events, + related_files: Some(Vec::new()), + active_buffer_diagnostics: Vec::new(), + excerpt_ranges: ExcerptRanges { + editable_150: 0..content.len(), + editable_180: 0..content.len(), + editable_350: 0..content.len(), + editable_150_context_350: 0..content.len(), + editable_180_context_350: 0..content.len(), + editable_350_context_150: 0..content.len(), + ..Default::default() + }, + syntax_ranges: None, + in_open_source_repo: false, + can_collect_data: false, + repo_url: None, + } + } + + #[test] + fn test_reversal_overlap() { + struct Case { + name: &'static str, + original: &'static str, + current: &'static str, + predicted: &'static str, + expected_reversal_chars: usize, + expected_total_chars: usize, + } + + let cases = [ + Case { + name: "user_adds_line_prediction_removes_it", + original: indoc! {" + a + b + c"}, + current: indoc! {" + a + new line + b + c"}, + predicted: indoc! {" + a + b + c"}, + expected_reversal_chars: 9, + expected_total_chars: 9, + }, + Case { + name: "user_deletes_line_prediction_restores_it", + original: indoc! {" + a + deleted + b"}, + current: indoc! {" + a + b"}, + predicted: indoc! {" + a + deleted + b"}, + expected_reversal_chars: 8, + expected_total_chars: 8, + }, + Case { + name: "user_deletes_text_prediction_restores_partial", + original: "hello beautiful world", + current: "hello world", + predicted: "hello beautiful world", + expected_reversal_chars: 10, + expected_total_chars: 10, + }, + Case { + name: "user_deletes_foo_prediction_adds_bar", + original: "foo", + current: "", + predicted: "bar", + expected_reversal_chars: 0, + expected_total_chars: 3, + }, + Case { + name: "independent_edits_different_locations", + original: indoc! {" + line1 + line2 + line3"}, + current: indoc! {" + LINE1 + line2 + line3"}, + predicted: indoc! {" + LINE1 + line2 + LINE3"}, + expected_reversal_chars: 0, + expected_total_chars: 10, + }, + Case { + name: "no_history_edits", + original: "same", + current: "same", + predicted: "different", + expected_reversal_chars: 0, + expected_total_chars: 13, + }, + Case { + name: "user_replaces_text_prediction_reverses", + original: indoc! {" + keep + delete_me + keep2"}, + current: indoc! {" + keep + added + keep2"}, + predicted: indoc! {" + keep + delete_me + keep2"}, + expected_reversal_chars: 14, + expected_total_chars: 14, + }, + Case { + name: "user_modifies_word_prediction_modifies_differently", + original: "the quick brown fox", + current: "the slow brown fox", + predicted: "the fast brown fox", + expected_reversal_chars: 4, + expected_total_chars: 8, + }, + Case { + name: "user finishes function name (suffix)", + original: "", + current: "epr", + predicted: "eprintln!()", + expected_reversal_chars: 0, + expected_total_chars: 8, + }, + Case { + name: "user starts function name (prefix)", + original: "", + current: "my_function()", + predicted: "test_my_function()", + expected_reversal_chars: 0, + expected_total_chars: 5, + }, + Case { + name: "user types partial, prediction extends in multiple places", + original: "", + current: "test_my_function", + predicted: "a_test_for_my_special_function_plz", + expected_reversal_chars: 0, + expected_total_chars: 18, + }, + // Edge cases for subsequence matching + Case { + name: "subsequence with interleaved underscores", + original: "", + current: "a_b_c", + predicted: "_a__b__c__", + expected_reversal_chars: 0, + expected_total_chars: 5, + }, + Case { + name: "not a subsequence - different characters", + original: "", + current: "abc", + predicted: "xyz", + expected_reversal_chars: 3, + expected_total_chars: 6, + }, + Case { + name: "not a subsequence - wrong order", + original: "", + current: "abc", + predicted: "cba", + expected_reversal_chars: 3, + expected_total_chars: 6, + }, + Case { + name: "partial subsequence - only some chars match", + original: "", + current: "abcd", + predicted: "axbx", + expected_reversal_chars: 4, + expected_total_chars: 8, + }, + // Common completion patterns + Case { + name: "completing a method call", + original: "", + current: "vec.pu", + predicted: "vec.push(item)", + expected_reversal_chars: 0, + expected_total_chars: 8, + }, + Case { + name: "completing an import statement", + original: "", + current: "use std::col", + predicted: "use std::collections::HashMap", + expected_reversal_chars: 0, + expected_total_chars: 17, + }, + Case { + name: "completing a struct field", + original: "", + current: "name: St", + predicted: "name: String", + expected_reversal_chars: 0, + expected_total_chars: 4, + }, + Case { + name: "prediction replaces with completely different text", + original: "", + current: "hello", + predicted: "world", + expected_reversal_chars: 5, + expected_total_chars: 10, + }, + Case { + name: "empty prediction removes user text", + original: "", + current: "mistake", + predicted: "", + expected_reversal_chars: 7, + expected_total_chars: 7, + }, + Case { + name: "fixing typo is not reversal", + original: "", + current: "", + expected_reversal_chars: 0, + expected_total_chars: 2, + }, + Case { + name: "infix insertion not reversal", + original: indoc! {" + from my_project import Foo + "}, + current: indoc! {" + ifrom my_project import Foo + "}, + predicted: indoc! {" + import + from my_project import Foo + "}, + expected_reversal_chars: 0, + expected_total_chars: 6, + }, + Case { + name: "non-word based reversal", + original: "from", + current: "ifrom", + predicted: "from", + expected_reversal_chars: 1, + expected_total_chars: 1, + }, + Case { + name: "multiple insertions no reversal", + original: "print(\"Hello, World!\")", + current: "sys.(\"Hello, World!\")", + predicted: "sys.stdout.write(\"Hello, World!\\n\")", + expected_reversal_chars: 0, + expected_total_chars: 14, + }, + ]; + + for case in &cases { + let overlap = compute_reversal_overlap(case.original, case.current, case.predicted); + assert_eq!( + overlap.chars_reversing_user_edits, case.expected_reversal_chars, + "Test '{}': expected {} reversal chars, got {}", + case.name, case.expected_reversal_chars, overlap.chars_reversing_user_edits + ); + assert_eq!( + overlap.total_chars_in_prediction, case.expected_total_chars, + "Test '{}': expected {} total chars, got {}", + case.name, case.expected_total_chars, overlap.total_chars_in_prediction + ); + } + } + + #[test] + fn test_reverse_diff() { + let forward_diff = indoc! {" + --- a/file.rs + +++ b/file.rs + @@ -1,3 +1,4 @@ + fn main() { + + let x = 42; + println!(\"hello\"); + }"}; + + let reversed = reverse_diff(forward_diff); + + assert!( + reversed.contains("+++ a/file.rs"), + "Should have +++ for old path" + ); + assert!( + reversed.contains("--- b/file.rs"), + "Should have --- for new path" + ); + assert!( + reversed.contains("- let x = 42;"), + "Added line should become deletion" + ); + assert!( + reversed.contains(" fn main()"), + "Context lines should be unchanged" + ); + } + + #[test] + fn test_reverse_diff_roundtrip() { + // Applying a diff and then its reverse should get back to original + let original = indoc! {" + first line + hello world + last line + "}; + let modified = indoc! {" + first line + hello beautiful world + last line + "}; + + // unified_diff doesn't include file headers, but apply_diff_to_string needs them + let diff_body = language::unified_diff(original, modified); + let forward_diff = format!("--- a/file\n+++ b/file\n{}", diff_body); + let reversed_diff = reverse_diff(&forward_diff); + + // Apply forward diff to original + let after_forward = apply_diff_to_string(&forward_diff, original).unwrap(); + assert_eq!(after_forward, modified); + + // Apply reversed diff to modified + let after_reverse = apply_diff_to_string(&reversed_diff, &after_forward).unwrap(); + assert_eq!(after_reverse, original); + } + + #[test] + fn test_filter_edit_history_by_path() { + // Test that filter_edit_history_by_path correctly matches paths when + // the edit history has paths with a repo prefix (e.g., "repo/src/file.rs") + // but the cursor_path doesn't have the repo prefix (e.g., "src/file.rs") + let events = vec![ + Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("myrepo/src/file.rs")), + old_path: Arc::from(Path::new("myrepo/src/file.rs")), + diff: indoc! {" + @@ -1 +1 @@ + -old + +new"} + .into(), + predicted: false, + in_open_source_repo: true, + }), + Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("myrepo/other.rs")), + old_path: Arc::from(Path::new("myrepo/other.rs")), + diff: indoc! {" + @@ -1 +1 @@ + -a + +b"} + .into(), + predicted: false, + in_open_source_repo: true, + }), + Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("src/file.rs")), + old_path: Arc::from(Path::new("src/file.rs")), + diff: indoc! {" + @@ -1 +1 @@ + -x + +y"} + .into(), + predicted: false, + in_open_source_repo: true, + }), + ]; + + // "myrepo/src/file.rs" stripped -> "src/file.rs" matches cursor_path + // "src/file.rs" exact match + let cursor_path = Path::new("src/file.rs"); + let filtered = filter_edit_history_by_path(&events, cursor_path); + assert_eq!( + filtered.len(), + 2, + "Should match myrepo/src/file.rs (stripped) and src/file.rs (exact)" + ); + + // "myrepo/src/file.rs" stripped -> "src/file.rs" != "file.rs" + // "src/file.rs" stripped -> "file.rs" == "file.rs" + let cursor_path = Path::new("file.rs"); + let filtered = filter_edit_history_by_path(&events, cursor_path); + assert_eq!( + filtered.len(), + 1, + "Should only match src/file.rs (stripped to file.rs)" + ); + + // "myrepo/other.rs" stripped -> "other.rs" == "other.rs" + let cursor_path = Path::new("other.rs"); + let filtered = filter_edit_history_by_path(&events, cursor_path); + assert_eq!(filtered.len(), 1, "Should match only myrepo/other.rs"); + } + + #[test] + fn test_reverse_diff_preserves_trailing_newline() { + let diff_with_trailing_newline = indoc! {" + --- a/file + +++ b/file + @@ -1 +1 @@ + -old + +new + "}; + let reversed = reverse_diff(diff_with_trailing_newline); + assert!( + reversed.ends_with('\n'), + "Reversed diff should preserve trailing newline" + ); + + let diff_without_trailing_newline = indoc! {" + --- a/file + +++ b/file + @@ -1 +1 @@ + -old + +new"}; + let reversed = reverse_diff(diff_without_trailing_newline); + assert!( + !reversed.ends_with('\n'), + "Reversed diff should not add trailing newline if original didn't have one" + ); + } + + #[test] + fn test_filter_hunks_by_excerpt_region() { + struct Case { + name: &'static str, + diff: &'static str, + excerpt_start_row: u32, + excerpt_row_count: u32, + expected_filtered_diff: &'static str, + expected_line_offset: i32, + } + + let cases = [ + Case { + name: "hunk_entirely_before_excerpt", + diff: indoc! {" + @@ -1,3 +1,4 @@ + line1 + +inserted + line2 + line3 + "}, + excerpt_start_row: 10, + excerpt_row_count: 5, + expected_filtered_diff: "", + expected_line_offset: 1, + }, + Case { + name: "hunk_entirely_inside_excerpt", + diff: indoc! {" + @@ -12,3 +12,4 @@ + line12 + +inserted + line13 + line14 + "}, + excerpt_start_row: 10, + excerpt_row_count: 10, + expected_filtered_diff: indoc! {" + @@ -2,3 +2,4 @@ + line12 + +inserted + line13 + line14 + "}, + expected_line_offset: 1, + }, + Case { + name: "hunk_entirely_after_excerpt", + diff: indoc! {" + @@ -50,3 +50,4 @@ + line50 + +inserted + line51 + line52 + "}, + excerpt_start_row: 10, + excerpt_row_count: 5, + expected_filtered_diff: "", + expected_line_offset: 0, + }, + Case { + name: "hunk_straddles_excerpt_start", + diff: indoc! {" + @@ -8,5 +8,6 @@ + line8 + line9 + +inserted + line10 + line11 + line12 + "}, + excerpt_start_row: 10, + excerpt_row_count: 10, + expected_filtered_diff: indoc! {" + @@ -1,3 +1,3 @@ + line10 + line11 + line12 + "}, + expected_line_offset: 1, + }, + Case { + name: "hunk_straddles_excerpt_end", + diff: indoc! {" + @@ -18,5 +18,6 @@ + line18 + line19 + +inserted + line20 + line21 + line22 + "}, + excerpt_start_row: 10, + excerpt_row_count: 10, + expected_filtered_diff: indoc! {" + @@ -8,2 +8,3 @@ + line18 + line19 + +inserted + "}, + expected_line_offset: 1, + }, + Case { + name: "multiple_hunks_mixed", + diff: indoc! {" + @@ -1,2 +1,3 @@ + line1 + +before_excerpt + line2 + @@ -12,2 +13,3 @@ + line12 + +inside_excerpt + line13 + @@ -50,2 +52,3 @@ + line50 + +after_excerpt + line51 + "}, + excerpt_start_row: 10, + excerpt_row_count: 10, + expected_filtered_diff: indoc! {" + @@ -3,2 +3,3 @@ + line12 + +inside_excerpt + line13 + "}, + expected_line_offset: 2, + }, + Case { + name: "deletion_before_excerpt", + diff: indoc! {" + @@ -1,4 +1,3 @@ + line1 + -deleted + line2 + line3 + "}, + excerpt_start_row: 10, + excerpt_row_count: 5, + expected_filtered_diff: "", + expected_line_offset: -1, + }, + Case { + name: "deletion_inside_excerpt", + diff: indoc! {" + @@ -12,4 +12,3 @@ + line12 + -deleted + line13 + line14 + "}, + excerpt_start_row: 10, + excerpt_row_count: 10, + expected_filtered_diff: indoc! {" + @@ -2,4 +2,3 @@ + line12 + -deleted + line13 + line14 + "}, + expected_line_offset: -1, + }, + Case { + name: "empty_diff", + diff: "", + excerpt_start_row: 10, + excerpt_row_count: 5, + expected_filtered_diff: "", + expected_line_offset: 0, + }, + Case { + name: "hunk_spans_entire_excerpt", + diff: indoc! {" + @@ -8,10 +8,12 @@ + line8 + line9 + line10 + line11 + +inserted1 + line12 + line13 + +inserted2 + line14 + line15 + line16 + line17 + "}, + excerpt_start_row: 10, + excerpt_row_count: 5, + expected_filtered_diff: indoc! {" + @@ -1,3 +1,5 @@ + line11 + +inserted1 + line12 + line13 + +inserted2 + "}, + expected_line_offset: 2, + }, + Case { + name: "replacement_inside_excerpt", + diff: indoc! {" + @@ -12,3 +12,3 @@ + line12 + -old_text + +new_text + line14 + "}, + excerpt_start_row: 10, + excerpt_row_count: 10, + expected_filtered_diff: indoc! {" + @@ -2,3 +2,3 @@ + line12 + -old_text + +new_text + line14 + "}, + expected_line_offset: 0, + }, + ]; + + for case in &cases { + let (filtered, line_offset) = filter_diff_hunks_by_excerpt( + case.diff, + case.excerpt_start_row, + case.excerpt_row_count, + ); + assert_eq!( + filtered, case.expected_filtered_diff, + "Test '{}': filtered diff mismatch.\nExpected:\n{}\nGot:\n{}", + case.name, case.expected_filtered_diff, filtered + ); + assert_eq!( + line_offset, case.expected_line_offset, + "Test '{}': line offset mismatch. Expected {}, got {}", + case.name, case.expected_line_offset, line_offset + ); + } + } + + #[test] + fn test_excerpt_aware_reversal_tracking() { + struct Case { + name: &'static str, + edit_history_diffs: Vec<&'static str>, + excerpt_content: &'static str, + excerpt_start_row: u32, + predicted_content: &'static str, + expected_reversal_chars: usize, + expected_total_chars: usize, + } + + let cases = [ + Case { + name: "edit_outside_excerpt_no_reversal", + edit_history_diffs: vec![indoc! {" + @@ -1,2 +1,3 @@ + line1 + +added_outside + line2 + "}], + excerpt_content: indoc! {" + line10 + line11 + line12 + "}, + excerpt_start_row: 10, + predicted_content: indoc! {" + line10 + modified + line12 + "}, + expected_reversal_chars: 0, + expected_total_chars: 14, + }, + Case { + name: "edit_inside_excerpt_with_reversal", + edit_history_diffs: vec![indoc! {" + @@ -10,3 +10,4 @@ + line10 + +user_added + line11 + line12 + "}], + excerpt_content: indoc! {" + line10 + user_added + line11 + line12 + "}, + excerpt_start_row: 10, + predicted_content: indoc! {" + line10 + line11 + line12 + "}, + expected_reversal_chars: 11, + expected_total_chars: 11, + }, + Case { + name: "straddling_edit_partial_reversal", + edit_history_diffs: vec![indoc! {" + @@ -8,6 +8,8 @@ + line8 + line9 + +before_excerpt + line10 + +inside_excerpt + line11 + line12 + line13 + "}], + excerpt_content: indoc! {" + line10 + inside_excerpt + line11 + line12 + line13 + "}, + excerpt_start_row: 10, + predicted_content: indoc! {" + line10 + line11 + line12 + line13 + "}, + expected_reversal_chars: 15, + expected_total_chars: 15, + }, + Case { + name: "multiple_edits_mixed_locations", + edit_history_diffs: vec![ + indoc! {" + @@ -1,2 +1,3 @@ + line1 + +outside1 + line2 + "}, + indoc! {" + @@ -11,2 +12,3 @@ + line11 + +inside1 + line12 + "}, + ], + excerpt_content: indoc! {" + line10 + line11 + inside1 + line12 + line13 + "}, + excerpt_start_row: 10, + predicted_content: indoc! {" + line10 + line11 + line12 + line13 + "}, + expected_reversal_chars: 8, + expected_total_chars: 8, + }, + Case { + name: "no_edit_history", + edit_history_diffs: vec![], + excerpt_content: indoc! {" + line10 + line11 + line12 + "}, + excerpt_start_row: 10, + predicted_content: indoc! {" + line10 + modified + line12 + "}, + expected_reversal_chars: 0, + expected_total_chars: 14, + }, + Case { + name: "edit_after_excerpt_no_effect", + edit_history_diffs: vec![indoc! {" + @@ -50,2 +50,3 @@ + line50 + +added_after + line51 + "}], + excerpt_content: indoc! {" + line10 + line11 + line12 + "}, + excerpt_start_row: 10, + predicted_content: indoc! {" + line10 + changed + line12 + "}, + expected_reversal_chars: 0, + expected_total_chars: 13, + }, + Case { + name: "line_offset_tracking_across_hunks", + edit_history_diffs: vec![ + indoc! {" + @@ -1,2 +1,4 @@ + line1 + +added1 + +added2 + line2 + "}, + indoc! {" + @@ -12,2 +14,3 @@ + line12 + +inside_after_offset + line13 + "}, + ], + excerpt_content: indoc! {" + line10 + line11 + line12 + inside_after_offset + line13 + "}, + excerpt_start_row: 10, + predicted_content: indoc! {" + line10 + line11 + line12 + line13 + "}, + expected_reversal_chars: 20, + expected_total_chars: 20, + }, + ]; + + for case in &cases { + let overlap = compute_excerpt_aware_reversal_overlap( + &case.edit_history_diffs, + case.excerpt_content, + case.excerpt_start_row, + case.predicted_content, + ); + assert_eq!( + overlap.chars_reversing_user_edits, case.expected_reversal_chars, + "Test '{}': expected {} reversal chars, got {}", + case.name, case.expected_reversal_chars, overlap.chars_reversing_user_edits + ); + assert_eq!( + overlap.total_chars_in_prediction, case.expected_total_chars, + "Test '{}': expected {} total chars, got {}", + case.name, case.expected_total_chars, overlap.total_chars_in_prediction + ); + } + } + + #[test] + fn test_lenient_diff_application() { + struct Case { + name: &'static str, + diff: &'static str, + content: &'static str, + expected_result: &'static str, + } + + let cases = [ + Case { + name: "hunk_context_not_found_skipped", + diff: indoc! {" + @@ -1,3 +1,4 @@ + context_not_in_content + +added_line + more_context + final_context + "}, + content: indoc! {" + completely + different + content + "}, + expected_result: indoc! {" + completely + different + content + "}, + }, + Case { + name: "hunk_context_found_applied", + diff: indoc! {" + @@ -1,3 +1,4 @@ + line1 + +inserted + line2 + line3 + "}, + content: indoc! {" + line1 + line2 + line3 + "}, + expected_result: indoc! {" + line1 + inserted + line2 + line3 + "}, + }, + Case { + name: "multiple_hunks_partial_match", + diff: indoc! {" + @@ -1,2 +1,3 @@ + not_found + +skipped + also_not_found + @@ -5,2 +6,3 @@ + line5 + +applied + line6 + "}, + content: indoc! {" + line1 + line2 + line3 + line4 + line5 + line6 + "}, + expected_result: indoc! {" + line1 + line2 + line3 + line4 + line5 + applied + line6 + "}, + }, + Case { + name: "empty_diff", + diff: "", + content: indoc! {" + unchanged + content + "}, + expected_result: indoc! {" + unchanged + content + "}, + }, + ]; + + for case in &cases { + let result = apply_diff_to_string_lenient(case.diff, case.content); + assert_eq!( + result, case.expected_result, + "Test '{}': expected:\n{}\ngot:\n{}", + case.name, case.expected_result, result + ); + } + } + + #[test] + fn test_unicode_reversal_overlap() { + struct Case { + name: &'static str, + original: &'static str, + current: &'static str, + predicted: &'static str, + expected_reversal_chars: usize, + expected_total_chars: usize, + } + + let cases = [ + Case { + name: "unicode_extension_cjk", + original: "", + current: "日", // 1 char + predicted: "日本語", // 3 chars, adds 2 chars + expected_reversal_chars: 0, + expected_total_chars: 2, // "本語" = 2 chars added + }, + Case { + name: "unicode_extension_emoji", + original: "", + current: "🎉", // 1 char + predicted: "🎉🎊🎈", // 3 chars, adds 2 chars + expected_reversal_chars: 0, + expected_total_chars: 2, // "🎊🎈" = 2 chars added + }, + Case { + name: "unicode_deletion_restored", + original: "héllo wörld", // 11 chars + current: "héllo", // 5 chars + predicted: "héllo wörld", // restores " wörld" = 6 chars + expected_reversal_chars: 6, // LCS(" wörld", " wörld") = 6 chars + expected_total_chars: 6, + }, + Case { + name: "unicode_addition_reversed", + original: "café", // 4 chars + current: "café latté", // 10 chars, added " latté" = 6 chars + predicted: "café", // removes " latté" + expected_reversal_chars: 6, // 6 chars removed + expected_total_chars: 6, + }, + Case { + name: "mixed_ascii_unicode", + original: "", + current: "test日本", // 6 chars + predicted: "test日本語です", // 9 chars + expected_reversal_chars: 0, + expected_total_chars: 3, // 3 new chars after subsequence normalization + }, + Case { + name: "unicode_replacement_not_subsequence", + original: "", + current: "日本", // 2 chars + predicted: "中国", // 2 chars, different + expected_reversal_chars: 2, // removes "日本" = 2 chars + expected_total_chars: 4, // 2 removed + 2 added + }, + ]; + + for case in &cases { + let overlap = compute_reversal_overlap(case.original, case.current, case.predicted); + assert_eq!( + overlap.chars_reversing_user_edits, case.expected_reversal_chars, + "Test '{}': expected {} reversal chars, got {}", + case.name, case.expected_reversal_chars, overlap.chars_reversing_user_edits + ); + assert_eq!( + overlap.total_chars_in_prediction, case.expected_total_chars, + "Test '{}': expected {} total chars, got {}", + case.name, case.expected_total_chars, overlap.total_chars_in_prediction + ); + } + } + + #[test] + fn test_compute_lcs_length() { + assert_eq!(compute_lcs_length("", ""), 0); + assert_eq!(compute_lcs_length("abc", ""), 0); + assert_eq!(compute_lcs_length("", "abc"), 0); + assert_eq!(compute_lcs_length("abc", "abc"), 3); + assert_eq!(compute_lcs_length("abc", "def"), 0); + assert_eq!(compute_lcs_length("abcdef", "ace"), 3); + assert_eq!(compute_lcs_length("AGGTAB", "GXTXAYB"), 4); + assert_eq!(compute_lcs_length("日本語", "日語"), 2); + } + + #[test] + fn test_compute_prediction_reversal_ratio_full_file() { + let prompt_inputs = make_test_prompt_inputs( + indoc! {" + line1 + user_added + line2 + "}, + vec![Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("src/test.rs")), + old_path: Arc::from(Path::new("src/test.rs")), + diff: indoc! {" + @@ -1,2 +1,3 @@ + line1 + +user_added + line2 + "} + .into(), + predicted: false, + in_open_source_repo: false, + })], + None, + ); + + let predicted = indoc! {" + line1 + line2 + "}; + let ratio = + compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs")); + + assert!( + ratio > 0.9, + "Expected high reversal ratio when prediction removes user addition, got {}", + ratio + ); + } + + #[test] + fn test_compute_prediction_reversal_ratio_with_excerpt() { + let prompt_inputs = make_test_prompt_inputs( + indoc! {" + line10 + user_added + line11 + "}, + vec![Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("src/test.rs")), + old_path: Arc::from(Path::new("src/test.rs")), + diff: indoc! {" + @@ -10,2 +10,3 @@ + line10 + +user_added + line11 + "} + .into(), + predicted: false, + in_open_source_repo: false, + })], + Some(10), + ); + + let predicted = indoc! {" + line10 + line11 + "}; + let ratio = + compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs")); + + assert!( + ratio > 0.9, + "Expected high reversal ratio for excerpt-aware computation, got {}", + ratio + ); + } + + #[test] + fn test_compute_prediction_reversal_ratio_no_history() { + let prompt_inputs = make_test_prompt_inputs( + indoc! {" + original content + "}, + vec![], + None, + ); + + let predicted = indoc! {" + completely different + "}; + let ratio = + compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs")); + + assert_eq!( + ratio, 0.0, + "Expected zero reversal ratio with no edit history" + ); + } + + #[test] + fn test_compute_prediction_reversal_ratio_path_filtering() { + let prompt_inputs = make_test_prompt_inputs( + indoc! {" + line1 + user_added + line2 + "}, + vec![Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("src/other.rs")), + old_path: Arc::from(Path::new("src/other.rs")), + diff: indoc! {" + @@ -1,2 +1,3 @@ + line1 + +user_added + line2 + "} + .into(), + predicted: false, + in_open_source_repo: false, + })], + None, + ); + + let predicted = indoc! {" + line1 + line2 + "}; + let ratio = + compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs")); + + assert_eq!( + ratio, 0.0, + "Expected zero reversal when edit history is for different file" + ); + } + + #[test] + fn test_compute_prediction_reversal_ratio_lenient_fallback() { + let prompt_inputs = make_test_prompt_inputs( + indoc! {" + actual_line1 + user_added + actual_line2 + "}, + vec![Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("src/test.rs")), + old_path: Arc::from(Path::new("src/test.rs")), + diff: indoc! {" + @@ -1,2 +1,3 @@ + wrong_context + +user_added + more_wrong + "} + .into(), + predicted: false, + in_open_source_repo: false, + })], + None, + ); + + let predicted = indoc! {" + actual_line1 + actual_line2 + "}; + let ratio = + compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs")); + + assert!( + ratio >= 0.0 && ratio <= 1.0, + "Ratio should be valid even with lenient fallback, got {}", + ratio + ); + } + + #[test] + fn test_excerpt_aware_reversal_error_recovery() { + let diffs = vec![indoc! {" + @@ -1,2 +1,3 @@ + nonexistent_context + +added + more_nonexistent + "}]; + let excerpt_content = indoc! {" + completely + different + content + "}; + let predicted_content = indoc! {" + completely + modified + content + "}; + + let overlap = + compute_excerpt_aware_reversal_overlap(&diffs, excerpt_content, 0, predicted_content); + + assert!( + overlap.ratio() >= 0.0 && overlap.ratio() <= 1.0, + "Should handle failed diff application gracefully" + ); + } + + #[test] + fn test_only_most_recent_edit_tracked() { + let prompt_inputs = make_test_prompt_inputs( + indoc! {" + line1 + first_add + second_add + line2 + "}, + vec![ + Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("src/test.rs")), + old_path: Arc::from(Path::new("src/test.rs")), + diff: indoc! {" + @@ -1,2 +1,3 @@ + line1 + +first_add + line2 + "} + .into(), + predicted: false, + in_open_source_repo: false, + }), + Arc::new(zeta_prompt::Event::BufferChange { + path: Arc::from(Path::new("src/test.rs")), + old_path: Arc::from(Path::new("src/test.rs")), + diff: indoc! {" + @@ -2,2 +2,3 @@ + first_add + +second_add + line2 + "} + .into(), + predicted: false, + in_open_source_repo: false, + }), + ], + None, + ); + + let predicted = indoc! {" + line1 + first_add + line2 + "}; + let ratio = + compute_prediction_reversal_ratio(&prompt_inputs, predicted, Path::new("src/test.rs")); + + assert!( + ratio > 0.9, + "Expected high reversal ratio when prediction exactly reverses the most recent edit, got {}", + ratio + ); + } +}