#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use tp_lib_core::*;
#[test]
fn test_successful_path_calculation_linear() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.351, 50.851), (4.352, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_C".to_string(),
LineString::from(vec![(4.352, 50.852), (4.353, 50.853)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![
NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap(),
NetRelation::new(
"NR_BC".to_string(),
"NE_B".to_string(),
"NE_C".to_string(),
1,
0,
true,
true,
)
.unwrap(),
];
let gnss_positions = vec![
GnssPosition::new(50.8503, 4.3502, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.8512, 4.3512, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.8523, 4.3522, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let config = PathConfig::default();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(result.is_ok(), "Path calculation should succeed");
let path_result = result.unwrap();
assert!(path_result.path.is_some(), "Path should be calculated");
let path = path_result.path.unwrap();
assert_eq!(path.segments.len(), 3, "Should have 3 segments");
assert_eq!(path.segments[0].netelement_id, "NE_A");
assert_eq!(path.segments[1].netelement_id, "NE_B");
assert_eq!(path.segments[2].netelement_id, "NE_C");
}
#[test]
fn test_path_calculation_with_netelement_connection() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B1".to_string(),
LineString::from(vec![(4.351, 50.851), (4.352, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B2".to_string(),
LineString::from(vec![(4.351, 50.851), (4.350, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B3".to_string(),
LineString::from(vec![(4.351, 50.851), (4.351, 50.853)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![
NetRelation::new(
"NR_AB1".to_string(),
"NE_A".to_string(),
"NE_B1".to_string(),
1,
0,
true,
true,
)
.unwrap(),
NetRelation::new(
"NR_AB2".to_string(),
"NE_A".to_string(),
"NE_B2".to_string(),
1,
0,
true,
true,
)
.unwrap(),
NetRelation::new(
"NR_AB3".to_string(),
"NE_A".to_string(),
"NE_B3".to_string(),
1,
0,
true,
true,
)
.unwrap(),
];
let gnss_positions = vec![
GnssPosition::new(50.8503, 4.3502, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.8515, 4.3515, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let config = PathConfig::default();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(result.is_ok());
let path_result = result.unwrap();
let path = path_result.path.unwrap();
assert!(
path.segments.iter().any(|s| s.netelement_id == "NE_B1"),
"Should select NE_B1 branch"
);
}
#[test]
fn test_heading_filtering() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let netelements = vec![
Netelement::new(
"NE_forward".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_backward".to_string(),
LineString::from(vec![(4.351, 50.851), (4.350, 50.850)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![NetRelation::new(
"NR_conn".to_string(),
"NE_forward".to_string(),
"NE_backward".to_string(),
1,
0,
true,
true,
)
.unwrap()];
let mut gnss_positions = vec![
GnssPosition::new(50.8502, 4.3502, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.8508, 4.3508, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
gnss_positions[0].heading = Some(45.0); gnss_positions[1].heading = Some(45.0);
let config = PathConfig::default();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(result.is_ok());
let path_result = result.unwrap();
if let Some(path) = path_result.path {
let forward_prob = path
.segments
.iter()
.find(|s| s.netelement_id == "NE_forward")
.map(|s| s.probability);
let backward_prob = path
.segments
.iter()
.find(|s| s.netelement_id == "NE_backward")
.map(|s| s.probability);
if let (Some(fw), Some(bw)) = (forward_prob, backward_prob) {
assert!(
fw > bw,
"Forward segment should have higher probability than backward"
);
}
}
}
#[test]
fn test_netrelation_geojson_parsing() {
use tp_lib_core::io::parse_netrelations_geojson;
let test_geojson = r#"{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.3517, 50.8503]
},
"properties": {
"type": "netrelation",
"id": "NR_001",
"netelementA": "NE_A",
"netelementB": "NE_B",
"positionOnA": 1,
"positionOnB": 0,
"navigability": "both"
}
},
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.3520, 50.8505]
},
"properties": {
"type": "netrelation",
"id": "NR_002",
"netelementA": "NE_B",
"netelementB": "NE_C",
"positionOnA": 1,
"positionOnB": 0,
"navigability": "AB"
}
},
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [[4.35, 50.85], [4.36, 50.86]]
},
"properties": {
"id": "NE_A"
}
}
]
}"#;
let temp_file = std::env::temp_dir().join("test_netrelations.geojson");
std::fs::write(&temp_file, test_geojson).unwrap();
let netrelations = parse_netrelations_geojson(temp_file.to_str().unwrap()).unwrap();
assert_eq!(
netrelations.len(),
2,
"Should parse 2 netrelations, skipping netelement"
);
let nr1 = &netrelations[0];
assert_eq!(nr1.id, "NR_001");
assert_eq!(nr1.from_netelement_id, "NE_A");
assert_eq!(nr1.to_netelement_id, "NE_B");
assert_eq!(nr1.position_on_a, 1);
assert_eq!(nr1.position_on_b, 0);
assert!(nr1.navigable_forward);
assert!(nr1.navigable_backward);
let nr2 = &netrelations[1];
assert_eq!(nr2.id, "NR_002");
assert_eq!(nr2.from_netelement_id, "NE_B");
assert_eq!(nr2.to_netelement_id, "NE_C");
assert!(nr2.navigable_forward);
assert!(!nr2.navigable_backward);
std::fs::remove_file(temp_file).unwrap();
}
#[test]
fn test_trainpath_serialization_roundtrip() {
use chrono::Utc;
use geo::LineString;
use std::collections::HashMap;
use tp_lib_core::io::{parse_trainpath_csv, write_trainpath_csv, write_trainpath_geojson};
use tp_lib_core::{AssociatedNetElement, Netelement, TrainPath};
let segments = vec![
AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10).unwrap(),
AssociatedNetElement::new("NE_B".to_string(), 0.92, 0.0, 1.0, 11, 18).unwrap(),
];
let original_path = TrainPath::new(segments, 0.89, Some(Utc::now()), None).unwrap();
let csv_temp = std::env::temp_dir().join("test_trainpath.csv");
let mut csv_file = std::fs::File::create(&csv_temp).unwrap();
write_trainpath_csv(&original_path, &mut csv_file).unwrap();
drop(csv_file);
let parsed_path = parse_trainpath_csv(csv_temp.to_str().unwrap()).unwrap();
assert_eq!(parsed_path.segments.len(), 2);
assert_eq!(parsed_path.overall_probability, 0.89);
assert_eq!(parsed_path.segments[0].netelement_id, "NE_A");
assert_eq!(parsed_path.segments[0].probability, 0.87);
assert_eq!(parsed_path.segments[1].netelement_id, "NE_B");
assert_eq!(parsed_path.segments[1].probability, 0.92);
std::fs::remove_file(&csv_temp).unwrap();
let mut netelements_map = HashMap::new();
netelements_map.insert(
"NE_A".to_string(),
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.35, 50.85), (4.36, 50.86)]),
"EPSG:4326".to_string(),
)
.unwrap(),
);
netelements_map.insert(
"NE_B".to_string(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.36, 50.86), (4.37, 50.87)]),
"EPSG:4326".to_string(),
)
.unwrap(),
);
let geojson_temp = std::env::temp_dir().join("test_trainpath.geojson");
let mut geojson_file = std::fs::File::create(&geojson_temp).unwrap();
write_trainpath_geojson(&original_path, &netelements_map, &mut geojson_file).unwrap();
drop(geojson_file);
let geojson_content = std::fs::read_to_string(&geojson_temp).unwrap();
assert!(geojson_content.contains("\"overall_probability\""));
assert!(geojson_content.contains("NE_A"));
assert!(geojson_content.contains("NE_B"));
std::fs::remove_file(&geojson_temp).unwrap();
}
#[test]
fn test_project_coordinates_onto_path() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{
AssociatedNetElement, GnssPosition, NetRelation, Netelement, TrainPath,
};
use tp_lib_core::{project_onto_path, PathConfig};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.360, 50.860)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.360, 50.860), (4.370, 50.870)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let _netrelations = [NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap()];
let segments = vec![
AssociatedNetElement::new(
"NE_A".to_string(),
1.0, 0.0, 1.0, 0, 10, )
.unwrap(),
AssociatedNetElement::new("NE_B".to_string(), 1.0, 0.0, 1.0, 11, 20).unwrap(),
];
let path = TrainPath::new(segments, 0.90, Some(Utc::now()), None).unwrap();
let gnss_positions = vec![
GnssPosition::new(50.8551, 4.3551, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.8652, 4.3652, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let config = PathConfig::default();
let result = project_onto_path(&gnss_positions, &path, &netelements, &config);
assert!(result.is_ok(), "Projection should succeed");
let projected = result.unwrap();
assert_eq!(projected.len(), 2, "Should have 2 projected positions");
for proj in &projected {
assert!(
proj.intrinsic.is_some(),
"Intrinsic coordinate should be present"
);
let intr = proj.intrinsic.unwrap();
assert!(
(0.0..=1.0).contains(&intr),
"Intrinsic coordinate should be between 0 and 1"
);
}
}
#[test]
fn test_project_coordinates_between_segments() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{AssociatedNetElement, GnssPosition, Netelement, TrainPath};
use tp_lib_core::{project_onto_path, PathConfig};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.360, 50.860)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.360, 50.860), (4.370, 50.870)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let segments = vec![
AssociatedNetElement::new("NE_A".to_string(), 1.0, 0.0, 1.0, 0, 10).unwrap(),
AssociatedNetElement::new("NE_B".to_string(), 1.0, 0.0, 1.0, 11, 20).unwrap(),
];
let path = TrainPath::new(segments, 0.90, Some(Utc::now()), None).unwrap();
let gnss_positions = vec![
GnssPosition::new(50.860, 4.360, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let config = PathConfig::default();
let result = project_onto_path(&gnss_positions, &path, &netelements, &config);
assert!(result.is_ok(), "Projection should succeed");
let projected = result.unwrap();
assert_eq!(projected.len(), 1);
assert!(projected[0].netelement_id == "NE_A" || projected[0].netelement_id == "NE_B");
}
#[test]
fn test_project_with_presupplied_path() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{AssociatedNetElement, GnssPosition, Netelement, TrainPath};
use tp_lib_core::{project_onto_path, PathConfig};
let netelements = vec![Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.360, 50.860)]),
"EPSG:4326".to_string(),
)
.unwrap()];
let segments =
vec![AssociatedNetElement::new("NE_A".to_string(), 1.0, 0.0, 1.0, 0, 10).unwrap()];
let path = TrainPath::new(segments, 0.85, Some(Utc::now()), None).unwrap();
let gnss_positions =
vec![
GnssPosition::new(50.8551, 4.3551, Utc::now().into(), "EPSG:4326".to_string())
.unwrap(),
];
let config = PathConfig::default();
let result = project_onto_path(&gnss_positions, &path, &netelements, &config);
assert!(
result.is_ok(),
"Projection with pre-supplied path should succeed"
);
let projected = result.unwrap();
assert_eq!(projected.len(), 1);
assert_eq!(projected[0].netelement_id, "NE_A");
}
#[test]
fn test_path_only_export_csv() {
use chrono::Utc;
use tp_lib_core::io::{parse_trainpath_csv, write_trainpath_csv};
use tp_lib_core::{AssociatedNetElement, TrainPath};
let segments = vec![
AssociatedNetElement::new("NE_A".to_string(), 0.9, 0.0, 1.0, 0, 5).unwrap(),
AssociatedNetElement::new("NE_B".to_string(), 0.8, 0.0, 1.0, 6, 12).unwrap(),
];
let path = TrainPath::new(segments, 0.85, Some(Utc::now()), None).unwrap();
let unique_id = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let csv_temp = std::env::temp_dir().join(format!("test_path_only_{}.csv", unique_id));
let mut csv_file = std::fs::File::create(&csv_temp).unwrap();
write_trainpath_csv(&path, &mut csv_file).unwrap();
drop(csv_file);
let parsed = parse_trainpath_csv(csv_temp.to_str().unwrap()).unwrap();
assert_eq!(parsed.segments.len(), 2);
assert_eq!(parsed.segments[0].netelement_id, "NE_A");
assert_eq!(parsed.segments[1].netelement_id, "NE_B");
std::fs::remove_file(&csv_temp).unwrap();
}
#[test]
fn test_path_only_export_geojson() {
use chrono::Utc;
use geo::LineString;
use std::collections::HashMap;
use tp_lib_core::io::write_trainpath_geojson;
use tp_lib_core::{AssociatedNetElement, Netelement, TrainPath};
let segments = vec![
AssociatedNetElement::new("NE_A".to_string(), 0.9, 0.0, 1.0, 0, 5).unwrap(),
AssociatedNetElement::new("NE_B".to_string(), 0.8, 0.0, 1.0, 6, 12).unwrap(),
];
let path = TrainPath::new(segments, 0.85, Some(Utc::now()), None).unwrap();
let mut netelements_map = HashMap::new();
netelements_map.insert(
"NE_A".to_string(),
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.35, 50.85), (4.36, 50.86)]),
"EPSG:4326".to_string(),
)
.unwrap(),
);
netelements_map.insert(
"NE_B".to_string(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.36, 50.86), (4.37, 50.87)]),
"EPSG:4326".to_string(),
)
.unwrap(),
);
let geojson_temp = std::env::temp_dir().join("test_path_only.geojson");
let mut geojson_file = std::fs::File::create(&geojson_temp).unwrap();
write_trainpath_geojson(&path, &netelements_map, &mut geojson_file).unwrap();
drop(geojson_file);
let content = std::fs::read_to_string(&geojson_temp).unwrap();
assert!(content.contains("\"overall_probability\""));
assert!(content.contains("NE_A"));
assert!(content.contains("NE_B"));
std::fs::remove_file(&geojson_temp).unwrap();
}
#[test]
fn test_heading_enhanced_path_calculation() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.360, 50.860)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B_parallel".to_string(),
LineString::from(vec![(4.350, 50.851), (4.360, 50.861)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let gnss_positions = vec![
GnssPosition::with_heading_distance(
50.8503,
4.3502,
Utc::now().into(),
"EPSG:4326".to_string(),
Some(45.0), None,
)
.unwrap(),
GnssPosition::with_heading_distance(
50.8512,
4.3512,
Utc::now().into(),
"EPSG:4326".to_string(),
Some(47.0), None,
)
.unwrap(),
];
let netrelations = vec![];
let config = PathConfig::default();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
let _ = result;
}
#[test]
fn test_backward_compatibility_no_heading_distance() {
use chrono::Utc;
use tp_lib_core::models::GnssPosition;
let position =
GnssPosition::new(50.8503, 4.3517, Utc::now().into(), "EPSG:4326".to_string()).unwrap();
assert!(
position.heading.is_none(),
"Heading should be None by default"
);
assert!(
position.distance.is_none(),
"Distance should be None by default"
);
}
#[test]
fn test_csv_parsing_with_heading_distance() {
use tp_lib_core::io::parse_gnss_csv;
let csv_content = r#"timestamp,latitude,longitude,crs,heading,distance
2026-01-09T10:00:00+01:00,50.8503,4.3517,EPSG:4326,45.3,
2026-01-09T10:00:01+01:00,50.8504,4.3518,EPSG:4326,47.1,12.5
2026-01-09T10:00:02+01:00,50.8505,4.3519,EPSG:4326,46.8,11.9"#;
let temp_file = std::env::temp_dir().join("test_heading_distance.csv");
std::fs::write(&temp_file, csv_content).unwrap();
let positions = parse_gnss_csv(
temp_file.to_str().unwrap(),
"EPSG:4326",
"latitude",
"longitude",
"timestamp",
)
.unwrap();
assert_eq!(positions.len(), 3);
assert_eq!(positions[0].heading, Some(45.3));
assert!(positions[0].distance.is_none());
assert_eq!(positions[1].heading, Some(47.1));
assert_eq!(positions[1].distance, Some(12.5));
assert_eq!(positions[2].heading, Some(46.8));
assert_eq!(positions[2].distance, Some(11.9));
std::fs::remove_file(&temp_file).unwrap();
}
#[test]
fn test_geojson_parsing_with_heading_distance() {
use tp_lib_core::io::parse_gnss_geojson;
let geojson_content = r#"{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [4.3517, 50.8503]},
"properties": {
"timestamp": "2026-01-09T10:00:00+01:00",
"heading": 45.3
}
},
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [4.3518, 50.8504]},
"properties": {
"timestamp": "2026-01-09T10:00:01+01:00",
"heading": 47.1,
"distance": 12.5
}
}
]
}"#;
let temp_file = std::env::temp_dir().join("test_heading_distance.geojson");
std::fs::write(&temp_file, geojson_content).unwrap();
let positions = parse_gnss_geojson(temp_file.to_str().unwrap(), "EPSG:4326").unwrap();
assert_eq!(positions.len(), 2);
assert_eq!(positions[0].heading, Some(45.3));
assert!(positions[0].distance.is_none());
assert_eq!(positions[1].heading, Some(47.1));
assert_eq!(positions[1].distance, Some(12.5));
std::fs::remove_file(&temp_file).unwrap();
}
#[test]
fn test_calculate_mean_spacing_with_distance() {
use chrono::Utc;
use tp_lib_core::calculate_mean_spacing;
use tp_lib_core::models::GnssPosition;
let mut pos1 =
GnssPosition::new(50.850, 4.350, Utc::now().into(), "EPSG:4326".to_string()).unwrap();
pos1.distance = Some(0.0);
let mut pos2 =
GnssPosition::new(50.851, 4.351, Utc::now().into(), "EPSG:4326".to_string()).unwrap();
pos2.distance = Some(15.0);
let mut pos3 =
GnssPosition::new(50.852, 4.352, Utc::now().into(), "EPSG:4326".to_string()).unwrap();
pos3.distance = Some(30.0);
let mut pos4 =
GnssPosition::new(50.853, 4.353, Utc::now().into(), "EPSG:4326".to_string()).unwrap();
pos4.distance = Some(45.0);
let positions = vec![pos1, pos2, pos3, pos4];
let mean_spacing = calculate_mean_spacing(&positions);
assert!(
(mean_spacing - 15.0).abs() < 0.1,
"Mean spacing should be 15.0m, got {}",
mean_spacing
);
}
#[test]
fn test_resampling_reduces_computation() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let gnss_positions: Vec<GnssPosition> = (0..100)
.map(|i| {
let lat = 50.850 + i as f64 * 0.000009; let lon = 4.350 + i as f64 * 0.000012;
let mut pos =
GnssPosition::new(lat, lon, Utc::now().into(), "EPSG:4326".to_string())
.unwrap();
pos.distance = Some(i as f64); pos
})
.collect();
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.353, 50.853)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.353, 50.853), (4.356, 50.856)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_C".to_string(),
LineString::from(vec![(4.356, 50.856), (4.360, 50.860)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![
NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap(),
NetRelation::new(
"NR_BC".to_string(),
"NE_B".to_string(),
"NE_C".to_string(),
1,
0,
true,
true,
)
.unwrap(),
];
let config_no_resample = PathConfig::builder()
.resampling_distance(None)
.build()
.unwrap();
let result_no_resample = calculate_train_path(
&gnss_positions,
&netelements,
&netrelations,
&config_no_resample,
);
assert!(
result_no_resample.is_ok(),
"Path calculation without resampling should succeed"
);
assert!(
result_no_resample.as_ref().unwrap().warnings.is_empty()
|| !result_no_resample
.as_ref()
.unwrap()
.warnings
.iter()
.any(|w| w.contains("Resampling")),
"Should not have resampling warning"
);
let config_resample = PathConfig::builder()
.resampling_distance(Some(10.0))
.build()
.unwrap();
let result_resample = calculate_train_path(
&gnss_positions,
&netelements,
&netrelations,
&config_resample,
);
assert!(
result_resample.is_ok(),
"Path calculation with resampling should succeed"
);
let path_result = result_resample.unwrap();
assert!(
path_result
.warnings
.iter()
.any(|w| w.contains("Resampling applied")),
"Should have resampling warning indicating it was applied"
);
assert!(
path_result.path.is_some(),
"Should calculate a valid path with resampling"
);
let path = path_result.path.unwrap();
assert!(!path.segments.is_empty(), "Path should have segments");
}
#[test]
fn test_resampling_preserves_original_positions() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let gnss_positions: Vec<GnssPosition> = (0..50)
.map(|i| {
let lat = 50.850 + i as f64 * 0.000009;
let lon = 4.350 + i as f64 * 0.000012;
let mut pos =
GnssPosition::new(lat, lon, Utc::now().into(), "EPSG:4326".to_string())
.unwrap();
pos.distance = Some(i as f64);
pos
})
.collect();
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.355, 50.855)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.355, 50.855), (4.360, 50.860)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap()];
let config = PathConfig::builder()
.resampling_distance(Some(5.0))
.build()
.unwrap();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(
result.is_ok(),
"Path calculation with resampling should succeed"
);
let path_result = result.unwrap();
assert!(
path_result
.warnings
.iter()
.any(|w| w.contains("Resampling applied")),
"Resampling should be applied"
);
let resample_warning = path_result
.warnings
.iter()
.find(|w| w.contains("Resampling applied"))
.unwrap();
assert!(
resample_warning.contains("of 50"),
"Warning should mention original 50 positions: {}",
resample_warning
);
assert_eq!(
gnss_positions.len(),
50,
"Original positions should remain intact for projection phase"
);
assert!(path_result.path.is_some(), "Path should be calculated");
}
#[test]
fn test_fallback_with_disconnected_network() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathCalculationMode, PathConfig};
let gnss_positions = vec![
GnssPosition::new(50.950, 4.450, Utc::now().into(), "EPSG:4326".to_string()).unwrap(), GnssPosition::new(50.951, 4.451, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.351, 50.851), (4.352, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap()];
let config = PathConfig::builder()
.cutoff_distance(100.0) .build()
.unwrap();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(
result.is_ok(),
"Should succeed with fallback even when GNSS far from network"
);
let path_result = result.unwrap();
assert!(
matches!(path_result.mode, PathCalculationMode::FallbackIndependent),
"Should use fallback mode when no candidates found, got {:?}",
path_result.mode
);
assert!(
path_result.path.is_none(),
"No path should be calculated in fallback mode"
);
assert!(
path_result
.warnings
.iter()
.any(|w| w.contains("No continuous path")),
"Should warn about no continuous path, got: {:?}",
path_result.warnings
);
assert!(
path_result
.warnings
.iter()
.any(|w| w.contains("Falling back")),
"Should warn about falling back, got: {:?}",
path_result.warnings
);
}
#[test]
fn test_fallback_notification() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let gnss_positions =
vec![
GnssPosition::new(50.950, 4.450, Utc::now().into(), "EPSG:4326".to_string())
.unwrap(),
];
let netelements = vec![Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap()];
let netrelations: Vec<NetRelation> = vec![];
let config = PathConfig::builder()
.cutoff_distance(100.0)
.build()
.unwrap();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(result.is_ok());
let path_result = result.unwrap();
let warnings_text = path_result.warnings.join(" ");
assert!(
warnings_text.contains("No continuous path") || warnings_text.contains("no valid path"),
"Should clearly state no path was found: {}",
warnings_text
);
assert!(
warnings_text.contains("Falling back") || warnings_text.contains("fallback"),
"Should explicitly mention fallback: {}",
warnings_text
);
assert!(
warnings_text.contains("independent") || warnings_text.contains("nearest-segment"),
"Should explain fallback uses independent projection: {}",
warnings_text
);
}
#[test]
fn test_fallback_ignores_navigability() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathCalculationMode, PathConfig};
let gnss_positions = vec![
GnssPosition::new(50.850, 4.350, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.851, 4.351, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.351, 50.851), (4.352, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![
NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
false,
)
.unwrap(), ];
let config = PathConfig::default();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(result.is_ok());
let path_result = result.unwrap();
if matches!(path_result.mode, PathCalculationMode::FallbackIndependent) {
assert!(
!path_result.projected_positions.is_empty(),
"Fallback should project all positions despite navigability constraints"
);
}
}
#[test]
fn test_debug_export_candidate_paths() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, PathConfig};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.351, 50.851), (4.352, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap()];
let gnss_positions = vec![
GnssPosition::new(50.850, 4.350, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.852, 4.352, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let config = PathConfig::builder().debug_mode(true).build().unwrap();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(
result.is_ok(),
"Path calculation should succeed with debug mode"
);
let path_result = result.unwrap();
assert!(
path_result.debug_info.is_some(),
"Debug info should be populated when debug_mode=true"
);
let debug_info = path_result.debug_info.as_ref().unwrap();
assert!(
!debug_info.position_candidates.is_empty(),
"Debug info should contain position candidates"
);
assert!(
!debug_info.decision_tree.is_empty(),
"Debug info should contain decision tree entries"
);
let json_result = debug_info.to_json();
assert!(json_result.is_ok(), "Debug info should serialize to JSON");
let json = json_result.unwrap();
assert!(
json.contains("position_candidates"),
"JSON should contain position_candidates"
);
assert!(
json.contains("decision_tree"),
"JSON should contain decision_tree"
);
}
#[test]
fn test_debug_export_position_candidates() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{calculate_train_path, CandidateInfo, PathConfig, PositionCandidates};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.351, 50.851), (4.352, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap()];
let gnss_positions = vec![
GnssPosition::new(50.850, 4.350, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.8505, 4.3505, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.851, 4.351, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let config = PathConfig::builder().debug_mode(true).build().unwrap();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(result.is_ok());
let test_candidates = PositionCandidates {
position_index: 0,
timestamp: "2025-01-09T12:00:00Z".to_string(),
coordinates: (50.850, 4.350),
candidates: vec![CandidateInfo {
netelement_id: "NE_A".to_string(),
distance: 5.0,
heading_difference: Some(2.0),
distance_probability: 0.9,
heading_probability: Some(0.8),
combined_probability: 0.72,
status: "selected".to_string(),
projected_lat: 50.851,
projected_lon: 4.351,
}],
selected_netelement: Some("NE_A".to_string()),
};
let json = serde_json::to_string(&test_candidates).unwrap();
assert!(json.contains("NE_A"));
assert!(json.contains("position_index"));
}
#[test]
fn test_debug_export_decision_tree() {
use chrono::Utc;
use geo::LineString;
use tp_lib_core::models::{GnssPosition, NetRelation, Netelement};
use tp_lib_core::{
calculate_train_path, CandidatePath, DebugInfo, PathConfig, PathDecision,
};
let netelements = vec![
Netelement::new(
"NE_A".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_B".to_string(),
LineString::from(vec![(4.351, 50.851), (4.352, 50.852)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let netrelations = vec![NetRelation::new(
"NR_AB".to_string(),
"NE_A".to_string(),
"NE_B".to_string(),
1,
0,
true,
true,
)
.unwrap()];
let gnss_positions = vec![
GnssPosition::new(50.850, 4.350, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
GnssPosition::new(50.852, 4.352, Utc::now().into(), "EPSG:4326".to_string()).unwrap(),
];
let config = PathConfig::builder().debug_mode(true).build().unwrap();
let result = calculate_train_path(&gnss_positions, &netelements, &netrelations, &config);
assert!(result.is_ok());
let test_decision = PathDecision {
step: 1,
decision_type: "forward_extend".to_string(),
current_segment: "NE_A".to_string(),
options: vec!["NE_B".to_string()],
option_probabilities: vec![0.85],
chosen_option: "NE_B".to_string(),
reason: "Highest probability candidate".to_string(),
};
let test_candidate_path = CandidatePath {
id: "forward_1".to_string(),
direction: "forward".to_string(),
segment_ids: vec!["NE_A".to_string(), "NE_B".to_string()],
segment_probabilities: vec![0.85, 0.85],
probability: 0.85,
selected: true,
};
let mut debug_info = DebugInfo::new();
debug_info.add_decision(test_decision);
debug_info.add_candidate_path(test_candidate_path);
assert!(!debug_info.is_empty());
assert_eq!(debug_info.decision_tree.len(), 1);
assert_eq!(debug_info.candidate_paths.len(), 1);
let json = debug_info.to_json().unwrap();
assert!(json.contains("forward_extend"));
assert!(json.contains("segment_ids"));
}
}