Something went wrong. Try again.
Live STIB-MIVB departures on the Pebble Time 2. It covers the bus, tram, and metro network of Brussels
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869use 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<T>(id: &str, mut lookup: impl FnMut(&str) -> Option<T>) -> Option<T> { 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<WaitingEntry>,}
#[derive(Deserialize)]struct WaitingEntry { pointid: String, passingtimes: String,}
#[derive(Deserialize)]struct RawPassing { destination: Option<Bilingual>, #[serde(rename = "expectedArrivalTime")] expected_arrival_time: Option<String>, #[serde(rename = "lineId")] line_id: Option<String>, message: Option<Bilingual>,}
pub type WaitingTimesResult = (HashMap<Box<str>, Vec<Passage>>, HashMap<Box<str>, Vec<EndNotice>>);
pub fn parse_waiting_times(body: &str) -> Result<WaitingTimesResult> { let doc: WaitingDoc = serde_json::from_str(body).context("waiting times envelope")?; let mut passages: HashMap<Box<str>, Vec<Passage>> = HashMap::new(); let mut end_notices: HashMap<Box<str>, Vec<EndNotice>> = HashMap::new(); for entry in doc.results { let raws: Vec<RawPassing> = 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<StopEntry>,}
#[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<Vec<StopInfo>> { 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<PositionsEntry>,}
#[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<HashMap<Box<str>, Vec<VehiclePosition>>> { let doc: PositionsDoc = serde_json::from_str(body).context("vehicle positions envelope")?; let mut map: HashMap<Box<str>, Vec<VehiclePosition>> = HashMap::new(); for entry in doc.results { let raws: Vec<RawVehiclePosition> = 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<StopsByLineEntry>,}
#[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<HashMap<Box<str>, Vec<LineSequence>>> { let doc: StopsByLineDoc = serde_json::from_str(body).context("stops by line envelope")?; let mut map: HashMap<Box<str>, Vec<LineSequence>> = HashMap::new(); for entry in doc.results { let Ok(destination) = serde_json::from_str::<Bilingual>(&entry.destination) else { continue; }; let Ok(mut points) = serde_json::from_str::<Vec<RawSequencePoint>>(&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<TravellersEntry>,}
#[derive(Deserialize)]struct TravellersEntry { content: String, lines: String, points: String, priority: i64,}
#[derive(Deserialize)]struct RawContentItem { text: Vec<RawText>,}
#[derive(Deserialize)]struct RawText { en: Option<String>, fr: Option<String>, nl: Option<String>,}
#[derive(Deserialize)]struct RawIdRef { id: String,}
pub fn parse_travellers_info(body: &str) -> Result<Vec<Disruption>> { let doc: TravellersDoc = serde_json::from_str(body).context("travellers info envelope")?; Ok(doc .results .into_iter() .filter_map(|entry| { let content: Vec<RawContentItem> = serde_json::from_str(&entry.content).ok()?; let lines: Vec<RawIdRef> = serde_json::from_str(&entry.lines).ok()?; let points: Vec<RawIdRef> = serde_json::from_str(&entry.points).ok()?; let texts: Vec<RawText> = content.into_iter().flat_map(|c| c.text).collect(); if texts.is_empty() { return None; } let join = |f: fn(&RawText) -> &Option<String>| -> Option<String> { 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<HashMap<Box<str>, 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<Box<str>, Vec<LineSequence>>, 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<Quota>,}
impl Client { pub fn new(base: String, key: String, quota: Arc<Quota>) -> 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<reqwest::Response> { 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<RawFeed, FeedFailure> { 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<crate::storage::WaitingBatch, FeedFailure> { 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<crate::storage::StaticBatch, FeedFailure> { 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<crate::storage::StaticBatch> { 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<crate::storage::PositionsBatch, FeedFailure> { 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<crate::storage::DisruptionsBatch, FeedFailure> { 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<String, i32> = 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); }}