1use std::rc::Rc;
24
25use frus_widgets::host::{self, Effect};
26use frus_widgets::{
27 BuildContext, Callback, Component, GoRouter, Locale, Localizations, StatefulWidget,
28 StatelessWidget, Theme, ThemeMode, Widget,
29};
30
31use crate::application::{Application, LocationStrategy};
32use crate::command::Command;
33use crate::subscription::Subscription;
34
35type Save = Box<dyn Fn() -> Option<Vec<u8>>>;
37type Restore = Box<dyn Fn(&[u8])>;
38
39type Root = Rc<dyn Fn(&BuildContext) -> Box<dyn Widget>>;
41
42pub struct FrusApp {
60 root: Root,
61 router: Option<GoRouter>,
62 window_size: Option<(f32, f32)>,
63 on_start: Option<Box<dyn FnOnce()>>,
64 save: Option<Save>,
65 restore: Option<Restore>,
66 strategy: LocationStrategy,
67 instance: Option<String>,
68}
69
70impl FrusApp {
71 pub fn new(root: impl StatelessWidget) -> Self {
73 Self::with_root(Rc::new(move |cx| root.build(cx)))
74 }
75
76 pub fn from_fn(root: impl Fn(&BuildContext) -> Box<dyn Widget> + 'static) -> Self {
79 Self::with_root(Rc::new(root))
80 }
81
82 pub fn stateful<W: StatefulWidget + Clone>(root: W) -> Self {
85 Self::with_root(Rc::new(move |_| {
86 Box::new(Component::stateful(root.clone()))
87 }))
88 }
89
90 pub fn router(router: GoRouter) -> Self {
105 let building = router.clone();
106 let mut app = Self::with_root(Rc::new(move |cx| building.build(cx)));
107 app.router = Some(router);
108 app
109 }
110
111 fn with_root(root: Root) -> Self {
112 let app = host::app();
114 app.set_title("frus".to_string());
115 app.set_theme(Theme::light());
116 app.set_dark_theme(None);
117 app.set_theme_mode(ThemeMode::System);
118 app.set_density(1.0);
119 app.set_locale(None);
120 app.set_supported_locales(Vec::new());
121 app.set_localizations(None);
122 Self {
123 root,
124 router: None,
125 window_size: None,
126 on_start: None,
127 save: None,
128 restore: None,
129 strategy: LocationStrategy::Hash,
130 instance: None,
131 }
132 }
133
134 pub fn single_instance(mut self, id: impl Into<String>) -> Self {
139 self.instance = Some(id.into());
140 self
141 }
142
143 pub fn location_strategy(mut self, strategy: LocationStrategy) -> Self {
148 self.strategy = strategy;
149 self
150 }
151
152 pub fn title(self, title: impl Into<String>) -> Self {
154 host::app().set_title(title.into());
155 self
156 }
157
158 pub fn theme(self, theme: Theme) -> Self {
160 host::app().set_theme(theme);
161 self
162 }
163
164 pub fn dark_theme(self, theme: Theme) -> Self {
167 host::app().set_dark_theme(Some(theme));
168 self
169 }
170
171 pub fn theme_mode(self, mode: ThemeMode) -> Self {
173 host::app().set_theme_mode(mode);
174 self
175 }
176
177 pub fn supported_locales(self, locales: Vec<Locale>) -> Self {
180 host::app().set_supported_locales(locales);
181 self
182 }
183
184 pub fn localizations(self, table: std::rc::Rc<dyn Localizations>) -> Self {
186 host::app().set_localizations(Some(table));
187 self
188 }
189
190 pub fn on_start(mut self, start: impl FnOnce() + 'static) -> Self {
193 self.on_start = Some(Box::new(start));
194 self
195 }
196
197 pub fn persist(
200 mut self,
201 save: impl Fn() -> Option<Vec<u8>> + 'static,
202 restore: impl Fn(&[u8]) + 'static,
203 ) -> Self {
204 self.save = Some(Box::new(save));
205 self.restore = Some(Box::new(restore));
206 self
207 }
208
209 pub fn window_size(mut self, width: f32, height: f32) -> Self {
211 self.window_size = Some((width, height));
212 self
213 }
214
215 pub fn density(self, density: f32) -> Self {
217 host::app().set_density(density);
218 self
219 }
220}
221
222impl Application for FrusApp {
223 type Message = Callback;
224
225 fn update(&mut self, callback: Callback) -> Command<Callback> {
226 callback.call();
227 Command::none()
228 }
229
230 fn init(&mut self) -> Command<Callback> {
231 if let Some(start) = self.on_start.take() {
232 start();
233 }
234 self.effects()
235 }
236
237 fn effects(&mut self) -> Command<Callback> {
238 Command::batch(host::take_effects().into_iter().map(|effect| match effect {
239 Effect::Focus(key) => Command::focus_hashed(key),
240 Effect::Scroll(key, to) => Command::scroll_hashed(key, to),
241 Effect::Sheet(key, to) => Command::sheet_hashed(key, to),
242 Effect::Spawn(task) => Command::run(task),
243 Effect::After(delay, callback) => Command::after(delay, callback),
244 }))
245 }
246
247 fn save_state(&self) -> Option<Vec<u8>> {
248 self.save.as_ref().and_then(|save| save())
249 }
250
251 fn restore_state(&mut self, bytes: &[u8]) {
252 if let Some(restore) = &self.restore {
253 restore(bytes);
254 }
255 }
256
257 fn view(&self, _theme: &Theme) -> Box<dyn Widget<Callback>> {
258 let root = self.root.clone();
259 Box::new(Component::stateless(move |cx: &BuildContext| root(cx)))
260 }
261
262 fn subscription(&self) -> Subscription<Callback> {
263 Subscription::batch(frus_widgets::intervals().into_iter().map(|interval| {
266 let callback = interval.callback;
267 Subscription::every_keyed(interval.id, interval.period, move |_| callback.clone())
268 }))
269 }
270
271 fn tick(&mut self, dt: f32) -> bool {
272 self.router.as_ref().is_some_and(|router| router.tick(dt))
273 }
274
275 fn location(&self) -> Option<String> {
276 self.router.as_ref().map(GoRouter::location)
277 }
278
279 fn location_replaces(&self) -> bool {
280 self.router.as_ref().is_some_and(GoRouter::replaced)
281 }
282
283 fn location_stack(&self) -> Vec<String> {
284 self.router
285 .as_ref()
286 .map(GoRouter::locations)
287 .unwrap_or_default()
288 }
289
290 fn open_location(&mut self, location: &str) -> Command<Callback> {
291 if let Some(router) = &self.router {
292 router.open(location);
293 }
294 Command::none()
295 }
296
297 fn location_strategy(&self) -> LocationStrategy {
298 self.strategy
299 }
300
301 fn instance_id(&self) -> Option<String> {
302 self.instance.clone()
303 }
304
305 fn can_go_back(&self) -> bool {
306 !frus_widgets::back_blocked()
308 && self
309 .router
310 .as_ref()
311 .is_some_and(|router| router.can_go_back())
312 }
313
314 fn back_gesture(&mut self, progress: f32) {
315 if let Some(router) = &self.router {
316 router.back_gesture(progress);
317 }
318 }
319
320 fn back_gesture_end(&mut self, velocity: f32) {
321 if let Some(router) = &self.router {
322 router.back_gesture_end(velocity);
323 }
324 }
325
326 fn theme(&self) -> Theme {
327 host::app().theme()
328 }
329
330 fn dark_theme(&self) -> Option<Theme> {
331 host::app().dark_theme()
332 }
333
334 fn theme_mode(&self) -> ThemeMode {
335 host::app().theme_mode()
336 }
337
338 fn supported_locales(&self) -> Vec<Locale> {
339 host::app().supported_locales()
340 }
341
342 fn locale(&self) -> Option<Locale> {
343 host::app().locale()
344 }
345
346 fn localizations(&self) -> Option<std::rc::Rc<dyn Localizations>> {
347 host::app().localizations()
348 }
349
350 fn title(&self) -> String {
351 host::app().title()
352 }
353
354 fn window_size(&self) -> Option<(f32, f32)> {
355 self.window_size
356 }
357
358 fn density(&self) -> f32 {
359 host::app().density()
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use crate::app::testing::Driver;
367 use frus_widgets::{Container, Point, State, StateContext};
368 use std::cell::RefCell;
369
370 const SIDE: f32 = 200.0;
371 type Seen = Rc<RefCell<Vec<i32>>>;
372
373 fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
375 Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
376 }
377
378 fn tap(driver: &mut Driver<FrusApp>) {
379 let at = Point::new(SIDE / 2.0, SIDE / 2.0);
380 driver.press(at);
381 driver.release(at);
382 driver.frame(1.0 / 60.0);
383 }
384
385 #[test]
386 fn a_hook_survives_a_tap_and_the_view_is_built_again_with_the_new_value() {
387 let seen: Seen = Rc::default();
388 let probe = seen.clone();
389 let mut driver = Driver::new(
390 FrusApp::from_fn(move |cx| {
391 let count = cx.use_state(|| 0);
392 probe.borrow_mut().push(count.get());
393 let add = count.clone();
394 target(move || add.update(|n| *n += 1))
395 }),
396 SIDE,
397 SIDE,
398 );
399 driver.frame(1.0 / 60.0);
400 assert_eq!(*seen.borrow(), [0], "built once to begin with");
401 tap(&mut driver);
402 tap(&mut driver);
403 tap(&mut driver);
404 assert_eq!(
405 seen.borrow().last(),
406 Some(&3),
407 "three taps, three changes, each kept: {:?}",
408 seen.borrow()
409 );
410 }
411
412 #[derive(Clone)]
413 struct Counter {
414 seen: Seen,
415 }
416
417 struct CounterState {
418 count: i32,
419 seen: Seen,
420 }
421
422 impl StatefulWidget for Counter {
423 type State = CounterState;
424 fn create_state(&self) -> CounterState {
425 CounterState {
426 count: 100,
427 seen: self.seen.clone(),
428 }
429 }
430 }
431
432 impl State for CounterState {
433 type Widget = Counter;
434 fn build(&self, cx: &StateContext<Self>) -> Box<dyn Widget> {
435 self.seen.borrow_mut().push(self.count);
436 target(cx.callback(|state| state.count += 1))
437 }
438 }
439
440 #[test]
441 fn a_stateful_root_keeps_its_state_between_taps() {
442 let seen: Seen = Rc::default();
443 let mut driver = Driver::new(
444 FrusApp::stateful(Counter { seen: seen.clone() }),
445 SIDE,
446 SIDE,
447 );
448 driver.frame(1.0 / 60.0);
449 tap(&mut driver);
450 tap(&mut driver);
451 assert_eq!(seen.borrow().last(), Some(&102), "{:?}", seen.borrow());
452 }
453
454 #[test]
455 fn a_value_handler_that_returns_nothing_runs_once_per_event() {
456 use frus_widgets::Checkbox;
457 let toggles: Rc<RefCell<Vec<bool>>> = Rc::default();
458 let log = toggles.clone();
459 let mut driver = Driver::new(
460 FrusApp::from_fn(move |_| {
461 let log = log.clone();
462 Box::new(Checkbox::new(false).on_toggle(move |on| log.borrow_mut().push(on)))
463 }),
464 SIDE,
465 SIDE,
466 );
467 driver.frame(1.0 / 60.0);
468 let at = Point::new(12.0, 12.0);
470 driver.press(at);
471 driver.release(at);
472 driver.frame(1.0 / 60.0);
473 assert_eq!(*toggles.borrow(), [true], "one tap, one call");
474 }
475
476 #[test]
477 fn a_timer_a_component_asks_for_is_a_subscription_while_it_asks() {
478 let on = Rc::new(std::cell::Cell::new(true));
479 let wanted = on.clone();
480 let mut driver = Driver::new(
481 FrusApp::from_fn(move |cx| {
482 if wanted.get() {
483 cx.use_interval(std::time::Duration::from_secs(1), || {});
484 }
485 target(|| {})
486 }),
487 SIDE,
488 SIDE,
489 );
490 assert!(driver.app().subscription().is_empty(), "nothing built yet");
491 driver.frame(1.0 / 60.0);
492 assert_eq!(driver.app().subscription().ids().len(), 1);
493 on.set(false);
494 frus_widgets::request_rebuild();
495 driver.frame(1.0 / 60.0);
496 assert!(
497 driver.app().subscription().is_empty(),
498 "and it stops when it is not asked for"
499 );
500 }
501
502 #[test]
503 fn an_effect_runs_after_the_frame_and_a_change_it_makes_is_shown() {
504 let seen: Seen = Rc::default();
505 let probe = seen.clone();
506 let mut driver = Driver::new(
507 FrusApp::from_fn(move |cx| {
508 let count = cx.use_state(|| 0);
509 probe.borrow_mut().push(count.get());
510 let set = count.clone();
511 cx.use_effect((), move || set.set(7));
512 target(|| {})
513 }),
514 SIDE,
515 SIDE,
516 );
517 driver.frame(1.0 / 60.0);
518 driver.frame(1.0 / 60.0);
519 assert_eq!(
520 *seen.borrow(),
521 [0, 7],
522 "built with 0, the effect set 7, and the next frame built with it"
523 );
524 }
525}
526
527#[cfg(test)]
528mod router_tests {
529 use super::*;
530 use crate::app::testing::Driver;
531 use frus_widgets::{component, Container, GoRoute, Point};
532 use std::cell::RefCell;
533
534 const SIDE: f32 = 200.0;
535
536 fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
537 Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
538 }
539
540 fn tap(driver: &mut Driver<FrusApp>) {
541 let at = Point::new(SIDE / 2.0, SIDE / 2.0);
542 driver.press(at);
543 driver.release(at);
544 driver.frame(1.0 / 60.0);
545 }
546
547 fn app(seen: Rc<RefCell<Vec<i32>>>) -> (FrusApp, GoRouter) {
549 let router = GoRouter::new(vec![
550 GoRoute::new("/", move |_, _| {
551 let seen = seen.clone();
552 component(move |cx| {
553 let visits = cx.use_state(|| 0);
554 seen.borrow_mut().push(visits.get());
555 let router = cx.router();
556 target(move || {
557 visits.update(|n| *n += 1);
558 router.push("/second");
559 })
560 })
561 }),
562 GoRoute::new("/second", |cx, _| {
563 let router = cx.router();
564 target(move || {
565 router.pop();
566 })
567 }),
568 ]);
569 (FrusApp::router(router.clone()), router)
570 }
571
572 #[test]
573 fn a_tap_pushes_a_page_and_the_covered_page_keeps_what_its_components_hold() {
574 let seen = Rc::default();
575 let (app, router) = app(Rc::clone(&seen));
576 let mut driver = Driver::new(app, SIDE, SIDE);
577 driver.frame(1.0 / 60.0);
578 assert_eq!((router.location(), router.depth()), ("/".to_string(), 1));
579
580 tap(&mut driver);
581 driver.run(1.5);
582 assert_eq!(router.location(), "/second", "the tap pushed a page");
583 assert_eq!(router.depth(), 2);
584 assert_eq!(
585 seen.borrow().last(),
586 Some(&1),
587 "the page underneath is still built, holding what it held: {:?}",
588 seen.borrow()
589 );
590
591 tap(&mut driver);
592 driver.run(1.5);
593 assert_eq!(
594 router.location(),
595 "/",
596 "the tap on the second page went back"
597 );
598 assert_eq!(
599 seen.borrow().last(),
600 Some(&1),
601 "and the first page is shown again with its state, not made anew"
602 );
603 }
604
605 #[test]
606 fn a_back_gesture_carries_the_top_page_away() {
607 let (app, router) = app(Rc::default());
608 let mut driver = Driver::new(app, SIDE, SIDE);
609 driver.frame(1.0 / 60.0);
610 router.push("/second");
611 driver.run(1.5);
612 assert!(FrusApp::router(router.clone()).can_go_back());
613
614 router.back_gesture(0.9);
615 driver.frame(1.0 / 60.0);
616 router.back_gesture_end(0.0);
617 driver.run(1.5);
618 assert_eq!((router.location(), router.depth()), ("/".to_string(), 1));
619 }
620
621 #[test]
622 fn a_popped_page_is_disposed_once_its_transition_is_over() {
623 let disposed = Rc::new(std::cell::Cell::new(0));
624 let counted = disposed.clone();
625 let router = GoRouter::new(vec![
626 GoRoute::new("/", |_, _| Container::new().width(SIDE).height(SIDE)),
627 GoRoute::new("/gone", move |_, _| {
628 let counted = counted.clone();
629 component(move |cx| {
630 let counted = counted.clone();
631 cx.use_effect((), move || move || counted.set(counted.get() + 1));
632 Box::new(Container::new().width(SIDE).height(SIDE)) as Box<dyn Widget>
633 })
634 }),
635 ]);
636 let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
637 driver.frame(1.0 / 60.0);
638 router.push("/gone");
639 driver.run(1.5);
640 assert_eq!(disposed.get(), 0, "on the stack, it is alive");
641 router.pop();
642 driver.run(1.5);
643 assert_eq!(disposed.get(), 1, "popped and gone, its cleanup has run");
644 }
645}
646
647#[cfg(test)]
648mod host_tests {
649 use super::*;
650 use crate::app::testing::Driver;
651 use frus_widgets::{component, Container, GoRoute, ScrollTo};
652 use std::cell::{Cell, RefCell};
653
654 const SIDE: f32 = 200.0;
655
656 fn target(on_tap: impl Into<Callback>) -> Box<dyn Widget> {
657 Box::new(Container::new().width(SIDE).height(SIDE).on_click(on_tap))
658 }
659
660 fn quiet() -> FrusApp {
661 FrusApp::from_fn(|_| target(|| {}))
662 }
663
664 #[test]
665 fn the_builders_and_a_component_write_to_the_same_settings_the_shell_reads() {
666 let app = quiet()
667 .title("first")
668 .theme_mode(ThemeMode::Light)
669 .density(1.1);
670 assert_eq!(Application::title(&app), "first");
671 assert_eq!(Application::theme_mode(&app), ThemeMode::Light);
672 assert_eq!(Application::density(&app), 1.1);
673 host::app().set_title("second".to_string());
675 host::app().set_theme_mode(ThemeMode::Dark);
676 host::app().set_dark_theme(Some(Theme::dark()));
677 assert_eq!(Application::title(&app), "second");
678 assert_eq!(Application::theme_mode(&app), ThemeMode::Dark);
679 assert!(Application::dark_theme(&app).is_some());
680 }
681
682 #[test]
683 fn a_new_application_starts_from_the_defaults_not_from_the_last_one() {
684 host::app().set_theme_mode(ThemeMode::Dark);
685 host::app().set_density(1.4);
686 host::app().set_locale(Some(Locale::new("fr")));
687 let app = quiet();
688 assert_eq!(Application::theme_mode(&app), ThemeMode::System);
689 assert_eq!(Application::density(&app), 1.0);
690 assert_eq!(Application::locale(&app), None);
691 }
692
693 #[test]
694 fn the_language_a_component_picks_is_the_one_the_application_answers() {
695 let app = quiet().supported_locales(vec![Locale::new("en"), Locale::new("fr")]);
696 assert_eq!(Application::supported_locales(&app).len(), 2);
697 host::app().set_locale(Some(Locale::new("fr")));
698 assert_eq!(Application::locale(&app), Some(Locale::new("fr")));
699 }
700
701 #[test]
702 fn what_a_component_asks_of_the_shell_becomes_a_command_once() {
703 let mut app = quiet();
704 let _ = host::take_effects();
705 assert!(app.effects().is_empty(), "nothing asked, nothing to run");
706 host::focus("name");
707 host::scroll_to("list", ScrollTo::start());
708 host::after(std::time::Duration::from_secs(1), || {});
709 host::spawn(|| 1 + 1, |_| {});
710 assert!(!app.effects().is_empty());
711 assert!(app.effects().is_empty(), "and taken once");
712 }
713
714 #[test]
715 fn on_start_runs_once_before_the_first_frame() {
716 let started = Rc::new(Cell::new(0));
717 let count = started.clone();
718 let mut app = quiet().on_start(move || count.set(count.get() + 1));
719 let _ = app.init();
720 let _ = app.init();
721 assert_eq!(started.get(), 1);
722 }
723
724 #[test]
725 fn a_state_can_be_kept_across_a_live_reload() {
726 let held = Rc::new(RefCell::new(0u8));
727 let (read, write) = (held.clone(), held.clone());
728 let mut app = quiet().persist(
729 move || Some(vec![*read.borrow()]),
730 move |bytes| *write.borrow_mut() = bytes[0],
731 );
732 *held.borrow_mut() = 7;
733 let bytes = app.save_state().expect("something to keep");
734 *held.borrow_mut() = 0;
735 app.restore_state(&bytes);
736 assert_eq!(*held.borrow(), 7);
737 assert!(quiet().save_state().is_none(), "nothing kept unless asked");
738 }
739
740 #[test]
741 fn a_page_is_told_how_far_in_it_is_while_it_arrives() {
742 let seen = Rc::new(RefCell::new(Vec::<f32>::new()));
743 let log = seen.clone();
744 let router = GoRouter::new(vec![GoRoute::new("/", |_, _| target(|| {})).routes(vec![
745 GoRoute::new("second", move |_, state| {
746 log.borrow_mut().push(state.entering());
747 target(|| {})
748 }),
749 ])]);
750 let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
751 driver.frame(1.0 / 60.0);
752 router.push("/second");
753 driver.run(1.5);
754 let seen = seen.borrow();
755 assert!(
756 seen.iter().any(|e| (0.0..1.0).contains(e)),
757 "it was built part of the way in: {seen:?}"
758 );
759 assert!(
760 seen.windows(2).all(|w| w[1] >= w[0] - 1e-3),
761 "and only ever further in: {seen:?}"
762 );
763 assert_eq!(seen.last(), Some(&1.0), "settled, it is all the way in");
764 }
765
766 #[test]
767 fn an_open_menu_takes_the_back_gesture_before_the_router_does() {
768 let open = Rc::new(Cell::new(false));
769 let asked = open.clone();
770 let router = GoRouter::new(vec![GoRoute::new("/", |_, _| target(|| {})).routes(vec![
771 GoRoute::new("second", move |_, _| {
772 let asked = asked.clone();
773 component(move |cx| {
774 cx.block_back(asked.get());
775 target(|| {})
776 })
777 }),
778 ])]);
779 let mut driver = Driver::new(FrusApp::router(router.clone()), SIDE, SIDE);
780 driver.frame(1.0 / 60.0);
781 assert!(
782 !driver.app().can_go_back(),
783 "nowhere to go back to from the first page"
784 );
785 router.push("/second");
786 driver.run(1.5);
787 assert!(driver.app().can_go_back(), "a page to go back to");
788 open.set(true);
789 frus_widgets::request_rebuild();
790 driver.frame(1.0 / 60.0);
791 assert!(
792 !driver.app().can_go_back(),
793 "the menu is open: back closes it, it does not leave the page"
794 );
795 open.set(false);
796 frus_widgets::request_rebuild();
797 driver.frame(1.0 / 60.0);
798 assert!(driver.app().can_go_back(), "and once it is shut, it does");
799 }
800}
801
802#[cfg(test)]
803mod address_tests {
804 use super::*;
805 use crate::history::{History, Step};
806 use frus_widgets::{text, GoRoute};
807
808 fn router() -> GoRouter {
809 GoRouter::new(vec![
810 GoRoute::new("/", |_, _| text("home"))
811 .routes(vec![GoRoute::new("users/:id", |_, _| text("user"))]),
812 GoRoute::new("/settings", |_, _| text("settings")),
813 ])
814 }
815
816 #[test]
817 fn an_application_of_pages_reports_the_page_it_is_on() {
818 let router = router();
819 let app = FrusApp::router(router.clone());
820 assert_eq!(app.location(), Some("/".to_string()));
821 router.push("/settings");
822 assert_eq!(app.location(), Some("/settings".to_string()));
823 }
824
825 #[test]
826 fn an_application_with_no_router_has_no_address() {
827 let mut app = FrusApp::from_fn(|_| Box::new(text("one screen")));
828 assert_eq!(app.location(), None);
829 let _ = app.open_location("/anywhere");
830 assert_eq!(app.location(), None, "nothing to move");
831 }
832
833 #[test]
834 fn an_address_opens_the_page_it_names_with_the_pages_before_it_underneath() {
835 let router = router();
836 let mut app = FrusApp::router(router.clone());
837 let _ = app.open_location("/users/42");
838 assert_eq!(router.location(), "/users/42");
839 assert_eq!(router.depth(), 2, "home is underneath, to go back to");
840 assert!(
841 !app.tick(1.0),
842 "opened at an address, nothing slides: there was no page to leave"
843 );
844 }
845
846 #[test]
847 fn the_same_address_twice_changes_nothing() {
848 let router = router();
849 let mut app = FrusApp::router(router.clone());
850 let _ = app.open_location("/settings");
851 let _ = app.open_location("/settings");
852 assert_eq!(router.depth(), 1);
853 }
854
855 #[test]
856 fn the_strategy_is_the_hash_unless_the_application_says_otherwise() {
857 let app = FrusApp::router(router());
858 assert_eq!(Application::location_strategy(&app), LocationStrategy::Hash);
859 let app = app.location_strategy(LocationStrategy::Path);
860 assert_eq!(Application::location_strategy(&app), LocationStrategy::Path);
861 }
862
863 #[test]
866 fn a_page_opened_deep_gets_the_pages_beneath_it_as_entries_so_its_own_back_goes_back() {
867 let router = router();
868 let mut app = FrusApp::router(router.clone());
869 let mut history = History::new();
870 history.opened(Some("/users/42".to_string()), None);
871 let _ = app.open_location("/users/42");
872 assert_eq!(app.location_stack(), ["/", "/users/42"]);
873 assert_eq!(
874 history.seed(&app.location_stack()),
875 [
876 (Step::Replace("/".to_string()), 0),
877 (Step::Push("/users/42".to_string()), 1)
878 ],
879 "the entry the page opened at becomes home, and the deep page is pushed on it"
880 );
881 assert_eq!(history.reflect(&app.location().unwrap(), false), None);
882
883 router.pop();
884 assert_eq!(router.location(), "/");
885 assert_eq!(
886 history.reflect(&app.location().unwrap(), false),
887 Some(Step::Go(-1)),
888 "back in the browser's list, not a copy of home on top of it"
889 );
890 }
891
892 #[test]
893 fn a_swapped_page_is_reported_as_swapped_and_only_until_the_next_move() {
894 let router = router();
895 let app = FrusApp::router(router.clone());
896 assert!(!app.location_replaces());
897 router.push("/settings");
898 assert!(!app.location_replaces());
899 router.replace("/users/1");
900 assert!(app.location_replaces(), "a swap: the entry is swapped too");
901 router.push("/settings");
902 assert!(!app.location_replaces());
903 router.replace("/users/2");
904 router.pop();
905 assert!(!app.location_replaces(), "a pop is not a swap");
906 }
907
908 #[test]
909 fn a_redirect_at_the_door_is_the_first_address() {
910 let router = GoRouter::new(vec![
911 GoRoute::new("/", |_, _| text("home")),
912 GoRoute::new("/login", |_, _| text("login")),
913 ])
914 .redirect(|state| (state.location() != "/login").then(|| "/login".to_string()));
915 let mut app = FrusApp::router(router);
916 let mut history = History::new();
917 history.opened(Some("/private".to_string()), None);
918 let _ = app.open_location("/private");
919 assert_eq!(app.location(), Some("/login".to_string()));
920 assert_eq!(
921 history.reflect("/login", false),
922 Some(Step::Replace("/login".to_string())),
923 "the address the page opened at is replaced, so back has no `/private` to bounce off"
924 );
925 }
926}