From bc56a1f8e6dd3ee4a0b3a091bd676a026af8d0cc Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 9 Jul 2024 19:08:04 +0200 Subject: [PATCH] Implement routability more or less completely --- features/car/destination.feature | 36 +- tests/common/comparison.rs | 17 + tests/common/f64_utils.rs | 17 +- tests/common/location.rs | 4 +- tests/common/mod.rs | 1 + tests/common/nearest_response.rs | 4 +- tests/common/osm.rs | 16 +- tests/common/osm_db.rs | 14 +- tests/common/osrm_error.rs | 17 + tests/common/osrm_world.rs | 81 ++--- tests/common/route_response.rs | 29 +- tests/cucumber.rs | 570 ++++++++++++++++++++++++------- 12 files changed, 565 insertions(+), 241 deletions(-) create mode 100644 tests/common/comparison.rs create mode 100644 tests/common/osrm_error.rs diff --git a/features/car/destination.feature b/features/car/destination.feature index d5d3c1287..cd8eebb40 100644 --- a/features/car/destination.feature +++ b/features/car/destination.feature @@ -113,12 +113,12 @@ Feature: Car - Destination only, no passing through Scenario: Car - Routing around a way that becomes destination only Given the node map """ - a---c---b - + \ - + | - d | - 1 | - \___e + a---c---b + + \ + + | + d | + 1 | + \___e """ And the ways @@ -136,12 +136,12 @@ Feature: Car - Destination only, no passing through Scenario: Car - Routing through a parking lot tagged access=destination,service Given the node map """ - a----c++++b+++g------h---i - | + + + / - | + + + / - | + + + / - | d++++e+f / - z--------------y + a----c++++b+++g------h---i + | + + + / + | + + + / + | + + + / + | d++++e+f / + z--------------y """ And the ways @@ -165,12 +165,12 @@ Feature: Car - Destination only, no passing through Given a grid size of 20 meters Given the node map """ - a---c---b - : - x - : - d - \__e + a---c---b + : + x + : + d + \__e """ And the ways diff --git a/tests/common/comparison.rs b/tests/common/comparison.rs new file mode 100644 index 000000000..b371b702e --- /dev/null +++ b/tests/common/comparison.rs @@ -0,0 +1,17 @@ +#[derive(Debug)] +pub enum Offset { + Absolute(f64), + Percentage(f64), +} + +// #[cfg(test)] +// mod tests { +// use crate::extract_number_and_offset; + +// #[test] +// fn extract_number_and_offset() { +// let (value, result) = extract_number_and_offset("m", "300 +- 1m"); +// assert_eq!(value, 300.); +// assert_eq!(offset, 1); +// } +// } diff --git a/tests/common/f64_utils.rs b/tests/common/f64_utils.rs index 52530aa87..7929f1cce 100644 --- a/tests/common/f64_utils.rs +++ b/tests/common/f64_utils.rs @@ -1,17 +1,24 @@ -pub fn approx_equal(a: f32, b: f32, dp: u8) -> bool { - let p = 10f32.powi(-(dp as i32)); +use super::comparison::Offset; + +pub fn approx_equal(a: f64, b: f64, dp: u8) -> bool { + let p = 10f64.powi(-(dp as i32)); (a - b).abs() < p } -pub fn aprox_equal_within_percentage_range(actual: f64, expectation: f64, percentage: f64) -> bool { +fn aprox_equal_within_percentage_range(actual: f64, expectation: f64, percentage: f64) -> bool { assert!(percentage.is_sign_positive() && percentage <= 100.); let factor = 0.01 * percentage as f64; actual >= expectation - (factor * expectation) && actual <= expectation + (factor * expectation) } -pub fn approx_equal_within_offset_range(actual: f64, expectation: f64, offset: f64) -> bool { +fn approx_equal_within_offset_range(actual: f64, expectation: f64, offset: f64) -> bool { assert!(offset >= 0., "offset must be positive"); actual >= expectation - offset && actual <= expectation + offset } -// TODO: test coverage +pub fn approximate_within_range(actual: f64, expectation: f64, offset: &Offset) -> bool{ + match offset { + Offset::Absolute(a) => approx_equal_within_offset_range(actual, expectation, *a), + Offset::Percentage(p) => aprox_equal_within_percentage_range(actual, expectation, *p), + } +} diff --git a/tests/common/location.rs b/tests/common/location.rs index 52e0cf127..12eb280f1 100644 --- a/tests/common/location.rs +++ b/tests/common/location.rs @@ -3,6 +3,6 @@ use serde::Deserialize; #[derive(Clone, Copy, Debug, Default, Deserialize)] pub struct Location { // Note: The order is important since we derive Deserialize - pub longitude: f32, - pub latitude: f32, + pub longitude: f64, + pub latitude: f64, } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 4f8ad8e6e..eefeeae22 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -2,6 +2,7 @@ extern crate flatbuffers; pub mod cli_arguments; +pub mod comparison; pub mod dot_writer; pub mod f64_utils; pub mod file_util; diff --git a/tests/common/nearest_response.rs b/tests/common/nearest_response.rs index 40f71e1f6..bb8ea65d2 100644 --- a/tests/common/nearest_response.rs +++ b/tests/common/nearest_response.rs @@ -62,8 +62,8 @@ impl NearestResponse { let hint = wp.hint().expect("hint is missing").to_string(); let location = wp.location().expect("waypoint must have a location"); let location = Location { - latitude: location.latitude(), - longitude: location.longitude(), + latitude: location.latitude() as f64, + longitude: location.longitude() as f64, }; let nodes = wp.nodes().expect("waypoint mus have nodes"); let nodes = Some(vec![nodes.first(), nodes.second()]); diff --git a/tests/common/osm.rs b/tests/common/osm.rs index d9835f4bc..859e30f98 100644 --- a/tests/common/osm.rs +++ b/tests/common/osm.rs @@ -16,9 +16,12 @@ pub struct OSMNode { } impl OSMNode { - // pub fn add_tag(&mut self, key: &str, value: &str) { - // self.tags.insert(key.into(), value.into()); - // } + pub fn add_tag(&mut self, key: &str, value: &str) { + if key.is_empty() || value.is_empty() { + return; + } + self.tags.insert(key.into(), value.into()); + } // pub fn set_id_(&mut self, id: u64) { // self.id = id; @@ -68,6 +71,13 @@ impl OSMWay { // self.tags = tags; // } + pub fn add_tag(&mut self, key: &str, value: &str) { + if key.is_empty() || value.is_empty() { + return; + } + self.tags.insert(key.into(), value.into()); + } + pub fn to_xml(&self) -> XMLElement { let mut way = XMLElement::new("way"); way.add_attribute("id", &self.id.to_string()); diff --git a/tests/common/osm_db.rs b/tests/common/osm_db.rs index aa7af53b9..73ace9767 100644 --- a/tests/common/osm_db.rs +++ b/tests/common/osm_db.rs @@ -4,7 +4,7 @@ use xml_builder::{XMLBuilder, XMLElement, XMLVersion}; // TODO: better error handling in XML creation #[derive(Debug, Default)] pub struct OSMDb { - nodes: Vec<(char, OSMNode)>, + nodes: Vec<(String, OSMNode)>, ways: Vec, // relations: Vec, } @@ -12,14 +12,14 @@ pub struct OSMDb { impl OSMDb { pub fn add_node(&mut self, node: OSMNode) { let name = node.tags.get("name").unwrap(); - assert!( - name.len() == 1, - "name needs to be of length 1, but was \"{name}\"" - ); - self.nodes.push((name.chars().next().unwrap(), node)); + // assert!( + // name.len() == 1, + // "name needs to be of length 1, but was \"{name}\"" + // ); + self.nodes.push((name.clone(), node)); } - pub fn find_node(&self, search_name: char) -> Option<&(char, OSMNode)> { + pub fn find_node(&self, search_name: String) -> Option<&(String, OSMNode)> { // TODO: this is a linear search. self.nodes.iter().find(|(name, _node)| search_name == *name) } diff --git a/tests/common/osrm_error.rs b/tests/common/osrm_error.rs new file mode 100644 index 000000000..d0d34e166 --- /dev/null +++ b/tests/common/osrm_error.rs @@ -0,0 +1,17 @@ +use serde::Deserialize; + +#[derive(Debug, Default, Deserialize)] +pub struct OSRMError { + pub code: String, + pub message: String, +} + +impl OSRMError { + pub fn from_json_reader(reader: impl std::io::Read) -> Self { + let response = match serde_json::from_reader::<_, Self>(reader) { + Ok(response) => response, + Err(e) => panic!("parsing error {e}"), + }; + response + } +} \ No newline at end of file diff --git a/tests/common/osrm_world.rs b/tests/common/osrm_world.rs index 59d36d958..ee69deb6c 100644 --- a/tests/common/osrm_world.rs +++ b/tests/common/osrm_world.rs @@ -1,5 +1,5 @@ use super::{ - nearest_response::NearestResponse, osm::OSMNode, osm_db::OSMDb, osrm_error::OSRMError, + nearest_response::NearestResponse, osm::{OSMNode, OSMWay}, osm_db::OSMDb, osrm_error::OSRMError, route_response::RouteResponse, }; use crate::{common::local_task::LocalTask, Location}; @@ -10,18 +10,18 @@ use reqwest::StatusCode; use std::{ collections::HashMap, fs::{create_dir_all, File}, - io::{Read, Write}, + io::Write, path::PathBuf, time::Duration, }; -// use ureq::Error; const DEFAULT_ORIGIN: Location = Location { - longitude: 1.0f32, - latitude: 1.0f32, + longitude: 1.0f64, + latitude: 1.0f64, }; -const DEFAULT_GRID_SIZE: f32 = 100.; -const WAY_SPACING: f32 = 100.; +const DEFAULT_GRID_SIZE: f64 = 100.; +const WAY_SPACING: f64 = 100.; +const DEFAULT_PROFILE: &str = "bicycle"; #[derive(Debug, World)] pub struct OSRMWorld { @@ -42,13 +42,12 @@ pub struct OSRMWorld { pub query_options: HashMap, pub request_string: Option, - pub grid_size: f32, + pub grid_size: f64, pub origin: Location, - pub way_spacing: f32, + pub way_spacing: f64, task: LocalTask, client: reqwest::blocking::Client, - // agent: ureq::Agent, } impl Default for OSRMWorld { @@ -59,7 +58,7 @@ impl Default for OSRMWorld { feature_digest: Default::default(), osrm_digest: Default::default(), osm_id: Default::default(), - profile: Default::default(), + profile: DEFAULT_PROFILE.into(), known_osm_nodes: Default::default(), known_locations: Default::default(), osm_db: Default::default(), @@ -69,7 +68,7 @@ impl Default for OSRMWorld { // default parameters // TODO: check if necessary ("steps".into(), "true".into()), ("alternatives".into(), "false".into()), - ("annotations".into(), "true".into()), + // ("annotations".into(), "true".into()), ]), request_string: Default::default(), @@ -77,10 +76,6 @@ impl Default for OSRMWorld { origin: DEFAULT_ORIGIN, way_spacing: WAY_SPACING, task: LocalTask::default(), - // agent: ureq::AgentBuilder::new() - // .timeout_read(Duration::from_secs(5)) - // .timeout_write(Duration::from_secs(5)) - // .build(), client: reqwest::blocking::Client::builder() .connect_timeout(Duration::from_secs(5)) .no_proxy() @@ -166,6 +161,14 @@ impl OSRMWorld { self.osm_db.add_node(node); } + pub fn add_osm_way(&mut self, way: OSMWay) { + way.nodes.iter().for_each(|node| { + self.osm_db.add_node(node.clone()); + }); + + self.osm_db.add_way(way); + } + pub fn get_location(&self, name: char) -> Location { *match name { // TODO: move lookup to world @@ -264,20 +267,6 @@ impl OSRMWorld { return Err((status.as_u16(), OSRMError::from_json_reader(bytes))); } } - - // match call { - // Ok(response) => { - // let response = match self.request_with_flatbuffers { - // true => NearestResponse::from_flatbuffer(response.into_reader()), - // false => NearestResponse::from_json_reader(response.into_reader()), - // }; - // Ok((200u16, response)) - // } - // Err(Error::Status(code, response)) => { - // return Err((code, OSRMError::from_json_reader(response.into_reader()))); - // } - // Err(e) => panic!("http error: {e}"), - // } } pub fn route( @@ -295,7 +284,7 @@ impl OSRMWorld { let url = match &self.request_string { None => { let mut url = format!( - "http://localhost:5000/route/v1/{}/{waypoint_string}", + "http://127.0.0.1:5000/route/v1/{}/{waypoint_string}", self.profile, ); if self.request_with_flatbuffers { @@ -314,19 +303,11 @@ impl OSRMWorld { url } Some(request_string) => { - let temp = format!("http://localhost:5000/{}", request_string); - // if request_string == "?" { - // panic!("s: {temp}"); - // } + let temp = format!("http://127.0.0.1:5000/{}", request_string); temp } }; // println!("url: {url}"); - // let request = self.agent.get(&url); - // if url.ends_with("?") { - // // request = request.query("", ""); - // } - // let call = request.call(); let response = match self.client.get(url).send() { Ok(response) => response, Err(e) => panic!("http error: {e}"), @@ -347,25 +328,5 @@ impl OSRMWorld { return Err((status.as_u16(), OSRMError::from_json_reader(bytes))); } } - - // match call { - // Ok(response) => { - // let text = std::io::read_to_string(response.into_reader()).unwrap(); - // let response = match self.request_with_flatbuffers { - // true => unimplemented!("RouteResponse::from_flatbuffer(body)"), - // false => RouteResponse::from_string(&text), - // }; - // Ok((200u16, response)) - // } - // Err(Error::Status(code, response)) => { - // let result = Err((code, OSRMError::from_json_reader(response.into_reader()))); - // if url.ends_with("?") { - // panic!("{url} {result:?}"); - // } - - // return result; - // } - // Err(e) => panic!("http error: {e}"), - // } } } diff --git a/tests/common/route_response.rs b/tests/common/route_response.rs index 2f84041f1..ef5e00a2e 100644 --- a/tests/common/route_response.rs +++ b/tests/common/route_response.rs @@ -1,3 +1,4 @@ + use std::default; use serde::Deserialize; @@ -6,11 +7,12 @@ use super::{location::Location, nearest_response::Waypoint}; #[derive(Deserialize, Default, Debug)] pub struct Maneuver { - pub bearing_after: f64, - pub bearing_before: f64, + pub bearing_after: u64, + pub bearing_before: u64, pub location: Location, pub modifier: Option, // TODO: should be an enum pub r#type: String, // TODO: should be an enum + pub exit: Option } #[derive(Debug, Clone, Deserialize)] @@ -29,6 +31,15 @@ impl Default for Geometry { } } +#[derive(Debug, Default, Clone, Deserialize)] +pub struct Intersection { + pub r#in: Option, + pub out: Option, + pub entry: Vec, + pub bearings: Vec, + pub location: Location, +} + #[derive(Deserialize, Default, Debug)] pub struct Step { pub geometry: Geometry, @@ -36,9 +47,11 @@ pub struct Step { pub maneuver: Maneuver, pub name: String, pub pronunciation: Option, + pub rotary_name: Option, pub r#ref: Option, pub duration: f64, pub distance: f64, + pub intersections: Vec, } // #[derive(Deserialize, Debug)] @@ -92,15 +105,3 @@ impl RouteResponse { response } } - -// #[cfg(test)] -// mod tests { -// use super::RouteResponse; - -// #[test] -// fn parse_geojson() { -// let input = r#"{"code":"Ok","routes":[{"geometry":{"coordinates":[[1.00009,1],[1.000269,1]],"type":"LineString"},"weight":1.9,"duration":1.9,"legs":[{"annotation":{"speed":[10.5],"weight":[1.9],"nodes":[1,2],"duration":[1.9],"distance":[19.92332315]},"summary":"abc","weight":1.9,"duration":1.9,"steps":[{"geometry":{"coordinates":[[1.00009,1],[1.000269,1]],"type":"LineString"},"maneuver":{"location":[1.00009,1],"bearing_after":90,"bearing_before":0,"modifier":"right","type":"depart"},"mode":"driving","name":"abc","intersections":[{"out":0,"entry":[true],"bearings":[90],"location":[1.00009,1]}],"driving_side":"right","weight":1.9,"duration":1.9,"distance":19.9},{"geometry":{"coordinates":[[1.000269,1],[1.000269,1]],"type":"LineString"},"maneuver":{"location":[1.000269,1],"bearing_after":0,"bearing_before":90,"modifier":"right","type":"arrive"},"mode":"driving","name":"abc","intersections":[{"in":0,"entry":[true],"bearings":[270],"location":[1.000269,1]}],"driving_side":"right","weight":0,"duration":0,"distance":0}],"distance":19.9}],"weight_name":"duration","distance":19.9}],"waypoints":[{"name":"abc","hint":"AAAAgAEAAIAKAAAAHgAAAAAAAAAoAAAA6kYgQWyG70EAAAAA6kYgQgoAAAAeAAAAAAAAACgAAAABAACAmkIPAEBCDwCaQg8Ai0EPAAAArwUAAAAA","distance":20.01400211,"location":[1.00009,1]},{"name":"abc","hint":"AAAAgAEAAIAdAAAACwAAAAAAAAAoAAAAbIbvQepGIEEAAAAA6kYgQh0AAAALAAAAAAAAACgAAAABAACATUMPAEBCDwBNQw8Ai0EPAAAArwUAAAAA","distance":20.01400211,"location":[1.000269,1]}]} "#; -// let result = RouteResponse::from_string(&input); - -// } -// } diff --git a/tests/cucumber.rs b/tests/cucumber.rs index ca4fa43b3..85939ffdb 100644 --- a/tests/cucumber.rs +++ b/tests/cucumber.rs @@ -4,18 +4,18 @@ use cheap_ruler::CheapRuler; use clap::Parser; use common::{ cli_arguments::Args, + comparison::Offset, dot_writer::DotWriter, - f64_utils::{ - approx_equal, approx_equal_within_offset_range, aprox_equal_within_percentage_range, - }, + f64_utils::{approx_equal, approximate_within_range}, hash_util::md5_of_osrm_executables, location::Location, - osm::OSMWay, + osm::{OSMNode, OSMWay}, osrm_world::OSRMWorld, route_response::{self, RouteResponse}, }; use core::panic; use cucumber::{ + codegen::ParametersProvider, gherkin::{Step, Table}, given, then, when, writer::summarize, @@ -25,17 +25,16 @@ use futures::{future, FutureExt}; use geo_types::Point; use log::debug; use std::{ - collections::{HashMap, HashSet}, - iter::zip, + collections::{HashMap, HashSet}, fmt::format, iter::zip, process::ExitCode, result }; -fn offset_origin_by(dx: f32, dy: f32, origin: Location, grid_size: f32) -> Location { +fn offset_origin_by(dx: f64, dy: f64, origin: Location, grid_size: f64) -> Location { let ruler = CheapRuler::new(origin.latitude, cheap_ruler::DistanceUnit::Meters); let loc = ruler.offset( &Point::new(origin.longitude, origin.latitude), dx * grid_size, dy * grid_size, - ); //TODO: needs to be world's gridSize, not the local one + ); Location { latitude: loc.y(), longitude: loc.x(), @@ -89,8 +88,12 @@ fn set_node_locations(world: &mut OSRMWorld, step: &Step) { let lon = &row[header_lookup["lon"]]; let lat = &row[header_lookup["lat"]]; let location = Location { - latitude: lat.parse::().expect("lat {lat} needs to be a f64"), - longitude: lon.parse::().expect("lon {lon} needs to be a f64"), + latitude: lat + .parse() + .expect("lat {lat} needs to be a floating point number"), + longitude: lon + .parse() + .expect("lon {lon} needs to be a floating point number"), }; let id = match header_lookup.get("id") { Some(index) => { @@ -117,22 +120,25 @@ fn set_node_map(world: &mut OSRMWorld, step: &Step) { .split('\n') .enumerate() .for_each(|(row_index, row)| { + let row_index = row_index - 1; row.chars() .enumerate() - .filter(|(_column_index, charater)| *charater != ' ') + .filter(|(_column_index, character)| { + *character >= '0' && *character <= '9' + || *character >= 'a' && *character <= 'z' + }) .for_each(|(column_index, name)| { - // This ports the logic from previous implementations. + // This ports the logic from previous JS/Ruby implementations. let location = offset_origin_by( - column_index as f32 * 0.5, - -(row_index as f32 - 1.), + column_index as f64 * 0.5, + -(row_index as f64), world.origin, world.grid_size, ); match name { '0'...'9' => world.add_location(name, location), 'a'...'z' => world.add_osm_node(name, location, None), - _ => {} // TODO: unreachable!("node name not in [0..9][a..z]: {docstring}"), - // tests contain random characters. + _ => unreachable!("node name not in [0..9][a..z]: {docstring}"), } }); }); @@ -147,7 +153,7 @@ fn extra_parameters(world: &mut OSRMWorld, parameters: String) { } #[given(expr = "a grid size of {float} meters")] -fn set_grid_size(world: &mut OSRMWorld, meters: f32) { +fn set_grid_size(world: &mut OSRMWorld, meters: f64) { world.grid_size = meters; } @@ -181,7 +187,7 @@ fn set_ways(world: &mut OSRMWorld, step: &Step) { // TODO: this check is probably not necessary since it is also checked below implicitly panic!("referenced unknown node {name} in way {token}"); } - if let Some((_, node)) = world.osm_db.find_node(name) { + if let Some((_, node)) = world.osm_db.find_node(name.to_string()) { way.add_node(node.clone()); } else { panic!("node is known, but not found in osm_db"); @@ -299,13 +305,7 @@ fn request_nearest(world: &mut OSRMWorld, step: &Step, state: String) { #[then(expr = "routability should be")] fn routability(world: &mut OSRMWorld, step: &Step) { - world.write_osm_file(); - world.extract_osm_file(); - // TODO: preprocess - - let (header, test_cases) = parse_table_from_steps(&step.table.as_ref()); - // TODO: rename forw/backw to forw/backw_speed - let supported_headers = HashSet::<_>::from([ + let tested_headers = HashSet::<_>::from([ "forw", "backw", "bothw", @@ -313,9 +313,67 @@ fn routability(world: &mut OSRMWorld, step: &Step) { "backw_rate", "bothw_rate", ]); - if 0 == header + let (headers, test_cases) = parse_table_from_steps(&step.table.as_ref()); + // add all non-empty, non-test headers as key=value pairs to OSM data + test_cases .iter() - .filter(|title| supported_headers.contains(title.as_str())) + .enumerate() + .for_each(|(index, test_case)| { + let mut way = OSMWay { + id: world.make_osm_id(), + ..Default::default() + }; + way.add_tag("highway", "primary"); + way.add_tag("name", &format!("w{index}")); + let source = offset_origin_by( + world.way_spacing * index as f64, + 0., + world.origin, + world.grid_size, + ); + let mut source_node = OSMNode { + id: world.make_osm_id(), + location: source, + ..Default::default() + }; + source_node.add_tag("name", &format!("a{index}")); + way.add_node(source_node); + + let target = offset_origin_by( + 4. + world.way_spacing * index as f64, + 0., + world.origin, + world.grid_size, + ); + let mut target_node = OSMNode { + id: world.make_osm_id(), + location: target, + ..Default::default() + }; + target_node.add_tag("name", &format!("e{index}")); + way.add_node(target_node); + + test_case + .iter() + .filter(|(key, _value)| !tested_headers.iter().any(|header| header == key)) + .for_each(|(key, value)| { + if key != "#" { + // ignore comments + way.add_tag(key, value); + } + }); + world.add_osm_way(way); + }); + + world.write_osm_file(); + world.extract_osm_file(); + // TODO: preprocess + + // TODO: rename forw/backw to forw/backw_speed (comment from JS implementation) + + if 0 == headers + .iter() + .filter(|title| tested_headers.contains(title.as_str())) .count() { panic!( @@ -328,96 +386,234 @@ fn routability(world: &mut OSRMWorld, step: &Step) { .enumerate() .for_each(|(index, test_case)| { let source = offset_origin_by( - 1. + world.way_spacing * index as f32, + 1. + world.way_spacing * index as f64, 0., world.origin, world.grid_size, ); let target = offset_origin_by( - 3. + world.way_spacing * index as f32, + 3. + world.way_spacing * index as f64, 0., world.origin, world.grid_size, ); + + let expected_summary = format!("w{index}"); + test_case .iter() - .filter(|(title, _)| supported_headers.contains(title.as_str())) + .filter(|(title, _)| tested_headers.contains(title.as_str())) .for_each(|(title, expectation)| { + let route_results = vec![world.route(&vec![source, target]) + ,world.route(&vec![target, source])]; let forward = title.starts_with("forw"); let route_result = match forward { - true => world.route(&vec![source, target]), - false => world.route(&vec![target, source]), + true => &route_results[0], + false => &route_results[1], }; - let (_, response) = route_result - .as_ref() - .expect("osrm-routed returned an unexpected error"); - if expectation.is_empty() { - assert!( - response.routes.is_empty() - || response.routes.first().unwrap().distance == 0., - "no route expected when result column {title} is unset" - ); - } else if expectation.contains("km/h") { - assert!( - !response.routes.is_empty(), - "route expected when result column is set" - ); + match title.as_str() { + "forw" | "backw" => { + match expectation.as_str() { + "" => { + assert!( + route_result.is_err() || + extract_summary_from_route_response(&route_result.as_ref().unwrap().1) != expected_summary, + // || response.routes.first().unwrap().distance == 0., + "no route expected when result column {title} is unset" + ); + } + "x" => { + let (_, response) = route_result + .as_ref() + .expect("osrm-routed returned an unexpected error"); + assert!( + !response.routes.is_empty() + && response.routes.first().unwrap().distance >= 0., + "no route expected when result column {title} is set to {expectation}" + ); + } + _ if expectation.contains("km/h") => { + assert!(route_result.is_ok()); + let (_, response) = route_result.as_ref().unwrap(); + assert!( + !response.routes.is_empty(), + "route expected when result column is set" + ); - let (expected_speed, offset) = - extract_number_and_offset("km/h", expectation); - let route = response.routes.first().unwrap(); - let actual_speed = route.distance / route.duration * 3.6; - assert!( - aprox_equal_within_percentage_range( - actual_speed, - expected_speed, - offset - ), - "{actual_speed} and {expected_speed} differ by more than {offset}" - ); - } else if title.ends_with("_rate") { - assert!(!response.routes.is_empty()); - let expected_rate = expectation - .parse::() - .expect("rate needs to be a number"); - let route = response.routes.first().unwrap(); - let actual_rate = route.distance / route.weight; - assert!( - aprox_equal_within_percentage_range(actual_rate, expected_rate, 1.), - "{actual_rate} and {expected_rate} differ by more than 1%" - ); - } else { - unimplemented!("{title} = {expectation}"); + let (expected_speed, offset) = + extract_number_and_offset("km/h", expectation); + let route = response.routes.first().unwrap(); + let actual_speed = route.distance / route.duration * 3.6; + assert!( + approximate_within_range( + actual_speed, + expected_speed, + &offset + ), + "{actual_speed} and {expected_speed} differ by more than {offset:?}" + ); + } + _ => { + let (_, response) = route_result + .as_ref() + .expect("osrm-routed returned an unexpected error"); + let mode = extract_mode_string_from_route_response(response); + assert_eq!(&mode, expectation, "failed: {test_case:?}"); + } + } + } + "forw_rate" | "backw_rate" => { + assert!(route_result.is_ok()); + let (_, response) = route_result.as_ref().unwrap(); + assert!(!response.routes.is_empty()); + let expected_rate = expectation + .parse::() + .expect("rate needs to be a number"); + let route = response.routes.first().unwrap(); + let actual_rate = route.distance / route.weight; + assert!( + approximate_within_range(actual_rate, expected_rate, &Offset::Percentage(1.)), + "{actual_rate} and {expected_rate} differ by more than 1%" + ); + } + "bothw" => { + match expectation.as_str() { + "x" => { + for result in &route_results { + let (_, response) = result + .as_ref() + .expect("osrm-routed returned an unexpected error"); + assert!( + !response.routes.is_empty() + && response.routes.first().unwrap().distance >= 0., + "no forward route when result column {title}={expectation}" + ); + } + } + _ if expectation.contains("km/h") => { + let (expected_speed, offset) = + extract_number_and_offset("km/h", expectation); + for result in &route_results { + assert!(result.is_ok()); + let (_, response) = result.as_ref().unwrap(); + assert!( + !response.routes.is_empty(), + "route expected when result column is set" + ); + let route = response.routes.first().unwrap(); + let actual_speed = route.distance / route.duration * 3.6; + assert!( + approximate_within_range( + actual_speed, + expected_speed, + &offset + ), + "{actual_speed} and {expected_speed} differ by more than {offset:?}" + ); + } + } + + _ => { + // match expectation against mode + for result in &route_results { + match result { + Ok((_,response)) => { + let mode = extract_mode_string_from_route_response(response); + let summary = extract_summary_from_route_response(response); + if summary != expected_summary { + assert!(expectation.is_empty()); + } else { + assert_eq!(&mode, expectation, "failed: {source:?} -> {target:?}"); + } + }, + Err(_) => { + assert!(expectation.is_empty()); + }, + + } + } + } + } } - }); + _ => { + unreachable!("{title} = {expectation}"); + } + } + }); }); - // unimplemented!("{test_cases:#?}"); } -fn extract_number_and_offset(unit: &str, expectation: &str) -> (f64, f64) { +fn extract_summary_from_route_response(response: &RouteResponse) -> String { + response + .routes + .first() + .unwrap() + .legs + .first() + .unwrap() + .summary + .clone() +} + +fn extract_mode_string_from_route_response(response: &RouteResponse) -> String { + // From JS test suite: + // use the mode of the first step of the route + // for routability table test, we can assume the mode is the same throughout the route, + // since the route is just a single way + assert!( + !response.routes.is_empty(), + "route expected when extracting mode" + ); + response + .routes + .first() + .unwrap() + .legs + .first() + .unwrap() + .steps + .first() + .unwrap() + .mode + .clone() +} + +fn extract_number_and_offset(unit: &str, expectation: &str) -> (f64, Offset) { + let expectation = expectation.replace(unit, ""); + let delimiter = if expectation.contains("+-") { + "+-" + } else if expectation.contains("~") { + "~" + } else { + "unknown" + }; let tokens: Vec<_> = expectation - .split(unit) + .split(delimiter) .map(|token| token.trim()) .filter(|token| !token.is_empty()) .collect(); // println!("{tokens:?}"); let number = tokens[0] .parse::() - .expect("{expectation} needs to define a speed"); + .expect(&format!("'{}' needs to denote a parseablespeed", tokens[0])); let offset = match tokens.len() { 1 => 5., // TODO: the JS fuzzy matcher has a default margin of 5% for absolute comparsions. This is imprecise 2 => tokens[1] - .replace("~", "") - .replace("+-", "") + .replace("~", "") + .replace("+-", "") + .replace("%", "") .trim() .parse() .expect(&format!("{} needs to specify a number", tokens[1])), _ => unreachable!("expectations can't be parsed"), }; - (number, offset) + if expectation.ends_with("%") { + return (number, Offset::Percentage(offset)); + } + (number, Offset::Absolute(offset)) } -fn extract_number_vector_and_offset(unit: &str, expectation: &str) -> (Vec, u8) { +fn extract_number_vector_and_offset(unit: &str, expectation: &str) -> (Vec, Offset) { let expectation = expectation.replace(",", ""); let tokens: Vec<_> = expectation .split(unit) @@ -446,7 +642,10 @@ fn extract_number_vector_and_offset(unit: &str, expectation: &str) -> (Vec, .expect(&format!("{} needs to specify a number", tokens[1])), // _ => unreachable!("expectations can't be parsed"), }; - (numbers, offset) + if expectation.ends_with("%") { + return (numbers, Offset::Percentage(offset.into())); + } + (numbers, Offset::Absolute(offset.into())) } pub enum WaypointsOrLocation { @@ -538,7 +737,7 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { .expect("to node name is one char long"), ); vec![from_location, to_location] - }, + } WaypointsOrLocation::Undefined => { world.request_string = test_case.get("request").cloned(); // println!("setting request to: {:?}", world.request_string); @@ -554,7 +753,6 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { } let route_result = world.route(&waypoints); - test_case .iter() .map(|(column_title, expectation)| (column_title.as_str(), expectation.as_str())) @@ -566,13 +764,14 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { Err(_) => &RouteResponse::default(), }; let route = if expectation.is_empty() { + assert!(route_result.is_err()); assert!(response.routes.is_empty()); String::new() } else { response .routes .first() - .expect("no route returned") + .expect("no route returned when checking 'route' column") .legs .iter() .map(|leg| { @@ -633,26 +832,32 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { let (expected_speed, offset) = extract_number_and_offset("km/h", expectation); // println!("{actual_speed} == {expected_speed} +- {offset}"); assert!( - aprox_equal_within_percentage_range(actual_speed, expected_speed, offset), + approximate_within_range(actual_speed, expected_speed, &offset), "actual time {actual_speed} not equal to expected value {expected_speed}" ); }, "modes" => { - let (_, response) = route_result.as_ref().expect("osrm-routed returned an unexpected error"); - let route = response.routes.first().expect("no route returned"); - let actual_modes = route - .legs - .iter() - .map(|leg| { - leg.steps + let actual = match &route_result { + Ok((_, response)) => { + let route = response.routes.first().expect("no route returned"); + let actual_modes = route + .legs .iter() - .map(|step| step.mode.clone()) + .map(|leg| { + leg.steps + .iter() + .map(|step| step.mode.clone()) + .collect::>() + .join(",") + }) .collect::>() - .join(",") - }) - .collect::>() - .join(","); - assert_eq!(actual_modes, expectation); + .join(","); + actual_modes + }, + Err(_) => String::new(), + }; + + assert_eq!(actual, expectation); }, "turns" => { let (_, response) = route_result.as_ref().expect("osrm-routed returned an unexpected error"); @@ -664,17 +869,43 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { leg.steps .iter() .map(|step| { - let prefix = step.maneuver.r#type.clone(); - if prefix == "depart" || prefix == "arrive" { - // TODO: this reimplements the behavior that depart and arrive are not checked for their modifier - // check if tests shall be adapted, since this is reported by the engine - return prefix; - } - let suffix = match &step.maneuver.modifier { - Some(modifier) => " ".to_string() + &modifier, - _ => "".into(), + // NOTE: this is port of JS logic as is. Arguably, this should be replace by a simple join over all type/modifier pairs + let r#type = step.maneuver.r#type.clone(); + let modifier = match &step.maneuver.modifier { + Some(modifier) => modifier.as_str(), + _ => "", }; - prefix + &suffix + match r#type.as_str() { + "depart" | "arrive" => { + r#type + } + "roundabout" => { + let exit = match step.maneuver.exit { + Some(x) => x, + None => unreachable!("roundabout maneuver must come with an exit number"), + }; + format!("roundabout-exit-{}", exit) + }, + "rotary" => { + let exit = match step.maneuver.exit { + Some(x) => x, + None => unreachable!("roundabout maneuver must come with an exit number"), + }; + if let Some(rotary_name) = &step.rotary_name { + format!("{rotary_name}-exit-{exit}") + } else { + format!("rotary-exit-{exit}") + } + }, + "roundabout turn" => { + let exit = match step.maneuver.exit { + Some(x) => x, + None => unreachable!("roundabout maneuver must come with an exit number"), + }; + format!("{} {} exit-{}", r#type, modifier, exit) + } + _ => format!("{} {}", r#type, &modifier), + } }) .collect::>() .join(",") @@ -689,7 +920,7 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { let (expected_time, offset) = extract_number_and_offset("s", expectation); // println!("{actual_time} == {expected_time} +- {offset}"); assert!( - approx_equal_within_offset_range(actual_time, expected_time, offset as f64), + approximate_within_range(actual_time, expected_time, &offset), "actual time {actual_time} not equal to expected value {expected_time}" ); }, @@ -700,10 +931,10 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { leg.steps.iter().filter(|step| step.duration > 0.).map(|step| step.duration).collect::>() }).flatten().collect(); let (expected_times, offset) = extract_number_vector_and_offset("s", expectation); - assert_eq!(actual_times.len(), expected_times.len(), "times mismatch: {actual_times:?} != {expected_times:?} +- {offset}"); + assert_eq!(actual_times.len(), expected_times.len(), "times mismatch: {actual_times:?} != {expected_times:?} +- {offset:?}"); zip(actual_times, expected_times).for_each(|(actual_time, expected_time)| { - assert!(approx_equal_within_offset_range(actual_time, expected_time, offset as f64), + assert!(approximate_within_range(actual_time, expected_time, &offset), "actual time {actual_time} not equal to expected value {expected_time}"); }); }, @@ -713,10 +944,10 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { leg.steps.iter().filter(|step| step.distance > 0.).map(|step| step.distance).collect::>() }).flatten().collect::>(); let (expected_distances, offset) = extract_number_vector_and_offset("m", expectation); - assert_eq!(expected_distances.len(), actual_distances.len(), "distances mismatch {expected_distances:?} != {actual_distances:?} +- {offset}"); + assert_eq!(expected_distances.len(), actual_distances.len(), "distances mismatch {expected_distances:?} != {actual_distances:?} +- {offset:?}"); zip(actual_distances, expected_distances).for_each(|(actual_distance, expected_distance)| { - assert!(approx_equal_within_offset_range(actual_distance, expected_distance, offset as f64), + assert!(approximate_within_range(actual_distance, expected_distance, &offset), "actual distance {actual_distance} not equal to expected value {expected_distance}"); }); }, @@ -725,26 +956,32 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { let actual_weight = response.routes.first().expect("no route returned").weight; let (expected_weight, offset) = extract_number_and_offset("s", expectation); assert!( - approx_equal_within_offset_range( + approximate_within_range( actual_weight, expected_weight, - offset as f64 + &offset ), - "actual time {actual_weight} not equal to expected value {expected_weight}" + "actual weight {actual_weight} not equal to expected value {expected_weight}" ); }, "distance" => { - let (_, response) = route_result.as_ref().expect("osrm-routed returned an unexpected error"); - let actual_distance = response.routes.first().expect("no route returned").distance; - let (expected_distance, offset) = extract_number_and_offset("m", expectation); - assert!( - approx_equal_within_offset_range( - actual_distance, - expected_distance, - offset as f64 - ), - "actual time {actual_distance} not equal to expected value {expected_distance}" - ); + match &route_result { + Ok((_, response)) => { + let actual_distance = response.routes.first().expect("no route returned").distance; + let (expected_distance, offset) = extract_number_and_offset("m", expectation); + assert!( + approximate_within_range( + actual_distance, + expected_distance, + &offset + ), + "actual distance {actual_distance} not equal to expected value {expected_distance} +- {offset:?}" + ); + }, + Err(_) => { + assert_eq!("", expectation); + } + }; }, "summary" => { let (_, response) = route_result.as_ref().expect("osrm-routed returned an unexpected error"); @@ -817,6 +1054,79 @@ fn request_route(world: &mut OSRMWorld, step: &Step, state: String) { assert_eq!(&actual_message, expected_message, "message does not match {test_case:?}"); }, + "bearing" => { + let reverse_bearing = | bearing: u64 | -> u64 { + assert!(bearing <= 360); + if bearing >= 180 { + return bearing - 180; + } + bearing + 180 + }; + + let actual_bearing = match &route_result { + Ok((_,r)) => { + r.routes.first().expect("no routes found in 'bearing' check").legs.iter().map(|leg| { + leg.steps.iter().map(|step| { + let intersection = step.intersections.first().expect("could not find intersection when checking bearing"); + let prefix = if let Some(r#in) = intersection.r#in { + reverse_bearing(intersection.bearings[r#in as usize]) + } else { + 0 + }; + let suffix = if let Some(out) = intersection.out { + intersection.bearings[out as usize] + } else { + 0 + }; + format!("{}->{}", prefix, suffix) + }).collect::>().join(",") + }).collect::>().join(",") + }, + Err(_) => String::new(), + }; + let expected_bearing = test_case.get("bearing").expect("test case doesn't have bearing"); + assert_eq!(&actual_bearing, expected_bearing, "bearings don't match"); + }, + "lanes" => { + let actual = match &route_result { + Ok((_, response)) => { + let route = response.routes.first().expect("no route returned"); + + /* if(i.lanes) + { + return i.lanes.map( l => { + let indications = l.indications.join(';'); + return indications + ':' + (l.valid ? 'true' : 'false'); + }).join(' '); + } + else + { + return ''; + } + }).join(';'); + */ + + let actual_lanes = route + .legs + .iter() + .map(|leg| { + leg.steps + .iter() + .map(|step| {//step.intersections.iter().map(|i|{ + "-".to_string() + }) + .collect::>() + .join(" ") + }) + .collect::>() + .join(";"); + actual_lanes + }, + Err(_) => String::new(), + }; + + assert_eq!(actual, expectation); + } // TODO: more checks need to be implemented _ => { let msg = format!("case {case} = {expectation} not implemented"); @@ -843,9 +1153,9 @@ fn main() { future::ready(()).boxed() }) // .with_writer(DotWriter::default().normalized()) - // .filter_run("features", |_, _, sc| { - .filter_run("features/testbot/oneway_phantom.feature", |_, _, sc| { - !sc.tags.iter().any(|t| t == "todo") + // .filter_run("features/", |fe, _, sc| { + .filter_run("features/guidance/anticipate-lanes.feature", |fe, _, sc| { + !sc.tags.iter().any(|t| t == "todo") && !fe.tags.iter().any(|t| t == "todo") }), ); }