use super::*;
use super::types::{MarkViewedResult, PendingApproveChoice};
use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use lasso::Rodeo;
use crate::cache::{PrCacheKey, PrData};
use crate::github::{ChangedFile, PullRequest};
use crate::loader::DataLoadResult;
#[test]
fn test_find_diff_line_index_basic() {
let patch = r#"@@ -1,3 +1,4 @@
context line
+added line
another context
-removed line"#;
assert_eq!(App::find_diff_line_index(patch, 1), Some(1));
assert_eq!(App::find_diff_line_index(patch, 2), Some(2));
assert_eq!(App::find_diff_line_index(patch, 3), Some(3));
assert_eq!(App::find_diff_line_index(patch, 5), None);
}
#[test]
fn test_find_diff_line_index_multi_hunk() {
let patch = r#"@@ -1,2 +1,2 @@
line1
+new line2
@@ -10,2 +10,2 @@
line10
+new line11"#;
assert_eq!(App::find_diff_line_index(patch, 1), Some(1));
assert_eq!(App::find_diff_line_index(patch, 2), Some(2));
assert_eq!(App::find_diff_line_index(patch, 10), Some(4));
assert_eq!(App::find_diff_line_index(patch, 11), Some(5));
}
#[test]
fn test_has_comment_at_current_line() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.file_comment_positions = vec![
CommentPosition {
diff_line_index: 5,
comment_index: 0,
},
CommentPosition {
diff_line_index: 10,
comment_index: 1,
},
];
app.selected_line = 5;
assert!(app.has_comment_at_current_line());
app.selected_line = 10;
assert!(app.has_comment_at_current_line());
app.selected_line = 7;
assert!(!app.has_comment_at_current_line());
}
#[test]
fn test_get_comment_indices_at_current_line() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.file_comment_positions = vec![
CommentPosition {
diff_line_index: 5,
comment_index: 0,
},
CommentPosition {
diff_line_index: 5,
comment_index: 2,
},
CommentPosition {
diff_line_index: 10,
comment_index: 1,
},
];
app.selected_line = 5;
let indices = app.get_comment_indices_at_current_line();
assert_eq!(indices, vec![0, 2]);
app.selected_line = 10;
let indices = app.get_comment_indices_at_current_line();
assert_eq!(indices, vec![1]);
app.selected_line = 7;
let indices = app.get_comment_indices_at_current_line();
assert!(indices.is_empty());
}
#[test]
fn test_jump_to_next_comment_basic() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.file_comment_positions = vec![
CommentPosition {
diff_line_index: 5,
comment_index: 0,
},
CommentPosition {
diff_line_index: 10,
comment_index: 1,
},
CommentPosition {
diff_line_index: 15,
comment_index: 2,
},
];
app.selected_line = 0;
app.jump_to_next_comment();
assert_eq!(app.selected_line, 5);
app.jump_to_next_comment();
assert_eq!(app.selected_line, 10);
app.jump_to_next_comment();
assert_eq!(app.selected_line, 15);
}
#[test]
fn test_jump_to_next_comment_no_wrap() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.file_comment_positions = vec![CommentPosition {
diff_line_index: 5,
comment_index: 0,
}];
app.selected_line = 5;
app.jump_to_next_comment();
assert_eq!(app.selected_line, 5);
}
#[test]
fn test_jump_to_prev_comment_basic() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.file_comment_positions = vec![
CommentPosition {
diff_line_index: 5,
comment_index: 0,
},
CommentPosition {
diff_line_index: 10,
comment_index: 1,
},
CommentPosition {
diff_line_index: 15,
comment_index: 2,
},
];
app.selected_line = 20;
app.jump_to_prev_comment();
assert_eq!(app.selected_line, 15);
app.jump_to_prev_comment();
assert_eq!(app.selected_line, 10);
app.jump_to_prev_comment();
assert_eq!(app.selected_line, 5);
}
#[test]
fn test_jump_to_prev_comment_no_wrap() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.file_comment_positions = vec![CommentPosition {
diff_line_index: 5,
comment_index: 0,
}];
app.selected_line = 5;
app.jump_to_prev_comment();
assert_eq!(app.selected_line, 5);
}
#[test]
fn test_jump_with_empty_positions() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.file_comment_positions = vec![];
app.selected_line = 10;
app.jump_to_next_comment();
assert_eq!(app.selected_line, 10);
app.jump_to_prev_comment();
assert_eq!(app.selected_line, 10);
}
#[test]
fn test_liststate_autoscroll_with_multiline_items() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget};
let items: Vec<ListItem> = (0..10)
.map(|i| {
ListItem::new(vec![
Line::from(format!("Header {}", i)),
Line::from(format!(" Body {}", i)),
Line::from(""),
])
})
.collect();
let area = Rect::new(0, 0, 40, 12);
let mut offset = 0usize;
for selected in 0..10 {
let list = List::new(items.clone()).block(Block::default().borders(Borders::ALL));
let mut state = ListState::default()
.with_offset(offset)
.with_selected(Some(selected));
let mut buf = Buffer::empty(area);
StatefulWidget::render(&list, area, &mut buf, &mut state);
offset = state.offset();
assert!(
selected >= offset,
"selected={} should be >= offset={}",
selected,
offset
);
}
assert!(offset > 0, "offset should have scrolled, got {}", offset);
}
#[test]
fn test_back_to_pr_list_clears_view_receivers() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.started_from_pr_list = true;
assert!(app.data_receiver.is_some());
let (_comment_tx, comment_rx) = mpsc::channel(1);
app.comment_receiver = Some((1, comment_rx));
let (_disc_tx, disc_rx) = mpsc::channel(1);
app.discussion_comment_receiver = Some((1, disc_rx));
let (_submit_tx, submit_rx) = mpsc::channel(1);
app.comment_submit_receiver = Some((1, submit_rx));
let (_mark_tx, mark_rx) = mpsc::channel(1);
app.mark_viewed_receiver = Some((1, mark_rx));
app.comment_submitting = true;
app.comments_loading = true;
app.discussion_comments_loading = true;
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(1);
app.retry_sender = Some(retry_tx);
app.back_to_pr_list();
assert!(app.data_receiver.is_some());
assert!(app.retry_sender.is_some());
assert!(app.comment_receiver.is_none());
assert!(app.discussion_comment_receiver.is_none());
assert!(app.comment_submit_receiver.is_none());
assert!(app.mark_viewed_receiver.is_none());
assert!(app.diff_cache_receiver.is_none());
assert!(app.prefetch_receiver.is_none());
assert!(!app.comment_submitting);
assert!(!app.comments_loading);
assert!(!app.discussion_comments_loading);
assert!(app.pr_number.is_none());
assert_eq!(app.state, AppState::PullRequestList);
}
#[test]
fn test_back_to_pr_list_from_local_mode_resets_local_state() {
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
let (_data_tx, data_rx) = mpsc::channel(2);
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 0, config);
app.started_from_pr_list = true;
app.local_mode = true;
app.pr_number = Some(0);
app.retry_sender = Some(retry_tx);
app.data_receiver = Some((0, data_rx));
app.selected_file = 2;
app.back_to_pr_list();
assert!(!app.local_mode);
assert!(app.saved_local_snapshot.is_some());
assert_eq!(app.state, AppState::PullRequestList);
assert!(app.pr_number.is_none());
}
#[tokio::test]
async fn test_pr_list_local_toggle_round_trip() {
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(8);
let (_data_tx, data_rx) = mpsc::channel(2);
let mut app = App::new_for_test();
app.started_from_pr_list = true;
app.state = AppState::PullRequestList;
app.pr_number = None;
app.original_pr_number = None;
app.retry_sender = Some(retry_tx);
app.data_receiver = Some((0, data_rx));
let local_pr = PullRequest {
number: 0,
node_id: None,
title: "Local HEAD diff".to_string(),
body: None,
state: "local".to_string(),
head: crate::github::Branch {
ref_name: "HEAD".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "local".to_string(),
sha: "local".to_string(),
},
user: crate::github::User {
login: "local".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
let local_files = vec![ChangedFile {
filename: "src/main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1,1 +1,2 @@\n line1\n+line2".to_string()),
viewed: false,
}];
app.session_cache.put_pr_data(
PrCacheKey {
repo: "test/repo".to_string(),
pr_number: 0,
},
PrData {
pr: Box::new(local_pr),
files: local_files,
pr_updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
assert_eq!(app.state, AppState::FileList);
assert!(matches!(app.data_state, DataState::Loaded { .. }));
app.back_to_pr_list();
assert!(!app.local_mode);
assert_eq!(app.state, AppState::PullRequestList);
assert!(app.saved_local_snapshot.is_some());
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
assert_eq!(app.state, AppState::FileList);
assert!(matches!(app.data_state, DataState::Loaded { .. }));
}
#[tokio::test]
async fn test_poll_data_updates_discards_stale_pr_data() {
let config = Config::default();
let (mut app, tx) = App::new_loading("owner/repo", 1, config);
app.started_from_pr_list = true;
app.pr_number = Some(2);
let pr = PullRequest {
number: 1,
node_id: None,
title: "PR 1".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
};
tx.send(DataLoadResult::Success {
pr: Box::new(pr),
files: vec![],
})
.await
.unwrap();
app.poll_data_updates();
assert!(app.data_receiver.is_some());
assert!(matches!(app.data_state, DataState::Loading));
let cache_key = PrCacheKey {
repo: "owner/repo".to_string(),
pr_number: 1,
};
assert!(app.session_cache.get_pr_data(&cache_key).is_some());
}
#[tokio::test]
async fn test_poll_comment_updates_discards_stale_pr_comments() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.started_from_pr_list = true;
let (comment_tx, comment_rx) = mpsc::channel(1);
app.comment_receiver = Some((1, comment_rx));
app.comments_loading = true;
app.pr_number = Some(2);
comment_tx.send(Ok(vec![])).await.unwrap();
app.poll_comment_updates();
assert!(app.comment_receiver.is_none());
assert!(app.comments_loading);
let cache_key = PrCacheKey {
repo: "owner/repo".to_string(),
pr_number: 1,
};
assert!(app.session_cache.get_review_comments(&cache_key).is_none());
}
#[tokio::test]
async fn test_handle_data_result_clamps_selected_file_when_files_shrink() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
let make_file = |name: &str| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files: Vec<ChangedFile> = (0..5)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files,
};
app.selected_file = 4;
let fewer_files: Vec<ChangedFile> = (0..2)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: fewer_files,
},
);
assert_eq!(app.selected_file, 1);
assert!(app.files().get(app.selected_file).is_some());
}
#[tokio::test]
async fn test_handle_data_result_resyncs_diff_state_when_selected_file_changes() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
let make_file = |name: &str| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files: Vec<ChangedFile> = (0..5)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files,
};
app.selected_file = 4;
app.selected_line = 10;
app.scroll_offset = 5;
app.diff_cache = Some(DiffCache {
file_index: 4,
patch_hash: 0,
lines: vec![],
interner: Rodeo::default(),
highlighted: false,
markdown_rich: false,
});
let fewer_files: Vec<ChangedFile> = (0..2)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: fewer_files,
},
);
assert_eq!(app.selected_file, 1);
assert_eq!(
app.diff_cache.as_ref().map(|c| c.file_index),
Some(1),
"diff_cache should be rebuilt for the new selected file"
);
assert_eq!(app.selected_line, 0, "selected_line should be reset to 0");
assert_eq!(app.scroll_offset, 0, "scroll_offset should be reset to 0");
}
#[tokio::test]
async fn test_handle_data_result_resyncs_comment_positions_when_selected_file_changes() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
let make_file = |name: &str| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files: Vec<ChangedFile> = (0..5)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files,
};
app.selected_file = 4;
app.review_comments = Some(vec![ReviewComment {
id: 1,
path: "file_4.rs".to_string(),
line: Some(1),
body: "comment on old file".to_string(),
user: crate::github::User {
login: "reviewer".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
}]);
app.file_comment_positions = vec![CommentPosition {
diff_line_index: 2,
comment_index: 0,
}];
app.file_comment_lines.insert(2);
app.comment_panel_open = true;
app.comment_panel_scroll = 5;
let fewer_files: Vec<ChangedFile> = (0..2)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: fewer_files,
},
);
assert_eq!(app.selected_file, 1);
assert!(
app.file_comment_positions.is_empty(),
"file_comment_positions should be recalculated for new file (no comments for file_1.rs)"
);
assert!(
app.file_comment_lines.is_empty(),
"file_comment_lines should be recalculated for new file"
);
assert!(
!app.comment_panel_open,
"comment_panel_open should be reset when selected_file changes"
);
assert_eq!(
app.comment_panel_scroll, 0,
"comment_panel_scroll should be reset when selected_file changes"
);
}
#[tokio::test]
async fn test_handle_data_result_preserves_diff_state_when_selected_file_unchanged() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
let make_file = |name: &str| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files: Vec<ChangedFile> = (0..5)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files,
};
app.selected_file = 1;
app.selected_line = 10;
app.scroll_offset = 5;
app.diff_cache = Some(DiffCache {
file_index: 1,
patch_hash: 0,
lines: vec![],
interner: Rodeo::default(),
highlighted: false,
markdown_rich: false,
});
let same_files: Vec<ChangedFile> = (0..5)
.map(|i| make_file(&format!("file_{}.rs", i)))
.collect();
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: same_files,
},
);
assert_eq!(app.selected_file, 1);
assert!(
app.diff_cache.is_some(),
"diff_cache should be preserved when selected_file is unchanged"
);
assert_eq!(app.selected_line, 10);
assert_eq!(app.scroll_offset, 5);
}
#[tokio::test]
async fn test_handle_data_result_keeps_selected_file_by_filename() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.set_local_mode(true);
app.set_local_auto_focus(false);
let make_file = |name: &str| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files: Vec<ChangedFile> = vec![
make_file("file_a.rs"),
make_file("file_b.rs"),
make_file("file_c.rs"),
];
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files.clone(),
};
app.selected_file = 1; app.remember_local_file_signatures(&initial_files);
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: vec![make_file("file_b.rs"), make_file("file_c.rs")],
},
);
assert_eq!(
app.selected_file, 0,
"selected file should track file_b.rs by filename, not by index"
);
}
#[tokio::test]
async fn test_handle_data_result_auto_focus_selects_next_changed_file() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.set_local_mode(true);
app.set_local_auto_focus(true);
app.selected_file = 1;
let make_file = |name: &str, additions: u32| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files = vec![
make_file("file_a.rs", 1),
make_file("file_b.rs", 1),
make_file("file_c.rs", 1),
make_file("file_d.rs", 1),
];
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files.clone(),
};
app.remember_local_file_signatures(&initial_files);
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: vec![
make_file("file_a.rs", 2), make_file("file_b.rs", 1), make_file("file_c.rs", 1), make_file("file_d.rs", 2), ],
},
);
assert_eq!(
app.selected_file, 3,
"auto-focus should prefer the next changed file after current selection"
);
}
#[tokio::test]
async fn test_handle_data_result_auto_focus_prefers_nearest_changed_file() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.set_local_mode(true);
app.set_local_auto_focus(true);
app.selected_file = 3;
let make_file = |name: &str, additions: u32| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files = vec![
make_file("file_a.rs", 1),
make_file("file_b.rs", 1),
make_file("file_c.rs", 1),
make_file("file_d.rs", 1),
make_file("file_e.rs", 1),
];
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files.clone(),
};
app.remember_local_file_signatures(&initial_files);
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: vec![
make_file("file_a.rs", 2), make_file("file_b.rs", 1), make_file("file_c.rs", 1), make_file("file_d.rs", 1), make_file("file_e.rs", 2), ],
},
);
assert_eq!(
app.selected_file, 4,
"auto-focus should move to the nearer changed file around current selection"
);
}
#[tokio::test]
async fn test_handle_data_result_auto_focus_prefers_next_when_distances_are_tie() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.set_local_mode(true);
app.set_local_auto_focus(true);
app.selected_file = 2;
let make_file = |name: &str, additions: u32| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
};
let initial_files = vec![
make_file("file_a.rs", 1),
make_file("file_b.rs", 1),
make_file("file_c.rs", 1),
make_file("file_d.rs", 1),
make_file("file_e.rs", 1),
];
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr: pr.clone(),
files: initial_files.clone(),
};
app.remember_local_file_signatures(&initial_files);
app.handle_data_result(
1,
DataLoadResult::Success {
pr,
files: vec![
make_file("file_a.rs", 1), make_file("file_b.rs", 2), make_file("file_c.rs", 1), make_file("file_d.rs", 2), make_file("file_e.rs", 1), ],
},
);
assert_eq!(
app.selected_file, 3,
"auto-focus should prefer the next file when before/after distances are equal"
);
}
#[tokio::test]
async fn test_handle_data_result_auto_focus_transitions_to_split_view_diff() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.set_local_mode(true);
app.set_local_auto_focus(true);
app.state = AppState::FileList;
let make_file = |name: &str, patch: &str| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some(patch.to_string()),
viewed: false,
};
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.handle_data_result(
1,
DataLoadResult::Success {
pr: pr.clone(),
files: vec![make_file("initial.rs", "@@ -1,1 +1,1 @@\n-old\n+new")],
},
);
assert_eq!(app.state, AppState::SplitViewDiff);
assert_eq!(app.selected_file, 0);
assert_eq!(app.files().len(), 1);
}
#[test]
fn test_toggle_auto_focus() {
let mut app = App::new_for_test();
app.local_mode = true;
assert!(!app.local_auto_focus);
app.toggle_auto_focus();
assert!(app.local_auto_focus);
assert!(app.submission_result.is_some());
assert!(app.submission_result.as_ref().unwrap().1.contains("ON"));
app.toggle_auto_focus();
assert!(!app.local_auto_focus);
assert!(app.submission_result.as_ref().unwrap().1.contains("OFF"));
}
#[test]
fn test_toggle_local_mode_blocks_during_ai_rally() {
let mut app = App::new_for_test();
app.state = AppState::AiRally;
app.toggle_local_mode();
assert!(!app.local_mode);
assert!(app.submission_result.as_ref().unwrap().1.contains("Cannot"));
}
#[test]
fn test_save_and_restore_view_snapshot() {
let mut app = App::new_for_test();
app.selected_file = 5;
app.file_list_scroll_offset = 2;
app.selected_line = 10;
app.scroll_offset = 3;
let snapshot = app.save_view_snapshot();
assert!(app.diff_cache.is_none());
app.selected_file = 0;
app.selected_line = 0;
app.restore_view_snapshot(snapshot);
assert_eq!(app.selected_file, 5);
assert_eq!(app.file_list_scroll_offset, 2);
assert_eq!(app.selected_line, 10);
assert_eq!(app.scroll_offset, 3);
}
#[test]
fn test_save_snapshot_captures_all_fields() {
let mut app = App::new_for_test();
app.pr_number = Some(42);
app.selected_file = 7;
app.file_list_scroll_offset = 3;
app.selected_line = 15;
app.scroll_offset = 5;
let mut dc = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
dc.file_index = 7;
app.diff_cache = Some(dc);
let mut hc = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
hc.file_index = 2;
app.highlighted_cache_store.insert(2, hc);
app.review_comments = Some(vec![crate::github::comment::ReviewComment {
id: 10,
path: "test.rs".to_string(),
line: Some(5),
body: "snapshot test".to_string(),
user: crate::github::User {
login: "reviewer".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
}]);
app.discussion_comments = Some(vec![crate::github::comment::DiscussionComment {
id: 20,
body: "discussion".to_string(),
user: crate::github::User {
login: "user".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
}]);
app.local_file_signatures
.insert("a.rs".to_string(), 111);
app.local_file_patch_signatures
.insert("a.rs".to_string(), 222);
let snapshot = app.save_view_snapshot();
assert_eq!(snapshot.pr_number, Some(42));
assert_eq!(snapshot.selected_file, 7);
assert_eq!(snapshot.file_list_scroll_offset, 3);
assert_eq!(snapshot.selected_line, 15);
assert_eq!(snapshot.scroll_offset, 5);
assert!(snapshot.diff_cache.is_some());
assert_eq!(snapshot.diff_cache.as_ref().unwrap().file_index, 7);
assert_eq!(snapshot.highlighted_cache_store.len(), 1);
assert!(snapshot.highlighted_cache_store.contains_key(&2));
assert_eq!(snapshot.review_comments.as_ref().unwrap().len(), 1);
assert_eq!(snapshot.review_comments.as_ref().unwrap()[0].id, 10);
assert_eq!(snapshot.discussion_comments.as_ref().unwrap().len(), 1);
assert_eq!(snapshot.discussion_comments.as_ref().unwrap()[0].id, 20);
assert_eq!(snapshot.local_file_signatures.len(), 1);
assert_eq!(snapshot.local_file_signatures["a.rs"], 111);
assert_eq!(snapshot.local_file_patch_signatures.len(), 1);
assert_eq!(snapshot.local_file_patch_signatures["a.rs"], 222);
}
#[test]
fn test_save_snapshot_takes_from_app() {
let mut app = App::new_for_test();
app.diff_cache =
Some(crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+x", 4));
let mut hc = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+y", 4);
hc.file_index = 1;
app.highlighted_cache_store.insert(1, hc);
app.review_comments = Some(vec![crate::github::comment::ReviewComment {
id: 1,
path: "f.rs".to_string(),
line: Some(1),
body: "c".to_string(),
user: crate::github::User {
login: "u".to_string(),
},
created_at: "".to_string(),
}]);
app.discussion_comments = Some(vec![crate::github::comment::DiscussionComment {
id: 1,
body: "d".to_string(),
user: crate::github::User {
login: "u".to_string(),
},
created_at: "".to_string(),
}]);
app.local_file_signatures.insert("b.rs".to_string(), 1);
app.local_file_patch_signatures
.insert("b.rs".to_string(), 2);
let _snapshot = app.save_view_snapshot();
assert!(app.diff_cache.is_none());
assert!(app.highlighted_cache_store.is_empty());
assert!(app.review_comments.is_none());
assert!(app.discussion_comments.is_none());
assert!(app.local_file_signatures.is_empty());
assert!(app.local_file_patch_signatures.is_empty());
}
#[test]
fn test_restore_snapshot_all_fields() {
use super::types::ViewSnapshot;
use std::collections::HashMap;
let mut app = App::new_for_test();
app.pr_number = Some(99);
app.selected_file = 0;
app.selected_line = 0;
let mut dc = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+restored", 4);
dc.file_index = 3;
let mut hcs = HashMap::new();
let mut hc = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+cached", 4);
hc.file_index = 5;
hcs.insert(5, hc);
let mut sigs = HashMap::new();
sigs.insert("sig.rs".to_string(), 333_u64);
let mut patch_sigs = HashMap::new();
patch_sigs.insert("sig.rs".to_string(), 444_u64);
let snapshot = ViewSnapshot {
pr_number: Some(42),
selected_file: 8,
file_list_scroll_offset: 4,
selected_line: 20,
scroll_offset: 6,
diff_cache: Some(dc),
highlighted_cache_store: hcs,
review_comments: Some(vec![crate::github::comment::ReviewComment {
id: 50,
path: "r.rs".to_string(),
line: Some(10),
body: "restored comment".to_string(),
user: crate::github::User {
login: "r".to_string(),
},
created_at: "".to_string(),
}]),
discussion_comments: Some(vec![crate::github::comment::DiscussionComment {
id: 60,
body: "restored discussion".to_string(),
user: crate::github::User {
login: "d".to_string(),
},
created_at: "".to_string(),
}]),
local_file_signatures: sigs,
local_file_patch_signatures: patch_sigs,
};
app.restore_view_snapshot(snapshot);
assert_eq!(app.pr_number, Some(42));
assert_eq!(app.selected_file, 8);
assert_eq!(app.file_list_scroll_offset, 4);
assert_eq!(app.selected_line, 20);
assert_eq!(app.scroll_offset, 6);
assert!(app.diff_cache.is_some());
assert_eq!(app.diff_cache.as_ref().unwrap().file_index, 3);
assert_eq!(app.highlighted_cache_store.len(), 1);
assert!(app.highlighted_cache_store.contains_key(&5));
assert_eq!(app.review_comments.as_ref().unwrap().len(), 1);
assert_eq!(app.review_comments.as_ref().unwrap()[0].id, 50);
assert_eq!(app.discussion_comments.as_ref().unwrap().len(), 1);
assert_eq!(app.discussion_comments.as_ref().unwrap()[0].id, 60);
assert_eq!(app.local_file_signatures["sig.rs"], 333);
assert_eq!(app.local_file_patch_signatures["sig.rs"], 444);
}
#[test]
fn test_restore_snapshot_clears_receivers() {
use super::types::ViewSnapshot;
use std::collections::HashMap;
let mut app = App::new_for_test();
let (_tx1, rx1) = mpsc::channel(1);
app.diff_cache_receiver = Some(rx1);
let (_tx2, rx2) = mpsc::channel(1);
app.prefetch_receiver = Some(rx2);
let (_tx3, rx3) = mpsc::channel::<Result<Vec<crate::github::comment::ReviewComment>, String>>(1);
app.comment_receiver = Some((1, rx3));
let (_tx4, rx4) = mpsc::channel::<Result<Vec<crate::github::comment::DiscussionComment>, String>>(1);
app.discussion_comment_receiver = Some((1, rx4));
let (_tx5, rx5) = mpsc::channel::<crate::loader::CommentSubmitResult>(1);
app.comment_submit_receiver = Some((1, rx5));
app.comment_submitting = true;
app.comments_loading = true;
app.discussion_comments_loading = true;
let snapshot = ViewSnapshot {
pr_number: None,
selected_file: 0,
file_list_scroll_offset: 0,
selected_line: 0,
scroll_offset: 0,
diff_cache: None,
highlighted_cache_store: HashMap::new(),
review_comments: None,
discussion_comments: None,
local_file_signatures: HashMap::new(),
local_file_patch_signatures: HashMap::new(),
};
app.restore_view_snapshot(snapshot);
assert!(app.diff_cache_receiver.is_none());
assert!(app.prefetch_receiver.is_none());
assert!(app.comment_receiver.is_none());
assert!(app.discussion_comment_receiver.is_none());
assert!(app.comment_submit_receiver.is_none());
assert!(!app.comment_submitting);
assert!(!app.comments_loading);
assert!(!app.discussion_comments_loading);
}
#[test]
fn test_save_restore_roundtrip_preserves_data() {
let mut app = App::new_for_test();
app.pr_number = Some(10);
app.selected_file = 3;
app.file_list_scroll_offset = 1;
app.selected_line = 7;
app.scroll_offset = 2;
app.local_file_signatures.insert("x.rs".to_string(), 500);
app.local_file_patch_signatures
.insert("x.rs".to_string(), 600);
app.review_comments = Some(vec![crate::github::comment::ReviewComment {
id: 77,
path: "x.rs".to_string(),
line: Some(3),
body: "roundtrip".to_string(),
user: crate::github::User {
login: "u".to_string(),
},
created_at: "".to_string(),
}]);
let snapshot = app.save_view_snapshot();
assert!(app.review_comments.is_none());
assert!(app.local_file_signatures.is_empty());
app.pr_number = Some(999);
app.selected_file = 99;
app.selected_line = 99;
app.restore_view_snapshot(snapshot);
assert_eq!(app.pr_number, Some(10));
assert_eq!(app.selected_file, 3);
assert_eq!(app.file_list_scroll_offset, 1);
assert_eq!(app.selected_line, 7);
assert_eq!(app.scroll_offset, 2);
assert_eq!(app.local_file_signatures["x.rs"], 500);
assert_eq!(app.local_file_patch_signatures["x.rs"], 600);
assert_eq!(app.review_comments.as_ref().unwrap()[0].id, 77);
}
#[test]
fn test_toggle_local_mode_clears_receivers_on_entry() {
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
let (_data_tx, data_rx) = mpsc::channel(2);
let mut app = App::new_for_test();
app.retry_sender = Some(retry_tx);
app.data_receiver = Some((42, data_rx));
app.original_pr_number = Some(42);
app.pr_number = Some(42);
let (_tx1, rx1) = mpsc::channel::<MarkViewedResult>(1);
app.mark_viewed_receiver = Some((42, rx1));
let (_tx2, rx2) = mpsc::channel::<Vec<crate::loader::SingleFileDiffResult>>(1);
app.batch_diff_receiver = Some(rx2);
let (_tx3, rx3) = mpsc::channel::<crate::loader::SingleFileDiffResult>(1);
app.lazy_diff_receiver = Some(rx3);
app.lazy_diff_pending_file = Some("file.rs".to_string());
app.toggle_local_mode();
assert!(app.mark_viewed_receiver.is_none());
assert!(app.batch_diff_receiver.is_none());
assert!(app.lazy_diff_receiver.is_none());
assert!(app.lazy_diff_pending_file.is_none());
}
#[test]
fn test_toggle_local_mode_resets_file_list_filter() {
use crate::filter::ListFilter;
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
let (_data_tx, data_rx) = mpsc::channel(2);
let mut app = App::new_for_test();
app.retry_sender = Some(retry_tx);
app.data_receiver = Some((42, data_rx));
app.original_pr_number = Some(42);
app.pr_number = Some(42);
app.file_list_filter = Some(ListFilter::new());
app.toggle_local_mode();
assert!(app.file_list_filter.is_none());
app.file_list_filter = Some(ListFilter::new());
app.toggle_local_mode();
assert!(app.file_list_filter.is_none());
}
#[test]
fn test_toggle_local_mode_from_pr_list_transitions_to_file_list() {
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
let (_data_tx, data_rx) = mpsc::channel(2);
let mut app = App::new_for_test();
app.retry_sender = Some(retry_tx);
app.data_receiver = Some((42, data_rx));
app.pr_number = Some(42);
app.state = AppState::PullRequestList;
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.state, AppState::FileList);
}
#[test]
fn test_back_to_pr_list_saves_local_snapshot() {
let mut app = App::new_for_test();
app.started_from_pr_list = true;
app.local_mode = true;
app.selected_file = 5;
app.local_file_signatures.insert("z.rs".to_string(), 999);
app.back_to_pr_list();
assert!(app.saved_local_snapshot.is_some());
let snap = app.saved_local_snapshot.as_ref().unwrap();
assert_eq!(snap.selected_file, 5);
assert_eq!(snap.local_file_signatures["z.rs"], 999);
assert!(!app.local_mode);
assert_eq!(app.state, AppState::PullRequestList);
}
#[test]
fn test_back_to_pr_list_resets_pr_state() {
let mut app = App::new_for_test();
app.started_from_pr_list = true;
app.pr_number = Some(42);
app.review_comments = Some(vec![]);
app.discussion_comments = Some(vec![]);
app.diff_cache =
Some(crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+x", 4));
let mut hc = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+y", 4);
hc.file_index = 1;
app.highlighted_cache_store.insert(1, hc);
app.back_to_pr_list();
assert!(app.pr_number.is_none());
assert!(matches!(app.data_state, DataState::Loading));
assert!(app.review_comments.is_none());
assert!(app.discussion_comments.is_none());
assert!(app.diff_cache.is_none());
assert!(app.highlighted_cache_store.is_empty());
assert_eq!(app.selected_file, 0);
assert_eq!(app.file_list_scroll_offset, 0);
assert_eq!(app.selected_line, 0);
assert_eq!(app.scroll_offset, 0);
assert!(app.file_list_filter.is_none());
}
#[test]
fn test_back_to_pr_list_clears_all_receivers() {
let mut app = App::new_for_test();
app.started_from_pr_list = true;
let (_tx1, rx1) = mpsc::channel::<Result<Vec<crate::github::comment::ReviewComment>, String>>(1);
app.comment_receiver = Some((1, rx1));
let (_tx2, rx2) = mpsc::channel(1);
app.diff_cache_receiver = Some(rx2);
let (_tx3, rx3) = mpsc::channel(1);
app.prefetch_receiver = Some(rx3);
let (_tx4, rx4) = mpsc::channel::<Result<Vec<crate::github::comment::DiscussionComment>, String>>(1);
app.discussion_comment_receiver = Some((1, rx4));
let (_tx5, rx5) = mpsc::channel::<crate::loader::CommentSubmitResult>(1);
app.comment_submit_receiver = Some((1, rx5));
let (_tx6, rx6) = mpsc::channel::<MarkViewedResult>(1);
app.mark_viewed_receiver = Some((1, rx6));
let (_tx7, rx7) = mpsc::channel::<Vec<crate::loader::SingleFileDiffResult>>(1);
app.batch_diff_receiver = Some(rx7);
let (_tx8, rx8) = mpsc::channel::<crate::loader::SingleFileDiffResult>(1);
app.lazy_diff_receiver = Some(rx8);
app.lazy_diff_pending_file = Some("file.rs".to_string());
app.comment_submitting = true;
app.comments_loading = true;
app.discussion_comments_loading = true;
app.back_to_pr_list();
assert!(app.comment_receiver.is_none());
assert!(app.diff_cache_receiver.is_none());
assert!(app.prefetch_receiver.is_none());
assert!(app.discussion_comment_receiver.is_none());
assert!(app.comment_submit_receiver.is_none());
assert!(app.mark_viewed_receiver.is_none());
assert!(app.batch_diff_receiver.is_none());
assert!(app.lazy_diff_receiver.is_none());
assert!(app.lazy_diff_pending_file.is_none());
assert!(!app.comment_submitting);
assert!(!app.comments_loading);
assert!(!app.discussion_comments_loading);
}
#[test]
fn test_toggle_local_mode_pr_to_local_and_back() {
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
let (_data_tx, data_rx) = mpsc::channel(2);
let mut app = App::new_for_test();
app.retry_sender = Some(retry_tx);
app.data_receiver = Some((42, data_rx));
app.original_pr_number = Some(42);
app.pr_number = Some(42);
app.selected_file = 3;
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
assert!(app.saved_pr_snapshot.is_some());
assert!(app.submission_result.as_ref().unwrap().1.contains("Local"));
app.toggle_local_mode();
assert!(!app.local_mode);
assert!(app.saved_local_snapshot.is_some());
assert!(app.saved_pr_snapshot.is_none());
assert_eq!(app.selected_file, 3); assert!(app.submission_result.as_ref().unwrap().1.contains("PR"));
}
#[test]
fn test_toggle_local_mode_no_pr_to_return() {
let mut app = App::new_for_test();
app.original_pr_number = None;
app.started_from_pr_list = false;
app.local_mode = true;
app.toggle_local_mode();
assert!(app.local_mode);
assert!(app.submission_result.as_ref().unwrap().1.contains("No PR"));
}
#[test]
fn test_retry_load_sends_correct_request_type() {
let (tx, mut rx) = mpsc::channel::<RefreshRequest>(1);
let mut app = App::new_for_test();
app.retry_sender = Some(tx);
app.local_mode = false;
app.pr_number = Some(42);
app.retry_load();
let req = rx.try_recv().unwrap();
assert!(matches!(req, RefreshRequest::PrRefresh { pr_number: 42 }));
app.local_mode = true;
app.data_state = DataState::Loading; app.retry_load();
let req = rx.try_recv().unwrap();
assert!(matches!(req, RefreshRequest::LocalRefresh));
}
#[test]
fn test_is_shift_char_shortcut_accepts_uppercase() {
let key = KeyEvent {
code: KeyCode::Char('J'),
modifiers: KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert!(App::is_shift_char_shortcut(&key, 'j'));
}
#[test]
fn test_is_shift_char_shortcut_rejects_ctrl_or_alt() {
let ctrl = KeyEvent {
code: KeyCode::Char('J'),
modifiers: KeyModifiers::SHIFT | KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let alt = KeyEvent {
code: KeyCode::Char('K'),
modifiers: KeyModifiers::SHIFT | KeyModifiers::ALT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
assert!(!App::is_shift_char_shortcut(&ctrl, 'j'));
assert!(!App::is_shift_char_shortcut(&alt, 'k'));
}
#[test]
fn test_collect_unviewed_directory_paths_selected_prefix() {
let files = vec![
ChangedFile {
filename: "src/main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: false,
},
ChangedFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: true,
},
ChangedFile {
filename: "src/utils/mod.rs".to_string(),
status: "added".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -0,0 +1 @@\n+test".to_string()),
viewed: false,
},
ChangedFile {
filename: "README.md".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: false,
},
];
let paths = App::collect_unviewed_directory_paths(&files, 0);
assert_eq!(
paths,
vec!["src/main.rs".to_string(), "src/utils/mod.rs".to_string()]
);
}
#[test]
fn test_collect_unviewed_directory_paths_root_prefix_matches_only_root_files() {
let files = vec![
ChangedFile {
filename: "README.md".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: false,
},
ChangedFile {
filename: "src/main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: false,
},
ChangedFile {
filename: "Cargo.toml".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: true,
},
];
let paths = App::collect_unviewed_directory_paths(&files, 0);
assert_eq!(paths, vec!["README.md".to_string()]);
}
#[tokio::test]
async fn test_poll_mark_viewed_applies_unmark() {
let mut app = App::new_for_test();
app.pr_number = Some(1);
app.data_state = DataState::Loaded {
pr: Box::new(PullRequest {
number: 1,
node_id: Some("PR_node1".to_string()),
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
}),
files: vec![
ChangedFile {
filename: "src/main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: true,
},
ChangedFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: true,
},
],
};
let (tx, rx) = mpsc::channel(1);
app.mark_viewed_receiver = Some((1, rx));
tx.send(MarkViewedResult::Completed {
marked_paths: vec!["src/main.rs".to_string()],
total_targets: 1,
error: None,
set_viewed: false,
})
.await
.unwrap();
app.poll_mark_viewed_updates();
if let DataState::Loaded { files, .. } = &app.data_state {
assert!(!files[0].viewed, "src/main.rs should be unviewed");
assert!(files[1].viewed, "src/lib.rs should remain viewed");
} else {
panic!("Expected DataState::Loaded");
}
let (success, msg) = app.submission_result.unwrap();
assert!(success);
assert!(msg.contains("unviewed"));
}
#[tokio::test]
async fn test_poll_mark_viewed_skips_apply_on_pr_mismatch() {
let mut app = App::new_for_test();
app.pr_number = Some(2);
app.data_state = DataState::Loaded {
pr: Box::new(PullRequest {
number: 2,
node_id: Some("PR_node2".to_string()),
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
}),
files: vec![ChangedFile {
filename: "src/main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
let (tx, rx) = mpsc::channel(1);
app.mark_viewed_receiver = Some((1, rx));
tx.send(MarkViewedResult::Completed {
marked_paths: vec!["src/main.rs".to_string()],
total_targets: 1,
error: None,
set_viewed: true,
})
.await
.unwrap();
app.poll_mark_viewed_updates();
if let DataState::Loaded { files, .. } = &app.data_state {
assert!(
!files[0].viewed,
"File should remain unviewed due to PR mismatch"
);
} else {
panic!("Expected DataState::Loaded");
}
}
#[tokio::test]
async fn test_handle_data_result_auto_focus_skips_state_transition_during_bg_rally() {
let mut app = App::new_for_test();
app.local_mode = true;
app.local_auto_focus = true;
app.state = AppState::FileList;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 0,
pending_config_warning: None,
pause_state: PauseState::Running,
});
let pr = Box::new(make_local_pr());
let files = vec![ChangedFile {
filename: "new.rs".to_string(),
status: "added".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -0,0 +1,1 @@\n+new content".to_string()),
viewed: false,
}];
app.handle_data_result(0, DataLoadResult::Success { pr, files });
assert_eq!(app.state, AppState::FileList);
assert_eq!(app.selected_file, 0);
}
fn make_local_pr() -> PullRequest {
PullRequest {
number: 0,
node_id: None,
title: "Local diff".to_string(),
body: None,
state: "local".to_string(),
base: crate::github::Branch {
ref_name: "local".to_string(),
sha: "".to_string(),
},
head: crate::github::Branch {
ref_name: "HEAD".to_string(),
sha: "".to_string(),
},
user: crate::github::User {
login: "local".to_string(),
},
updated_at: "".to_string(),
}
}
#[test]
fn test_toggle_markdown_rich() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "README.md".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: false,
}],
};
assert!(!app.is_markdown_rich());
app.toggle_markdown_rich();
assert!(app.is_markdown_rich());
assert!(
app.diff_cache.is_none(),
"Cache should be cleared for md file"
);
app.toggle_markdown_rich();
assert!(!app.is_markdown_rich());
}
#[test]
fn test_toggle_markdown_rich_clears_receivers() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "README.md".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: false,
}],
};
let (_tx, rx) = tokio::sync::mpsc::channel::<DiffCache>(1);
app.diff_cache_receiver = Some(rx);
let (_tx2, rx2) = tokio::sync::mpsc::channel::<DiffCache>(1);
app.prefetch_receiver = Some(rx2);
app.toggle_markdown_rich();
assert!(
app.diff_cache_receiver.is_none(),
"diff_cache_receiver should be cleared for md file"
);
assert!(
app.prefetch_receiver.is_none(),
"prefetch_receiver should be cleared on toggle"
);
}
#[test]
fn test_toggle_markdown_rich_clears_only_md_cache() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![
ChangedFile {
filename: "README.md".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+test".to_string()),
viewed: false,
},
ChangedFile {
filename: "main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
viewed: false,
},
],
};
let md_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+test", 4);
let mut rs_cache =
crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
rs_cache.file_index = 1;
app.highlighted_cache_store.insert(0, md_cache);
app.highlighted_cache_store.insert(1, rs_cache);
assert_eq!(app.highlighted_cache_store.len(), 2);
app.toggle_markdown_rich();
assert!(
!app.highlighted_cache_store.contains_key(&0),
"md cache should be cleared"
);
assert!(
app.highlighted_cache_store.contains_key(&1),
"rs cache should be preserved"
);
assert_eq!(app.highlighted_cache_store.len(), 1);
}
#[test]
fn test_toggle_markdown_rich_preserves_non_md_diff_cache() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
viewed: false,
}],
};
let rs_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
app.diff_cache = Some(rs_cache);
app.toggle_markdown_rich();
assert!(
app.diff_cache.is_some(),
"non-md diff_cache should be preserved"
);
}
fn make_app_with_patch(patch: &str) -> App {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some(patch.to_string()),
viewed: false,
}],
};
app.selected_file = 0;
app
}
#[test]
fn test_enter_multiline_selection_sets_anchor() {
let mut app = make_app_with_patch("@@ -1,3 +1,4 @@\n context\n+added\n more context");
app.selected_line = 1; app.enter_multiline_selection();
assert!(app.multiline_selection.is_some());
let sel = app.multiline_selection.as_ref().unwrap();
assert_eq!(sel.anchor_line, 1);
assert_eq!(sel.cursor_line, 1);
}
#[test]
fn test_enter_multiline_selection_rejected_on_header() {
let mut app = make_app_with_patch("@@ -1,3 +1,4 @@\n context\n+added");
app.selected_line = 0; app.enter_multiline_selection();
assert!(app.multiline_selection.is_none());
}
#[test]
fn test_multiline_comment_preserves_selection_on_invalid_range() {
let patch = "@@ -1,2 +1,2 @@\n line1\n+new line2\n@@ -10,2 +10,2 @@\n line10\n+new line11";
let mut app = make_app_with_patch(patch);
app.multiline_selection = Some(MultilineSelection {
anchor_line: 1,
cursor_line: 4,
});
app.enter_multiline_comment_input();
assert!(
app.multiline_selection.is_some(),
"selection should not be cleared on validation failure"
);
assert!(
app.input_mode.is_none(),
"should not enter input mode on validation failure"
);
}
#[test]
fn test_multiline_comment_clears_selection_on_valid_range() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.multiline_selection = Some(MultilineSelection {
anchor_line: 1,
cursor_line: 2,
});
app.enter_multiline_comment_input();
assert!(
app.multiline_selection.is_none(),
"selection should be cleared after successful validation"
);
assert!(app.input_mode.is_some(), "should enter input mode");
assert_eq!(app.state, AppState::TextInput);
}
#[test]
fn test_multiline_suggestion_preserves_selection_on_invalid_range() {
let patch = "@@ -1,3 +1,3 @@\n context\n-removed\n+added";
let mut app = make_app_with_patch(patch);
app.multiline_selection = Some(MultilineSelection {
anchor_line: 1,
cursor_line: 3,
});
app.enter_multiline_suggestion_input();
assert!(
app.multiline_selection.is_some(),
"selection should not be cleared on validation failure"
);
assert!(app.input_mode.is_none());
}
#[test]
fn test_multiline_suggestion_clears_selection_on_valid_range() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.multiline_selection = Some(MultilineSelection {
anchor_line: 1,
cursor_line: 2,
});
app.enter_multiline_suggestion_input();
assert!(
app.multiline_selection.is_none(),
"selection should be cleared after successful validation"
);
assert!(app.input_mode.is_some());
if let Some(InputMode::Suggestion {
context,
original_code,
}) = &app.input_mode
{
assert!(context.start_line_number.is_some());
assert!(!original_code.is_empty());
} else {
panic!("expected InputMode::Suggestion");
}
}
#[test]
fn test_multiline_cancel_clears_selection() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.multiline_selection = Some(MultilineSelection {
anchor_line: 1,
cursor_line: 2,
});
app.multiline_selection = None;
assert!(app.multiline_selection.is_none());
assert!(app.input_mode.is_none());
}
fn make_key(code: KeyCode) -> event::KeyEvent {
event::KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn make_ctrl_key(c: char) -> event::KeyEvent {
event::KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn test_help_scroll_j_increments_by_one() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
assert_eq!(app.help_scroll_offset, 1);
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
assert_eq!(app.help_scroll_offset, 2);
}
#[test]
fn test_help_scroll_k_decrements_by_one_saturating() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 3;
app.apply_help_scroll(make_key(KeyCode::Char('k')), 30);
assert_eq!(app.help_scroll_offset, 2);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_key(KeyCode::Char('k')), 30);
assert_eq!(app.help_scroll_offset, 0);
}
#[test]
fn test_help_scroll_page_down_j_uppercase() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_key(KeyCode::Char('J')), 30);
assert_eq!(app.help_scroll_offset, 24);
}
#[test]
fn test_help_scroll_page_up_k_uppercase() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 50;
app.apply_help_scroll(make_key(KeyCode::Char('K')), 30);
assert_eq!(app.help_scroll_offset, 26);
}
#[test]
fn test_help_scroll_ctrl_d_half_page() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_ctrl_key('d'), 30);
assert_eq!(app.help_scroll_offset, 12);
}
#[test]
fn test_help_scroll_ctrl_u_half_page() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 20;
app.apply_help_scroll(make_ctrl_key('u'), 30);
assert_eq!(app.help_scroll_offset, 8);
}
#[test]
fn test_help_scroll_ctrl_d_at_least_1_on_small_terminal() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_ctrl_key('d'), 6);
assert_eq!(app.help_scroll_offset, 1);
}
#[test]
fn test_help_scroll_ctrl_d_at_least_1_on_very_small_terminal() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_ctrl_key('d'), 5);
assert_eq!(app.help_scroll_offset, 1);
}
#[test]
fn test_help_scroll_g_jumps_to_top() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 50;
app.apply_help_scroll(make_key(KeyCode::Char('g')), 30);
assert_eq!(app.help_scroll_offset, 0);
}
#[test]
fn test_help_scroll_g_uppercase_jumps_to_bottom() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_key(KeyCode::Char('G')), 30);
assert_eq!(app.help_scroll_offset, usize::MAX);
}
#[test]
fn test_help_scroll_q_returns_to_previous_state() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.previous_state = AppState::FileList;
app.state = AppState::Help;
app.apply_help_scroll(make_key(KeyCode::Char('q')), 30);
assert_eq!(app.state, AppState::FileList);
}
#[test]
fn test_help_viewport_overhead_matches_render_layout() {
assert_eq!(App::HELP_VIEWPORT_OVERHEAD, 6);
}
fn make_shift_key(c: char) -> event::KeyEvent {
event::KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::SHIFT,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
#[test]
fn test_help_scroll_shift_j_page_down() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_shift_key('j'), 30);
assert_eq!(app.help_scroll_offset, 24);
}
#[test]
fn test_help_scroll_shift_k_page_up() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 50;
app.apply_help_scroll(make_shift_key('k'), 30);
assert_eq!(app.help_scroll_offset, 26);
}
#[test]
fn test_help_scroll_shift_g_jumps_to_bottom() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 0;
app.apply_help_scroll(make_shift_key('g'), 30);
assert_eq!(app.help_scroll_offset, usize::MAX);
}
#[test]
fn test_help_scroll_g_without_modifiers_jumps_to_top() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.help_scroll_offset = 50;
app.apply_help_scroll(make_key(KeyCode::Char('g')), 30);
assert_eq!(app.help_scroll_offset, 0);
}
#[tokio::test]
async fn test_help_from_pr_list_not_blocked_by_loading_guard() {
let config = Config::default();
let mut app = App::new_pr_list("owner/repo", config);
app.pr_list_loading = false;
app.pr_list = Some(vec![]);
assert!(matches!(app.data_state, DataState::Loading));
app.handle_pr_list_input(make_key(KeyCode::Char('?')))
.await
.unwrap();
assert_eq!(app.state, AppState::Help);
assert_eq!(app.previous_state, AppState::PullRequestList);
app.apply_help_scroll(make_key(KeyCode::Char('q')), 30);
assert_eq!(app.state, AppState::PullRequestList);
}
#[tokio::test]
async fn test_patch_signature_detects_same_numstat_different_patch() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.set_local_mode(true);
app.set_local_auto_focus(true);
app.selected_file = 0;
let make_file = |name: &str, patch: &str| ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some(patch.to_string()),
viewed: false,
};
let initial_files = vec![
make_file("file_a.rs", "@@ -1,1 +1,1 @@\n-old\n+new"),
make_file("file_b.rs", "@@ -1,1 +1,1 @@\n-foo\n+bar"),
];
app.data_state = DataState::Loaded {
pr: Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
}),
files: initial_files,
};
app.update_patch_signatures_and_auto_focus();
assert_eq!(app.local_file_patch_signatures.len(), 2);
assert_eq!(app.selected_file, 0, "first batch should not auto-focus");
let updated_files = vec![
make_file("file_a.rs", "@@ -1,1 +1,1 @@\n-old\n+new"), make_file("file_b.rs", "@@ -1,1 +1,1 @@\n-foo\n+baz"), ];
app.data_state = DataState::Loaded {
pr: Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
}),
files: updated_files,
};
app.update_patch_signatures_and_auto_focus();
assert_eq!(
app.selected_file, 1,
"should auto-focus to file_b.rs whose patch content changed (same numstat)"
);
}
#[test]
fn test_key_event_kind_press_only() {
let press = event::KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
};
let release = event::KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
};
let repeat = event::KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Repeat,
state: KeyEventState::NONE,
};
assert_eq!(press.kind, KeyEventKind::Press);
assert_ne!(release.kind, KeyEventKind::Press);
assert_ne!(repeat.kind, KeyEventKind::Press);
}
#[test]
fn test_pending_approve_choice_q_cancels_and_clears_prompt() {
let mut app = App::new_for_test();
app.pending_approve_body = Some(String::new());
app.submission_result = Some((true, "placeholder".to_string()));
app.submission_result_time = Some(Instant::now());
let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('q')));
assert_eq!(choice, PendingApproveChoice::Cancel);
assert!(app.pending_approve_body.is_none());
assert!(app.submission_result.is_none());
assert!(app.submission_result_time.is_none());
}
#[test]
fn test_pending_approve_choice_esc_cancels() {
let mut app = App::new_for_test();
app.pending_approve_body = Some("some body".to_string());
let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Esc));
assert_eq!(choice, PendingApproveChoice::Cancel);
assert!(app.pending_approve_body.is_none());
}
#[test]
fn test_pending_approve_choice_a_submits_empty_body() {
let mut app = App::new_for_test();
app.pending_approve_body = Some(String::new());
let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('a')));
assert_eq!(choice, PendingApproveChoice::Submit);
assert!(app.pending_approve_body.is_some());
}
#[test]
fn test_pending_approve_choice_a_submits_with_body() {
let mut app = App::new_for_test();
app.pending_approve_body = Some("LGTM!".to_string());
let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('a')));
assert_eq!(choice, PendingApproveChoice::Submit);
assert!(app.pending_approve_body.is_some());
assert_eq!(app.pending_approve_body.as_deref(), Some("LGTM!"));
}
#[test]
fn test_calc_diff_line_count_basic() {
let files = vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
}];
assert_eq!(App::calc_diff_line_count(&files, 0), 3);
}
#[test]
fn test_calc_diff_line_count_no_patch() {
let files = vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 0,
deletions: 0,
patch: None,
viewed: false,
}];
assert_eq!(App::calc_diff_line_count(&files, 0), 0);
}
#[test]
fn test_calc_diff_line_count_out_of_bounds() {
let files = vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 1,
patch: Some("@@ -1,1 +1,1 @@\n-old\n+new".to_string()),
viewed: false,
}];
assert_eq!(App::calc_diff_line_count(&files, 5), 0);
}
#[test]
fn test_files_returns_empty_when_loading() {
let mut app = App::new_for_test();
app.data_state = DataState::Loading;
assert!(app.files().is_empty());
}
#[test]
fn test_files_returns_files_when_loaded() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
assert_eq!(app.files().len(), 1);
assert_eq!(app.files()[0].filename, "a.rs");
}
#[test]
fn test_pr_returns_none_when_loading() {
let mut app = App::new_for_test();
app.data_state = DataState::Loading;
assert!(app.pr().is_none());
}
#[test]
fn test_pr_returns_some_when_loaded() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![],
};
assert!(app.pr().is_some());
assert_eq!(app.pr().unwrap().number, 0);
}
#[tokio::test]
async fn test_restore_data_from_cache_hit() {
let mut app = App::new_for_test();
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
app.retry_sender = Some(retry_tx);
app.pr_number = Some(42);
let cache_key = PrCacheKey {
repo: "test/repo".to_string(),
pr_number: 42,
};
app.session_cache.put_pr_data(
cache_key,
PrData {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "cached.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line".to_string()),
viewed: false,
}],
pr_updated_at: "2024-01-01T00:00:00Z".to_string(),
},
);
app.restore_data_from_cache();
assert!(matches!(app.data_state, DataState::Loaded { .. }));
}
#[test]
fn test_restore_data_from_cache_miss() {
let mut app = App::new_for_test();
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
app.retry_sender = Some(retry_tx);
app.pr_number = Some(999);
app.restore_data_from_cache();
assert!(matches!(app.data_state, DataState::Loading));
}
#[test]
fn test_update_data_receiver_origin() {
let mut app = App::new_for_test();
let (_tx, rx) = mpsc::channel::<DataLoadResult>(2);
app.data_receiver = Some((1, rx));
app.update_data_receiver_origin(42);
if let Some((origin, _)) = &app.data_receiver {
assert_eq!(*origin, 42);
} else {
panic!("data_receiver should exist");
}
}
#[test]
fn test_multiline_selection_start_end() {
let sel = MultilineSelection {
anchor_line: 3,
cursor_line: 7,
};
assert_eq!(sel.start(), 3);
assert_eq!(sel.end(), 7);
let sel2 = MultilineSelection {
anchor_line: 10,
cursor_line: 2,
};
assert_eq!(sel2.start(), 2);
assert_eq!(sel2.end(), 10);
}
#[test]
fn test_ai_rally_state_push_log_auto_follow() {
use crate::ai::RallyState;
let mut state = AiRallyState {
iteration: 1,
max_iterations: 10,
state: RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
};
state.push_log(LogEntry::new(LogEventType::Info, "first".to_string()));
assert_eq!(state.selected_log_index, Some(0));
state.push_log(LogEntry::new(LogEventType::Info, "second".to_string()));
assert_eq!(state.selected_log_index, Some(1));
}
#[test]
fn test_ai_rally_state_push_log_no_auto_follow() {
use crate::ai::RallyState;
let mut state = AiRallyState {
iteration: 1,
max_iterations: 10,
state: RallyState::ReviewerReviewing,
history: vec![],
logs: vec![
LogEntry::new(LogEventType::Info, "old1".to_string()),
LogEntry::new(LogEventType::Info, "old2".to_string()),
LogEntry::new(LogEventType::Info, "old3".to_string()),
],
log_scroll_offset: 0,
selected_log_index: Some(0), showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
};
state.push_log(LogEntry::new(LogEventType::Info, "new".to_string()));
assert_eq!(state.selected_log_index, Some(0)); }
#[test]
fn test_ai_rally_state_is_selection_at_tail() {
use crate::ai::RallyState;
let mut state = AiRallyState {
iteration: 1,
max_iterations: 10,
state: RallyState::ReviewerReviewing,
history: vec![],
logs: vec![
LogEntry::new(LogEventType::Info, "a".to_string()),
LogEntry::new(LogEventType::Info, "b".to_string()),
],
log_scroll_offset: 0,
selected_log_index: Some(1), showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
};
assert_eq!(state.selected_log_index, Some(1));
assert_eq!(state.logs.len(), 2);
state.selected_log_index = Some(0);
assert_ne!(state.selected_log_index.unwrap(), state.logs.len() - 1);
state.selected_log_index = None;
}
#[test]
fn test_hash_string_deterministic() {
let h1 = hash_string("hello world");
let h2 = hash_string("hello world");
assert_eq!(h1, h2);
let h3 = hash_string("different");
assert_ne!(h1, h3);
}
#[test]
fn test_check_sequence_timeout_clears_expired() {
let mut app = App::new_for_test();
app.pending_since = Some(Instant::now() - std::time::Duration::from_secs(10));
app.pending_keys.push(crate::keybinding::KeyBinding::char('g'));
app.check_sequence_timeout();
assert!(app.pending_keys.is_empty());
assert!(app.pending_since.is_none());
}
#[test]
fn test_check_sequence_timeout_keeps_active() {
let mut app = App::new_for_test();
app.pending_since = Some(Instant::now());
app.pending_keys.push(crate::keybinding::KeyBinding::char('g'));
app.check_sequence_timeout();
assert!(!app.pending_keys.is_empty());
assert!(app.pending_since.is_some());
}
#[test]
fn test_push_pending_key_sets_timestamp() {
let mut app = App::new_for_test();
assert!(app.pending_since.is_none());
app.push_pending_key(crate::keybinding::KeyBinding::char('g'));
assert!(app.pending_since.is_some());
assert_eq!(app.pending_keys.len(), 1);
}
#[test]
fn test_push_pending_key_appends() {
let mut app = App::new_for_test();
app.push_pending_key(crate::keybinding::KeyBinding::char('g'));
app.push_pending_key(crate::keybinding::KeyBinding::char('d'));
assert_eq!(app.pending_keys.len(), 2);
assert_eq!(
app.pending_keys[0],
crate::keybinding::KeyBinding::char('g')
);
assert_eq!(
app.pending_keys[1],
crate::keybinding::KeyBinding::char('d')
);
}
#[test]
fn test_clear_pending_keys_resets() {
let mut app = App::new_for_test();
app.push_pending_key(crate::keybinding::KeyBinding::char('g'));
app.push_pending_key(crate::keybinding::KeyBinding::char('d'));
app.clear_pending_keys();
assert!(app.pending_keys.is_empty());
assert!(app.pending_since.is_none());
}
#[test]
fn test_matches_single_key_basic() {
let app = App::new_for_test();
let seq = crate::keybinding::KeySequence(vec![
crate::keybinding::KeyBinding::char('j')
]);
let key = make_key(KeyCode::Char('j'));
assert!(app.matches_single_key(&key, &seq));
}
#[test]
fn test_matches_single_key_ignores_sequence() {
let app = App::new_for_test();
let seq = crate::keybinding::KeySequence(vec![
crate::keybinding::KeyBinding::char('g'),
crate::keybinding::KeyBinding::char('d')
]);
let key = make_key(KeyCode::Char('g'));
assert!(!app.matches_single_key(&key, &seq));
}
#[test]
fn test_try_match_sequence_full_partial_none() {
use crate::keybinding::{KeyBinding, KeySequence, SequenceMatch};
let mut app = App::new_for_test();
let seq = KeySequence(vec![
KeyBinding::char('g'),
KeyBinding::char('d')
]);
assert_eq!(app.try_match_sequence(&seq), SequenceMatch::None);
app.pending_keys.push(KeyBinding::char('g'));
assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Partial);
app.pending_keys.push(KeyBinding::char('d'));
assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Full);
}
#[test]
fn test_key_could_match_sequence_start() {
use crate::keybinding::{KeyBinding, KeySequence};
let app = App::new_for_test();
let seq = KeySequence(vec![
KeyBinding::char('g'),
KeyBinding::char('d')
]);
let key = make_key(KeyCode::Char('g'));
assert!(app.key_could_match_sequence(&key, &seq));
}
#[test]
fn test_key_could_match_sequence_no_match() {
use crate::keybinding::{KeyBinding, KeySequence};
let app = App::new_for_test();
let seq = KeySequence(vec![
KeyBinding::char('g'),
KeyBinding::char('d')
]);
let key = make_key(KeyCode::Char('x'));
assert!(!app.key_could_match_sequence(&key, &seq));
}
#[test]
fn test_handle_filter_input_char_updates_query() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
let mut filter = crate::filter::ListFilter::new();
filter.apply(app.files(), |_, _| true);
filter.sync_selection();
app.file_list_filter = Some(filter);
let key = make_key(KeyCode::Char('t'));
assert!(app.handle_filter_input(&key, "file"));
assert_eq!(app.file_list_filter.as_ref().unwrap().query, "t");
}
#[test]
fn test_handle_filter_input_backspace() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
let mut filter = crate::filter::ListFilter::new();
filter.insert_char('a');
filter.insert_char('b');
filter.apply(app.files(), |_, _| true);
filter.sync_selection();
app.file_list_filter = Some(filter);
let key = make_key(KeyCode::Backspace);
assert!(app.handle_filter_input(&key, "file"));
assert_eq!(app.file_list_filter.as_ref().unwrap().query, "a");
}
#[test]
fn test_handle_filter_input_enter_deactivates() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
let mut filter = crate::filter::ListFilter::new();
filter.insert_char('t');
filter.apply(app.files(), |_, _| true);
filter.sync_selection();
app.file_list_filter = Some(filter);
let key = make_key(KeyCode::Enter);
assert!(app.handle_filter_input(&key, "file"));
assert!(!app.file_list_filter.as_ref().unwrap().input_active);
}
#[test]
fn test_handle_filter_input_esc_clears() {
let mut app = App::new_for_test();
let mut filter = crate::filter::ListFilter::new();
filter.insert_char('x');
app.file_list_filter = Some(filter);
let key = make_key(KeyCode::Esc);
assert!(app.handle_filter_input(&key, "file"));
assert!(app.file_list_filter.is_none());
}
#[test]
fn test_handle_filter_input_returns_false_no_filter() {
let mut app = App::new_for_test();
app.file_list_filter = None;
let key = make_key(KeyCode::Char('a'));
assert!(!app.handle_filter_input(&key, "file"));
}
#[test]
fn test_reapply_filter_pr_list() {
use crate::github::PullRequestSummary;
let mut app = App::new_for_test();
app.pr_list = Some(vec![
PullRequestSummary {
number: 1,
title: "fix bug".to_string(),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
PullRequestSummary {
number: 2,
title: "add feature".to_string(),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
let mut filter = crate::filter::ListFilter::new();
filter.insert_char('b');
filter.insert_char('u');
filter.insert_char('g');
app.pr_list_filter = Some(filter);
app.reapply_filter("pr");
let filter = app.pr_list_filter.as_ref().unwrap();
assert_eq!(filter.matched_indices, vec![0]); }
#[test]
fn test_reapply_filter_file_list() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![
ChangedFile {
filename: "src/main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
ChangedFile {
filename: "src/lib.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
],
};
let mut filter = crate::filter::ListFilter::new();
filter.insert_char('l');
filter.insert_char('i');
filter.insert_char('b');
app.file_list_filter = Some(filter);
app.reapply_filter("file");
let filter = app.file_list_filter.as_ref().unwrap();
assert_eq!(filter.matched_indices, vec![1]); }
#[test]
fn test_handle_filter_navigation_down() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![
ChangedFile {
filename: "a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
ChangedFile {
filename: "b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
],
};
let mut filter = crate::filter::ListFilter::new();
filter.apply(app.files(), |_, _| true);
filter.sync_selection();
filter.input_active = false;
app.file_list_filter = Some(filter);
app.selected_file = 0;
assert!(app.handle_filter_navigation("file", true));
assert_eq!(app.selected_file, 1);
}
#[test]
fn test_handle_filter_navigation_up() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![
ChangedFile {
filename: "a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
ChangedFile {
filename: "b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
],
};
let mut filter = crate::filter::ListFilter::new();
filter.apply(app.files(), |_, _| true);
filter.sync_selection();
filter.input_active = false;
filter.navigate_down();
app.file_list_filter = Some(filter);
app.selected_file = 1;
assert!(app.handle_filter_navigation("file", false));
assert_eq!(app.selected_file, 0);
}
#[test]
fn test_handle_filter_esc_clears_pr() {
let mut app = App::new_for_test();
let filter = crate::filter::ListFilter::new();
app.pr_list_filter = Some(filter);
assert!(app.handle_filter_esc("pr"));
assert!(app.pr_list_filter.is_none());
}
#[test]
fn test_handle_filter_esc_no_filter() {
let mut app = App::new_for_test();
app.pr_list_filter = None;
assert!(!app.handle_filter_esc("pr"));
}
#[test]
fn test_is_filter_selection_empty() {
let mut app = App::new_for_test();
assert!(!app.is_filter_selection_empty("file"));
let mut filter = crate::filter::ListFilter::new();
filter.matched_indices = vec![0];
filter.selected = Some(0);
app.file_list_filter = Some(filter);
assert!(!app.is_filter_selection_empty("file"));
let mut filter2 = crate::filter::ListFilter::new();
filter2.matched_indices = vec![];
filter2.selected = None;
app.file_list_filter = Some(filter2);
assert!(app.is_filter_selection_empty("file"));
}
#[test]
fn test_directory_prefix_for_nested() {
assert_eq!(App::directory_prefix_for("a/b/c.txt"), "a/b/");
}
#[test]
fn test_directory_prefix_for_root() {
assert_eq!(App::directory_prefix_for("root.txt"), "");
}
#[test]
fn test_directory_prefix_for_single_dir() {
assert_eq!(App::directory_prefix_for("dir/file.rs"), "dir/");
}
#[test]
fn test_collect_unviewed_all_viewed() {
let files = vec![
ChangedFile {
filename: "src/a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: true,
},
ChangedFile {
filename: "src/b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: true,
},
];
let paths = App::collect_unviewed_directory_paths(&files, 0);
assert!(paths.is_empty());
}
#[test]
fn test_collect_unviewed_mixed() {
let files = vec![
ChangedFile {
filename: "src/a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
ChangedFile {
filename: "src/b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: true,
},
ChangedFile {
filename: "src/c.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
},
];
let paths = App::collect_unviewed_directory_paths(&files, 0);
assert_eq!(paths, vec!["src/a.rs", "src/c.rs"]);
}
#[test]
fn test_refresh_all_resets_state() {
let mut app = App::new_for_test();
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
app.retry_sender = Some(retry_tx);
app.review_comments = Some(vec![]);
app.discussion_comments = Some(vec![]);
app.comments_loading = true;
app.discussion_comments_loading = true;
let filter = crate::filter::ListFilter::new();
app.file_list_filter = Some(filter);
app.refresh_all();
assert!(matches!(app.data_state, DataState::Loading));
assert!(app.review_comments.is_none());
assert!(app.discussion_comments.is_none());
assert!(!app.comments_loading);
assert!(!app.discussion_comments_loading);
assert!(app.file_list_filter.is_none());
}
#[test]
fn test_refresh_all_invalidates_session_cache() {
let mut app = App::new_for_test();
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
app.retry_sender = Some(retry_tx);
let cache_key = PrCacheKey {
repo: "test/repo".to_string(),
pr_number: 1,
};
app.session_cache.put_pr_data(
cache_key.clone(),
PrData {
pr: Box::new(make_local_pr()),
files: vec![],
pr_updated_at: "x".to_string(),
},
);
assert!(app.session_cache.get_pr_data(&cache_key).is_some());
app.refresh_all();
assert!(app.session_cache.get_pr_data(&cache_key).is_none());
}
#[tokio::test]
async fn test_handle_mark_viewed_key_v_returns_true() {
let mut app = App::new_for_test();
app.pr_number = Some(1);
app.data_state = DataState::Loaded {
pr: Box::new(PullRequest {
number: 1,
node_id: Some("PR_node".to_string()),
title: "Test".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "f".to_string(),
sha: "a".to_string(),
},
base: crate::github::Branch {
ref_name: "m".to_string(),
sha: "b".to_string(),
},
user: crate::github::User {
login: "u".to_string(),
},
updated_at: "".to_string(),
}),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
let key = make_key(KeyCode::Char('v'));
assert!(app.handle_mark_viewed_key(key));
}
#[test]
fn test_handle_mark_viewed_key_ignored_local_mode() {
let mut app = App::new_for_test();
app.local_mode = true;
let key = make_key(KeyCode::Char('v'));
assert!(!app.handle_mark_viewed_key(key));
}
#[test]
fn test_handle_mark_viewed_key_other_key() {
let mut app = App::new_for_test();
let key = make_key(KeyCode::Char('x'));
assert!(!app.handle_mark_viewed_key(key));
}
#[test]
fn test_cancel_input_clears_mode() {
let mut app = App::new_for_test();
app.input_mode = Some(InputMode::Comment(LineInputContext {
file_index: 0,
line_number: 1,
diff_position: 1,
start_line_number: None,
}));
app.state = AppState::TextInput;
app.preview_return_state = AppState::DiffView;
app.cancel_input();
assert!(app.input_mode.is_none());
}
#[test]
fn test_cancel_input_clears_text_area() {
let mut app = App::new_for_test();
app.input_text_area.set_content("some text");
app.state = AppState::TextInput;
app.preview_return_state = AppState::DiffView;
app.cancel_input();
assert!(app.input_text_area.content().is_empty());
}
#[test]
fn test_cancel_input_restores_state() {
let mut app = App::new_for_test();
app.state = AppState::TextInput;
app.preview_return_state = AppState::DiffView;
app.cancel_input();
assert_eq!(app.state, AppState::DiffView);
}
#[test]
fn test_cancel_input_clears_multiline_selection() {
let mut app = App::new_for_test();
app.multiline_selection = Some(MultilineSelection {
anchor_line: 1,
cursor_line: 5,
});
app.state = AppState::TextInput;
app.preview_return_state = AppState::DiffView;
app.cancel_input();
assert!(app.input_mode.is_none());
}
#[test]
fn test_adjust_scroll_above_viewport() {
let mut app = App::new_for_test();
app.selected_line = 2;
app.scroll_offset = 5;
app.diff_line_count = 100;
app.adjust_scroll(20);
assert_eq!(app.scroll_offset, 2);
}
#[test]
fn test_adjust_scroll_below_viewport() {
let mut app = App::new_for_test();
app.selected_line = 30;
app.scroll_offset = 5;
app.diff_line_count = 100;
app.adjust_scroll(20);
assert!(app.scroll_offset > 5);
assert!(app.selected_line >= app.scroll_offset);
}
#[test]
fn test_adjust_scroll_within_viewport() {
let mut app = App::new_for_test();
app.selected_line = 10;
app.scroll_offset = 5;
app.diff_line_count = 100;
app.adjust_scroll(20);
assert!(app.selected_line >= app.scroll_offset);
assert!(app.selected_line < app.scroll_offset + 20);
}
#[test]
fn test_adjust_scroll_zero_visible() {
let mut app = App::new_for_test();
app.selected_line = 10;
app.scroll_offset = 5;
app.diff_line_count = 100;
app.adjust_scroll(0);
assert_eq!(app.scroll_offset, 5);
}
#[test]
fn test_adjust_scroll_end_padding() {
let mut app = App::new_for_test();
app.diff_line_count = 50;
app.selected_line = 49; app.scroll_offset = 0;
app.adjust_scroll(20);
assert!(app.scroll_offset > 0);
}
#[test]
fn test_adjust_scroll_single_line() {
let mut app = App::new_for_test();
app.diff_line_count = 1;
app.selected_line = 0;
app.scroll_offset = 0;
app.adjust_scroll(20);
assert_eq!(app.scroll_offset, 0);
assert_eq!(app.selected_line, 0);
}
#[test]
fn test_cleanup_rally_state() {
let mut app = App::new_for_test();
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
let (cmd_tx, _cmd_rx) = mpsc::channel(10);
app.rally_command_sender = Some(cmd_tx);
let (_, event_rx) = mpsc::channel(100);
app.rally_event_receiver = Some(event_rx);
app.cleanup_rally_state();
assert!(app.ai_rally_state.is_none());
assert!(app.rally_command_sender.is_none());
assert!(app.rally_event_receiver.is_none());
}
#[test]
fn test_is_rally_running_in_background_not_in_rally() {
let mut app = App::new_for_test();
app.state = AppState::FileList;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
assert!(app.is_rally_running_in_background());
}
#[test]
fn test_is_rally_running_in_background_in_rally() {
let mut app = App::new_for_test();
app.state = AppState::AiRally;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
assert!(!app.is_rally_running_in_background());
}
#[test]
fn test_is_rally_running_in_background_no_state() {
let mut app = App::new_for_test();
app.state = AppState::FileList;
app.ai_rally_state = None;
assert!(!app.is_rally_running_in_background());
}
#[test]
fn test_is_rally_running_in_background_finished() {
let mut app = App::new_for_test();
app.state = AppState::FileList;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::Completed,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
assert!(!app.is_rally_running_in_background());
}
#[test]
fn test_has_background_rally_true() {
let mut app = App::new_for_test();
app.state = AppState::FileList;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
assert!(app.has_background_rally());
}
#[test]
fn test_has_background_rally_false_in_rally() {
let mut app = App::new_for_test();
app.state = AppState::AiRally;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
assert!(!app.has_background_rally());
}
#[test]
fn test_has_background_rally_false_no_state() {
let mut app = App::new_for_test();
app.state = AppState::FileList;
app.ai_rally_state = None;
assert!(!app.has_background_rally());
}
#[test]
fn test_is_background_rally_finished_completed() {
let mut app = App::new_for_test();
app.state = AppState::FileList;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::Completed,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
assert!(app.is_background_rally_finished());
}
#[test]
fn test_is_background_rally_finished_running() {
let mut app = App::new_for_test();
app.state = AppState::FileList;
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::Running,
});
assert!(!app.is_background_rally_finished());
}
#[test]
fn test_adjust_log_scroll_selection_above() {
let mut app = App::new_for_test();
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: (0..20)
.map(|i| LogEntry::new(LogEventType::Info, format!("log {}", i)))
.collect(),
log_scroll_offset: 10,
selected_log_index: Some(5), showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 5,
pending_config_warning: None,
pause_state: PauseState::Running,
});
app.adjust_log_scroll_to_selection();
let rally_state = app.ai_rally_state.as_ref().unwrap();
assert!(rally_state.log_scroll_offset <= 5);
}
#[test]
fn test_adjust_log_scroll_selection_below() {
let mut app = App::new_for_test();
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: (0..20)
.map(|i| LogEntry::new(LogEventType::Info, format!("log {}", i)))
.collect(),
log_scroll_offset: 1,
selected_log_index: Some(15), showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 5,
pending_config_warning: None,
pause_state: PauseState::Running,
});
app.adjust_log_scroll_to_selection();
let rally_state = app.ai_rally_state.as_ref().unwrap();
assert!(rally_state.log_scroll_offset > 1);
}
#[test]
fn test_pause_state_reset_on_approve_state_change() {
use crate::ai::orchestrator::RallyEvent;
use crate::ai::RallyState;
let mut app = App::new_for_test();
let (event_tx, event_rx) = mpsc::channel(100);
app.rally_event_receiver = Some(event_rx);
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::PauseRequested,
});
event_tx
.try_send(RallyEvent::StateChanged(RallyState::Completed))
.unwrap();
app.poll_rally_events();
let rally_state = app.ai_rally_state.as_ref().unwrap();
assert_eq!(rally_state.state, RallyState::Completed);
assert_eq!(
rally_state.pause_state,
PauseState::Running,
"pause_state must be reset to Running on Completed"
);
}
#[test]
fn test_pause_state_reset_on_waiting_for_clarification() {
use crate::ai::orchestrator::RallyEvent;
use crate::ai::RallyState;
let mut app = App::new_for_test();
let (event_tx, event_rx) = mpsc::channel(100);
app.rally_event_receiver = Some(event_rx);
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::RevieweeFix,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::PauseRequested,
});
event_tx
.try_send(RallyEvent::StateChanged(
RallyState::WaitingForClarification,
))
.unwrap();
app.poll_rally_events();
let rally_state = app.ai_rally_state.as_ref().unwrap();
assert_eq!(rally_state.state, RallyState::WaitingForClarification);
assert_eq!(
rally_state.pause_state,
PauseState::Running,
"pause_state must be reset to Running on WaitingForClarification"
);
}
#[test]
fn test_pause_state_reset_on_waiting_for_permission() {
use crate::ai::orchestrator::RallyEvent;
use crate::ai::RallyState;
let mut app = App::new_for_test();
let (event_tx, event_rx) = mpsc::channel(100);
app.rally_event_receiver = Some(event_rx);
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::RevieweeFix,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::PauseRequested,
});
event_tx
.try_send(RallyEvent::StateChanged(
RallyState::WaitingForPermission,
))
.unwrap();
app.poll_rally_events();
let rally_state = app.ai_rally_state.as_ref().unwrap();
assert_eq!(rally_state.state, RallyState::WaitingForPermission);
assert_eq!(
rally_state.pause_state,
PauseState::Running,
"pause_state must be reset to Running on WaitingForPermission"
);
}
#[test]
fn test_pause_state_reset_on_waiting_for_post_confirmation() {
use crate::ai::orchestrator::RallyEvent;
use crate::ai::RallyState;
let mut app = App::new_for_test();
let (event_tx, event_rx) = mpsc::channel(100);
app.rally_event_receiver = Some(event_rx);
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::RevieweeFix,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::PauseRequested,
});
event_tx
.try_send(RallyEvent::StateChanged(
RallyState::WaitingForPostConfirmation,
))
.unwrap();
app.poll_rally_events();
let rally_state = app.ai_rally_state.as_ref().unwrap();
assert_eq!(rally_state.state, RallyState::WaitingForPostConfirmation);
assert_eq!(
rally_state.pause_state,
PauseState::Running,
"pause_state must be reset to Running on WaitingForPostConfirmation"
);
}
#[test]
fn test_pause_state_preserved_on_active_state_change() {
use crate::ai::orchestrator::RallyEvent;
use crate::ai::RallyState;
let mut app = App::new_for_test();
let (event_tx, event_rx) = mpsc::channel(100);
app.rally_event_receiver = Some(event_rx);
app.ai_rally_state = Some(AiRallyState {
iteration: 1,
max_iterations: 10,
state: crate::ai::RallyState::ReviewerReviewing,
history: vec![],
logs: vec![],
log_scroll_offset: 0,
selected_log_index: None,
showing_log_detail: false,
pending_question: None,
pending_permission: None,
pending_review_post: None,
pending_fix_post: None,
last_visible_log_height: 10,
pending_config_warning: None,
pause_state: PauseState::PauseRequested,
});
event_tx
.try_send(RallyEvent::StateChanged(RallyState::RevieweeFix))
.unwrap();
app.poll_rally_events();
let rally_state = app.ai_rally_state.as_ref().unwrap();
assert_eq!(rally_state.state, RallyState::RevieweeFix);
assert_eq!(
rally_state.pause_state,
PauseState::PauseRequested,
"pause_state should remain PauseRequested during active state transitions"
);
}
#[test]
fn test_push_jump_location_basic() {
let mut app = App::new_for_test();
app.selected_file = 2;
app.selected_line = 10;
app.scroll_offset = 5;
app.push_jump_location();
assert_eq!(app.jump_stack.len(), 1);
assert_eq!(app.jump_stack[0].file_index, 2);
assert_eq!(app.jump_stack[0].line_index, 10);
assert_eq!(app.jump_stack[0].scroll_offset, 5);
}
#[test]
fn test_push_jump_location_max_capacity() {
let mut app = App::new_for_test();
for i in 0..101 {
app.selected_file = i;
app.selected_line = i;
app.scroll_offset = 0;
app.push_jump_location();
}
assert_eq!(app.jump_stack.len(), 100);
assert_eq!(app.jump_stack[0].file_index, 1);
}
#[test]
fn test_push_jump_location_preserves_fields() {
let mut app = App::new_for_test();
app.selected_file = 42;
app.selected_line = 99;
app.scroll_offset = 33;
app.push_jump_location();
let loc = &app.jump_stack[0];
assert_eq!(loc.file_index, 42);
assert_eq!(loc.line_index, 99);
assert_eq!(loc.scroll_offset, 33);
}
#[tokio::test]
async fn test_jump_back_restores_position() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![
ChangedFile {
filename: "a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line".to_string()),
viewed: false,
},
ChangedFile {
filename: "b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line".to_string()),
viewed: false,
},
],
};
app.selected_file = 0;
app.selected_line = 5;
app.scroll_offset = 2;
app.push_jump_location();
app.selected_file = 1;
app.selected_line = 10;
app.scroll_offset = 8;
app.jump_back();
assert_eq!(app.selected_file, 0);
assert_eq!(app.selected_line, 5);
assert_eq!(app.scroll_offset, 2);
}
#[test]
fn test_jump_back_empty_stack() {
let mut app = App::new_for_test();
app.selected_file = 3;
app.selected_line = 7;
app.scroll_offset = 4;
app.jump_back();
assert_eq!(app.selected_file, 3);
assert_eq!(app.selected_line, 7);
assert_eq!(app.scroll_offset, 4);
}
#[test]
fn test_enter_comment_input_sets_mode() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.selected_line = 1; app.state = AppState::DiffView;
app.enter_comment_input();
assert!(matches!(app.input_mode, Some(InputMode::Comment(_))));
assert_eq!(app.state, AppState::TextInput);
}
#[test]
fn test_enter_comment_input_no_patch() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 0,
deletions: 0,
patch: None,
viewed: false,
}],
};
app.enter_comment_input();
assert!(app.input_mode.is_none());
}
#[test]
fn test_enter_suggestion_input_sets_mode() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added line\n more context";
let mut app = make_app_with_patch(patch);
app.selected_line = 2; app.state = AppState::DiffView;
app.enter_suggestion_input();
assert!(matches!(app.input_mode, Some(InputMode::Suggestion { .. })));
assert_eq!(app.state, AppState::TextInput);
}
#[tokio::test]
async fn test_open_comment_list_transitions_state() {
let mut app = App::new_for_test();
let (retry_tx, _) = mpsc::channel::<RefreshRequest>(4);
app.retry_sender = Some(retry_tx);
app.state = AppState::FileList;
app.data_state = DataState::Loaded {
pr: Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "f".to_string(),
sha: "a".to_string(),
},
base: crate::github::Branch {
ref_name: "m".to_string(),
sha: "b".to_string(),
},
user: crate::github::User {
login: "u".to_string(),
},
updated_at: "".to_string(),
}),
files: vec![],
};
app.previous_state = AppState::FileList;
app.open_comment_list();
assert_eq!(app.state, AppState::CommentList);
}
#[tokio::test]
async fn test_open_comment_list_sets_previous_state() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![],
};
app.state = AppState::FileList;
app.previous_state = AppState::FileList;
app.open_comment_list();
assert_eq!(app.state, AppState::CommentList);
}
#[test]
fn test_update_file_comment_positions_empty_comments() {
let mut app = make_app_with_patch("@@ -1,3 +1,4 @@\n context\n+added\n more context");
app.review_comments = Some(vec![]);
app.update_file_comment_positions();
assert!(app.file_comment_positions.is_empty());
}
#[test]
fn test_update_file_comment_positions_with_comments() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.review_comments = Some(vec![
crate::github::comment::ReviewComment {
id: 1,
path: "test.rs".to_string(),
line: Some(1),
body: "comment at line 1".to_string(),
user: crate::github::User {
login: "reviewer".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
app.update_file_comment_positions();
assert_eq!(app.file_comment_positions.len(), 1);
}
#[test]
fn test_update_file_comment_positions_stale_comment() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.review_comments = Some(vec![
crate::github::comment::ReviewComment {
id: 1,
path: "other_file.rs".to_string(), line: Some(1),
body: "wrong file".to_string(),
user: crate::github::User {
login: "reviewer".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
app.update_file_comment_positions();
assert!(app.file_comment_positions.is_empty());
}
#[test]
fn test_wrapped_line_count_short() {
assert_eq!(App::wrapped_line_count("hello", 80), 1);
}
#[test]
fn test_wrapped_line_count_long() {
let text: String = "x".repeat(100);
assert_eq!(App::wrapped_line_count(&text, 40), 3);
}
#[test]
fn test_wrapped_line_count_empty() {
assert_eq!(App::wrapped_line_count("", 80), 1);
}
#[test]
fn test_comment_body_wrapped_lines() {
let body = "short line\na longer line that has more characters";
let count = App::comment_body_wrapped_lines(body, 80);
assert_eq!(count, 2); }
#[test]
fn test_comment_panel_inner_width() {
let mut app = App::new_for_test();
app.state = AppState::DiffView;
assert_eq!(app.comment_panel_inner_width(100), 98);
}
#[test]
fn test_max_comment_panel_scroll() {
let mut app = App::new_for_test();
app.state = AppState::DiffView;
app.file_comment_positions = vec![];
app.review_comments = Some(vec![]);
let max = app.max_comment_panel_scroll(40, 80);
assert_eq!(max, 0);
}
#[test]
fn test_enter_reply_input_sets_mode() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.selected_line = 1;
app.review_comments = Some(vec![
crate::github::comment::ReviewComment {
id: 42,
path: "test.rs".to_string(),
line: Some(1),
body: "original comment".to_string(),
user: crate::github::User {
login: "reviewer".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
app.file_comment_positions = vec![CommentPosition {
diff_line_index: 1,
comment_index: 0,
}];
app.state = AppState::DiffView;
app.enter_reply_input();
assert!(matches!(app.input_mode, Some(InputMode::Reply { .. })));
assert_eq!(app.state, AppState::TextInput);
}
#[test]
fn test_handle_discussion_detail_scroll_j() {
let mut app = App::new_for_test();
app.discussion_comment_detail_mode = true;
app.discussion_comment_detail_scroll = 0;
let result = app.handle_discussion_detail_input(make_key(KeyCode::Char('j')), 20);
assert!(result.is_ok());
assert_eq!(app.discussion_comment_detail_scroll, 1);
}
#[test]
fn test_handle_discussion_detail_scroll_k() {
let mut app = App::new_for_test();
app.discussion_comment_detail_mode = true;
app.discussion_comment_detail_scroll = 5;
let result = app.handle_discussion_detail_input(make_key(KeyCode::Char('k')), 20);
assert!(result.is_ok());
assert_eq!(app.discussion_comment_detail_scroll, 4);
}
#[tokio::test]
async fn test_jump_to_comment_sets_file_and_line() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "f".to_string(),
sha: "a".to_string(),
},
base: crate::github::Branch {
ref_name: "m".to_string(),
sha: "b".to_string(),
},
user: crate::github::User {
login: "u".to_string(),
},
updated_at: "".to_string(),
}),
files: vec![
ChangedFile {
filename: "first.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1,1 +1,2 @@\n line1\n+line2".to_string()),
viewed: false,
},
ChangedFile {
filename: "second.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1,1 +1,2 @@\n line1\n+line2".to_string()),
viewed: false,
},
],
};
app.review_comments = Some(vec![
crate::github::comment::ReviewComment {
id: 1,
path: "second.rs".to_string(),
line: Some(2),
body: "check this".to_string(),
user: crate::github::User {
login: "r".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
app.selected_comment = 0;
app.jump_to_comment();
assert_eq!(app.selected_file, 1); assert_eq!(app.state, AppState::DiffView);
}
#[tokio::test]
async fn test_handle_pr_list_input_quit() {
let mut app = App::new_for_test();
app.state = AppState::PullRequestList;
app.pr_list_loading = false;
app.pr_list = Some(vec![]);
app.handle_pr_list_input(make_key(KeyCode::Char('q')))
.await
.unwrap();
assert!(app.should_quit);
}
#[tokio::test]
async fn test_handle_pr_list_input_loading_blocks() {
use crate::github::PullRequestSummary;
let mut app = App::new_for_test();
app.state = AppState::PullRequestList;
app.pr_list_loading = true;
app.pr_list = Some(vec![
PullRequestSummary {
number: 1,
title: "PR 1".to_string(),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
app.selected_pr = 0;
app.handle_pr_list_input(make_key(KeyCode::Char('j')))
.await
.unwrap();
assert_eq!(app.selected_pr, 0); }
#[tokio::test]
async fn test_handle_pr_list_input_move_down() {
use crate::github::PullRequestSummary;
let mut app = App::new_for_test();
app.state = AppState::PullRequestList;
app.pr_list_loading = false;
app.pr_list = Some(vec![
PullRequestSummary {
number: 1,
title: "PR 1".to_string(),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
PullRequestSummary {
number: 2,
title: "PR 2".to_string(),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
app.selected_pr = 0;
app.handle_pr_list_input(make_key(KeyCode::Char('j')))
.await
.unwrap();
assert_eq!(app.selected_pr, 1);
}
#[tokio::test]
async fn test_handle_pr_list_input_move_up() {
use crate::github::PullRequestSummary;
let mut app = App::new_for_test();
app.state = AppState::PullRequestList;
app.pr_list_loading = false;
app.pr_list = Some(vec![
PullRequestSummary {
number: 1,
title: "PR 1".to_string(),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
PullRequestSummary {
number: 2,
title: "PR 2".to_string(),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
},
]);
app.selected_pr = 1;
app.handle_pr_list_input(make_key(KeyCode::Char('k')))
.await
.unwrap();
assert_eq!(app.selected_pr, 0);
}
#[tokio::test]
async fn test_handle_pr_list_input_jump_to_last() {
use crate::github::PullRequestSummary;
let mut app = App::new_for_test();
app.state = AppState::PullRequestList;
app.pr_list_loading = false;
app.pr_list = Some(
(0..10)
.map(|i| PullRequestSummary {
number: i,
title: format!("PR {}", i),
state: "open".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "2024-01-01T00:00:00Z".to_string(),
})
.collect(),
);
app.selected_pr = 0;
app.handle_pr_list_input(make_key(KeyCode::Char('G')))
.await
.unwrap();
assert_eq!(app.selected_pr, 9);
}
#[tokio::test]
async fn test_reload_pr_list_resets_state() {
let mut app = App::new_for_test();
app.selected_pr = 5;
app.pr_list_scroll_offset = 10;
let filter = crate::filter::ListFilter::new();
app.pr_list_filter = Some(filter);
app.reload_pr_list();
assert_eq!(app.selected_pr, 0);
assert_eq!(app.pr_list_scroll_offset, 0);
assert!(app.pr_list_loading);
assert!(!app.pr_list_has_more);
assert!(app.pr_list_filter.is_none());
}
#[test]
fn test_load_more_prs_skips_when_loading() {
let mut app = App::new_for_test();
app.pr_list_loading = true;
let prev_receiver = app.pr_list_receiver.is_some();
app.load_more_prs();
assert_eq!(app.pr_list_receiver.is_some(), prev_receiver);
}
#[test]
fn test_select_pr_cache_miss_sets_loading() {
let mut app = App::new_for_test();
let (retry_tx, _retry_rx) = mpsc::channel::<RefreshRequest>(4);
let (_data_tx, data_rx) = mpsc::channel(2);
app.retry_sender = Some(retry_tx);
app.data_receiver = Some((0, data_rx));
app.select_pr(42);
assert_eq!(app.pr_number, Some(42));
assert_eq!(app.state, AppState::FileList);
assert!(matches!(app.data_state, DataState::Loading));
}
#[tokio::test]
async fn test_poll_diff_cache_accepts_valid() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line".to_string()),
viewed: false,
}],
};
app.selected_file = 0;
app.diff_line_count = 2;
let (tx, rx) = mpsc::channel(1);
app.diff_cache_receiver = Some(rx);
let cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
tx.send(cache).await.unwrap();
app.poll_diff_cache_updates();
assert!(app.diff_cache.is_some());
}
#[tokio::test]
async fn test_poll_diff_cache_rejects_stale_file() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![
ChangedFile {
filename: "a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line".to_string()),
viewed: false,
},
ChangedFile {
filename: "b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line2".to_string()),
viewed: false,
},
],
};
app.selected_file = 1;
let (tx, rx) = mpsc::channel(1);
app.diff_cache_receiver = Some(rx);
let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
cache.file_index = 0;
tx.send(cache).await.unwrap();
app.poll_diff_cache_updates();
if let Some(ref c) = app.diff_cache {
assert_ne!(c.file_index, 0, "stale cache should not be applied");
}
}
#[tokio::test]
async fn test_poll_prefetch_stores_cache() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![
ChangedFile {
filename: "a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line".to_string()),
viewed: false,
},
ChangedFile {
filename: "b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line2".to_string()),
viewed: false,
},
],
};
app.selected_file = 0;
let (tx, rx) = mpsc::channel(2);
app.prefetch_receiver = Some(rx);
let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line2", 4);
cache.file_index = 1;
cache.highlighted = true;
tx.send(cache).await.unwrap();
app.poll_prefetch_updates();
assert!(app.highlighted_cache_store.contains_key(&1));
}
#[tokio::test]
async fn test_poll_prefetch_skips_current_file() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "a.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+line".to_string()),
viewed: false,
}],
};
app.selected_file = 0;
let mut existing_cache =
crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
existing_cache.file_index = 0;
existing_cache.highlighted = true;
app.diff_cache = Some(existing_cache);
let (tx, rx) = mpsc::channel(2);
app.prefetch_receiver = Some(rx);
let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
cache.file_index = 0; cache.highlighted = true;
tx.send(cache).await.unwrap();
app.poll_prefetch_updates();
assert!(!app.highlighted_cache_store.contains_key(&0));
}
#[tokio::test]
async fn test_poll_comment_submit_success() {
use crate::loader::CommentSubmitResult;
let mut app = App::new_for_test();
app.pr_number = Some(1);
app.comment_submitting = true;
let (tx, rx) = mpsc::channel(1);
app.comment_submit_receiver = Some((1, rx));
tx.send(CommentSubmitResult::Success).await.unwrap();
app.poll_comment_submit_updates();
assert!(!app.comment_submitting);
let (success, _) = app.submission_result.unwrap();
assert!(success);
}
#[tokio::test]
async fn test_poll_comment_submit_failure() {
use crate::loader::CommentSubmitResult;
let mut app = App::new_for_test();
app.pr_number = Some(1);
app.comment_submitting = true;
let (tx, rx) = mpsc::channel(1);
app.comment_submit_receiver = Some((1, rx));
tx.send(CommentSubmitResult::Error("network error".to_string()))
.await
.unwrap();
app.poll_comment_submit_updates();
assert!(!app.comment_submitting);
let (success, msg) = app.submission_result.unwrap();
assert!(!success);
assert!(msg.contains("network error"));
}
#[tokio::test]
async fn test_poll_mark_viewed_success() {
let mut app = App::new_for_test();
app.pr_number = Some(1);
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
let (tx, rx) = mpsc::channel(1);
app.mark_viewed_receiver = Some((1, rx));
tx.send(MarkViewedResult::Completed {
marked_paths: vec!["test.rs".to_string()],
total_targets: 1,
error: None,
set_viewed: true,
})
.await
.unwrap();
app.poll_mark_viewed_updates();
if let DataState::Loaded { files, .. } = &app.data_state {
assert!(files[0].viewed);
}
let (success, _) = app.submission_result.unwrap();
assert!(success);
}
#[tokio::test]
async fn test_poll_mark_viewed_error() {
let mut app = App::new_for_test();
app.pr_number = Some(1);
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: None,
viewed: false,
}],
};
let (tx, rx) = mpsc::channel(1);
app.mark_viewed_receiver = Some((1, rx));
tx.send(MarkViewedResult::Completed {
marked_paths: vec![],
total_targets: 1,
error: Some("API error".to_string()),
set_viewed: true,
})
.await
.unwrap();
app.poll_mark_viewed_updates();
let (success, msg) = app.submission_result.unwrap();
assert!(!success);
assert!(msg.contains("API error"));
}
#[tokio::test]
async fn test_poll_comment_updates_cross_pr_discards() {
let mut app = App::new_for_test();
app.pr_number = Some(2);
let (tx, rx) = mpsc::channel(1);
app.comment_receiver = Some((1, rx)); app.comments_loading = true;
tx.send(Ok(vec![])).await.unwrap();
app.poll_comment_updates();
assert!(app.comments_loading);
assert!(app.review_comments.is_none());
}
#[tokio::test]
async fn test_poll_discussion_comment_cross_pr_discards() {
let mut app = App::new_for_test();
app.pr_number = Some(2);
let (tx, rx) = mpsc::channel(1);
app.discussion_comment_receiver = Some((1, rx)); app.discussion_comments_loading = true;
tx.send(Ok(vec![])).await.unwrap();
app.poll_discussion_comment_updates();
assert!(app.discussion_comments_loading);
assert!(app.discussion_comments.is_none());
}
#[test]
fn test_help_tab_switch_with_bracket_keys() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.state = AppState::Help;
assert_eq!(app.help_tab, HelpTab::Keybindings);
app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
assert_eq!(app.help_tab, HelpTab::Config);
app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
assert_eq!(app.help_tab, HelpTab::Keybindings);
app.apply_help_scroll(make_key(KeyCode::Char('[')), 30);
assert_eq!(app.help_tab, HelpTab::Config);
app.apply_help_scroll(make_key(KeyCode::Char('[')), 30);
assert_eq!(app.help_tab, HelpTab::Keybindings);
}
#[test]
fn test_help_tab_independent_scroll_offsets() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.state = AppState::Help;
app.help_tab = HelpTab::Keybindings;
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
assert_eq!(app.help_scroll_offset, 3);
assert_eq!(app.config_scroll_offset, 0);
app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
assert_eq!(app.help_tab, HelpTab::Config);
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
assert_eq!(app.config_scroll_offset, 1);
assert_eq!(app.help_scroll_offset, 3);
app.apply_help_scroll(make_key(KeyCode::Char('[')), 30);
assert_eq!(app.help_tab, HelpTab::Keybindings);
assert_eq!(app.help_scroll_offset, 3);
}
#[test]
fn test_help_tab_switch_does_not_scroll() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.state = AppState::Help;
app.help_scroll_offset = 5;
app.config_scroll_offset = 10;
app.apply_help_scroll(make_key(KeyCode::Char(']')), 30);
assert_eq!(app.help_scroll_offset, 5);
assert_eq!(app.config_scroll_offset, 10);
}
#[test]
fn test_help_reopen_resets_scroll_but_preserves_tab() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.state = AppState::Help;
app.help_tab = HelpTab::Config;
app.config_scroll_offset = 5;
app.help_scroll_offset = 10;
app.apply_help_scroll(make_key(KeyCode::Char('q')), 30);
assert_ne!(app.state, AppState::Help);
app.previous_state = AppState::FileList;
app.state = AppState::Help;
app.help_scroll_offset = 0;
app.config_scroll_offset = 0;
assert_eq!(app.help_tab, HelpTab::Config);
assert_eq!(app.help_scroll_offset, 0);
assert_eq!(app.config_scroll_offset, 0);
}
#[test]
fn test_config_tab_scroll_with_jk() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.state = AppState::Help;
app.help_tab = HelpTab::Config;
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
assert_eq!(app.config_scroll_offset, 1);
app.apply_help_scroll(make_key(KeyCode::Char('j')), 30);
assert_eq!(app.config_scroll_offset, 2);
app.apply_help_scroll(make_key(KeyCode::Char('k')), 30);
assert_eq!(app.config_scroll_offset, 1);
}
#[test]
fn test_open_pr_description_state_transition() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: Some("# Hello\nWorld".to_string()),
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![],
};
app.state = AppState::FileList;
app.open_pr_description();
assert_eq!(app.state, AppState::PrDescription);
assert_eq!(app.previous_state, AppState::FileList);
assert_eq!(app.pr_description_scroll_offset, 0);
assert!(app.pr_description_cache.is_some());
}
#[test]
fn test_open_pr_description_from_split_view() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: Some("description body".to_string()),
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![],
};
app.state = AppState::SplitViewFileList;
app.open_pr_description();
assert_eq!(app.state, AppState::PrDescription);
assert_eq!(app.previous_state, AppState::SplitViewFileList);
}
#[test]
fn test_open_pr_description_body_none() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![],
};
app.open_pr_description();
assert_eq!(app.state, AppState::PrDescription);
assert!(app.pr_description_cache.is_none());
}
#[test]
fn test_open_pr_description_body_empty() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: Some("".to_string()),
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![],
};
app.open_pr_description();
assert_eq!(app.state, AppState::PrDescription);
assert!(app.pr_description_cache.is_none());
}
#[test]
fn test_toggle_markdown_rich_clears_pr_description_cache() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: Some("# Title\nBody".to_string()),
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![],
};
app.open_pr_description();
assert!(app.pr_description_cache.is_some());
app.toggle_markdown_rich();
assert!(app.pr_description_cache.is_none());
}
#[test]
fn test_pr_description_cache_reuse() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: Some("Same body".to_string()),
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc123".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def456".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![],
};
app.open_pr_description();
assert!(app.pr_description_cache.is_some());
let first_hash = app.pr_description_cache.as_ref().unwrap().patch_hash;
app.state = AppState::FileList;
app.open_pr_description();
assert!(app.pr_description_cache.is_some());
assert_eq!(
app.pr_description_cache.as_ref().unwrap().patch_hash,
first_hash
);
}
#[test]
fn test_ensure_diff_cache_non_md_ignores_markdown_rich_mismatch() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
viewed: false,
}],
};
app.selected_file = 0;
app.markdown_rich = false;
let mut cache =
crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
cache.file_index = 0;
cache.highlighted = true;
cache.markdown_rich = true; app.diff_cache = Some(cache);
app.ensure_diff_cache();
assert!(
app.diff_cache.as_ref().is_some_and(|c| c.highlighted),
"non-md file: highlighted cache should be preserved despite markdown_rich mismatch"
);
}
#[test]
fn test_ensure_diff_cache_store_non_md_ignores_markdown_rich_mismatch() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
viewed: false,
}],
};
app.selected_file = 0;
app.markdown_rich = false;
app.diff_cache = None;
let mut store_cache =
crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
store_cache.file_index = 0;
store_cache.highlighted = true;
store_cache.markdown_rich = true;
app.highlighted_cache_store.insert(0, store_cache);
app.ensure_diff_cache();
assert!(
app.diff_cache.as_ref().is_some_and(|c| c.highlighted),
"non-md file: store cache should be restored despite markdown_rich mismatch"
);
}
#[tokio::test]
async fn test_ensure_diff_cache_md_invalidates_on_markdown_rich_mismatch() {
let mut app = App::new_for_test();
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "README.md".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+# Hello".to_string()),
viewed: false,
}],
};
app.selected_file = 0;
app.markdown_rich = true;
let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+# Hello", 4);
cache.file_index = 0;
cache.highlighted = true;
cache.markdown_rich = false; app.diff_cache = Some(cache);
app.ensure_diff_cache();
assert!(
app.diff_cache
.as_ref()
.is_some_and(|c| !c.highlighted),
"md file: cache should be rebuilt (plain) on markdown_rich mismatch"
);
}
#[tokio::test]
async fn test_pr_description_toggle_rich_preserves_prefetch_and_store() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: Some("# Description\nBody text".to_string()),
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![ChangedFile {
filename: "main.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+fn main(){}".to_string()),
viewed: false,
}],
};
let mut rs_cache =
crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+fn main(){}", 4);
rs_cache.file_index = 0;
rs_cache.highlighted = true;
app.highlighted_cache_store.insert(0, rs_cache);
let (_tx, rx) = tokio::sync::mpsc::channel(1);
app.prefetch_receiver = Some(rx);
app.open_pr_description();
assert_eq!(app.state, AppState::PrDescription);
app.markdown_rich = !app.markdown_rich;
app.pr_description_cache = None;
app.rebuild_pr_description_cache();
assert!(
app.prefetch_receiver.is_some(),
"prefetch_receiver should be preserved after toggling rich in PR description view"
);
assert!(
app.highlighted_cache_store.contains_key(&0),
"highlighted_cache_store should be preserved after toggling rich in PR description view"
);
assert!(
app.pr_description_cache.is_some(),
"pr_description_cache should be rebuilt"
);
}
#[test]
fn test_rebuild_pr_description_cache_preserves_scroll() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
let pr = Box::new(PullRequest {
number: 1,
node_id: None,
title: "Test PR".to_string(),
body: Some("line1\nline2\nline3\nline4\nline5".to_string()),
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feature".to_string(),
sha: "abc".to_string(),
},
base: crate::github::Branch {
ref_name: "main".to_string(),
sha: "def".to_string(),
},
user: crate::github::User {
login: "user".to_string(),
},
updated_at: "2024-01-01T00:00:00Z".to_string(),
});
app.data_state = DataState::Loaded {
pr,
files: vec![],
};
app.open_pr_description();
assert_eq!(app.pr_description_scroll_offset, 0);
app.pr_description_scroll_offset = 3;
app.markdown_rich = !app.markdown_rich;
app.pr_description_cache = None;
app.rebuild_pr_description_cache();
assert_eq!(
app.pr_description_scroll_offset, 3,
"rebuild_pr_description_cache should not reset scroll offset"
);
assert!(app.pr_description_cache.is_some());
}