use std::fs;
use std::path::Path;
use git2::{Repository, Signature};
use tempfile::TempDir;
use gitstack::{
analyze_topology_from_repo, build_graph, checkout_branch_in_repo, create_commit_in_repo,
get_commit_files_from_repo, get_head_hash_from_repo, get_index_mtime_from_repo,
get_status_from_repo, has_staged_files_in_repo, list_branches_from_repo, load_events_from_repo,
stage_file_in_repo, unstage_file_in_repo, App, BranchInfo, BranchStatus, FileStatusKind,
FilterQuery, GitEvent, GraphCell, InputMode, RepoInfo, TopologyConfig,
};
fn branch_infos(names: &[&str]) -> Vec<BranchInfo> {
names
.iter()
.map(|n| BranchInfo::new(n.to_string(), false))
.collect()
}
fn init_test_repo() -> (TempDir, Repository) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("Failed to init repo");
let sig = Signature::now("Test Author", "test@example.com").unwrap();
let tree_id = {
let mut index = repo.index().unwrap();
let test_file = temp_dir.path().join("initial.txt");
fs::write(&test_file, "initial content").unwrap();
index.add_path(Path::new("initial.txt")).unwrap();
index.write().unwrap();
index.write_tree().unwrap()
};
{
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
}
(temp_dir, repo)
}
fn create_test_events(messages: &[&str]) -> Vec<GitEvent> {
use chrono::Local;
messages
.iter()
.map(|msg| {
GitEvent::commit(
"abc1234".to_string(),
msg.to_string(),
"author".to_string(),
Local::now(),
1,
0,
)
})
.collect()
}
mod app_integration {
use super::*;
#[test]
fn test_app_loads_repo_info_correctly() {
let (_temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let mut app = App::new();
let events = load_events_from_repo(&repo, 50, true).unwrap();
app.load(repo_info, events);
assert!(app.event_count() > 0);
assert!(app.repo_info.is_some());
}
#[test]
fn test_app_filter_works_with_real_events() {
let (_temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
let initial_count = app.event_count();
assert!(initial_count > 0);
app.filter_push('I');
app.filter_push('n');
app.filter_push('i');
app.filter_push('t');
app.filter_push('i');
app.filter_push('a');
app.filter_push('l');
assert_eq!(app.event_count(), 1);
assert!(app.event_at(0).unwrap().message.contains("Initial"));
}
#[test]
fn test_app_filter_no_match_returns_empty() {
let mut app = App::new();
let events = create_test_events(&["feat: add feature", "fix: bug fix"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for c in "nonexistent".chars() {
app.filter_push(c);
}
assert_eq!(app.event_count(), 0);
}
#[test]
fn test_app_cursor_movement_respects_filtered_list() {
let mut app = App::new();
let events = create_test_events(&[
"feat: feature 1",
"fix: bug fix",
"feat: feature 2",
"docs: update",
"feat: feature 3",
]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for c in "feat".chars() {
app.filter_push(c);
}
assert_eq!(app.event_count(), 3);
assert_eq!(app.selected_index, 0);
app.move_down();
assert_eq!(app.selected_index, 1);
app.move_down();
assert_eq!(app.selected_index, 2);
app.move_down(); assert_eq!(app.selected_index, 2);
}
#[test]
fn test_app_mode_transitions() {
let mut app = App::new();
assert_eq!(app.input_mode, InputMode::Normal);
app.start_filter();
assert_eq!(app.input_mode, InputMode::Filter);
app.end_filter();
assert_eq!(app.input_mode, InputMode::Normal);
app.start_branch_select(branch_infos(&["main", "develop"]));
assert_eq!(app.input_mode, InputMode::BranchSelect);
app.end_branch_select();
assert_eq!(app.input_mode, InputMode::Normal);
app.start_status_view(vec![]);
assert_eq!(app.input_mode, InputMode::StatusView);
app.start_commit_input();
assert_eq!(app.input_mode, InputMode::CommitInput);
app.end_commit_input();
assert_eq!(app.input_mode, InputMode::StatusView);
app.end_status_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_app_selected_index_adjusts_when_filter_shrinks_list() {
let mut app = App::new();
let events = create_test_events(&["aaa", "aab", "bbb", "ccc", "ddd"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for _ in 0..4 {
app.move_down();
}
assert_eq!(app.selected_index, 4);
for c in "aa".chars() {
app.filter_push(c);
}
assert!(app.selected_index < app.event_count());
}
}
mod git_integration {
use super::*;
#[test]
fn test_load_events_returns_commits_in_reverse_chronological_order() {
let (temp_dir, repo) = init_test_repo();
let sig = Signature::now("Test Author", "test@example.com").unwrap();
for i in 1..=3 {
let file_path = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&file_path, format!("content {}", i)).unwrap();
let mut index = repo.index().unwrap();
index
.add_path(Path::new(&format!("file{}.txt", i)))
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
&format!("Commit {}", i),
&tree,
&[&parent],
)
.unwrap();
}
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events.len(), 4);
assert_eq!(events[0].message, "Commit 3");
assert_eq!(events[1].message, "Commit 2");
assert_eq!(events[2].message, "Commit 1");
assert_eq!(events[3].message, "Initial commit");
}
#[test]
fn test_staging_and_unstaging_workflow() {
let (temp_dir, repo) = init_test_repo();
let new_file = temp_dir.path().join("new_file.txt");
fs::write(&new_file, "new content").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "new_file.txt" && s.kind == FileStatusKind::Untracked));
stage_file_in_repo(&repo, "new_file.txt").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "new_file.txt" && s.kind == FileStatusKind::StagedNew));
unstage_file_in_repo(&repo, "new_file.txt").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "new_file.txt" && s.kind == FileStatusKind::Untracked));
}
#[test]
fn test_commit_workflow() {
let (temp_dir, repo) = init_test_repo();
let new_file = temp_dir.path().join("feature.rs");
fs::write(&new_file, "fn feature() {}").unwrap();
stage_file_in_repo(&repo, "feature.rs").unwrap();
assert!(has_staged_files_in_repo(&repo).unwrap());
create_commit_in_repo(&repo, "feat: add feature").unwrap();
assert!(!has_staged_files_in_repo(&repo).unwrap());
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events[0].message, "feat: add feature");
}
#[test]
fn test_branch_switch_workflow() {
let (_temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("develop", &head, false).unwrap();
}
let branches = list_branches_from_repo(&repo).unwrap();
assert!(branches.iter().any(|b| b.name == "develop"));
checkout_branch_in_repo(&repo, "develop").unwrap();
let info = RepoInfo::from_repo(&repo).unwrap();
assert_eq!(info.branch, "develop");
}
#[test]
fn test_modified_file_detection() {
let (temp_dir, repo) = init_test_repo();
let existing_file = temp_dir.path().join("initial.txt");
fs::write(&existing_file, "modified content").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "initial.txt" && s.kind == FileStatusKind::Modified));
}
#[test]
fn test_multiple_file_staging() {
let (temp_dir, repo) = init_test_repo();
for i in 1..=3 {
let file = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&file, format!("content {}", i)).unwrap();
}
stage_file_in_repo(&repo, "file1.txt").unwrap();
stage_file_in_repo(&repo, "file3.txt").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "file1.txt" && s.kind == FileStatusKind::StagedNew));
assert!(statuses
.iter()
.any(|s| s.path == "file3.txt" && s.kind == FileStatusKind::StagedNew));
assert!(statuses
.iter()
.any(|s| s.path == "file2.txt" && s.kind == FileStatusKind::Untracked));
}
}
mod workflow_integration {
use super::*;
#[test]
fn test_full_development_workflow() {
let (temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature-new", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feature-new").unwrap();
let new_file = temp_dir.path().join("new_feature.rs");
fs::write(&new_file, "pub fn new_feature() {}").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "new_feature.rs" && s.kind == FileStatusKind::Untracked));
stage_file_in_repo(&repo, "new_feature.rs").unwrap();
create_commit_in_repo(&repo, "feat: add new feature").unwrap();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events[0].message, "feat: add new feature");
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let mut app = App::new();
app.load(repo_info, events);
for c in "feat".chars() {
app.filter_push(c);
}
assert!(app.event_count() > 0);
assert!(app.event_at(0).unwrap().message.contains("feat"));
}
#[test]
fn test_incremental_commit_workflow() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("feature_a.rs"), "// Feature A").unwrap();
fs::write(temp_dir.path().join("feature_b.rs"), "// Feature B").unwrap();
fs::write(temp_dir.path().join("feature_c.rs"), "// Feature C").unwrap();
stage_file_in_repo(&repo, "feature_a.rs").unwrap();
create_commit_in_repo(&repo, "feat: implement feature A").unwrap();
stage_file_in_repo(&repo, "feature_b.rs").unwrap();
stage_file_in_repo(&repo, "feature_c.rs").unwrap();
create_commit_in_repo(&repo, "feat: implement features B and C").unwrap();
let events = load_events_from_repo(&repo, 10, true).unwrap();
assert_eq!(events.len(), 3);
assert_eq!(events[0].message, "feat: implement features B and C");
assert_eq!(events[1].message, "feat: implement feature A");
assert_eq!(events[2].message, "Initial commit");
}
#[test]
fn test_app_with_real_repo_filter_and_navigate() {
let (temp_dir, repo) = init_test_repo();
let sig = Signature::now("Developer", "dev@example.com").unwrap();
let commit_data = [
("fix1.txt", "fix: first bug fix"),
("feat1.txt", "feat: add feature 1"),
("fix2.txt", "fix: second bug fix"),
("docs.txt", "docs: update documentation"),
("fix3.txt", "fix: third bug fix"),
];
for (file, msg) in commit_data {
let file_path = temp_dir.path().join(file);
fs::write(&file_path, "content").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new(file)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &[&parent])
.unwrap();
}
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 10, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
for c in "fix".chars() {
app.filter_push(c);
}
assert_eq!(app.event_count(), 3);
app.move_down();
assert_eq!(app.selected_index, 1);
let selected = app.selected_event().unwrap();
assert!(selected.message.contains("fix"));
}
}
mod status_and_commit_tests {
use super::*;
use gitstack::FileStatus;
fn create_file_statuses() -> Vec<FileStatus> {
vec![
FileStatus {
path: "staged_new.rs".to_string(),
kind: FileStatusKind::StagedNew,
},
FileStatus {
path: "modified.rs".to_string(),
kind: FileStatusKind::Modified,
},
FileStatus {
path: "untracked.txt".to_string(),
kind: FileStatusKind::Untracked,
},
]
}
#[test]
fn test_status_view_cursor_movement() {
let mut app = App::new();
app.start_status_view(create_file_statuses());
assert_eq!(app.status_selected_index, 0);
app.status_move_down();
assert_eq!(app.status_selected_index, 1);
app.status_move_down();
assert_eq!(app.status_selected_index, 2);
app.status_move_down(); assert_eq!(app.status_selected_index, 2);
app.status_move_up();
assert_eq!(app.status_selected_index, 1);
app.status_move_up();
assert_eq!(app.status_selected_index, 0);
app.status_move_up(); assert_eq!(app.status_selected_index, 0);
}
#[test]
fn test_status_view_selected_file() {
let mut app = App::new();
app.start_status_view(create_file_statuses());
let selected = app.selected_file_status().unwrap();
assert_eq!(selected.path, "staged_new.rs");
app.status_move_down();
let selected = app.selected_file_status().unwrap();
assert_eq!(selected.path, "modified.rs");
}
#[test]
fn test_status_view_update_statuses() {
let mut app = App::new();
app.start_status_view(create_file_statuses());
app.status_move_down();
app.status_move_down();
assert_eq!(app.status_selected_index, 2);
app.update_file_statuses(vec![FileStatus {
path: "only_one.rs".to_string(),
kind: FileStatusKind::Modified,
}]);
assert_eq!(app.status_selected_index, 0);
}
#[test]
fn test_commit_message_input() {
let mut app = App::new();
app.start_status_view(vec![]);
app.start_commit_input();
assert!(app.commit_message.is_empty());
for c in "feat: add feature".chars() {
app.commit_message_push(c);
}
assert_eq!(app.commit_message, "feat: add feature");
app.commit_message_pop();
assert_eq!(app.commit_message, "feat: add featur");
app.commit_message_clear();
assert!(app.commit_message.is_empty());
}
#[test]
fn test_commit_message_with_unicode() {
let mut app = App::new();
app.start_commit_input();
for c in "feat: 日本語メッセージ".chars() {
app.commit_message_push(c);
}
assert_eq!(app.commit_message, "feat: 日本語メッセージ");
app.commit_message_pop();
assert_eq!(app.commit_message, "feat: 日本語メッセー");
}
#[test]
fn test_status_message_set_and_clear() {
let mut app = App::new();
assert!(app.status_message.is_none());
app.set_status_message("Committed successfully".to_string());
assert_eq!(
app.status_message,
Some("Committed successfully".to_string())
);
app.start_status_view(vec![]);
assert!(app.status_message.is_none()); }
}
mod detail_view_tests {
use super::*;
#[test]
fn test_open_close_detail() {
let mut app = App::new();
let events = create_test_events(&["test commit"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
assert!(!app.show_detail);
app.open_detail();
assert!(app.show_detail);
app.close_detail();
assert!(!app.show_detail);
}
#[test]
fn test_open_detail_requires_event() {
let mut app = App::new();
app.open_detail();
assert!(!app.show_detail); }
#[test]
fn test_detail_shows_selected_event() {
let mut app = App::new();
let events = create_test_events(&["first", "second", "third"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
app.move_down(); app.open_detail();
assert!(app.show_detail);
assert_eq!(app.selected_event().unwrap().message, "second");
}
}
mod branch_select_tests {
use super::*;
#[test]
fn test_branch_cursor_movement() {
let mut app = App::new();
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, vec![]);
app.start_branch_select(branch_infos(&["develop", "feature-a", "main"]));
assert_eq!(app.branch_selected_index, 2);
app.branch_move_up();
assert_eq!(app.branch_selected_index, 1);
app.branch_move_up();
assert_eq!(app.branch_selected_index, 0);
app.branch_move_up(); assert_eq!(app.branch_selected_index, 0);
app.branch_move_down();
app.branch_move_down();
app.branch_move_down(); assert_eq!(app.branch_selected_index, 2);
}
#[test]
fn test_selected_branch() {
let mut app = App::new();
app.start_branch_select(branch_infos(&["develop", "feature-a", "main"]));
app.branch_selected_index = 1;
assert_eq!(app.selected_branch(), Some("feature-a"));
}
#[test]
fn test_update_branch_after_checkout() {
let mut app = App::new();
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, vec![]);
assert_eq!(app.branch_name(), "main");
app.update_branch("develop".to_string());
assert_eq!(app.branch_name(), "develop");
}
}
mod change_detection_tests {
use super::*;
#[test]
fn test_get_head_hash_returns_valid_hash() {
let (_temp_dir, repo) = init_test_repo();
let hash = get_head_hash_from_repo(&repo).unwrap();
assert_eq!(hash.len(), 40);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_head_hash_changes_after_commit() {
let (temp_dir, repo) = init_test_repo();
let hash_before = get_head_hash_from_repo(&repo).unwrap();
let file = temp_dir.path().join("new.txt");
fs::write(&file, "content").unwrap();
stage_file_in_repo(&repo, "new.txt").unwrap();
create_commit_in_repo(&repo, "new commit").unwrap();
let hash_after = get_head_hash_from_repo(&repo).unwrap();
assert_ne!(hash_before, hash_after);
}
#[test]
fn test_head_hash_same_without_commit() {
let (_temp_dir, repo) = init_test_repo();
let hash1 = get_head_hash_from_repo(&repo).unwrap();
let hash2 = get_head_hash_from_repo(&repo).unwrap();
assert_eq!(hash1, hash2);
}
#[test]
fn test_get_index_mtime_returns_time() {
let (_temp_dir, repo) = init_test_repo();
let mtime = get_index_mtime_from_repo(&repo);
assert!(mtime.is_ok());
}
#[test]
fn test_index_mtime_changes_after_staging() {
let (temp_dir, repo) = init_test_repo();
let mtime_before = get_index_mtime_from_repo(&repo).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let file = temp_dir.path().join("staged.txt");
fs::write(&file, "content").unwrap();
stage_file_in_repo(&repo, "staged.txt").unwrap();
let mtime_after = get_index_mtime_from_repo(&repo).unwrap();
assert!(mtime_after >= mtime_before);
}
#[test]
fn test_head_hash_changes_on_branch_switch() {
let (temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feature").unwrap();
let file = temp_dir.path().join("feature.txt");
fs::write(&file, "feature content").unwrap();
stage_file_in_repo(&repo, "feature.txt").unwrap();
create_commit_in_repo(&repo, "feature commit").unwrap();
let hash_feature = get_head_hash_from_repo(&repo).unwrap();
checkout_branch_in_repo(&repo, "master").unwrap_or_else(|_| {
checkout_branch_in_repo(&repo, "main").unwrap();
});
let hash_main = get_head_hash_from_repo(&repo).unwrap();
assert_ne!(hash_feature, hash_main);
}
}
mod remote_fetch_tests {
use super::*;
use gitstack::fetch_remote_at_path;
fn setup_repo_with_remote() -> (TempDir, Repository, TempDir, Repository) {
let remote_dir = TempDir::new().expect("Failed to create remote dir");
let remote_repo =
Repository::init_bare(remote_dir.path()).expect("Failed to init bare repo");
let local_dir = TempDir::new().expect("Failed to create local dir");
let local_repo = Repository::init(local_dir.path()).expect("Failed to init local repo");
let sig = Signature::now("Test", "test@example.com").unwrap();
{
let mut index = local_repo.index().unwrap();
let test_file = local_dir.path().join("initial.txt");
fs::write(&test_file, "initial").unwrap();
index.add_path(Path::new("initial.txt")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = local_repo.find_tree(tree_id).unwrap();
local_repo
.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
.unwrap();
}
local_repo
.remote("origin", remote_dir.path().to_str().unwrap())
.unwrap();
{
let mut remote = local_repo.find_remote("origin").unwrap();
let branch = local_repo.head().unwrap().shorthand().unwrap().to_string();
let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
remote.push(&[&refspec], None).unwrap();
}
(local_dir, local_repo, remote_dir, remote_repo)
}
#[test]
fn test_fetch_from_local_remote() {
let (local_dir, local_repo, remote_dir, _remote_repo) = setup_repo_with_remote();
let result = fetch_remote_at_path(local_dir.path());
assert!(result.is_ok(), "Fetch should succeed: {:?}", result);
drop(local_repo);
drop(remote_dir);
}
#[test]
fn test_fetch_updates_remote_refs() {
let (local_dir, local_repo, remote_dir, remote_repo) = setup_repo_with_remote();
{
let clone_dir = TempDir::new().unwrap();
let clone =
Repository::clone(remote_dir.path().to_str().unwrap(), clone_dir.path()).unwrap();
let sig = Signature::now("Other", "other@example.com").unwrap();
let file = clone_dir.path().join("remote_change.txt");
fs::write(&file, "remote content").unwrap();
let mut index = clone.index().unwrap();
index.add_path(Path::new("remote_change.txt")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = clone.find_tree(tree_id).unwrap();
let parent = clone.head().unwrap().peel_to_commit().unwrap();
clone
.commit(Some("HEAD"), &sig, &sig, "Remote commit", &tree, &[&parent])
.unwrap();
let mut remote = clone.find_remote("origin").unwrap();
let branch = clone.head().unwrap().shorthand().unwrap().to_string();
remote
.push(
&[&format!("refs/heads/{}:refs/heads/{}", branch, branch)],
None,
)
.unwrap();
}
let local_head_before = get_head_hash_from_repo(&local_repo).unwrap();
fetch_remote_at_path(local_dir.path()).unwrap();
let local_head_after = get_head_hash_from_repo(&local_repo).unwrap();
assert_eq!(local_head_before, local_head_after);
drop(remote_repo);
}
#[test]
fn test_fetch_without_remote_fails() {
let temp_dir = TempDir::new().unwrap();
let _repo = Repository::init(temp_dir.path()).unwrap();
let result = fetch_remote_at_path(temp_dir.path());
assert!(result.is_err());
}
#[test]
fn test_fetch_nonexistent_path_fails() {
let result = fetch_remote_at_path(Path::new("/nonexistent/path"));
assert!(result.is_err());
}
}
mod auto_refresh_workflow_tests {
use super::*;
#[test]
fn test_app_detects_new_commit_via_head_hash() {
let (temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
let initial_count = app.event_count();
let initial_head = get_head_hash_from_repo(&repo).unwrap();
let file = temp_dir.path().join("external.txt");
fs::write(&file, "external content").unwrap();
stage_file_in_repo(&repo, "external.txt").unwrap();
create_commit_in_repo(&repo, "external commit").unwrap();
let new_head = get_head_hash_from_repo(&repo).unwrap();
assert_ne!(initial_head, new_head);
let new_events = load_events_from_repo(&repo, 50, true).unwrap();
let count = new_events.len();
app.all_events_replace(new_events);
app.filtered_indices_reset(count);
assert_eq!(app.event_count(), initial_count + 1);
assert_eq!(app.event_at(0).unwrap().message, "external commit");
}
#[test]
fn test_app_detects_staging_changes() {
let (temp_dir, repo) = init_test_repo();
let mut app = App::new();
let statuses = get_status_from_repo(&repo).unwrap();
app.start_status_view(statuses);
assert!(app.file_statuses.is_empty());
let file = temp_dir.path().join("new_file.txt");
fs::write(&file, "new content").unwrap();
let new_statuses = get_status_from_repo(&repo).unwrap();
app.update_file_statuses(new_statuses);
assert_eq!(app.file_statuses.len(), 1);
assert_eq!(app.file_statuses[0].path, "new_file.txt");
assert_eq!(app.file_statuses[0].kind, FileStatusKind::Untracked);
}
#[test]
fn test_app_refreshes_branch_info() {
let (_temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let mut app = App::new();
app.load(repo_info, vec![]);
let initial_branch = app.branch_name().to_string();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("new-branch", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "new-branch").unwrap();
let new_repo_info = RepoInfo::from_repo(&repo).unwrap();
app.update_branch(new_repo_info.branch);
assert_ne!(initial_branch, app.branch_name());
assert_eq!(app.branch_name(), "new-branch");
}
#[test]
fn test_head_hash_consistent_with_events() {
let (temp_dir, repo) = init_test_repo();
for i in 1..=3 {
let file = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&file, format!("content{}", i)).unwrap();
stage_file_in_repo(&repo, &format!("file{}.txt", i)).unwrap();
create_commit_in_repo(&repo, &format!("Commit {}", i)).unwrap();
}
let head_hash = get_head_hash_from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
assert!(events[0].short_hash.len() == 7);
assert!(head_hash.starts_with(&events[0].short_hash));
}
}
mod file_status_kind_tests {
use super::*;
#[test]
fn test_detect_deleted_file() {
let (temp_dir, repo) = init_test_repo();
fs::remove_file(temp_dir.path().join("initial.txt")).unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "initial.txt" && s.kind == FileStatusKind::Deleted));
}
#[test]
fn test_detect_staged_modified() {
let (temp_dir, repo) = init_test_repo();
fs::write(temp_dir.path().join("initial.txt"), "modified").unwrap();
stage_file_in_repo(&repo, "initial.txt").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "initial.txt" && s.kind == FileStatusKind::StagedModified));
}
#[test]
fn test_detect_staged_deleted() {
let (temp_dir, repo) = init_test_repo();
fs::remove_file(temp_dir.path().join("initial.txt")).unwrap();
stage_file_in_repo(&repo, "initial.txt").unwrap();
let statuses = get_status_from_repo(&repo).unwrap();
assert!(statuses
.iter()
.any(|s| s.path == "initial.txt" && s.kind == FileStatusKind::StagedDeleted));
}
}
mod error_cases {
use super::*;
#[test]
fn test_empty_repo_handling() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("Failed to init repo");
let events = load_events_from_repo(&repo, 10, true);
assert!(events.is_err() || events.unwrap().is_empty());
}
#[test]
fn test_filter_with_special_characters() {
let mut app = App::new();
let events = create_test_events(&["test: message with [brackets]"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for c in "[brackets]".chars() {
app.filter_push(c);
}
assert_eq!(app.event_count(), 1);
}
#[test]
fn test_cursor_on_empty_list() {
let mut app = App::new();
let events = create_test_events(&["test"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for c in "nonexistent".chars() {
app.filter_push(c);
}
assert_eq!(app.event_count(), 0);
app.move_down();
app.move_up();
assert!(app.selected_event().is_none());
}
#[test]
fn test_rapid_filter_input() {
let mut app = App::new();
let events = create_test_events(&["abcdefghijklmnop"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for c in "abcdefghijklmnop".chars() {
app.filter_push(c);
}
assert_eq!(app.event_count(), 1);
for _ in 0..16 {
app.filter_pop();
}
assert_eq!(app.event_count(), 1);
assert_eq!(app.filter_text, "");
}
#[test]
fn test_branch_select_with_current_branch_not_in_list() {
let mut app = App::new();
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "nonexistent".to_string(),
};
app.load(repo_info, vec![]);
app.start_branch_select(branch_infos(&["main", "develop"]));
assert_eq!(app.branch_selected_index, 0);
}
#[test]
fn test_status_view_with_no_files() {
let mut app = App::new();
app.start_status_view(vec![]);
assert_eq!(app.input_mode, InputMode::StatusView);
assert!(app.file_statuses.is_empty());
assert!(app.selected_file_status().is_none());
app.status_move_down();
app.status_move_up();
assert_eq!(app.status_selected_index, 0);
}
#[test]
fn test_checkout_nonexistent_branch_fails() {
let (_temp_dir, repo) = init_test_repo();
let result = checkout_branch_in_repo(&repo, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_stage_already_staged_file() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("test.txt");
fs::write(&file, "content").unwrap();
stage_file_in_repo(&repo, "test.txt").unwrap();
let result = stage_file_in_repo(&repo, "test.txt");
assert!(result.is_ok());
}
#[test]
fn test_commit_with_no_staged_files() {
let (_temp_dir, repo) = init_test_repo();
assert!(!has_staged_files_in_repo(&repo).unwrap());
}
#[test]
fn test_empty_branch_list() {
let mut app = App::new();
app.start_branch_select(vec![]);
assert_eq!(app.input_mode, InputMode::BranchSelect);
assert!(app.branches.is_empty());
assert!(app.selected_branch().is_none());
}
#[test]
fn test_filter_clears_on_escape() {
let mut app = App::new();
let events = create_test_events(&["test"]);
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for c in "filter".chars() {
app.filter_push(c);
}
assert_eq!(app.filter_text, "filter");
app.filter_clear();
assert!(app.filter_text.is_empty());
assert_eq!(app.event_count(), 1); }
}
mod e2e_simulation {
use super::*;
use gitstack::FileStatus;
struct AppSimulator {
app: App,
}
impl AppSimulator {
fn new() -> Self {
Self { app: App::new() }
}
fn load_repo(&mut self, repo: &Repository) {
let repo_info = RepoInfo::from_repo(repo).unwrap();
let events = load_events_from_repo(repo, 50, true).unwrap_or_default();
self.app.load(repo_info, events);
}
fn press_j(&mut self) {
match self.app.input_mode {
InputMode::Normal => self.app.move_down(),
InputMode::BranchSelect => self.app.branch_move_down(),
InputMode::StatusView => self.app.status_move_down(),
_ => {}
}
}
fn press_k(&mut self) {
match self.app.input_mode {
InputMode::Normal => self.app.move_up(),
InputMode::BranchSelect => self.app.branch_move_up(),
InputMode::StatusView => self.app.status_move_up(),
_ => {}
}
}
fn press_down(&mut self) {
self.press_j();
}
fn press_slash(&mut self) {
if self.app.input_mode == InputMode::Normal {
self.app.start_filter();
}
}
fn type_text(&mut self, text: &str) {
for c in text.chars() {
match self.app.input_mode {
InputMode::Filter => self.app.filter_push(c),
InputMode::CommitInput => self.app.commit_message_push(c),
_ => {}
}
}
}
fn press_enter(&mut self) {
match self.app.input_mode {
InputMode::Filter => self.app.end_filter(),
InputMode::Normal if !self.app.show_detail => self.app.open_detail(),
_ => {}
}
}
fn press_escape(&mut self) {
match self.app.input_mode {
InputMode::Filter => {
self.app.filter_clear();
self.app.end_filter();
}
InputMode::BranchSelect => self.app.end_branch_select(),
InputMode::StatusView => self.app.end_status_view(),
InputMode::CommitInput => self.app.end_commit_input(),
InputMode::Normal if self.app.show_detail => self.app.close_detail(),
_ => {}
}
}
fn press_s_for_status(&mut self, statuses: Vec<FileStatus>) {
if self.app.input_mode == InputMode::Normal {
self.app.start_status_view(statuses);
}
}
fn press_c(&mut self) {
if self.app.input_mode == InputMode::StatusView {
self.app.start_commit_input();
}
}
fn press_q(&mut self) {
self.app.quit();
}
}
#[test]
fn test_e2e_browse_and_filter() {
let (_temp_dir, repo) = init_test_repo();
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
sim.press_j();
assert_eq!(sim.app.selected_index, 0);
sim.press_slash();
assert_eq!(sim.app.input_mode, InputMode::Filter);
sim.type_text("Initial");
assert_eq!(sim.app.filter_text, "Initial");
assert_eq!(sim.app.event_count(), 1);
sim.press_enter();
assert_eq!(sim.app.input_mode, InputMode::Normal);
sim.press_enter();
assert!(sim.app.show_detail);
sim.press_escape();
assert!(!sim.app.show_detail);
}
#[test]
fn test_e2e_status_and_commit() {
let mut sim = AppSimulator::new();
sim.press_s_for_status(vec![
FileStatus {
path: "file1.rs".to_string(),
kind: FileStatusKind::StagedNew,
},
FileStatus {
path: "file2.rs".to_string(),
kind: FileStatusKind::Modified,
},
]);
assert_eq!(sim.app.input_mode, InputMode::StatusView);
sim.press_j();
assert_eq!(sim.app.status_selected_index, 1);
sim.press_k();
assert_eq!(sim.app.status_selected_index, 0);
sim.press_c();
assert_eq!(sim.app.input_mode, InputMode::CommitInput);
sim.type_text("feat: new feature");
assert_eq!(sim.app.commit_message, "feat: new feature");
sim.press_escape();
assert_eq!(sim.app.input_mode, InputMode::StatusView);
sim.press_escape();
assert_eq!(sim.app.input_mode, InputMode::Normal);
}
#[test]
fn test_e2e_quit() {
let mut sim = AppSimulator::new();
assert!(!sim.app.should_quit);
sim.press_q();
assert!(sim.app.should_quit);
}
#[test]
fn test_e2e_complex_workflow() {
let (temp_dir, repo) = init_test_repo();
let sig = Signature::now("Dev", "dev@example.com").unwrap();
for i in 1..=5 {
let file = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&file, format!("content {}", i)).unwrap();
let mut index = repo.index().unwrap();
index
.add_path(Path::new(&format!("file{}.txt", i)))
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
&format!("Commit {}", i),
&tree,
&[&parent],
)
.unwrap();
}
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
assert_eq!(sim.app.event_count(), 6);
sim.press_j();
sim.press_j();
sim.press_j();
assert_eq!(sim.app.selected_index, 3);
sim.press_slash();
sim.type_text("Commit 3");
assert_eq!(sim.app.event_count(), 1);
sim.press_enter();
assert_eq!(sim.app.selected_event().unwrap().message, "Commit 3");
sim.app.filter_clear();
assert_eq!(sim.app.event_count(), 6);
}
#[test]
fn test_e2e_external_commit_detection() {
let (temp_dir, repo) = init_test_repo();
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
let initial_count = sim.app.event_count();
let initial_head = get_head_hash_from_repo(&repo).unwrap();
let file = temp_dir.path().join("external.txt");
fs::write(&file, "external").unwrap();
stage_file_in_repo(&repo, "external.txt").unwrap();
create_commit_in_repo(&repo, "External commit").unwrap();
let new_head = get_head_hash_from_repo(&repo).unwrap();
assert_ne!(initial_head, new_head);
let events = load_events_from_repo(&repo, 50, true).unwrap();
let count = events.len();
sim.app.all_events_replace(events);
sim.app.filtered_indices_reset(count);
assert_eq!(sim.app.event_count(), initial_count + 1);
assert_eq!(sim.app.event_at(0).unwrap().message, "External commit");
}
#[test]
fn test_e2e_status_view_auto_update() {
let (temp_dir, repo) = init_test_repo();
let mut sim = AppSimulator::new();
let statuses = get_status_from_repo(&repo).unwrap();
sim.press_s_for_status(statuses);
assert!(sim.app.file_statuses.is_empty());
fs::write(temp_dir.path().join("new1.txt"), "content1").unwrap();
fs::write(temp_dir.path().join("new2.txt"), "content2").unwrap();
let new_statuses = get_status_from_repo(&repo).unwrap();
sim.app.update_file_statuses(new_statuses);
assert_eq!(sim.app.file_statuses.len(), 2);
}
#[test]
fn test_e2e_branch_switch_updates_events() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("main_file.txt");
fs::write(&file, "main content").unwrap();
stage_file_in_repo(&repo, "main_file.txt").unwrap();
create_commit_in_repo(&repo, "Main commit").unwrap();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feature").unwrap();
let file = temp_dir.path().join("feature_file.txt");
fs::write(&file, "feature content").unwrap();
stage_file_in_repo(&repo, "feature_file.txt").unwrap();
create_commit_in_repo(&repo, "Feature commit").unwrap();
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
assert!(sim.app.events().any(|e| e.message == "Feature commit"));
checkout_branch_in_repo(&repo, "master").unwrap_or_else(|_| {
checkout_branch_in_repo(&repo, "main").unwrap();
});
let events = load_events_from_repo(&repo, 50, true).unwrap();
let count = events.len();
sim.app.all_events_replace(events);
sim.app.filtered_indices_reset(count);
assert!(sim.app.events().any(|e| e.message == "Feature commit"));
let repo_info = RepoInfo::from_repo(&repo).unwrap();
sim.app.update_branch(repo_info.branch);
assert!(sim.app.branch_name() == "master" || sim.app.branch_name() == "main");
}
#[test]
fn test_e2e_graph_with_real_repo() {
let (temp_dir, repo) = init_test_repo();
let sig = Signature::now("Dev", "dev@example.com").unwrap();
for i in 1..=5 {
let file = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&file, format!("content {}", i)).unwrap();
let mut index = repo.index().unwrap();
index
.add_path(Path::new(&format!("file{}.txt", i)))
.unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let parent = repo.head().unwrap().peel_to_commit().unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
&format!("Commit {}", i),
&tree,
&[&parent],
)
.unwrap();
}
let events = load_events_from_repo(&repo, 50, true).unwrap();
let head_hash = events.first().map(|e| e.short_hash.as_str());
let layout = build_graph(&events, head_hash);
assert_eq!(layout.len(), events.len());
for row in layout.iter() {
assert_eq!(row.column, 0);
}
assert!(layout.rows[0].is_head);
assert!(layout.rows[0]
.cells
.iter()
.any(|c| matches!(c, GraphCell::HeadNode { .. })));
}
#[test]
fn test_e2e_filter_preserved_after_refresh() {
let (temp_dir, repo) = init_test_repo();
for i in 1..=3 {
let file = temp_dir.path().join(format!("fix{}.txt", i));
fs::write(&file, format!("fix {}", i)).unwrap();
stage_file_in_repo(&repo, &format!("fix{}.txt", i)).unwrap();
create_commit_in_repo(&repo, &format!("fix: bug {}", i)).unwrap();
}
for i in 1..=2 {
let file = temp_dir.path().join(format!("feat{}.txt", i));
fs::write(&file, format!("feat {}", i)).unwrap();
stage_file_in_repo(&repo, &format!("feat{}.txt", i)).unwrap();
create_commit_in_repo(&repo, &format!("feat: feature {}", i)).unwrap();
}
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
sim.press_slash();
sim.type_text("fix");
sim.press_enter();
let filter_text = sim.app.filter_text.clone();
let filtered_count = sim.app.event_count();
assert_eq!(filtered_count, 3);
let file = temp_dir.path().join("new_fix.txt");
fs::write(&file, "new fix").unwrap();
stage_file_in_repo(&repo, "new_fix.txt").unwrap();
create_commit_in_repo(&repo, "fix: new bug").unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let count = events.len();
sim.app.all_events_replace(events);
sim.app.filtered_indices_reset(count);
sim.app.filter_clear();
for c in filter_text.chars() {
sim.app.filter_push(c);
}
assert_eq!(sim.app.event_count(), 4); }
#[test]
fn test_e2e_at_key_jumps_to_head() {
let (temp_dir, repo) = init_test_repo();
for i in 1..=5 {
let file = temp_dir.path().join(format!("file{}.txt", i));
fs::write(&file, format!("content {}", i)).unwrap();
stage_file_in_repo(&repo, &format!("file{}.txt", i)).unwrap();
create_commit_in_repo(&repo, &format!("Commit {}", i)).unwrap();
}
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
let head_hash = get_head_hash_from_repo(&repo).unwrap();
sim.app.set_head_hash(head_hash);
sim.press_down();
sim.press_down();
sim.press_down();
assert!(sim.app.selected_index > 0, "Should have scrolled down");
sim.app.jump_to_head();
assert_eq!(sim.app.selected_index, 0, "Should jump to HEAD (index 0)");
}
#[test]
fn test_e2e_at_key_jumps_to_head_with_filter() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("fix1.txt");
fs::write(&file, "fix1").unwrap();
stage_file_in_repo(&repo, "fix1.txt").unwrap();
create_commit_in_repo(&repo, "fix: first bug").unwrap();
let file = temp_dir.path().join("feat.txt");
fs::write(&file, "feat").unwrap();
stage_file_in_repo(&repo, "feat.txt").unwrap();
create_commit_in_repo(&repo, "feat: feature").unwrap();
let file = temp_dir.path().join("fix2.txt");
fs::write(&file, "fix2").unwrap();
stage_file_in_repo(&repo, "fix2.txt").unwrap();
create_commit_in_repo(&repo, "fix: HEAD commit").unwrap();
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
let head_hash = get_head_hash_from_repo(&repo).unwrap();
sim.app.set_head_hash(head_hash);
sim.press_slash();
sim.type_text("fix");
sim.press_enter();
let filtered_count = sim.app.event_count();
assert!(filtered_count >= 2, "Should have at least 2 fix commits");
sim.press_down();
assert!(sim.app.selected_index > 0);
sim.app.jump_to_head();
let selected = sim.app.selected_event().unwrap();
assert!(
selected.message.contains("HEAD commit"),
"Should jump to HEAD commit, got: {}",
selected.message
);
}
#[test]
fn test_e2e_at_key_no_jump_when_head_not_in_filter() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("feat.txt");
fs::write(&file, "feat").unwrap();
stage_file_in_repo(&repo, "feat.txt").unwrap();
create_commit_in_repo(&repo, "feat: feature").unwrap();
let file = temp_dir.path().join("fix.txt");
fs::write(&file, "fix").unwrap();
stage_file_in_repo(&repo, "fix.txt").unwrap();
create_commit_in_repo(&repo, "fix: bug").unwrap();
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
let head_hash = get_head_hash_from_repo(&repo).unwrap();
sim.app.set_head_hash(head_hash);
sim.press_slash();
sim.type_text("feat");
sim.press_enter();
assert_eq!(sim.app.event_count(), 1);
let before_index = sim.app.selected_index;
sim.app.jump_to_head();
assert_eq!(
sim.app.selected_index, before_index,
"Should not move when HEAD is not in filter"
);
}
#[test]
fn test_e2e_at_key_jumps_to_head_with_multiple_branches() {
let (temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feature").unwrap();
let file = temp_dir.path().join("feature.txt");
fs::write(&file, "feature").unwrap();
stage_file_in_repo(&repo, "feature.txt").unwrap();
create_commit_in_repo(&repo, "Feature commit").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
let file = temp_dir.path().join("main.txt");
fs::write(&file, "main").unwrap();
stage_file_in_repo(&repo, "main.txt").unwrap();
create_commit_in_repo(&repo, "Main HEAD commit").unwrap();
let mut sim = AppSimulator::new();
sim.load_repo(&repo);
let head_hash = get_head_hash_from_repo(&repo).unwrap();
sim.app.set_head_hash(head_hash);
for _ in 0..3 {
sim.press_down();
}
sim.app.jump_to_head();
let selected = sim.app.selected_event().unwrap();
assert!(
selected.message.contains("Main HEAD commit"),
"Should jump to Main HEAD commit, got: {}",
selected.message
);
}
}
mod graph_integration {
use super::*;
#[test]
fn test_graph_with_merge_commit() {
let (temp_dir, repo) = init_test_repo();
let sig = Signature::now("Dev", "dev@example.com").unwrap();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
}
let main_file = temp_dir.path().join("main.txt");
fs::write(&main_file, "main content").unwrap();
stage_file_in_repo(&repo, "main.txt").unwrap();
create_commit_in_repo(&repo, "main: add file").unwrap();
checkout_branch_in_repo(&repo, "feature").unwrap();
let feature_file = temp_dir.path().join("feature.txt");
fs::write(&feature_file, "feature content").unwrap();
stage_file_in_repo(&repo, "feature.txt").unwrap();
create_commit_in_repo(&repo, "feature: add file").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
{
let feature_commit = repo
.find_branch("feature", git2::BranchType::Local)
.unwrap()
.get()
.peel_to_commit()
.unwrap();
let main_commit = repo.head().unwrap().peel_to_commit().unwrap();
let mut index = repo
.merge_commits(&main_commit, &feature_commit, None)
.unwrap();
let tree_id = index.write_tree_to(&repo).unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(
Some("HEAD"),
&sig,
&sig,
"Merge branch 'feature'",
&tree,
&[&main_commit, &feature_commit],
)
.unwrap();
}
let events = load_events_from_repo(&repo, 50, true).unwrap();
let head_hash = events.first().map(|e| e.short_hash.as_str());
let layout = build_graph(&events, head_hash);
let merge_row = layout.rows.iter().find(|r| {
r.event
.as_ref()
.is_some_and(|e| e.message.contains("Merge"))
});
assert!(merge_row.is_some(), "Should have merge commit row");
let merge_row = merge_row.unwrap();
let has_connection = merge_row.cells.iter().any(|c| {
matches!(
c,
GraphCell::Horizontal { .. }
| GraphCell::CurveUpLeft { .. }
| GraphCell::CurveUpRight { .. }
)
});
assert!(
has_connection,
"Merge row should have horizontal connection"
);
}
#[test]
fn test_graph_parallel_branches() {
let (temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat1", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feat1").unwrap();
let f1 = temp_dir.path().join("feat1.txt");
fs::write(&f1, "feat1").unwrap();
stage_file_in_repo(&repo, "feat1.txt").unwrap();
create_commit_in_repo(&repo, "feat1: add file").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feat2", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feat2").unwrap();
let f2 = temp_dir.path().join("feat2.txt");
fs::write(&f2, "feat2").unwrap();
stage_file_in_repo(&repo, "feat2.txt").unwrap();
create_commit_in_repo(&repo, "feat2: add file").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
let main_f = temp_dir.path().join("main.txt");
fs::write(&main_f, "main").unwrap();
stage_file_in_repo(&repo, "main.txt").unwrap();
create_commit_in_repo(&repo, "main: add file").unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let head_hash = events.first().map(|e| e.short_hash.as_str());
let layout = build_graph(&events, head_hash);
assert!(!layout.is_empty());
assert!(layout.rows[0].is_head);
}
#[test]
fn test_graph_cell_characters() {
use chrono::Local;
let mut merge = GitEvent::merge(
"merge1".to_string(),
"Merge branch".to_string(),
"author".to_string(),
Local::now(),
);
merge.parent_hashes = vec!["main".to_string(), "feat".to_string()];
let mut main = GitEvent::commit(
"main".to_string(),
"main commit".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
main.parent_hashes = vec!["base".to_string()];
let mut feat = GitEvent::commit(
"feat".to_string(),
"feat commit".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
feat.parent_hashes = vec!["base".to_string()];
let base = GitEvent::commit(
"base".to_string(),
"base commit".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
let events = vec![merge, main, feat, base];
let layout = build_graph(&events, Some("merge1"));
for row in &layout.rows {
for cell in &row.cells {
let ch = cell.to_char();
assert!(
" │●◉◆╭╮╰╯─┼├┤┴".contains(ch),
"Invalid cell character: '{}'",
ch
);
}
}
}
#[test]
fn test_connector_row_generation() {
use chrono::Local;
let mut c1 = GitEvent::commit(
"c1".to_string(),
"commit 1".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
c1.parent_hashes = vec!["base".to_string()];
let mut c2 = GitEvent::commit(
"c2".to_string(),
"commit 2".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
c2.parent_hashes = vec!["base".to_string()];
let mut c3 = GitEvent::commit(
"c3".to_string(),
"commit 3".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
c3.parent_hashes = vec!["base".to_string()];
let base = GitEvent::commit(
"base".to_string(),
"base commit".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
let events = vec![c1, c2, c3, base];
let layout = build_graph(&events, None);
let connector_rows: Vec<_> = layout.rows.iter().filter(|r| r.event.is_none()).collect();
assert!(
!connector_rows.is_empty(),
"Should have connector rows for fork point"
);
for row in connector_rows {
let has_tee = row.cells.iter().any(|c| {
matches!(
c,
GraphCell::TeeRight { .. }
| GraphCell::TeeUp { .. }
| GraphCell::CurveDownLeft { .. }
)
});
assert!(has_tee, "Connector row should have Tee or Curve symbol");
}
}
#[test]
fn test_graph_color_consistency() {
use chrono::Local;
let mut merge = GitEvent::merge(
"merge".to_string(),
"Merge".to_string(),
"author".to_string(),
Local::now(),
);
merge.parent_hashes = vec!["main2".to_string(), "feat".to_string()];
let mut main2 = GitEvent::commit(
"main2".to_string(),
"main2".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
main2.parent_hashes = vec!["main1".to_string()];
let mut feat = GitEvent::commit(
"feat".to_string(),
"feat".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
feat.parent_hashes = vec!["main1".to_string()];
let mut main1 = GitEvent::commit(
"main1".to_string(),
"main1".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
main1.parent_hashes = vec!["base".to_string()];
let base = GitEvent::commit(
"base".to_string(),
"base".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
let events = vec![merge, main2, feat, main1, base];
let layout = build_graph(&events, None);
for row in &layout.rows {
assert!(row.color_idx < 8, "Color index should be < 8");
for cell in &row.cells {
if let Some(idx) = cell.color_index() {
assert!(idx < 8, "Cell color index should be < 8");
}
}
}
let main_rows: Vec<_> = layout
.rows
.iter()
.filter(|r| r.column == 0 && r.event.is_some())
.collect();
if main_rows.len() > 1 {
let first_color = main_rows[0].color_idx;
for row in &main_rows[1..] {
assert_eq!(
row.color_idx, first_color,
"Same column should have consistent color"
);
}
}
}
#[test]
fn test_graph_row_count_with_connectors() {
use chrono::Local;
let mut c1 = GitEvent::commit(
"c1".to_string(),
"c1".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
c1.parent_hashes = vec!["base".to_string()];
let mut c2 = GitEvent::commit(
"c2".to_string(),
"c2".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
c2.parent_hashes = vec!["base".to_string()];
let base = GitEvent::commit(
"base".to_string(),
"base".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
let events = vec![c1, c2, base];
let layout = build_graph(&events, None);
let event_count = events.len();
let event_rows = layout.rows.iter().filter(|r| r.event.is_some()).count();
assert_eq!(
event_rows, event_count,
"Event rows should match event count"
);
assert!(
layout.len() >= event_count,
"Total rows should be >= event count"
);
}
#[test]
fn test_merge_node_type() {
use chrono::Local;
let mut merge = GitEvent::merge(
"merge".to_string(),
"Merge".to_string(),
"author".to_string(),
Local::now(),
);
merge.parent_hashes = vec!["main".to_string(), "feat".to_string()];
let mut main = GitEvent::commit(
"main".to_string(),
"main".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
main.parent_hashes = vec![];
let mut feat = GitEvent::commit(
"feat".to_string(),
"feat".to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
feat.parent_hashes = vec![];
let events = vec![merge, main, feat];
let layout = build_graph(&events, None);
let merge_row = layout
.rows
.iter()
.find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "merge"))
.unwrap();
assert!(
merge_row
.cells
.iter()
.any(|c| matches!(c, GraphCell::MergeNode { .. })),
"Non-HEAD merge should have MergeNode"
);
let layout_head = build_graph(&events, Some("merge"));
let merge_row_head = layout_head
.rows
.iter()
.find(|r| r.event.as_ref().is_some_and(|e| e.short_hash == "merge"))
.unwrap();
assert!(
merge_row_head
.cells
.iter()
.any(|c| matches!(c, GraphCell::HeadNode { .. })),
"HEAD merge should have HeadNode"
);
}
}
mod v040_all_branches {
use super::*;
#[test]
fn test_all_branches_commits_visible() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("main_file.txt");
fs::write(&file, "main content").unwrap();
stage_file_in_repo(&repo, "main_file.txt").unwrap();
create_commit_in_repo(&repo, "Main commit").unwrap();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature-branch", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feature-branch").unwrap();
let file = temp_dir.path().join("feature_file.txt");
fs::write(&file, "feature content").unwrap();
stage_file_in_repo(&repo, "feature_file.txt").unwrap();
create_commit_in_repo(&repo, "Feature branch commit").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("hotfix-branch", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "hotfix-branch").unwrap();
let file = temp_dir.path().join("hotfix_file.txt");
fs::write(&file, "hotfix content").unwrap();
stage_file_in_repo(&repo, "hotfix_file.txt").unwrap();
create_commit_in_repo(&repo, "Hotfix branch commit").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let messages: Vec<&str> = events.iter().map(|e| e.message.as_str()).collect();
assert!(
messages.contains(&"Feature branch commit"),
"Feature branch commit should be visible"
);
assert!(
messages.contains(&"Hotfix branch commit"),
"Hotfix branch commit should be visible"
);
assert!(
messages.contains(&"Main commit"),
"Main commit should be visible"
);
assert!(
messages.contains(&"Initial commit"),
"Initial commit should be visible"
);
}
#[test]
fn test_other_branch_commits_visible_from_main() {
let (temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
}
checkout_branch_in_repo(&repo, "feature").unwrap();
let file = temp_dir.path().join("feature.txt");
fs::write(&file, "feature").unwrap();
stage_file_in_repo(&repo, "feature.txt").unwrap();
create_commit_in_repo(&repo, "Feature only commit").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
assert!(
events.iter().any(|e| e.message == "Feature only commit"),
"Feature branch commit should be visible from main"
);
}
#[test]
fn test_all_branches_in_graph() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("main1.txt");
fs::write(&file, "main1").unwrap();
stage_file_in_repo(&repo, "main1.txt").unwrap();
create_commit_in_repo(&repo, "Main 1").unwrap();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
}
let file = temp_dir.path().join("main2.txt");
fs::write(&file, "main2").unwrap();
stage_file_in_repo(&repo, "main2.txt").unwrap();
create_commit_in_repo(&repo, "Main 2").unwrap();
checkout_branch_in_repo(&repo, "feature").unwrap();
let file = temp_dir.path().join("feature1.txt");
fs::write(&file, "feature1").unwrap();
stage_file_in_repo(&repo, "feature1.txt").unwrap();
create_commit_in_repo(&repo, "Feature 1").unwrap();
checkout_branch_in_repo(&repo, "master")
.or_else(|_| checkout_branch_in_repo(&repo, "main"))
.unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let head_hash = get_head_hash_from_repo(&repo).unwrap();
let short_head = &head_hash[..7];
let layout = build_graph(&events, Some(short_head));
let graph_events: Vec<_> = layout
.rows
.iter()
.filter_map(|r| r.event.as_ref())
.collect();
assert!(
graph_events.iter().any(|e| e.message == "Main 1"),
"Main 1 should be in graph"
);
assert!(
graph_events.iter().any(|e| e.message == "Main 2"),
"Main 2 should be in graph"
);
assert!(
graph_events.iter().any(|e| e.message == "Feature 1"),
"Feature 1 should be in graph"
);
}
}
mod smart_filter_tests {
use super::*;
#[test]
fn test_filter_query_parse_plain_text() {
let query = FilterQuery::parse("fix bug");
assert_eq!(query.plain_text, Some("fix bug".to_string()));
assert!(query.author.is_none());
}
#[test]
fn test_filter_query_parse_author() {
let query = FilterQuery::parse("/author:john");
assert_eq!(query.author, Some("john".to_string()));
assert!(query.plain_text.is_none());
}
#[test]
fn test_filter_query_parse_combined() {
let query = FilterQuery::parse("/author:john since:1week fix");
assert_eq!(query.author, Some("john".to_string()));
assert!(query.since.is_some());
assert_eq!(query.message_pattern, Some("fix".to_string()));
}
#[test]
fn test_filter_query_matches_author() {
let query = FilterQuery::parse("/author:Test");
let event = GitEvent::commit(
"abc1234".to_string(),
"Some commit".to_string(),
"Test Author".to_string(),
chrono::Local::now(),
1,
0,
);
assert!(query.matches(&event, None));
}
#[test]
fn test_filter_query_matches_message() {
let query = FilterQuery::parse("/message:feat");
let event = GitEvent::commit(
"abc1234".to_string(),
"feat: add feature".to_string(),
"Author".to_string(),
chrono::Local::now(),
1,
0,
);
assert!(query.matches(&event, None));
}
#[test]
fn test_filter_query_file_filter_with_repo() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("src/auth.rs");
fs::create_dir_all(temp_dir.path().join("src")).unwrap();
fs::write(&file, "auth code").unwrap();
stage_file_in_repo(&repo, "src/auth.rs").unwrap();
create_commit_in_repo(&repo, "Add auth").unwrap();
let head_hash = get_head_hash_from_repo(&repo).unwrap();
let short_hash = &head_hash[..7];
let files = get_commit_files_from_repo(&repo, short_hash).unwrap();
assert!(files.iter().any(|f| f.contains("auth.rs")));
let query = FilterQuery::parse("/file:auth");
let event = GitEvent::commit(
short_hash.to_string(),
"Add auth".to_string(),
"Author".to_string(),
chrono::Local::now(),
1,
0,
);
assert!(query.matches(&event, Some(&files)));
let query_no_match = FilterQuery::parse("/file:nonexistent");
assert!(!query_no_match.matches(&event, Some(&files)));
}
#[test]
fn test_app_smart_filter_integration() {
let (_temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
app.filter_push('/');
app.filter_push('a');
app.filter_push('u');
app.filter_push('t');
app.filter_push('h');
app.filter_push('o');
app.filter_push('r');
app.filter_push(':');
app.filter_push('T');
app.filter_push('e');
app.filter_push('s');
app.filter_push('t');
app.end_filter();
assert!(app.event_count() > 0);
assert!(app.filter_query.author.is_some());
}
}
mod topology_view_tests {
use super::*;
#[test]
fn test_topology_analysis_single_branch() {
let (_temp_dir, repo) = init_test_repo();
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
assert!(!topology.branches.is_empty());
assert!(topology.active_branch().is_some());
}
#[test]
fn test_topology_analysis_with_feature_branch() {
let (temp_dir, repo) = init_test_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature", &head, false).unwrap();
checkout_branch_in_repo(&repo, "feature").unwrap();
let file = temp_dir.path().join("feature.txt");
fs::write(&file, "feature content").unwrap();
stage_file_in_repo(&repo, "feature.txt").unwrap();
create_commit_in_repo(&repo, "Feature commit").unwrap();
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
let active = topology.active_branch().unwrap();
assert_eq!(active.name, "feature");
assert_eq!(active.status, BranchStatus::Active);
assert!(active.relation.is_some());
let relation = active.relation.as_ref().unwrap();
assert!(relation.ahead_count > 0);
}
#[test]
fn test_topology_stale_branch_detection() {
let (_temp_dir, repo) = init_test_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("stale-feature", &head, false).unwrap();
let config = TopologyConfig {
stale_threshold_days: 0, long_lived_threshold_days: 0,
far_behind_threshold: 50,
divergence_threshold: 20,
max_branches: 50,
};
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
let stale_branch = topology.branches.iter().find(|b| b.name == "stale-feature");
assert!(stale_branch.is_some());
if let Some(branch) = stale_branch {
if branch.status != BranchStatus::Active {
assert!(
branch.status == BranchStatus::Stale || branch.status == BranchStatus::Merged,
"Expected Stale or Merged, got {:?}",
branch.status
);
}
}
}
#[test]
fn test_app_topology_view_integration() {
let (_temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
app.start_topology_view(topology);
assert_eq!(app.input_mode, InputMode::TopologyView);
assert!(app.topology_cache.is_some());
assert_eq!(app.topology_nav.selected_index, 0);
let selected = app.selected_topology_branch();
assert!(selected.is_some());
app.end_topology_view();
assert_eq!(app.input_mode, InputMode::Normal);
assert!(app.topology_cache.is_none()); }
#[test]
fn test_app_topology_scroll() {
let (_temp_dir, repo) = init_test_repo();
let head = repo.head().unwrap().peel_to_commit().unwrap();
for i in 0..10 {
repo.branch(&format!("feature-{}", i), &head, false)
.unwrap();
}
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
let branch_count = topology.branches.len();
app.start_topology_view(topology);
for _ in 0..5 {
app.topology_move_down();
app.topology_adjust_scroll(3); }
assert!(app.topology_nav.selected_index < branch_count);
assert!(app.topology_nav.scroll_offset <= app.topology_nav.selected_index);
for _ in 0..3 {
app.topology_move_up();
}
assert!(app.topology_nav.scroll_offset <= app.topology_nav.selected_index);
}
#[test]
fn test_topology_branch_relation_summary() {
use gitstack::BranchRelation;
let mut relation = BranchRelation::new("main".to_string(), "feature".to_string());
relation.ahead_count = 3;
relation.behind_count = 1;
assert_eq!(relation.summary(), "3 ahead, 1 behind");
relation.is_merged = true;
assert_eq!(relation.summary(), "merged");
}
}
mod file_cache_tests {
use super::*;
#[test]
fn test_preload_file_cache_basic() {
let (temp_dir, repo) = init_test_repo();
let file1 = temp_dir.path().join("src/main.rs");
fs::create_dir_all(temp_dir.path().join("src")).unwrap();
fs::write(&file1, "fn main() {}").unwrap();
stage_file_in_repo(&repo, "src/main.rs").unwrap();
create_commit_in_repo(&repo, "Add main.rs").unwrap();
let file2 = temp_dir.path().join("src/lib.rs");
fs::write(&file2, "pub mod app;").unwrap();
stage_file_in_repo(&repo, "src/lib.rs").unwrap();
create_commit_in_repo(&repo, "Add lib.rs").unwrap();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
for c in "/file:main".chars() {
app.filter_push(c);
}
app.end_filter();
app.preload_file_cache(|hash| get_commit_files_from_repo(&repo, hash).ok());
assert!(!app.file_cache_is_empty());
app.reapply_filter();
assert!(app.event_count() > 0);
assert!(app.events().any(|e| e.message.contains("main.rs")));
}
#[test]
fn test_preload_file_cache_no_filter() {
let (_temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
app.preload_file_cache(|hash| get_commit_files_from_repo(&repo, hash).ok());
assert!(app.file_cache_is_empty());
}
#[test]
fn test_preload_file_cache_respects_limit() {
use std::cell::Cell;
let events: Vec<GitEvent> = (0..300)
.map(|i| {
GitEvent::commit(
format!("hash{:03}", i),
format!("Commit {}", i),
"Author".to_string(),
chrono::Local::now(),
1,
0,
)
})
.collect();
let mut app = App::new();
let repo_info = RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
};
app.load(repo_info, events);
for c in "/file:test".chars() {
app.filter_push(c);
}
app.end_filter();
let call_count = Cell::new(0);
app.preload_file_cache(|_hash| {
call_count.set(call_count.get() + 1);
Some(vec!["test.rs".to_string()])
});
let count = call_count.get();
assert!(
count <= 200,
"preload_file_cache should respect MAX_PRELOAD_COMMITS limit, but called {} times",
count
);
}
#[test]
fn test_file_filter_e2e_workflow() {
let (temp_dir, repo) = init_test_repo();
fs::create_dir_all(temp_dir.path().join("src")).unwrap();
let auth_file = temp_dir.path().join("src/auth.rs");
fs::write(&auth_file, "// auth module").unwrap();
stage_file_in_repo(&repo, "src/auth.rs").unwrap();
create_commit_in_repo(&repo, "Add auth module").unwrap();
let db_file = temp_dir.path().join("src/db.rs");
fs::write(&db_file, "// db module").unwrap();
stage_file_in_repo(&repo, "src/db.rs").unwrap();
create_commit_in_repo(&repo, "Add db module").unwrap();
let api_file = temp_dir.path().join("src/api.rs");
fs::write(&api_file, "// api module").unwrap();
stage_file_in_repo(&repo, "src/api.rs").unwrap();
create_commit_in_repo(&repo, "Add api module").unwrap();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
let initial_count = app.event_count();
assert!(initial_count >= 4);
for c in "/file:auth".chars() {
app.filter_push(c);
}
app.end_filter();
app.preload_file_cache(|hash| get_commit_files_from_repo(&repo, hash).ok());
app.reapply_filter();
assert_eq!(app.event_count(), 1, "Should only show auth commit");
assert!(app.event_at(0).unwrap().message.contains("auth"));
app.filter_clear();
assert_eq!(app.event_count(), initial_count);
}
#[test]
fn test_file_filter_combined_with_author() {
let (temp_dir, repo) = init_test_repo();
fs::create_dir_all(temp_dir.path().join("src")).unwrap();
let file = temp_dir.path().join("src/feature.rs");
fs::write(&file, "// feature").unwrap();
stage_file_in_repo(&repo, "src/feature.rs").unwrap();
create_commit_in_repo(&repo, "Add feature").unwrap();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
for c in "/author:Test".chars() {
app.filter_push(c);
}
app.end_filter();
assert!(app.event_count() >= 1, "Should match Test Author commits");
assert!(app.event_at(0).unwrap().author.contains("Test"));
}
#[test]
fn test_file_cache_cleared_on_filter_clear() {
let (temp_dir, repo) = init_test_repo();
fs::create_dir_all(temp_dir.path().join("src")).unwrap();
let file = temp_dir.path().join("src/test.rs");
fs::write(&file, "// test").unwrap();
stage_file_in_repo(&repo, "src/test.rs").unwrap();
create_commit_in_repo(&repo, "Add test").unwrap();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
for c in "/file:test".chars() {
app.filter_push(c);
}
app.end_filter();
app.preload_file_cache(|hash| get_commit_files_from_repo(&repo, hash).ok());
assert!(!app.file_cache_is_empty());
app.filter_clear();
}
}
mod comprehensive_navigation_tests {
use super::*;
#[test]
fn test_move_down_basic() {
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
assert_eq!(app.selected_index, 0);
app.move_down();
assert_eq!(app.selected_index, 1);
app.move_down();
assert_eq!(app.selected_index, 2);
}
#[test]
fn test_move_down_at_bottom_stays() {
let events = create_test_events(&["Commit 1", "Commit 2"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.move_down();
app.move_down();
app.move_down(); assert_eq!(app.selected_index, 1);
}
#[test]
fn test_move_up_basic() {
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.selected_index = 2;
app.move_up();
assert_eq!(app.selected_index, 1);
app.move_up();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_move_up_at_top_stays() {
let events = create_test_events(&["Commit 1", "Commit 2"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.move_up(); assert_eq!(app.selected_index, 0);
}
#[test]
fn test_move_to_top() {
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3", "Commit 4"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.selected_index = 3;
app.move_to_top();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_move_to_bottom() {
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3", "Commit 4"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.move_to_bottom();
assert_eq!(app.selected_index, 3);
}
#[test]
fn test_page_down() {
let events: Vec<GitEvent> = (0..50)
.map(|i| {
GitEvent::commit(
format!("hash{}", i),
format!("Commit {}", i),
"Author".to_string(),
chrono::Local::now(),
1,
0,
)
})
.collect();
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.page_down(10);
assert_eq!(app.selected_index, 10);
app.page_down(10);
assert_eq!(app.selected_index, 20);
}
#[test]
fn test_page_up() {
let events: Vec<GitEvent> = (0..50)
.map(|i| {
GitEvent::commit(
format!("hash{}", i),
format!("Commit {}", i),
"Author".to_string(),
chrono::Local::now(),
1,
0,
)
})
.collect();
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.selected_index = 30;
app.page_up(10);
assert_eq!(app.selected_index, 20);
app.page_up(10);
assert_eq!(app.selected_index, 10);
}
#[test]
fn test_page_down_at_end() {
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.page_down(10); assert_eq!(app.selected_index, 2);
}
#[test]
fn test_page_up_at_start() {
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.selected_index = 1;
app.page_up(10); assert_eq!(app.selected_index, 0);
}
#[test]
fn test_jump_to_head_finds_head_commit() {
let (_temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
let head_hash = get_head_hash_from_repo(&repo).unwrap();
app.set_head_hash(head_hash);
app.selected_index = 0;
app.jump_to_head();
assert!(app.selected_event().is_some());
}
#[test]
fn test_navigation_with_empty_events() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
vec![],
);
app.move_down();
app.move_up();
app.move_to_top();
app.move_to_bottom();
app.page_down(10);
app.page_up(10);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_navigation_with_single_event() {
let events = create_test_events(&["Single commit"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.move_down();
assert_eq!(app.selected_index, 0);
app.move_up();
assert_eq!(app.selected_index, 0);
app.move_to_bottom();
assert_eq!(app.selected_index, 0);
app.move_to_top();
assert_eq!(app.selected_index, 0);
}
}
mod comprehensive_filter_tests {
use super::*;
#[test]
fn test_filter_mode_start_and_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
assert_eq!(app.input_mode, InputMode::Normal);
app.start_filter();
assert_eq!(app.input_mode, InputMode::Filter);
app.end_filter();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_filter_text_input() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_filter();
app.filter_push('h');
app.filter_push('e');
app.filter_push('l');
app.filter_push('l');
app.filter_push('o');
assert_eq!(app.filter_text, "hello");
}
#[test]
fn test_filter_backspace() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_filter();
app.filter_push('h');
app.filter_push('e');
app.filter_push('l');
app.filter_pop();
assert_eq!(app.filter_text, "he");
app.filter_pop();
app.filter_pop();
assert_eq!(app.filter_text, "");
app.filter_pop(); assert_eq!(app.filter_text, "");
}
#[test]
fn test_filter_clear() {
let mut app = App::new();
let events = create_test_events(&["feat: add", "fix: bug", "docs: update"]);
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.start_filter();
for c in "feat".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 1);
app.filter_clear();
assert_eq!(app.filter_text, "");
assert_eq!(app.event_count(), 3);
}
#[test]
fn test_filter_by_message() {
let events =
create_test_events(&["feat: add feature", "fix: bug fix", "docs: update readme"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
for c in "feat".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 1);
assert!(app.event_at(0).unwrap().message.contains("feat"));
}
#[test]
fn test_filter_by_author_prefix() {
let mut events = create_test_events(&["Commit 1", "Commit 2"]);
events[0].author = "Alice".to_string();
events[1].author = "Bob".to_string();
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
for c in "/author:Alice".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 1);
assert_eq!(app.event_at(0).unwrap().author, "Alice");
}
#[test]
fn test_filter_case_insensitive() {
let events = create_test_events(&["UPPERCASE message", "lowercase message"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
for c in "upper".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 1);
}
#[test]
fn test_filter_no_match() {
let events = create_test_events(&["Commit 1", "Commit 2"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
for c in "nonexistent".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 0);
}
#[test]
fn test_filter_escapes_on_esc() {
let events = create_test_events(&["Commit 1", "Commit 2"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.start_filter();
app.filter_push('1');
app.filter_clear();
app.end_filter();
assert_eq!(app.input_mode, InputMode::Normal);
assert_eq!(app.filter_text, "");
assert_eq!(app.event_count(), 2);
}
#[test]
fn test_filter_preserves_selection_when_possible() {
let events = create_test_events(&["Alpha", "Beta", "Gamma"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.selected_index = 1; for c in "Beta".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 1);
assert_eq!(app.selected_index, 0); }
#[test]
fn test_filter_unicode_support() {
let events = create_test_events(&["日本語コミット", "English commit", "한국어 커밋"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
for c in "日本語".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 1);
assert!(app.event_at(0).unwrap().message.contains("日本語"));
}
#[test]
fn test_filter_by_author_method() {
let mut events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
events[0].author = "Alice".to_string();
events[1].author = "Bob".to_string();
events[2].author = "Alice".to_string();
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.filter_by_author("Alice");
assert_eq!(app.event_count(), 2);
}
#[test]
fn test_filter_by_file_method() {
let (temp_dir, repo) = init_test_repo();
let test_file = temp_dir.path().join("src/module.rs");
fs::create_dir_all(temp_dir.path().join("src")).unwrap();
fs::write(&test_file, "// module").unwrap();
stage_file_in_repo(&repo, "src/module.rs").unwrap();
create_commit_in_repo(&repo, "Add module").unwrap();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
app.filter_by_file("module.rs");
assert!(app.filter_query.has_file_filter());
}
}
mod comprehensive_view_mode_tests {
use super::*;
#[test]
fn test_help_toggle() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
assert!(!app.show_help);
app.toggle_help();
assert!(app.show_help);
app.toggle_help();
assert!(!app.show_help);
}
#[test]
fn test_close_help() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.toggle_help();
assert!(app.show_help);
app.close_help();
assert!(!app.show_help);
}
#[test]
fn test_detail_view_open_close() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
assert!(!app.show_detail);
app.open_detail();
assert!(app.show_detail);
app.close_detail();
assert!(!app.show_detail);
}
#[test]
fn test_detail_navigation() {
use gitstack::{CommitDiff, DiffStats, FileChange, FileChangeStatus};
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let diff = CommitDiff {
stats: DiffStats {
files_changed: 3,
insertions: 3,
deletions: 0,
},
files: vec![
FileChange {
path: "file1.rs".to_string(),
status: FileChangeStatus::Modified,
insertions: 1,
deletions: 0,
},
FileChange {
path: "file2.rs".to_string(),
status: FileChangeStatus::Modified,
insertions: 1,
deletions: 0,
},
FileChange {
path: "file3.rs".to_string(),
status: FileChangeStatus::Modified,
insertions: 1,
deletions: 0,
},
],
patches: vec![],
};
app.open_detail();
app.set_detail_diff(diff);
app.detail_nav.selected_index = 1;
app.detail_move_up();
assert_eq!(app.detail_nav.selected_index, 0);
app.detail_move_down();
assert_eq!(app.detail_nav.selected_index, 1);
}
#[test]
fn test_detail_move_up_at_zero() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.open_detail();
app.detail_nav.selected_index = 0;
app.detail_move_up();
assert_eq!(app.detail_nav.selected_index, 0);
}
}
mod comprehensive_status_view_tests {
use super::*;
use gitstack::FileStatus;
fn create_test_statuses() -> Vec<FileStatus> {
vec![
FileStatus {
path: "file1.rs".to_string(),
kind: FileStatusKind::Modified,
},
FileStatus {
path: "file2.rs".to_string(),
kind: FileStatusKind::StagedModified,
},
FileStatus {
path: "file3.rs".to_string(),
kind: FileStatusKind::Untracked,
},
]
}
#[test]
fn test_status_view_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let statuses = create_test_statuses();
app.start_status_view(statuses);
assert_eq!(app.input_mode, InputMode::StatusView);
app.end_status_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_status_view_navigation() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let statuses = create_test_statuses();
app.start_status_view(statuses);
assert_eq!(app.status_selected_index, 0);
app.status_move_down();
assert_eq!(app.status_selected_index, 1);
app.status_move_down();
assert_eq!(app.status_selected_index, 2);
app.status_move_down();
assert_eq!(app.status_selected_index, 2);
app.status_move_up();
assert_eq!(app.status_selected_index, 1);
}
#[test]
fn test_status_view_selected_file() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let statuses = create_test_statuses();
app.start_status_view(statuses);
let selected = app.selected_file_status();
assert!(selected.is_some());
assert_eq!(selected.unwrap().path, "file1.rs");
}
#[test]
fn test_status_view_update_statuses() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let statuses = create_test_statuses();
app.start_status_view(statuses);
let new_statuses = vec![FileStatus {
path: "new_file.rs".to_string(),
kind: FileStatusKind::Untracked,
}];
app.update_file_statuses(new_statuses);
assert_eq!(app.file_statuses.len(), 1);
assert_eq!(app.file_statuses[0].path, "new_file.rs");
}
#[test]
fn test_status_view_empty_statuses() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_status_view(vec![]);
assert_eq!(app.input_mode, InputMode::StatusView);
assert!(app.selected_file_status().is_none());
app.status_move_down();
app.status_move_up();
assert_eq!(app.status_selected_index, 0);
}
}
mod comprehensive_commit_input_tests {
use super::*;
use gitstack::CommitType;
#[test]
fn test_commit_input_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_commit_input();
assert_eq!(app.input_mode, InputMode::CommitInput);
app.end_commit_input();
assert_eq!(app.input_mode, InputMode::StatusView);
}
#[test]
fn test_commit_message_input() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_commit_input();
app.commit_message_push('H');
app.commit_message_push('e');
app.commit_message_push('l');
app.commit_message_push('l');
app.commit_message_push('o');
assert_eq!(app.commit_message, "Hello");
}
#[test]
fn test_commit_message_backspace() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_commit_input();
app.commit_message_push('H');
app.commit_message_push('i');
app.commit_message_pop();
assert_eq!(app.commit_message, "H");
app.commit_message_pop();
app.commit_message_pop(); assert_eq!(app.commit_message, "");
}
#[test]
fn test_commit_message_clear() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_commit_input();
app.commit_message_push('T');
app.commit_message_push('e');
app.commit_message_push('s');
app.commit_message_push('t');
app.commit_message_clear();
assert_eq!(app.commit_message, "");
}
#[test]
fn test_commit_type_selection() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_commit_input();
app.select_commit_type(CommitType::Feat);
assert_eq!(app.commit_type, Some(CommitType::Feat));
assert_eq!(app.commit_message, "feat: ");
app.commit_message_clear();
app.commit_type = None;
app.select_commit_type(CommitType::Fix);
assert_eq!(app.commit_type, Some(CommitType::Fix));
assert_eq!(app.commit_message, "fix: ");
}
#[test]
fn test_all_commit_types() {
let types = vec![
(CommitType::Feat, "feat: "),
(CommitType::Fix, "fix: "),
(CommitType::Docs, "docs: "),
(CommitType::Style, "style: "),
(CommitType::Refactor, "refactor: "),
(CommitType::Test, "test: "),
(CommitType::Chore, "chore: "),
(CommitType::Perf, "perf: "),
];
for (commit_type, expected_prefix) in types {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_commit_input();
app.select_commit_type(commit_type);
assert_eq!(app.commit_message, expected_prefix);
}
}
#[test]
fn test_commit_unicode_message() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_commit_input();
for c in "日本語メッセージ".chars() {
app.commit_message_push(c);
}
assert_eq!(app.commit_message, "日本語メッセージ");
}
}
mod comprehensive_branch_select_tests {
use super::*;
#[test]
fn test_branch_select_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_branch_select(branch_infos(&["main", "feature", "develop"]));
assert_eq!(app.input_mode, InputMode::BranchSelect);
app.end_branch_select();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_branch_select_navigation() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_branch_select(branch_infos(&["main", "feature", "develop"]));
assert_eq!(app.branch_selected_index, 0);
app.branch_move_down();
assert_eq!(app.branch_selected_index, 1);
app.branch_move_down();
assert_eq!(app.branch_selected_index, 2);
app.branch_move_down();
assert_eq!(app.branch_selected_index, 2);
app.branch_move_up();
assert_eq!(app.branch_selected_index, 1);
app.branch_move_up();
assert_eq!(app.branch_selected_index, 0);
app.branch_move_up();
assert_eq!(app.branch_selected_index, 0); }
#[test]
fn test_branch_select_selected_branch() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_branch_select(branch_infos(&["main", "feature"]));
assert_eq!(app.selected_branch(), Some("main"));
app.branch_move_down();
assert_eq!(app.selected_branch(), Some("feature"));
}
#[test]
fn test_branch_select_empty_list() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_branch_select(vec![]);
assert!(app.selected_branch().is_none());
app.branch_move_down();
app.branch_move_up();
assert_eq!(app.branch_selected_index, 0);
}
#[test]
fn test_branch_select_many_branches() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let branches: Vec<BranchInfo> = (0..50)
.map(|i| BranchInfo::new(format!("branch-{}", i), false))
.collect();
app.start_branch_select(branches);
for _ in 0..30 {
app.branch_move_down();
}
assert_eq!(app.branch_selected_index, 30);
app.branch_move_down();
assert_eq!(app.branch_selected_index, 31);
}
}
mod comprehensive_stats_view_tests {
use super::*;
use gitstack::{calculate_stats, RepoStats};
#[test]
fn test_stats_view_start_end() {
let mut app = App::new();
let events = create_test_events(&["Commit 1", "Commit 2"]);
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events.clone(),
);
let refs: Vec<&GitEvent> = events.iter().collect();
let stats = calculate_stats(&refs);
app.start_stats_view(stats);
assert_eq!(app.input_mode, InputMode::StatsView);
app.end_stats_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_stats_view_navigation() {
let mut app = App::new();
let mut events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
events[0].author = "Alice".to_string();
events[1].author = "Bob".to_string();
events[2].author = "Charlie".to_string();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events.clone(),
);
let refs: Vec<&GitEvent> = events.iter().collect();
let stats = calculate_stats(&refs);
app.start_stats_view(stats);
app.stats_move_down();
app.stats_move_down();
app.stats_move_up();
}
#[test]
fn test_stats_view_selected_author() {
let mut app = App::new();
let mut events = create_test_events(&["Commit 1", "Commit 2"]);
events[0].author = "Alice".to_string();
events[1].author = "Bob".to_string();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events.clone(),
);
let refs: Vec<&GitEvent> = events.iter().collect();
let stats = calculate_stats(&refs);
app.start_stats_view(stats);
let selected = app.selected_author();
assert!(selected.is_some());
}
#[test]
fn test_stats_view_empty() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
vec![],
);
let stats = RepoStats::default();
app.start_stats_view(stats);
assert!(app.selected_author().is_none());
}
}
mod comprehensive_heatmap_view_tests {
use super::*;
use gitstack::FileHeatmap;
#[test]
fn test_heatmap_view_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let heatmap = FileHeatmap::default();
app.start_heatmap_view(heatmap);
assert_eq!(app.input_mode, InputMode::HeatmapView);
app.end_heatmap_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_heatmap_view_navigation() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let mut heatmap = FileHeatmap::default();
heatmap.files.push(gitstack::FileHeatmapEntry {
path: "file1.rs".to_string(),
change_count: 10,
max_changes: 10,
});
heatmap.files.push(gitstack::FileHeatmapEntry {
path: "file2.rs".to_string(),
change_count: 5,
max_changes: 10,
});
app.start_heatmap_view(heatmap);
app.heatmap_move_down();
app.heatmap_move_up();
}
#[test]
fn test_heatmap_view_selected_file() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let mut heatmap = FileHeatmap::default();
heatmap.files.push(gitstack::FileHeatmapEntry {
path: "test.rs".to_string(),
change_count: 10,
max_changes: 10,
});
app.start_heatmap_view(heatmap);
let selected = app.selected_heatmap_file();
assert!(selected.is_some());
assert_eq!(selected.unwrap(), "test.rs");
}
}
mod comprehensive_timeline_view_tests {
use super::*;
use gitstack::{calculate_activity_timeline, ActivityTimeline};
#[test]
fn test_timeline_view_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let timeline = ActivityTimeline::default();
app.start_timeline_view(timeline);
assert_eq!(app.input_mode, InputMode::TimelineView);
app.end_timeline_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_timeline_with_real_events() {
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
let refs: Vec<&GitEvent> = events.iter().collect();
let timeline = calculate_activity_timeline(&refs);
assert_eq!(timeline.total_commits, 3);
}
}
mod comprehensive_file_history_view_tests {
use super::*;
use gitstack::FileHistoryEntry;
fn create_test_history() -> Vec<FileHistoryEntry> {
vec![
FileHistoryEntry {
hash: "abc1234".to_string(),
author: "Alice".to_string(),
date: chrono::Local::now(),
message: "Add feature".to_string(),
insertions: 10,
deletions: 5,
},
FileHistoryEntry {
hash: "def5678".to_string(),
author: "Bob".to_string(),
date: chrono::Local::now(),
message: "Fix bug".to_string(),
insertions: 2,
deletions: 1,
},
]
}
#[test]
fn test_file_history_view_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let history = create_test_history();
app.start_file_history_view("test.rs".to_string(), history);
assert_eq!(app.input_mode, InputMode::FileHistoryView);
app.end_file_history_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_file_history_view_navigation() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let history = create_test_history();
app.start_file_history_view("test.rs".to_string(), history);
assert_eq!(app.file_history_view.nav.selected_index, 0);
app.file_history_move_down();
assert_eq!(app.file_history_view.nav.selected_index, 1);
app.file_history_move_down();
assert_eq!(app.file_history_view.nav.selected_index, 1); app.file_history_move_up();
assert_eq!(app.file_history_view.nav.selected_index, 0);
}
#[test]
fn test_file_history_view_selected_entry() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let history = create_test_history();
app.start_file_history_view("test.rs".to_string(), history);
let selected = app.selected_file_history();
assert!(selected.is_some());
assert_eq!(selected.unwrap().hash, "abc1234");
}
#[test]
fn test_file_history_view_empty() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_file_history_view("test.rs".to_string(), vec![]);
assert!(app.selected_file_history().is_none());
app.file_history_move_down();
app.file_history_move_up();
}
}
mod comprehensive_blame_view_tests {
use super::*;
use gitstack::BlameLine;
fn create_test_blame() -> Vec<BlameLine> {
vec![
BlameLine {
hash: "abc1234".to_string(),
author: "Alice".to_string(),
date: chrono::Local::now(),
line_number: 1,
content: "fn main() {".to_string(),
},
BlameLine {
hash: "abc1234".to_string(),
author: "Alice".to_string(),
date: chrono::Local::now(),
line_number: 2,
content: " println!(\"Hello\");".to_string(),
},
BlameLine {
hash: "def5678".to_string(),
author: "Bob".to_string(),
date: chrono::Local::now(),
line_number: 3,
content: "}".to_string(),
},
]
}
#[test]
fn test_blame_view_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let blame = create_test_blame();
app.start_blame_view("main.rs".to_string(), blame);
assert_eq!(app.input_mode, InputMode::BlameView);
app.end_blame_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_blame_view_navigation() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let blame = create_test_blame();
app.start_blame_view("main.rs".to_string(), blame);
assert_eq!(app.blame_view.nav.selected_index, 0);
app.blame_move_down();
assert_eq!(app.blame_view.nav.selected_index, 1);
app.blame_move_down();
assert_eq!(app.blame_view.nav.selected_index, 2);
app.blame_move_down();
assert_eq!(app.blame_view.nav.selected_index, 2); app.blame_move_up();
assert_eq!(app.blame_view.nav.selected_index, 1);
}
#[test]
fn test_blame_view_selected_line() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let blame = create_test_blame();
app.start_blame_view("main.rs".to_string(), blame);
let selected = app.selected_blame_line();
assert!(selected.is_some());
assert_eq!(selected.unwrap().line_number, 1);
}
#[test]
fn test_blame_view_scroll_adjustment() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let blame: Vec<BlameLine> = (0..100)
.map(|i| BlameLine {
hash: "abc1234".to_string(),
author: "Author".to_string(),
date: chrono::Local::now(),
line_number: i + 1,
content: format!("Line {}", i + 1),
})
.collect();
app.start_blame_view("large.rs".to_string(), blame);
for _ in 0..50 {
app.blame_move_down();
}
app.blame_adjust_scroll(20);
assert!(app.blame_view.nav.scroll_offset > 0);
}
}
mod comprehensive_ownership_view_tests {
use super::*;
use gitstack::{CodeOwnership, CodeOwnershipEntry};
fn create_test_ownership() -> CodeOwnership {
let mut ownership = CodeOwnership::default();
ownership.entries.push(CodeOwnershipEntry {
path: "src/".to_string(),
primary_author: "Alice".to_string(),
primary_commits: 50,
total_commits: 100,
depth: 0,
is_directory: true,
});
ownership.entries.push(CodeOwnershipEntry {
path: "src/app.rs".to_string(),
primary_author: "Bob".to_string(),
primary_commits: 30,
total_commits: 40,
depth: 1,
is_directory: false,
});
ownership.total_files = 2;
ownership
}
#[test]
fn test_ownership_view_start_end() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let ownership = create_test_ownership();
app.start_ownership_view(ownership);
assert_eq!(app.input_mode, InputMode::OwnershipView);
app.end_ownership_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_ownership_view_navigation() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let ownership = create_test_ownership();
app.start_ownership_view(ownership);
assert_eq!(app.ownership_view.nav.selected_index, 0);
app.ownership_move_down();
assert_eq!(app.ownership_view.nav.selected_index, 1);
app.ownership_move_down();
assert_eq!(app.ownership_view.nav.selected_index, 1); app.ownership_move_up();
assert_eq!(app.ownership_view.nav.selected_index, 0);
}
#[test]
fn test_ownership_view_selected_entry() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
let ownership = create_test_ownership();
app.start_ownership_view(ownership);
let selected = app.selected_ownership_entry();
assert!(selected.is_some());
assert_eq!(selected.unwrap().path, "src/");
}
#[test]
fn test_ownership_entry_percentage() {
let entry = CodeOwnershipEntry {
path: "test.rs".to_string(),
primary_author: "Alice".to_string(),
primary_commits: 75,
total_commits: 100,
depth: 0,
is_directory: false,
};
assert!((entry.ownership_percentage() - 75.0).abs() < 0.01);
}
#[test]
fn test_ownership_entry_percentage_zero_total() {
let entry = CodeOwnershipEntry {
path: "test.rs".to_string(),
primary_author: "Alice".to_string(),
primary_commits: 0,
total_commits: 0,
depth: 0,
is_directory: false,
};
assert!((entry.ownership_percentage() - 0.0).abs() < 0.01);
}
}
mod comprehensive_topology_health_tests {
use super::*;
use gitstack::{BranchHealth, HealthWarning};
#[test]
fn test_branch_health_new() {
let health = BranchHealth::new();
assert!(health.is_healthy());
assert_eq!(health.warning_count(), 0);
}
#[test]
fn test_branch_health_add_warning() {
let mut health = BranchHealth::new();
health.add_warning(HealthWarning::Stale);
assert!(!health.is_healthy());
assert_eq!(health.warning_count(), 1);
}
#[test]
fn test_branch_health_no_duplicate_warnings() {
let mut health = BranchHealth::new();
health.add_warning(HealthWarning::Stale);
health.add_warning(HealthWarning::Stale);
assert_eq!(health.warning_count(), 1);
}
#[test]
fn test_branch_health_multiple_warnings() {
let mut health = BranchHealth::new();
health.add_warning(HealthWarning::Stale);
health.add_warning(HealthWarning::LongLived);
health.add_warning(HealthWarning::FarBehind);
health.add_warning(HealthWarning::LargeDivergence);
assert_eq!(health.warning_count(), 4);
assert!(!health.is_healthy());
}
#[test]
fn test_branch_health_warning_icons() {
let mut health = BranchHealth::new();
health.add_warning(HealthWarning::Stale);
health.add_warning(HealthWarning::LongLived);
let icons = health.warning_icons();
assert!(icons.contains("⚠"));
assert!(icons.contains("⏳"));
}
#[test]
fn test_health_warning_description() {
assert_eq!(
HealthWarning::Stale.description(),
"No activity for 30+ days"
);
assert_eq!(
HealthWarning::LongLived.description(),
"Branch exists for 60+ days"
);
assert_eq!(
HealthWarning::FarBehind.description(),
"50+ commits behind main"
);
assert_eq!(
HealthWarning::LargeDivergence.description(),
"Large divergence from main"
);
}
#[test]
fn test_health_warning_icon() {
assert_eq!(HealthWarning::Stale.icon(), "⚠");
assert_eq!(HealthWarning::LongLived.icon(), "⏳");
assert_eq!(HealthWarning::FarBehind.icon(), "⬇");
assert_eq!(HealthWarning::LargeDivergence.icon(), "⚡");
}
#[test]
fn test_topology_unhealthy_count() {
let (_temp_dir, repo) = init_test_repo();
let config = TopologyConfig {
stale_threshold_days: 0, long_lived_threshold_days: 0,
far_behind_threshold: 50,
divergence_threshold: 20,
max_branches: 50,
};
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
let _unhealthy = topology.unhealthy_count();
}
#[test]
fn test_topology_warning_count() {
let (_temp_dir, repo) = init_test_repo();
let config = TopologyConfig {
stale_threshold_days: 0,
long_lived_threshold_days: 0,
far_behind_threshold: 50,
divergence_threshold: 20,
max_branches: 50,
};
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
let _stale_count = topology.warning_count(HealthWarning::Stale);
}
}
mod comprehensive_e2e_workflow_tests {
use super::*;
#[test]
fn test_e2e_new_user_exploration() {
let (_temp_dir, repo) = init_test_repo();
for i in 1..=5 {
let file = _temp_dir.path().join(format!("file{}.txt", i));
fs::write(&file, format!("content {}", i)).unwrap();
stage_file_in_repo(&repo, &format!("file{}.txt", i)).unwrap();
create_commit_in_repo(&repo, &format!("Add file {}", i)).unwrap();
}
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events);
assert!(app.event_count() >= 5);
app.move_down();
app.move_down();
assert_eq!(app.selected_index, 2);
app.toggle_help();
assert!(app.show_help);
app.close_help();
assert!(!app.show_help);
app.open_detail();
assert!(app.show_detail);
app.close_detail();
assert!(!app.show_detail);
app.start_filter();
for c in "file 3".chars() {
app.filter_push(c);
}
app.end_filter();
app.filter_clear();
assert!(app.event_count() >= 5);
}
#[test]
fn test_e2e_developer_commit_workflow() {
let (temp_dir, repo) = init_test_repo();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.set_repo(repo);
app.load(repo_info, events);
let new_file = temp_dir.path().join("feature.rs");
fs::write(&new_file, "// new feature").unwrap();
if let Ok(statuses) = get_status_from_repo(app.get_repo().unwrap()) {
app.start_status_view(statuses);
}
assert_eq!(app.input_mode, InputMode::StatusView);
stage_file_in_repo(app.get_repo().unwrap(), "feature.rs").unwrap();
if let Ok(statuses) = get_status_from_repo(app.get_repo().unwrap()) {
app.update_file_statuses(statuses);
}
app.start_commit_input();
assert_eq!(app.input_mode, InputMode::CommitInput);
app.select_commit_type(gitstack::CommitType::Feat);
assert!(app.commit_message.starts_with("feat: "));
for c in "add new feature".chars() {
app.commit_message_push(c);
}
create_commit_in_repo(app.get_repo().unwrap(), &app.commit_message).unwrap();
app.commit_message_clear();
app.end_commit_input();
app.end_status_view();
}
#[test]
fn test_e2e_team_lead_branch_analysis() {
let (_temp_dir, repo) = init_test_repo();
{
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch("feature/auth", &head, false).unwrap();
repo.branch("feature/api", &head, false).unwrap();
repo.branch("bugfix/login", &head, false).unwrap();
}
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let branches = list_branches_from_repo(&repo).unwrap();
let config = TopologyConfig::default();
let topology = analyze_topology_from_repo(&repo, &config).unwrap();
let mut app = App::new();
app.set_repo(repo);
app.load(repo_info, events);
app.start_branch_select(branches);
assert_eq!(app.input_mode, InputMode::BranchSelect);
app.branch_move_down();
app.branch_move_down();
app.end_branch_select();
assert_eq!(app.input_mode, InputMode::Normal);
app.start_topology_view(topology);
assert_eq!(app.input_mode, InputMode::TopologyView);
app.topology_move_down();
app.topology_move_up();
app.end_topology_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_e2e_reviewer_stats_analysis() {
let (_temp_dir, repo) = init_test_repo();
for i in 1..=3 {
let file = _temp_dir.path().join(format!("review{}.txt", i));
fs::write(&file, format!("review content {}", i)).unwrap();
stage_file_in_repo(&repo, &format!("review{}.txt", i)).unwrap();
create_commit_in_repo(&repo, &format!("Review commit {}", i)).unwrap();
}
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.load(repo_info, events.clone());
let refs: Vec<&gitstack::GitEvent> = events.iter().collect();
let stats = gitstack::calculate_stats(&refs);
app.start_stats_view(stats);
assert_eq!(app.input_mode, InputMode::StatsView);
if let Some(author) = app.selected_author() {
let author = author.to_string();
app.end_stats_view();
app.filter_by_author(&author);
}
let heatmap = gitstack::calculate_file_heatmap(&refs, |hash| {
get_commit_files_from_repo(&repo, hash).ok()
});
app.filter_clear();
app.start_heatmap_view(heatmap);
assert_eq!(app.input_mode, InputMode::HeatmapView);
app.heatmap_move_down();
app.end_heatmap_view();
let timeline = gitstack::calculate_activity_timeline(&refs);
app.start_timeline_view(timeline);
assert_eq!(app.input_mode, InputMode::TimelineView);
app.end_timeline_view();
}
#[test]
fn test_e2e_user_quits() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
assert!(!app.should_quit);
app.quit();
assert!(app.should_quit);
}
#[test]
fn test_e2e_filter_cancel_on_esc() {
let mut app = App::new();
let events = create_test_events(&["Commit 1", "Commit 2", "Commit 3"]);
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
let initial_count = app.event_count();
app.start_filter();
app.filter_push('1');
app.filter_clear();
app.end_filter();
assert_eq!(app.input_mode, InputMode::Normal);
assert_eq!(app.event_count(), initial_count);
}
#[test]
fn test_e2e_detail_to_file_history_to_blame() {
let (temp_dir, repo) = init_test_repo();
let file = temp_dir.path().join("module.rs");
fs::write(&file, "// module content\nfn main() {}").unwrap();
stage_file_in_repo(&repo, "module.rs").unwrap();
create_commit_in_repo(&repo, "Add module").unwrap();
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.set_repo(repo);
app.load(repo_info, events);
app.open_detail();
assert!(app.show_detail);
let history = vec![gitstack::FileHistoryEntry {
hash: "abc1234".to_string(),
author: "Test".to_string(),
date: chrono::Local::now(),
message: "Add module".to_string(),
insertions: 2,
deletions: 0,
}];
app.close_detail();
app.start_file_history_view("module.rs".to_string(), history);
assert_eq!(app.input_mode, InputMode::FileHistoryView);
app.end_file_history_view();
let blame = vec![gitstack::BlameLine {
hash: "abc1234".to_string(),
author: "Test".to_string(),
date: chrono::Local::now(),
line_number: 1,
content: "// module content".to_string(),
}];
app.start_blame_view("module.rs".to_string(), blame);
assert_eq!(app.input_mode, InputMode::BlameView);
app.end_blame_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_e2e_code_ownership_analysis() {
let (temp_dir, repo) = init_test_repo();
fs::create_dir_all(temp_dir.path().join("src/auth")).unwrap();
fs::create_dir_all(temp_dir.path().join("src/api")).unwrap();
let files = [
"src/auth/login.rs",
"src/auth/logout.rs",
"src/api/users.rs",
];
for file in &files {
let path = temp_dir.path().join(file);
fs::write(&path, format!("// {}", file)).unwrap();
stage_file_in_repo(&repo, file).unwrap();
create_commit_in_repo(&repo, &format!("Add {}", file)).unwrap();
}
let repo_info = RepoInfo::from_repo(&repo).unwrap();
let events = load_events_from_repo(&repo, 50, true).unwrap();
let mut app = App::new();
app.set_repo(repo);
app.load(repo_info, events.clone());
let refs: Vec<&gitstack::GitEvent> = events.iter().collect();
let ownership = gitstack::calculate_ownership(&refs, |hash| {
get_commit_files_from_repo(app.get_repo().unwrap(), hash).ok()
});
app.start_ownership_view(ownership);
assert_eq!(app.input_mode, InputMode::OwnershipView);
app.ownership_move_down();
app.ownership_move_up();
app.end_ownership_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_e2e_rapid_mode_transitions() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit 1", "Commit 2"]),
);
app.start_filter();
assert_eq!(app.input_mode, InputMode::Filter);
app.end_filter();
assert_eq!(app.input_mode, InputMode::Normal);
app.toggle_help();
assert!(app.show_help);
app.close_help();
assert!(!app.show_help);
app.open_detail();
assert!(app.show_detail);
app.close_detail();
assert!(!app.show_detail);
app.start_branch_select(branch_infos(&["main"]));
assert_eq!(app.input_mode, InputMode::BranchSelect);
app.end_branch_select();
assert_eq!(app.input_mode, InputMode::Normal);
let stats = gitstack::RepoStats::default();
app.start_stats_view(stats);
assert_eq!(app.input_mode, InputMode::StatsView);
app.end_stats_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
}
mod edge_case_tests {
use super::*;
#[test]
fn test_empty_repository() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo = Repository::init(temp_dir.path()).expect("Failed to init repo");
let events = load_events_from_repo(&repo, 50, true).unwrap_or_default();
assert!(events.is_empty());
let mut app = App::new();
app.load(
RepoInfo {
name: "empty".to_string(),
branch: "main".to_string(),
},
events,
);
app.move_down();
app.move_up();
app.move_to_top();
app.move_to_bottom();
app.open_detail();
app.close_detail();
}
#[test]
fn test_very_long_commit_message() {
let long_message = "a".repeat(10000);
let events = create_test_events(&[&long_message]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
assert_eq!(app.event_count(), 1);
assert_eq!(app.event_at(0).unwrap().message.len(), 10000);
}
#[test]
fn test_special_characters_in_filter() {
let events = create_test_events(&["Fix: bug (issue #123)", "feat[scope]: add feature"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
for c in "#123".chars() {
app.filter_push(c);
}
app.end_filter();
assert_eq!(app.event_count(), 1);
}
#[test]
fn test_unicode_in_branch_names() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
app.start_branch_select(branch_infos(&[
"main",
"feature/日本語",
"bugfix/한국어",
"feature/émoji-🚀",
]));
assert_eq!(app.branches.len(), 4);
app.branch_move_down();
assert_eq!(app.selected_branch(), Some("feature/日本語"));
}
#[test]
fn test_very_large_event_list() {
let events: Vec<GitEvent> = (0..10000)
.map(|i| {
GitEvent::commit(
format!("hash{:05}", i),
format!("Commit {}", i),
"Author".to_string(),
chrono::Local::now(),
1,
0,
)
})
.collect();
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
assert_eq!(app.event_count(), 10000);
app.move_to_bottom();
assert_eq!(app.selected_index, 9999);
app.move_to_top();
assert_eq!(app.selected_index, 0);
app.page_down(100);
assert_eq!(app.selected_index, 100);
}
#[test]
fn test_concurrent_state_changes() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit 1", "Commit 2"]),
);
app.start_filter();
app.filter_push('a');
app.move_down(); app.end_filter();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_status_message_lifecycle() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
create_test_events(&["Commit"]),
);
assert!(app.status_message.is_none());
app.set_status_message("Test message".to_string());
assert_eq!(app.status_message.as_deref(), Some("Test message"));
app.status_message = None;
assert!(app.status_message.is_none());
}
#[test]
fn test_filter_with_empty_string() {
let events = create_test_events(&["Commit 1", "Commit 2"]);
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.start_filter();
app.end_filter();
assert_eq!(app.event_count(), 2); }
#[test]
fn test_selected_event_with_no_events() {
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
vec![],
);
assert!(app.selected_event().is_none());
}
#[test]
fn test_selection_index_boundaries() {
let events: Vec<GitEvent> = (0..100)
.map(|i| {
GitEvent::commit(
format!("hash{}", i),
format!("Commit {}", i),
"Author".to_string(),
chrono::Local::now(),
1,
0,
)
})
.collect();
let mut app = App::new();
app.load(
RepoInfo {
name: "test".to_string(),
branch: "main".to_string(),
},
events,
);
app.selected_index = 0;
app.move_up();
assert_eq!(app.selected_index, 0);
app.move_to_bottom();
assert_eq!(app.selected_index, 99);
app.move_down();
assert_eq!(app.selected_index, 99);
}
}
mod cross_cell_integration {
use super::*;
#[test]
fn test_cross_cell_colors_are_valid() {
let (temp_dir, repo) = init_test_repo();
for branch in &["feat1", "feat2"] {
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch(branch, &head, false).unwrap();
checkout_branch_in_repo(&repo, branch).unwrap();
let file = temp_dir.path().join(format!("{}.txt", branch));
fs::write(&file, branch).unwrap();
stage_file_in_repo(&repo, &format!("{}.txt", branch)).unwrap();
create_commit_in_repo(&repo, &format!("{} commit", branch)).unwrap();
}
let events = load_events_from_repo(&repo, 50, true).unwrap();
let layout = build_graph(&events, None);
for row in &layout.rows {
for cell in &row.cells {
if let GraphCell::Cross { h_color, v_color } = cell {
assert!(*h_color < 8, "h_color should be valid");
assert!(*v_color < 8, "v_color should be valid");
}
}
}
}
#[test]
fn test_cross_cell_position_is_valid() {
let (temp_dir, repo) = init_test_repo();
for branch in &["feat1", "feat2", "feat3"] {
let head = repo.head().unwrap().peel_to_commit().unwrap();
repo.branch(branch, &head, false).unwrap();
checkout_branch_in_repo(&repo, branch).unwrap();
let file = temp_dir.path().join(format!("{}.txt", branch));
fs::write(&file, branch).unwrap();
stage_file_in_repo(&repo, &format!("{}.txt", branch)).unwrap();
create_commit_in_repo(&repo, &format!("{} commit", branch)).unwrap();
}
let events = load_events_from_repo(&repo, 50, true).unwrap();
let layout = build_graph(&events, None);
for row in &layout.rows {
for (idx, cell) in row.cells.iter().enumerate() {
if matches!(cell, GraphCell::Cross { .. }) {
assert!(idx < row.cells.len(), "Cross cell index should be valid");
}
}
}
}
}