diff --git a/editoast/editoast_schemas/src/infra/direction.rs b/editoast/editoast_schemas/src/infra/direction.rs index 82e5b602a38..c752e7d9b53 100644 --- a/editoast/editoast_schemas/src/infra/direction.rs +++ b/editoast/editoast_schemas/src/infra/direction.rs @@ -22,3 +22,13 @@ impl From for RangeMapDirection { } } } + +impl Direction { + #[must_use] + pub fn toggle(self) -> Self { + match self { + Self::StartToStop => Self::StopToStart, + Self::StopToStart => Self::StartToStop, + } + } +} diff --git a/editoast/src/core/pathfinding.rs b/editoast/src/core/pathfinding.rs index 7acc24facad..74c5ba738fd 100644 --- a/editoast/src/core/pathfinding.rs +++ b/editoast/src/core/pathfinding.rs @@ -180,6 +180,40 @@ impl From for TrackRange { } } +#[cfg(test)] +impl std::str::FromStr for TrackRange { + type Err = String; + + fn from_str(s: &str) -> Result { + let Some((name, offsets)) = s.split_once('+') else { + return Err(String::from( + "track range must contain at least a '+' and be of the form \"A+12-25\"", + )); + }; + let track_section = Identifier::from(name); + let Some((begin, end)) = offsets.split_once('-') else { + return Err(String::from("track range must contain '-' to separate the offsets and be of the form \"A+12-25\"")); + }; + let Ok(begin) = begin.parse() else { + return Err(format!("{begin} in track range should be an integer")); + }; + let Ok(end) = end.parse() else { + return Err(format!("{end} in track range should be an integer")); + }; + let (begin, end, direction) = if begin < end { + (begin, end, Direction::StartToStop) + } else { + (end, begin, Direction::StopToStart) + }; + Ok(TrackRange { + track_section, + begin, + end, + direction, + }) + } +} + impl TrackRange { #[cfg(test)] /// Creates a new `TrackRange`. diff --git a/editoast/src/views/path/projection.rs b/editoast/src/views/path/projection.rs index 2a50e79d779..eac354235d0 100644 --- a/editoast/src/views/path/projection.rs +++ b/editoast/src/views/path/projection.rs @@ -288,6 +288,7 @@ pub enum TrackLocationFromPath { mod tests { use super::*; use rstest::rstest; + use std::iter::DoubleEndedIterator; #[test] #[should_panic] @@ -407,111 +408,111 @@ mod tests { ); } - #[test] - fn get_boundaries_case_1() { - let path = vec![ - TrackRange::new("A", 50, 100, Direction::StartToStop), - TrackRange::new("B", 0, 200, Direction::StopToStart), - TrackRange::new("C", 0, 300, Direction::StartToStop), - TrackRange::new("D", 120, 250, Direction::StopToStart), - ]; - let projection = PathProjection::new(&path); - - let track_ranges = vec![ - TrackRange::new("A", 0, 100, Direction::StartToStop), - TrackRange::new("B", 0, 200, Direction::StopToStart), - TrackRange::new("C", 0, 300, Direction::StartToStop), - TrackRange::new("D", 0, 250, Direction::StopToStart), - TrackRange::new("E", 0, 100, Direction::StartToStop), - ]; - - let boundaries = projection.get_intersections(&track_ranges); - let expected: Vec = vec![Intersection::from((50, 730))]; - - assert_eq!(boundaries, expected); + // To invert track ranges, we need to get the list of track ranges + // backwards, and toggle the direction for each track range + fn invert_track_ranges( + track_ranges: impl DoubleEndedIterator, + ) -> Vec { + track_ranges + .rev() + .map(|mut track_range| { + track_range.direction = track_range.direction.toggle(); + track_range + }) + .collect() } - #[test] - fn get_boundaries_case_2() { - let path = vec![ - TrackRange::new("A", 50, 100, Direction::StartToStop), - TrackRange::new("B", 0, 200, Direction::StopToStart), - TrackRange::new("C", 0, 300, Direction::StartToStop), - TrackRange::new("D", 0, 250, Direction::StopToStart), - TrackRange::new("E", 25, 100, Direction::StopToStart), - ]; - let projection = PathProjection::new(&path); - - let track_ranges = vec![ - TrackRange::new("X", 0, 100, Direction::StartToStop), - TrackRange::new("B", 0, 200, Direction::StartToStop), - TrackRange::new("C", 150, 200, Direction::StopToStart), - TrackRange::new("E", 30, 100, Direction::StartToStop), - TrackRange::new("Z", 0, 100, Direction::StartToStop), - ]; - - let boundaries = projection.get_intersections(&track_ranges); - let expected: Vec = vec![ - Intersection::from((100, 350)), - Intersection::from((350, 420)), - ]; - - assert_eq!(boundaries, expected); + // To invert the intersection, we need to get the intersection backwards, + // invert each tuple and change the offsets by subtracting them from + // the total length of the projection path. + // + // For example, let's project "A+120-140" on a path "A+100-200", it will + // give the intersection (20, 40). If we invert the projection path (from + // "A+100-200" into "A+200-100"), we then get an intersection (60, 80). + // This new result can be calculated by: + // - calculating the length of the projection path: 200 - 100 = 100 + // - inverting the original tuple: (20, 40) -> (40, 20) + // - subtracting from the length: (100-40, 100-20) = (60, 80) + fn invert_intersections( + intersections: impl DoubleEndedIterator, + path_length: u64, + ) -> Vec { + // If 'track_range' is inverted, then offset of intersections are backwards + intersections + .into_iter() + .rev() + .map(|intersection| { + Intersection::from(( + path_length - intersection.end(), + path_length - intersection.start(), + )) + }) + .collect() } - #[test] - fn get_boundaries_case_3() { - let path = vec![ - TrackRange::new("A", 50, 100, Direction::StartToStop), - TrackRange::new("B", 0, 200, Direction::StopToStart), - TrackRange::new("C", 0, 300, Direction::StartToStop), - TrackRange::new("D", 0, 250, Direction::StopToStart), - TrackRange::new("E", 25, 100, Direction::StopToStart), - ]; - let projection = PathProjection::new(&path); - - let track_ranges = vec![ - TrackRange::new("A", 0, 100, Direction::StartToStop), - TrackRange::new("B", 0, 200, Direction::StartToStop), - TrackRange::new("X", 0, 100, Direction::StartToStop), - TrackRange::new("C", 150, 200, Direction::StopToStart), - TrackRange::new("Z", 0, 100, Direction::StartToStop), - TrackRange::new("E", 30, 100, Direction::StartToStop), - ]; - - let boundaries = projection.get_intersections(&track_ranges); - let expected: Vec = vec![ - Intersection::from((50, 300)), - Intersection::from((400, 450)), - Intersection::from((550, 620)), - ]; - - assert_eq!(boundaries, expected); - } - - #[test] - fn get_boundaries_case_4() { - let path = vec![TrackRange::new("A", 0, 100, Direction::StartToStop)]; - let projection = PathProjection::new(&path); - - let track_ranges = vec![TrackRange::new("B", 0, 100, Direction::StartToStop)]; - - let boundaries = projection.get_intersections(&track_ranges); - - let expected: Vec = vec![]; - assert_eq!(boundaries, expected); - } - - #[test] - fn get_boundaries_case_5() { - let path = vec![TrackRange::new("A", 0, 100, Direction::StartToStop)]; + #[rstest] + // One track on the path + #[case::one_path_different_track(&["A+0-100"], &["B+0-100"], &[])] + #[case::one_path_no_overlap(&["A+0-100"], &["A+100-200"], &[])] + #[case::one_path_one_simple_intersection(&["A+120-140"], &["A+100-200"], &[(20, 40)])] + #[case::one_path_one_simple_intersection_reverse_on_track_ranges(&["A+140-120"], &["A+100-200"], &[(20, 40)])] + #[case::two_path_merged(&["A+180-200", "B+100-120"], &["A+100-200", "B+100-200"], &[(80, 120)])] + #[case::two_path_not_merged(&["A+180-220", "B+80-120"], &["A+100-200", "B+100-200"], &[(80, 120)])] + #[case::two_path_merged_with_extra_bounds(&["A+180-220", "B+80-120"], &["A+100-200", "B+100-200"], &[(80, 120)])] + #[case::three_path_with_hole(&["A+150-200", "C+100-150"], &["A+100-200", "B+100-200", "C+100-200"], &[(50, 100), (200, 250)])] + // Complex paths with complex track ranges + #[case::complex_path_one_intersection( + &["A+50-100", "B+200-0", "C+0-300", "D+250-120"], + &["A+0-100", "B+200-0", "C+0-300", "D+250-0", "E+0-100"], + &[(50, 730)] + )] + #[case::complex_path_two_intersections( + &["A+50-100", "B+200-0", "C+0-300", "D+250-0", "E+100-25"], + &["X+0-100", "B+0-200", "C+200-150", "E+30-100", "Z+0-100"], + &[(100, 350), (350, 420)] + )] + #[case::complex_path_three_intersections( + &["A+50-100", "B+200-0", "C+0-300", "D+250-0", "E+100-25"], + &["A+0-100", "B+0-200", "X+0-100", "C+200-150", "Z+0-100", "E+30-100"], + &[(50, 300), (400, 450), (550, 620)] + )] + fn get_intersections( + #[case] path: &[&str], + #[case] track_ranges: &[&str], + #[case] expected_intersections: &[(u64, u64)], + // If we invert the projected track ranges, it doesn't change the intersection + #[values(false, true)] toggle_path: bool, + // If we invert the projection path, the intersections will be backwards + // and the offsets will be subtracted from the total length + #[values(false, true)] toggle_track_ranges: bool, + ) { + let path = path.iter().map(|s| s.parse().unwrap()); + let path = if toggle_path { + invert_track_ranges(path) + } else { + path.collect() + }; let projection = PathProjection::new(&path); - let track_ranges = vec![TrackRange::new("A", 100, 200, Direction::StartToStop)]; + let track_ranges = track_ranges.iter().map(|s| s.parse().unwrap()); + let track_ranges = if toggle_track_ranges { + invert_track_ranges(track_ranges) + } else { + track_ranges.collect() + }; + let expected_intersections = expected_intersections + .iter() + .copied() + .map(Intersection::from); + let expected_intersections = if toggle_track_ranges { + let length: u64 = track_ranges.iter().map(TrackRange::length).sum(); + invert_intersections(expected_intersections, length) + } else { + expected_intersections.collect() + }; - let boundaries = projection.get_intersections(&track_ranges); + let intersections = projection.get_intersections(&track_ranges); - let expected: Vec = vec![]; - assert_eq!(boundaries, expected); + assert_eq!(intersections, expected_intersections); } }