use super::utils::{
allow_overscroll_state, evolution_display_metrics, max_scroll, split_display_metrics,
};
use super::*;
use crate::test_utils::{DiffSettingsGuard, TestApp};
use oyo_core::{LineKind, MultiFileDiff, StepDirection, ViewLine};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
static VIEW_DEBUG_ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn diff_scrollbar_thumb_tracks_scroll_offset() {
assert_eq!(diff_scrollbar_thumb(100, 10, 20, 0), Some((0, 2)));
assert_eq!(diff_scrollbar_thumb(100, 10, 20, 90), Some((18, 2)));
assert_eq!(diff_scrollbar_thumb(10, 10, 20, 0), None);
}
struct ViewDebugEnvGuard {
_lock: MutexGuard<'static, ()>,
old_view: Option<std::ffi::OsString>,
old_view_nav: Option<std::ffi::OsString>,
old_view_file: Option<std::ffi::OsString>,
}
impl ViewDebugEnvGuard {
fn new(path: &std::path::Path) -> Self {
let lock = VIEW_DEBUG_ENV_LOCK.lock().unwrap();
let old_view = std::env::var_os("OYO_DEBUG_VIEW");
let old_view_nav = std::env::var_os("OYO_DEBUG_VIEW_NAV");
let old_view_file = std::env::var_os("OYO_DEBUG_VIEW_FILE");
std::env::set_var("OYO_DEBUG_VIEW", "1");
std::env::set_var("OYO_DEBUG_VIEW_NAV", "1");
std::env::set_var("OYO_DEBUG_VIEW_FILE", path);
Self {
_lock: lock,
old_view,
old_view_nav,
old_view_file,
}
}
}
impl Drop for ViewDebugEnvGuard {
fn drop(&mut self) {
match &self.old_view {
Some(val) => std::env::set_var("OYO_DEBUG_VIEW", val),
None => std::env::remove_var("OYO_DEBUG_VIEW"),
}
match &self.old_view_nav {
Some(val) => std::env::set_var("OYO_DEBUG_VIEW_NAV", val),
None => std::env::remove_var("OYO_DEBUG_VIEW_NAV"),
}
match &self.old_view_file {
Some(val) => std::env::set_var("OYO_DEBUG_VIEW_FILE", val),
None => std::env::remove_var("OYO_DEBUG_VIEW_FILE"),
}
}
}
#[test]
fn test_allow_overscroll_state() {
assert!(!allow_overscroll_state(false, false, false, false));
assert!(!allow_overscroll_state(false, true, true, false));
assert!(!allow_overscroll_state(false, false, false, true));
assert!(!allow_overscroll_state(true, false, false, false));
assert!(allow_overscroll_state(true, false, false, true));
assert!(!allow_overscroll_state(true, false, true, false));
assert!(!allow_overscroll_state(true, true, false, false));
assert!(allow_overscroll_state(true, true, true, false));
assert!(allow_overscroll_state(true, true, true, true));
assert!(allow_overscroll_state(true, true, false, true));
}
#[test]
fn selection_toolbar_waits_for_mouse_selection_finish() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"old\n".to_string(),
"new\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 100, false, None);
app.diff_view_area = Some((0, 0, 20, 2));
let mut row = vec![" ".to_string(); 20];
row[8] = "a".to_string();
row[9] = "b".to_string();
app.set_diff_selection_cells(vec![row.clone(), row]);
assert!(app.start_diff_selection(8, 0));
assert!(!app.selection_toolbar_visible());
assert!(app.drag_diff_selection(9, 0));
assert!(!app.selection_toolbar_visible());
assert!(app.finish_diff_selection(9, 0));
assert!(app.selection_toolbar_visible());
let selection = app.diff_selection;
app.set_selection_toolbar_hits(Vec::new());
app.set_selection_toolbar_rect(Some((5, 0, 10, 3)));
assert!(app.handle_selection_toolbar_click(6, 1));
assert_eq!(app.diff_selection, selection);
assert!(app.selection_toolbar_visible());
assert!(app.dismiss_selection_toolbar_click(0, 1));
assert!(app.diff_selection.is_none());
assert!(!app.selection_toolbar_visible());
assert!(app.start_diff_selection(8, 0));
assert!(app.drag_diff_selection(9, 0));
assert!(app.finish_diff_selection(9, 0));
app.set_selection_toolbar_rect(Some((5, 0, 10, 3)));
app.set_selection_toolbar_hits(vec![SelectionToolbarHit {
action: SelectionToolbarAction::Cancel,
x: 6,
y: 1,
width: 10,
height: 1,
}]);
assert!(app.handle_selection_toolbar_click(6, 1));
assert!(app.diff_selection.is_none());
assert!(!app.selection_toolbar_visible());
assert!(app.start_diff_selection(8, 0));
assert!(app.drag_diff_selection(9, 0));
assert!(app.finish_diff_selection(9, 0));
app.selection_actions = vec![crate::config::SelectionActionConfig::default()];
app.set_selection_toolbar_rect(Some((5, 0, 10, 3)));
app.set_selection_toolbar_hits(vec![SelectionToolbarHit {
action: SelectionToolbarAction::Custom(0),
x: 6,
y: 1,
width: 10,
height: 1,
}]);
assert!(app.handle_selection_toolbar_click(6, 1));
assert!(app.diff_selection.is_none());
assert!(!app.selection_toolbar_visible());
assert!(app.start_keyboard_selection());
assert!(!app.selection_toolbar_visible());
assert!(app.show_selection_toolbar());
assert!(app.selection_toolbar_visible());
}
#[test]
fn split_selection_comment_uses_selected_side() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"one\ntwo\nthree\n".to_string(),
"one\nTWO\nthree\n".to_string(),
);
let mut app = App::new(diff, ViewMode::Split, 100, false, None);
app.review_mode = true;
app.diff_view_area = Some((0, 0, 100, 6));
app.set_diff_selection_cells(vec![vec!["x".to_string(); 100]; 6]);
assert!(app.start_diff_selection(60, 1));
assert!(app.finish_diff_selection(61, 1));
app.set_selection_toolbar_rect(Some((0, 0, 1, 1)));
app.set_selection_toolbar_hits(vec![SelectionToolbarHit {
action: SelectionToolbarAction::Comment,
x: 0,
y: 0,
width: 1,
height: 1,
}]);
assert!(app.handle_selection_toolbar_click(0, 0));
let editor = app.review_editor.as_ref().expect("editor");
assert_eq!(editor.anchor.side, Some(review::ReviewSide::New));
assert_eq!(
editor.anchor.new_range,
Some(review::ReviewRange { start: 2, end: 2 })
);
}
#[test]
fn entering_image_file_keeps_current_view_mode() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("image.png"),
std::path::PathBuf::from("image.png"),
String::new(),
String::new(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 100, false, None);
app.handle_file_enter();
assert_eq!(app.view_mode, ViewMode::UnifiedPane);
}
#[test]
fn selecting_file_discards_review_editor() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"old\n".to_string(),
"new\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"old\n".to_string(),
"new\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 100, false, None);
app.review_mode = true;
app.review_editor = Some(review::ReviewEditorState {
anchor: review::ReviewAnchor {
file_index: 0,
file_path: "a.txt".to_string(),
kind: review::ReviewTargetKind::Line,
side: Some(review::ReviewSide::New),
old_range: Some(review::ReviewRange { start: 1, end: 1 }),
new_range: Some(review::ReviewRange { start: 1, end: 1 }),
hunk_id: None,
display_idx_hint: Some(0),
anchor_key: "line|a.txt|new|1".to_string(),
},
text: "draft".to_string(),
cursor: 5,
});
app.select_file(1);
assert!(!app.review_editor_active());
}
#[test]
fn review_line_add_rows_skip_reserved_comment_notes() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"old\n".to_string(),
"new\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 100, false, None);
app.review_mode = true;
app.diff_view_area = Some((0, 10, 40, 20));
app.set_diff_selection_cells(vec![vec!["x".to_string(); 40]; 20]);
app.add_review_preview_box(0, 12, 37, 3, "note".to_string());
assert_eq!(app.review_display_idx_for_screen_row(11), Some(1));
assert_eq!(app.review_display_idx_for_screen_row(12), None);
assert_eq!(app.review_display_idx_for_screen_row(15), Some(2));
assert_eq!(app.review_line_add_hover_at(38, 12), (None, false));
assert_eq!(app.review_line_add_hover_at(38, 15), (Some(15), true));
}
#[test]
fn review_line_add_click_opens_line_comment() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"old\n".to_string(),
"new\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 100, false, None);
app.review_mode = true;
app.diff_view_area = Some((0, 0, 40, 4));
app.set_diff_selection_cells(vec![vec!["x".to_string(); 40]]);
let (row, hover) = app.review_line_add_hover_at(38, 0);
assert_eq!(row, Some(0));
assert!(hover);
app.review_line_add_hit = Some(ReviewLineAddHit {
x: 37,
y: 0,
width: 3,
height: 1,
row: 0,
});
assert!(app.start_diff_selection(8, 0));
assert!(app.finish_diff_selection(9, 0));
assert!(app.selection_toolbar_visible());
assert!(app.handle_review_line_add_click(38, 0));
assert!(app.review_editor_active());
assert!(app.diff_selection.is_none());
assert!(!app.selection_toolbar_visible());
app.review_insert_char('a');
app.review_save_editor();
assert_eq!(app.review_comment_count(), 1);
app.review_preview_hover = Some(app.review_comments[0].anchor.anchor_key.clone());
assert!(app.remove_hovered_review_comment());
assert_eq!(app.review_comment_count(), 0);
}
#[test]
fn test_max_scroll_normal() {
assert_eq!(max_scroll(100, 20, false), 80);
assert_eq!(max_scroll(50, 10, false), 40);
assert_eq!(max_scroll(20, 20, false), 0);
assert_eq!(max_scroll(5, 20, false), 0);
}
#[test]
fn test_max_scroll_overscroll() {
assert_eq!(max_scroll(100, 20, true), 89);
assert_eq!(max_scroll(50, 10, true), 44);
assert_eq!(max_scroll(5, 20, true), 0);
assert_eq!(max_scroll(1, 20, true), 0);
}
fn make_view_line(
kind: LineKind,
old_line: Option<usize>,
new_line: Option<usize>,
is_active: bool,
is_primary_active: bool,
) -> ViewLine {
ViewLine {
content: String::new(),
spans: vec![],
kind,
old_line,
new_line,
is_active,
is_active_change: is_active,
is_primary_active,
show_hunk_extent: false,
change_id: 0,
hunk_index: None,
has_changes: kind != LineKind::Context,
}
}
#[test]
fn test_evolution_metrics_skips_deleted() {
let view = vec![
make_view_line(LineKind::Context, Some(1), Some(1), false, false),
make_view_line(LineKind::Deleted, Some(2), None, false, false),
make_view_line(LineKind::Deleted, Some(3), None, false, false),
make_view_line(LineKind::Context, Some(4), Some(2), true, true),
];
let (len, idx) = evolution_display_metrics(&view, AnimationPhase::Idle);
assert_eq!(len, 2);
assert_eq!(idx, Some(1));
}
#[test]
fn test_evolution_metrics_pending_delete_visibility() {
let view = vec![
make_view_line(LineKind::Context, Some(1), Some(1), false, false),
make_view_line(LineKind::PendingDelete, Some(2), None, true, true),
make_view_line(LineKind::Context, Some(3), Some(2), false, false),
];
let (len, idx) = evolution_display_metrics(&view, AnimationPhase::Idle);
assert_eq!(len, 2);
assert_eq!(idx, None);
let (len, idx) = evolution_display_metrics(&view, AnimationPhase::FadeOut);
assert_eq!(len, 3);
assert_eq!(idx, Some(1));
let (len, idx) = evolution_display_metrics(&view, AnimationPhase::FadeIn);
assert_eq!(len, 3);
assert_eq!(idx, Some(1));
}
#[test]
fn test_split_metrics_primary_dominates() {
let view = vec![
make_view_line(LineKind::Context, Some(1), Some(1), true, false),
make_view_line(LineKind::Context, Some(2), Some(2), false, false),
make_view_line(LineKind::Inserted, None, Some(3), true, true),
];
let (len, idx) = split_display_metrics(&view, 0, StepDirection::Forward, false);
assert_eq!(len, 3);
assert_eq!(idx, Some(2));
}
#[test]
fn test_split_metrics_minimize_jump() {
let view = vec![
make_view_line(LineKind::Context, Some(1), Some(1), false, false),
make_view_line(LineKind::Context, Some(2), Some(2), false, false),
make_view_line(LineKind::Modified, Some(3), Some(3), true, true),
make_view_line(LineKind::Context, Some(4), Some(4), false, false),
];
let (_, idx) = split_display_metrics(&view, 0, StepDirection::Forward, false);
assert_eq!(idx, Some(2));
let (_, idx) = split_display_metrics(&view, 0, StepDirection::Backward, false);
assert_eq!(idx, Some(2));
let (_, idx) = split_display_metrics(&view, 10, StepDirection::Forward, false);
assert_eq!(idx, Some(2));
}
#[test]
fn test_split_metrics_fallback_when_no_primary() {
let view = vec![
make_view_line(LineKind::Context, Some(1), Some(1), false, false),
make_view_line(LineKind::Context, Some(2), Some(2), true, false),
make_view_line(LineKind::Context, Some(3), Some(3), false, false),
];
let (len, idx) = split_display_metrics(&view, 0, StepDirection::Forward, false);
assert_eq!(len, 3);
assert_eq!(idx, Some(1));
}
fn make_app_with_two_hunks() -> TestApp {
TestApp::new_default(|| {
let old_lines: Vec<String> = (1..=25).map(|i| format!("line{}", i)).collect();
let mut new_lines = old_lines.clone();
new_lines[1] = "line2-new".to_string();
new_lines[19] = "line20-new".to_string();
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old,
new,
);
let mut app = App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.enter_no_step_mode();
app
})
}
fn make_app_with_unified_hunk() -> TestApp {
TestApp::new_default(|| {
let old = "one\ntwo\nthree".to_string();
let new = "one\nTWO\nthree".to_string();
let multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old,
new,
);
let mut app = App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.enter_no_step_mode();
app
})
}
fn make_app_with_unified_hunk_two_changes() -> TestApp {
TestApp::new_default(|| {
let old = "one\ntwo\nthree\nfour".to_string();
let new = "ONE\nTWO\nthree\nfour".to_string();
let multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old,
new,
);
App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None)
})
}
#[test]
fn test_right_file_panel_resize_uses_left_edge() {
let multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"old".to_string(),
"new".to_string(),
);
let mut app = App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None);
app.file_panel_position = FilePanelPosition::Right;
app.file_panel_rect = Some((70, 0, 30, 20));
assert!(app.start_file_panel_resize(70, 5));
assert!(app.drag_file_panel_resize(65, 100));
assert_eq!(app.file_panel_width, 35);
}
fn make_large_app(lines: usize, change_line: usize) -> App {
let old_lines: Vec<String> = (0..lines).map(|i| format!("line{}", i)).collect();
let mut new_lines = old_lines.clone();
new_lines[change_line] = format!("LINE{}", change_line);
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let mut multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old.clone(),
new.clone(),
);
let diff = MultiFileDiff::compute_diff(&old, &new);
multi_diff.apply_diff_result(0, diff);
multi_diff.ensure_full_navigator(0);
let mut app = App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.no_step_auto_jump_on_enter = false;
app.enter_no_step_mode();
app
}
fn make_large_step_app(lines: usize, change_lines: &[usize]) -> App {
let old_lines: Vec<String> = (0..lines).map(|i| format!("line{}", i)).collect();
let mut new_lines = old_lines.clone();
for &idx in change_lines {
if idx < new_lines.len() {
new_lines[idx] = format!("LINE{}", idx);
}
}
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let mut multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old.clone(),
new.clone(),
);
let diff = MultiFileDiff::compute_diff(&old, &new);
multi_diff.apply_diff_result(0, diff);
multi_diff.ensure_full_navigator(0);
let mut app = App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None);
app.no_step_auto_jump_on_enter = false;
app
}
#[test]
fn test_no_step_prev_hunk_from_bottom_advances() {
let mut app = make_app_with_two_hunks();
let total_hunks = app.multi_diff.current_navigator().state().total_hunks;
assert_eq!(total_hunks, 2);
app.goto_end();
app.prev_hunk_scroll();
{
let state = app.multi_diff.current_navigator().state();
assert!(state.cursor_change.is_some());
assert!(state.last_nav_was_hunk);
}
app.prev_hunk_scroll();
let state = app.multi_diff.current_navigator().state();
assert_eq!(state.current_hunk, 0);
}
#[test]
fn test_no_step_next_hunk_after_goto_start() {
let mut app = make_app_with_two_hunks();
app.goto_start();
app.next_hunk_scroll();
let state = app.multi_diff.current_navigator().state();
assert_eq!(state.current_hunk, 0);
assert!(state.cursor_change.is_some());
assert!(state.last_nav_was_hunk);
}
#[test]
fn test_unified_hunk_jump_sets_cursor() {
let mut app = make_app_with_unified_hunk();
app.next_hunk_scroll();
let state = app.multi_diff.current_navigator().state();
assert_eq!(state.total_hunks, 1);
assert_eq!(state.current_hunk, 0);
assert!(state.cursor_change.is_some());
assert!(state.last_nav_was_hunk);
}
#[test]
fn test_goto_start_clears_hunk_scope_in_no_step() {
let mut app = make_app_with_two_hunks();
app.next_hunk_scroll();
app.goto_start();
let state = app.multi_diff.current_navigator().state();
assert!(!state.last_nav_was_hunk);
assert!(state.cursor_change.is_none());
}
#[test]
fn test_goto_end_clears_hunk_scope_in_no_step() {
let mut app = make_app_with_two_hunks();
app.next_hunk_scroll();
app.goto_end();
let state = app.multi_diff.current_navigator().state();
assert!(!state.last_nav_was_hunk);
assert!(state.cursor_change.is_none());
}
#[test]
fn test_no_step_b_e_jump_within_hunk() {
let mut app = make_app_with_two_hunks();
app.next_hunk_scroll();
let state = app.multi_diff.current_navigator().state();
let current_hunk = state.current_hunk;
app.goto_hunk_end_scroll();
let end_state = app.multi_diff.current_navigator().state();
assert_eq!(end_state.current_hunk, current_hunk);
assert!(end_state.cursor_change.is_some());
app.goto_hunk_start_scroll();
let start_state = app.multi_diff.current_navigator().state();
assert_eq!(start_state.current_hunk, current_hunk);
assert!(start_state.cursor_change.is_some());
}
#[test]
fn test_toggle_stepping_restores_no_step_cursor_scope() {
let mut app = make_app_with_two_hunks();
app.next_hunk_scroll();
let before = app.multi_diff.current_navigator().state().clone();
assert!(before.last_nav_was_hunk);
assert!(before.cursor_change.is_some());
app.toggle_stepping();
assert!(app.stepping);
app.toggle_stepping();
let after = app.multi_diff.current_navigator().state();
assert_eq!(after.current_hunk, before.current_hunk);
assert_eq!(after.cursor_change, before.cursor_change);
assert!(after.last_nav_was_hunk);
}
#[test]
fn test_hunk_step_info_counts_applied_changes() {
let mut app = make_app_with_unified_hunk_two_changes();
assert_eq!(app.hunk_step_info(), Some((0, 2)));
app.next_step();
assert_eq!(app.hunk_step_info(), Some((1, 2)));
app.next_step();
assert_eq!(app.hunk_step_info(), Some((2, 2)));
}
#[test]
fn test_no_step_snapshot_restores_cursor_or_jumps() {
let _guard = DiffSettingsGuard::default();
let old_lines: Vec<String> = (1..=25).map(|i| format!("line{}", i)).collect();
let mut new_lines = old_lines.clone();
new_lines[1] = "line2-new".to_string();
new_lines[19] = "line20-new".to_string();
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old,
new,
);
let mut app = App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.no_step_auto_jump_on_enter = true;
app.enter_no_step_mode();
let idx = app.multi_diff.selected_index;
app.save_no_step_state_snapshot(idx);
app.multi_diff.current_navigator().clear_cursor_change();
app.multi_diff.current_navigator().set_hunk_scope(false);
assert!(app.restore_no_step_state_snapshot(idx));
let cursor_id = app
.multi_diff
.current_navigator()
.state()
.cursor_change
.expect("cursor change expected");
assert!(cursor_id > 0);
}
fn topbar_tab(id: usize, file_index: usize) -> TopbarTab {
TopbarTab {
id,
content: TopbarTabContent::File(file_index),
view_mode: ViewMode::UnifiedPane,
step_view_mode: ViewMode::UnifiedPane,
stepping: true,
scroll_offset: 0,
horizontal_scroll: 0,
preview_rendered: true,
navigator_state: None,
}
}
fn topbar_files(app: &App) -> Vec<usize> {
app.topbar_tabs
.iter()
.filter_map(|tab| match tab.content {
TopbarTabContent::File(index) => Some(index),
TopbarTabContent::Help => None,
})
.collect()
}
fn topbar_ids(app: &App) -> Vec<usize> {
app.topbar_tabs.iter().map(|tab| tab.id).collect()
}
#[test]
fn disabled_toasts_do_not_enqueue_notifications() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.toasts_enabled = false;
app.toggle_line_wrap();
assert_eq!(app.toast_engine.queue_len(), 0);
}
#[test]
fn diff_view_scrolls_horizontally_with_mouse() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"short\n".to_string(),
"very very very long line\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.diff_view_area = Some((0, 1, 10, 10));
app.set_current_max_line_width(40);
assert!(app.mouse_over_diff_view(5, 5));
assert!(app.scroll_diff_horizontally(1));
assert_eq!(app.horizontal_scroll, 4);
assert!(app.scroll_diff_horizontally(-1));
assert_eq!(app.horizontal_scroll, 0);
app.diff_view_area = Some((0, 1, 200, 10));
assert!(app.scroll_diff_horizontally(1));
assert_eq!(app.horizontal_scroll, 4);
}
#[test]
fn topbar_tabs_scroll_horizontally_without_scrollbar_state() {
let diff = MultiFileDiff::from_file_pairs(
(0..4)
.map(|idx| {
(
std::path::PathBuf::from(format!("file-{idx}.txt")),
"old\n".to_string(),
"new\n".to_string(),
)
})
.collect(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.new_topbar_tab();
app.new_topbar_tab();
assert!(app.scroll_topbar_tabs(1));
assert_eq!(app.topbar_tab_scroll, 1);
assert!(app.scroll_topbar_tabs(-1));
assert_eq!(app.topbar_tab_scroll, 0);
}
#[test]
fn topbar_overflow_buttons_scroll_tabs() {
let diff = MultiFileDiff::from_file_pairs(
(0..4)
.map(|idx| {
(
std::path::PathBuf::from(format!("file-{idx}.txt")),
"old\n".to_string(),
"new\n".to_string(),
)
})
.collect(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.new_topbar_tab();
app.new_topbar_tab();
app.topbar_scroll_right_hit = Some((10, 0, 1, 1));
assert!(app.update_topbar_hover(10, 0));
assert!(app.topbar_scroll_right_hover);
assert!(app.handle_topbar_mouse_down(10, 0));
assert_eq!(app.topbar_tab_scroll, 1);
app.topbar_scroll_left_hit = Some((0, 0, 1, 1));
assert!(app.handle_topbar_mouse_down(0, 0));
assert_eq!(app.topbar_tab_scroll, 0);
}
#[test]
fn status_bar_mode_click_cycles_views() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.status_mode_hit = Some((0, 9, 9, 1));
assert!(app.handle_status_bar_mouse_down(1, 9, false));
assert_eq!(app.view_mode, ViewMode::Split);
assert!(app.handle_status_bar_mouse_down(1, 9, true));
assert_eq!(app.view_mode, ViewMode::UnifiedPane);
}
#[test]
fn topbar_preview_toggle_hover_tracks_hitbox() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("README.md"),
std::path::PathBuf::from("README.md"),
"old\n".to_string(),
"new\n".to_string(),
);
let mut app = App::new(diff, ViewMode::Preview, 0, false, None);
app.preview_toggle_hit = Some((10, 0, 8, 1));
assert!(app.update_topbar_hover(12, 0));
assert!(app.preview_toggle_hover);
assert!(app.update_topbar_hover(1, 0));
assert!(!app.preview_toggle_hover);
}
#[test]
fn file_filter_click_keeps_existing_query() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_filter = "abc".to_string();
app.file_filter_area = Some((0, 0, 20, 3));
assert!(app.handle_file_list_click(1, 1, false));
assert!(app.file_filter_active);
assert_eq!(app.file_filter, "abc");
}
#[test]
fn file_filter_click_out_blurs_filter() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_filter_active = true;
app.file_list_area = Some((0, 0, 20, 5));
assert!(app.handle_file_list_click(30, 1, false));
assert!(!app.file_filter_active);
}
#[test]
fn file_filter_clear_button_clears_filter() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_filter = "abc".to_string();
app.file_filter_clear_hit = Some((10, 2, 1, 1));
app.file_panel_rect = Some((0, 0, 20, 10));
assert!(app.update_topbar_hover(10, 2));
assert!(app.file_filter_clear_hover);
assert!(app.handle_file_list_click(10, 2, false));
assert!(app.file_filter.is_empty());
}
#[test]
fn ctrl_click_file_list_opens_new_tab() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_list_area = Some((0, 0, 20, 5));
app.file_list_rows = vec![Some(1)];
assert!(app.handle_file_list_click(1, 1, true));
assert_eq!(topbar_files(&app), vec![0, 1]);
assert_eq!(app.active_topbar_content(), Some(TopbarTabContent::File(1)));
}
#[test]
fn file_list_hover_tracks_row_hitbox() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_list_area = Some((0, 1, 20, 5));
app.file_list_rows = vec![None, Some(1)];
app.file_panel_rect = Some((0, 0, 20, 10));
assert!(app.update_topbar_hover(2, 3));
assert_eq!(app.file_list_hover, Some(1));
assert!(app.file_panel_hover);
assert!(app.update_topbar_hover(25, 3));
assert_eq!(app.file_list_hover, None);
assert!(!app.file_panel_hover);
}
#[test]
fn file_panel_root_hover_and_click_open_oy_view() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_panel_mode = FilePanelMode::Comments;
app.file_panel_root_hit = Some((1, 0, 8, 1));
app.file_panel_rect = Some((0, 0, 20, 10));
assert!(app.update_topbar_hover(2, 0));
assert!(app.file_panel_root_hover);
assert!(app.handle_file_list_click(2, 0, false));
assert!(app.open_dashboard);
assert_eq!(app.file_panel_mode, FilePanelMode::Comments);
}
#[test]
fn selecting_visible_file_does_not_recentre_sidebar() {
let pairs = (0..50)
.map(|idx| {
(
std::path::PathBuf::from(format!("file-{idx}.txt")),
"old\n".to_string(),
"new\n".to_string(),
)
})
.collect();
let diff = MultiFileDiff::from_file_pairs(pairs);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_list_area = Some((0, 0, 30, 43));
app.select_file(39);
assert_eq!(app.file_list_scroll, 0);
}
#[test]
fn file_list_scroll_counts_group_rows() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a/one.txt"),
"old\n".to_string(),
"new\n".to_string(),
),
(
std::path::PathBuf::from("b/two.txt"),
"old\n".to_string(),
"new\n".to_string(),
),
]);
let app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
let indices = app.filtered_file_indices();
assert_eq!(app.file_list_total_rows(&indices), 5);
}
#[test]
fn single_file_can_show_sidebar() {
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.file_panel_visible = false;
assert!(app.show_files_sidebar());
assert!(app.file_panel_visible);
assert_eq!(app.file_panel_mode, FilePanelMode::Files);
}
#[test]
fn topbar_sidebar_toggle_button_toggles_file_panel() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.topbar_sidebar_toggle_hit = Some((0, 0, 3, 1));
assert!(app.update_topbar_hover(1, 0));
assert!(app.topbar_sidebar_toggle_hover);
assert!(app.handle_topbar_mouse_down(1, 0));
assert!(!app.file_panel_visible);
assert!(app.handle_topbar_mouse_down(1, 0));
assert!(app.file_panel_visible);
}
#[test]
fn topbar_close_keeps_last_tab() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.topbar_tabs = vec![topbar_tab(1, 0), topbar_tab(2, 1)];
app.active_topbar_tab = Some(2);
app.multi_diff.select_file(1);
app.topbar_tab_hits = vec![TopbarTabHit {
tab_id: 2,
row: 0,
start_col: 0,
end_col: 7,
close_col: Some(5),
}];
assert!(app.update_topbar_hover(5, 0));
assert_eq!(app.topbar_hover_close, Some(2));
assert!(app.update_topbar_hover(1, 0));
assert_eq!(app.topbar_hover_close, None);
assert!(app.handle_topbar_mouse_down(5, 0));
assert_eq!(topbar_files(&app), vec![0]);
assert_eq!(app.active_topbar_tab, Some(1));
assert_eq!(app.multi_diff.selected_index, 0);
app.topbar_tab_hits = vec![TopbarTabHit {
tab_id: 1,
row: 0,
start_col: 0,
end_col: 7,
close_col: Some(5),
}];
assert!(app.handle_topbar_mouse_down(5, 0));
assert_eq!(topbar_files(&app), vec![0]);
}
#[test]
fn topbar_select_replaces_active_tab_file() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.select_file(1);
assert_eq!(topbar_files(&app), vec![1]);
app.new_topbar_tab();
assert_eq!(topbar_files(&app), vec![1, 1]);
app.select_file(0);
assert_eq!(topbar_files(&app), vec![1, 0]);
}
#[test]
fn help_opens_as_preview_tab() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.md"),
"# old\n".to_string(),
"# new\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.open_help_tab();
assert_eq!(app.view_mode, ViewMode::Preview);
assert_eq!(app.active_topbar_content(), Some(TopbarTabContent::Help));
assert_eq!(app.topbar_tabs.len(), 2);
app.open_help_tab();
assert_eq!(app.topbar_tabs.len(), 2);
app.select_file(1);
assert_eq!(app.active_topbar_content(), Some(TopbarTabContent::File(1)));
}
#[test]
fn preview_render_toggle_is_per_tab() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.md"),
"# old\n".to_string(),
"# new\n".to_string(),
),
(
std::path::PathBuf::from("b.md"),
"# b\n".to_string(),
"# bb\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::Preview, 0, false, None);
app.toggle_preview_rendered();
assert!(!app.active_preview_rendered());
app.new_topbar_tab();
assert!(!app.active_preview_rendered());
app.select_file(1);
assert!(app.active_preview_rendered());
}
#[test]
fn topbar_tabs_keep_view_and_step_state() {
let diff = MultiFileDiff::from_file_pairs(vec![(
std::path::PathBuf::from("a.txt"),
"a\nb\n".to_string(),
"aa\nbb\n".to_string(),
)]);
let mut app = App::new(diff, ViewMode::Split, 0, false, None);
app.stepping = false;
app.multi_diff.current_navigator().goto(1);
app.new_topbar_tab();
let first = app.topbar_tabs[0].id;
let second = app.topbar_tabs[1].id;
app.view_mode = ViewMode::UnifiedPane;
app.stepping = true;
app.multi_diff.current_navigator().goto(0);
app.select_topbar_tab(first);
assert_eq!(app.view_mode, ViewMode::Split);
assert!(!app.stepping);
assert_eq!(app.multi_diff.current_navigator().state().current_step, 1);
app.select_topbar_tab(second);
assert_eq!(app.view_mode, ViewMode::UnifiedPane);
assert!(app.stepping);
assert_eq!(app.multi_diff.current_navigator().state().current_step, 0);
}
#[test]
fn topbar_drag_reorders_tabs() {
let diff = MultiFileDiff::from_file_pairs(vec![
(
std::path::PathBuf::from("a.txt"),
"a\n".to_string(),
"aa\n".to_string(),
),
(
std::path::PathBuf::from("b.txt"),
"b\n".to_string(),
"bb\n".to_string(),
),
(
std::path::PathBuf::from("c.txt"),
"c\n".to_string(),
"cc\n".to_string(),
),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.topbar_tabs = vec![topbar_tab(1, 0), topbar_tab(2, 1), topbar_tab(3, 2)];
app.active_topbar_tab = Some(1);
app.topbar_tab_hits = vec![
TopbarTabHit {
tab_id: 1,
row: 0,
start_col: 0,
end_col: 6,
close_col: Some(4),
},
TopbarTabHit {
tab_id: 2,
row: 0,
start_col: 7,
end_col: 13,
close_col: Some(11),
},
TopbarTabHit {
tab_id: 3,
row: 0,
start_col: 14,
end_col: 20,
close_col: Some(18),
},
];
assert!(app.handle_topbar_mouse_down(1, 0));
assert_eq!(app.topbar_drag_target, None);
assert!(app.drag_topbar_tab(1, 0));
assert_eq!(app.topbar_drag_target, None);
assert!(app.drag_topbar_tab(15, 0));
assert_eq!(app.topbar_drag_target, Some(2));
app.topbar_tab_hits[2].start_col = 15;
app.topbar_tab_hits[2].end_col = 21;
assert!(app.drag_topbar_tab(14, 0));
assert_eq!(app.topbar_drag_target, Some(2));
assert!(app.finish_topbar_drag());
assert_eq!(topbar_ids(&app), vec![2, 1, 3]);
}
#[test]
fn test_no_step_cursor_stable_through_file_cycles() {
let _guard = DiffSettingsGuard::default();
let old_lines: Vec<String> = (1..=25).map(|i| format!("line{}", i)).collect();
let mut new_lines = old_lines.clone();
new_lines[1] = "line2-new".to_string();
new_lines[19] = "line20-new".to_string();
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let multi = MultiFileDiff::from_file_pairs(vec![
(std::path::PathBuf::from("a.txt"), old.clone(), new.clone()),
(std::path::PathBuf::from("b.txt"), old.clone(), new.clone()),
(std::path::PathBuf::from("c.txt"), old.clone(), new.clone()),
]);
let mut app = App::new(multi, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.no_step_auto_jump_on_enter = true;
app.enter_no_step_mode();
app.goto_hunk_start_scroll();
let first_cursor = app.multi_diff.current_navigator().state().cursor_change;
app.next_file();
app.next_file();
app.prev_file();
app.prev_file();
let cursor_after = app.multi_diff.current_navigator().state().cursor_change;
assert_eq!(first_cursor, cursor_after);
}
#[test]
fn test_windowed_view_tracks_scroll_offset_in_no_step_large_file() {
let _guard = DiffSettingsGuard::new(64);
let mut app = make_large_app(600, 320);
app.last_viewport_height = 25;
app.scroll_offset = 250;
let view = app.current_view_with_frame(AnimationFrame::Idle);
assert!(app.view_windowed());
let start = app.view_window_start();
assert!(start <= app.scroll_offset);
assert_eq!(app.render_scroll_offset(), app.scroll_offset - start);
let span = app.last_viewport_height.max(20).saturating_mul(4).max(200);
assert!(view.len() <= span.saturating_add(1));
}
#[test]
fn test_step_jump_waits_for_view_rebuild_before_scroll() {
let _guard = DiffSettingsGuard::new(64);
let change_lines: Vec<usize> = (0..600).collect();
let mut app = make_large_step_app(600, &change_lines);
app.view_mode = ViewMode::Split;
app.split_align_lines = true;
app.last_viewport_height = 25;
let _ = app.current_view_with_frame(AnimationFrame::Idle);
app.defer_view_build_for_jump();
app.goto_last_step();
assert!(app.needs_scroll_to_active);
app.ensure_active_visible_if_needed(app.last_viewport_height);
assert!(
app.needs_scroll_to_active,
"deferred view should keep active scroll pending"
);
app.ensure_active_visible_if_needed(app.last_viewport_height);
assert!(!app.needs_scroll_to_active);
let state = app.multi_diff.current_navigator().state().clone();
let window_start = app.view_window_start();
let pending = app.view_build_pending();
let scroll_offset = app.scroll_offset;
assert!(
scroll_offset > 0,
"scroll_offset={} window_start={} pending={} active_change={:?} current_step={} step_dir={:?}",
scroll_offset,
window_start,
pending,
state.active_change,
state.current_step,
state.step_direction
);
assert!(window_start > 0);
}
#[test]
fn test_no_step_end_scroll_does_not_shift_window() {
let _guard = DiffSettingsGuard::new(64);
let mut app = make_large_app(600, 320);
app.last_viewport_height = 72;
let total_len = app.multi_diff.current_navigator().diff().changes.len();
let max = max_scroll(total_len, app.last_viewport_height, app.allow_overscroll());
app.scroll_offset = max;
let _ = app.current_view_with_frame(AnimationFrame::Idle);
let start = app.view_window_start();
app.scroll_down();
let _ = app.current_view_with_frame(AnimationFrame::Idle);
assert_eq!(app.scroll_offset, max);
assert_eq!(app.view_window_start(), start);
assert_eq!(app.render_scroll_offset(), app.scroll_offset - start);
}
#[test]
fn test_no_step_goto_end_preserves_hunk_scope() {
let _guard = DiffSettingsGuard::new(64);
let mut app = make_large_app(600, 599);
app.view_mode = ViewMode::Split;
app.split_align_lines = true;
app.last_viewport_height = 25;
app.goto_last_hunk_scroll();
let view = app.current_view_with_frame(AnimationFrame::Idle);
let state = app.multi_diff.current_navigator().state();
assert!(state.last_nav_was_hunk);
assert!(view.iter().any(|line| line.show_hunk_extent));
app.goto_end();
let view = app.current_view_with_frame(AnimationFrame::Idle);
let state = app.multi_diff.current_navigator().state();
assert!(state.last_nav_was_hunk);
assert!(view.iter().any(|line| line.show_hunk_extent));
}
#[test]
fn test_no_step_goto_end_updates_hunk_scope_after_scroll() {
let _guard = DiffSettingsGuard::new(64);
let mut app = make_large_step_app(600, &[10, 590]);
app.stepping = false;
app.no_step_auto_jump_on_enter = false;
app.enter_no_step_mode();
app.view_mode = ViewMode::Split;
app.split_align_lines = true;
app.last_viewport_height = 25;
app.goto_hunk_index_scroll(0);
app.goto_end();
let view = app.current_view_with_frame(AnimationFrame::Idle);
let state = app.multi_diff.current_navigator().state();
assert!(state.last_nav_was_hunk);
assert!(view.iter().any(|line| line.show_hunk_extent));
}
#[test]
fn test_no_step_hunk_scope_shows_extent_in_windowed_view() {
let _guard = DiffSettingsGuard::new(64);
let mut app = make_large_app(600, 320);
app.last_viewport_height = 25;
app.next_hunk_scroll();
let view = app.current_view_with_frame(AnimationFrame::Idle);
let state = app.multi_diff.current_navigator().state();
assert!(state.last_nav_was_hunk);
assert!(state.cursor_change.is_some());
assert!(app.view_windowed());
assert!(view.iter().any(|line| line.show_hunk_extent));
}
#[test]
fn test_step_hunk_nav_clears_view_build_defer_in_large_file() {
let _guard = DiffSettingsGuard::new(64);
let mut app = make_large_step_app(600, &[50, 450]);
app.last_viewport_height = 25;
let _ = app.current_view_with_frame(AnimationFrame::Idle);
app.next_hunk();
let _ = app.current_view_with_frame(AnimationFrame::Idle);
app.defer_view_build_for_jump();
assert!(app.view_build_defer);
let hunk_before = app.multi_diff.current_navigator().state().current_hunk;
app.next_hunk();
let hunk_after = app.multi_diff.current_navigator().state().current_hunk;
assert_ne!(hunk_before, hunk_after);
let _ = app.current_view_with_frame(AnimationFrame::Idle);
assert!(
!app.view_build_pending(),
"hunk nav should rebuild immediately without pending view"
);
app.defer_view_build_for_jump();
assert!(app.view_build_defer);
app.prev_hunk();
let hunk_back = app.multi_diff.current_navigator().state().current_hunk;
assert_ne!(hunk_after, hunk_back);
let _ = app.current_view_with_frame(AnimationFrame::Idle);
assert!(
!app.view_build_pending(),
"reverse hunk nav should rebuild immediately without pending view"
);
}
#[test]
fn test_refresh_current_file_keeps_no_step_state() {
let _guard = DiffSettingsGuard::default();
let old = "line1\nline2\nline3\n".to_string();
let new = "line1\nLINE2\nline3\n".to_string();
let path = std::env::temp_dir().join(format!(
"oyo_refresh_state_test_{}_{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::fs::write(&path, &new).expect("write test file");
let diff = MultiFileDiff::from_file_pair(path.clone(), path.clone(), old, new);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.no_step_auto_jump_on_enter = false;
app.enter_no_step_mode();
app.scroll_offset = 1;
app.horizontal_scroll = 4;
app.refresh_current_file();
let state = app.multi_diff.current_navigator().state().clone();
assert_eq!(state.step_direction, StepDirection::None);
assert!(state.active_change.is_none());
assert_eq!(app.scroll_offset, 1);
assert_eq!(app.horizontal_scroll, 4);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_refresh_current_file_preserves_no_step_hunk_scope() {
let _guard = DiffSettingsGuard::default();
let old_lines: Vec<String> = (1..=25).map(|i| format!("line{}", i)).collect();
let mut new_lines = old_lines.clone();
new_lines[1] = "line2-new".to_string();
new_lines[19] = "line20-new".to_string();
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let path = std::env::temp_dir().join(format!(
"oyo_refresh_hunk_test_{}_{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::fs::write(&path, &new).expect("write test file");
let diff = MultiFileDiff::from_file_pair(path.clone(), path.clone(), old, new);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.no_step_auto_jump_on_enter = false;
app.enter_no_step_mode();
app.goto_last_hunk_scroll();
let before = app.multi_diff.current_navigator().state().clone();
assert!(before.last_nav_was_hunk);
assert!(before.cursor_change.is_some());
app.refresh_current_file();
let (after, cursor_in_hunk) = {
let nav = app.multi_diff.current_navigator();
let state = nav.state().clone();
let in_hunk = state
.cursor_change
.map(|id| {
nav.diff()
.hunks
.get(state.current_hunk)
.map(|hunk| hunk.change_ids.contains(&id))
.unwrap_or(false)
})
.unwrap_or(false);
(state, in_hunk)
};
assert!(after.last_nav_was_hunk);
assert_eq!(after.current_hunk, before.current_hunk);
assert!(after.cursor_change.is_some());
assert!(cursor_in_hunk, "cursor should remain within selected hunk");
let _ = std::fs::remove_file(path);
}
#[test]
fn test_files_changed_indicator_detects_disk_modification_and_clears_on_refresh() {
let _guard = DiffSettingsGuard::default();
let initial = "line1\n";
let path = std::env::temp_dir().join(format!(
"oyo_changed_indicator_test_{}_{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::fs::write(&path, initial).expect("write test file");
let diff = MultiFileDiff::from_file_pair(
path.clone(),
path.clone(),
"old\n".to_string(),
initial.to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.last_fs_check = Instant::now() - Duration::from_secs(2);
app.maybe_check_file_changes();
assert!(!app.files_changed_on_disk);
assert!(!app.file_changed_on_disk(0));
std::fs::write(&path, "line1 changed on disk\n").expect("update test file");
app.last_fs_check = Instant::now() - Duration::from_secs(2);
app.maybe_check_file_changes();
assert!(app.files_changed_on_disk);
assert!(app.file_changed_on_disk(0));
app.refresh_current_file();
assert!(!app.files_changed_on_disk);
assert!(!app.file_changed_on_disk(0));
let _ = std::fs::remove_file(path);
}
#[test]
fn test_empty_git_diff_no_step_does_not_panic() {
let _guard = DiffSettingsGuard::default();
let diff = MultiFileDiff::from_git_changes(std::env::temp_dir(), Vec::new()).expect("diff");
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.enter_no_step_mode();
app.toggle_stepping();
assert_eq!(app.multi_diff.file_count(), 0);
assert!(!app.syntax_enabled());
assert!(!app.tick());
assert_eq!(app.stats(), (0, 0));
}
#[test]
fn test_tick_watch_refreshes_changed_files_on_disk() {
let _guard = DiffSettingsGuard::default();
let path = std::env::temp_dir().join(format!(
"oyo_watch_test_{}_{}.txt",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::fs::write(&path, "new\n").expect("write test file");
let diff = MultiFileDiff::from_file_pair_with_sources(
path.clone(),
b"old\n".to_vec(),
b"new\n".to_vec(),
None,
Some(path.clone()),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
assert!(app.watch);
std::fs::write(&path, "new changed on disk\n").expect("update test file");
app.last_fs_check = Instant::now() - Duration::from_secs(2);
assert!(app.tick());
assert!(!app.files_changed_on_disk);
assert!(!app.file_changed_on_disk(0));
assert_eq!(
app.multi_diff.file_contents(0).map(|(_, new)| new),
Some("new changed on disk\n")
);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_tick_watch_adds_new_untracked_file() {
let _guard = DiffSettingsGuard::default();
let repo = std::env::temp_dir().join(format!(
"oyo_watch_repo_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::fs::create_dir_all(&repo).expect("create repo");
std::process::Command::new("git")
.arg("init")
.arg("-q")
.current_dir(&repo)
.status()
.expect("git init");
let changes = oyo_core::git::get_uncommitted_changes(&repo).expect("changes");
let diff = MultiFileDiff::from_git_changes(repo.clone(), changes).expect("diff");
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
assert_eq!(app.multi_diff.file_count(), 0);
std::fs::write(repo.join("new.txt"), "new\n").expect("write new file");
app.last_git_watch_check = Instant::now() - Duration::from_secs(2);
assert!(app.maybe_watch_refresh_git_files());
assert_eq!(app.multi_diff.file_count(), 1);
assert_eq!(app.current_file_path(), "new.txt");
assert_eq!(
app.multi_diff.file_contents(0).map(|(_, new)| new),
Some("new\n")
);
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn test_tick_watch_refreshes_non_current_changed_file() {
let _guard = DiffSettingsGuard::default();
let now_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos();
let path_a = std::env::temp_dir().join(format!(
"oyo_watch_a_{}_{}.txt",
std::process::id(),
now_nanos
));
let path_b = std::env::temp_dir().join(format!(
"oyo_watch_b_{}_{}.txt",
std::process::id(),
now_nanos
));
std::fs::write(&path_a, "A\n").expect("write file a");
std::fs::write(&path_b, "B\n").expect("write file b");
let diff = MultiFileDiff::from_file_pairs(vec![
(path_a.clone(), "old A\n".to_string(), "A\n".to_string()),
(path_b.clone(), "old B\n".to_string(), "B\n".to_string()),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
std::fs::write(&path_a, "A changed\n").expect("update file a");
std::fs::write(&path_b, "B changed\n").expect("update file b");
app.last_fs_check = Instant::now() - Duration::from_secs(2);
assert!(app.tick());
assert!(!app.files_changed_on_disk);
assert_eq!(
app.multi_diff.file_contents(0).map(|(_, new)| new),
Some("A changed\n")
);
assert_eq!(
app.multi_diff.file_contents(1).map(|(_, new)| new),
Some("B changed\n")
);
let _ = std::fs::remove_file(path_a);
let _ = std::fs::remove_file(path_b);
}
#[test]
fn test_refresh_current_file_keeps_changed_indicator_if_other_file_is_modified() {
let _guard = DiffSettingsGuard::default();
let now_nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos();
let path_a = std::env::temp_dir().join(format!(
"oyo_changed_a_{}_{}.txt",
std::process::id(),
now_nanos
));
let path_b = std::env::temp_dir().join(format!(
"oyo_changed_b_{}_{}.txt",
std::process::id(),
now_nanos
));
std::fs::write(&path_a, "A\n").expect("write file a");
std::fs::write(&path_b, "B\n").expect("write file b");
let diff = MultiFileDiff::from_file_pairs(vec![
(path_a.clone(), "old A\n".to_string(), "A\n".to_string()),
(path_b.clone(), "old B\n".to_string(), "B\n".to_string()),
]);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
std::fs::write(&path_a, "A changed on disk\n").expect("update file a");
std::fs::write(&path_b, "B changed on disk\n").expect("update file b");
app.last_fs_check = Instant::now() - Duration::from_secs(2);
app.maybe_check_file_changes();
assert!(app.files_changed_on_disk);
assert!(app.file_changed_on_disk(0));
assert!(app.file_changed_on_disk(1));
app.multi_diff.select_file(0);
app.refresh_current_file();
assert!(
app.files_changed_on_disk,
"indicator should stay on while another file remains changed"
);
assert!(!app.file_changed_on_disk(0));
assert!(app.file_changed_on_disk(1));
app.multi_diff.select_file(1);
app.refresh_current_file();
assert!(!app.files_changed_on_disk);
assert!(!app.file_changed_on_disk(0));
assert!(!app.file_changed_on_disk(1));
let _ = std::fs::remove_file(path_a);
let _ = std::fs::remove_file(path_b);
}
#[test]
fn test_view_nav_logging_emits_entry() {
let _guard = DiffSettingsGuard::default();
let path = std::env::temp_dir().join(format!("oyo_view_nav_test_{}.log", std::process::id()));
let _guard = ViewDebugEnvGuard::new(&path);
let _ = std::fs::remove_file(&path);
let old = "line1\nline2\nline3\n";
let new = "line1\nLINE2\nline3\n";
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old.to_string(),
new.to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.next_step();
let log = std::fs::read_to_string(&path).expect("read nav log");
assert!(log.contains("OYO_VIEW_NAV"), "missing nav log header");
assert!(log.contains("action=step_down"), "missing step_down action");
assert!(log.contains("moved=true"), "expected moved=true for step");
}
#[test]
fn test_diff_worker_upgrades_deferred_diff_and_updates_counts() {
let _guard = DiffSettingsGuard::new(32);
let old = "line1\nline2\nline3\nline4\nline5\nline6\n";
let new = "line1\nLINE2\nline3\nline4\nline5\nline6\n";
let diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old.to_string(),
new.to_string(),
);
assert_eq!(diff.diff_status(0), DiffStatus::Deferred);
let expected = MultiFileDiff::compute_diff(old, new);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.stepping = false;
app.no_step_auto_jump_on_enter = false;
app.enter_no_step_mode();
let _ = app.multi_diff.current_navigator();
assert!(app.multi_diff.current_navigator_is_placeholder());
app.queue_current_file_diff();
let mut ready = false;
for _ in 0..200 {
app.poll_diff_responses();
if matches!(app.multi_diff.current_file_diff_status(), DiffStatus::Ready) {
ready = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(ready, "diff worker did not finish");
assert!(!app.multi_diff.current_navigator_is_placeholder());
let file = &app.multi_diff.files[0];
assert_eq!(file.insertions, expected.insertions);
assert_eq!(file.deletions, expected.deletions);
}