1use std::cell::RefCell;
99use std::collections::{HashMap, HashSet};
100use std::rc::Rc;
101
102use frust_core::{AnyView, View, 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<V: View<State>>(
158 path: impl Into<String>,
159 builder: impl Fn(&RouteParams) -> V + 'static,
160 ) -> Self {
161 Route {
162 path: path.into(),
163 name: None,
164 builder: Rc::new(move |params: &RouteParams| any(builder(params))),
165 redirect: None,
166 children: Vec::new(),
167 shell: None,
168 }
169 }
170
171 pub fn name(mut self, name: impl Into<String>) -> Self {
174 self.name = Some(name.into());
175 self
176 }
177
178 pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
181 self.redirect = Some(Rc::new(redirect));
182 self
183 }
184
185 pub fn child(mut self, child: Route<State>) -> Self {
187 self.children.push(child);
188 self
189 }
190
191 pub fn children(mut self, children: Vec<Route<State>>) -> Self {
193 self.children = children;
194 self
195 }
196}
197
198pub fn shell_route<State: 'static, V: View<State>>(
285 inner: &NavigatorController<State>,
286 builder: impl Fn(&RouteParams) -> V + 'static,
287 children: Vec<Route<State>>,
288) -> Route<State> {
289 Route {
290 path: String::new(),
291 name: None,
292 builder: Rc::new(move |params: &RouteParams| any(builder(params))),
293 redirect: None,
294 children,
295 shell: Some(inner.clone()),
296 }
297}
298
299pub struct ResolvedPage<State: 'static> {
305 builder: RouteBuilder<State>,
306 params: RouteParams,
307 location: Location,
308 shell: Option<NavigatorController<State>>,
313}
314
315impl<State: 'static> ResolvedPage<State> {
316 pub fn build(&self) -> AnyView<State> {
318 (self.builder)(&self.params)
319 }
320
321 pub fn params(&self) -> &RouteParams {
323 &self.params
324 }
325
326 pub fn location(&self) -> &Location {
330 &self.location
331 }
332
333 fn into_page_builder(self) -> impl Fn() -> AnyView<State> + 'static {
336 let ResolvedPage {
337 builder, params, ..
338 } = self;
339 move || (builder)(¶ms)
340 }
341}
342
343pub enum Resolution<State: 'static> {
346 Matched {
351 location: Location,
352 params: RouteParams,
353 pages: Vec<ResolvedPage<State>>,
354 },
355 Error { location: Location },
358}
359
360impl<State: 'static> Resolution<State> {
361 pub fn is_matched(&self) -> bool {
363 matches!(self, Resolution::Matched { .. })
364 }
365
366 pub fn params(&self) -> RouteParams {
369 match self {
370 Resolution::Matched { params, .. } => params.clone(),
371 Resolution::Error { .. } => RouteParams::new(),
372 }
373 }
374
375 pub fn page_count(&self) -> usize {
377 match self {
378 Resolution::Matched { pages, .. } => pages.len(),
379 Resolution::Error { .. } => 0,
380 }
381 }
382}
383
384#[derive(Clone, Copy, PartialEq, Eq, Debug)]
388enum Verb {
389 Go,
390 Push,
391 Replace,
392}
393
394struct Segment<State: 'static> {
398 controller: NavigatorController<State>,
399 pages: Vec<ResolvedPage<State>>,
400}
401
402pub struct Router<State: 'static> {
405 routes: Vec<Route<State>>,
406 controller: NavigatorController<State>,
407 redirect: Option<Redirect>,
408 error_builder: ErrorBuilder<State>,
409 redirect_limit: usize,
410 route_nav: RouteNavigator,
414 pending_shells: RefCell<HashMap<NavigatorId, u64>>,
429}
430
431impl<State: 'static> Router<State> {
432 pub fn new(routes: Vec<Route<State>>) -> Self {
435 Self::with_controller(&NavigatorController::new(), routes)
436 }
437
438 pub fn with_controller(
441 controller: &NavigatorController<State>,
442 routes: Vec<Route<State>>,
443 ) -> Self {
444 Router {
445 routes,
446 controller: controller.clone(),
447 redirect: None,
448 error_builder: Rc::new(default_error_page),
449 redirect_limit: DEFAULT_REDIRECT_LIMIT,
450 route_nav: RouteNavigator::new(),
451 pending_shells: RefCell::new(HashMap::new()),
452 }
453 }
454
455 pub fn redirect(mut self, redirect: impl Fn(&Location) -> Option<String> + 'static) -> Self {
457 self.redirect = Some(Rc::new(redirect));
458 self
459 }
460
461 pub fn error_builder<V: View<State>>(
464 mut self,
465 error_builder: impl Fn(&Location) -> V + 'static,
466 ) -> Self {
467 self.error_builder = Rc::new(move |location: &Location| any(error_builder(location)));
468 self
469 }
470
471 pub fn redirect_limit(mut self, limit: usize) -> Self {
473 self.redirect_limit = limit;
474 self
475 }
476
477 pub fn controller(&self) -> &NavigatorController<State> {
481 &self.controller
482 }
483
484 pub fn resolve(&self, raw: &str) -> Resolution<State> {
489 let mut loc = Location::parse(raw);
490 let mut redirects = 0usize;
491
492 loop {
493 if let Some(next) = self.fire_redirect(self.redirect.as_ref(), &loc) {
495 redirects += 1;
496 if redirects > self.redirect_limit {
497 return Resolution::Error { location: loc };
498 }
499 loc = next;
500 continue;
501 }
502
503 let Some((chain, params)) = self.match_chain(&loc) else {
505 return Resolution::Error { location: loc };
506 };
507
508 let mut redirected = None;
510 for route in &chain {
511 if let Some(next) = self.fire_redirect(route.redirect.as_ref(), &loc) {
512 redirected = Some(next);
513 break;
514 }
515 }
516 if let Some(next) = redirected {
517 redirects += 1;
518 if redirects > self.redirect_limit {
519 return Resolution::Error { location: loc };
520 }
521 loc = next;
522 continue;
523 }
524
525 let mut merged = loc.query.clone();
532 merged.extend(params);
533 let pages = chain
534 .iter()
535 .map(|route| ResolvedPage {
536 builder: route.builder.clone(),
537 params: merged.clone(),
538 location: loc.clone(),
539 shell: route.shell.clone(),
540 })
541 .collect();
542 return Resolution::Matched {
543 location: loc,
544 params: merged,
545 pages,
546 };
547 }
548 }
549
550 fn fire_redirect(&self, redirect: Option<&Redirect>, loc: &Location) -> Option<Location> {
554 let redirect = redirect?;
555 let next = redirect(loc)?;
556 let next_loc = Location::parse(&next);
557 if next_loc.location_string() == loc.location_string() {
558 None
559 } else {
560 Some(next_loc)
561 }
562 }
563
564 fn match_chain<'a>(&'a self, loc: &Location) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
567 match_routes(&self.routes, &loc.segments, RouteParams::new(), Vec::new())
568 }
569
570 pub fn route_navigator(&self) -> RouteNavigator {
578 self.route_nav.clone()
579 }
580
581 pub fn pump(&self) {
588 for request in self.route_nav.drain() {
589 match request {
590 NavRequest::Go(location) => self.go(&location),
591 NavRequest::Push(location) => self.push(&location),
592 NavRequest::Replace(location) => self.replace(&location),
593 NavRequest::Pop => self.pop(),
594 NavRequest::GoNamed { name, params } => self.go_named(&name, ¶ms),
595 NavRequest::PushNamed { name, params } => self.push_named(&name, ¶ms),
596 }
597 }
598 }
599
600 pub fn go(&self, location: &str) {
611 self.apply(self.resolved(location), Verb::Go);
612 }
613
614 pub fn push(&self, location: &str) {
620 self.apply(self.resolved(location), Verb::Push);
621 }
622
623 pub fn replace(&self, location: &str) {
631 self.apply(self.resolved(location), Verb::Replace);
632 }
633
634 pub fn pop(&self) {
642 self.controller.pop();
643 }
644
645 fn apply(&self, resolution: Resolution<State>, verb: Verb) {
652 match resolution {
653 Resolution::Matched { pages, .. } => {
654 let mut segments = self.split_chain(pages);
655 if segments.len() == 1 {
656 let segment = segments.pop().expect("a chain has at least one segment");
657 Self::apply_flat(&segment.controller, segment.pages, verb);
658 } else {
659 self.apply_shell_chain(segments, verb);
660 }
661 }
662 Resolution::Error { location } => match verb {
663 Verb::Push => self.controller.push(self.error_page_builder(location)),
664 Verb::Go | Verb::Replace => {
665 self.controller.replace(self.error_page_builder(location))
666 }
667 },
668 }
669 }
670
671 fn split_chain(&self, pages: Vec<ResolvedPage<State>>) -> Vec<Segment<State>> {
681 let mut segments = vec![Segment {
682 controller: self.controller.clone(),
683 pages: Vec::new(),
684 }];
685 for page in pages {
686 let inner = page.shell.clone();
687 segments
688 .last_mut()
689 .expect("segments is seeded with the outer segment")
690 .pages
691 .push(page);
692 if let Some(inner) = inner {
693 segments.push(Segment {
694 controller: inner,
695 pages: Vec::new(),
696 });
697 }
698 }
699 segments
700 }
701
702 fn apply_shell_chain(&self, segments: Vec<Segment<State>>, verb: Verb) {
705 let shells: Vec<NavigatorId> = segments
708 .iter()
709 .skip(1)
710 .map(|segment| segment.controller.id())
711 .collect();
712 let last = segments.len() - 1;
713 let mut enclosing_kept = true;
719
720 for (index, segment) in segments.into_iter().enumerate() {
721 let effective = if enclosing_kept { verb } else { Verb::Go };
722 if index == last {
723 Self::apply_flat(&segment.controller, segment.pages, effective);
724 continue;
725 }
726
727 let shell = shells[index];
728 if enclosing_kept && self.shell_is_placed(&segment.controller, index, shell) {
729 continue;
730 }
731 enclosing_kept = false;
732 self.pending_shells
733 .borrow_mut()
734 .insert(shell, segment.controller.route_generation());
735 Self::place_segment(&segment.controller, segment.pages, effective);
736 }
737 }
738
739 fn shell_is_placed(
760 &self,
761 target: &NavigatorController<State>,
762 boundary: usize,
763 shell: NavigatorId,
764 ) -> bool {
765 let stack = target.route_stack();
766 let committed = stack
767 .entries()
768 .iter()
769 .flatten()
770 .any(|location| self.shell_at(location, boundary) == Some(shell));
771 if committed {
772 return true;
773 }
774 self.pending_shells.borrow().get(&shell) == Some(&stack.generation())
777 }
778
779 fn shell_at(&self, location: &Location, boundary: usize) -> Option<NavigatorId> {
782 let (chain, _) = self.match_chain(location)?;
783 chain
784 .iter()
785 .filter_map(|route| route.shell.as_ref())
786 .nth(boundary)
787 .map(|controller| controller.id())
788 }
789
790 fn apply_flat(
794 controller: &NavigatorController<State>,
795 pages: Vec<ResolvedPage<State>>,
796 verb: Verb,
797 ) {
798 match verb {
799 Verb::Go => {
800 let mut pages = pages.into_iter();
801 if let Some(first) = pages.next() {
802 let route = first.location().clone();
803 controller.replace_with_options(
804 first.into_page_builder(),
805 ReplaceOptions::opaque().route(route),
806 );
807 }
808 for page in pages {
809 let route = page.location().clone();
810 controller.push_with_options(
811 page.into_page_builder(),
812 PushOptions::opaque().route(route),
813 );
814 }
815 }
816 Verb::Push => {
817 if let Some(leaf) = pages.into_iter().next_back() {
818 let route = leaf.location().clone();
819 controller.push_with_options(
820 leaf.into_page_builder(),
821 PushOptions::opaque().route(route),
822 );
823 }
824 }
825 Verb::Replace => {
826 if let Some(leaf) = pages.into_iter().next_back() {
827 let route = leaf.location().clone();
828 controller.replace_with_options(
829 leaf.into_page_builder(),
830 ReplaceOptions::opaque().route(route),
831 );
832 }
833 }
834 }
835 }
836
837 fn place_segment(
842 controller: &NavigatorController<State>,
843 pages: Vec<ResolvedPage<State>>,
844 verb: Verb,
845 ) {
846 match verb {
847 Verb::Push => {
848 for page in pages {
849 let route = page.location().clone();
850 controller.push_with_options(
851 page.into_page_builder(),
852 PushOptions::opaque().route(route),
853 );
854 }
855 }
856 Verb::Go | Verb::Replace => Self::apply_flat(controller, pages, Verb::Go),
857 }
858 }
859
860 pub fn handle_location(&self, location: &str) {
864 self.go(location);
865 }
866
867 pub fn go_named(&self, name: &str, params: &RouteParams) {
870 match self.path_for_name(name, params) {
871 Some(path) => self.go(&path),
872 None => {
873 let location = named_error_location(name);
874 self.route_nav.set_location(location.clone());
875 self.controller.replace(self.error_page_builder(location));
876 }
877 }
878 }
879
880 pub fn push_named(&self, name: &str, params: &RouteParams) {
883 match self.path_for_name(name, params) {
884 Some(path) => self.push(&path),
885 None => {
886 let location = named_error_location(name);
887 self.route_nav.set_location(location.clone());
888 self.controller.push(self.error_page_builder(location));
889 }
890 }
891 }
892
893 pub fn path_for_name(&self, name: &str, params: &RouteParams) -> Option<String> {
898 let pattern = find_named_pattern(&self.routes, name, "")?;
899 Some(substitute_pattern(&pattern, params))
900 }
901
902 fn resolved(&self, raw: &str) -> Resolution<State> {
907 let resolution = self.resolve(raw);
908 let location = match &resolution {
909 Resolution::Matched { location, .. } | Resolution::Error { location } => location,
910 };
911 self.route_nav.set_location(location.clone());
912 resolution
913 }
914
915 fn error_page_builder(&self, location: Location) -> impl Fn() -> AnyView<State> + 'static {
917 let error_builder = self.error_builder.clone();
918 move || (error_builder)(&location)
919 }
920}
921
922fn match_routes<'a, State: 'static>(
927 routes: &'a [Route<State>],
928 segments: &[String],
929 acc_params: RouteParams,
930 chain: Vec<&'a Route<State>>,
931) -> Option<(Vec<&'a Route<State>>, RouteParams)> {
932 for route in routes {
933 let pattern = PathPattern::parse(&route.path);
934 let Some((consumed, params)) = pattern.match_prefix(segments) else {
935 continue;
936 };
937
938 let mut next_params = acc_params.clone();
939 next_params.extend(params);
940
941 let mut next_chain = chain.clone();
942 next_chain.push(route);
943
944 let remaining = &segments[consumed..];
945 if remaining.is_empty() {
946 return Some((next_chain, next_params));
947 }
948 if let Some(found) = match_routes(&route.children, remaining, next_params, next_chain) {
950 return Some(found);
951 }
952 }
955 None
956}
957
958fn find_named_pattern<State: 'static>(
961 routes: &[Route<State>],
962 name: &str,
963 prefix: &str,
964) -> Option<String> {
965 for route in routes {
966 let full = join_paths(prefix, &route.path);
967 if route.name.as_deref() == Some(name) {
968 return Some(full);
969 }
970 if let Some(found) = find_named_pattern(&route.children, name, &full) {
971 return Some(found);
972 }
973 }
974 None
975}
976
977fn join_paths(prefix: &str, path: &str) -> String {
979 let a = prefix.trim_matches('/');
980 let b = path.trim_matches('/');
981 match (a.is_empty(), b.is_empty()) {
982 (true, true) => String::new(),
983 (true, false) => b.to_string(),
984 (false, true) => a.to_string(),
985 (false, false) => format!("{a}/{b}"),
986 }
987}
988
989fn substitute_pattern(pattern: &str, params: &RouteParams) -> String {
992 let mut used: HashSet<&str> = HashSet::new();
993 let mut path = String::new();
994
995 for seg in pattern.split('/').filter(|s| !s.is_empty()) {
996 path.push('/');
997 if let Some(name) = seg.strip_prefix(':') {
998 match params.get(name) {
999 Some(value) => {
1000 path.push_str(&encode_segment(value));
1001 used.insert(name);
1002 }
1003 None => path.push_str(seg),
1007 }
1008 } else {
1009 path.push_str(seg);
1010 }
1011 }
1012 if path.is_empty() {
1013 path.push('/');
1014 }
1015
1016 let extras: Vec<String> = params
1017 .iter()
1018 .filter(|(k, _)| !used.contains(k.as_str()))
1019 .map(|(k, v)| format!("{}={}", encode_segment(k), encode_segment(v)))
1020 .collect();
1021 if extras.is_empty() {
1022 path
1023 } else {
1024 format!("{}?{}", path, extras.join("&"))
1025 }
1026}
1027
1028fn named_error_location(name: &str) -> Location {
1030 Location::parse(&format!("/{name}"))
1031}
1032
1033fn default_error_page<State: 'static>(location: &Location) -> AnyView<State> {
1035 any(crate::text(format!("Page not found: {}", location.path)))
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040 use super::*;
1041 use crate::test_support::RecordingScene;
1042 use frust_core::{
1043 BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, FrameTime, InputEvent,
1044 LayoutCtx, PaintCtx, PaintScene, PointerButton, PointerEvent, PointerPhase, RenderRoot,
1045 View, Widget,
1046 };
1047 use kurbo::{Point, Size};
1048 use std::cell::{Cell, RefCell};
1049 use std::rc::Rc;
1050 use std::sync::Arc;
1051 use std::sync::atomic::{AtomicUsize, Ordering};
1052
1053 use super::super::navigator::{NavigatorView, navigator};
1054 use super::super::route_state::NavChange;
1055
1056 fn params(pairs: &[(&str, &str)]) -> RouteParams {
1057 pairs
1058 .iter()
1059 .map(|(k, v)| (k.to_string(), v.to_string()))
1060 .collect()
1061 }
1062
1063 struct SizedLeaf {
1066 size: Size,
1067 }
1068 struct SizedLeafWidget {
1069 size: Size,
1070 }
1071 impl<S: 'static> View<S> for SizedLeaf {
1072 type Element = SizedLeafWidget;
1073 fn build(&self, _ctx: &mut BuildCtx<'_>) -> SizedLeafWidget {
1074 SizedLeafWidget { size: self.size }
1075 }
1076 fn rebuild(
1077 &self,
1078 _prev: &Self,
1079 _element: &mut SizedLeafWidget,
1080 _ctx: &mut BuildCtx<'_>,
1081 ) -> ChangeFlags {
1082 ChangeFlags::NONE
1083 }
1084 }
1085 impl Widget for SizedLeafWidget {
1086 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1087 bc.constrain(self.size)
1088 }
1089 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1090 scene.fill_rect(ctx.origin(), ctx.size(), peniko::Color::BLACK);
1091 }
1092 }
1093 fn sized<S: 'static>(w: f64, h: f64) -> impl View<S> {
1094 SizedLeaf {
1095 size: Size::new(w, h),
1096 }
1097 }
1098
1099 struct CounterView {
1102 observed: Rc<Cell<u32>>,
1103 }
1104 struct CounterWidget {
1105 count: u32,
1106 observed: Rc<Cell<u32>>,
1107 }
1108 impl View<()> for CounterView {
1109 type Element = CounterWidget;
1110 fn build(&self, _ctx: &mut BuildCtx<'_>) -> CounterWidget {
1111 CounterWidget {
1112 count: 0,
1113 observed: self.observed.clone(),
1114 }
1115 }
1116 fn rebuild(
1117 &self,
1118 _prev: &Self,
1119 element: &mut CounterWidget,
1120 _ctx: &mut BuildCtx<'_>,
1121 ) -> ChangeFlags {
1122 element.observed = self.observed.clone();
1123 ChangeFlags::NONE
1124 }
1125 }
1126 impl Widget for CounterWidget {
1127 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1128 bc.max()
1129 }
1130 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {
1131 self.observed.set(self.count);
1132 }
1133 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
1134 if let InputEvent::Pointer(p) = event
1135 && p.phase == PointerPhase::Down
1136 {
1137 self.count += 1;
1138 ctx.request_redraw();
1139 return EventResult::Handled;
1140 }
1141 EventResult::Ignored
1142 }
1143 }
1144 fn counter(observed: &Rc<Cell<u32>>) -> AnyView<()> {
1146 any(CounterView {
1147 observed: observed.clone(),
1148 })
1149 }
1150
1151 fn down(x: f64, y: f64) -> InputEvent {
1152 InputEvent::Pointer(PointerEvent {
1153 phase: PointerPhase::Down,
1154 position: Point::new(x, y),
1155 button: PointerButton::Primary,
1156 })
1157 }
1158
1159 #[test]
1162 fn matches_static_route() {
1163 let router: Router<()> = Router::new(vec![Route::new("/settings", |_| sized(10.0, 10.0))]);
1164 let res = router.resolve("/settings");
1165 assert!(res.is_matched());
1166 assert_eq!(res.page_count(), 1);
1167 }
1168
1169 #[test]
1170 fn extracts_param() {
1171 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1172 let res = router.resolve("/users/42");
1173 assert!(res.is_matched());
1174 assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
1175 }
1176
1177 fn builder_params(resolution: &Resolution<()>) -> RouteParams {
1181 let Resolution::Matched { pages, .. } = resolution else {
1182 panic!("expected match");
1183 };
1184 let leaf = pages.last().expect("a matched chain has a leaf");
1185 let _view = leaf.build();
1186 leaf.params().clone()
1187 }
1188
1189 #[test]
1190 fn query_reaches_the_page_builder() {
1191 let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
1193 let recorder = seen.clone();
1194 let router: Router<()> = Router::new(vec![Route::new("/terminal", move |p| {
1195 *recorder.borrow_mut() = p.clone();
1196 sized(10.0, 10.0)
1197 })]);
1198
1199 let resolution = router.resolve("/terminal?session=abc");
1200 assert_eq!(
1201 builder_params(&resolution)
1202 .get("session")
1203 .map(String::as_str),
1204 Some("abc")
1205 );
1206 assert_eq!(
1207 seen.borrow().get("session").map(String::as_str),
1208 Some("abc"),
1209 "the builder itself receives the query parameter"
1210 );
1211 }
1212
1213 #[test]
1214 fn path_capture_beats_a_colliding_query_key() {
1215 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1217 let resolution = router.resolve("/users/42?id=99");
1218 assert_eq!(
1219 builder_params(&resolution).get("id").map(String::as_str),
1220 Some("42")
1221 );
1222 assert_eq!(
1223 resolution.params().get("id").map(String::as_str),
1224 Some("42")
1225 );
1226 }
1227
1228 #[test]
1229 fn named_extra_params_round_trip_through_resolve() {
1230 let router: Router<()> = Router::new(vec![
1233 Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1234 ]);
1235 let path = router
1236 .path_for_name("user", ¶ms(&[("id", "42"), ("tab", "posts")]))
1237 .expect("named route");
1238 let received = builder_params(&router.resolve(&path));
1239 assert_eq!(received.get("id").map(String::as_str), Some("42"));
1240 assert_eq!(received.get("tab").map(String::as_str), Some("posts"));
1241 }
1242
1243 #[test]
1244 fn resolved_page_exposes_its_location() {
1245 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1246 let Resolution::Matched { pages, .. } = router.resolve("/users/42?tab=posts") else {
1247 panic!("expected match");
1248 };
1249 let leaf = pages.last().expect("leaf");
1250 assert_eq!(leaf.location().path, "/users/42");
1251 assert_eq!(
1252 leaf.location().query.get("tab").map(String::as_str),
1253 Some("posts"),
1254 "the raw location keeps path and query separate"
1255 );
1256 }
1257
1258 #[test]
1259 fn parses_query_into_params_via_location() {
1260 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1263 let Resolution::Matched { location, .. } = router.resolve("/users/42?tab=posts") else {
1264 panic!("expected match");
1265 };
1266 assert_eq!(location.query.get("tab").map(String::as_str), Some("posts"));
1267 }
1268
1269 #[test]
1270 fn nested_route_composes_and_chains() {
1271 let router: Router<()> = Router::new(vec![
1272 Route::new("/users", |_| sized(10.0, 10.0))
1273 .child(Route::new(":id", |_| sized(20.0, 20.0))),
1274 ]);
1275 let res = router.resolve("/users/42");
1276 assert!(res.is_matched());
1277 assert_eq!(res.page_count(), 2);
1279 assert_eq!(res.params().get("id").map(String::as_str), Some("42"));
1280 }
1281
1282 #[test]
1283 fn trailing_slash_still_matches() {
1284 let router: Router<()> = Router::new(vec![Route::new("/users/:id", |_| sized(10.0, 10.0))]);
1285 assert!(router.resolve("/users/42/").is_matched());
1286 }
1287
1288 #[test]
1289 fn no_match_is_error() {
1290 let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
1291 let res = router.resolve("/nope");
1292 assert!(!res.is_matched());
1293 assert!(matches!(res, Resolution::Error { .. }));
1294 }
1295
1296 #[test]
1299 fn per_route_redirect_follows_chain() {
1300 let router: Router<()> = Router::new(vec![
1301 Route::new("/old", |_| sized(10.0, 10.0)).redirect(|_| Some("/new".to_string())),
1302 Route::new("/new", |_| sized(20.0, 20.0)),
1303 ]);
1304 let Resolution::Matched { location, .. } = router.resolve("/old") else {
1305 panic!("expected match after redirect");
1306 };
1307 assert_eq!(location.path, "/new");
1308 }
1309
1310 #[test]
1311 fn top_level_redirect_applies() {
1312 let router: Router<()> = Router::new(vec![
1313 Route::new("/login", |_| sized(10.0, 10.0)),
1314 Route::new("/home", |_| sized(20.0, 20.0)),
1315 ])
1316 .redirect(|loc| (loc.path == "/home").then(|| "/login".to_string()));
1317 let Resolution::Matched { location, .. } = router.resolve("/home") else {
1318 panic!("expected redirect to /login");
1319 };
1320 assert_eq!(location.path, "/login");
1321 }
1322
1323 #[test]
1324 fn redirect_loop_falls_back_to_error_at_limit() {
1325 let router: Router<()> = Router::new(vec![
1328 Route::new("/a", |_| sized(10.0, 10.0)).redirect(|_| Some("/b".to_string())),
1329 Route::new("/b", |_| sized(20.0, 20.0)).redirect(|_| Some("/a".to_string())),
1330 ]);
1331 let res = router.resolve("/a");
1332 assert!(
1333 matches!(res, Resolution::Error { .. }),
1334 "loop must error out"
1335 );
1336 }
1337
1338 #[test]
1339 fn redirect_to_same_location_is_not_a_loop() {
1340 let router: Router<()> = Router::new(vec![
1343 Route::new("/x", |_| sized(10.0, 10.0)).redirect(|_| Some("/x".to_string())),
1344 ]);
1345 assert!(router.resolve("/x").is_matched());
1346 }
1347
1348 #[test]
1351 fn named_resolves_params_into_path() {
1352 let router: Router<()> = Router::new(vec![
1353 Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1354 ]);
1355 assert_eq!(
1356 router.path_for_name("user", ¶ms(&[("id", "42")])),
1357 Some("/users/42".to_string())
1358 );
1359 }
1360
1361 #[test]
1362 fn named_composes_nested_path() {
1363 let router: Router<()> = Router::new(vec![
1364 Route::new("/users", |_| sized(10.0, 10.0))
1365 .child(Route::new(":id", |_| sized(20.0, 20.0)).name("user")),
1366 ]);
1367 assert_eq!(
1368 router.path_for_name("user", ¶ms(&[("id", "7")])),
1369 Some("/users/7".to_string())
1370 );
1371 }
1372
1373 #[test]
1374 fn named_extra_params_become_query() {
1375 let router: Router<()> = Router::new(vec![
1376 Route::new("/users/:id", |_| sized(10.0, 10.0)).name("user"),
1377 ]);
1378 assert_eq!(
1379 router.path_for_name("user", ¶ms(&[("id", "42"), ("tab", "posts")])),
1380 Some("/users/42?tab=posts".to_string())
1381 );
1382 }
1383
1384 #[test]
1385 fn unknown_name_is_none() {
1386 let router: Router<()> = Router::new(vec![Route::new("/home", |_| sized(10.0, 10.0))]);
1387 assert!(
1388 router
1389 .path_for_name("missing", &RouteParams::new())
1390 .is_none()
1391 );
1392 }
1393
1394 fn drive_paint(root: &mut RenderRoot<(), NavigatorView<()>>) -> Vec<(Point, Size)> {
1397 root.layout(Size::new(100.0, 100.0));
1398 let mut scene = RecordingScene::default();
1399 root.paint(&mut scene, FrameTime::ZERO);
1400 scene.rects
1401 }
1402
1403 #[test]
1404 fn go_replaces_top_dropping_state() {
1405 let controller: NavigatorController<()> = NavigatorController::new();
1406 let observed = Rc::new(Cell::new(0u32));
1407 let router = {
1408 let obs = observed.clone();
1409 Router::with_controller(
1410 &controller,
1411 vec![Route::new("/home", move |_| counter(&obs))],
1412 )
1413 };
1414
1415 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1416 let mut app = {
1417 let ctrl = controller.clone();
1418 let obs = observed.clone();
1419 move |_: &mut ()| {
1420 let obs = obs.clone();
1421 navigator(&ctrl, move || counter(&obs))
1422 }
1423 };
1424 let mut state = ();
1425 root.rebuild(&mut app, &mut state);
1426 root.layout(Size::new(100.0, 100.0));
1427 root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1428
1429 root.event(&mut state, &down(5.0, 5.0));
1431 root.rebuild(&mut app, &mut state);
1432 root.layout(Size::new(100.0, 100.0));
1433 root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1434 assert_eq!(observed.get(), 1);
1435
1436 router.go("/home");
1438 root.rebuild(&mut app, &mut state);
1439 root.layout(Size::new(100.0, 100.0));
1440 root.paint(&mut RecordingScene::default(), FrameTime::ZERO);
1441 assert_eq!(observed.get(), 0, "go dropped the previous page's state");
1442 }
1443
1444 #[test]
1445 fn push_stacks_and_retains_below() {
1446 let controller: NavigatorController<()> = NavigatorController::new();
1447 let router: Router<()> = Router::with_controller(
1448 &controller,
1449 vec![
1450 Route::new("/home", |_| sized(10.0, 10.0)),
1451 Route::new("/detail", |_| sized(20.0, 20.0)),
1452 ],
1453 );
1454
1455 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1456 let mut app = {
1457 let ctrl = controller.clone();
1458 move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1459 };
1460 let mut state = ();
1461 root.rebuild(&mut app, &mut state);
1462
1463 assert_eq!(
1465 drive_paint(&mut root),
1466 vec![(Point::ZERO, Size::new(10.0, 10.0))]
1467 );
1468
1469 router.push("/detail");
1471 root.rebuild(&mut app, &mut state);
1472 assert_eq!(
1473 drive_paint(&mut root),
1474 vec![(Point::ZERO, Size::new(20.0, 20.0))]
1475 );
1476
1477 router.pop();
1479 root.rebuild(&mut app, &mut state);
1480 assert_eq!(
1481 drive_paint(&mut root),
1482 vec![(Point::ZERO, Size::new(10.0, 10.0))]
1483 );
1484 }
1485
1486 #[test]
1487 fn go_to_unmatched_shows_error_page() {
1488 let controller: NavigatorController<()> = NavigatorController::new();
1491 let router: Router<()> = Router::with_controller(
1492 &controller,
1493 vec![Route::new("/home", |_| sized(10.0, 10.0))],
1494 )
1495 .error_builder(|_| sized(99.0, 99.0));
1496
1497 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1498 let mut app = {
1499 let ctrl = controller.clone();
1500 move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1501 };
1502 let mut state = ();
1503 root.rebuild(&mut app, &mut state);
1504
1505 router.go("/does-not-exist");
1506 root.rebuild(&mut app, &mut state);
1507 assert_eq!(
1508 drive_paint(&mut root),
1509 vec![(Point::ZERO, Size::new(99.0, 99.0))],
1510 "the error page replaced the top"
1511 );
1512 }
1513
1514 #[test]
1517 fn go_push_replace_all_stamp_the_resolved_location_onto_the_route_stack() {
1518 let controller: NavigatorController<()> = NavigatorController::new();
1519 let router: Router<()> = Router::with_controller(
1520 &controller,
1521 vec![
1522 Route::new("/home", |_| sized(10.0, 10.0)),
1523 Route::new("/detail", |_| sized(20.0, 20.0)),
1524 Route::new("/other", |_| sized(30.0, 30.0)),
1525 ],
1526 );
1527
1528 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
1529 let mut app = {
1530 let ctrl = controller.clone();
1531 move |_: &mut ()| navigator(&ctrl, || sized(10.0, 10.0))
1532 };
1533 let mut state = ();
1534 root.rebuild(&mut app, &mut state);
1537
1538 router.go("/home");
1539 root.rebuild(&mut app, &mut state);
1540 assert_eq!(
1541 controller.route_stack().entries(),
1542 &[Some(Location::parse("/home"))],
1543 "Router::go stamps the resolved location via ReplaceOptions::route"
1544 );
1545
1546 router.push("/detail");
1547 root.rebuild(&mut app, &mut state);
1548 assert_eq!(
1549 controller.route_stack().entries(),
1550 &[
1551 Some(Location::parse("/home")),
1552 Some(Location::parse("/detail"))
1553 ],
1554 "Router::push stamps the resolved location via PushOptions::route"
1555 );
1556 assert_eq!(controller.route_stack().change(), NavChange::Push);
1557
1558 router.replace("/other");
1559 root.rebuild(&mut app, &mut state);
1560 assert_eq!(
1561 controller.route_stack().entries(),
1562 &[
1563 Some(Location::parse("/home")),
1564 Some(Location::parse("/other"))
1565 ],
1566 "Router::replace stamps the resolved location via ReplaceOptions::route"
1567 );
1568 assert_eq!(controller.route_stack().change(), NavChange::Replace);
1569 }
1570
1571 type Build = Box<dyn FnMut(&mut ()) -> NavigatorView<()>>;
1576
1577 struct PumpHarness {
1581 router: Router<()>,
1582 root: RenderRoot<(), NavigatorView<()>>,
1583 app: Build,
1584 state: (),
1585 }
1586
1587 impl PumpHarness {
1588 fn new(routes: Vec<Route<()>>) -> Self {
1589 let controller: NavigatorController<()> = NavigatorController::new();
1590 let router =
1593 Router::with_controller(&controller, routes).error_builder(|_| sized(99.0, 99.0));
1594 let app: Build =
1595 Box::new(move |_: &mut ()| navigator(&controller, || sized(10.0, 10.0)));
1596 let mut harness = PumpHarness {
1597 router,
1598 root: RenderRoot::new(),
1599 app,
1600 state: (),
1601 };
1602 harness.rebuild();
1603 harness
1604 }
1605
1606 fn rebuild(&mut self) {
1607 self.root.rebuild(&mut self.app, &mut self.state);
1608 }
1609
1610 fn frame(&mut self) -> Vec<(Point, Size)> {
1613 self.router.pump();
1614 self.rebuild();
1615 drive_paint(&mut self.root)
1616 }
1617 }
1618
1619 fn pump_routes() -> Vec<Route<()>> {
1620 vec![
1621 Route::new("/home", |_| sized(10.0, 10.0)),
1622 Route::new("/detail", |_| sized(20.0, 20.0)),
1623 Route::new("/other", |_| sized(30.0, 30.0)),
1624 ]
1625 }
1626
1627 #[test]
1628 fn pump_applies_a_queued_request_on_the_next_frame() {
1629 let mut h = PumpHarness::new(pump_routes());
1630 let nav = h.router.route_navigator();
1631 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1632
1633 nav.push("/detail");
1636 assert_eq!(
1637 h.frame(),
1638 vec![(Point::ZERO, Size::new(20.0, 20.0))],
1639 "a request queued between frames lands on the very next one"
1640 );
1641
1642 nav.pop();
1643 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1644 }
1645
1646 #[test]
1647 fn pump_applies_every_request_variant_in_order() {
1648 let mut h = PumpHarness::new(pump_routes());
1649 let nav = h.router.route_navigator();
1650
1651 nav.push("/detail");
1652 nav.replace("/other");
1653 assert_eq!(
1654 h.frame(),
1655 vec![(Point::ZERO, Size::new(30.0, 30.0))],
1656 "push then replace leaves /other on top, not /detail"
1657 );
1658 nav.pop();
1660 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1661
1662 nav.go("/detail");
1663 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1664 }
1665
1666 #[test]
1667 fn pump_applies_named_requests_with_their_params() {
1668 let seen: Rc<RefCell<RouteParams>> = Rc::new(RefCell::new(RouteParams::new()));
1669 let recorder = seen.clone();
1670 let mut h = PumpHarness::new(vec![
1671 Route::new("/home", |_| sized(10.0, 10.0)),
1672 Route::new("/users/:id", move |p| {
1673 *recorder.borrow_mut() = p.clone();
1674 sized(20.0, 20.0)
1675 })
1676 .name("user"),
1677 ]);
1678 let nav = h.router.route_navigator();
1679
1680 nav.push_named("user", params(&[("id", "42"), ("tab", "posts")]));
1681 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1682 assert_eq!(seen.borrow().get("id").map(String::as_str), Some("42"));
1685 assert_eq!(seen.borrow().get("tab").map(String::as_str), Some("posts"));
1686 }
1687
1688 #[test]
1689 fn pump_is_a_no_op_when_the_queue_is_empty() {
1690 let mut h = PumpHarness::new(pump_routes());
1691 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1692 for _ in 0..3 {
1695 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(10.0, 10.0))]);
1696 }
1697 }
1698
1699 #[test]
1700 fn a_request_from_another_thread_pumps_normally() {
1701 let mut h = PumpHarness::new(pump_routes());
1704 let nav = h.router.route_navigator();
1705 let woke = Arc::new(AtomicUsize::new(0));
1706 let counter = woke.clone();
1707 nav.set_waker(Arc::new(move || {
1708 counter.fetch_add(1, Ordering::SeqCst);
1709 }));
1710
1711 std::thread::spawn(move || nav.push("/detail"))
1712 .join()
1713 .expect("off-thread request must not panic");
1714
1715 assert_eq!(
1716 woke.load(Ordering::SeqCst),
1717 1,
1718 "the waker asked for a frame"
1719 );
1720 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(20.0, 20.0))]);
1721 }
1722
1723 #[test]
1724 fn route_navigator_publishes_the_resolved_location() {
1725 let mut h = PumpHarness::new(pump_routes());
1726 let nav = h.router.route_navigator();
1727 assert!(nav.location().is_none(), "nothing resolved yet");
1728
1729 nav.push("/detail?tab=posts");
1730 let _ = h.frame();
1731 let loc = nav.location().expect("published after the pump");
1732 assert_eq!(loc.path, "/detail");
1733 assert_eq!(loc.query.get("tab").map(String::as_str), Some("posts"));
1734
1735 nav.go("/nope");
1737 let _ = h.frame();
1738 assert_eq!(nav.location().expect("published").path, "/nope");
1739 }
1740
1741 #[test]
1742 fn route_navigator_clones_share_one_queue() {
1743 let mut h = PumpHarness::new(pump_routes());
1746 let first = h.router.route_navigator();
1747 let second = h.router.route_navigator();
1748 first.push("/detail");
1749 second.replace("/other");
1750 assert_eq!(h.frame(), vec![(Point::ZERO, Size::new(30.0, 30.0))]);
1751 }
1752
1753 struct BuildCounter {
1760 builds: Rc<Cell<u32>>,
1761 }
1762 struct BuildCounterWidget;
1763 impl<S: 'static> View<S> for BuildCounter {
1764 type Element = BuildCounterWidget;
1765 fn build(&self, _ctx: &mut BuildCtx<'_>) -> BuildCounterWidget {
1766 self.builds.set(self.builds.get() + 1);
1767 BuildCounterWidget
1768 }
1769 fn rebuild(
1770 &self,
1771 _prev: &Self,
1772 _element: &mut BuildCounterWidget,
1773 _ctx: &mut BuildCtx<'_>,
1774 ) -> ChangeFlags {
1775 ChangeFlags::NONE
1776 }
1777 }
1778 impl Widget for BuildCounterWidget {
1779 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1780 bc.constrain(Size::ZERO)
1781 }
1782 fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut dyn PaintScene) {}
1783 }
1784
1785 struct ShellHarness {
1789 router: Router<()>,
1790 outer: NavigatorController<()>,
1791 inner: NavigatorController<()>,
1792 root: RenderRoot<(), NavigatorView<()>>,
1793 app: Build,
1794 shell_builds: Rc<Cell<u32>>,
1796 }
1797
1798 impl ShellHarness {
1799 fn new() -> Self {
1800 let outer: NavigatorController<()> = NavigatorController::new();
1801 let inner: NavigatorController<()> = NavigatorController::new();
1802 let shell_builds = Rc::new(Cell::new(0u32));
1803
1804 let shell_page = {
1805 let inner = inner.clone();
1806 let builds = shell_builds.clone();
1807 move |_: &RouteParams| {
1808 let inner = inner.clone();
1809 let builds = builds.clone();
1810 any(crate::column()
1811 .child(BuildCounter { builds })
1812 .child(navigator(&inner, || sized(11.0, 11.0))))
1813 }
1814 };
1815 let routes = vec![
1816 Route::new("/", |_| sized(10.0, 10.0)),
1817 shell_route(
1818 &inner,
1819 shell_page,
1820 vec![
1821 Route::new("/sessions", |_| sized(20.0, 20.0)),
1822 Route::new("/terminal", |_| sized(30.0, 30.0)),
1823 ],
1824 ),
1825 ];
1826 let router =
1827 Router::with_controller(&outer, routes).error_builder(|_| sized(99.0, 99.0));
1828 let app: Build = {
1829 let c = outer.clone();
1830 Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
1831 };
1832 let mut harness = ShellHarness {
1833 router,
1834 outer,
1835 inner,
1836 root: RenderRoot::new(),
1837 app,
1838 shell_builds,
1839 };
1840 harness.rebuild();
1841 harness
1842 }
1843
1844 fn rebuild(&mut self) {
1845 self.root.rebuild(&mut self.app, &mut ());
1846 }
1847
1848 fn stacks(&self) -> (Vec<String>, Vec<String>) {
1850 (paths(&self.outer), paths(&self.inner))
1851 }
1852 }
1853
1854 fn paths(controller: &NavigatorController<()>) -> Vec<String> {
1857 controller
1858 .route_stack()
1859 .entries()
1860 .iter()
1861 .map(|entry| match entry {
1862 Some(location) => location.path.clone(),
1863 None => "-".to_string(),
1864 })
1865 .collect()
1866 }
1867
1868 #[test]
1869 fn a_shell_chain_splits_the_outer_page_from_the_inner_leaf() {
1870 let mut h = ShellHarness::new();
1871 h.router.go("/terminal");
1872 h.rebuild();
1873
1874 let (outer, inner) = h.stacks();
1875 assert_eq!(
1876 outer,
1877 vec!["/terminal".to_string()],
1878 "the outer controller got the SHELL page (stamped with the location \
1879 that placed it), never the leaf"
1880 );
1881 assert_eq!(
1882 inner,
1883 vec!["/terminal".to_string()],
1884 "the leaf landed on the shell's inner controller"
1885 );
1886 assert_eq!(h.inner.depth(), 1, "the inner leaf replaced its root");
1887 assert!(h.inner.is_mounted(), "the shell page mounted its navigator");
1888 assert_eq!(h.shell_builds.get(), 1, "the shell page was built once");
1889 }
1890
1891 #[test]
1892 fn a_sibling_navigation_inside_the_shell_issues_zero_outer_ops() {
1893 let mut h = ShellHarness::new();
1897 h.router.go("/sessions");
1898 h.rebuild();
1899 let outer_generation = h.outer.route_generation();
1900 assert_eq!(h.shell_builds.get(), 1);
1901
1902 h.router.go("/terminal");
1903 h.rebuild();
1904
1905 assert_eq!(
1906 h.outer.route_generation(),
1907 outer_generation,
1908 "no outer op ran at all — an unchanged stack publishes nothing"
1909 );
1910 assert_eq!(
1911 h.shell_builds.get(),
1912 1,
1913 "the shell page's widget was retained, not rebuilt"
1914 );
1915 assert_eq!(
1916 h.stacks().1,
1917 vec!["/terminal".to_string()],
1918 "only the inner navigator moved"
1919 );
1920 assert_eq!(
1921 h.stacks().0,
1922 vec!["/sessions".to_string()],
1923 "the outer entry keeps naming the location that PLACED the shell — \
1924 live in-shell state is read from the inner navigator"
1925 );
1926 }
1927
1928 #[test]
1929 fn a_push_inside_a_placed_shell_stacks_on_the_inner_navigator() {
1930 let mut h = ShellHarness::new();
1931 h.router.go("/sessions");
1932 h.rebuild();
1933
1934 h.router.push("/terminal");
1935 h.rebuild();
1936 assert_eq!(h.outer.depth(), 1, "the outer stack never moved");
1937 assert_eq!(
1938 h.stacks().1,
1939 vec!["/sessions".to_string(), "/terminal".to_string()],
1940 "the leaf stacked inside the shell"
1941 );
1942 assert_eq!(h.shell_builds.get(), 1);
1943 }
1944
1945 #[test]
1946 fn a_push_that_must_place_the_shell_retains_the_page_below_and_resets_the_inner() {
1947 let mut h = ShellHarness::new();
1952 h.router.go("/");
1953 h.rebuild();
1954 assert_eq!(h.stacks().0, vec!["/".to_string()]);
1955
1956 h.router.push("/sessions");
1957 h.rebuild();
1958 assert_eq!(
1959 h.stacks().0,
1960 vec!["/".to_string(), "/sessions".to_string()],
1961 "the shell page stacked over the root page, which is retained"
1962 );
1963 assert_eq!(
1964 h.inner.depth(),
1965 1,
1966 "the fresh inner navigator is at its root"
1967 );
1968 assert_eq!(h.stacks().1, vec!["/sessions".to_string()]);
1969 }
1970
1971 #[test]
1972 fn a_page_pushed_over_the_shell_still_counts_as_placed() {
1973 let mut h = ShellHarness::new();
1977 h.router.go("/sessions");
1978 h.rebuild();
1979 h.outer.push(|| sized(77.0, 77.0));
1980 h.rebuild();
1981 assert_eq!(h.outer.depth(), 2);
1982
1983 h.router.go("/terminal");
1984 h.rebuild();
1985 assert_eq!(h.outer.depth(), 2, "no second shell page was placed");
1986 assert_eq!(h.shell_builds.get(), 1);
1987 assert_eq!(h.stacks().1, vec!["/terminal".to_string()]);
1988 }
1989
1990 #[test]
1991 fn two_shell_navigations_in_one_frame_place_the_shell_page_once() {
1992 let mut h = ShellHarness::new();
1996 h.router.go("/");
1997 h.rebuild();
1998
1999 h.router.push("/sessions");
2000 h.router.push("/terminal");
2001 h.rebuild();
2002
2003 assert_eq!(
2004 h.stacks().0,
2005 vec!["/".to_string(), "/sessions".to_string()],
2006 "exactly one shell page, placed by the first navigation"
2007 );
2008 assert_eq!(
2009 h.stacks().1,
2010 vec!["/sessions".to_string(), "/terminal".to_string()],
2011 "both in-shell ops landed on the one inner navigator"
2012 );
2013 assert_eq!(h.shell_builds.get(), 1);
2014 }
2015
2016 #[test]
2017 fn the_root_route_beats_a_pathless_shell_for_the_empty_location() {
2018 let mut h = ShellHarness::new();
2021 h.router.go("/");
2022 h.rebuild();
2023 assert_eq!(h.stacks().0, vec!["/".to_string()]);
2024 assert!(
2025 !h.inner.is_mounted(),
2026 "the shell was never placed, so its navigator never mounted"
2027 );
2028 }
2029
2030 #[test]
2031 fn a_redirect_out_of_the_shell_splits_the_post_redirect_chain() {
2032 let outer: NavigatorController<()> = NavigatorController::new();
2035 let inner: NavigatorController<()> = NavigatorController::new();
2036 let router = {
2037 let inner_for_shell = inner.clone();
2038 Router::with_controller(
2039 &outer,
2040 vec![
2041 Route::new("/", |_| sized(10.0, 10.0)),
2042 shell_route(
2043 &inner,
2044 move |_| {
2045 let inner = inner_for_shell.clone();
2046 any(navigator(&inner, || sized(11.0, 11.0)))
2047 },
2048 vec![
2049 Route::new("/sessions", |_| sized(20.0, 20.0))
2050 .redirect(|_| Some("/".to_string())),
2051 ],
2052 ),
2053 ],
2054 )
2055 };
2056 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
2057 let mut app: Build = {
2058 let c = outer.clone();
2059 Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
2060 };
2061 root.rebuild(&mut app, &mut ());
2062
2063 router.go("/sessions");
2064 root.rebuild(&mut app, &mut ());
2065 assert_eq!(paths(&outer), vec!["/".to_string()], "the redirect won");
2066 assert!(!inner.is_mounted(), "no shell page, no inner navigator");
2067 }
2068
2069 #[test]
2070 fn a_non_shell_chain_still_flattens_onto_one_controller() {
2071 let controller: NavigatorController<()> = NavigatorController::new();
2074 let router: Router<()> = Router::with_controller(
2075 &controller,
2076 vec![
2077 Route::new("/home", |_| sized(10.0, 10.0)),
2078 Route::new("/users", |_| sized(20.0, 20.0))
2079 .child(Route::new(":id", |_| sized(30.0, 30.0))),
2080 ],
2081 );
2082 let mut root: RenderRoot<(), NavigatorView<()>> = RenderRoot::new();
2083 let mut app: Build = {
2084 let c = controller.clone();
2085 Box::new(move |_: &mut ()| navigator(&c, || sized(10.0, 10.0)))
2086 };
2087 root.rebuild(&mut app, &mut ());
2088
2089 router.go("/users/42");
2090 root.rebuild(&mut app, &mut ());
2091 assert_eq!(
2092 paths(&controller),
2093 vec!["/users/42".to_string(), "/users/42".to_string()],
2094 "go replaced the top with the chain root and pushed the rest — both \
2095 pages on the one controller"
2096 );
2097
2098 router.push("/users/7");
2099 root.rebuild(&mut app, &mut ());
2100 assert_eq!(
2101 controller.depth(),
2102 3,
2103 "push stacked the leaf alone, unchanged"
2104 );
2105
2106 router.replace("/home");
2107 root.rebuild(&mut app, &mut ());
2108 assert_eq!(
2109 paths(&controller).last().map(String::as_str),
2110 Some("/home"),
2111 "replace swapped the top alone, unchanged"
2112 );
2113 assert_eq!(controller.depth(), 3);
2114 }
2115}