#![allow(clippy::float_cmp)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::{CochangeGraph, FileId, MAX_COCHANGE_COMMIT_FILES, node_entropy, shannon_entropy};
const LOG2_3: f64 = 1.584_962_500_721_156;
fn entropy(weights: &[f64]) -> f64 {
shannon_entropy(weights.iter().copied())
}
fn paths(names: &[&str]) -> Vec<PathBuf> {
names.iter().map(PathBuf::from).collect()
}
#[test]
fn empty_and_singleton_distributions_have_zero_entropy() {
assert_eq!(entropy(&[]), 0.0);
assert_eq!(entropy(&[5.0]), 0.0);
assert_eq!(entropy(&[0.0, 7.0, 0.0]), 0.0);
assert!(!entropy(&[7.0]).is_sign_negative());
}
#[test]
fn uniform_distribution_entropy_is_log2_of_support() {
assert!((entropy(&[1.0, 1.0]) - 1.0).abs() < 1e-12);
assert!((entropy(&[2.0, 2.0, 2.0]) - LOG2_3).abs() < 1e-12);
}
#[test]
fn skewed_distribution_entropy_matches_closed_form() {
let h = entropy(&[3.0, 1.0]);
assert!((h - 0.811_278_124_459_133).abs() < 1e-12, "got {h}");
assert!(h < 1.0);
}
#[test]
fn negative_and_zero_weights_are_ignored() {
let h = entropy(&[1.0, 1.0, 0.0, -4.0]);
assert!((h - 1.0).abs() < 1e-12, "got {h}");
assert!(h.is_finite());
}
#[test]
fn single_file_commits_yield_no_cochange_edges() {
let mut g = CochangeGraph::new();
g.record_commit(&paths(&["a.rs"]), true);
g.record_commit(&paths(&["b.rs"]), true);
assert_eq!(g.entropy(Path::new("a.rs")), (0.0, 0.0));
assert_eq!(g.entropy(Path::new("b.rs")), (0.0, 0.0));
assert_eq!(g.entropy(Path::new("never.rs")), (0.0, 0.0));
}
#[test]
fn triangle_commit_gives_each_node_uniform_two_edges() {
let mut g = CochangeGraph::new();
g.record_commit(&paths(&["a.rs", "b.rs", "c.rs"]), true);
for f in ["a.rs", "b.rs", "c.rs"] {
let (long, recent) = g.entropy(Path::new(f));
assert!((long - 1.0).abs() < 1e-12, "{f} long = {long}");
assert!((recent - 1.0).abs() < 1e-12, "{f} recent = {recent}");
}
}
#[test]
fn hub_has_higher_entropy_than_its_leaves() {
let mut g = CochangeGraph::new();
g.record_commit(&paths(&["hub.rs", "a.rs"]), true);
g.record_commit(&paths(&["hub.rs", "b.rs"]), true);
g.record_commit(&paths(&["hub.rs", "c.rs"]), true);
let (hub_long, _) = g.entropy(Path::new("hub.rs"));
assert!((hub_long - LOG2_3).abs() < 1e-12, "hub = {hub_long}");
assert_eq!(g.entropy(Path::new("a.rs")).0, 0.0);
assert!(hub_long > g.entropy(Path::new("a.rs")).0);
}
#[test]
fn repeated_co_change_weights_the_edge() {
let mut g = CochangeGraph::new();
g.record_commit(&paths(&["a.rs", "b.rs"]), true);
g.record_commit(&paths(&["a.rs", "b.rs"]), true);
g.record_commit(&paths(&["a.rs", "c.rs"]), true);
let (a_long, _) = g.entropy(Path::new("a.rs"));
assert!(
(a_long - 0.918_295_834_054_490).abs() < 1e-12,
"a = {a_long}"
);
assert_eq!(g.entropy(Path::new("b.rs")).0, 0.0);
}
#[test]
fn recent_subgraph_excludes_long_only_commits() {
let mut g = CochangeGraph::new();
g.record_commit(&paths(&["a.rs", "b.rs"]), false);
g.record_commit(&paths(&["a.rs", "c.rs"]), false);
g.record_commit(&paths(&["a.rs", "b.rs"]), true);
let (a_long, a_recent) = g.entropy(Path::new("a.rs"));
assert!(
(a_long - 0.918_295_834_054_490).abs() < 1e-12,
"long {a_long}"
);
assert_eq!(a_recent, 0.0, "recent should see one neighbour only");
}
#[test]
fn over_wide_commits_are_excluded_from_the_graph() {
let mut g = CochangeGraph::new();
let wide: Vec<String> = (0..=MAX_COCHANGE_COMMIT_FILES)
.map(|i| format!("f{i}.rs"))
.collect();
let wide_paths: Vec<PathBuf> = wide.iter().map(PathBuf::from).collect();
g.record_commit(&wide_paths, true);
assert_eq!(g.entropy(Path::new("f0.rs")), (0.0, 0.0));
let at_cap: Vec<PathBuf> = (0..MAX_COCHANGE_COMMIT_FILES)
.map(|i| PathBuf::from(format!("g{i}.rs")))
.collect();
g.record_commit(&at_cap, true);
let (g0_long, _) = g.entropy(Path::new("g0.rs"));
assert!(
g0_long > 0.0,
"at-cap commit should produce edges: {g0_long}"
);
}
#[test]
fn cochange_entropy_is_independent_of_edge_interning_order() {
let build = |neighbour_order: &[&str]| {
let mut g = CochangeGraph::new();
let weights = [("x.rs", 3), ("y.rs", 2), ("z.rs", 1)];
for name in neighbour_order {
let &(_, w) = weights.iter().find(|(n, _)| n == name).expect("known");
for _ in 0..w {
g.record_commit(&paths(&["hub.rs", name]), true);
}
}
g.entropy(Path::new("hub.rs")).0
};
let forward = build(&["x.rs", "y.rs", "z.rs"]);
let reversed = build(&["z.rs", "y.rs", "x.rs"]);
let shuffled = build(&["y.rs", "z.rs", "x.rs"]);
assert_eq!(
forward.to_bits(),
reversed.to_bits(),
"entropy must be bit-identical regardless of edge interning order"
);
assert_eq!(forward.to_bits(), shuffled.to_bits());
assert!(
(forward - 1.459_147_917_027_245).abs() < 1e-12,
"got {forward}"
);
}
#[test]
fn node_entropy_sums_in_fileid_order_not_hashmap_order() {
let weights_in_fileid_order = [1u32, 2, 4, 8, 16];
let mut hub_edges: HashMap<FileId, u32> = HashMap::new();
for (i, &w) in weights_in_fileid_order.iter().enumerate() {
hub_edges.insert(FileId(u32::try_from(i + 1).expect("small")), w);
}
let adjacency = vec![hub_edges];
let expected = shannon_entropy(weights_in_fileid_order.iter().map(|&w| f64::from(w)));
assert_eq!(
node_entropy(&adjacency, 0).to_bits(),
expected.to_bits(),
"node_entropy must sum edge weights in FileId-ascending order"
);
let reversed = shannon_entropy(weights_in_fileid_order.iter().rev().map(|&w| f64::from(w)));
assert_ne!(
expected.to_bits(),
reversed.to_bits(),
"summation order must be observable for this test to mean anything"
);
}
#[test]
fn duplicate_path_within_a_commit_adds_no_self_loop() {
let mut g = CochangeGraph::new();
g.record_commit(&paths(&["a.rs", "a.rs", "b.rs"]), true);
assert_eq!(g.entropy(Path::new("a.rs")).0, 0.0);
assert_eq!(g.entropy(Path::new("b.rs")).0, 0.0);
}