a rust tui to view amtrak train status
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}