a rust tui to view amtrak train status
at main 960 lines 28 kB view raw
1// Application state - viewport, selected train, etc. 2 3use crate::geo::GeoLayers; 4use crate::model::{DelayStatus, Station, Train, TrainState}; 5 6#[derive(Debug, Clone, PartialEq, Eq)] 7pub enum InputMode { 8 Normal, 9 Search, 10 Layers, 11} 12 13pub struct LayerVisibility { 14 pub coastlines: bool, 15 pub land: bool, 16 pub lakes: bool, 17 pub urban: bool, 18 pub rivers: bool, 19 pub states: bool, 20 pub roads: bool, 21 pub cities: bool, 22 pub routes: bool, 23 pub stations: bool, 24 pub trains: bool, 25} 26 27impl Default for LayerVisibility { 28 fn default() -> Self { 29 Self { 30 coastlines: true, 31 land: true, 32 lakes: true, 33 urban: true, 34 rivers: true, 35 states: true, 36 roads: true, 37 cities: true, 38 routes: true, 39 stations: true, 40 trains: true, 41 } 42 } 43} 44 45pub struct Viewport { 46 pub x_min: f64, // west longitude 47 pub x_max: f64, // east longitude 48 pub y_min: f64, // south latitude 49 pub y_max: f64, // north latitude 50} 51 52impl Viewport { 53 pub fn conus() -> Self { 54 Self { 55 x_min: -130.0, 56 x_max: -60.0, 57 y_min: 22.0, 58 y_max: 52.0, 59 } 60 } 61 62 pub fn width(&self) -> f64 { 63 self.x_max - self.x_min 64 } 65 66 pub fn height(&self) -> f64 { 67 self.y_max - self.y_min 68 } 69 70 pub fn center(&self) -> (f64, f64) { 71 ( 72 (self.x_min + self.x_max) / 2.0, 73 (self.y_min + self.y_max) / 2.0, 74 ) 75 } 76 77 pub fn zoom_in(&mut self) { 78 // Minimum viewport width ~3 degrees (~200 miles) — beyond this 79 // there's not enough geodata to provide useful context 80 if self.width() < 3.5 { 81 return; 82 } 83 let (cx, cy) = self.center(); 84 let new_w = self.width() * 0.7; 85 let new_h = self.height() * 0.7; 86 self.x_min = cx - new_w / 2.0; 87 self.x_max = cx + new_w / 2.0; 88 self.y_min = cy - new_h / 2.0; 89 self.y_max = cy + new_h / 2.0; 90 } 91 92 pub fn zoom_out(&mut self) { 93 let (cx, cy) = self.center(); 94 let new_w = self.width() / 0.7; 95 let new_h = self.height() / 0.7; 96 self.x_min = cx - new_w / 2.0; 97 self.x_max = cx + new_w / 2.0; 98 self.y_min = cy - new_h / 2.0; 99 self.y_max = cy + new_h / 2.0; 100 } 101 102 pub fn pan(&mut self, dx_frac: f64, dy_frac: f64) { 103 let dx = self.width() * dx_frac; 104 let dy = self.height() * dy_frac; 105 self.x_min += dx; 106 self.x_max += dx; 107 self.y_min += dy; 108 self.y_max += dy; 109 } 110 111 pub fn contains(&self, lon: f64, lat: f64) -> bool { 112 lon >= self.x_min && lon <= self.x_max && lat >= self.y_min && lat <= self.y_max 113 } 114 115 pub fn center_on(&mut self, lon: f64, lat: f64, width: f64, height: f64) { 116 self.x_min = lon - width / 2.0; 117 self.x_max = lon + width / 2.0; 118 self.y_min = lat - height / 2.0; 119 self.y_max = lat + height / 2.0; 120 } 121} 122 123pub struct App { 124 pub viewport: Viewport, 125 pub trains: Vec<Train>, 126 pub stations: Vec<Station>, 127 pub geo: Option<GeoLayers>, 128 pub selected_index: Option<usize>, 129 pub selected_station: Option<usize>, 130 pub station_mode: bool, 131 pub should_quit: bool, 132 pub last_update: Option<std::time::Instant>, 133 pub status_message: String, 134 pub input_mode: InputMode, 135 pub search_query: String, 136 pub animation_tick: u64, 137 pub layers: LayerVisibility, 138} 139 140impl Default for App { 141 fn default() -> Self { 142 Self::new() 143 } 144} 145 146impl App { 147 pub fn new() -> Self { 148 let geo = GeoLayers::load().ok(); 149 Self { 150 viewport: Viewport::conus(), 151 trains: Vec::new(), 152 stations: Vec::new(), 153 geo, 154 selected_index: None, 155 selected_station: None, 156 station_mode: false, 157 should_quit: false, 158 last_update: None, 159 status_message: "Loading...".into(), 160 input_mode: InputMode::Normal, 161 search_query: String::new(), 162 animation_tick: 0, 163 layers: LayerVisibility::default(), 164 } 165 } 166 167 pub fn toggle_layer(&mut self, layer: &str) { 168 match layer { 169 "coastlines" => self.layers.coastlines = !self.layers.coastlines, 170 "land" => self.layers.land = !self.layers.land, 171 "lakes" => self.layers.lakes = !self.layers.lakes, 172 "urban" => self.layers.urban = !self.layers.urban, 173 "rivers" => self.layers.rivers = !self.layers.rivers, 174 "states" => self.layers.states = !self.layers.states, 175 "roads" => self.layers.roads = !self.layers.roads, 176 "cities" => self.layers.cities = !self.layers.cities, 177 "routes" => self.layers.routes = !self.layers.routes, 178 "stations" => self.layers.stations = !self.layers.stations, 179 "trains" => self.layers.trains = !self.layers.trains, 180 _ => {} 181 } 182 } 183 184 fn matches_search(&self, train: &Train) -> bool { 185 if self.search_query.is_empty() { 186 return true; 187 } 188 let q = self.search_query.to_lowercase(); 189 train.number.to_lowercase().contains(&q) 190 || train.route_name.to_lowercase().contains(&q) 191 || train.origin.to_lowercase().contains(&q) 192 || train.destination.to_lowercase().contains(&q) 193 } 194 195 pub fn visible_trains(&self) -> Vec<&Train> { 196 self.trains 197 .iter() 198 .filter(|t| t.state == TrainState::Active) 199 .filter(|t| self.search_query.is_empty() || self.matches_search(t)) 200 .filter(|t| self.viewport.contains(t.lon, t.lat)) 201 .collect() 202 } 203 204 pub fn selected_train(&self) -> Option<&Train> { 205 let visible = self.visible_trains(); 206 self.selected_index.and_then(|i| visible.get(i).copied()) 207 } 208 209 pub fn select_next(&mut self) { 210 let count = self.visible_trains().len(); 211 if count == 0 { 212 self.selected_index = None; 213 return; 214 } 215 self.selected_index = Some(match self.selected_index { 216 None => 0, 217 Some(i) => (i + 1) % count, 218 }); 219 } 220 221 pub fn select_prev(&mut self) { 222 let count = self.visible_trains().len(); 223 if count == 0 { 224 self.selected_index = None; 225 return; 226 } 227 self.selected_index = Some(match self.selected_index { 228 None => count - 1, 229 Some(0) => count - 1, 230 Some(i) => i - 1, 231 }); 232 } 233 234 pub fn active_train_count(&self) -> usize { 235 self.trains 236 .iter() 237 .filter(|t| t.state == TrainState::Active) 238 .count() 239 } 240 241 pub fn delayed_train_count(&self) -> usize { 242 self.trains 243 .iter() 244 .filter(|t| t.state == TrainState::Active) 245 .filter(|t| t.delay_status() == DelayStatus::Late) 246 .count() 247 } 248 249 pub fn jump_to_selected(&mut self) { 250 let visible = self.visible_trains(); 251 if let Some(train) = self.selected_index.and_then(|i| visible.get(i).copied()) { 252 let lon = train.lon; 253 let lat = train.lat; 254 // Zoom to roughly a 15x10 degree window centered on the train 255 self.viewport.center_on(lon, lat, 15.0, 10.0); 256 } 257 } 258 259 pub fn enter_search(&mut self) { 260 self.input_mode = InputMode::Search; 261 self.search_query.clear(); 262 } 263 264 pub fn exit_search(&mut self) { 265 self.input_mode = InputMode::Normal; 266 self.search_query.clear(); 267 self.selected_index = None; 268 } 269 270 pub fn search_push(&mut self, c: char) { 271 self.search_query.push(c); 272 self.selected_index = None; 273 } 274 275 pub fn search_pop(&mut self) { 276 self.search_query.pop(); 277 self.selected_index = None; 278 } 279 280 pub fn tick(&mut self) { 281 self.animation_tick = self.animation_tick.wrapping_add(1); 282 } 283 284 pub fn is_train_bright(&self, velocity: f64) -> bool { 285 if velocity == 0.0 { 286 return false; 287 } 288 let period = if velocity < 20.0 { 289 8 290 } else if velocity < 60.0 { 291 4 292 } else { 293 2 294 }; 295 self.animation_tick.is_multiple_of(period) 296 } 297 298 pub fn zoom_detail_level(&self) -> u32 { 299 let w = self.viewport.width(); 300 if w > 50.0 { 301 4 302 } else if w > 30.0 { 303 5 304 } else if w > 15.0 { 305 6 306 } else { 307 7 // show all stations 308 } 309 } 310 311 pub fn visible_stations(&self) -> Vec<&Station> { 312 let level = self.zoom_detail_level(); 313 self.stations 314 .iter() 315 .filter(|s| s.zoom_level <= level) 316 .filter(|s| self.viewport.contains(s.lon, s.lat)) 317 .collect() 318 } 319 320 pub fn trains_at_station(&self, station_code: &str) -> Vec<&Train> { 321 self.trains 322 .iter() 323 .filter(|t| t.state == TrainState::Active) 324 .filter(|t| { 325 t.origin == station_code 326 || t.destination == station_code 327 || t.stations.iter().any(|s| s.code == station_code) 328 }) 329 .collect() 330 } 331 332 pub fn toggle_station_mode(&mut self) { 333 self.station_mode = !self.station_mode; 334 if self.station_mode { 335 let visible = self.visible_stations(); 336 if !visible.is_empty() { 337 self.selected_station = Some(0); 338 } 339 } else { 340 self.selected_station = None; 341 } 342 } 343 344 pub fn select_next_station(&mut self) { 345 let count = self.visible_stations().len(); 346 if count == 0 { 347 self.selected_station = None; 348 return; 349 } 350 self.selected_station = Some(match self.selected_station { 351 None => 0, 352 Some(i) => (i + 1) % count, 353 }); 354 } 355 356 pub fn select_prev_station(&mut self) { 357 let count = self.visible_stations().len(); 358 if count == 0 { 359 self.selected_station = None; 360 return; 361 } 362 self.selected_station = Some(match self.selected_station { 363 None => count - 1, 364 Some(0) => count - 1, 365 Some(i) => i - 1, 366 }); 367 } 368 369 pub fn reset_view(&mut self) { 370 self.viewport = Viewport::conus(); 371 self.selected_index = None; 372 self.selected_station = None; 373 self.station_mode = false; 374 self.search_query.clear(); 375 self.input_mode = InputMode::Normal; 376 self.layers = LayerVisibility::default(); 377 } 378} 379 380#[cfg(test)] 381mod tests { 382 use super::*; 383 384 fn make_train(num: &str, lon: f64, lat: f64, state: TrainState) -> Train { 385 make_train_named(num, "Test", lon, lat, state) 386 } 387 388 fn make_train_named(num: &str, route: &str, lon: f64, lat: f64, state: TrainState) -> Train { 389 Train { 390 number: num.into(), 391 route_name: route.into(), 392 origin: "A".into(), 393 destination: "B".into(), 394 lon, 395 lat, 396 velocity: 50.0, 397 heading: "N".into(), 398 state, 399 status_msg: String::new(), 400 stations: vec![], 401 } 402 } 403 404 // --- Viewport --- 405 406 #[test] 407 fn test_viewport_conus_defaults() { 408 let vp = Viewport::conus(); 409 assert!(vp.width() > 60.0); // US is about 70 degrees wide 410 assert!(vp.height() > 25.0); 411 let (cx, cy) = vp.center(); 412 assert!(cx < -80.0 && cx > -110.0); // roughly center of US 413 assert!(cy > 30.0 && cy < 45.0); 414 } 415 416 #[test] 417 fn test_viewport_zoom_in_shrinks_bounds() { 418 let mut vp = Viewport::conus(); 419 let old_w = vp.width(); 420 let old_h = vp.height(); 421 let old_center = vp.center(); 422 vp.zoom_in(); 423 assert!(vp.width() < old_w); 424 assert!(vp.height() < old_h); 425 // Center should stay the same 426 let new_center = vp.center(); 427 assert!((new_center.0 - old_center.0).abs() < 0.001); 428 assert!((new_center.1 - old_center.1).abs() < 0.001); 429 } 430 431 #[test] 432 fn test_viewport_zoom_out_expands_bounds() { 433 let mut vp = Viewport::conus(); 434 let old_w = vp.width(); 435 vp.zoom_out(); 436 assert!(vp.width() > old_w); 437 } 438 439 #[test] 440 fn test_viewport_pan() { 441 let mut vp = Viewport::conus(); 442 let old_center = vp.center(); 443 vp.pan(0.1, 0.0); // pan right 10% 444 let new_center = vp.center(); 445 assert!(new_center.0 > old_center.0); 446 assert!((new_center.1 - old_center.1).abs() < 0.001); 447 } 448 449 #[test] 450 fn test_viewport_contains() { 451 let vp = Viewport::conus(); 452 // Chicago should be visible 453 assert!(vp.contains(-87.6, 41.8)); 454 // London should not 455 assert!(!vp.contains(0.0, 51.5)); 456 } 457 458 // --- App --- 459 460 #[test] 461 fn test_visible_trains_filters_by_viewport_and_state() { 462 let mut app = App::new(); 463 app.trains = vec![ 464 make_train("1", -87.6, 41.8, TrainState::Active), // Chicago - visible 465 make_train("2", 0.0, 51.5, TrainState::Active), // London - out of bounds 466 make_train("3", -90.0, 38.0, TrainState::Completed), // STL completed - filtered 467 make_train("4", -118.0, 34.0, TrainState::Active), // LA - visible 468 ]; 469 470 let visible = app.visible_trains(); 471 assert_eq!(visible.len(), 2); 472 assert_eq!(visible[0].number, "1"); 473 assert_eq!(visible[1].number, "4"); 474 } 475 476 #[test] 477 fn test_select_next_wraps() { 478 let mut app = App::new(); 479 app.trains = vec![ 480 make_train("1", -87.6, 41.8, TrainState::Active), 481 make_train("2", -90.0, 38.0, TrainState::Active), 482 ]; 483 484 assert_eq!(app.selected_index, None); 485 app.select_next(); 486 assert_eq!(app.selected_index, Some(0)); 487 app.select_next(); 488 assert_eq!(app.selected_index, Some(1)); 489 app.select_next(); 490 assert_eq!(app.selected_index, Some(0)); // wraps 491 } 492 493 #[test] 494 fn test_select_prev_wraps() { 495 let mut app = App::new(); 496 app.trains = vec![ 497 make_train("1", -87.6, 41.8, TrainState::Active), 498 make_train("2", -90.0, 38.0, TrainState::Active), 499 ]; 500 501 app.select_prev(); 502 assert_eq!(app.selected_index, Some(1)); // starts from end 503 app.select_prev(); 504 assert_eq!(app.selected_index, Some(0)); 505 app.select_prev(); 506 assert_eq!(app.selected_index, Some(1)); // wraps 507 } 508 509 #[test] 510 fn test_train_counts() { 511 let mut app = App::new(); 512 app.trains = vec![ 513 make_train("1", -87.6, 41.8, TrainState::Active), 514 make_train("2", -90.0, 38.0, TrainState::Active), 515 make_train("3", -118.0, 34.0, TrainState::Completed), 516 ]; 517 assert_eq!(app.active_train_count(), 2); 518 } 519 520 // --- Jump to train --- 521 522 #[test] 523 fn test_jump_to_train_centers_viewport() { 524 let mut app = App::new(); 525 app.trains = vec![ 526 make_train("785", -118.6, 34.2, TrainState::Active), // LA area 527 make_train("316", -90.2, 38.6, TrainState::Active), // St Louis 528 ]; 529 app.selected_index = Some(0); 530 531 app.jump_to_selected(); 532 533 let (cx, cy) = app.viewport.center(); 534 assert!( 535 (cx - (-118.6)).abs() < 0.1, 536 "center x should be near -118.6, got {}", 537 cx 538 ); 539 assert!( 540 (cy - 34.2).abs() < 0.1, 541 "center y should be near 34.2, got {}", 542 cy 543 ); 544 // Should have zoomed in (smaller than CONUS default of 70 degrees) 545 assert!( 546 app.viewport.width() < 30.0, 547 "should be zoomed in, width={}", 548 app.viewport.width() 549 ); 550 } 551 552 #[test] 553 fn test_jump_to_train_no_selection_is_noop() { 554 let mut app = App::new(); 555 app.trains = vec![make_train("1", -87.6, 41.8, TrainState::Active)]; 556 let old_center = app.viewport.center(); 557 app.jump_to_selected(); // no selection 558 assert_eq!(app.viewport.center(), old_center); 559 } 560 561 // --- Input mode --- 562 563 #[test] 564 fn test_input_mode_starts_normal() { 565 let app = App::new(); 566 assert_eq!(app.input_mode, InputMode::Normal); 567 } 568 569 #[test] 570 fn test_enter_search_mode() { 571 let mut app = App::new(); 572 app.enter_search(); 573 assert_eq!(app.input_mode, InputMode::Search); 574 assert_eq!(app.search_query, ""); 575 } 576 577 #[test] 578 fn test_exit_search_clears_query() { 579 let mut app = App::new(); 580 app.enter_search(); 581 app.search_query = "acela".into(); 582 app.exit_search(); 583 assert_eq!(app.input_mode, InputMode::Normal); 584 assert_eq!(app.search_query, ""); 585 } 586 587 // --- Search filtering --- 588 589 #[test] 590 fn test_search_filters_by_train_number() { 591 let mut app = App::new(); 592 app.trains = vec![ 593 make_train("785", -118.6, 34.2, TrainState::Active), 594 make_train("316", -90.2, 38.6, TrainState::Active), 595 make_train("2121", -87.6, 41.8, TrainState::Active), 596 ]; 597 app.search_query = "78".into(); 598 599 let visible = app.visible_trains(); 600 assert_eq!(visible.len(), 1); 601 assert_eq!(visible[0].number, "785"); 602 } 603 604 #[test] 605 fn test_search_filters_by_route_name_case_insensitive() { 606 let mut app = App::new(); 607 app.trains = vec![ 608 make_train_named("785", "Pacific Surfliner", -118.6, 34.2, TrainState::Active), 609 make_train_named( 610 "316", 611 "Missouri River Runner", 612 -90.2, 613 38.6, 614 TrainState::Active, 615 ), 616 make_train_named("171", "Acela", -73.9, 40.7, TrainState::Active), 617 ]; 618 app.search_query = "acela".into(); 619 620 let visible = app.visible_trains(); 621 assert_eq!(visible.len(), 1); 622 assert_eq!(visible[0].number, "171"); 623 } 624 625 #[test] 626 fn test_search_filters_by_origin_dest() { 627 let mut app = App::new(); 628 app.trains = vec![ 629 make_train("1", -87.6, 41.8, TrainState::Active), 630 make_train("2", -90.0, 38.0, TrainState::Active), 631 ]; 632 // Both have origin "A", destination "B" 633 app.search_query = "A".into(); 634 assert_eq!(app.visible_trains().len(), 2); 635 636 app.search_query = "zzz".into(); 637 assert_eq!(app.visible_trains().len(), 0); 638 } 639 640 #[test] 641 fn test_empty_search_shows_all() { 642 let mut app = App::new(); 643 app.trains = vec![ 644 make_train("1", -87.6, 41.8, TrainState::Active), 645 make_train("2", -90.0, 38.0, TrainState::Active), 646 ]; 647 app.search_query = "".into(); 648 assert_eq!(app.visible_trains().len(), 2); 649 } 650 651 #[test] 652 fn test_selection_resets_when_search_changes() { 653 let mut app = App::new(); 654 app.trains = vec![ 655 make_train("785", -118.6, 34.2, TrainState::Active), 656 make_train("316", -90.2, 38.6, TrainState::Active), 657 ]; 658 app.selected_index = Some(1); 659 app.search_push('7'); // type a character 660 assert_eq!( 661 app.selected_index, None, 662 "selection should reset on search change" 663 ); 664 } 665 666 // --- Animation tick and brightness --- 667 668 #[test] 669 fn test_tick_increments() { 670 let mut app = App::new(); 671 assert_eq!(app.animation_tick, 0); 672 app.tick(); 673 assert_eq!(app.animation_tick, 1); 674 app.tick(); 675 assert_eq!(app.animation_tick, 2); 676 } 677 678 #[test] 679 fn test_is_train_bright_stopped() { 680 let app = App::new(); 681 // velocity == 0 is always dim 682 assert!(!app.is_train_bright(0.0)); 683 } 684 685 #[test] 686 fn test_is_train_bright_slow() { 687 let mut app = App::new(); 688 // velocity < 20: bright every 8 ticks 689 assert!(app.is_train_bright(10.0)); // tick 0 690 app.tick(); // tick 1 691 assert!(!app.is_train_bright(10.0)); 692 for _ in 0..7 { 693 app.tick(); 694 } 695 // tick 8 696 assert!(app.is_train_bright(10.0)); 697 } 698 699 #[test] 700 fn test_is_train_bright_medium() { 701 let mut app = App::new(); 702 // velocity < 60: bright every 4 ticks 703 assert!(app.is_train_bright(40.0)); // tick 0 704 app.tick(); 705 assert!(!app.is_train_bright(40.0)); // tick 1 706 app.tick(); 707 app.tick(); 708 app.tick(); 709 // tick 4 710 assert!(app.is_train_bright(40.0)); 711 } 712 713 #[test] 714 fn test_is_train_bright_fast() { 715 let mut app = App::new(); 716 // velocity >= 60: bright every 2 ticks 717 assert!(app.is_train_bright(80.0)); // tick 0 718 app.tick(); 719 assert!(!app.is_train_bright(80.0)); // tick 1 720 app.tick(); 721 assert!(app.is_train_bright(80.0)); // tick 2 722 } 723 724 // --- Station methods --- 725 726 #[test] 727 fn test_visible_stations_filters_by_viewport_and_zoom() { 728 let mut app = App::new(); 729 // Default CONUS viewport width is 70, so zoom_detail_level = 4 730 app.stations = vec![ 731 Station { 732 code: "CHI".into(), 733 name: "Chicago".into(), 734 city: "Chicago".into(), 735 state: "IL".into(), 736 lon: -87.6, 737 lat: 41.8, 738 station_type: "Station".into(), 739 zoom_level: 4, 740 }, 741 Station { 742 code: "ABE".into(), 743 name: "Aberdeen".into(), 744 city: "Aberdeen".into(), 745 state: "MD".into(), 746 lon: -76.2, 747 lat: 39.5, 748 station_type: "Station".into(), 749 zoom_level: 5, // too detailed for level 4 750 }, 751 Station { 752 code: "LDN".into(), 753 name: "London".into(), 754 city: "London".into(), 755 state: "UK".into(), 756 lon: 0.0, 757 lat: 51.5, 758 station_type: "Station".into(), 759 zoom_level: 4, // out of viewport 760 }, 761 ]; 762 763 let visible = app.visible_stations(); 764 assert_eq!(visible.len(), 1); 765 assert_eq!(visible[0].code, "CHI"); 766 } 767 768 #[test] 769 fn test_trains_at_station() { 770 let mut app = App::new(); 771 app.trains = vec![ 772 Train { 773 number: "1".into(), 774 route_name: "Test".into(), 775 origin: "CHI".into(), 776 destination: "NYP".into(), 777 lon: -87.6, 778 lat: 41.8, 779 velocity: 50.0, 780 heading: "E".into(), 781 state: TrainState::Active, 782 status_msg: String::new(), 783 stations: vec![ 784 crate::model::StationStop { 785 code: "CHI".into(), 786 comment: None, 787 }, 788 crate::model::StationStop { 789 code: "TOL".into(), 790 comment: None, 791 }, 792 ], 793 }, 794 Train { 795 number: "2".into(), 796 route_name: "Test2".into(), 797 origin: "NYP".into(), 798 destination: "BOS".into(), 799 lon: -73.9, 800 lat: 40.7, 801 velocity: 60.0, 802 heading: "NE".into(), 803 state: TrainState::Active, 804 status_msg: String::new(), 805 stations: vec![], 806 }, 807 ]; 808 809 let at_chi = app.trains_at_station("CHI"); 810 assert_eq!(at_chi.len(), 1); 811 assert_eq!(at_chi[0].number, "1"); 812 813 let at_nyp = app.trains_at_station("NYP"); 814 assert_eq!(at_nyp.len(), 2); // train 1 destination + train 2 origin 815 816 let at_tol = app.trains_at_station("TOL"); 817 assert_eq!(at_tol.len(), 1); // train 1 has TOL in stations list 818 } 819 820 #[test] 821 fn test_toggle_station_mode() { 822 let mut app = App::new(); 823 app.stations = vec![Station { 824 code: "CHI".into(), 825 name: "Chicago".into(), 826 city: "Chicago".into(), 827 state: "IL".into(), 828 lon: -87.6, 829 lat: 41.8, 830 station_type: "Station".into(), 831 zoom_level: 4, 832 }]; 833 834 assert!(!app.station_mode); 835 app.toggle_station_mode(); 836 assert!(app.station_mode); 837 assert_eq!(app.selected_station, Some(0)); 838 app.toggle_station_mode(); 839 assert!(!app.station_mode); 840 assert_eq!(app.selected_station, None); 841 } 842 843 #[test] 844 fn test_zoom_detail_level() { 845 let mut app = App::new(); 846 // Default CONUS = 70 degrees wide 847 assert_eq!(app.zoom_detail_level(), 4); 848 849 app.viewport.x_min = -100.0; 850 app.viewport.x_max = -60.0; // width = 40 851 assert_eq!(app.zoom_detail_level(), 5); 852 853 app.viewport.x_min = -90.0; 854 app.viewport.x_max = -70.0; // width = 20 855 assert_eq!(app.zoom_detail_level(), 6); 856 857 app.viewport.x_min = -90.0; 858 app.viewport.x_max = -80.0; // width = 10 859 assert_eq!(app.zoom_detail_level(), 7); 860 } 861 862 #[test] 863 fn test_search_backspace() { 864 let mut app = App::new(); 865 app.search_query = "ace".into(); 866 app.search_pop(); 867 assert_eq!(app.search_query, "ac"); 868 app.search_pop(); 869 app.search_pop(); 870 assert_eq!(app.search_query, ""); 871 app.search_pop(); // should not panic on empty 872 assert_eq!(app.search_query, ""); 873 } 874 875 // --- Layer visibility --- 876 877 #[test] 878 fn test_layer_visibility_defaults_all_true() { 879 let layers = LayerVisibility::default(); 880 assert!(layers.coastlines); 881 assert!(layers.land); 882 assert!(layers.lakes); 883 assert!(layers.urban); 884 assert!(layers.rivers); 885 assert!(layers.states); 886 assert!(layers.roads); 887 assert!(layers.cities); 888 assert!(layers.routes); 889 assert!(layers.stations); 890 assert!(layers.trains); 891 } 892 893 #[test] 894 fn test_toggle_layer() { 895 let mut app = App::new(); 896 assert!(app.layers.lakes); 897 app.toggle_layer("lakes"); 898 assert!(!app.layers.lakes); 899 app.toggle_layer("lakes"); 900 assert!(app.layers.lakes); 901 } 902 903 #[test] 904 fn test_toggle_all_layers() { 905 let mut app = App::new(); 906 let layer_names = [ 907 "coastlines", 908 "land", 909 "lakes", 910 "urban", 911 "rivers", 912 "states", 913 "roads", 914 "cities", 915 "routes", 916 "stations", 917 "trains", 918 ]; 919 for name in &layer_names { 920 app.toggle_layer(name); 921 } 922 assert!(!app.layers.coastlines); 923 assert!(!app.layers.land); 924 assert!(!app.layers.lakes); 925 assert!(!app.layers.urban); 926 assert!(!app.layers.rivers); 927 assert!(!app.layers.states); 928 assert!(!app.layers.roads); 929 assert!(!app.layers.cities); 930 assert!(!app.layers.routes); 931 assert!(!app.layers.stations); 932 assert!(!app.layers.trains); 933 } 934 935 #[test] 936 fn test_toggle_unknown_layer_is_noop() { 937 let mut app = App::new(); 938 app.toggle_layer("nonexistent"); // should not panic 939 assert!(app.layers.coastlines); // unchanged 940 } 941 942 #[test] 943 fn test_reset_view_resets_layers() { 944 let mut app = App::new(); 945 app.toggle_layer("lakes"); 946 app.toggle_layer("roads"); 947 assert!(!app.layers.lakes); 948 assert!(!app.layers.roads); 949 app.reset_view(); 950 assert!(app.layers.lakes); 951 assert!(app.layers.roads); 952 } 953 954 #[test] 955 fn test_layers_mode() { 956 let mut app = App::new(); 957 app.input_mode = InputMode::Layers; 958 assert_eq!(app.input_mode, InputMode::Layers); 959 } 960}