use super::types::{MarkViewedResult, PendingApproveChoice};
use super::*;
use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use lasso::Rodeo;
use serial_test::serial;
use tempfile::tempdir;
use crate::cache::{PrCacheKey, PrData};
use crate::github::comment::ReviewComment;
use crate::github::{ChangedFile, PrCommit, PullRequest};
use crate::loader::DataLoadResult;
struct ScopedCacheHome {
old: Option<std::ffi::OsString>,
}
impl ScopedCacheHome {
fn new(dir: &std::path::Path) -> Self {
let old = std::env::var_os("XDG_CACHE_HOME");
unsafe { std::env::set_var("XDG_CACHE_HOME", dir) };
Self { old }
}
}
impl Drop for ScopedCacheHome {
fn drop(&mut self) {
match self.old.take() {
Some(v) => unsafe { std::env::set_var("XDG_CACHE_HOME", v) },
None => unsafe { std::env::remove_var("XDG_CACHE_HOME") },
}
}
}
#[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.cmt.file_comment_positions = vec![
CommentPosition {
diff_line_index: 5,
comment_index: 0,
},
CommentPosition {
diff_line_index: 10,
comment_index: 1,
},
];
app.diff_scroll.selected_line = 5;
assert!(app.has_comment_at_current_line());
app.diff_scroll.selected_line = 10;
assert!(app.has_comment_at_current_line());
app.diff_scroll.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.cmt.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.diff_scroll.selected_line = 5;
let indices = app.get_comment_indices_at_current_line();
assert_eq!(indices, vec![0, 2]);
app.diff_scroll.selected_line = 10;
let indices = app.get_comment_indices_at_current_line();
assert_eq!(indices, vec![1]);
app.diff_scroll.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.cmt.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.diff_scroll.selected_line = 0;
app.jump_to_next_comment();
assert_eq!(app.diff_scroll.selected_line, 5);
app.jump_to_next_comment();
assert_eq!(app.diff_scroll.selected_line, 10);
app.jump_to_next_comment();
assert_eq!(app.diff_scroll.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.cmt.file_comment_positions = vec![CommentPosition {
diff_line_index: 5,
comment_index: 0,
}];
app.diff_scroll.selected_line = 5;
app.jump_to_next_comment();
assert_eq!(app.diff_scroll.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.cmt.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.diff_scroll.selected_line = 20;
app.jump_to_prev_comment();
assert_eq!(app.diff_scroll.selected_line, 15);
app.jump_to_prev_comment();
assert_eq!(app.diff_scroll.selected_line, 10);
app.jump_to_prev_comment();
assert_eq!(app.diff_scroll.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.cmt.file_comment_positions = vec![CommentPosition {
diff_line_index: 5,
comment_index: 0,
}];
app.diff_scroll.selected_line = 5;
app.jump_to_prev_comment();
assert_eq!(app.diff_scroll.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.cmt.file_comment_positions = vec![];
app.diff_scroll.selected_line = 10;
app.jump_to_next_comment();
assert_eq!(app.diff_scroll.selected_line, 10);
app.jump_to_prev_comment();
assert_eq!(app.diff_scroll.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.cmt.comment_receiver = Some((1, comment_rx));
let (_disc_tx, disc_rx) = mpsc::channel(1);
app.cmt.discussion_comment_receiver = Some((1, disc_rx));
let (_submit_tx, submit_rx) = mpsc::channel(1);
app.cmt.comment_submit_receiver = Some((1, submit_rx));
let (_mark_tx, mark_rx) = mpsc::channel(1);
app.mark_viewed_receiver = Some((1, mark_rx));
app.cmt.comment_submitting = true;
app.cmt.comments_loading = true;
app.cmt.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.cmt.comment_receiver.is_none());
assert!(app.cmt.discussion_comment_receiver.is_none());
assert!(app.cmt.comment_submit_receiver.is_none());
assert!(app.mark_viewed_receiver.is_none());
assert!(!app.diff_store.has_highlight_rx());
assert!(!app.diff_store.has_prefetch_rx());
assert!(!app.cmt.comment_submitting);
assert!(!app.cmt.comments_loading);
assert!(!app.cmt.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_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);
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.cmt.comment_receiver = Some((1, comment_rx));
app.cmt.comments_loading = true;
app.pr_number = Some(2);
comment_tx.send(Ok(vec![])).await.unwrap();
app.poll_comment_updates();
assert!(app.cmt.comment_receiver.is_none());
assert!(app.cmt.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.diff_scroll.selected_line = 10;
app.diff_scroll.scroll_offset = 5;
app.diff_store.current = 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_store.current.as_ref().map(|c| c.file_index),
Some(1),
"diff_cache should be rebuilt for the new selected file"
);
assert_eq!(
app.diff_scroll.selected_line, 0,
"selected_line should be reset to 0"
);
assert_eq!(
app.diff_scroll.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.cmt.review_comments = Some(vec![ReviewComment {
id: 1,
path: "file_4.rs".to_string(),
line: Some(1),
start_line: None,
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.cmt.file_comment_positions = vec![CommentPosition {
diff_line_index: 2,
comment_index: 0,
}];
app.cmt.file_comment_lines.insert(2);
app.cmt.comment_panel_open = true;
app.cmt.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.cmt.file_comment_positions.is_empty(),
"file_comment_positions should be recalculated for new file (no comments for file_1.rs)"
);
assert!(
app.cmt.file_comment_lines.is_empty(),
"file_comment_lines should be recalculated for new file"
);
assert!(
!app.cmt.comment_panel_open,
"comment_panel_open should be reset when selected_file changes"
);
assert_eq!(
app.cmt.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.diff_scroll.selected_line = 2;
app.diff_scroll.scroll_offset = 1;
app.diff_store.current = 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_store.current.is_some(),
"diff_cache should be preserved when selected_file is unchanged"
);
assert_eq!(app.diff_scroll.selected_line, 2);
assert_eq!(app.diff_scroll.scroll_offset, 1);
}
#[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.cmt.submission_result.is_some());
assert!(app.cmt.submission_result.as_ref().unwrap().1.contains("ON"));
app.toggle_auto_focus();
assert!(!app.local_auto_focus);
assert!(app
.cmt
.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
.cmt
.submission_result
.as_ref()
.unwrap()
.1
.contains("Cannot"));
}
#[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.cmt.review_comments = Some(vec![]);
app.cmt.discussion_comments = Some(vec![]);
app.diff_store.current = 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.diff_store.store.insert(1, hc);
app.back_to_pr_list();
assert!(app.pr_number.is_none());
assert!(matches!(app.data_state, DataState::Loading));
assert!(app.cmt.review_comments.is_none());
assert!(app.cmt.discussion_comments.is_none());
assert!(app.diff_store.current.is_none());
assert!(app.diff_store.store.is_empty());
assert_eq!(app.selected_file, 0);
assert_eq!(app.file_list_scroll_offset, 0);
assert_eq!(app.diff_scroll.selected_line, 0);
assert_eq!(app.diff_scroll.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.cmt.comment_receiver = Some((1, rx1));
let (_tx2, rx2) = mpsc::channel(1);
app.diff_store.set_highlight_rx(rx2);
let (_tx3, rx3) = mpsc::channel(1);
app.diff_store.set_prefetch_rx(rx3);
let (_tx4, rx4) =
mpsc::channel::<Result<Vec<crate::github::comment::DiscussionComment>, String>>(1);
app.cmt.discussion_comment_receiver = Some((1, rx4));
let (_tx5, rx5) = mpsc::channel::<crate::loader::CommentSubmitResult>(1);
app.cmt.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.cmt.comment_submitting = true;
app.cmt.comments_loading = true;
app.cmt.discussion_comments_loading = true;
app.back_to_pr_list();
assert!(app.cmt.comment_receiver.is_none());
assert!(!app.diff_store.has_highlight_rx());
assert!(!app.diff_store.has_prefetch_rx());
assert!(app.cmt.discussion_comment_receiver.is_none());
assert!(app.cmt.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.cmt.comment_submitting);
assert!(!app.cmt.comments_loading);
assert!(!app.cmt.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.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
assert_eq!(app.selected_file, 0); assert!(app
.cmt
.submission_result
.as_ref()
.unwrap()
.1
.contains("Local"));
app.toggle_local_mode();
assert!(!app.local_mode);
assert_eq!(app.pr_number, Some(42));
assert_eq!(app.state, AppState::FileList);
assert!(app.cmt.submission_result.as_ref().unwrap().1.contains("PR"));
}
#[test]
fn test_toggle_local_mode_roundtrip_preserves_pr_number() {
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);
for _ in 0..3 {
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
app.toggle_local_mode();
assert!(!app.local_mode);
assert_eq!(app.pr_number, Some(42));
}
}
#[tokio::test]
async fn test_toggle_local_mode_from_pr_list_without_selecting_pr() {
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((0, data_rx));
app.original_pr_number = None;
app.started_from_pr_list = false;
app.pr_number = None;
app.state = AppState::PullRequestList;
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
assert_eq!(app.state, AppState::FileList);
app.toggle_local_mode();
assert!(!app.local_mode);
assert_eq!(app.state, AppState::PullRequestList);
}
#[tokio::test]
async fn test_toggle_local_mode_roundtrip_from_pr_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((99, data_rx));
app.original_pr_number = None;
app.started_from_pr_list = false;
app.pr_number = Some(99);
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
app.toggle_local_mode();
assert!(!app.local_mode);
assert_eq!(app.state, AppState::PullRequestList);
}
#[tokio::test]
async fn test_toggle_local_mode_from_local_startup_with_valid_repo() {
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((0, data_rx));
app.original_pr_number = Some(0); app.started_from_pr_list = false;
app.local_mode = true;
app.pr_number = Some(0);
app.toggle_local_mode();
assert!(!app.local_mode);
assert_eq!(app.state, AppState::PullRequestList);
assert!(app.started_from_pr_list);
}
#[test]
fn test_toggle_local_mode_from_local_startup_with_dummy_repo() {
let mut app = App::new_for_test();
app.repo = "local".to_string(); app.original_pr_number = Some(0);
app.started_from_pr_list = false;
app.local_mode = true;
app.pr_number = Some(0);
app.toggle_local_mode();
assert!(app.local_mode, "should stay in local mode with dummy repo");
assert!(
app.cmt
.submission_result
.as_ref()
.unwrap()
.1
.contains("No PR"),
"should show error message"
);
}
#[tokio::test]
async fn test_toggle_local_mode_roundtrip_from_local_startup() {
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((0, data_rx));
app.original_pr_number = Some(0);
app.started_from_pr_list = false;
app.local_mode = true;
app.pr_number = Some(0);
app.toggle_local_mode();
assert!(!app.local_mode);
assert_eq!(app.state, AppState::PullRequestList);
app.toggle_local_mode();
assert!(app.local_mode);
assert_eq!(app.pr_number, Some(0));
assert_eq!(app.state, AppState::FileList);
app.toggle_local_mode();
assert!(!app.local_mode);
assert_eq!(app.state, AppState::PullRequestList);
}
#[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.cmt.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_store.current.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::<(usize, DiffCache)>(1);
app.diff_store.set_highlight_rx(rx);
let (_tx2, rx2) = tokio::sync::mpsc::channel::<(usize, DiffCache)>(1);
app.diff_store.set_prefetch_rx(rx2);
app.toggle_markdown_rich();
assert!(
!app.diff_store.has_highlight_rx(),
"diff_cache_receiver should be cleared for md file"
);
assert!(
!app.diff_store.has_prefetch_rx(),
"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;
rs_cache.markdown_rich = true; app.diff_store.store.insert(0, md_cache);
app.diff_store.store.insert(1, rs_cache);
assert_eq!(app.diff_store.store.len(), 2);
app.toggle_markdown_rich();
assert!(
!app.diff_store.store.contains_key(&0),
"md cache should be cleared"
);
assert!(
app.diff_store.store.contains_key(&1),
"rs cache should be preserved"
);
assert_eq!(app.diff_store.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_store.current = Some(rs_cache);
app.toggle_markdown_rich();
assert!(
app.diff_store.current.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.diff_scroll.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.diff_scroll.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_gg_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);
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_open_help_from_diff_view_sets_previous_state() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.open_help(AppState::DiffView);
assert_eq!(app.state, AppState::Help);
assert_eq!(app.previous_state, AppState::DiffView);
}
#[test]
fn test_open_help_from_split_view_diff_sets_previous_state() {
let config = Config::default();
let (mut app, _) = App::new_loading("owner/repo", 1, config);
app.open_help(AppState::SplitViewDiff);
assert_eq!(app.state, AppState::Help);
assert_eq!(app.previous_state, AppState::SplitViewDiff);
}
#[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_gg_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);
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.prs.pr_list = LoadState::Loaded(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.cmt.pending_approve_body = Some(String::new());
app.cmt.submission_result = Some((true, "placeholder".to_string()));
app.cmt.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.cmt.pending_approve_body.is_none());
assert!(app.cmt.submission_result.is_none());
assert!(app.cmt.submission_result_time.is_none());
}
#[test]
fn test_pending_approve_choice_esc_cancels() {
let mut app = App::new_for_test();
app.cmt.pending_approve_body = Some("some body".to_string());
let choice = app.handle_pending_approve_choice(&make_key(KeyCode::Char('q')));
assert_eq!(choice, PendingApproveChoice::Cancel);
assert!(app.cmt.pending_approve_body.is_none());
}
#[test]
fn test_pending_approve_choice_a_submits_empty_body() {
let mut app = App::new_for_test();
app.cmt.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.cmt.pending_approve_body.is_some());
}
#[test]
fn test_pending_approve_choice_a_submits_with_body() {
let mut app = App::new_for_test();
app.cmt.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.cmt.pending_approve_body.is_some());
assert_eq!(app.cmt.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::single(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::double(
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::double(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::double(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::double(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.prs.pr_list = LoadState::Loaded(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(),
status_check_rollup: vec![],
},
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(),
status_check_rollup: vec![],
},
]);
let mut filter = crate::filter::ListFilter::new();
filter.insert_char('b');
filter.insert_char('u');
filter.insert_char('g');
app.prs.pr_list_filter = Some(filter);
app.reapply_filter("pr");
let filter = app.prs.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.prs.pr_list_filter = Some(filter);
assert!(app.handle_filter_esc("pr"));
assert!(app.prs.pr_list_filter.is_none());
}
#[test]
fn test_handle_filter_esc_no_filter() {
let mut app = App::new_for_test();
app.prs.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.cmt.review_comments = Some(vec![]);
app.cmt.discussion_comments = Some(vec![]);
app.cmt.comments_loading = true;
app.cmt.discussion_comments_loading = true;
app.chk.ci_status = Some(crate::github::CiStatus::Failure);
let filter = crate::filter::ListFilter::new();
app.file_list_filter = Some(filter);
app.refresh_all();
assert!(matches!(app.data_state, DataState::Loading));
assert!(app.cmt.review_comments.is_none());
assert!(app.cmt.discussion_comments.is_none());
assert!(!app.cmt.comments_loading);
assert!(!app.cmt.discussion_comments_loading);
assert!(app.chk.ci_status.is_none());
assert!(app.chk.ci_status_receiver.is_none());
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.diff_scroll.selected_line = 2;
app.diff_scroll.scroll_offset = 5;
app.diff_scroll.line_count = 100;
app.adjust_scroll(20);
assert_eq!(app.diff_scroll.scroll_offset, 0);
}
#[test]
fn test_adjust_scroll_below_viewport() {
let mut app = App::new_for_test();
app.diff_scroll.selected_line = 30;
app.diff_scroll.scroll_offset = 5;
app.diff_scroll.line_count = 100;
app.adjust_scroll(20);
assert!(app.diff_scroll.scroll_offset > 5);
assert!(app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset);
assert!(app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + 20);
}
#[test]
fn test_adjust_scroll_within_viewport() {
let mut app = App::new_for_test();
app.diff_scroll.selected_line = 15;
app.diff_scroll.scroll_offset = 5;
app.diff_scroll.line_count = 100;
app.adjust_scroll(20);
assert!(app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset);
assert!(app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + 20);
}
#[test]
fn test_adjust_scroll_zero_visible() {
let mut app = App::new_for_test();
app.diff_scroll.selected_line = 10;
app.diff_scroll.scroll_offset = 5;
app.diff_scroll.line_count = 100;
app.adjust_scroll(0);
assert_eq!(app.diff_scroll.scroll_offset, 5);
}
#[test]
fn test_adjust_scroll_at_last_line() {
let mut app = App::new_for_test();
app.diff_scroll.line_count = 50;
app.diff_scroll.selected_line = 49; app.diff_scroll.scroll_offset = 0;
app.adjust_scroll(20);
assert!(app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset);
assert!(app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + 20);
assert_eq!(app.diff_scroll.scroll_offset, 40);
}
#[test]
fn test_adjust_scroll_single_line() {
let mut app = App::new_for_test();
app.diff_scroll.line_count = 1;
app.diff_scroll.selected_line = 0;
app.diff_scroll.scroll_offset = 0;
app.adjust_scroll(20);
assert_eq!(app.diff_scroll.scroll_offset, 0);
assert_eq!(app.diff_scroll.selected_line, 0);
}
#[test]
fn test_adjust_scroll_invariant_all_positions() {
for diff_line_count in [1usize, 5, 10, 20, 50, 100] {
for visible_lines in [1usize, 5, 10, 20, 40] {
for selected_line in 0..diff_line_count {
for initial_scroll in [
0,
selected_line.saturating_sub(visible_lines),
selected_line,
] {
let mut app = App::new_for_test();
app.diff_scroll.line_count = diff_line_count;
app.diff_scroll.selected_line = selected_line;
app.diff_scroll.scroll_offset = initial_scroll;
app.adjust_scroll(visible_lines);
assert!(
app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset,
"cursor above viewport: selected_line={}, scroll_offset={}, \
visible_lines={}, diff_line_count={}, initial_scroll={}",
app.diff_scroll.selected_line,
app.diff_scroll.scroll_offset,
visible_lines,
diff_line_count,
initial_scroll,
);
assert!(
app.diff_scroll.selected_line
< app.diff_scroll.scroll_offset + visible_lines,
"cursor below viewport: selected_line={}, scroll_offset={}, \
visible_lines={}, diff_line_count={}, initial_scroll={}",
app.diff_scroll.selected_line,
app.diff_scroll.scroll_offset,
visible_lines,
diff_line_count,
initial_scroll,
);
}
}
}
}
}
#[test]
fn test_adjust_scroll_sequential_down_no_jump() {
let diff_line_count = 100;
let visible_lines = 20;
let mut app = App::new_for_test();
app.diff_scroll.line_count = diff_line_count;
app.diff_scroll.selected_line = 0;
app.diff_scroll.scroll_offset = 0;
let mut prev_scroll = 0;
for line in 0..diff_line_count {
app.diff_scroll.selected_line = line;
app.adjust_scroll(visible_lines);
assert!(
app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset
&& app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + visible_lines,
"line={}: scroll_offset={}, visible_lines={}",
line,
app.diff_scroll.scroll_offset,
visible_lines,
);
assert!(
app.diff_scroll.scroll_offset <= prev_scroll + 1,
"scroll jumped at line={}: prev={}, now={}",
line,
prev_scroll,
app.diff_scroll.scroll_offset,
);
prev_scroll = app.diff_scroll.scroll_offset;
}
}
#[test]
fn test_adjust_scroll_sequential_up_no_jump() {
let diff_line_count = 100;
let visible_lines = 20;
let mut app = App::new_for_test();
app.diff_scroll.line_count = diff_line_count;
app.diff_scroll.selected_line = diff_line_count - 1;
app.diff_scroll.scroll_offset = diff_line_count.saturating_sub(visible_lines);
app.adjust_scroll(visible_lines);
let mut prev_scroll = app.diff_scroll.scroll_offset;
for line in (0..diff_line_count).rev() {
app.diff_scroll.selected_line = line;
app.adjust_scroll(visible_lines);
assert!(
app.diff_scroll.selected_line >= app.diff_scroll.scroll_offset
&& app.diff_scroll.selected_line < app.diff_scroll.scroll_offset + visible_lines,
"line={}: scroll_offset={}, visible_lines={}",
line,
app.diff_scroll.scroll_offset,
visible_lines,
);
assert!(
prev_scroll <= app.diff_scroll.scroll_offset + 1,
"scroll jumped at line={}: prev={}, now={}",
line,
prev_scroll,
app.diff_scroll.scroll_offset,
);
prev_scroll = app.diff_scroll.scroll_offset;
}
}
#[test]
fn test_adjust_scroll_file_shorter_than_viewport() {
let visible_lines = 40;
for diff_line_count in [1, 5, 10, 39] {
for line in 0..diff_line_count {
let mut app = App::new_for_test();
app.diff_scroll.line_count = diff_line_count;
app.diff_scroll.selected_line = line;
app.diff_scroll.scroll_offset = 0;
app.adjust_scroll(visible_lines);
assert_eq!(
app.diff_scroll.scroll_offset, 0,
"short file: diff_line_count={}, selected_line={}",
diff_line_count, line,
);
}
}
}
#[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.pending_rally_seed_review = Some(crate::ai::ReviewerOutput {
action: crate::ai::ReviewAction::RequestChanges,
summary: "seeded".to_string(),
comments: vec![],
blocking_issues: vec![],
});
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());
assert!(app.pending_rally_seed_review.is_none());
}
#[test]
#[serial]
fn test_build_seed_review_from_local_comments_returns_none_without_comments() {
let tempdir = tempdir().unwrap();
let workdir = tempdir.path().join("worktree");
std::fs::create_dir_all(&workdir).unwrap();
let mut app = App::new_for_test();
app.local_mode = true;
app.repo = "owner/repo".to_string();
app.working_dir = Some(workdir.to_string_lossy().to_string());
let _cache_home = ScopedCacheHome::new(tempdir.path());
let seed = app.build_seed_review_from_local_comments().unwrap();
assert!(seed.is_none());
}
#[test]
#[serial]
fn test_build_seed_review_from_local_comments_uses_persisted_comments() {
let tempdir = tempdir().unwrap();
let workdir = tempdir.path().join("worktree");
std::fs::create_dir_all(&workdir).unwrap();
let _cache_home = ScopedCacheHome::new(tempdir.path());
crate::cache::save_local_review_comments(
"owner/repo",
Some(workdir.to_string_lossy().as_ref()),
&[crate::cache::LocalReviewComment::new(
crate::github::comment::ReviewComment {
id: 1,
path: "src/main.rs".to_string(),
line: Some(12),
start_line: None,
body: "Please simplify this branch.".to_string(),
user: crate::github::User {
login: "local".to_string(),
},
created_at: "2026-03-24T00:00:00Z".to_string(),
},
)],
)
.unwrap();
let mut app = App::new_for_test();
app.local_mode = true;
app.repo = "owner/repo".to_string();
app.working_dir = Some(workdir.to_string_lossy().to_string());
let seed = app
.build_seed_review_from_local_comments()
.unwrap()
.unwrap();
assert_eq!(seed.action, crate::ai::ReviewAction::RequestChanges);
assert_eq!(seed.comments.len(), 1);
assert_eq!(seed.comments[0].path, "src/main.rs");
assert_eq!(seed.comments[0].line, 12);
assert_eq!(seed.comments[0].body, "Please simplify this branch.");
assert_eq!(
seed.comments[0].severity,
crate::ai::adapter::CommentSeverity::Major
);
assert!(seed.summary.contains("1 local comment"));
}
#[test]
#[serial]
fn test_build_seed_review_from_local_comments_skips_resolved_comments() {
let tempdir = tempdir().unwrap();
let workdir = tempdir.path().join("worktree");
std::fs::create_dir_all(&workdir).unwrap();
let _cache_home = ScopedCacheHome::new(tempdir.path());
crate::cache::save_local_review_comments(
"owner/repo",
Some(workdir.to_string_lossy().as_ref()),
&[crate::cache::LocalReviewComment::with_meta(
crate::github::comment::ReviewComment {
id: 1,
path: "src/main.rs".to_string(),
line: Some(12),
start_line: None,
body: "Already handled.".to_string(),
user: crate::github::User {
login: "local".to_string(),
},
created_at: "2026-03-24T00:00:00Z".to_string(),
},
crate::cache::LocalCommentMeta {
is_resolved: true,
resolved_at: Some("2026-03-24T01:00:00Z".to_string()),
},
)],
)
.unwrap();
let mut app = App::new_for_test();
app.local_mode = true;
app.repo = "owner/repo".to_string();
app.working_dir = Some(workdir.to_string_lossy().to_string());
let seed = app.build_seed_review_from_local_comments().unwrap();
assert!(seed.is_none());
}
#[test]
#[serial]
fn test_start_ai_rally_stashes_seed_review_while_waiting_for_confirmation() {
let tempdir = tempdir().unwrap();
let workdir = tempdir.path().join("worktree");
std::fs::create_dir_all(&workdir).unwrap();
let _cache_home = ScopedCacheHome::new(tempdir.path());
crate::cache::save_local_review_comments(
"owner/repo",
Some(workdir.to_string_lossy().as_ref()),
&[crate::cache::LocalReviewComment::new(
crate::github::comment::ReviewComment {
id: 1,
path: "src/main.rs".to_string(),
line: Some(7),
start_line: None,
body: "Handle the error explicitly.".to_string(),
user: crate::github::User {
login: "local".to_string(),
},
created_at: "2026-03-24T00:00:00Z".to_string(),
},
)],
)
.unwrap();
let mut app = App::new_for_test();
app.local_mode = true;
app.repo = "owner/repo".to_string();
app.pr_number = Some(0);
app.working_dir = Some(workdir.to_string_lossy().to_string());
app.config.local_overrides.insert("ai.reviewer".to_string());
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: Some("@@ -1 +1 @@\n+fn main() {}".to_string()),
viewed: false,
}],
};
app.start_ai_rally();
assert_eq!(app.state, AppState::AiRally);
assert!(app.pending_rally_context.is_some());
assert!(app.pending_rally_prompt_loader.is_some());
assert!(app.pending_rally_seed_review.is_some());
assert_eq!(
app.pending_rally_seed_review
.as_ref()
.unwrap()
.comments
.len(),
1
);
}
#[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.diff_scroll.selected_line = 10;
app.diff_scroll.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.diff_scroll.selected_line = i;
app.diff_scroll.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.diff_scroll.selected_line = 99;
app.diff_scroll.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,6 +1,6 @@\n line1\n line2\n line3\n line4\n line5\n+line6".to_string(),
),
viewed: false,
},
ChangedFile {
filename: "b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some(
"@@ -1,11 +1,11 @@\n l1\n l2\n l3\n l4\n l5\n l6\n l7\n l8\n l9\n l10\n+l11"
.to_string(),
),
viewed: false,
},
],
};
app.selected_file = 0;
app.diff_scroll.selected_line = 5;
app.diff_scroll.scroll_offset = 2;
app.push_jump_location();
app.selected_file = 1;
app.diff_scroll.selected_line = 10;
app.diff_scroll.scroll_offset = 8;
app.jump_back();
assert_eq!(app.selected_file, 0);
assert_eq!(app.diff_scroll.selected_line, 5);
assert_eq!(app.diff_scroll.scroll_offset, 2);
}
#[test]
fn test_jump_back_empty_stack() {
let mut app = App::new_for_test();
app.selected_file = 3;
app.diff_scroll.selected_line = 7;
app.diff_scroll.scroll_offset = 4;
app.jump_back();
assert_eq!(app.selected_file, 3);
assert_eq!(app.diff_scroll.selected_line, 7);
assert_eq!(app.diff_scroll.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.diff_scroll.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.diff_scroll.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);
}
#[test]
fn test_enter_comment_input_works_in_local_mode() {
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = make_app_with_patch(patch);
app.local_mode = true;
app.pr_number = Some(0);
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(patch.to_string()),
viewed: false,
}],
};
app.diff_scroll.selected_line = 2;
app.state = AppState::DiffView;
app.enter_comment_input();
assert!(matches!(app.input_mode, Some(InputMode::Comment(_))));
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_open_comment_list_transitions_state_in_local_mode() {
let mut app = App::new_for_test();
app.local_mode = true;
app.pr_number = Some(0);
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);
assert_eq!(app.cmt.comment_tab, CommentTab::Review);
}
#[test]
#[serial]
fn test_submit_local_comment_persists_and_loads() {
let tempdir = tempdir().unwrap();
let workdir = tempdir.path().join("worktree");
std::fs::create_dir_all(&workdir).unwrap();
let _cache_home = ScopedCacheHome::new(tempdir.path());
let patch = "@@ -1,3 +1,4 @@\n context\n+added\n more context";
let mut app = App::new_for_test();
app.repo = "owner/repo".to_string();
app.local_mode = true;
app.pr_number = Some(0);
app.working_dir = Some(workdir.to_string_lossy().to_string());
app.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "src/test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some(patch.to_string()),
viewed: false,
}],
};
app.diff_scroll.selected_line = 2;
app.state = AppState::DiffView;
let cache_key = PrCacheKey {
repo: app.repo.clone(),
pr_number: 0,
};
app.session_cache.put_pr_data(
cache_key,
PrData {
pr: Box::new(make_local_pr()),
files: app.files().to_vec(),
pr_updated_at: "".to_string(),
},
);
app.enter_comment_input();
let ctx = match app.input_mode.clone() {
Some(InputMode::Comment(ctx)) => ctx,
other => panic!("expected comment input mode, got {:?}", other),
};
app.submit_comment(ctx, "local note".to_string());
assert_eq!(
app.cmt
.review_comments
.as_ref()
.map(|c: &Vec<crate::github::comment::ReviewComment>| c.len()),
Some(1)
);
assert_eq!(
app.cmt.review_comments.as_ref().unwrap()[0].body,
"local note".to_string()
);
let mut reloaded = App::new_for_test();
reloaded.repo = "owner/repo".to_string();
reloaded.local_mode = true;
reloaded.pr_number = Some(0);
reloaded.working_dir = Some(workdir.to_string_lossy().to_string());
reloaded.data_state = DataState::Loaded {
pr: Box::new(make_local_pr()),
files: vec![ChangedFile {
filename: "src/test.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some(patch.to_string()),
viewed: false,
}],
};
reloaded.load_review_comments();
let stored = reloaded.cmt.review_comments.unwrap();
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].path, "src/test.rs");
assert_eq!(stored[0].line, Some(2));
assert_eq!(stored[0].body, "local note");
let path = crate::cache::local_review_comments_path(
"owner/repo",
Some(workdir.to_string_lossy().as_ref()),
)
.unwrap();
let _ = std::fs::remove_file(path);
}
#[test]
#[serial]
fn test_load_review_comments_local_mode_refreshes_meta_from_disk() {
let tempdir = tempdir().unwrap();
let workdir = tempdir.path().join("worktree");
std::fs::create_dir_all(&workdir).unwrap();
let _cache_home = ScopedCacheHome::new(tempdir.path());
let initial = vec![crate::cache::LocalReviewComment::new(
crate::github::comment::ReviewComment {
id: 7,
path: "src/main.rs".to_string(),
line: Some(3),
start_line: None,
body: "needs follow-up".to_string(),
user: crate::github::User {
login: "local".to_string(),
},
created_at: "2026-03-24T00:00:00Z".to_string(),
},
)];
crate::cache::save_local_review_comments(
"owner/repo",
Some(workdir.to_string_lossy().as_ref()),
&initial,
)
.unwrap();
let mut app = App::new_for_test();
app.repo = "owner/repo".to_string();
app.local_mode = true;
app.pr_number = Some(0);
app.working_dir = Some(workdir.to_string_lossy().to_string());
app.load_review_comments();
assert!(app.cmt.local_comment_meta.is_empty());
let resolved = vec![crate::cache::LocalReviewComment::with_meta(
initial[0].comment.clone(),
crate::cache::LocalCommentMeta {
is_resolved: true,
resolved_at: Some("2026-03-24T01:00:00Z".to_string()),
},
)];
crate::cache::save_local_review_comments(
"owner/repo",
Some(workdir.to_string_lossy().as_ref()),
&resolved,
)
.unwrap();
app.load_review_comments();
let meta = app
.cmt
.local_comment_meta
.get(&7)
.expect("local meta should reflect disk state");
assert!(meta.is_resolved);
assert_eq!(meta.resolved_at.as_deref(), Some("2026-03-24T01:00:00Z"));
let path = crate::cache::local_review_comments_path(
"owner/repo",
Some(workdir.to_string_lossy().as_ref()),
)
.unwrap();
let _ = std::fs::remove_file(path);
}
#[test]
fn test_toggle_local_mode_clears_local_comment_meta_on_exit() {
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((0, data_rx));
app.original_pr_number = Some(42);
app.pr_number = Some(0);
app.local_mode = true;
app.cmt.local_comment_meta.insert(
99,
crate::cache::LocalCommentMeta {
is_resolved: true,
resolved_at: Some("2026-03-25T00:00:00Z".to_string()),
},
);
app.toggle_local_mode();
assert!(!app.local_mode);
assert!(app.cmt.local_comment_meta.is_empty());
}
#[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.cmt.review_comments = Some(vec![]);
app.update_file_comment_positions();
assert!(app.cmt.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.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
id: 1,
path: "test.rs".to_string(),
line: Some(1),
start_line: None,
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.cmt.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.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
id: 1,
path: "other_file.rs".to_string(), line: Some(1),
start_line: None,
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.cmt.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.cmt.file_comment_positions = vec![];
app.cmt.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.diff_scroll.selected_line = 1;
app.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
id: 42,
path: "test.rs".to_string(),
line: Some(1),
start_line: None,
body: "original comment".to_string(),
user: crate::github::User {
login: "reviewer".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
}]);
app.cmt.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.cmt.discussion_comment_detail_mode = true;
app.cmt.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.cmt.discussion_comment_detail_scroll, 1);
}
#[test]
fn test_handle_discussion_detail_scroll_k() {
let mut app = App::new_for_test();
app.cmt.discussion_comment_detail_mode = true;
app.cmt.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.cmt.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.cmt.review_comments = Some(vec![crate::github::comment::ReviewComment {
id: 1,
path: "second.rs".to_string(),
line: Some(2),
start_line: None,
body: "check this".to_string(),
user: crate::github::User {
login: "r".to_string(),
},
created_at: "2024-01-01T00:00:00Z".to_string(),
}]);
app.cmt.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.prs.pr_list = LoadState::Loaded(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.prs.pr_list = LoadState::LoadingMore(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(),
status_check_rollup: vec![],
}]);
app.prs.selected_pr = 0;
app.handle_pr_list_input(make_key(KeyCode::Char('j')))
.await
.unwrap();
assert_eq!(app.prs.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.prs.pr_list = LoadState::Loaded(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(),
status_check_rollup: vec![],
},
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(),
status_check_rollup: vec![],
},
]);
app.prs.selected_pr = 0;
app.handle_pr_list_input(make_key(KeyCode::Char('j')))
.await
.unwrap();
assert_eq!(app.prs.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.prs.pr_list = LoadState::Loaded(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(),
status_check_rollup: vec![],
},
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(),
status_check_rollup: vec![],
},
]);
app.prs.selected_pr = 1;
app.handle_pr_list_input(make_key(KeyCode::Char('k')))
.await
.unwrap();
assert_eq!(app.prs.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.prs.pr_list = LoadState::Loaded(
(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(),
status_check_rollup: vec![],
})
.collect(),
);
app.prs.selected_pr = 0;
app.handle_pr_list_input(make_key(KeyCode::Char('G')))
.await
.unwrap();
assert_eq!(app.prs.selected_pr, 9);
}
#[tokio::test]
async fn test_reload_pr_list_resets_state() {
let mut app = App::new_for_test();
app.prs.selected_pr = 5;
app.prs.pr_list_scroll_offset = 10;
let filter = crate::filter::ListFilter::new();
app.prs.pr_list_filter = Some(filter);
app.reload_pr_list();
assert_eq!(app.prs.selected_pr, 0);
assert_eq!(app.prs.pr_list_scroll_offset, 0);
assert!(app.prs.pr_list.is_loading());
assert!(!app.prs.pr_list_has_more);
assert!(app.prs.pr_list_filter.is_none());
}
#[test]
fn test_load_more_prs_skips_when_loading() {
let mut app = App::new_for_test();
app.prs.pr_list = LoadState::Loading;
let prev_receiver = app.prs.pr_list_receiver.is_some();
app.load_more_prs();
assert_eq!(app.prs.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_scroll.line_count = 2;
let plain_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
let patch_hash = plain_cache.patch_hash;
app.diff_store.set_current(0, plain_cache);
let (tx, rx) = mpsc::channel(1);
app.diff_store.set_highlight_rx(rx);
let mut cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line", 4);
cache.highlighted = true;
cache.patch_hash = patch_hash;
tx.send((0_usize, cache)).await.unwrap();
app.poll_diff_cache_updates();
assert!(app.diff_store.current.is_some());
assert!(app.diff_store.current.as_ref().unwrap().highlighted);
}
#[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 mut current_cache = crate::ui::diff_view::build_plain_diff_cache("@@ -1 +1 @@\n+line2", 4);
current_cache.file_index = 1;
app.diff_store.set_current(1, current_cache);
let (tx, rx) = mpsc::channel(1);
app.diff_store.set_highlight_rx(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((0_usize, cache)).await.unwrap();
app.poll_diff_cache_updates();
if let Some(ref c) = app.diff_store.current {
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.diff_store.set_prefetch_rx(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((1_usize, cache)).await.unwrap();
app.poll_prefetch_updates();
assert!(app.diff_store.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_store.set_current(0, existing_cache);
let (tx, rx) = mpsc::channel(2);
app.diff_store.set_prefetch_rx(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((0_usize, cache)).await.unwrap();
app.poll_prefetch_updates();
assert!(!app.diff_store.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.cmt.comment_submitting = true;
let (tx, rx) = mpsc::channel(1);
app.cmt.comment_submit_receiver = Some((1, rx));
tx.send(CommentSubmitResult::Success).await.unwrap();
app.poll_comment_submit_updates();
assert!(!app.cmt.comment_submitting);
let (success, _) = app.cmt.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.cmt.comment_submitting = true;
let (tx, rx) = mpsc::channel(1);
app.cmt.comment_submit_receiver = Some((1, rx));
tx.send(CommentSubmitResult::Error("network error".to_string()))
.await
.unwrap();
app.poll_comment_submit_updates();
assert!(!app.cmt.comment_submitting);
let (success, msg) = app.cmt.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.cmt.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.cmt.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.cmt.comment_receiver = Some((1, rx)); app.cmt.comments_loading = true;
tx.send(Ok(vec![])).await.unwrap();
app.poll_comment_updates();
assert!(app.cmt.comments_loading);
assert!(app.cmt.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.cmt.discussion_comment_receiver = Some((1, rx)); app.cmt.discussion_comments_loading = true;
tx.send(Ok(vec![])).await.unwrap();
app.poll_discussion_comment_updates();
assert!(app.cmt.discussion_comments_loading);
assert!(app.cmt.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
);
}
#[tokio::test]
async 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_store.set_current(0, cache);
app.ensure_diff_cache();
assert!(
app.diff_store
.current
.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_store.current = 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.diff_store.store.insert(0, store_cache);
app.ensure_diff_cache();
assert!(
app.diff_store
.current
.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_store.current = Some(cache);
app.ensure_diff_cache();
assert!(
app.diff_store
.current
.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.diff_store.store.insert(0, rs_cache);
let (_tx, rx) = tokio::sync::mpsc::channel::<(usize, DiffCache)>(1);
app.diff_store.set_prefetch_rx(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.diff_store.has_prefetch_rx(),
"prefetch_receiver should be preserved after toggling rich in PR description view"
);
assert!(
app.diff_store.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());
}
#[test]
fn test_symbol_search_stale_result_discarded_when_file_changed() {
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,
},
],
};
let (tx, rx) = tokio::sync::mpsc::channel(1);
app.symbol_search = SymbolSearchState::Searching {
receiver: rx,
origin_file_index: 0,
};
app.selected_file = 1;
tx.try_send(SymbolSearchUpdate::Found(RepoSymbolSearchResult {
file_path: "src/foo.rs".to_string(),
line_number: 10,
repo_root: "/tmp".to_string(),
}))
.unwrap();
app.poll_symbol_search_updates();
assert!(
matches!(app.symbol_search, SymbolSearchState::Idle),
"Stale result should be discarded when user navigated to a different file"
);
}
#[test]
fn test_symbol_search_result_accepted_when_file_unchanged() {
let mut app = App::new_for_test();
app.selected_file = 2;
let (tx, rx) = tokio::sync::mpsc::channel(1);
app.symbol_search = SymbolSearchState::Searching {
receiver: rx,
origin_file_index: 2,
};
tx.try_send(SymbolSearchUpdate::Found(RepoSymbolSearchResult {
file_path: "src/bar.rs".to_string(),
line_number: 5,
repo_root: "/tmp".to_string(),
}))
.unwrap();
app.poll_symbol_search_updates();
assert!(
matches!(app.symbol_search, SymbolSearchState::Ready(..)),
"Result should be accepted when user is still on the same file"
);
}
#[test]
fn test_symbol_search_not_found_resets_to_idle() {
let mut app = App::new_for_test();
app.selected_file = 0;
let (tx, rx) = tokio::sync::mpsc::channel(1);
app.symbol_search = SymbolSearchState::Searching {
receiver: rx,
origin_file_index: 0,
};
tx.try_send(SymbolSearchUpdate::NotFound).unwrap();
app.poll_symbol_search_updates();
assert!(matches!(app.symbol_search, SymbolSearchState::Idle));
assert!(app.cmt.submission_result.is_some());
let (success, msg) = app.cmt.submission_result.unwrap();
assert!(!success);
assert!(msg.contains("not found"));
}
#[test]
fn test_symbol_search_failed_resets_to_idle() {
let mut app = App::new_for_test();
app.selected_file = 0;
let (tx, rx) = tokio::sync::mpsc::channel(1);
app.symbol_search = SymbolSearchState::Searching {
receiver: rx,
origin_file_index: 0,
};
tx.try_send(SymbolSearchUpdate::Failed("rg not found".to_string()))
.unwrap();
app.poll_symbol_search_updates();
assert!(matches!(app.symbol_search, SymbolSearchState::Idle));
assert!(app.cmt.submission_result.is_some());
let (success, msg) = app.cmt.submission_result.unwrap();
assert!(!success);
assert!(msg.contains("Search failed"));
}
#[test]
fn test_symbol_search_clears_submission_result_on_new_search() {
let mut app = App::new_for_test();
app.cmt.submission_result = Some((true, "old result".to_string()));
app.cmt.submission_result_time = Some(std::time::Instant::now());
let (_tx, rx) = tokio::sync::mpsc::channel(1);
app.cmt.submission_result = None;
app.cmt.submission_result_time = None;
app.symbol_search = SymbolSearchState::Searching {
receiver: rx,
origin_file_index: 0,
};
assert!(
app.cmt.submission_result.is_none(),
"submission_result should be cleared when a new search starts"
);
assert!(app.cmt.submission_result_time.is_none());
assert!(app.symbol_search.is_searching());
}
#[test]
fn test_symbol_search_is_searching_visible_when_no_submission_result() {
let mut app = App::new_for_test();
app.cmt.submission_result = None;
app.cmt.submission_result_time = None;
let (_tx, rx) = tokio::sync::mpsc::channel(1);
app.symbol_search = SymbolSearchState::Searching {
receiver: rx,
origin_file_index: 0,
};
assert!(app.cmt.submission_result.is_none());
assert!(app.symbol_search.is_searching());
}
#[tokio::test]
async fn test_poll_git_ops_starts_prefetch_after_status_update() {
let mut app = App::new_for_test();
app.set_local_mode(true);
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+a".to_string()),
viewed: false,
},
ChangedFile {
filename: "b.rs".to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n+b".to_string()),
viewed: false,
},
],
};
let entries = vec![
GitStatusEntry {
path: "a.rs".to_string(),
index_status: FileStatus::Unmodified,
worktree_status: FileStatus::Modified,
additions: 1,
deletions: 0,
staged_additions: 0,
staged_deletions: 0,
orig_path: None,
unmerged: false,
},
GitStatusEntry {
path: "b.rs".to_string(),
index_status: FileStatus::Unmodified,
worktree_status: FileStatus::Modified,
additions: 1,
deletions: 0,
staged_additions: 0,
staged_deletions: 0,
orig_path: None,
unmerged: false,
},
];
let (tx, rx) = tokio::sync::mpsc::channel(1);
tx.send(Ok(entries)).await.unwrap();
let mut ops = GitOpsState::new(Vec::new());
ops.status_receiver = Some(rx);
app.git_ops_state = Some(ops);
app.poll_git_ops_updates();
let ops = app.git_ops_state.as_ref().unwrap();
assert!(
ops.diff_store.has_prefetch_rx(),
"prefetch should be started after status update (regression: start_git_ops_prefetch not called)"
);
}
#[test]
fn test_left_pane_focus_default_is_tree() {
use crate::app::types::LeftPaneFocus;
assert_eq!(LeftPaneFocus::default(), LeftPaneFocus::Tree);
}
#[test]
fn test_commit_log_state_new_has_correct_defaults() {
use crate::app::types::CommitLogState;
let state = CommitLogState::new();
assert!(state.commits.is_empty());
assert_eq!(state.selected, 0);
assert_eq!(state.scroll_offset, 0);
assert!(!state.diff_loading);
assert!(!state.loading);
assert!(!state.has_more);
assert_eq!(state.page, 0);
assert!(state.error.is_none());
assert!(state.diff_error.is_none());
assert!(state.pending_diff_sha.is_none());
assert!(!state.initialized);
}
#[test]
fn test_git_ops_state_has_left_focus_and_commit_log() {
use crate::app::types::LeftPaneFocus;
let ops = GitOpsState::new(Vec::new());
assert_eq!(ops.left_focus, LeftPaneFocus::Tree);
assert_eq!(ops.left_return_focus, LeftPaneFocus::Tree);
assert!(ops.commit_log.commits.is_empty());
}
#[test]
fn test_matches_single_key_alt() {
use crate::keybinding::KeyBinding;
let app = App::new_for_test();
let seq = crate::keybinding::KeySequence::single(KeyBinding::char('j'))
.with_alt(vec![KeyBinding::named(crate::keybinding::NamedKey::Down)]);
assert!(app.matches_single_key(&make_key(KeyCode::Char('j')), &seq));
assert!(app.matches_single_key(&make_key(KeyCode::Down), &seq));
assert!(!app.matches_single_key(&make_key(KeyCode::Char('k')), &seq));
}
#[test]
fn test_matches_single_key_no_alt_no_arrow() {
use crate::keybinding::KeyBinding;
let app = App::new_for_test();
let seq = crate::keybinding::KeySequence::single(KeyBinding::char('j'));
assert!(app.matches_single_key(&make_key(KeyCode::Char('j')), &seq));
assert!(!app.matches_single_key(&make_key(KeyCode::Down), &seq));
}
#[test]
fn test_default_move_down_matches_arrow() {
let app = App::new_for_test();
let kb = &app.config.keybindings;
assert!(app.matches_single_key(&make_key(KeyCode::Char('j')), &kb.move_down));
assert!(app.matches_single_key(&make_key(KeyCode::Down), &kb.move_down));
}
#[test]
fn test_default_quit_matches_esc() {
let app = App::new_for_test();
let kb = &app.config.keybindings;
assert!(app.matches_single_key(&make_key(KeyCode::Char('q')), &kb.quit));
assert!(app.matches_single_key(&make_key(KeyCode::Esc), &kb.quit));
}
#[test]
fn test_key_sequence_alt_deserialize_roundtrip() {
use crate::keybinding::KeySequence;
let toml_str = r#"key = "j/Down""#;
#[derive(serde::Deserialize)]
struct Test {
key: KeySequence,
}
let test: Test = toml::from_str(toml_str).unwrap();
assert_eq!(test.key.keys.len(), 1);
assert_eq!(test.key.alt.len(), 1);
assert_eq!(test.key.display(), "j/Down");
}
#[test]
fn test_key_sequence_no_alt_deserialize() {
use crate::keybinding::KeySequence;
let toml_str = r#"key = "j""#;
#[derive(serde::Deserialize)]
struct Test {
key: KeySequence,
}
let test: Test = toml::from_str(toml_str).unwrap();
assert_eq!(test.key.keys.len(), 1);
assert!(test.key.alt.is_empty());
assert_eq!(test.key.display(), "j");
}
#[test]
fn test_try_match_sequence_alt() {
use crate::keybinding::{KeyBinding, KeySequence, SequenceMatch};
let mut app = App::new_for_test();
let seq = KeySequence::double(KeyBinding::char('g'), KeyBinding::char('g'))
.with_alt(vec![KeyBinding::named(crate::keybinding::NamedKey::Home)]);
app.push_pending_key(KeyBinding::named(crate::keybinding::NamedKey::Home));
assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Full);
app.clear_pending_keys();
app.push_pending_key(KeyBinding::char('g'));
assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Partial);
app.push_pending_key(KeyBinding::char('g'));
assert_eq!(app.try_match_sequence(&seq), SequenceMatch::Full);
}
#[tokio::test]
async fn test_open_git_ops_starts_commit_loading() {
let mut app = App::new_for_test();
app.set_local_mode(true);
app.open_git_ops();
let ops = app.git_ops_state.as_ref().unwrap();
assert!(
ops.commit_log.loading,
"open_git_ops should start commit loading"
);
assert!(
ops.commit_log.list_receiver.is_some(),
"open_git_ops should set commit list receiver"
);
}
#[tokio::test]
async fn test_poll_git_ops_receives_commit_list() {
use crate::github::CommitListPage;
let mut app = App::new_for_test();
app.set_local_mode(true);
let mut ops = GitOpsState::new(Vec::new());
let (tx, rx) = mpsc::channel(1);
ops.commit_log.list_receiver = Some(rx);
ops.commit_log.loading = true;
app.git_ops_state = Some(ops);
app.state = AppState::GitOpsSplitTree;
let page = CommitListPage {
items: vec![PrCommit {
sha: "abc123".to_string(),
message: "test commit".to_string(),
author_name: "test".to_string(),
author_login: None,
date: "2025-01-01T00:00:00Z".to_string(),
}],
has_more: false,
};
tx.send(Ok(page)).await.unwrap();
app.poll_git_ops_updates();
let ops = app.git_ops_state.as_ref().unwrap();
assert_eq!(ops.commit_log.commits.len(), 1);
assert_eq!(ops.commit_log.commits[0].sha, "abc123");
assert!(!ops.commit_log.loading);
}
#[tokio::test]
async fn test_tab_in_tree_focus_switches_to_commits() {
use crate::app::types::LeftPaneFocus;
let mut app = App::new_for_test();
app.open_git_ops();
app.toggle_git_ops_left_focus();
let ops = app.git_ops_state.as_ref().unwrap();
assert_eq!(ops.left_focus, LeftPaneFocus::Commits);
}
#[tokio::test]
async fn test_tab_in_commits_focus_switches_to_tree() {
use crate::app::types::LeftPaneFocus;
let mut app = App::new_for_test();
app.open_git_ops();
if let Some(ref mut ops) = app.git_ops_state {
ops.left_focus = LeftPaneFocus::Commits;
}
app.toggle_git_ops_left_focus();
let ops = app.git_ops_state.as_ref().unwrap();
assert_eq!(ops.left_focus, LeftPaneFocus::Tree);
}
#[tokio::test]
async fn test_diff_returns_to_left_return_focus() {
use crate::app::types::LeftPaneFocus;
let mut app = App::new_for_test();
app.open_git_ops();
if let Some(ref mut ops) = app.git_ops_state {
ops.left_return_focus = LeftPaneFocus::Commits;
}
app.state = AppState::GitOpsSplitDiff;
app.return_from_git_ops_diff();
assert_eq!(app.state, AppState::GitOpsSplitTree);
let ops = app.git_ops_state.as_ref().unwrap();
assert_eq!(ops.left_focus, LeftPaneFocus::Commits);
}
fn make_test_pr() -> Box<PullRequest> {
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: String::new(),
})
}
fn make_changed_file(name: &str) -> ChangedFile {
ChangedFile {
filename: name.to_string(),
status: "modified".to_string(),
additions: 1,
deletions: 0,
patch: Some("@@ -1 +1 @@\n-old\n+new".to_string()),
viewed: false,
}
}
fn make_app_with_files(filenames: &[&str]) -> App {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
let files: Vec<ChangedFile> = filenames.iter().map(|n| make_changed_file(n)).collect();
app.data_state = DataState::Loaded {
pr: make_test_pr(),
files,
};
app.state = AppState::FileList;
app
}
#[test]
fn test_toggle_file_tree_on() {
let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs", "README.md"]);
assert!(!app.tree_mode_active);
assert!(app.file_tree_state.is_none());
app.toggle_file_tree();
assert!(app.tree_mode_active);
assert!(app.file_tree_state.is_some());
assert!(app.is_file_tree_active());
let tree = app.file_tree_state.as_ref().unwrap();
assert!(tree.row_count() > 0);
}
#[test]
fn test_toggle_file_tree_off_preserves_state() {
let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs"]);
app.toggle_file_tree(); assert!(app.tree_mode_active);
{
let tree = app.file_tree_state.as_mut().unwrap();
let app_dir = tree.find_row_for_dir("src/app").unwrap();
tree.selected_row = app_dir;
tree.toggle_expand();
}
app.toggle_file_tree();
assert!(!app.tree_mode_active);
assert!(app.file_tree_state.is_some());
assert!(!app.is_file_tree_active());
let tree = app.file_tree_state.as_ref().unwrap();
assert!(!tree.expanded_dirs.contains("src/app"));
}
#[test]
fn test_toggle_preserves_selection() {
let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs", "README.md"]);
app.selected_file = 1;
app.toggle_file_tree();
assert_eq!(app.selected_file, 1);
let tree = app.file_tree_state.as_ref().unwrap();
let row = tree.find_row_for_file(1);
assert!(row.is_some());
assert_eq!(tree.selected_row, row.unwrap());
}
#[test]
fn test_retoggle_restores_expanded_dirs() {
let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs"]);
app.toggle_file_tree();
{
let tree = app.file_tree_state.as_mut().unwrap();
let app_dir = tree.find_row_for_dir("src/app").unwrap();
tree.selected_row = app_dir;
tree.toggle_expand();
}
app.toggle_file_tree(); app.toggle_file_tree();
let tree = app.file_tree_state.as_ref().unwrap();
assert!(!tree.expanded_dirs.contains("src/app"));
}
#[test]
fn test_tree_nav_down_updates_selected_file() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.toggle_file_tree();
assert_eq!(app.selected_file, 0);
let tree = app.file_tree_state.as_ref().unwrap();
assert_eq!(tree.selected_row, 1);
app.file_tree_move_down(); assert_eq!(app.selected_file, 1);
app.file_tree_move_down(); assert_eq!(app.selected_file, 1);
}
#[test]
fn test_tree_nav_on_dir_keeps_selected_file() {
let mut app = make_app_with_files(&["src/main.rs", "tests/test.rs", "README.md"]);
app.selected_file = 2;
app.toggle_file_tree();
let tree = app.file_tree_state.as_ref().unwrap();
assert!(tree.selected_dir_path().is_some() || tree.selected_file_index().is_some());
let old_selected = app.selected_file;
if let Some(dir_row) = app
.file_tree_state
.as_ref()
.unwrap()
.find_row_for_dir("src")
{
app.file_tree_state.as_mut().unwrap().selected_row = dir_row;
}
app.file_tree_move_down();
let tree = app.file_tree_state.as_ref().unwrap();
if tree.selected_file_index().is_none() {
assert_eq!(app.selected_file, old_selected);
}
}
#[test]
fn test_tree_enter_dir_toggles_expand() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.toggle_file_tree();
let tree = app.file_tree_state.as_mut().unwrap();
let src_row = tree.find_row_for_dir("src").unwrap();
tree.selected_row = src_row;
let initial_count = tree.row_count();
let is_dir = app.file_tree_enter();
assert!(is_dir);
let tree = app.file_tree_state.as_ref().unwrap();
assert!(tree.row_count() < initial_count);
assert_eq!(app.state, AppState::FileList);
}
#[test]
fn test_tree_enter_file_opens_split() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.toggle_file_tree();
let tree = app.file_tree_state.as_mut().unwrap();
let readme_row = tree.find_row_for_file(1).unwrap();
tree.selected_row = readme_row;
let is_dir = app.file_tree_enter();
assert!(!is_dir);
}
#[test]
fn test_tree_dir_row_blocks_mark_viewed() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.toggle_file_tree();
let tree = app.file_tree_state.as_mut().unwrap();
let src_row = tree.find_row_for_dir("src").unwrap();
tree.selected_row = src_row;
assert!(app.is_file_tree_on_dir_row());
}
#[test]
fn test_tree_filter_shows_flat() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.toggle_file_tree();
assert!(app.is_file_tree_active());
app.file_list_filter = Some(crate::filter::ListFilter::new());
assert!(!app.is_file_tree_active());
}
#[test]
fn test_tree_survives_filter_clear() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.toggle_file_tree();
let _tree_row_count = app.file_tree_state.as_ref().unwrap().row_count();
app.file_list_filter = Some(crate::filter::ListFilter::new());
assert!(!app.is_file_tree_active());
app.file_list_filter = None;
app.rebuild_file_tree_if_active();
assert!(app.is_file_tree_active());
let tree = app.file_tree_state.as_ref().unwrap();
assert!(tree.row_count() > 0);
}
#[test]
fn test_selected_file_always_valid_index() {
let mut app = make_app_with_files(&["src/app/mod.rs", "src/lib.rs", "README.md"]);
app.toggle_file_tree();
for _ in 0..10 {
app.file_tree_move_down();
assert!(
app.selected_file < app.files().len(),
"selected_file {} >= files().len() {}",
app.selected_file,
app.files().len()
);
}
for _ in 0..10 {
app.file_tree_move_up();
assert!(
app.selected_file < app.files().len(),
"selected_file {} >= files().len() {}",
app.selected_file,
app.files().len()
);
}
}
#[test]
fn test_rebuild_on_data_reload() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.toggle_file_tree();
let old_count = app.file_tree_state.as_ref().unwrap().row_count();
let files = vec![
make_changed_file("src/main.rs"),
make_changed_file("src/lib.rs"),
make_changed_file("README.md"),
];
app.data_state = DataState::Loaded {
pr: make_test_pr(),
files,
};
app.rebuild_file_tree_if_active();
let new_count = app.file_tree_state.as_ref().unwrap().row_count();
assert_ne!(old_count, new_count, "tree should be rebuilt with new data");
}
#[test]
fn test_tree_page_down_up() {
let mut app = make_app_with_files(&[
"src/a.rs",
"src/b.rs",
"src/c.rs",
"src/d.rs",
"src/e.rs",
"README.md",
]);
app.toggle_file_tree();
app.file_tree_page_down(3);
let tree = app.file_tree_state.as_ref().unwrap();
assert!(tree.selected_row >= 3);
app.file_tree_page_up(3);
let tree = app.file_tree_state.as_ref().unwrap();
assert!(tree.selected_row <= 1); }
#[test]
fn test_tree_jump_to_first_last() {
let mut app = make_app_with_files(&["src/a.rs", "src/b.rs", "README.md"]);
app.toggle_file_tree();
app.file_tree_jump_to_last();
let tree = app.file_tree_state.as_ref().unwrap();
assert_eq!(tree.selected_row, tree.row_count() - 1);
app.file_tree_jump_to_first();
let tree = app.file_tree_state.as_ref().unwrap();
assert_eq!(tree.selected_row, 0);
}
#[test]
fn test_collect_unviewed_paths_under_dir() {
let files = vec![
make_changed_file("src/app/mod.rs"),
make_changed_file("src/app/types.rs"),
make_changed_file("src/lib.rs"),
make_changed_file("README.md"),
];
let paths = App::collect_unviewed_paths_under_dir(&files, "src/app");
assert_eq!(paths.len(), 2);
assert!(paths.contains(&"src/app/mod.rs".to_string()));
assert!(paths.contains(&"src/app/types.rs".to_string()));
let paths = App::collect_unviewed_paths_under_dir(&files, "src");
assert_eq!(paths.len(), 3);
let paths = App::collect_unviewed_paths_under_dir(&files, "");
assert_eq!(paths.len(), 0);
}
#[test]
fn test_deep_nested_collapse_hides_descendants() {
use crate::app::file_tree::FileTreeState;
let mut tree = FileTreeState::new();
tree.rebuild(&[(0, "a/b/c/file.rs"), (1, "a/b/other.rs"), (2, "a/top.rs")]);
assert!(
tree.find_row_for_file(0).is_some(),
"file.rs should be visible"
);
assert!(
tree.find_row_for_file(1).is_some(),
"other.rs should be visible"
);
assert!(
tree.find_row_for_file(2).is_some(),
"top.rs should be visible"
);
let a_row = tree.find_row_for_dir("a").unwrap();
tree.selected_row = a_row;
tree.toggle_expand();
assert_eq!(
tree.row_count(),
1,
"only 'a' dir should remain, dump:\n{}",
tree.dump_tree()
);
assert!(
tree.find_row_for_dir("a/b").is_none(),
"a/b should be hidden"
);
assert!(
tree.find_row_for_dir("a/b/c").is_none(),
"a/b/c should be hidden"
);
assert!(
tree.find_row_for_file(0).is_none(),
"file.rs should be hidden"
);
assert!(
tree.find_row_for_file(1).is_none(),
"other.rs should be hidden"
);
assert!(
tree.find_row_for_file(2).is_none(),
"top.rs should be hidden"
);
tree.toggle_expand();
assert!(
tree.find_row_for_file(0).is_some(),
"file.rs should be visible again"
);
assert!(
tree.find_row_for_file(1).is_some(),
"other.rs should be visible again"
);
assert!(
tree.find_row_for_file(2).is_some(),
"top.rs should be visible again"
);
}
#[test]
fn test_select_pr_resets_tree_state() {
let mut app = make_app_with_files(&["src/main.rs", "README.md"]);
app.started_from_pr_list = true;
app.toggle_file_tree();
assert!(app.tree_mode_active);
assert!(app.file_tree_state.is_some());
app.select_pr(2);
assert!(!app.tree_mode_active);
assert!(app.file_tree_state.is_none());
}
#[test]
fn test_toggle_file_tree_with_empty_files() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("owner/repo", 1, config);
app.state = AppState::FileList;
app.toggle_file_tree();
assert!(!app.tree_mode_active);
assert!(app.file_tree_state.is_none());
}
fn render_top_lines(app: &mut App, height: u16, n: usize) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let width = 80;
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
crate::ui::render(frame, app);
})
.unwrap();
let buf = terminal.backend().buffer();
(0..n.min(height as usize))
.map(|y| {
let mut line = String::new();
for x in 0..width {
let cell = &buf[(x, y as u16)];
line.push_str(cell.symbol());
}
line.trim_end().to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn make_loaded_app() -> 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: "Add zen mode".to_string(),
body: None,
state: "open".to_string(),
head: crate::github::Branch {
ref_name: "feat-zen".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,
files: vec![ChangedFile {
filename: "src/app.rs".to_string(),
status: "modified".to_string(),
additions: 3,
deletions: 1,
patch: Some(
"@@ -1,3 +1,5 @@\n context\n-old line\n+new line\n+added\n+more".to_string(),
),
viewed: false,
}],
},
);
app
}
#[tokio::test]
async fn test_zen_mode_navigation_scenario() {
use insta::assert_snapshot;
let mut app = make_loaded_app();
app.state = AppState::FileList;
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────────────────────────────────────────────────────────┐
│PR #1: Add zen mode by @user │
└──────────────────────────────────────────────────────────────────────────────┘
┌Changed Files (1)─────────────────────────────────────────────────────────────┐
│[M] src/app.rs +3 -1 │
│ │
"#);
app.toggle_zen_mode();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────────────────────────────────────────────────────────┐
│PR #1: Add zen mode by @user │
└──────────────────────────────────────────────────────────────────────────────┘
┌Changed Files (1)─────────────────────────────────────────────────────────────┐
│[M] src/app.rs +3 -1 │
│ │
"#);
app.enter_diff_from_file_list();
app.sync_diff_to_selected_file();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌Diff──────────────────────────────────────────────────────────────────────────┐
│src/app.rs (+3 -1) │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│@@ -1,3 +1,5 @@ │
│ context │
"#);
app.handle_fullscreen_diff_quit();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────────────────────────────────────────────────────────┐
│PR #1: Add zen mode by @user │
└──────────────────────────────────────────────────────────────────────────────┘
┌Changed Files (1)─────────────────────────────────────────────────────────────┐
│[M] src/app.rs +3 -1 │
│ │
"#);
app.toggle_zen_mode();
app.enter_diff_from_file_list();
app.sync_diff_to_selected_file();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────┐┌Diff Preview──────────────────────────────────────┐
│PR #1: Add zen mode ││src/app.rs (+3 -1) │
└──────────────────────────┘└──────────────────────────────────────────────────┘
┌Files (1)─────────────────┐┌──────────────────────────────────────────────────┐
│[M] src/app.rs +3 -1 ││@@ -1,3 +1,5 @@ │
│ ││ context │
"#);
}
#[tokio::test]
async fn test_zen_mode_pr_list_origin_quit_scenario() {
use insta::assert_snapshot;
let mut app = make_loaded_app();
app.started_from_pr_list = true;
app.zen_mode = true;
app.state = AppState::FileList;
app.enter_diff_from_file_list();
app.sync_diff_to_selected_file();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌Diff──────────────────────────────────────────────────────────────────────────┐
│src/app.rs (+3 -1) │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│@@ -1,3 +1,5 @@ │
│ context │
"#);
app.handle_fullscreen_diff_quit();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────────────────────────────────────────────────────────┐
│PR #1: Add zen mode by @user │
└──────────────────────────────────────────────────────────────────────────────┘
┌Changed Files (1)─────────────────────────────────────────────────────────────┐
│[M] src/app.rs +3 -1 │
│ │
"#);
app.zen_mode = false;
app.state = AppState::DiffView;
app.diff_view_return_state = AppState::FileList;
app.handle_fullscreen_diff_quit();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────────────────────────────────────────────────────────┐
│PR List: owner/repo (open) │
└──────────────────────────────────────────────────────────────────────────────┘
┌Pull Requests─────────────────────────────────────────────────────────────────┐
│Failed to load pull requests │
│ │
"#);
}
#[tokio::test]
async fn test_zen_mode_split_view_preservation_scenario() {
use insta::assert_snapshot;
let mut app = make_loaded_app();
app.zen_mode = true;
app.state = AppState::SplitViewFileList;
app.enter_diff_from_file_list();
app.sync_diff_to_selected_file();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────┐┌Diff Preview──────────────────────────────────────┐
│PR #1: Add zen mode ││src/app.rs (+3 -1) │
└──────────────────────────┘└──────────────────────────────────────────────────┘
┌Files (1)─────────────────┐┌──────────────────────────────────────────────────┐
│[M] src/app.rs +3 -1 ││@@ -1,3 +1,5 @@ │
│ ││ context │
"#);
app.zen_mode = false;
app.state = AppState::SplitViewFileList;
app.enter_diff_from_file_list();
app.sync_diff_to_selected_file();
assert_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌octorus───────────────────┐┌Diff Preview──────────────────────────────────────┐
│PR #1: Add zen mode ││src/app.rs (+3 -1) │
└──────────────────────────┘└──────────────────────────────────────────────────┘
┌Files (1)─────────────────┐┌──────────────────────────────────────────────────┐
│[M] src/app.rs +3 -1 ││@@ -1,3 +1,5 @@ │
│ ││ context │
"#);
}
#[tokio::test]
async fn test_zen_mode_auto_focus_transitions_to_diff_view() {
use insta::assert_snapshot;
let mut config = Config::default();
config.layout.zen_mode = true;
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 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,
files: vec![ChangedFile {
filename: "initial.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_snapshot!(render_top_lines(&mut app, 20, 6), @r#"
┌Diff──────────────────────────────────────────────────────────────────────────┐
│initial.rs (+1 -1) │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│@@ -1,1 +1,1 @@ │
│-old │
"#);
}
#[test]
fn load_state_default_is_not_loaded() {
let state: super::types::LoadState<Vec<i32>> = Default::default();
assert!(matches!(state, super::types::LoadState::NotLoaded));
assert!(!state.is_loading());
assert!(!state.is_loaded());
assert!(state.as_loaded().is_none());
}
#[test]
fn load_state_loading_transitions() {
let state: super::types::LoadState<Vec<i32>> = super::types::LoadState::Loading;
assert!(state.is_loading());
assert!(!state.is_loaded());
assert!(state.as_loaded().is_none());
}
#[test]
fn load_state_loaded_accessors() {
let state = super::types::LoadState::Loaded(vec![1, 2, 3]);
assert!(!state.is_loading());
assert!(state.is_loaded());
assert_eq!(state.as_loaded(), Some(&vec![1, 2, 3]));
}
#[test]
fn load_state_loading_more_preserves_data() {
let state = super::types::LoadState::LoadingMore(vec![1, 2]);
assert!(state.is_loading());
assert!(!state.is_loaded());
assert_eq!(state.as_loaded(), Some(&vec![1, 2]));
}
#[test]
fn load_state_error_accessors() {
let state: super::types::LoadState<Vec<i32>> =
super::types::LoadState::Error("fail".to_string());
assert!(!state.is_loading());
assert!(!state.is_loaded());
assert!(state.as_loaded().is_none());
}
#[test]
fn load_state_as_loaded_mut() {
let mut state = super::types::LoadState::Loaded(vec![1]);
if let Some(data) = state.as_loaded_mut() {
data.push(2);
}
assert_eq!(state.as_loaded(), Some(&vec![1, 2]));
}
#[test]
fn load_state_into_loaded() {
let state = super::types::LoadState::Loaded(vec![42]);
assert_eq!(state.into_loaded(), Some(vec![42]));
let state = super::types::LoadState::LoadingMore(vec![99]);
assert_eq!(state.into_loaded(), Some(vec![99]));
let state: super::types::LoadState<Vec<i32>> = super::types::LoadState::Loading;
assert_eq!(state.into_loaded(), None);
}
#[test]
fn load_state_recover_or_preserves_loading_more() {
let mut state = super::types::LoadState::LoadingMore(vec![1, 2, 3]);
state.recover_or(vec![]);
assert_eq!(state.as_loaded(), Some(&vec![1, 2, 3]));
assert!(state.is_loaded());
}
#[test]
fn load_state_recover_or_preserves_loaded() {
let mut state = super::types::LoadState::Loaded(vec![4, 5]);
state.recover_or(vec![]);
assert_eq!(state.as_loaded(), Some(&vec![4, 5]));
}
#[test]
fn load_state_recover_or_uses_fallback_for_loading() {
let mut state: super::types::LoadState<Vec<i32>> = super::types::LoadState::Loading;
state.recover_or(vec![]);
assert_eq!(state.as_loaded(), Some(&vec![]));
assert!(state.is_loaded());
}
#[tokio::test]
async fn test_poll_pr_list_error_recovers_from_loading_more() {
use tokio::sync::mpsc;
let mut app = App::new_for_test();
let existing = vec![crate::github::PullRequestSummary {
number: 1,
title: "existing".to_string(),
state: "OPEN".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "".to_string(),
status_check_rollup: vec![],
}];
app.prs.pr_list = LoadState::LoadingMore(existing);
let (tx, rx) = mpsc::channel(1);
app.prs.pr_list_receiver = Some(rx);
tx.send(Err("network error".to_string())).await.unwrap();
app.poll_pr_list_updates();
assert!(app.prs.pr_list.is_loaded());
assert!(!app.prs.pr_list.is_loading());
let items = app.prs.pr_list.as_loaded().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].number, 1);
}
#[tokio::test]
async fn test_poll_pr_list_disconnect_recovers_from_loading_more() {
use tokio::sync::mpsc;
let mut app = App::new_for_test();
let existing = vec![crate::github::PullRequestSummary {
number: 2,
title: "existing".to_string(),
state: "OPEN".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
is_draft: false,
labels: vec![],
updated_at: "".to_string(),
status_check_rollup: vec![],
}];
app.prs.pr_list = LoadState::LoadingMore(existing);
let (tx, rx) = mpsc::channel(1);
app.prs.pr_list_receiver = Some(rx);
drop(tx);
app.poll_pr_list_updates();
assert!(app.prs.pr_list.is_loaded());
let items = app.prs.pr_list.as_loaded().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].number, 2);
}
#[tokio::test]
async fn test_poll_issue_list_error_recovers_from_loading_more() {
use tokio::sync::mpsc;
let mut app = App::new_for_test();
let existing = vec![crate::github::IssueSummary {
number: 10,
title: "existing issue".to_string(),
state: "OPEN".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
labels: vec![],
updated_at: "".to_string(),
comments: vec![],
}];
let mut state = super::types::IssueState::new();
state.issues = LoadState::LoadingMore(existing);
let (tx, rx) = mpsc::channel(1);
state.issue_list_receiver = Some(rx);
app.issue_state = Some(state);
tx.send(Err("network error".to_string())).await.unwrap();
app.poll_issue_list_updates();
let issue_state = app.issue_state.as_ref().unwrap();
assert!(issue_state.issues.is_loaded());
assert!(!issue_state.issues.is_loading());
let items = issue_state.issues.as_loaded().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].number, 10);
}
#[tokio::test]
async fn test_poll_issue_list_disconnect_recovers_from_loading_more() {
use tokio::sync::mpsc;
let mut app = App::new_for_test();
let existing = vec![crate::github::IssueSummary {
number: 20,
title: "existing issue".to_string(),
state: "OPEN".to_string(),
author: crate::github::User {
login: "user".to_string(),
},
labels: vec![],
updated_at: "".to_string(),
comments: vec![],
}];
let mut state = super::types::IssueState::new();
state.issues = LoadState::LoadingMore(existing);
let (tx, rx) = mpsc::channel(1);
state.issue_list_receiver = Some(rx);
app.issue_state = Some(state);
drop(tx);
app.poll_issue_list_updates();
let issue_state = app.issue_state.as_ref().unwrap();
assert!(issue_state.issues.is_loaded());
let items = issue_state.issues.as_loaded().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].number, 20);
}
#[test]
fn test_try_open_comment_panel_in_local_mode() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("local", 0, config);
app.local_mode = true;
app.cmt.review_comments = Some(vec![ReviewComment {
id: 1,
path: "src/main.rs".to_string(),
line: Some(10),
start_line: None,
body: "local note".to_string(),
user: crate::github::User {
login: "local".to_string(),
},
created_at: "2026-04-27T00:00:00Z".to_string(),
}]);
let kb = app.config.keybindings.clone();
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert!(app.try_open_comment_panel(&key, &kb));
assert!(app.cmt.comment_panel_open);
assert_eq!(app.cmt.comment_panel_scroll, 0);
assert_eq!(app.cmt.selected_inline_comment, 0);
}
#[test]
fn test_try_open_comment_panel_ignores_non_matching_key() {
let config = Config::default();
let (mut app, _tx) = App::new_loading("local", 0, config);
app.local_mode = true;
let kb = app.config.keybindings.clone();
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
assert!(!app.try_open_comment_panel(&key, &kb));
assert!(!app.cmt.comment_panel_open);
}