use std::path::Path;
use super::*;
fn author(name: &str) -> AuthorId {
AuthorId::new(b"", format!("{name}@example.com").as_bytes())
}
fn file(path: &str, contributions: &[(&str, u32, bool)]) -> FileAuthorship {
FileAuthorship {
path: PathBuf::from(path),
contributions: contributions
.iter()
.map(|&(name, deliveries, first_authorship)| AuthorContribution {
author: author(name),
deliveries,
first_authorship,
})
.collect(),
}
}
#[test]
fn schema_version_is_pinned() {
assert_eq!(BUS_FACTOR_SCHEMA_VERSION, 2);
}
#[test]
fn empty_input_yields_zero_everywhere() {
let bf = compute(&[], DEFAULT_COVERAGE_THRESHOLD, false, None);
assert_eq!(bf.repo, GroupBusFactor::default());
assert!(bf.by_directory.is_empty());
}
#[test]
fn single_author_repo_has_bus_factor_one() {
let files = [
file("a.rs", &[("alice", 10, true)]),
file("b.rs", &[("alice", 5, false)]),
file("c.rs", &[("alice", 3, false)]),
];
let bf = compute(&files, DEFAULT_COVERAGE_THRESHOLD, false, None);
assert_eq!(bf.repo.bus_factor, 1);
assert_eq!(bf.repo.files, 3);
assert_eq!(bf.repo.authors, 1);
}
#[test]
fn two_single_author_files_need_both_owners_removed() {
let files = [
file("a.rs", &[("alice", 5, true)]),
file("b.rs", &[("bob", 5, true)]),
];
let bf = compute(&files, DEFAULT_COVERAGE_THRESHOLD, false, None);
assert_eq!(bf.repo.bus_factor, 2);
assert_eq!(bf.repo.authors, 2);
}
#[test]
fn shared_authorship_raises_the_factor() {
let files = [
file("a.rs", &[("alice", 5, true), ("bob", 5, false)]),
file("b.rs", &[("alice", 5, false), ("bob", 5, true)]),
file("c.rs", &[("alice", 5, true), ("bob", 5, false)]),
file("d.rs", &[("alice", 5, false), ("bob", 5, true)]),
];
let bf = compute(&files, DEFAULT_COVERAGE_THRESHOLD, false, None);
assert_eq!(bf.repo.bus_factor, 2);
}
#[test]
fn dominant_author_excludes_minor_contributor() {
let files = [
file("a.rs", &[("alice", 40, true), ("bob", 1, false)]),
file("b.rs", &[("alice", 40, true), ("bob", 1, false)]),
];
let bf = compute(&files, DEFAULT_COVERAGE_THRESHOLD, false, None);
assert_eq!(bf.repo.bus_factor, 1);
assert_eq!(bf.repo.authors, 1);
}
#[test]
fn coverage_threshold_changes_the_factor() {
let files = [
file("a.rs", &[("alice", 5, true)]),
file("b.rs", &[("bob", 5, true)]),
file("c.rs", &[("carol", 5, true)]),
];
assert_eq!(compute(&files, 0.5, false, None).repo.bus_factor, 2);
assert_eq!(compute(&files, 0.9, false, None).repo.bus_factor, 3);
}
#[test]
fn directory_grouping_is_independent_of_the_repo() {
let files = [
file("dir1/a.rs", &[("alice", 5, true)]),
file("dir1/b.rs", &[("alice", 5, false)]),
file("dir2/c.rs", &[("bob", 5, true)]),
file("dir2/d.rs", &[("bob", 5, false)]),
];
let bf = compute(&files, DEFAULT_COVERAGE_THRESHOLD, false, None);
assert_eq!(bf.repo.bus_factor, 2);
let dirs: Vec<(&str, u32)> = bf
.by_directory
.iter()
.map(|d| (d.directory.as_str(), d.group.bus_factor))
.collect();
assert_eq!(dirs, vec![("dir1", 1), ("dir2", 1)]);
}
#[test]
fn directory_grouping_breaks_out_depth_two() {
let files = [
file("src/vcs/git/mod.rs", &[("alice", 5, true)]),
file("src/vcs/score.rs", &[("alice", 5, false)]),
file("src/metrics/abc.rs", &[("bob", 5, true)]),
file("README.md", &[("carol", 5, true)]),
];
let bf = compute(&files, DEFAULT_COVERAGE_THRESHOLD, false, None);
let dirs: Vec<&str> = bf
.by_directory
.iter()
.map(|d| d.directory.as_str())
.collect();
assert_eq!(dirs, vec!["src", "src/metrics", "src/vcs"]);
}
#[test]
fn result_is_independent_of_input_order() {
let files = [
file("dir1/a.rs", &[("alice", 7, true), ("bob", 2, false)]),
file("dir1/b.rs", &[("bob", 9, true), ("alice", 1, false)]),
file("dir2/c.rs", &[("carol", 4, true)]),
];
let mut reversed = files.to_vec();
reversed.reverse();
assert_eq!(
compute(&files, DEFAULT_COVERAGE_THRESHOLD, true, None),
compute(&reversed, DEFAULT_COVERAGE_THRESHOLD, true, None),
);
}
#[test]
fn key_author_ids_emitted_only_on_opt_in() {
let files = [file("a.rs", &[("alice", 5, true)])];
assert!(
compute(&files, DEFAULT_COVERAGE_THRESHOLD, false, None)
.repo
.key_author_ids
.is_none()
);
let opted = compute(&files, DEFAULT_COVERAGE_THRESHOLD, true, None);
let ids = opted.repo.key_author_ids.expect("opt-in emits ids");
assert_eq!(ids.len(), 1);
assert_eq!(ids[0], author("alice").hashed());
assert!(!ids[0].contains('@'));
}
#[test]
fn author_hash_key_hardens_ids_without_changing_the_factor() {
let files = [
file("a.rs", &[("alice", 5, true)]),
file("b.rs", &[("bob", 5, true)]),
];
let key = AuthorHashKey::new(b"team-secret".to_vec()).expect("non-empty");
let unkeyed = compute(&files, DEFAULT_COVERAGE_THRESHOLD, true, None);
let keyed = compute(&files, DEFAULT_COVERAGE_THRESHOLD, true, Some(&key));
assert_eq!(keyed.repo.bus_factor, unkeyed.repo.bus_factor);
assert_eq!(keyed.repo.authors, unkeyed.repo.authors);
let unkeyed_ids = unkeyed.repo.key_author_ids.expect("opt-in emits ids");
let keyed_ids = keyed.repo.key_author_ids.expect("opt-in emits ids");
assert_eq!(keyed_ids.len(), unkeyed_ids.len());
assert_ne!(keyed_ids, unkeyed_ids);
let expected: Vec<String> = unkeyed_ids
.iter()
.map(|digest| AuthorId::from_digest(digest.clone()).emit_hashed(Some(&key)))
.collect();
assert_eq!(keyed_ids, expected);
}
#[test]
fn doa_rises_with_first_authorship_and_deliveries_falls_with_accepted() {
let base = AuthorContribution {
author: author("alice"),
deliveries: 5,
first_authorship: false,
};
let with_fa = AuthorContribution {
first_authorship: true,
..base.clone()
};
let with_more_dl = AuthorContribution {
deliveries: 20,
..base.clone()
};
assert!(
doa(&with_fa, 3) > doa(&base, 3),
"first authorship raises DoA"
);
assert!(
doa(&with_more_dl, 3) > doa(&base, 3),
"deliveries raise DoA"
);
assert!(
doa(&base, 50) < doa(&base, 3),
"more accepted (other-author) changes lower DoA"
);
}
#[test]
fn directory_keys_cover_depth_one_and_two_only() {
assert!(directory_keys(Path::new("README.md")).is_empty());
assert_eq!(
directory_keys(Path::new("src/lib.rs")),
vec![PathBuf::from("src")]
);
assert_eq!(
directory_keys(Path::new("src/vcs/git/mod.rs")),
vec![PathBuf::from("src"), PathBuf::from("src/vcs")]
);
}
#[test]
fn coverage_threshold_is_clamped_into_the_open_interval() {
assert!((clamp_threshold(f64::NAN) - DEFAULT_COVERAGE_THRESHOLD).abs() < f64::EPSILON);
assert!(clamp_threshold(0.0) > 0.0);
assert!(clamp_threshold(1.0) < 1.0);
assert!(clamp_threshold(-5.0) > 0.0);
assert!((clamp_threshold(0.5) - 0.5).abs() < f64::EPSILON);
}
#[test]
fn authors_of_file_is_independent_of_contribution_order() {
let forward = file("m.rs", &[("alice", 4, true), ("bob", 4, true)]);
let mut backward_contribs = forward.contributions.clone();
backward_contribs.reverse();
let backward = FileAuthorship {
path: forward.path.clone(),
contributions: backward_contribs,
};
let mut a = authors_of_file(&forward);
let mut b = authors_of_file(&backward);
a.sort_by_key(|id| id.hashed());
b.sort_by_key(|id| id.hashed());
assert_eq!(a, b, "resolved authors must not depend on input order");
assert_eq!(
compute(
std::slice::from_ref(&forward),
DEFAULT_COVERAGE_THRESHOLD,
true,
None,
),
compute(
std::slice::from_ref(&backward),
DEFAULT_COVERAGE_THRESHOLD,
true,
None,
),
);
}
#[test]
fn compute_ignores_empty_contribution_files() {
let real = file("a.rs", &[("alice", 5, true)]);
let empty = FileAuthorship {
path: PathBuf::from("b.rs"),
contributions: Vec::new(),
};
let with_empty = compute(
&[real.clone(), empty],
DEFAULT_COVERAGE_THRESHOLD,
false,
None,
);
let without = compute(
std::slice::from_ref(&real),
DEFAULT_COVERAGE_THRESHOLD,
false,
None,
);
assert_eq!(with_empty.repo, without.repo);
assert_eq!(with_empty.repo.files, 1, "the empty file is excluded");
}
#[test]
fn authors_of_file_degenerate_tie_break_is_order_independent() {
const DEGENERATE_AUTHOR_COUNT: usize = 50_000;
let names: Vec<String> = (0..DEGENERATE_AUTHOR_COUNT)
.map(|i| format!("dev{i:05}"))
.collect();
let contrib_refs: Vec<(&str, u32, bool)> = names
.iter()
.map(|name| (name.as_str(), 1u32, false))
.collect();
let forward = file("huge.rs", &contrib_refs);
let mut backward_contribs = forward.contributions.clone();
backward_contribs.reverse();
let backward = FileAuthorship {
path: forward.path.clone(),
contributions: backward_contribs,
};
let forward_authors = authors_of_file(&forward);
let backward_authors = authors_of_file(&backward);
assert_eq!(
forward_authors.len(),
1,
"degenerate branch credits exactly one author"
);
assert_eq!(
backward_authors.len(),
1,
"degenerate branch credits exactly one author"
);
assert_eq!(
forward_authors[0], backward_authors[0],
"degenerate-path author resolution must not depend on input order"
);
let expected = forward
.contributions
.iter()
.map(|c| &c.author)
.min_by_key(|id| id.hashed())
.expect("non-empty contributions");
assert_eq!(
forward_authors[0], expected,
"tie-break must pick the smallest hashed id"
);
}