use super::*;
#[test]
fn time_window_default_is_six_months() {
let window = TimeWindow::default();
assert_eq!(window.default_months, 6);
assert!(window.since.is_some());
assert!(window.until.is_some());
let since = window.since.unwrap();
let until = window.until.unwrap();
let diff = until - since;
assert!(diff.num_days() >= 179 && diff.num_days() <= 181);
}
#[test]
fn time_window_contains_timestamp_in_range() {
let window = TimeWindow::default();
let now = Utc::now();
let three_months_ago = now - Duration::days(90);
assert!(window.contains(&three_months_ago));
}
#[test]
fn time_window_excludes_timestamp_before_window() {
let window = TimeWindow::default();
let now = Utc::now();
let one_year_ago = now - Duration::days(365);
assert!(!window.contains(&one_year_ago));
}
#[test]
fn time_window_excludes_timestamp_after_window() {
let now = Utc::now();
let window = TimeWindow {
since: Some(now - Duration::days(180)),
until: Some(now - Duration::days(30)),
default_months: 6,
};
assert!(!window.contains(&now));
}
#[test]
fn time_window_full_history_contains_everything() {
let window = TimeWindow::full_history();
let ancient = Utc::now() - Duration::days(10000);
let future = Utc::now() + Duration::days(10000);
assert!(window.contains(&ancient));
assert!(window.contains(&future));
}
#[test]
fn repo_snapshot_new_creates_empty() {
let snapshot = RepoSnapshot::new(
PathBuf::from("/tmp/test"),
"test-repo".to_string(),
"main".to_string(),
TimeWindow::default(),
);
assert_eq!(snapshot.name, "test-repo");
assert_eq!(snapshot.default_branch, "main");
assert!(snapshot.commits.is_empty());
assert!(snapshot.files.is_empty());
assert!(snapshot.authors.is_empty());
assert!(snapshot.blame_map.is_empty());
assert!(snapshot.file_metrics.is_empty());
}
#[test]
fn file_metrics_starts_empty() {
let snapshot = RepoSnapshot::new(
PathBuf::from("/tmp/test"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
assert!(snapshot.file_metrics.is_empty());
}
fn make_commit(id: u32, author: AuthorId, files: Vec<&str>) -> Commit {
Commit {
id: CommitId(id),
author,
timestamp: Utc::now(),
message: format!("Commit {}", id),
files_changed: files
.into_iter()
.map(|f| FileChange {
path: PathBuf::from(f),
additions: 1,
deletions: 0,
change_type: ChangeType::Modified,
})
.collect(),
is_merge: false,
parent_count: 1,
}
}
#[test]
fn build_commits_by_author_groups_correctly() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
snapshot.commits = vec![
make_commit(0, 0, vec!["f1"]),
make_commit(1, 0, vec!["f2"]),
make_commit(2, 1, vec!["f1"]),
];
snapshot.build_indexes();
assert_eq!(snapshot.commits_by_author[&0].len(), 2);
assert_eq!(snapshot.commits_by_author[&1].len(), 1);
}
#[test]
fn build_commits_by_file_maps_correctly() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
snapshot.commits = vec![
make_commit(0, 0, vec!["a.rs", "b.rs"]),
make_commit(1, 1, vec!["a.rs"]),
];
snapshot.build_indexes();
assert_eq!(snapshot.commits_by_file[&PathBuf::from("a.rs")].len(), 2);
assert_eq!(snapshot.commits_by_file[&PathBuf::from("b.rs")].len(), 1);
}
fn make_file(path: &str) -> FileEntry {
FileEntry {
path: PathBuf::from(path),
size_bytes: 100,
is_binary: false,
depth: 1,
blob_oid: String::new(),
}
}
#[test]
fn build_file_change_pairs_detects_coupling() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
snapshot.files = vec![make_file("a.rs"), make_file("b.rs"), make_file("c.rs")];
snapshot.commits = vec![
make_commit(0, 0, vec!["a.rs", "b.rs"]),
make_commit(1, 0, vec!["a.rs", "b.rs"]),
make_commit(2, 0, vec!["a.rs", "b.rs"]),
make_commit(3, 0, vec!["a.rs", "b.rs", "c.rs"]),
make_commit(4, 0, vec!["a.rs", "b.rs"]),
];
snapshot.build_indexes();
let ab_pair = snapshot.file_change_pairs.iter().find(|(a, b, _)| {
(a == &PathBuf::from("a.rs") && b == &PathBuf::from("b.rs"))
|| (a == &PathBuf::from("b.rs") && b == &PathBuf::from("a.rs"))
});
assert!(ab_pair.is_some(), "a.rs and b.rs should be coupled");
assert_eq!(ab_pair.unwrap().2, 5);
let ac_pair = snapshot.file_change_pairs.iter().find(|(a, b, _)| {
(a == &PathBuf::from("a.rs") && b == &PathBuf::from("c.rs"))
|| (a == &PathBuf::from("c.rs") && b == &PathBuf::from("a.rs"))
});
assert!(
ac_pair.is_none(),
"a.rs and c.rs should NOT be coupled (only 1 co-change)"
);
}
#[test]
fn build_file_change_pairs_excludes_files_not_in_tree() {
let mut snapshot = RepoSnapshot::new(
PathBuf::from("/tmp"),
"test".into(),
"main".into(),
TimeWindow::default(),
);
snapshot.files = vec![make_file("a.rs"), make_file("b.rs")];
snapshot.commits = vec![
make_commit(0, 0, vec!["a.rs", "b.rs", "src/i18n/en.ts"]),
make_commit(1, 0, vec!["a.rs", "b.rs", "src/i18n/en.ts"]),
make_commit(2, 0, vec!["a.rs", "b.rs", "src/i18n/en.ts"]),
];
snapshot.build_indexes();
assert!(
snapshot
.file_change_pairs
.iter()
.any(|(a, b, _)| a == &PathBuf::from("a.rs") && b == &PathBuf::from("b.rs")),
"a.rs and b.rs should be coupled"
);
assert!(
!snapshot
.file_change_pairs
.iter()
.any(|(a, b, _)| a.to_string_lossy().contains("i18n")
|| b.to_string_lossy().contains("i18n")),
"excluded i18n files should not appear in coupling pairs"
);
}
#[test]
fn co_changed_pairs_empty_commits_returns_empty() {
let known: std::collections::HashSet<&PathBuf> = std::collections::HashSet::new();
assert!(count_co_changed_pairs(&[], &known).is_empty());
}
#[test]
fn co_changed_pairs_below_threshold_excluded() {
let a = PathBuf::from("a.rs");
let b = PathBuf::from("b.rs");
let known: std::collections::HashSet<&PathBuf> = [&a, &b].into_iter().collect();
let commits = vec![
make_commit(0, 0, vec!["a.rs", "b.rs"]),
make_commit(1, 0, vec!["a.rs", "b.rs"]),
];
assert!(count_co_changed_pairs(&commits, &known).is_empty());
}
#[test]
fn co_changed_pairs_at_threshold_included() {
let a = PathBuf::from("a.rs");
let b = PathBuf::from("b.rs");
let known: std::collections::HashSet<&PathBuf> = [&a, &b].into_iter().collect();
let commits = vec![
make_commit(0, 0, vec!["a.rs", "b.rs"]),
make_commit(1, 0, vec!["a.rs", "b.rs"]),
make_commit(2, 0, vec!["a.rs", "b.rs"]),
];
let pairs = count_co_changed_pairs(&commits, &known);
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].2, 3);
}
#[test]
fn co_changed_pairs_unknown_files_excluded() {
let a = PathBuf::from("a.rs");
let b = PathBuf::from("b.rs");
let known: std::collections::HashSet<&PathBuf> = [&a, &b].into_iter().collect();
let commits = vec![
make_commit(0, 0, vec!["a.rs", "b.rs", "unknown.rs"]),
make_commit(1, 0, vec!["a.rs", "b.rs", "unknown.rs"]),
make_commit(2, 0, vec!["a.rs", "b.rs", "unknown.rs"]),
];
let pairs = count_co_changed_pairs(&commits, &known);
assert!(pairs
.iter()
.all(|(x, y, _)| x != &PathBuf::from("unknown.rs") && y != &PathBuf::from("unknown.rs")));
assert_eq!(pairs.len(), 1);
}
#[test]
fn co_changed_pairs_normalized_a_less_than_b() {
let a = PathBuf::from("a.rs");
let b = PathBuf::from("z.rs");
let known: std::collections::HashSet<&PathBuf> = [&a, &b].into_iter().collect();
let commits = (0..3)
.map(|i| make_commit(i, 0, vec!["z.rs", "a.rs"]))
.collect::<Vec<_>>();
let pairs = count_co_changed_pairs(&commits, &known);
assert_eq!(pairs.len(), 1);
assert!(pairs[0].0 < pairs[0].1, "pair should be normalized a < b");
}
#[test]
fn repo_snapshot_serialization_roundtrip() {
let snapshot = RepoSnapshot::new(
PathBuf::from("/tmp/test"),
"test-repo".to_string(),
"main".to_string(),
TimeWindow::default(),
);
let encoded = bincode::serialize(&snapshot).expect("Failed to serialize");
let decoded: RepoSnapshot = bincode::deserialize(&encoded).expect("Failed to deserialize");
assert_eq!(decoded.name, snapshot.name);
assert_eq!(decoded.default_branch, snapshot.default_branch);
assert_eq!(decoded.path, snapshot.path);
}