Files
Marcos Paulo f79dbf8c20 feat(graph): wave 1 implementation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:03:50 -03:00

303 lines
10 KiB
Rust

//! Integration tests for the public API of `notescraft-graph`.
use notescraft_graph::{Error, Graph, Layout, LayoutConfig, components, degrees, layout, local_graph, orphans};
/// A short config so the tests stay fast; the physics is unchanged.
fn config(seed: u64) -> LayoutConfig {
LayoutConfig { iterations: 40, seed, ..LayoutConfig::default() }
}
fn chain(n: usize) -> Graph {
let edges = (1..n).map(|i| (i - 1, i)).collect();
Graph::new(n, edges).expect("chain graph is valid")
}
fn all_finite(positions: &[[f32; 2]]) -> bool {
positions.iter().all(|p| p[0].is_finite() && p[1].is_finite())
}
fn bits(positions: &[[f32; 2]]) -> Vec<[u32; 2]> {
positions.iter().map(|p| [p[0].to_bits(), p[1].to_bits()]).collect()
}
// ---------------------------------------------------------------- determinism
#[test]
fn same_seed_gives_bit_identical_output() {
let g = chain(60);
let a = layout(&g, &config(1234));
let b = layout(&g, &config(1234));
assert_eq!(bits(&a), bits(&b));
}
#[test]
fn different_seeds_give_different_output() {
let g = chain(60);
let a = layout(&g, &config(1));
let b = layout(&g, &config(2));
assert_ne!(bits(&a), bits(&b));
}
#[test]
fn stepping_manually_matches_run_to_completion() {
let g = chain(30);
let cfg = config(77);
let done = layout(&g, &cfg);
let mut stepper = Layout::new(&g, &cfg);
let mut last = 1.0;
for _ in 0..cfg.iterations {
last = stepper.step();
}
assert!(last >= 0.0);
assert_eq!(bits(&done), bits(stepper.positions()));
assert_eq!(stepper.step(), 0.0, "an exhausted layout reports no energy");
}
#[test]
fn energy_decreases_over_time() {
let g = chain(50);
let mut l = Layout::new(&g, &config(9));
let first = l.step();
for _ in 0..20 {
l.step();
}
let last = l.step();
assert!(first >= 0.0 && last >= 0.0);
assert!(last <= first, "energy must not grow: {last} > {first}");
}
// ------------------------------------------------------------ degenerate input
#[test]
fn no_nan_on_degenerate_inputs() {
let hub_edges: Vec<(usize, usize)> = (1..=200).map(|i| (0, i)).collect();
let disconnected = Graph::new(8, vec![(0, 1), (1, 2), (5, 6), (6, 7)]).expect("valid");
let dupes = Graph::new(4, vec![(0, 1), (1, 0), (0, 1), (1, 1), (2, 3)]).expect("valid");
for g in [
Graph::new(0, vec![]).expect("valid"),
Graph::new(1, vec![]).expect("valid"),
Graph::new(1, vec![(0, 0)]).expect("valid"),
Graph::new(201, hub_edges).expect("valid"),
disconnected,
dupes,
chain(3),
] {
let positions = layout(&g, &config(5));
assert_eq!(positions.len(), g.node_count());
assert!(all_finite(&positions), "non-finite position for {:?}", g.edges());
}
}
#[test]
fn no_nan_with_hostile_config() {
let g = chain(10);
for cfg in [
LayoutConfig { area: f32::NAN, ..config(1) },
LayoutConfig { area: -1.0, ..config(1) },
LayoutConfig { area: f32::INFINITY, ..config(1) },
LayoutConfig { repulsion: f32::NAN, ..config(1) },
LayoutConfig { attraction: f32::INFINITY, ..config(1) },
LayoutConfig { gravity: f32::NAN, ..config(1) },
LayoutConfig { cooling: 0.0, ..config(1) },
LayoutConfig { cooling: 1.5, ..config(1) },
LayoutConfig { iterations: 0, ..config(1) },
] {
assert!(all_finite(&layout(&g, &cfg)));
}
}
// ---------------------------------------------------------------- pinning
#[test]
fn pinned_nodes_never_move() {
let g = chain(20);
let pinned_pos = [12.5_f32, -3.25];
let cfg = LayoutConfig { pinned: vec![(3, pinned_pos), (7, [-1.0, 1.0])], ..config(3) };
let positions = layout(&g, &cfg);
assert_eq!(positions[3], pinned_pos);
assert_eq!(positions[7], [-1.0, 1.0]);
}
#[test]
fn pin_unpin_and_set_position() {
let g = chain(10);
let mut l = Layout::new(&g, &config(11));
l.pin(2, [5.0, 5.0]).expect("node 2 exists");
l.step();
assert_eq!(l.positions()[2], [5.0, 5.0]);
l.step();
assert_eq!(l.positions()[2], [5.0, 5.0]);
l.unpin(2).expect("node 2 exists");
l.step();
assert_ne!(l.positions()[2], [5.0, 5.0]);
l.set_position(4, [1.0, 2.0]).expect("node 4 exists");
assert_eq!(l.positions()[4], [1.0, 2.0]);
assert!(matches!(l.pin(99, [0.0, 0.0]), Err(Error::NodeOutOfRange { .. })));
assert!(matches!(l.unpin(99), Err(Error::NodeOutOfRange { .. })));
assert!(matches!(l.set_position(99, [0.0, 0.0]), Err(Error::NodeOutOfRange { .. })));
}
// ---------------------------------------------------------------- graph
#[test]
fn graph_drops_self_loops_and_duplicates() {
let g = Graph::new(4, vec![(0, 0), (0, 1), (1, 0), (0, 1), (2, 3), (3, 2)]).expect("valid");
assert_eq!(g.node_count(), 4);
assert_eq!(g.edges(), &[(0, 1), (2, 3)]);
}
#[test]
fn graph_rejects_out_of_range_edges() {
assert!(matches!(Graph::new(3, vec![(0, 3)]), Err(Error::EdgeOutOfRange { from: 0, to: 3, count: 3 })));
assert!(matches!(Graph::new(0, vec![(0, 0)]), Err(Error::EdgeOutOfRange { .. })));
}
#[test]
fn graph_caps_node_count() {
assert!(matches!(Graph::new(200_001, vec![]), Err(Error::TooManyNodes { .. })));
assert!(Graph::new(200_000, vec![]).is_ok());
}
#[test]
fn graph_caps_edge_count() {
let edges = vec![(0usize, 1usize); 8_000_001];
assert!(matches!(Graph::new(4, edges), Err(Error::TooManyEdges { .. })));
}
#[test]
fn degrees_and_orphans() {
let g = Graph::new(6, vec![(0, 1), (1, 2), (2, 0), (3, 1)]).expect("valid");
assert_eq!(degrees(&g), vec![2, 3, 2, 1, 0, 0]);
assert_eq!(orphans(&g), vec![4, 5]);
assert_eq!(degrees(&Graph::new(0, vec![]).expect("valid")), Vec::<u32>::new());
assert_eq!(orphans(&Graph::new(0, vec![]).expect("valid")), Vec::<usize>::new());
}
#[test]
fn components_labels_each_connected_component() {
let g = Graph::new(7, vec![(0, 1), (1, 2), (3, 4)]).expect("valid");
assert_eq!(components(&g), vec![0, 0, 0, 1, 1, 2, 3]);
assert_eq!(components(&Graph::new(0, vec![]).expect("valid")), Vec::<usize>::new());
let single = Graph::new(1, vec![]).expect("valid");
assert_eq!(components(&single), vec![0]);
}
// ---------------------------------------------------------------- local graph
fn sample() -> Graph {
// 0-1-2-3-4 chain, 5 isolated, 6-7 pair.
Graph::new(8, vec![(0, 1), (1, 2), (2, 3), (3, 4), (6, 7)]).expect("valid")
}
#[test]
fn local_graph_depth_limits_the_neighbourhood() {
let g = sample();
let (d1, ids1) = local_graph(&g, 0, 1);
assert_eq!(ids1, vec![0, 1]);
assert_eq!(d1.node_count(), 2);
assert_eq!(d1.edges(), &[(0, 1)]);
let (d2, ids2) = local_graph(&g, 0, 2);
assert_eq!(ids2, vec![0, 1, 2]);
assert_eq!(d2.edges(), &[(0, 1), (1, 2)]);
let (d3, ids3) = local_graph(&g, 2, 3);
assert_eq!(ids3, vec![2, 1, 3, 0, 4]);
assert_eq!(d3.node_count(), 5);
// edges are remapped into BFS discovery order and keep the original order
assert_eq!(d3.edges(), &[(3, 1), (1, 0), (0, 2), (2, 4)]);
}
#[test]
fn local_graph_depth_zero_is_just_the_centre() {
let (g, ids) = local_graph(&sample(), 3, 0);
assert_eq!(ids, vec![3]);
assert_eq!(g.node_count(), 1);
assert_eq!(g.edges(), []);
}
#[test]
fn local_graph_isolated_centre_and_bad_centre() {
let (g, ids) = local_graph(&sample(), 5, 3);
assert_eq!(ids, vec![5]);
assert_eq!(g.edges(), []);
let (g, ids) = local_graph(&sample(), 99, 3);
assert_eq!(g.node_count(), 0);
assert_eq!(ids, [] as [usize; 0]);
}
#[test]
fn local_graph_depth_is_capped_and_lays_out() {
let g = chain(200);
let (sub, ids) = local_graph(&g, 0, usize::MAX);
assert_eq!(ids.len(), sub.node_count());
assert!(sub.node_count() <= g.node_count());
assert!(all_finite(&layout(&sub, &config(2))));
}
// ---------------------------------------------------------------- quality
#[test]
fn edges_end_up_closer_than_random_pairs() {
// A binary tree: leaves hang off their parent, so linked nodes must settle
// closer together than unrelated ones.
let n = 200;
let edges: Vec<(usize, usize)> = (1..n).map(|i| (i / 2, i)).collect();
let g = Graph::new(n, edges).expect("valid");
let p = layout(&g, &LayoutConfig::default());
let distance = |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).hypot(a[1] - b[1]);
let linked: f32 = g.edges().iter().map(|&(a, b)| distance(p[a], p[b])).sum();
let mut pairs = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
pairs.push(distance(p[i], p[j]));
}
}
let count = |v: usize| f32::from(u16::try_from(v).expect("small enough here"));
let mean_all = pairs.iter().sum::<f32>() / count(pairs.len());
assert!(linked / count(g.edges().len()) < mean_all * 0.8, "linked nodes must be closer than the average pair");
}
// ---------------------------------------------------------------- performance
#[test]
#[ignore = "performance budget; run with: cargo test -p notescraft-graph --release -- --ignored"]
fn five_thousand_nodes_lay_out_under_two_seconds() {
const N: usize = 5000;
const M: usize = 15000;
let mut state: u64 = 0x2545_f491_4f6c_dd1d;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let bound = u64::try_from(N).expect("node count fits");
let mut edges = Vec::with_capacity(M);
for _ in 0..M {
let a = usize::try_from(next() % bound).expect("in range");
let mut b = usize::try_from(next() % bound).expect("in range");
if a == b {
b = (b + 1) % N;
}
edges.push((a, b));
}
let g = Graph::new(N, edges).expect("random graph is in range");
let started = std::time::Instant::now();
let positions = layout(&g, &LayoutConfig::default());
let elapsed = started.elapsed();
eprintln!("layout: {} nodes, {} edges, {} iterations -> {elapsed:?}", g.node_count(), g.edges().len(), LayoutConfig::default().iterations);
assert_eq!(positions.len(), N);
assert!(all_finite(&positions));
assert!(elapsed.as_secs_f64() < 2.0, "layout took {elapsed:?}, budget is 2 s");
}