1use std::cell::RefCell;
99use std::collections::{HashMap, HashSet};
100use std::rc::Rc;
101
102use frust_core::{AnyView, any};
103
104use super::navigator::{NavigatorController, NavigatorId, PushOptions, ReplaceOptions};
105use super::path::{Location, PathPattern, RouteParams, encode_segment};
106use super::route::{NavRequest, RouteNavigator};
107
108pub type RouteBuilder<State> = Rc<dyn Fn(&RouteParams) -> AnyView<State>>;
112
113pub type Redirect = Rc<dyn Fn(&Location) -> Option<String>>;
117
118pub type ErrorBuilder<State> = Rc<dyn Fn(&Location) -> AnyView<State>>;
120
121pub const DEFAULT_REDIRECT_LIMIT: usize = 5;
124
125pub struct Route<State: 'static> {
133 path: String,
134 name: Option<String>,
135 builder: RouteBuilder<State>,
136 redirect: Option<Redirect>,
137 children: Vec<Route<State>>,
138 shell: Option<NavigatorController<State>>,
143}
144
145impl<State: 'static> Route<State> {
146 pub fn new(
149 path: impl Into<String>,
150 builder: impl Fn(&RouteParams) -> AnyView<State> + 'static,
151 ) -> Self {
152 Route {
153 path: path.into(),
154 name: None,
155 builder: Rc::new(builder),
156 redirect: None,
157 children: Vec::new(),
158 shell: None,
159 }
160 }
161
162 pub fn name(mut self, name: impl Into<String>) -> Self {
165 self.name = Some(name.into());
166 self
167 }
168
169 pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
172 self.redirect = Some(Rc::new(redirect));
173 self
174 }
175
176 pub fn child(mut self, child: Route<State>) -> Self {
178 self.children.push(child);
179 self
180 }
181
182 pub fn children(mut self, children: Vec<Route<State>>) -> Self {
184 self.children = children;
185 self
186 }
187}
188
189pub fn shell_route<State: 'static>(
276 inner: &NavigatorController<State>,
277 builder: impl Fn(&RouteParams) -> AnyView<State> + 'static,
278 children: Vec<Route<State>>,
279) -> Route<State> {
280 Route {
281 path: String::new(),
282 name: None,
283 builder: Rc::new(builder),
284 redirect: None,
285 children,
286 shell: Some(inner.clone()),
287 }
288}
289
290pub struct ResolvedPage<State: 'static> {
296 builder: RouteBuilder<State>,
297 params: RouteParams,
298 location: Location,
299 shell: Option<NavigatorController<State>>,
304}
305
306impl<State: 'static> ResolvedPage<State> {
307 pub fn build(&self) -> AnyView<State> {
309 (self.builder)(&self.params)
310 }
311
312 pub fn params(&self) -> &RouteParams {
314 &self.params
315 }
316
317 pub fn location(&self) -> &Location {
321 &self.location
322 }
323
324 fn into_page_builder(self) -> impl Fn() -> AnyView<State> + 'static {
327 let ResolvedPage {
328 builder, params, ..
329 } = self;
330 move || (builder)(¶ms)
331 }
332}
333
334pub enum Resolution<State: 'static> {
337 Matched {
342 location: Location,
343 params: RouteParams,
344 pages: Vec<ResolvedPage<State>>,
345 },
346 Error { location: Location },
349}
350
351impl<State: 'static> Resolution<State> {
352 pub fn is_matched(&self) -> bool {
354 matches!(self, Resolution::Matched { .. })
355 }
356
357 pub fn params(&self) -> RouteParams {
360 match self {
361 Resolution::Matched { params, .. } => params.clone(),
362 Resolution::Error { .. } => RouteParams::new(),
363 }
364 }
365
366 pub fn page_count(&self) -> usize {
368 match self {
369 Resolution::Matched { pages, .. } => pages.len(),
370 Resolution::Error { .. } => 0,
371 }
372 }
373}
374
375#[derive(Clone, Copy, PartialEq, Eq, Debug)]
379enum Verb {
380 Go,
381 Push,
382 Replace,
383}
384
385struct Segment<State: 'static> {
389 controller: NavigatorController<State>,
390 pages: Vec<ResolvedPage<State>>,
391}
392
393pub struct Router<State: 'static> {
396 routes: Vec<Route<State>>,
397 controller: NavigatorController<State>,
398 redirect: Option<Redirect>,
399 error_builder: ErrorBuilder<State>,
400 redirect_limit: usize,
401 route_nav: RouteNavigator,
405 pending_shells: RefCell<HashMap<NavigatorId, u64>>,
420}
421
422impl<State: 'static> Router<State> {
423 pub fn new(routes: Vec<Route<State>>) -> Self {
426 Self::with_controller(&NavigatorController::new(), routes)
427 }
428
429 pub fn with_controller(
432 controller: &NavigatorController<State>,
433 routes: Vec<Route<State>>,
434 ) -> Self {
435 Router {
436 routes,
437 controller: controller.clone(),
438 redirect: None,
439 error_builder: Rc::new(default_error_page),
440 redirect_limit: DEFAULT_REDIRECT_LIMIT,
441 route_nav: RouteNavigator::new(),
442 pending_shells: RefCell::new(HashMap::new()),
443 }
444 }
445
446 pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
448 self.redirect = Some(Rc::new(redirect));
449 self
450 }
451
452 pub fn error_builder(
454 mut self,
455 error_builder: impl Fn(&Location) -> AnyView<State> + 'static,
456 ) -> Self {
457 self.error_builder = Rc::new(error_builder);
458 self
459 }
460
461 pub fn redirect_limit(mut self, limit: usize) -> Self {
463 self.redirect_limit = limit;
464 self
465 }
466
467 pub fn controller(&self) -> &NavigatorController<State> {
471 &self.controller
472 }
473
474 pub fn resolve(&self, raw: &str) -> Resolution<State> {
479 let mut loc = Location::parse(raw);
480 let mut redirects = 0usize;
481
482 loop {
483 if let Some(next) = self.fire_redirect(self.redirect.as_ref(), &loc) {
485 redirects += 1;
486 if redirects > self.redirect_limit {
487 return Resolution::Error { location: loc };
488 }
489 loc = next;
490 continue;
491 }
492
493 let Some((chain, params)) = self.match_chain(&loc) else {
495 return Resolution::Error { location: loc };
496 };
497
498 let mut redirected = None;
500 for route in &chain {
501 if let Some(next) = self.fire_redirect(route.redirect.as_ref(), &loc) {
502 redirected = Some(next);
503 break;
504 }
505 }
506 if let Some(next) = redirected {
507 redirects += 1;
508 if redirects > self.redirect_limit {
509 return Resolution::Error { location: loc };
510 }
511 loc = next;
512 continue;
513 }
514
515 let mut merged = loc.query.clone();
522 merged.extend(params);
523 let pages = chain
524 .iter()
525 .map(|route| ResolvedPage {
526 builder: route.builder.clone(),
527 params: merged.clone(),
528 location: loc.clone(),
529 shell: route.shell.clone(),
530 })
531 .collect();
532 return Resolution::Matched {
533 location: loc,
534 params: merged,
535 pages,
536 };
537 }
538 }
539
540 fn fire_redirect(&self, redirect: Option<&Redirect>, loc: &Location) -> Option<Location> {
544 let redirect = redirect?;
545 let next = redirect(loc)?;
546 let next_loc = Location::parse(&next);
547 if next_loc.location_string() == loc.location_string() {
548 None
549 } else {
550 Some(next_loc)
551 }
552 }
553
554 fn match_chain<'a>(&'a self, loc: &Location) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
557 match_routes(&self.routes, &loc.segments, RouteParams::new(), Vec::new())
558 }
559
560 pub fn route_navigator(&self) -> RouteNavigator {
568 self.route_nav.clone()
569 }
570
571 pub fn pump(&self) {
578 for request in self.route_nav.drain() {
579 match request {
580 NavRequest::Go(location) => self.go(&location),
581 NavRequest::Push(location) => self.push(&location),
582 NavRequest::Replace(location) => self.replace(&location),
583 NavRequest::Pop => self.pop(),
584 NavRequest::GoNamed { name, params } => self.go_named(&name, ¶ms),
585 NavRequest::PushNamed { name, params } => self.push_named(&name, ¶ms),
586 }
587 }
588 }
589
590 pub fn go(&self, location: &str) {
601 self.apply(self.resolved(location), Verb::Go);
602 }
603
604 pub fn push(&self, location: &str) {
610 self.apply(self.resolved(location), Verb::Push);
611 }
612
613 pub fn replace(&self, location: &str) {
621 self.apply(self.resolved(location), Verb::Replace);
622 }
623
624 pub fn pop(&self) {
632 self.controller.pop();
633 }
634
635 fn apply(&self, resolution: Resolution<State>, verb: Verb) {
642 match resolution {
643 Resolution::Matched { pages, .. } => {
644 let mut segments = self.split_chain(pages);
645 if segments.len() == 1 {
646 let segment = segments.pop().expect("a chain has at least one segment");
647 Self::apply_flat(&segment.controller, segment.pages, verb);
648 } else {
649 self.apply_shell_chain(segments, verb);
650 }
651 }
652 Resolution::Error { location } => match verb {
653 Verb::Push => self.controller.push(self.error_page_builder(location)),
654 Verb::Go | Verb::Replace => {
655 self.controller.replace(self.error_page_builder(location))
656 }
657 },
658 }
659 }
660
661 fn split_chain(&self, pages: Vec<ResolvedPage<State>>) -> Vec<Segment<State>> {
671 let mut segments = vec![Segment {
672 controller: self.controller.clone(),
673 pages: Vec::new(),
674 }];
675 for page in pages {
676 let inner = page.shell.clone();
677 segments
678 .last_mut()
679 .expect("segments is seeded with the outer segment")
680 .pages
681 .push(page);
682 if let Some(inner) = inner {
683 segments.push(Segment {
684 controller: inner,
685 pages: Vec::new(),
686 });
687 }
688 }
689 segments
690 }
691
692 fn apply_shell_chain(&self, segments: Vec<Segment<State>>, verb: Verb) {
695 let shells: Vec<NavigatorId> = segments
698 .iter()
699 .skip(1)
700 .map(|segment| segment.controller.id())
701 .collect();
702 let last = segments.len() - 1;
703 let mut enclosing_kept = true;
709
710 for (index, segment) in segments.into_iter().enumerate() {
711 let effective = if enclosing_kept { verb } else { Verb::Go };
712 if index == last {
713 Self::apply_flat(&segment.controller, segment.pages, effective);
714 continue;
715 }
716
717 let shell = shells[index];
718 if enclosing_kept && self.shell_is_placed(&segment.controller, index, shell) {
719 continue;
720 }
721 enclosing_kept = false;
722 self.pending_shells
723 .borrow_mut()
724 .insert(shell, segment.controller.route_generation());
725 Self::place_segment(&segment.controller, segment.pages, effective);
726 }
727 }
728
729 fn shell_is_placed(
750 &self,
751 target: &NavigatorController<State>,
752 boundary: usize,
753 shell: NavigatorId,
754 ) -> bool {
755 let stack = target.route_stack();
756 let committed = stack
757 .entries()
758 .iter()
759 .flatten()
760 .any(|location| self.shell_at(location, boundary) == Some(shell));
761 if committed {
762 return true;
763 }
764 self.pending_shells.borrow().get(&shell) == Some(&stack.generation())
767 }
768
769 fn shell_at(&self, location: &Location, boundary: usize) -> Option<NavigatorId> {
772 let (chain, _) = self.match_chain(location)?;
773 chain
774 .iter()
775 .filter_map(|route| route.shell.as_ref())
776 .nth(boundary)
777 .map(|controller| controller.id())
778 }
779
780 fn apply_flat(
784 controller: &NavigatorController<State>,
785 pages: Vec<ResolvedPage<State>>,
786 verb: Verb,
787 ) {
788 match verb {
789 Verb::Go => {
790 let mut pages = pages.into_iter();
791 if let Some(first) = pages.next() {
792 let route = first.location().clone();
793 controller.replace_with_options(
794 first.into_page_builder(),
795 ReplaceOptions::opaque().route(route),
796 );
797 }
798 for page in pages {
799 let route = page.location().clone();
800 controller.push_with_options(
801 page.into_page_builder(),
802 PushOptions::opaque().route(route),
803 );
804 }
805 }
806 Verb::Push => {
807 if let Some(leaf) = pages.into_iter().next_back() {
808 let route = leaf.location().clone();
809 controller.push_with_options(
810 leaf.into_page_builder(),
811 PushOptions::opaque().route(route),
812 );
813 }
814 }
815 Verb::Replace => {
816 if let Some(leaf) = pages.into_iter().next_back() {
817 let route = leaf.location().clone();
818 controller.replace_with_options(
819 leaf.into_page_builder(),
820 ReplaceOptions::opaque().route(route),
821 );
822 }
823 }
824 }
825 }
826
827 fn place_segment(
832 controller: &NavigatorController<State>,
833 pages: Vec<ResolvedPage<State>>,
834 verb: Verb,
835 ) {
836 match verb {
837 Verb::Push => {
838 for page in pages {
839 let route = page.location().clone();
840 controller.push_with_options(
841 page.into_page_builder(),
842 PushOptions::opaque().route(route),
843 );
844 }
845 }
846 Verb::Go | Verb::Replace => Self::apply_flat(controller, pages, Verb::Go),
847 }
848 }
849
850 pub fn handle_location(&self, location: &str) {
854 self.go(location);
855 }
856
857 pub fn go_named(&self, name: &str, params: &RouteParams) {
860 match self.path_for_name(name, params) {
861 Some(path) => self.go(&path),
862 None => {
863 let location = named_error_location(name);
864 self.route_nav.set_location(location.clone());
865 self.controller.replace(self.error_page_builder(location));
866 }
867 }
868 }
869
870 pub fn push_named(&self, name: &str, params: &RouteParams) {
873 match self.path_for_name(name, params) {
874 Some(path) => self.push(&path),
875 None => {
876 let location = named_error_location(name);
877 self.route_nav.set_location(location.clone());
878 self.controller.push(self.error_page_builder(location));
879 }
880 }
881 }
882
883 pub fn path_for_name(&self, name: &str, params: &RouteParams) -> Option<String> {
888 let pattern = find_named_pattern(&self.routes, name, "")?;
889 Some(substitute_pattern(&pattern, params))
890 }
891
892 fn resolved(&self, raw: &str) -> Resolution<State> {
897 let resolution = self.resolve(raw);
898 let location = match &resolution {
899 Resolution::Matched { location, .. } | Resolution::Error { location } => location,
900 };
901 self.route_nav.set_location(location.clone());
902 resolution
903 }
904
905 fn error_page_builder(&self, location: Location) -> impl Fn() -> AnyView<State> + 'static {
907 let error_builder = self.error_builder.clone();
908 move || (error_builder)(&location)
909 }
910}
911
912fn match_routes<'a, State: 'static>(
917 routes: &'a [Route<State>],
918 segments: &[String],
919 acc_params: RouteParams,
920 chain: Vec<&'a Route<State>>,
921) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
922 for route in routes {
923 let pattern = PathPattern::parse(&route.path);
924 let Some((consumed, params)) = pattern.match_prefix(segments) else {
925 continue;
926 };
927
928 let mut next_params = acc_params.clone();
929 next_params.extend(params);
930
931 let mut next_chain = chain.clone();
932 next_chain.push(route);
933
934 let remaining = &segments[consumed..];
935 if remaining.is_empty() {
936 return Some((next_chain, next_params));
937 }
938 if let Some(found) = match_routes(&route.children, remaining, next_params, next_chain) {
940 return Some(found);
941 }
942 }
945 None
946}
947
948fn find_named_pattern<State: 'static>(
951 routes: &[Route<State>],
952 name: &str,
953 prefix: &str,
954) -> Option<String> {
955 for route in routes {
956 let full = join_paths(prefix, &route.path);
957 if route.name.as_deref() == Some(name) {
958 return Some(full);
959 }
960 if let Some(found) = find_named_pattern(&route.children, name, &full) {
961 return Some(found);
962 }
963 }
964 None
965}
966
967fn join_paths(prefix: &str, path: &str) -> String {
969 let a = prefix.trim_matches('/');
970 let b = path.trim_matches('/');
971 match (a.is_empty(), b.is_empty()) {
972 (true, true) => String::new(),
973 (true, false) => b.to_string(),
974 (false, true) => a.to_string(),
975 (false, false) => format!("{a}/{b}"),
976 }
977}
978
979fn substitute_pattern(pattern: &str, params: &RouteParams) -> String {
982 let mut used: HashSet<&str> = HashSet::new();
983 let mut path = String::new();
984
985 for seg in pattern.split('/').filter(|s| !s.is_empty()) {
986 path.push('/');
987 if let Some(name) = seg.strip_prefix(':') {
988 match params.get(name) {
989 Some(value) => {
990 path.push_str(&encode_segment(value));
991 used.insert(name);
992 }
993 None => path.push_str(seg),
997 }
998 } else {
999 path.push_str(seg);
1000 }
1001 }
1002 if path.is_empty() {
1003 path.push('/');
1004 }
1005
1006 let extras: Vec<String> = params
1007 .iter()
1008 .filter(|(k, _)| !used.contains(k.as_str()))
1009 .map(|(k, v)| format!("{}={}", encode_segment(k), encode_segment(v)))
1010 .collect();
1011 if extras.is_empty() {
1012 path
1013 } else {
1014 format!("{}?{}", path, extras.join("&"))
1015 }
1016}
1017
1018fn named_error_location(name: &str) -> Location {
1020 Location::parse(&format!("/{name}"))
1021}
1022
1023fn default_error_page<State: 'static>(location: &Location) -> AnyView<State> {
1025 any(crate::text(format!("Page not found: {}", location.path)))
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use crate::test_support::RecordingScene;
1032 use frust_core::{
1033 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, FrameTime, InputEvent,
1034 LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, RenderRoot,
1035 View, Widget,
1036 };
1037 use kurbo::{Point, Size};
1038 use std::cell::{Cell, RefCell};
1039 use std::rc::Rc;
1040 use std::sync::Arc;
1041 use std::sync::atomic::{AtomicUsize, Ordering};
1042
1043 use super::super::navigator::{NavigatorView, navigator};
1044 use super::super::route_state::NavChange;
1045
1046 fn params(pairs: &[(&str, &str)]) -> RouteParams {
1047 pairs
1048 .iter()
1049 .map(|(k, v)| (k.to_string(), v.to_string()))
1050 .collect()
1051 }
1052
1053 struct SizedLeaf {
1056 size: Size,
1057 }
1058 struct SizedLeafWidget {
1059 size: Size,
1060 }
1061 impl<S: 'static> View<S> for SizedLeaf {
1062 type Element = SizedLeafWidget;
1063 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SizedLeafWidget {
1064 SizedLeafWidget { size: self.size }
1065 }
1066 fn rebuild(
1067 &self,
1068 _prev: &Self,
1069 _element: &mut SizedLeafWidget,
1070 _ctx: &mut BuildCtx<'_>,
1071 ) -> ChangeFlags {
1072 ChangeFlags::NONE
1073 }
1074 }
1075 impl Widget for SizedLeafWidget {
1076 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1077 bc.constrain(self.size)
1078 }
1079 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1080 scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
1081 }
1082 }
1083 fn sized<S: 'static>(w: f64, h: f64) -> AnyView<S> {
1084 any(SizedLeaf {
1085 size: Size::new(w, h),
1086 })
1087 }
1088
1089 struct CounterView {
1092 observed: Rc<Cell<u32>>,
1093 }
1094 struct CounterWidget {
1095 count: u32,
1096 observed: Rc<Cell<u32>>,
1097 }
1098 impl View<()> for CounterView {
1099 type Element = CounterWidget;
1100 fn build(&self, _ctx: &mut BuildCtx<'_>) -> CounterWidget {
1101 CounterWidget {
1102 count: 0,
1103 observed: self.observed.clone(),
1104 }
1105 }
1106 fn rebuild(
1107 &self,
1108 _prev: &Self,
1109 element: &mut CounterWidget,
1110 _ctx: &mut BuildCtx<'_>,
1111 ) -> ChangeFlags {
1112 element.observed = self.observed.clone();
1113 ChangeFlags::NONE
1114 }
1115 }
1116 impl Widget for CounterWidget {
1117 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1118 bc.max()
1119 }
1120 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
1121 self.observed.set(self.count);
1122 }
1123 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1124 if let InputEvent::Pointer(p) = event
1125 && p.phase == PointerPhase::Down
1126 {
1127 self.count += 1;
1128 ctx.request_redraw();
1129 return EventResult::Handled;
1130 }
1131 EventResult::Ignored
1132 }
1133 }
1134 fn counter(observed: &Rc<Cell<u32>>) -> AnyView<()> {
1135 any(CounterView {
1136 observed: observed.clone(),
1137 })
1138 }
1139
1140 fn down(x: f64, y: f64) -> InputEvent {
1141 InputEvent::Pointer(PointerEvent {
1142 phase: PointerPhase::Down,
1143 position: Point::new(x, y),
1144 button: PointerButton::Primary,
1145 })
1146 }
1147
1148 #[test]
1151 fn matches_static_route() {
1152 let router: Router<()> = Router::new(vec![Route::new("/settings", |_| sized(10.0, 10.0))]);
1153 let res = router.resolve("/settings");
1154 assert!(res.is_matched());
1155 assert_eq!(res.page_count(), 1);
1156 }
1157
1158 #[test]
1159 fn extracts_param() {
1160 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1161 let res = router.resolve("/users/42");
1162 assert!(res.is_matched());
1163 assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
1164 }
1165
1166 fn builder_params(resolution: &Resolution<()>) -> RouteParams {
1170 let Resolution::Matched { pages, .. } = resolution else {
1171 panic!("expected match");
1172 };
1173 let leaf = pages.last().expect("a matched chain has a leaf");
1174 let _view = leaf.build();
1175 leaf.params().clone()
1176 }
1177
1178 #[test]
1179 fn query_reaches_the_page_builder() {
1180 let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
1182 let recorder = seen.clone();
1183 let router: Router<()> = Router::new(vec![Route::new("/terminal", move |p| {
1184 *recorder.borrow_mut() = p.clone();
1185 sized(10.0, 10.0)
1186 })]);
1187
1188 let resolution = router.resolve("/terminal?session=abc");
1189 assert_eq!(
1190 builder_params(&resolution)
1191 .get("session")
1192 .map(String::as_str),
1193 Some("abc")
1194 );
1195 assert_eq!(
1196 seen.borrow().get("session").map(String::as_str),
1197 Some("abc"),
1198 "the builder itself receives the query parameter"
1199 );
1200 }
1201
1202 #[test]
1203 fn path_capture_beats_a_colliding_query_key() {
1204 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1206 let resolution = router.resolve("/users/42?id=99");
1207 assert_eq!(
1208 builder_params(&resolution).get("id").map(String::as_str),
1209 Some("42")
1210 );
1211 assert_eq!(
1212 resolution.params().get("id").map(String::as_str),
1213 Some("42")
1214 );
1215 }
1216
1217 #[test]
1218 fn named_extra_params_round_trip_through_resolve() {
1219 let router: Router<()> = Router::new(vec![
1222 Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1223 ]);
1224 let path = router
1225 .path_for_name("user", ¶ms(&[("id", "42"), ("tab", "posts")]))
1226 .expect("named route");
1227 let received = builder_params(&router.resolve(&path));
1228 assert_eq!(received.get("id").map(String::as_str), Some("42"));
1229 assert_eq!(received.get("tab").map(String::as_str), Some("posts"));
1230 }
1231
1232 #[test]
1233 fn resolved_page_exposes_its_location() {
1234 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1235 let Resolution::Matched { pages, .. } = router.resolve("/users/42?tab=posts") else {
1236 panic!("expected match");
1237 };
1238 let leaf = pages.last().expect("leaf");
1239 assert_eq!(leaf.location().path, "/users/42");
1240 assert_eq!(
1241 leaf.location().query.get("tab").map(String::as_str),
1242 Some("posts"),
1243 "the raw location keeps path and query separate"
1244 );
1245 }
1246
1247 #[test]
1248 fn parses_query_into_params_via_location() {
1249 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1252 let Resolution::Matched { location, .. } = router.resolve("/users/42?tab=posts") else {
1253 panic!("expected match");
1254 };
1255 assert_eq!(location.query.get("tab").map(String::as_str), Some("posts"));
1256 }
1257
1258 #[test]
1259 fn nested_route_composes_and_chains() {
1260 let router: Router<()> = Router::new(vec![
1261 Route::new("/users", |_| sized(10.0, 10.0))
1262 .child(Route::new(":id", |_| sized(20.0, 20.0))),
1263 ]);
1264 let res = router.resolve("/users/42");
1265 assert!(res.is_matched());
1266 assert_eq!(res.page_count(), 2);
1268 assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
1269 }
1270
1271 #[test]
1272 fn trailing_slash_still_matches() {
1273 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1274 assert!(router.resolve("/users/42/").is_matched());
1275 }
1276
1277 #[test]
1278 fn no_match_is_error() {
1279 let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
1280 let res = router.resolve("/nope");
1281 assert!(!res.is_matched());
1282 assert!(matches!(res, Resolution::Error { .. }));
1283 }
1284
1285 #[test]
1288 fn per_route_redirect_follows_chain() {
1289 let router: Router<()> = Router::new(vec![
1290 Route::new("/old", |_| sized(10.0, 10.0)).redirect(|_| Some("/new".to_string())),
1291 Route::new("/new", |_| sized(20.0, 20.0)),
1292 ]);
1293 let Resolution::Matched { location, .. } = router.resolve("/old") else {
1294 panic!("expected match after redirect");
1295 };
1296 assert_eq!(location.path, "/new");
1297 }
1298
1299 #[test]
1300 fn top_level_redirect_applies() {
1301 let router: Router<()> = Router::new(vec![
1302 Route::new("/login", |_| sized(10.0, 10.0)),
1303 Route::new("/home", |_| sized(20.0, 20.0)),
1304 ])
1305 .redirect(|loc| (loc.path == "/home").then(|| "/login".to_string()));
1306 let Resolution::Matched { location, .. } = router.resolve("/home") else {
1307 panic!("expected redirect to /login");
1308 };
1309 assert_eq!(location.path, "/login");
1310 }
1311
1312 #[test]
1313 fn redirect_loop_falls_back_to_error_at_limit() {
1314 let router: Router<()> = Router::new(vec![
1317 Route::new("/a", |_| sized(10.0, 10.0)).redirect(|_| Some("/b".to_string())),
1318 Route::new("/b", |_| sized(20.0, 20.0)).redirect(|_| Some("/a".to_string())),
1319 ]);
1320 let res = router.resolve("/a");
1321 assert!(
1322 matches!(res, Resolution::Error { .. }),
1323 "loop must error out"
1324 );
1325 }
1326
1327 #[test]
1328 fn redirect_to_same_location_is_not_a_loop() {
1329 let router: Router<()> = Router::new(vec![
1332 Route::new("/x", |_| sized(10.0, 10.0)).redirect(|_| Some("/x".to_string())),
1333 ]);
1334 assert!(router.resolve("/x").is_matched());
1335 }
1336
1337 #[test]
1340 fn named_resolves_params_into_path() {
1341 let router: Router<()> = Router::new(vec![
1342 Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1343 ]);
1344 assert_eq!(
1345 router.path_for_name("user", ¶ms(&[("id", "42")])),
1346 Some("/users/42".to_string())
1347 );
1348 }
1349
1350 #[test]
1351 fn named_composes_nested_path() {
1352 let router: Router<()> = Router::new(vec![
1353 Route::new("/users", |_| sized(10.0, 10.0))
1354 .child(Route::new(":id", |_| sized(20.0, 20.0)).name("user")),
1355 ]);
1356 assert_eq!(
1357 router.path_for_name("user", ¶ms(&[("id", "7")])),
1358 Some("/users/7".to_string())
1359 );
1360 }
1361
1362 #[test]
1363 fn named_extra_params_become_query() {
1364 let router: Router<()> = Router::new(vec![
1365 Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1366 ]);
1367 assert_eq!(
1368 router.path_for_name("user", ¶ms(&[("id", "42"), ("tab", "posts")])),
1369 Some("/users/42?tab=posts".to_string())
1370 );
1371 }
1372
1373 #[test]
1374 fn unknown_name_is_none() {
1375 let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
1376 assert!(
1377 router
1378 .path_for_name("missing", &RouteParams::new())
1379 .is_none()
1380 );
1381 }
1382
1383 fn drive_paint(root: &mut RenderRoot<(), NavigatorView<()>>) -> Vec<(Point, Size)> {
1386 root.layout(Size::new(100.0, 100.0));
1387 let mut scene = RecordingScene::default();
1388 root.paint(&mut scene, FrameTime::ZERO);
1389 scene.rects
1390 }
1391
1392 #[test]
1393 fn go_replaces_top_dropping_state() {
1394 let controller: NavigatorController<()> = NavigatorController::new();
1395 let observed = Rc::new(Cell::new(0u32));
1396 let router = {
1397 let obs = observed.clone();
1398 Router::with_controller(
1399 &controller,
1400 vec![Route::new("/home", move |_| counter(&obs))],
1401 )
1402 };
1403
1404 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1405 let mut app = {
1406 let ctrl = controller.clone();
1407 let obs = observed.clone();
1408 move |_: &mut ()| {
1409 let obs = obs.clone();
1410 navigator(&ctrl, move || counter(&obs))
1411 }
1412 };
1413 let mut state = ();
1414 root.rebuild(&mut app, &mut state);
1415 root.layout(Size::new(100.0, 100.0));
1416 root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1417
1418 root.event(&mut state, &down(5.0, 5.0));
1420 root.rebuild(&mut app, &mut state);
1421 root.layout(Size::new(100.0, 100.0));
1422 root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1423 assert_eq!(observed.get(), 1);
1424
1425 router.go("/home");
1427 root.rebuild(&mut app, &mut state);
1428 root.layout(Size::new(100.0, 100.0));
1429 root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1430 assert_eq!(observed.get(), 0, "go dropped the previous page's state");
1431 }
1432
1433 #[test]
1434 fn push_stacks_and_retains_below() {
1435 let controller: NavigatorController<()> = NavigatorController::new();
1436 let router: Router<()> = Router::with_controller(
1437 &controller,
1438 vec![
1439 Route::new("/home", |_| sized(10.0, 10.0)),
1440 Route::new("/detail", |_| sized(20.0, 20.0)),
1441 ],
1442 );
1443
1444 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1445 let mut app = {
1446 let ctrl = controller.clone();
1447 move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1448 };
1449 let mut state = ();
1450 root.rebuild(&mut app, &mut state);
1451
1452 assert_eq!(
1454 drive_paint(&mut root),
1455 vec![(Point::ZERO, Size::new(10.0, 10.0))]
1456 );
1457
1458 router.push("/detail");
1460 root.rebuild(&mut app, &mut state);
1461 assert_eq!(
1462 drive_paint(&mut root),
1463 vec![(Point::ZERO, Size::new(20.0, 20.0))]
1464 );
1465
1466 router.pop();
1468 root.rebuild(&mut app, &mut state);
1469 assert_eq!(
1470 drive_paint(&mut root),
1471 vec![(Point::ZERO, Size::new(10.0, 10.0))]
1472 );
1473 }
1474
1475 #[test]
1476 fn go_to_unmatched_shows_error_page() {
1477 let controller: NavigatorController<()> = NavigatorController::new();
1480 let router: Router<()> = Router::with_controller(
1481 &controller,
1482 vec![Route::new("/home", |_| sized(10.0, 10.0))],
1483 )
1484 .error_builder(|_| sized(99.0, 99.0));
1485
1486 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1487 let mut app = {
1488 let ctrl = controller.clone();
1489 move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1490 };
1491 let mut state = ();
1492 root.rebuild(&mut app, &mut state);
1493
1494 router.go("/does-not-exist");
1495 root.rebuild(&mut app, &mut state);
1496 assert_eq!(
1497 drive_paint(&mut root),
1498 vec![(Point::ZERO, Size::new(99.0, 99.0))],
1499 "the error page replaced the top"
1500 );
1501 }
1502
1503 #[test]
1506 fn go_push_replace_all_stamp_the_resolved_location_onto_the_route_stack() {
1507 let controller: NavigatorController<()> = NavigatorController::new();
1508 let router: Router<()> = Router::with_controller(
1509 &controller,
1510 vec![
1511 Route::new("/home", |_| sized(10.0, 10.0)),
1512 Route::new("/detail", |_| sized(20.0, 20.0)),
1513 Route::new("/other", |_| sized(30.0, 30.0)),
1514 ],
1515 );
1516
1517 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1518 let mut app = {
1519 let ctrl = controller.clone();
1520 move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1521 };
1522 let mut state = ();
1523 root.rebuild(&mut app, &mut state);
1526
1527 router.go("/home");
1528 root.rebuild(&mut app, &mut state);
1529 assert_eq!(
1530 controller.route_stack().entries(),
1531 &[Some(Location::parse("/home"))],
1532 "Router::go stamps the resolved location via ReplaceOptions::route"
1533 );
1534
1535 router.push("/detail");
1536 root.rebuild(&mut app, &mut state);
1537 assert_eq!(
1538 controller.route_stack().entries(),
1539 &[
1540 Some(Location::parse("/home")),
1541 Some(Location::parse("/detail"))
1542 ],
1543 "Router::push stamps the resolved location via PushOptions::route"
1544 );
1545 assert_eq!(controller.route_stack().change(), NavChange::Push);
1546
1547 router.replace("/other");
1548 root.rebuild(&mut app, &mut state);
1549 assert_eq!(
1550 controller.route_stack().entries(),
1551 &[
1552 Some(Location::parse("/home")),
1553 Some(Location::parse("/other"))
1554 ],
1555 "Router::replace stamps the resolved location via ReplaceOptions::route"
1556 );
1557 assert_eq!(controller.route_stack().change(), NavChange::Replace);
1558 }
1559
1560 type Build = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
1565
1566 struct PumpHarness {
1570 router: Router<()>,
1571 root: RenderRoot<(), NavigatorView<()>>,
1572 app: Build,
1573 state: (),
1574 }
1575
1576 impl PumpHarness {
1577 fn new(routes: Vec<Route<()>>) -> Self {
1578 let controller: NavigatorController<()> = NavigatorController::new();
1579 let router =
1582 Router::with_controller(&controller, routes).error_builder(|_| sized(99.0, 99.0));
1583 let app: Build =
1584 Box::new(move |_: &mut ()| navigator(&controller, || sized(10.0, 10.0)));
1585 let mut harness = PumpHarness {
1586 router,
1587 root: RenderRoot::new(),
1588 app,
1589 state: (),
1590 };
1591 harness.rebuild();
1592 harness
1593 }
1594
1595 fn rebuild(&mut self) {
1596 self.root.rebuild(&mut self.app, &mut self.state);
1597 }
1598
1599 fn frame(&mut self) -> Vec<(Point, Size)> {
1602 self.router.pump();
1603 self.rebuild();
1604 drive_paint(&mut self.root)
1605 }
1606 }
1607
1608 fn pump_routes() -> Vec<Route<()>> {
1609 vec![
1610 Route::new("/home", |_| sized(10.0, 10.0)),
1611 Route::new("/detail", |_| sized(20.0, 20.0)),
1612 Route::new("/other", |_| sized(30.0, 30.0)),
1613 ]
1614 }
1615
1616 #[test]
1617 fn pump_applies_a_queued_request_on_the_next_frame() {
1618 let mut h = PumpHarness::new(pump_routes());
1619 let nav = h.router.route_navigator();
1620 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1621
1622 nav.push("/detail");
1625 assert_eq!(
1626 h.frame(),
1627 vec![(Point::ZERO, Size::new(20.0, 20.0))],
1628 "a request queued between frames lands on the very next one"
1629 );
1630
1631 nav.pop();
1632 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1633 }
1634
1635 #[test]
1636 fn pump_applies_every_request_variant_in_order() {
1637 let mut h = PumpHarness::new(pump_routes());
1638 let nav = h.router.route_navigator();
1639
1640 nav.push("/detail");
1641 nav.replace("/other");
1642 assert_eq!(
1643 h.frame(),
1644 vec![(Point::ZERO, Size::new(30.0, 30.0))],
1645 "push then replace leaves /other on top, not /detail"
1646 );
1647 nav.pop();
1649 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1650
1651 nav.go("/detail");
1652 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1653 }
1654
1655 #[test]
1656 fn pump_applies_named_requests_with_their_params() {
1657 let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
1658 let recorder = seen.clone();
1659 let mut h = PumpHarness::new(vec![
1660 Route::new("/home", |_| sized(10.0, 10.0)),
1661 Route::new("/users/:id", move |p| {
1662 *recorder.borrow_mut() = p.clone();
1663 sized(20.0, 20.0)
1664 })
1665 .name("user"),
1666 ]);
1667 let nav = h.router.route_navigator();
1668
1669 nav.push_named("user", params(&[("id", "42"), ("tab", "posts")]));
1670 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1671 assert_eq!(seen.borrow().get("id").map(String::as_str), Some("42"));
1674 assert_eq!(seen.borrow().get("tab").map(String::as_str), Some("posts"));
1675 }
1676
1677 #[test]
1678 fn pump_is_a_no_op_when_the_queue_is_empty() {
1679 let mut h = PumpHarness::new(pump_routes());
1680 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1681 for _ in 0..3 {
1684 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1685 }
1686 }
1687
1688 #[test]
1689 fn a_request_from_another_thread_pumps_normally() {
1690 let mut h = PumpHarness::new(pump_routes());
1693 let nav = h.router.route_navigator();
1694 let woke = Arc::new(AtomicUsize::new(0));
1695 let counter = woke.clone();
1696 nav.set_waker(Arc::new(move || {
1697 counter.fetch_add(1, Ordering::SeqCst);
1698 }));
1699
1700 std::thread::spawn(move || nav.push("/detail"))
1701 .join()
1702 .expect("off-thread request must not panic");
1703
1704 assert_eq!(
1705 woke.load(Ordering::SeqCst),
1706 1,
1707 "the waker asked for a frame"
1708 );
1709 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1710 }
1711
1712 #[test]
1713 fn route_navigator_publishes_the_resolved_location() {
1714 let mut h = PumpHarness::new(pump_routes());
1715 let nav = h.router.route_navigator();
1716 assert!(nav.location().is_none(), "nothing resolved yet");
1717
1718 nav.push("/detail?tab=posts");
1719 let _ = h.frame();
1720 let loc = nav.location().expect("published after the pump");
1721 assert_eq!(loc.path, "/detail");
1722 assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
1723
1724 nav.go("/nope");
1726 let _ = h.frame();
1727 assert_eq!(nav.location().expect("published").path, "/nope");
1728 }
1729
1730 #[test]
1731 fn route_navigator_clones_share_one_queue() {
1732 let mut h = PumpHarness::new(pump_routes());
1735 let first = h.router.route_navigator();
1736 let second = h.router.route_navigator();
1737 first.push("/detail");
1738 second.replace("/other");
1739 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(30.0, 30.0))]);
1740 }
1741
1742 struct BuildCounter {
1749 builds: Rc<Cell<u32>>,
1750 }
1751 struct BuildCounterWidget;
1752 impl<S: 'static> View<S> for BuildCounter {
1753 type Element = BuildCounterWidget;
1754 fn build(&self, _ctx: &mut BuildCtx<'_>) -> BuildCounterWidget {
1755 self.builds.set(self.builds.get() + 1);
1756 BuildCounterWidget
1757 }
1758 fn rebuild(
1759 &self,
1760 _prev: &Self,
1761 _element: &mut BuildCounterWidget,
1762 _ctx: &mut BuildCtx<'_>,
1763 ) -> ChangeFlags {
1764 ChangeFlags::NONE
1765 }
1766 }
1767 impl Widget for BuildCounterWidget {
1768 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1769 bc.constrain(Size::ZERO)
1770 }
1771 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1772 }
1773
1774 struct ShellHarness {
1778 router: Router<()>,
1779 outer: NavigatorController<()>,
1780 inner: NavigatorController<()>,
1781 root: RenderRoot<(), NavigatorView<()>>,
1782 app: Build,
1783 shell_builds: Rc<Cell<u32>>,
1785 }
1786
1787 impl ShellHarness {
1788 fn new() -> Self {
1789 let outer: NavigatorController<()> = NavigatorController::new();
1790 let inner: NavigatorController<()> = NavigatorController::new();
1791 let shell_builds = Rc::new(Cell::new(0u32));
1792
1793 let shell_page = {
1794 let inner = inner.clone();
1795 let builds = shell_builds.clone();
1796 move |_: &RouteParams| {
1797 let inner = inner.clone();
1798 let builds = builds.clone();
1799 any(crate::Column(vec![
1800 any(BuildCounter { builds }),
1801 any(navigator(&inner, || sized(11.0, 11.0))),
1802 ]))
1803 }
1804 };
1805 let routes = vec![
1806 Route::new("/", |_| sized(10.0, 10.0)),
1807 shell_route(
1808 &inner,
1809 shell_page,
1810 vec![
1811 Route::new("/sessions", |_| sized(20.0, 20.0)),
1812 Route::new("/terminal", |_| sized(30.0, 30.0)),
1813 ],
1814 ),
1815 ];
1816 let router =
1817 Router::with_controller(&outer, routes).error_builder(|_| sized(99.0, 99.0));
1818 let app: Build = {
1819 let c = outer.clone();
1820 Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
1821 };
1822 let mut harness = ShellHarness {
1823 router,
1824 outer,
1825 inner,
1826 root: RenderRoot::new(),
1827 app,
1828 shell_builds,
1829 };
1830 harness.rebuild();
1831 harness
1832 }
1833
1834 fn rebuild(&mut self) {
1835 self.root.rebuild(&mut self.app, &mut ());
1836 }
1837
1838 fn stacks(&self) -> (Vec<String>, Vec<String>) {
1840 (paths(&self.outer), paths(&self.inner))
1841 }
1842 }
1843
1844 fn paths(controller: &NavigatorController<()>) -> Vec<String> {
1847 controller
1848 .route_stack()
1849 .entries()
1850 .iter()
1851 .map(|entry| match entry {
1852 Some(location) => location.path.clone(),
1853 None => "-".to_string(),
1854 })
1855 .collect()
1856 }
1857
1858 #[test]
1859 fn a_shell_chain_splits_the_outer_page_from_the_inner_leaf() {
1860 let mut h = ShellHarness::new();
1861 h.router.go("/terminal");
1862 h.rebuild();
1863
1864 let (outer, inner) = h.stacks();
1865 assert_eq!(
1866 outer,
1867 vec!["/terminal".to_string()],
1868 "the outer controller got the SHELL page (stamped with the location \
1869 that placed it), never the leaf"
1870 );
1871 assert_eq!(
1872 inner,
1873 vec!["/terminal".to_string()],
1874 "the leaf landed on the shell's inner controller"
1875 );
1876 assert_eq!(h.inner.depth(), 1, "the inner leaf replaced its root");
1877 assert!(h.inner.is_mounted(), "the shell page mounted its navigator");
1878 assert_eq!(h.shell_builds.get(), 1, "the shell page was built once");
1879 }
1880
1881 #[test]
1882 fn a_sibling_navigation_inside_the_shell_issues_zero_outer_ops() {
1883 let mut h = ShellHarness::new();
1887 h.router.go("/sessions");
1888 h.rebuild();
1889 let outer_generation = h.outer.route_generation();
1890 assert_eq!(h.shell_builds.get(), 1);
1891
1892 h.router.go("/terminal");
1893 h.rebuild();
1894
1895 assert_eq!(
1896 h.outer.route_generation(),
1897 outer_generation,
1898 "no outer op ran at all — an unchanged stack publishes nothing"
1899 );
1900 assert_eq!(
1901 h.shell_builds.get(),
1902 1,
1903 "the shell page's widget was retained, not rebuilt"
1904 );
1905 assert_eq!(
1906 h.stacks().1,
1907 vec!["/terminal".to_string()],
1908 "only the inner navigator moved"
1909 );
1910 assert_eq!(
1911 h.stacks().0,
1912 vec!["/sessions".to_string()],
1913 "the outer entry keeps naming the location that PLACED the shell — \
1914 live in-shell state is read from the inner navigator"
1915 );
1916 }
1917
1918 #[test]
1919 fn a_push_inside_a_placed_shell_stacks_on_the_inner_navigator() {
1920 let mut h = ShellHarness::new();
1921 h.router.go("/sessions");
1922 h.rebuild();
1923
1924 h.router.push("/terminal");
1925 h.rebuild();
1926 assert_eq!(h.outer.depth(), 1, "the outer stack never moved");
1927 assert_eq!(
1928 h.stacks().1,
1929 vec!["/sessions".to_string(), "/terminal".to_string()],
1930 "the leaf stacked inside the shell"
1931 );
1932 assert_eq!(h.shell_builds.get(), 1);
1933 }
1934
1935 #[test]
1936 fn a_push_that_must_place_the_shell_retains_the_page_below_and_resets_the_inner() {
1937 let mut h = ShellHarness::new();
1942 h.router.go("/");
1943 h.rebuild();
1944 assert_eq!(h.stacks().0, vec!["/".to_string()]);
1945
1946 h.router.push("/sessions");
1947 h.rebuild();
1948 assert_eq!(
1949 h.stacks().0,
1950 vec!["/".to_string(), "/sessions".to_string()],
1951 "the shell page stacked over the root page, which is retained"
1952 );
1953 assert_eq!(
1954 h.inner.depth(),
1955 1,
1956 "the fresh inner navigator is at its root"
1957 );
1958 assert_eq!(h.stacks().1, vec!["/sessions".to_string()]);
1959 }
1960
1961 #[test]
1962 fn a_page_pushed_over_the_shell_still_counts_as_placed() {
1963 let mut h = ShellHarness::new();
1967 h.router.go("/sessions");
1968 h.rebuild();
1969 h.outer.push(|| sized(77.0, 77.0));
1970 h.rebuild();
1971 assert_eq!(h.outer.depth(), 2);
1972
1973 h.router.go("/terminal");
1974 h.rebuild();
1975 assert_eq!(h.outer.depth(), 2, "no second shell page was placed");
1976 assert_eq!(h.shell_builds.get(), 1);
1977 assert_eq!(h.stacks().1, vec!["/terminal".to_string()]);
1978 }
1979
1980 #[test]
1981 fn two_shell_navigations_in_one_frame_place_the_shell_page_once() {
1982 let mut h = ShellHarness::new();
1986 h.router.go("/");
1987 h.rebuild();
1988
1989 h.router.push("/sessions");
1990 h.router.push("/terminal");
1991 h.rebuild();
1992
1993 assert_eq!(
1994 h.stacks().0,
1995 vec!["/".to_string(), "/sessions".to_string()],
1996 "exactly one shell page, placed by the first navigation"
1997 );
1998 assert_eq!(
1999 h.stacks().1,
2000 vec!["/sessions".to_string(), "/terminal".to_string()],
2001 "both in-shell ops landed on the one inner navigator"
2002 );
2003 assert_eq!(h.shell_builds.get(), 1);
2004 }
2005
2006 #[test]
2007 fn the_root_route_beats_a_pathless_shell_for_the_empty_location() {
2008 let mut h = ShellHarness::new();
2011 h.router.go("/");
2012 h.rebuild();
2013 assert_eq!(h.stacks().0, vec!["/".to_string()]);
2014 assert!(
2015 !h.inner.is_mounted(),
2016 "the shell was never placed, so its navigator never mounted"
2017 );
2018 }
2019
2020 #[test]
2021 fn a_redirect_out_of_the_shell_splits_the_post_redirect_chain() {
2022 let outer: NavigatorController<()> = NavigatorController::new();
2025 let inner: NavigatorController<()> = NavigatorController::new();
2026 let router = {
2027 let inner_for_shell = inner.clone();
2028 Router::with_controller(
2029 &outer,
2030 vec![
2031 Route::new("/", |_| sized(10.0, 10.0)),
2032 shell_route(
2033 &inner,
2034 move |_| {
2035 let inner = inner_for_shell.clone();
2036 any(navigator(&inner, || sized(11.0, 11.0)))
2037 },
2038 vec![
2039 Route::new("/sessions", |_| sized(20.0, 20.0))
2040 .redirect(|_| Some("/".to_string())),
2041 ],
2042 ),
2043 ],
2044 )
2045 };
2046 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
2047 let mut app: Build = {
2048 let c = outer.clone();
2049 Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
2050 };
2051 root.rebuild(&mut app, &mut ());
2052
2053 router.go("/sessions");
2054 root.rebuild(&mut app, &mut ());
2055 assert_eq!(paths(&outer), vec!["/".to_string()], "the redirect won");
2056 assert!(!inner.is_mounted(), "no shell page, no inner navigator");
2057 }
2058
2059 #[test]
2060 fn a_non_shell_chain_still_flattens_onto_one_controller() {
2061 let controller: NavigatorController<()> = NavigatorController::new();
2064 let router: Router<()> = Router::with_controller(
2065 &controller,
2066 vec![
2067 Route::new("/home", |_| sized(10.0, 10.0)),
2068 Route::new("/users", |_| sized(20.0, 20.0))
2069 .child(Route::new(":id", |_| sized(30.0, 30.0))),
2070 ],
2071 );
2072 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
2073 let mut app: Build = {
2074 let c = controller.clone();
2075 Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
2076 };
2077 root.rebuild(&mut app, &mut ());
2078
2079 router.go("/users/42");
2080 root.rebuild(&mut app, &mut ());
2081 assert_eq!(
2082 paths(&controller),
2083 vec!["/users/42".to_string(), "/users/42".to_string()],
2084 "go replaced the top with the chain root and pushed the rest — both \
2085 pages on the one controller"
2086 );
2087
2088 router.push("/users/7");
2089 root.rebuild(&mut app, &mut ());
2090 assert_eq!(
2091 controller.depth(),
2092 3,
2093 "push stacked the leaf alone, unchanged"
2094 );
2095
2096 router.replace("/home");
2097 root.rebuild(&mut app, &mut ());
2098 assert_eq!(
2099 paths(&controller).last().map(String::as_str),
2100 Some("/home"),
2101 "replace swapped the top alone, unchanged"
2102 );
2103 assert_eq!(controller.depth(), 3);
2104 }
2105}