Skip to main content

max / goingson

7.0 KB · 207 lines History Blame Raw
1 //! The app's furniture, checked against the app it belongs to.
2 //!
3 //! The assertion worth reading is the last one. A nav and a screen each name a
4 //! place, and nothing but agreement between them makes a tab light: a key
5 //! misspelled at either end is a header that quietly never marks anything, and
6 //! no other test in this tree would notice. So the coherence check walks every
7 //! screen the router serves and demands the nav hold the key it named.
8
9 use std::sync::Arc;
10
11 use quasi_http::Serves as _;
12 use quasi_router::{Chrome, Outcome, Params, Request};
13
14 use super::super::router;
15 use crate::state::{AppState, DESKTOP_USER_ID};
16
17 async fn state() -> Arc<AppState> {
18 let (state, _) = crate::test_utils::setup_test_state().await;
19 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
20 state
21 .db
22 .conn()
23 .unwrap()
24 .execute(
25 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
26 VALUES (?, ?, ?, ?, ?)",
27 rusqlite::params![
28 DESKTOP_USER_ID.to_string(),
29 "desktop@localhost",
30 "x",
31 "Desktop User",
32 &now,
33 ],
34 )
35 .unwrap();
36 state
37 }
38
39 /// Every place in the nav, both levels, as keys.
40 fn keys(chrome: &Chrome) -> Vec<&str> {
41 chrome
42 .nav
43 .iter()
44 .flat_map(|place| {
45 std::iter::once(place.key.as_str())
46 .chain(place.within.iter().map(|inner| inner.key.as_str()))
47 })
48 .collect()
49 }
50
51 #[tokio::test]
52 async fn the_nav_offers_the_tabs_the_shipped_header_offers() {
53 // Transcribed from `navigation.js`'s TAB_GROUPS rather than invented. A nav
54 // that offered a different set would be a second answer to what the app
55 // contains. Search is the one place that is not in TAB_GROUPS: the shipped
56 // header reached it from a box rather than a tab, and goingson `6b3aa22b`
57 // chose a nav entry because it reaches every kind and the app can bind no
58 // keys.
59 let chrome = super::chrome();
60 let tabs: Vec<&str> = chrome
61 .nav
62 .iter()
63 .map(|place| place.label.as_str())
64 .collect();
65 assert_eq!(tabs, ["Work", "Time", "Messages", "Search", "Settings"]);
66
67 let work = &chrome.nav[0];
68 let inside: Vec<&str> = work.within.iter().map(|p| p.label.as_str()).collect();
69 assert_eq!(inside, ["Tasks", "Projects", "Problems"]);
70
71 // TAB_DEFAULTS: pressing a tab opens the view it opens in the shipped app.
72 assert_eq!(work.action, quasi_router::Action::get("/tasks"));
73 }
74
75 #[tokio::test]
76 async fn the_graph_is_not_offered_because_nothing_serves_it() {
77 // `task-graph` is in TAB_GROUPS and is bespoke: a hand-laid-out SVG that
78 // stays JavaScript. A place pointing at a route that does not exist would
79 // be a NotFound the first time it was pressed.
80 let chrome = super::chrome();
81 assert!(!keys(&chrome).contains(&"task-graph"));
82 assert!(
83 !format!("{chrome:?}").contains("Graph"),
84 "no label for it either"
85 );
86 }
87
88 #[tokio::test]
89 async fn the_band_is_still_the_timer_modules_to_describe() {
90 // The shell asks for the panel; it does not restate what the panel says.
91 let chrome = super::chrome();
92 let panel = chrome
93 .panel(crate::quasi::time_tracking::PANEL)
94 .expect("the band is declared");
95 assert_eq!(panel.role, quasi_router::Role::Activity);
96 }
97
98 #[tokio::test]
99 async fn a_screen_reached_from_a_row_marks_the_place_it_came_from() {
100 // The drawer, the project dashboard and Import & Export are not tabs. Each
101 // marks where a person got to it from, which is what TAB_GROUPS said for
102 // the first two and what the settings sidebar says for the third.
103 let state = state().await;
104 let task = state
105 .tasks
106 .create(
107 DESKTOP_USER_ID,
108 goingson_core::NewTask::builder("Write the thing").build(),
109 )
110 .unwrap();
111
112 for (path, expected) in [
113 (format!("/tasks/{}", task.id), super::TASKS),
114 ("/data".to_owned(), super::SETTINGS),
115 ("/board".to_owned(), super::TASKS),
116 ] {
117 let response = router()
118 .handle(&state, Request::get(&path).carrying(Params::new()))
119 .expect("the route answers");
120 let Outcome::Screen(screen) = &response.outcome else {
121 panic!("{path} answers a screen");
122 };
123 assert_eq!(screen.place.as_deref(), Some(expected), "{path}");
124 }
125 }
126
127 #[tokio::test]
128 async fn the_document_carries_the_nav_and_marks_where_it_is() {
129 let state = state().await;
130 let response = router()
131 .handle(&state, Request::get("/problems").carrying(Params::new()))
132 .expect("the route answers");
133 let Outcome::Screen(screen) = &response.outcome else {
134 panic!("problems answers a screen");
135 };
136
137 let markup = quasi_webview::Webview::new()
138 .with_shell(quasi_webview::Shell::default().with_chrome(super::chrome()))
139 .screen(screen);
140
141 assert!(markup.contains("data-chrome=\"nav\""), "{markup}");
142 assert!(markup.contains("Problems"), "{markup}");
143 // The place and the tab holding it: a header that lit the pill and not the
144 // tab would be lying about the tab.
145 assert_eq!(
146 markup.matches("aria-current=\"page\"").count(),
147 2,
148 "{markup}"
149 );
150 }
151
152 #[tokio::test]
153 async fn every_screen_names_a_place_the_nav_actually_has() {
154 // THE ONE THAT MATTERS. A key misspelled at either end is a tab that never
155 // lights, and nothing else in this tree would see it: the screen still
156 // renders, the nav still draws, and only the marking is quietly gone.
157 //
158 // Walked over the routes rather than over a list kept here, so a screen
159 // added without a place is a failure rather than a line somebody forgot.
160 let state = state().await;
161 let chrome = super::chrome();
162 let known = keys(&chrome);
163
164 let task = state
165 .tasks
166 .create(
167 DESKTOP_USER_ID,
168 goingson_core::NewTask::builder("Write the thing").build(),
169 )
170 .unwrap();
171
172 let paths = [
173 "/tasks".to_owned(),
174 "/board".to_owned(),
175 format!("/tasks/{}", task.id),
176 format!("/tasks/{}/edit", task.id),
177 "/projects".to_owned(),
178 "/problems".to_owned(),
179 "/day".to_owned(),
180 "/weekly-review".to_owned(),
181 "/monthly-review".to_owned(),
182 "/timer".to_owned(),
183 "/events".to_owned(),
184 "/emails".to_owned(),
185 "/contacts".to_owned(),
186 "/settings".to_owned(),
187 "/data".to_owned(),
188 ];
189
190 for path in paths {
191 let response = router()
192 .handle(&state, Request::get(&path).carrying(Params::new()))
193 .expect("the route answers");
194 let Outcome::Screen(screen) = &response.outcome else {
195 continue;
196 };
197 let place = screen
198 .place
199 .as_deref()
200 .unwrap_or_else(|| panic!("{path} names no place"));
201 assert!(
202 known.contains(&place),
203 "{path} names `{place}`, which the nav does not have: {known:?}"
204 );
205 }
206 }
207