1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum UiInput {
45 Forward,
46 Select,
47 Backward,
48 Back,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum Level {
58 Top,
59 Settings,
60 Bluetooth,
61 Gnss,
62 Radio,
63}
64
65impl Level {
66 pub const ALL: [Level; 5] = [
68 Level::Top,
69 Level::Settings,
70 Level::Bluetooth,
71 Level::Gnss,
72 Level::Radio,
73 ];
74
75 pub const fn opened_by(self) -> Option<MenuItem> {
78 match self {
79 Level::Top => None,
80 Level::Settings => Some(MenuItem::Settings),
81 Level::Bluetooth => Some(MenuItem::Bluetooth),
82 Level::Gnss => Some(MenuItem::Gnss),
83 Level::Radio => Some(MenuItem::Radio),
84 }
85 }
86
87 pub const fn back(self) -> Option<MenuItem> {
90 match self {
91 Level::Top => None,
92 Level::Settings => Some(MenuItem::SettingsBack),
93 Level::Bluetooth => Some(MenuItem::BluetoothBack),
94 Level::Gnss => Some(MenuItem::GnssBack),
95 Level::Radio => Some(MenuItem::RadioBack),
96 }
97 }
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum ToggleId {
107 Bluetooth,
108 Gnss,
109 ShareLocation,
110 Forwarding,
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum EntryKind {
116 Reading(Option<UiEffect>),
121 Submenu(Level),
123 Toggle(ToggleId),
126 Action(UiEffect),
128 Destructive(UiEffect),
130 Back,
132}
133
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum MenuItem {
141 Status,
144 Identity,
146 Settings,
148
149 SettingsBack,
151 Bluetooth,
153 Gnss,
155 Radio,
157
158 BluetoothBack,
160 BluetoothToggle,
162 StartPairing,
164 ClearBonds,
166
167 GnssBack,
169 GnssToggle,
171 ShareLocation,
174
175 RadioBack,
177 Forwarding,
179 Stats,
182}
183
184impl MenuItem {
185 pub const ALL: [MenuItem; 17] = [
187 MenuItem::Status,
188 MenuItem::Identity,
189 MenuItem::Settings,
190 MenuItem::SettingsBack,
191 MenuItem::Bluetooth,
192 MenuItem::Gnss,
193 MenuItem::Radio,
194 MenuItem::BluetoothBack,
195 MenuItem::BluetoothToggle,
196 MenuItem::StartPairing,
197 MenuItem::ClearBonds,
198 MenuItem::GnssBack,
199 MenuItem::GnssToggle,
200 MenuItem::ShareLocation,
201 MenuItem::RadioBack,
202 MenuItem::Forwarding,
203 MenuItem::Stats,
204 ];
205
206 const fn bit(self) -> u32 {
207 1 << self as u32
208 }
209
210 const fn index(self) -> usize {
211 self as usize
212 }
213
214 pub const fn level(self) -> Level {
216 match self {
217 MenuItem::Status | MenuItem::Identity | MenuItem::Settings => Level::Top,
218 MenuItem::SettingsBack | MenuItem::Bluetooth | MenuItem::Gnss | MenuItem::Radio => {
219 Level::Settings
220 }
221 MenuItem::BluetoothBack
222 | MenuItem::BluetoothToggle
223 | MenuItem::StartPairing
224 | MenuItem::ClearBonds => Level::Bluetooth,
225 MenuItem::GnssBack | MenuItem::GnssToggle | MenuItem::ShareLocation => Level::Gnss,
226 MenuItem::RadioBack | MenuItem::Forwarding | MenuItem::Stats => Level::Radio,
227 }
228 }
229
230 pub const fn kind(self) -> EntryKind {
232 match self {
233 MenuItem::Status => EntryKind::Reading(Some(UiEffect::CheckIn)),
237 MenuItem::Identity | MenuItem::Stats => EntryKind::Reading(None),
238 MenuItem::Settings => EntryKind::Submenu(Level::Settings),
239 MenuItem::Bluetooth => EntryKind::Submenu(Level::Bluetooth),
240 MenuItem::Gnss => EntryKind::Submenu(Level::Gnss),
241 MenuItem::Radio => EntryKind::Submenu(Level::Radio),
242 MenuItem::SettingsBack
243 | MenuItem::BluetoothBack
244 | MenuItem::GnssBack
245 | MenuItem::RadioBack => EntryKind::Back,
246 MenuItem::BluetoothToggle => EntryKind::Toggle(ToggleId::Bluetooth),
247 MenuItem::GnssToggle => EntryKind::Toggle(ToggleId::Gnss),
248 MenuItem::ShareLocation => EntryKind::Toggle(ToggleId::ShareLocation),
249 MenuItem::Forwarding => EntryKind::Toggle(ToggleId::Forwarding),
250 MenuItem::StartPairing => EntryKind::Action(UiEffect::StartPairing),
251 MenuItem::ClearBonds => EntryKind::Destructive(UiEffect::ClearBonds),
252 }
253 }
254
255 const fn always_enabled(self) -> bool {
261 matches!(
262 self,
263 MenuItem::Status
264 | MenuItem::SettingsBack
265 | MenuItem::BluetoothBack
266 | MenuItem::GnssBack
267 | MenuItem::RadioBack
268 )
269 }
270
271 pub const fn reads_in_place(self) -> bool {
280 matches!(self.level(), Level::Top)
281 }
282
283 pub const fn requires_confirmation(self) -> bool {
289 matches!(self.kind(), EntryKind::Destructive(_))
290 }
291
292 pub const fn effect(self) -> Option<UiEffect> {
296 match self.kind() {
297 EntryKind::Action(effect) | EntryKind::Destructive(effect) => Some(effect),
298 EntryKind::Reading(effect) => effect,
299 EntryKind::Toggle(id) => Some(UiEffect::Toggle(id)),
300 EntryKind::Submenu(_) | EntryKind::Back => None,
301 }
302 }
303}
304
305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
310pub struct MenuItems(u32);
311
312impl MenuItems {
313 pub const fn new() -> Self {
315 Self(
316 MenuItem::Status.bit()
317 | MenuItem::SettingsBack.bit()
318 | MenuItem::BluetoothBack.bit()
319 | MenuItem::GnssBack.bit()
320 | MenuItem::RadioBack.bit(),
321 )
322 }
323
324 pub const fn all() -> Self {
326 let mut bits = 0u32;
327 let mut i = 0;
328 while i < MenuItem::ALL.len() {
329 bits |= MenuItem::ALL[i].bit();
330 i += 1;
331 }
332 Self(bits)
333 }
334
335 pub const fn with(self, item: MenuItem) -> Self {
337 Self(self.0 | item.bit())
338 }
339
340 pub const fn without(self, item: MenuItem) -> Self {
349 Self(self.0 & !item.bit())
350 }
351
352 pub const fn contains(self, item: MenuItem) -> bool {
353 item.always_enabled() || self.0 & item.bit() != 0
354 }
355
356 pub const fn len(self) -> u32 {
359 self.0.count_ones()
360 }
361
362 pub const fn is_empty(self) -> bool {
363 false
364 }
365
366 pub fn level_is_empty(self, level: Level) -> bool {
372 !MenuItem::ALL
373 .iter()
374 .any(|&item| item.level() == level && !item.is_back() && self.reachable(item))
375 }
376
377 fn reachable(self, item: MenuItem) -> bool {
380 if !self.contains(item) {
381 return false;
382 }
383 match item.kind() {
384 EntryKind::Submenu(level) => !self.level_is_empty(level),
385 _ => true,
386 }
387 }
388
389 pub fn entries(self, level: Level) -> impl Iterator<Item = MenuItem> {
394 MenuItem::ALL
395 .into_iter()
396 .filter(move |&item| item.level() == level && self.reachable(item))
397 }
398
399 pub fn first_after_back(self, level: Level) -> MenuItem {
407 MenuItem::ALL
408 .iter()
409 .copied()
410 .find(|&item| item.level() == level && !item.is_back() && self.reachable(item))
411 .or_else(|| level.back())
412 .unwrap_or(MenuItem::Status)
413 }
414
415 fn step(self, from: MenuItem, step: isize) -> MenuItem {
419 let level = from.level();
420 let n = MenuItem::ALL.len();
421 let mut index = from.index();
422 for _ in 0..n {
426 index = (index as isize + step).rem_euclid(n as isize) as usize;
427 let candidate = MenuItem::ALL[index];
428 if candidate.level() == level && self.reachable(candidate) {
429 return candidate;
430 }
431 }
432 from
433 }
434}
435
436impl MenuItem {
437 const fn is_back(self) -> bool {
438 matches!(self.kind(), EntryKind::Back)
439 }
440}
441
442impl Default for MenuItems {
443 fn default() -> Self {
444 Self::new()
445 }
446}
447
448#[derive(Clone, Copy, Debug, PartialEq, Eq)]
450pub enum Page {
451 Menu(MenuItem),
454 Detail(MenuItem),
458 Confirm {
461 item: MenuItem,
462 confirm_selected: bool,
463 },
464}
465
466#[derive(Clone, Copy, Debug, PartialEq, Eq)]
468pub enum UiEffect {
469 CheckIn,
470 StartPairing,
471 ClearBonds,
472 Toggle(ToggleId),
475}
476
477#[derive(Clone, Copy, Debug, PartialEq, Eq)]
479pub enum UiNotice {
480 CheckInRequested,
481 PairingStarted,
482 PairingUnavailable,
483 BondsCleared,
484 ClearFailed,
485 ToggleUnavailable,
487}
488
489#[derive(Clone, Copy, Debug, PartialEq, Eq)]
495pub struct UiModel {
496 items: MenuItems,
497 page: Page,
498 notice: Option<UiNotice>,
499}
500
501impl UiModel {
502 pub const fn new(items: MenuItems) -> Self {
503 Self {
504 items,
505 page: Page::Menu(MenuItem::Status),
506 notice: None,
507 }
508 }
509
510 pub const fn page(&self) -> Page {
511 self.page
512 }
513
514 pub const fn notice(&self) -> Option<UiNotice> {
515 self.notice
516 }
517
518 pub const fn items(&self) -> MenuItems {
519 self.items
520 }
521
522 pub const fn level(&self) -> Level {
524 match self.page {
525 Page::Menu(item) | Page::Detail(item) => item.level(),
526 Page::Confirm { item, .. } => item.level(),
527 }
528 }
529
530 pub const fn is_home(&self) -> bool {
537 matches!(self.page, Page::Menu(MenuItem::Status)) && self.notice.is_none()
538 }
539
540 pub fn set_notice(&mut self, notice: UiNotice) {
542 self.page = Page::Menu(MenuItem::Status);
543 self.notice = Some(notice);
544 }
545
546 pub fn clear_notice(&mut self) {
547 self.notice = None;
548 }
549
550 pub fn go_home(&mut self) {
557 self.page = Page::Menu(MenuItem::Status);
558 self.notice = None;
559 }
560
561 fn leave(&mut self, level: Level) {
573 self.page = Page::Menu(level.opened_by().unwrap_or(MenuItem::Status));
574 }
575
576 pub fn apply(&mut self, input: UiInput) -> Option<UiEffect> {
577 self.notice = None;
578 match (self.page, input) {
579 (Page::Menu(item), UiInput::Forward) => {
580 self.page = Page::Menu(self.items.step(item, 1));
581 None
582 }
583 (Page::Menu(item), UiInput::Backward) => {
584 self.page = Page::Menu(self.items.step(item, -1));
585 None
586 }
587 (Page::Menu(item), UiInput::Select) => match item.kind() {
588 EntryKind::Reading(effect) if item.reads_in_place() => effect,
592 EntryKind::Reading(_) => {
596 self.page = Page::Detail(item);
597 None
598 }
599 EntryKind::Submenu(level) => {
600 self.page = Page::Menu(self.items.first_after_back(level));
601 None
602 }
603 EntryKind::Back => {
604 self.leave(item.level());
605 None
606 }
607 EntryKind::Toggle(id) => Some(UiEffect::Toggle(id)),
611 EntryKind::Action(effect) => {
612 self.page = Page::Menu(MenuItem::Status);
613 Some(effect)
614 }
615 EntryKind::Destructive(_) => {
616 self.page = Page::Confirm {
617 item,
618 confirm_selected: false,
619 };
620 None
621 }
622 },
623 (Page::Menu(item), UiInput::Back) => {
624 self.leave(item.level());
625 None
626 }
627 (Page::Detail(item), _) => {
632 self.page = Page::Menu(item);
633 None
634 }
635 (Page::Confirm { item, .. }, UiInput::Back) => {
638 self.page = Page::Menu(item);
639 None
640 }
641 (
642 Page::Confirm {
643 item,
644 confirm_selected,
645 },
646 UiInput::Forward | UiInput::Backward,
647 ) => {
648 self.page = Page::Confirm {
649 item,
650 confirm_selected: !confirm_selected,
651 };
652 None
653 }
654 (
655 Page::Confirm {
656 item,
657 confirm_selected: false,
658 },
659 UiInput::Select,
660 ) => {
661 self.page = Page::Menu(item);
662 None
663 }
664 (
665 Page::Confirm {
666 item,
667 confirm_selected: true,
668 },
669 UiInput::Select,
670 ) => {
671 self.page = Page::Menu(MenuItem::Status);
672 item.effect()
673 }
674 }
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 fn full() -> UiModel {
683 UiModel::new(MenuItems::all())
684 }
685
686 fn walk_to(ui: &mut UiModel, item: MenuItem) {
689 for _ in 0..MenuItem::ALL.len() + 1 {
690 if ui.page() == Page::Menu(item) {
691 return;
692 }
693 ui.apply(UiInput::Forward);
694 }
695 panic!("never reached {item:?}");
696 }
697
698 #[test]
699 fn forward_and_backward_wrap_the_top_level() {
700 let mut ui = full();
701 ui.apply(UiInput::Forward);
702 assert_eq!(ui.page(), Page::Menu(MenuItem::Identity));
703 ui.apply(UiInput::Forward);
704 assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
705 ui.apply(UiInput::Forward);
706 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
707 ui.apply(UiInput::Backward);
708 assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
709 }
710
711 #[test]
712 fn navigation_never_leaves_the_current_level() {
713 let mut ui = full();
714 walk_to(&mut ui, MenuItem::Settings);
715 ui.apply(UiInput::Select);
716 for _ in 0..8 {
718 let Page::Menu(item) = ui.page() else {
719 panic!("left the menu");
720 };
721 assert_eq!(item.level(), Level::Settings);
722 ui.apply(UiInput::Forward);
723 }
724 }
725
726 #[test]
727 fn entering_a_submenu_highlights_the_entry_after_back() {
728 let mut ui = full();
729 walk_to(&mut ui, MenuItem::Settings);
730 assert_eq!(ui.apply(UiInput::Select), None);
731 assert_eq!(ui.page(), Page::Menu(MenuItem::Bluetooth));
732 assert_eq!(ui.level(), Level::Settings);
733
734 ui.apply(UiInput::Backward);
736 assert_eq!(ui.page(), Page::Menu(MenuItem::SettingsBack));
737 }
738
739 #[test]
740 fn back_returns_to_the_entry_that_opened_the_level() {
741 let mut ui = full();
742 walk_to(&mut ui, MenuItem::Settings);
743 ui.apply(UiInput::Select);
744 walk_to(&mut ui, MenuItem::Gnss);
745 ui.apply(UiInput::Select);
746 assert_eq!(ui.page(), Page::Menu(MenuItem::GnssToggle));
747
748 walk_to(&mut ui, MenuItem::GnssBack);
749 assert_eq!(ui.apply(UiInput::Select), None);
750 assert_eq!(ui.page(), Page::Menu(MenuItem::Gnss));
751
752 walk_to(&mut ui, MenuItem::SettingsBack);
753 assert_eq!(ui.apply(UiInput::Select), None);
754 assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
755 assert_eq!(ui.level(), Level::Top);
756 }
757
758 #[test]
761 fn a_back_press_leaves_the_level_from_any_entry() {
762 let mut ui = full();
763 walk_to(&mut ui, MenuItem::Settings);
764 ui.apply(UiInput::Select);
765 walk_to(&mut ui, MenuItem::Bluetooth);
766 ui.apply(UiInput::Select);
767 walk_to(&mut ui, MenuItem::ClearBonds);
768
769 assert_eq!(ui.apply(UiInput::Back), None);
770 assert_eq!(ui.page(), Page::Menu(MenuItem::Bluetooth));
771 assert_eq!(ui.apply(UiInput::Back), None);
772 assert_eq!(ui.page(), Page::Menu(MenuItem::Settings));
773 assert_eq!(ui.level(), Level::Top);
774 }
775
776 #[test]
778 fn a_back_press_at_the_top_goes_home() {
779 let mut ui = full();
780 walk_to(&mut ui, MenuItem::Identity);
781 assert_eq!(ui.apply(UiInput::Back), None);
782 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
783 assert_eq!(ui.apply(UiInput::Back), None);
785 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
786 }
787
788 #[test]
789 fn a_back_press_answers_a_confirmation_with_no() {
790 let mut ui = full();
791 walk_to(&mut ui, MenuItem::Settings);
792 ui.apply(UiInput::Select);
793 walk_to(&mut ui, MenuItem::Bluetooth);
794 ui.apply(UiInput::Select);
795 walk_to(&mut ui, MenuItem::ClearBonds);
796 ui.apply(UiInput::Select);
797 ui.apply(UiInput::Forward);
799 assert_eq!(
800 ui.page(),
801 Page::Confirm {
802 item: MenuItem::ClearBonds,
803 confirm_selected: true,
804 }
805 );
806 assert_eq!(ui.apply(UiInput::Back), None);
807 assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
808 }
809
810 #[test]
811 fn a_submenu_with_nothing_in_it_is_not_shown() {
812 let items = MenuItems::new()
815 .with(MenuItem::Settings)
816 .with(MenuItem::Bluetooth)
817 .with(MenuItem::StartPairing)
818 .with(MenuItem::Gnss);
819 assert!(items.level_is_empty(Level::Gnss));
820 assert!(!items.level_is_empty(Level::Bluetooth));
821
822 let mut ui = UiModel::new(items);
823 walk_to(&mut ui, MenuItem::Settings);
824 ui.apply(UiInput::Select);
825 for _ in 0..6 {
828 assert_ne!(ui.page(), Page::Menu(MenuItem::Gnss));
829 ui.apply(UiInput::Forward);
830 }
831 }
832
833 #[test]
834 fn a_toggle_stays_on_its_entry() {
835 let mut ui = full();
836 walk_to(&mut ui, MenuItem::Settings);
837 ui.apply(UiInput::Select);
838 walk_to(&mut ui, MenuItem::Radio);
839 ui.apply(UiInput::Select);
840 walk_to(&mut ui, MenuItem::Forwarding);
841
842 assert_eq!(
843 ui.apply(UiInput::Select),
844 Some(UiEffect::Toggle(ToggleId::Forwarding))
845 );
846 assert_eq!(ui.page(), Page::Menu(MenuItem::Forwarding));
847 }
848
849 #[test]
850 fn navigation_skips_items_the_board_does_not_enable() {
851 let items = MenuItems::new()
853 .with(MenuItem::Bluetooth)
854 .with(MenuItem::BluetoothToggle)
855 .with(MenuItem::StartPairing);
856 let mut ui = UiModel::new(items);
857 ui.page = Page::Menu(MenuItem::BluetoothToggle);
858 ui.apply(UiInput::Forward);
859 assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
860 ui.apply(UiInput::Forward);
861 assert_eq!(ui.page(), Page::Menu(MenuItem::BluetoothBack));
862 ui.apply(UiInput::Backward);
863 assert_eq!(ui.page(), Page::Menu(MenuItem::StartPairing));
864 }
865
866 #[test]
867 fn status_only_menu_stays_put() {
868 let mut ui = UiModel::new(MenuItems::new());
869 ui.apply(UiInput::Forward);
870 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
871 assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::CheckIn));
872 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
873 }
874
875 #[test]
876 fn safe_items_activate_and_return_home() {
877 let mut ui = full();
878 walk_to(&mut ui, MenuItem::Settings);
879 ui.apply(UiInput::Select);
880 walk_to(&mut ui, MenuItem::Bluetooth);
881 ui.apply(UiInput::Select);
882 walk_to(&mut ui, MenuItem::StartPairing);
883 assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::StartPairing));
884 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
885 assert_eq!(ui.level(), Level::Top);
886 }
887
888 #[test]
889 fn top_level_reading_entries_stay_put_and_only_home_acts() {
890 let mut ui = full();
891 assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::CheckIn));
893 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
894
895 walk_to(&mut ui, MenuItem::Identity);
896 assert_eq!(ui.apply(UiInput::Select), None);
897 assert_eq!(ui.page(), Page::Menu(MenuItem::Identity));
898 }
899
900 fn at_stats() -> UiModel {
902 let mut ui = full();
903 walk_to(&mut ui, MenuItem::Settings);
904 ui.apply(UiInput::Select);
905 walk_to(&mut ui, MenuItem::Radio);
906 ui.apply(UiInput::Select);
907 walk_to(&mut ui, MenuItem::Stats);
908 ui
909 }
910
911 #[test]
912 fn a_reading_entry_below_the_top_opens_a_page() {
913 let mut ui = at_stats();
914 assert_eq!(ui.page(), Page::Menu(MenuItem::Stats));
916 assert_eq!(ui.apply(UiInput::Select), None);
917 assert_eq!(ui.page(), Page::Detail(MenuItem::Stats));
918 assert_eq!(ui.level(), Level::Radio);
919 }
920
921 #[test]
922 fn any_press_dismisses_a_reading_page() {
923 for input in [
924 UiInput::Forward,
925 UiInput::Backward,
926 UiInput::Select,
927 UiInput::Back,
928 ] {
929 let mut ui = at_stats();
930 ui.apply(UiInput::Select);
931 assert_eq!(ui.page(), Page::Detail(MenuItem::Stats));
932 assert_eq!(ui.apply(input), None, "{input:?}");
935 assert_eq!(ui.page(), Page::Menu(MenuItem::Stats), "{input:?}");
936 }
937 }
938
939 #[test]
940 fn a_reading_page_is_never_home_and_a_lapse_unwinds_it() {
941 let mut ui = at_stats();
942 ui.apply(UiInput::Select);
943 assert!(!ui.is_home());
944 ui.go_home();
945 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
946 assert_eq!(ui.level(), Level::Top);
947 assert!(ui.is_home());
948 }
949
950 fn at_clear_bonds() -> UiModel {
952 let mut ui = full();
953 walk_to(&mut ui, MenuItem::Settings);
954 ui.apply(UiInput::Select);
955 walk_to(&mut ui, MenuItem::Bluetooth);
956 ui.apply(UiInput::Select);
957 walk_to(&mut ui, MenuItem::ClearBonds);
958 ui
959 }
960
961 #[test]
962 fn clear_defaults_to_cancel_and_requires_visible_confirmation() {
963 let mut ui = at_clear_bonds();
964 assert_eq!(ui.apply(UiInput::Select), None);
965 assert_eq!(
966 ui.page(),
967 Page::Confirm {
968 item: MenuItem::ClearBonds,
969 confirm_selected: false,
970 }
971 );
972
973 assert_eq!(ui.apply(UiInput::Select), None);
975 assert_eq!(ui.page(), Page::Menu(MenuItem::ClearBonds));
976
977 ui.apply(UiInput::Select);
979 ui.apply(UiInput::Forward);
980 assert_eq!(
981 ui.page(),
982 Page::Confirm {
983 item: MenuItem::ClearBonds,
984 confirm_selected: true,
985 }
986 );
987 assert_eq!(ui.apply(UiInput::Select), Some(UiEffect::ClearBonds));
988 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
989 }
990
991 #[test]
992 fn backward_also_toggles_the_confirmation() {
993 let mut ui = at_clear_bonds();
994 ui.apply(UiInput::Select);
995 ui.apply(UiInput::Backward);
996 assert_eq!(
997 ui.page(),
998 Page::Confirm {
999 item: MenuItem::ClearBonds,
1000 confirm_selected: true,
1001 }
1002 );
1003 }
1004
1005 #[test]
1006 fn notice_returns_to_status_and_clears_on_input() {
1007 let mut ui = full();
1008 walk_to(&mut ui, MenuItem::Settings);
1009 ui.set_notice(UiNotice::BondsCleared);
1010 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
1011 assert_eq!(ui.notice(), Some(UiNotice::BondsCleared));
1012
1013 ui.apply(UiInput::Forward);
1014 assert_eq!(ui.notice(), None);
1015 assert_eq!(ui.page(), Page::Menu(MenuItem::Identity));
1016 }
1017
1018 #[test]
1019 fn go_home_unwinds_from_the_deepest_level() {
1020 let mut ui = at_clear_bonds();
1021 ui.apply(UiInput::Select);
1022 assert!(!ui.is_home());
1023
1024 ui.go_home();
1025 assert_eq!(ui.page(), Page::Menu(MenuItem::Status));
1026 assert_eq!(ui.notice(), None);
1027 assert_eq!(ui.level(), Level::Top);
1028 assert!(ui.is_home());
1029 }
1030
1031 #[test]
1032 fn a_submenu_is_never_home() {
1033 let mut ui = full();
1034 walk_to(&mut ui, MenuItem::Settings);
1035 ui.apply(UiInput::Select);
1036 assert!(!ui.is_home());
1037 }
1038
1039 #[test]
1040 fn go_home_drops_a_stale_notice() {
1041 let mut ui = full();
1042 ui.set_notice(UiNotice::PairingStarted);
1043 assert!(!ui.is_home());
1044 ui.go_home();
1045 assert!(ui.is_home());
1046 }
1047
1048 #[test]
1049 fn home_and_every_exit_are_always_enabled() {
1050 let bare = MenuItems::new();
1051 assert!(bare.contains(MenuItem::Status));
1052 for level in Level::ALL {
1053 if let Some(back) = level.back() {
1054 assert!(bare.contains(back), "{level:?} has no way out");
1055 }
1056 }
1057 }
1058
1059 #[test]
1060 fn every_level_below_the_top_has_a_back_and_an_opener() {
1061 for level in Level::ALL {
1062 let has_back = MenuItem::ALL
1063 .iter()
1064 .any(|i| i.level() == level && i.is_back());
1065 assert_eq!(has_back, level != Level::Top, "{level:?}");
1066 assert_eq!(
1067 level.opened_by().is_some(),
1068 level != Level::Top,
1069 "{level:?}"
1070 );
1071 assert_eq!(level.back().is_some(), level != Level::Top, "{level:?}");
1072 }
1073 }
1074
1075 #[test]
1076 fn a_levels_opener_and_back_agree_about_where_they_sit() {
1077 for level in Level::ALL {
1078 if let (Some(opener), Some(back)) = (level.opened_by(), level.back()) {
1079 assert_eq!(opener.kind(), EntryKind::Submenu(level));
1082 assert_eq!(back.level(), level);
1083 }
1084 }
1085 }
1086
1087 #[test]
1088 fn every_item_is_listed_exactly_once() {
1089 for item in MenuItem::ALL {
1090 let count = MenuItem::ALL.iter().filter(|&&i| i == item).count();
1091 assert_eq!(count, 1, "{item:?}");
1092 }
1093 assert_eq!(MenuItems::all().len(), MenuItem::ALL.len() as u32);
1094 }
1095
1096 #[test]
1097 fn each_levels_entries_are_contiguous() {
1098 for level in Level::ALL {
1101 let mut first = None;
1102 let mut offset = 0;
1103 for (position, item) in MenuItem::ALL.iter().enumerate() {
1104 if item.level() != level {
1105 continue;
1106 }
1107 let start = *first.get_or_insert(position);
1108 assert_eq!(position, start + offset, "{level:?} is not contiguous");
1109 offset += 1;
1110 }
1111 assert!(first.is_some(), "{level:?} has no entries");
1112 }
1113 }
1114}