use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use tracematch::{
detect_sections_from_tracks, detect_sections_multiscale, group_signatures, GpsPoint,
MatchConfig, RouteSignature, ScalePreset, SectionConfig,
};
fn load_fixture(name: &str) -> Vec<GpsPoint> {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("tests/fixtures/raw_traces");
path.push(name);
let contents =
fs::read_to_string(&path).unwrap_or_else(|_| panic!("Failed to read fixture: {:?}", path));
let coords: Vec<Vec<f64>> = serde_json::from_str(&contents)
.unwrap_or_else(|_| panic!("Failed to parse fixture: {:?}", path));
coords
.into_iter()
.map(|c| GpsPoint::new(c[0], c[1]))
.collect()
}
fn load_all_fixtures() -> Vec<(String, Vec<GpsPoint>)> {
(1..=5)
.map(|i| {
let id = format!("activity_{}", i);
let points = load_fixture(&format!("{}_trimmed.json", id));
(id, points)
})
.collect()
}
fn create_sport_types(tracks: &[(String, Vec<GpsPoint>)]) -> HashMap<String, String> {
tracks
.iter()
.map(|(id, _)| (id.clone(), "Run".to_string()))
.collect()
}
#[test]
fn test_load_fixtures() {
let tracks = load_all_fixtures();
assert_eq!(tracks.len(), 5);
assert!(
tracks[0].1.len() > 3500,
"Activity 1 should have ~3900 points, got {}",
tracks[0].1.len()
);
assert!(
tracks[1].1.len() > 3000,
"Activity 2 should have ~3170 points, got {}",
tracks[1].1.len()
);
assert!(
tracks[2].1.len() > 2000,
"Activity 3 should have ~2320 points, got {}",
tracks[2].1.len()
);
assert!(
tracks[3].1.len() > 2500,
"Activity 4 should have ~2700 points, got {}",
tracks[3].1.len()
);
assert!(
tracks[4].1.len() > 5000,
"Activity 5 should have ~5440 points, got {}",
tracks[4].1.len()
);
}
#[test]
fn test_detect_sections_finds_overlaps() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
min_section_length: 500.0, max_section_length: 2000.0, min_activities: 2, proximity_threshold: 50.0,
cluster_tolerance: 100.0,
scale_presets: vec![], ..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &config);
println!("Found {} sections:", sections.len());
for section in §ions {
println!(
" {} - {:.0}m, {} activities: {:?}",
section.id,
section.distance_meters,
section.activity_ids.len(),
section.activity_ids
);
}
assert!(
!sections.is_empty(),
"Should detect at least one common section"
);
}
#[test]
fn test_detect_common_sections_exploration() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
min_section_length: 300.0,
max_section_length: 1500.0,
min_activities: 3,
proximity_threshold: 50.0,
cluster_tolerance: 100.0,
scale_presets: vec![],
..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &config);
println!("Found {} sections with 3+ activities:", sections.len());
for section in §ions {
let ids: Vec<_> = section
.activity_ids
.iter()
.map(|id| id.replace("activity_", "A"))
.collect();
println!(
" {}: {:.0}m, {} activities: {:?}",
section.id,
section.distance_meters,
section.activity_ids.len(),
ids
);
}
let config_low = SectionConfig {
min_activities: 2,
..config.clone()
};
let sections_low = detect_sections_from_tracks(&tracks, &sport_types, &[], &config_low);
println!(
"\nWith min_activities=2, found {} sections:",
sections_low.len()
);
for section in §ions_low {
let ids: Vec<_> = section
.activity_ids
.iter()
.map(|id| id.replace("activity_", "A"))
.collect();
println!(
" {}: {:.0}m, {} activities: {:?}",
section.id,
section.distance_meters,
section.activity_ids.len(),
ids
);
}
for section in §ions {
assert!(
section.activity_ids.len() >= 3,
"Each section should have at least 3 activities"
);
}
assert!(!sections.is_empty(), "Should find at least one section");
}
#[test]
fn test_section_includes_activity_5() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
min_section_length: 300.0,
max_section_length: 1500.0,
min_activities: 2, proximity_threshold: 50.0,
cluster_tolerance: 100.0,
scale_presets: vec![],
..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &config);
let a5_sections: Vec<_> = sections
.iter()
.filter(|s| s.activity_ids.contains(&"activity_5".to_string()))
.collect();
println!("Sections containing Activity 5:");
for section in &a5_sections {
let ids: Vec<_> = section
.activity_ids
.iter()
.map(|id| id.replace("activity_", "A"))
.collect();
println!(
" {}: {:.0}m, activities: {:?}",
section.id, section.distance_meters, ids
);
}
assert!(
!a5_sections.is_empty(),
"Activity 5 should participate in at least one detected section"
);
for section in &a5_sections {
let has_partner = section.activity_ids.contains(&"activity_1".to_string())
|| section.activity_ids.contains(&"activity_3".to_string())
|| section.activity_ids.contains(&"activity_4".to_string());
assert!(
has_partner,
"A5's section should include at least one of A1, A3, A4"
);
}
}
#[test]
fn test_activity_5_shares_end_not_start() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
min_section_length: 200.0,
max_section_length: 2000.0,
min_activities: 2,
proximity_threshold: 50.0,
cluster_tolerance: 100.0,
scale_presets: vec![],
..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &config);
let a5_sections: Vec<_> = sections
.iter()
.filter(|s| s.activity_ids.contains(&"activity_5".to_string()))
.collect();
println!("Sections including Activity 5:");
for section in &a5_sections {
println!(
" {}: {:.0}m, activities: {:?}",
section.id, section.distance_meters, section.activity_ids
);
}
assert!(
!a5_sections.is_empty(),
"Activity 5 should share at least one section with other activities (the final stretch)"
);
for section in &a5_sections {
let has_end_partner = section.activity_ids.contains(&"activity_1".to_string())
|| section.activity_ids.contains(&"activity_3".to_string())
|| section.activity_ids.contains(&"activity_4".to_string());
assert!(
has_end_partner,
"A5's sections should include at least one of A1,A3,A4 (shared final stretch)"
);
}
}
#[test]
fn test_multiscale_detection() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
proximity_threshold: 50.0,
cluster_tolerance: 100.0,
scale_presets: vec![
ScalePreset {
name: "short".to_string(),
min_length: 200.0,
max_length: 600.0,
min_activities: 2,
},
ScalePreset {
name: "medium".to_string(),
min_length: 500.0,
max_length: 1500.0,
min_activities: 2,
},
],
include_potentials: true,
..SectionConfig::default()
};
let result = detect_sections_multiscale(&tracks, &sport_types, &[], &config);
println!(
"Multi-scale detection: {} sections, {} potentials",
result.sections.len(),
result.potentials.len()
);
println!("Stats: {:?}", result.stats);
for section in &result.sections {
println!(
" [{}] {}: {:.0}m, {} activities",
section.scale.as_deref().unwrap_or("?"),
section.id,
section.distance_meters,
section.activity_ids.len()
);
}
assert!(
!result.sections.is_empty(),
"Multi-scale detection should find sections"
);
assert!(
result.stats.overlaps_found > 0,
"Should find pairwise overlaps"
);
}
#[test]
fn test_section_polyline_quality() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
min_section_length: 300.0,
max_section_length: 1500.0,
min_activities: 2,
..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &config);
for section in §ions {
assert!(
section.polyline.len() >= 5,
"Section {} should have at least 5 points, got {}",
section.id,
section.polyline.len()
);
let mut polyline_length = 0.0;
for i in 1..section.polyline.len() {
polyline_length += tracematch::geo_utils::haversine_distance(
§ion.polyline[i - 1],
§ion.polyline[i],
);
}
let length_error =
(polyline_length - section.distance_meters).abs() / section.distance_meters;
assert!(
length_error < 0.1,
"Section {} polyline length ({:.0}m) should match reported distance ({:.0}m) within 10%",
section.id,
polyline_length,
section.distance_meters
);
for point in §ion.polyline {
assert!(
point.is_valid(),
"All points should be valid GPS coordinates"
);
}
assert!(
section.confidence > 0.0,
"Section {} should have positive confidence",
section.id
);
}
}
#[test]
fn test_similar_length_routes_group_together() {
let tracks = load_all_fixtures();
let config = MatchConfig::default();
let signatures: Vec<RouteSignature> = tracks
.iter()
.filter_map(|(id, points)| RouteSignature::from_points(id, points, &config))
.collect();
assert_eq!(signatures.len(), 5, "Should create 5 signatures");
println!("Route distances:");
for sig in &signatures {
println!(
" {}: {:.1}km",
sig.activity_id,
sig.total_distance / 1000.0
);
}
let groups = group_signatures(&signatures, &config);
println!("Route grouping results:");
for (i, group) in groups.iter().enumerate() {
println!(" Group {}: {:?}", i, group.activity_ids);
}
let a3_group = groups
.iter()
.find(|g| g.activity_ids.contains(&"activity_3".to_string()));
assert!(a3_group.is_some(), "Activity 3 should be in a group");
let a3_group = a3_group.unwrap();
assert!(
a3_group.activity_ids.contains(&"activity_4".to_string()),
"Activities 3 and 4 should be grouped together (similar length routes)"
);
println!(
"Activities 3 and 4 correctly grouped: {:?}",
a3_group.activity_ids
);
}
#[test]
fn test_activity_5_shares_common_section_with_others() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
min_section_length: 200.0, max_section_length: 1500.0, min_activities: 2, proximity_threshold: 50.0, ..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &config);
println!("Section detection results:");
for section in §ions {
let ids: Vec<_> = section
.activity_ids
.iter()
.map(|id| id.replace("activity_", "A"))
.collect();
println!(
" {}: {:.0}m, activities: {:?}",
section.id, section.distance_meters, ids
);
}
let a5_sections: Vec<_> = sections
.iter()
.filter(|s| s.activity_ids.contains(&"activity_5".to_string()))
.collect();
assert!(
!a5_sections.is_empty(),
"Activity 5 must share at least one common section with other activities"
);
for section in &a5_sections {
let shares_with_1_to_4 = section.activity_ids.contains(&"activity_1".to_string())
|| section.activity_ids.contains(&"activity_2".to_string())
|| section.activity_ids.contains(&"activity_3".to_string())
|| section.activity_ids.contains(&"activity_4".to_string());
assert!(
shares_with_1_to_4,
"Activity 5's section must include at least one activity from 1-4"
);
}
let max_section_length = a5_sections
.iter()
.map(|s| s.distance_meters)
.fold(0.0, f64::max);
assert!(
max_section_length >= 200.0,
"Shared section should be at least 200m, found {:.0}m",
max_section_length
);
println!(
"Activity 5 shares {} section(s) with other activities, longest: {:.0}m",
a5_sections.len(),
max_section_length
);
}
#[test]
fn test_debug_a3_sections() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let config = SectionConfig {
min_section_length: 100.0,
max_section_length: 5000.0,
min_activities: 2,
proximity_threshold: 100.0, cluster_tolerance: 150.0,
scale_presets: vec![],
..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &config);
println!("Sections with 100m proximity threshold:");
for section in §ions {
let ids: Vec<_> = section
.activity_ids
.iter()
.map(|id| id.replace("activity_", "A"))
.collect();
let has_a3 = ids.contains(&"A3".to_string());
println!(
" {}: {:.0}m, {} activities: {:?} {}",
section.id,
section.distance_meters,
section.activity_ids.len(),
ids,
if has_a3 { "<-- HAS A3" } else { "" }
);
}
let a3_sections: Vec<_> = sections
.iter()
.filter(|s| s.activity_ids.contains(&"activity_3".to_string()))
.collect();
println!("\nSections containing A3: {}", a3_sections.len());
}
#[test]
fn test_investigate_start_section() {
let tracks = load_all_fixtures();
let sport_types = create_sport_types(&tracks);
let start_tracks: Vec<(String, Vec<GpsPoint>)> = tracks
.iter()
.map(|(id, points)| {
let mut distance = 0.0;
let mut start_points = vec![points[0]];
for i in 1..points.len() {
distance += tracematch::geo_utils::haversine_distance(&points[i - 1], &points[i]);
start_points.push(points[i]);
if distance >= 1000.0 {
break;
}
}
(format!("{}_start", id), start_points)
})
.collect();
println!("First 1km of each track:");
for (id, points) in &start_tracks {
let dist: f64 = (1..points.len())
.map(|i| tracematch::geo_utils::haversine_distance(&points[i - 1], &points[i]))
.sum();
println!(" {}: {} points, {:.0}m", id, points.len(), dist);
}
let start_sport_types: HashMap<String, String> = start_tracks
.iter()
.map(|(id, _)| (id.clone(), "Run".to_string()))
.collect();
let config = SectionConfig {
min_section_length: 200.0,
max_section_length: 1500.0,
min_activities: 2,
proximity_threshold: 50.0,
..SectionConfig::default()
};
let sections = detect_sections_from_tracks(&start_tracks, &start_sport_types, &[], &config);
println!("\nSections in START portions:");
for section in §ions {
let ids: Vec<_> = section
.activity_ids
.iter()
.map(|id| id.replace("activity_", "A").replace("_start", ""))
.collect();
println!(
" {}: {:.0}m, activities: {:?}",
section.id, section.distance_meters, ids
);
}
let full_config = SectionConfig {
min_section_length: 200.0,
max_section_length: 1500.0,
min_activities: 3, proximity_threshold: 50.0,
..SectionConfig::default()
};
let full_sections = detect_sections_from_tracks(&tracks, &sport_types, &[], &full_config);
println!("\nSections in FULL tracks (min 3 activities):");
for section in &full_sections {
let ids: Vec<_> = section
.activity_ids
.iter()
.map(|id| id.replace("activity_", "A"))
.collect();
println!(
" {}: {:.0}m, activities: {:?}",
section.id, section.distance_meters, ids
);
}
}