use crate::model::{ Bilingual, Disruption, EndNotice, LineSequence, Passage, RouteInfo, StopInfo, VehicleKind, VehiclePosition, }; use crate::quota::Quota; use crate::provider::{FeedFailure, FeedStage, RawFeed}; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::Deserialize; use std::collections::HashMap; use std::io::Read; use std::sync::Arc; pub fn normalize_id(id: &str) -> String { let up = id.trim().to_ascii_uppercase(); let trimmed = up.trim_start_matches('0'); if trimmed.is_empty() { "0".to_string() } else { trimmed.to_string() } } pub fn strip_platform_suffix(id: &str) -> Option<&str> { let bare = id.trim_end_matches(|c: char| c.is_ascii_uppercase()); if bare.len() == id.len() || bare.is_empty() { return None; } bare.bytes().all(|b| b.is_ascii_digit()).then_some(bare) } pub fn with_stop_fallback(id: &str, mut lookup: impl FnMut(&str) -> Option) -> Option { lookup(id).or_else(|| strip_platform_suffix(id).and_then(lookup)) } pub fn ids_match(static_id: &str, rt_id: &str) -> bool { static_id == rt_id || strip_platform_suffix(static_id) == Some(rt_id) } #[derive(Deserialize)] struct WaitingDoc { results: Vec, } #[derive(Deserialize)] struct WaitingEntry { pointid: String, passingtimes: String, } #[derive(Deserialize)] struct RawPassing { destination: Option, #[serde(rename = "expectedArrivalTime")] expected_arrival_time: Option, #[serde(rename = "lineId")] line_id: Option, message: Option, } pub type WaitingTimesResult = (HashMap, Vec>, HashMap, Vec>); pub fn parse_waiting_times(body: &str) -> Result { let doc: WaitingDoc = serde_json::from_str(body).context("waiting times envelope")?; let mut passages: HashMap, Vec> = HashMap::new(); let mut end_notices: HashMap, Vec> = HashMap::new(); for entry in doc.results { let raws: Vec = match serde_json::from_str(&entry.passingtimes) { Ok(v) => v, Err(_) => continue, }; let id = normalize_id(&entry.pointid).into_boxed_str(); for raw in raws { if raw.expected_arrival_time.is_none() { let (Some(message), Some(line)) = (raw.message, raw.line_id) else { continue; }; let line = normalize_id(&line); let notices = end_notices.entry(id.clone()).or_default(); if !notices.iter().any(|n| n.line == line) { notices.push(EndNotice { line, message }); } continue; } let (Some(dest), Some(when), Some(line)) = (raw.destination, raw.expected_arrival_time, raw.line_id) else { continue; }; let line = normalize_id(&line); let Ok(expected) = DateTime::parse_from_rfc3339(&when) else { continue; }; passages.entry(id.clone()).or_default().push(Passage { line, destination: dest, expected, }); } } passages.retain(|_, v| !v.is_empty()); Ok((passages, end_notices)) } #[derive(Deserialize)] struct StopDoc { results: Vec, } #[derive(Deserialize)] struct StopEntry { id: String, gpscoordinates: String, name: String, } #[derive(Deserialize)] struct RawCoords { latitude: f64, longitude: f64, } pub fn parse_stop_details(body: &str) -> Result> { let doc: StopDoc = serde_json::from_str(body).context("stop details envelope")?; Ok(doc .results .into_iter() .filter_map(|e| { let coords: RawCoords = serde_json::from_str(&e.gpscoordinates).ok()?; let name: Bilingual = serde_json::from_str(&e.name).ok()?; Some(StopInfo { id: normalize_id(&e.id), name, lat: coords.latitude, lon: coords.longitude, }) }) .collect()) } #[derive(Deserialize)] struct PositionsDoc { results: Vec, } #[derive(Deserialize)] struct PositionsEntry { lineid: String, vehiclepositions: String, } #[derive(Deserialize)] struct RawVehiclePosition { #[serde(rename = "directionId")] direction_id: String, #[serde(rename = "distanceFromPoint")] distance_from_point: u32, #[serde(rename = "pointId")] point_id: String, } pub fn parse_vehicle_positions(body: &str) -> Result, Vec>> { let doc: PositionsDoc = serde_json::from_str(body).context("vehicle positions envelope")?; let mut map: HashMap, Vec> = HashMap::new(); for entry in doc.results { let raws: Vec = match serde_json::from_str(&entry.vehiclepositions) { Ok(v) => v, Err(_) => continue, }; let line = normalize_id(&entry.lineid).into_boxed_str(); for raw in raws { map.entry(line.clone()).or_default().push(VehiclePosition { terminus: normalize_id(&raw.direction_id), point_id: normalize_id(&raw.point_id), distance_m: raw.distance_from_point, }); } } Ok(map) } #[derive(Deserialize)] struct StopsByLineDoc { results: Vec, } #[derive(Deserialize)] struct StopsByLineEntry { lineid: String, destination: String, points: String, } #[derive(Deserialize)] struct RawSequencePoint { id: String, order: i64, } pub fn parse_stops_by_line(body: &str) -> Result, Vec>> { let doc: StopsByLineDoc = serde_json::from_str(body).context("stops by line envelope")?; let mut map: HashMap, Vec> = HashMap::new(); for entry in doc.results { let Ok(destination) = serde_json::from_str::(&entry.destination) else { continue; }; let Ok(mut points) = serde_json::from_str::>(&entry.points) else { continue; }; points.sort_by_key(|p| p.order); let line = normalize_id(&entry.lineid); let stops = points.into_iter().map(|p| normalize_id(&p.id)).collect(); map.entry(line.clone().into_boxed_str()).or_default().push(LineSequence { line, destination, stops, }); } Ok(map) } #[derive(Deserialize)] struct TravellersDoc { results: Vec, } #[derive(Deserialize)] struct TravellersEntry { content: String, lines: String, points: String, priority: i64, } #[derive(Deserialize)] struct RawContentItem { text: Vec, } #[derive(Deserialize)] struct RawText { en: Option, fr: Option, nl: Option, } #[derive(Deserialize)] struct RawIdRef { id: String, } pub fn parse_travellers_info(body: &str) -> Result> { let doc: TravellersDoc = serde_json::from_str(body).context("travellers info envelope")?; Ok(doc .results .into_iter() .filter_map(|entry| { let content: Vec = serde_json::from_str(&entry.content).ok()?; let lines: Vec = serde_json::from_str(&entry.lines).ok()?; let points: Vec = serde_json::from_str(&entry.points).ok()?; let texts: Vec = content.into_iter().flat_map(|c| c.text).collect(); if texts.is_empty() { return None; } let join = |f: fn(&RawText) -> &Option| -> Option { let parts: Vec<&str> = texts.iter().filter_map(|t| f(t).as_deref()).collect(); if parts.is_empty() { None } else { Some(parts.join(" ")) } }; Some(Disruption { en: join(|t| &t.en), fr: join(|t| &t.fr), nl: join(|t| &t.nl), priority: entry.priority, lines: lines.into_iter().map(|l| normalize_id(&l.id)).collect(), points: points.into_iter().map(|p| normalize_id(&p.id)).collect(), }) }) .collect()) } pub fn parse_gtfs_routes(zip_bytes: &[u8]) -> Result, RouteInfo>> { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).context("gtfs zip")?; let mut file = archive.by_name("routes.txt").context("routes.txt missing")?; let mut text = String::new(); file.read_to_string(&mut text)?; let mut reader = csv::Reader::from_reader(text.as_bytes()); let headers = reader.headers()?.clone(); let col = |name: &str| headers.iter().position(|h| h == name); let (Some(id), Some(short), Some(rtype), Some(color), Some(text_color)) = ( col("route_id"), col("route_short_name"), col("route_type"), col("route_color"), col("route_text_color"), ) else { anyhow::bail!("routes.txt header mismatch"); }; let mut map = HashMap::new(); for record in reader.records() { let record = record?; let line = record[short].to_string(); let kind = match &record[rtype] { "0" => VehicleKind::Tram, "1" => VehicleKind::Metro, _ => VehicleKind::Bus, }; map.insert( line.clone().into_boxed_str(), RouteInfo { line, route_id: record[id].to_string(), kind, color: record[color].to_string(), text_color: record[text_color].to_string(), }, ); } Ok(map) } fn build_shape_index( by_line: &crate::shapes::ShapesByLine, sequences: &HashMap, Vec>, stops: &[StopInfo], ) -> crate::shapes::ShapeIndex { let coords: HashMap<&str, (f64, f64)> = stops.iter().map(|s| (s.id.as_str(), (s.lat, s.lon))).collect(); let mut index = HashMap::new(); for (line, seqs) in sequences { let Some(candidates) = by_line.get(line.as_ref()) else { continue; }; for seq in seqs { let Some(terminus_id) = seq.stops.last() else { continue; }; let Some(&(lat, lon)) = coords.get(terminus_id.as_str()) else { continue; }; let origin = seq.stops.first().and_then(|id| coords.get(id.as_str()).copied()); if let Some(shape) = crate::shapes::for_terminus(candidates, origin, lat, lon) { index.insert((line.to_string(), terminus_id.clone()), crate::shapes::simplify(shape, 48)); } } } index } pub struct Client { http: reqwest::Client, base: String, quota: Arc, } impl Client { pub fn new(base: String, key: String, quota: Arc) -> Self { let mut headers = reqwest::header::HeaderMap::new(); headers.insert("bmc-partner-key", key.parse().expect("valid key header")); let http = reqwest::Client::builder() .default_headers(headers) .timeout(std::time::Duration::from_secs(120)) .build() .expect("http client"); Self { http, base, quota } } async fn get(&self, path: &str) -> Result { if !self.quota.try_take(Utc::now()) { anyhow::bail!("local quota exhausted for {path}"); } let resp = self .http .get(format!("{}{}", self.base, path)) .send() .await? .error_for_status()?; Ok(resp) } async fn fetch_raw( &self, path: &str, feed: &'static str, scope: &'static str, ) -> std::result::Result { let response = self.get(path).await.map_err(|source| FeedFailure { stage: FeedStage::Provider, source, })?; let content_type = response .headers() .get(reqwest::header::CONTENT_TYPE) .with_context(|| format!("missing content type for {path}")) .and_then(|value| { value .to_str() .with_context(|| format!("invalid content type for {path}")) }) .map_err(|source| FeedFailure { stage: FeedStage::Provider, source, })? .to_owned() .into_boxed_str(); let body = response .bytes() .await .with_context(|| format!("failed to read {path}")) .map_err(|source| FeedFailure { stage: FeedStage::Provider, source, })? .to_vec(); Ok(RawFeed { provider: "bmc", feed, scope: scope.into(), captured_at: Utc::now(), source_at: None, content_type, body, }) } pub async fn fetch_waiting( &self, ) -> std::result::Result { let raw = self .fetch_raw( "/api/datasets/stibmivb/rt/WaitingTimes", "waiting", "", ) .await?; let parsed = std::str::from_utf8(&raw.body) .context("waiting times encoding") .and_then(parse_waiting_times) .map_err(|source| FeedFailure { stage: FeedStage::Parse, source, })?; Ok(crate::storage::WaitingBatch { raw, passages: parsed.0, end_notices: parsed.1, }) } pub async fn fetch_static( &self, ) -> std::result::Result { let stop_details = self .fetch_raw( "/api/datasets/stibmivb/static/StopDetails", "static", "stop-details", ) .await?; let gtfs = self .fetch_raw("/api/gtfs/feed/stibmivb/static", "static", "gtfs") .await?; let stops_by_line = self .fetch_raw( "/api/datasets/stibmivb/static/stopsByLine", "static", "stops-by-line", ) .await?; let parsed = (|| -> anyhow::Result { let stops = parse_stop_details( std::str::from_utf8(&stop_details.body).context("stop details encoding")?, )?; let routes = parse_gtfs_routes(>fs.body)?; let sequences = parse_stops_by_line( std::str::from_utf8(&stops_by_line.body).context("stops by line encoding")?, )?; let shapes_by_line = crate::shapes::parse(>fs.body, &routes)?; let shapes = build_shape_index(&shapes_by_line, &sequences, &stops); let batch = crate::storage::StaticBatch { raw: vec![stop_details, gtfs, stops_by_line], stops, routes, sequences, shapes, }; batch.validate()?; Ok(batch) })(); parsed.map_err(|source| FeedFailure { stage: FeedStage::Parse, source, }) } pub async fn fetch_positions( &self, ) -> std::result::Result { let raw = self .fetch_raw( "/api/datasets/stibmivb/rt/VehiclePositions", "positions", "", ) .await?; let positions = std::str::from_utf8(&raw.body) .context("vehicle positions encoding") .and_then(parse_vehicle_positions) .map_err(|source| FeedFailure { stage: FeedStage::Parse, source, })?; Ok(crate::storage::PositionsBatch { raw, positions }) } pub async fn fetch_disruptions( &self, ) -> std::result::Result { let raw = self .fetch_raw( "/api/datasets/stibmivb/rt/TravellersInformation", "disruptions", "", ) .await?; let disruptions = std::str::from_utf8(&raw.body) .context("travellers information encoding") .and_then(parse_travellers_info) .map_err(|source| FeedFailure { stage: FeedStage::Parse, source, })?; Ok(crate::storage::DisruptionsBatch { raw, disruptions }) } } #[cfg(test)] mod tests { use super::*; const WAITING: &str = r#"{"results":[ {"pointid":"6419","lineid":"62","passingtimes":"[{\"destination\":{\"fr\":\"SAINT-LAMBERT\",\"nl\":\"SINT-LAMBERTUS\"},\"expectedArrivalTime\":\"2026-08-20T19:11:39.475+02:00\",\"lineId\":\"62\"}]"}, {"pointid":"0089","lineid":"7","passingtimes":"[{\"message\":{\"fr\":\"Fin de service\",\"nl\":\"Einde dienst\"},\"lineId\":\"7\"}]"}, {"pointid":"1057","lineid":"062","passingtimes":"[{\"destination\":{\"fr\":\"ARTS-LOI\",\"nl\":\"KUNST-WET\"},\"expectedArrivalTime\":\"2026-08-20T19:11:39.475+02:00\",\"lineId\":\"062\"}]"} ]}"#; const WAITING_MIXED: &str = r#"{"results":[ {"pointid":"6419","lineid":"62","passingtimes":"[{\"destination\":{\"fr\":\"SAINT-LAMBERT\",\"nl\":\"SINT-LAMBERTUS\"},\"expectedArrivalTime\":\"2026-08-20T19:11:39.475+02:00\",\"lineId\":\"62\"},{\"message\":{\"fr\":\"Fin de service\",\"nl\":\"Einde dienst\"},\"lineId\":\"7\"},{\"message\":{\"fr\":\"Fin de service\",\"nl\":\"Einde dienst\"},\"lineId\":\"7\"}]"} ]}"#; const STOPS: &str = r#"{"results":[ {"gpscoordinates":"{\"latitude\":50.78894,\"longitude\":4.334006}","id":"1961B","name":"{\"fr\":\"ENGELAND\",\"nl\":\"ENGELAND\"}"}, {"gpscoordinates":"bogus","id":"9999","name":"{\"fr\":\"X\",\"nl\":\"X\"}"} ]}"#; const POSITIONS: &str = r#"{"results":[ {"lineid":"10","vehiclepositions":"[{\"directionId\":\"05758\",\"distanceFromPoint\":120,\"pointId\":\"0758\"}]"}, {"lineid":"07","vehiclepositions":"bogus"} ]}"#; const STOPS_BY_LINE: &str = r#"{"results":[ {"destination":"{\"fr\":\"GARE CENTRALE\",\"nl\":\"CENTRAAL STATION\"}","direction":"City","lineid":"63","points":"[{\"id\":\"3402\",\"order\":2},{\"id\":\"3401\",\"order\":1}]"}, {"destination":"bogus","direction":"City","lineid":"64","points":"[{\"id\":\"1\",\"order\":1}]"} ]}"#; const TRAVELLERS: &str = r#"{"results":[ {"id":"28485733","content":"[{\"text\":[{\"en\":\"Delay\",\"fr\":\"Retard\",\"nl\":\"Vertraging\"},{\"fr\":\"sur ligne 66\"}],\"type\":\"Description\"}]","lines":"[{\"id\":\"066\"}]","points":"[{\"id\":\"2346\"}]","priority":4}, {"id":"1","content":"bogus","lines":"[{\"id\":\"1\"}]","points":"[]","priority":1}, {"id":"2","content":"[{\"text\":[{\"fr\":\"Uniquement fr\"}]}]","lines":"[{\"id\":\"5\"}]","points":"[{\"id\":\"10\"}]","priority":2} ]}"#; async fn client_for(app: axum::Router) -> (Client, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); let quota = Arc::new(Quota::new(500, 12_000)); ( Client::new(format!("http://{address}"), "test".into(), quota), server, ) } #[tokio::test] async fn fetch_static_maps_http_failures_to_provider_stage() { let app = axum::Router::new() .fallback(|| async { axum::http::StatusCode::BAD_GATEWAY }); let (client, server) = client_for(app).await; let failure = match client.fetch_static().await { Ok(_) => panic!("expected provider failure"), Err(failure) => failure, }; server.abort(); assert_eq!(failure.stage, FeedStage::Provider); } #[tokio::test] async fn fetch_static_maps_invalid_bodies_to_parse_stage() { let app = axum::Router::new().fallback(|| async { ( [(axum::http::header::CONTENT_TYPE, "application/json")], "invalid", ) }); let (client, server) = client_for(app).await; let failure = match client.fetch_static().await { Ok(_) => panic!("expected parse failure"), Err(failure) => failure, }; server.abort(); assert_eq!(failure.stage, FeedStage::Parse); } #[tokio::test] async fn fetch_waiting_returns_raw_and_parsed_batch() { let app = axum::Router::new().route( "/api/datasets/stibmivb/rt/WaitingTimes", axum::routing::get(|| async { ( [(axum::http::header::CONTENT_TYPE, "application/json")], WAITING, ) }), ); let (client, server) = client_for(app).await; let batch = client.fetch_waiting().await.unwrap(); server.abort(); assert_eq!(batch.raw.provider, "bmc"); assert_eq!(batch.raw.feed, "waiting"); assert!(batch.raw.scope.is_empty()); assert_eq!(batch.raw.body, WAITING.as_bytes()); assert_eq!(batch.passages["6419"].len(), 1); assert_eq!(batch.end_notices["89"].len(), 1); } #[tokio::test] async fn fetch_positions_returns_raw_and_parsed_batch() { let app = axum::Router::new().route( "/api/datasets/stibmivb/rt/VehiclePositions", axum::routing::get(|| async { ( [(axum::http::header::CONTENT_TYPE, "application/json")], POSITIONS, ) }), ); let (client, server) = client_for(app).await; let batch = client.fetch_positions().await.unwrap(); server.abort(); assert_eq!(batch.raw.provider, "bmc"); assert_eq!(batch.raw.feed, "positions"); assert!(batch.raw.scope.is_empty()); assert_eq!(batch.raw.body, POSITIONS.as_bytes()); assert_eq!(batch.positions["10"].len(), 1); } #[tokio::test] async fn fetch_disruptions_returns_raw_and_parsed_batch() { let app = axum::Router::new().route( "/api/datasets/stibmivb/rt/TravellersInformation", axum::routing::get(|| async { ( [(axum::http::header::CONTENT_TYPE, "application/json")], TRAVELLERS, ) }), ); let (client, server) = client_for(app).await; let batch = client.fetch_disruptions().await.unwrap(); server.abort(); assert_eq!(batch.raw.provider, "bmc"); assert_eq!(batch.raw.feed, "disruptions"); assert!(batch.raw.scope.is_empty()); assert_eq!(batch.raw.body, TRAVELLERS.as_bytes()); assert_eq!(batch.disruptions.len(), 2); } #[tokio::test] async fn fetch_waiting_maps_invalid_bodies_to_parse_stage() { let app = axum::Router::new().route( "/api/datasets/stibmivb/rt/WaitingTimes", axum::routing::get(|| async { ( [(axum::http::header::CONTENT_TYPE, "application/json")], "invalid", ) }), ); let (client, server) = client_for(app).await; let failure = match client.fetch_waiting().await { Ok(_) => panic!("expected parse failure"), Err(failure) => failure, }; server.abort(); assert_eq!(failure.stage, FeedStage::Parse); } #[test] fn normalizes_ids() { assert_eq!(normalize_id("0089"), "89"); assert_eq!(normalize_id("1961b"), "1961B"); assert_eq!(normalize_id("0"), "0"); } #[test] fn strips_trailing_letters_from_numeric_id() { assert_eq!(strip_platform_suffix("2290B"), Some("2290")); assert_eq!(strip_platform_suffix("470F"), Some("470")); } #[test] fn does_not_strip_ids_without_a_suffix() { assert_eq!(strip_platform_suffix("2290"), None); } #[test] fn does_not_strip_when_remainder_is_empty() { assert_eq!(strip_platform_suffix("ABC"), None); } #[test] fn does_not_strip_when_remainder_is_not_numeric() { assert_eq!(strip_platform_suffix("AB12CD"), None); } #[test] fn fallback_lookup_prefers_exact_match() { let mut map = HashMap::new(); map.insert("2290".to_string(), 1); map.insert("2290B".to_string(), 2); assert_eq!(with_stop_fallback("2290B", |id| map.get(id).copied()), Some(2)); } #[test] fn fallback_lookup_strips_suffix_on_miss() { let mut map = HashMap::new(); map.insert("2290".to_string(), 1); assert_eq!(with_stop_fallback("2290B", |id| map.get(id).copied()), Some(1)); } #[test] fn fallback_lookup_stays_none_when_bare_sibling_is_also_absent() { let map: HashMap = HashMap::new(); assert_eq!(with_stop_fallback("2290B", |id| map.get(id).copied()), None); } #[test] fn fallback_lookup_does_not_retry_non_numeric_remainder() { let mut map = HashMap::new(); map.insert("AB12".to_string(), 1); assert_eq!(with_stop_fallback("AB12CD", |id| map.get(id).copied()), None); } #[test] fn parses_waiting_times_and_end_notices() { let (passages, end_notices) = parse_waiting_times(WAITING).unwrap(); let p = &passages["6419"]; assert_eq!(p.len(), 1); assert_eq!(p[0].line, "62"); assert_eq!(p[0].destination.fr, "SAINT-LAMBERT"); assert!(!passages.contains_key("89")); let padded = &passages["1057"]; assert_eq!(padded.len(), 1); assert_eq!(padded[0].line, "62"); let n = &end_notices["89"]; assert_eq!(n.len(), 1); assert_eq!(n[0].line, "7"); assert_eq!(n[0].message.fr, "Fin de service"); assert_eq!(n[0].message.nl, "Einde dienst"); } #[test] fn dedups_end_notices_per_stop_and_line() { let (passages, end_notices) = parse_waiting_times(WAITING_MIXED).unwrap(); assert_eq!(passages["6419"].len(), 1); assert_eq!(passages["6419"][0].line, "62"); let notices = &end_notices["6419"]; assert_eq!(notices.len(), 1); assert_eq!(notices[0].line, "7"); } #[test] fn parses_vehicle_positions_and_normalizes_ids() { let map = parse_vehicle_positions(POSITIONS).unwrap(); let v = &map["10"]; assert_eq!(v.len(), 1); assert_eq!( v[0], VehiclePosition { terminus: "5758".to_string(), point_id: "758".to_string(), distance_m: 120, } ); assert!(!map.contains_key("7")); } #[test] fn parses_stops_by_line_ordered_and_skips_bad_destination() { let map = parse_stops_by_line(STOPS_BY_LINE).unwrap(); let seqs = &map["63"]; assert_eq!(seqs.len(), 1); assert_eq!( seqs[0], LineSequence { line: "63".to_string(), destination: Bilingual { fr: "GARE CENTRALE".to_string(), nl: "CENTRAAL STATION".to_string(), }, stops: vec!["3401".to_string(), "3402".to_string()], } ); assert!(!map.contains_key("64")); } #[test] fn parses_travellers_info_and_joins_text_and_normalizes_ids() { let disruptions = parse_travellers_info(TRAVELLERS).unwrap(); assert_eq!(disruptions.len(), 2); let joined = &disruptions[0]; assert_eq!( joined, &Disruption { en: Some("Delay".to_string()), fr: Some("Retard sur ligne 66".to_string()), nl: Some("Vertraging".to_string()), priority: 4, lines: vec!["66".to_string()], points: vec!["2346".to_string()], } ); let fr_only = &disruptions[1]; assert_eq!(fr_only.en, None); assert_eq!(fr_only.fr, Some("Uniquement fr".to_string())); assert_eq!(fr_only.nl, None); assert_eq!(fr_only.lines, vec!["5".to_string()]); assert_eq!(fr_only.points, vec!["10".to_string()]); } #[test] fn parses_stop_details_and_skips_bad_rows() { let stops = parse_stop_details(STOPS).unwrap(); assert_eq!(stops.len(), 1); assert_eq!(stops[0].id, "1961B"); assert!((stops[0].lat - 50.78894).abs() < 1e-9); } #[test] fn parses_gtfs_routes_from_zip() { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); z.start_file::<_, ()>("routes.txt", zip::write::FileOptions::default()).unwrap(); use std::io::Write; z.write_all(b"route_id,agency_id,route_short_name,route_long_name,route_desc,route_type,route_url,route_color,route_text_color\n5,\"STIB/MIVB\",4,\"GARE DU NORD - STALLE (P)\",,0,,EA4F80,000000\n2,\"STIB/MIVB\",1,\"GARE DE L'OUEST - STOCKEL\",,1,,B5378C,FFFFFF\n").unwrap(); z.finish().unwrap(); } let routes = parse_gtfs_routes(buf.get_ref()).unwrap(); assert_eq!(routes["4"].kind, crate::model::VehicleKind::Tram); assert_eq!(routes["4"].color, "EA4F80"); assert_eq!(routes["1"].kind, crate::model::VehicleKind::Metro); } }