Skip to main content

max / goingson

8.0 KB · 256 lines History Blame Raw
1 //! The search screen, driven through the router against a real database.
2 //!
3 //! Same property as the contacts tests: no Tauri runtime and no window, because
4 //! a route is a function from state and params to a description.
5
6 use std::sync::Arc;
7
8 use goingson_core::{
9 NewContact, NewEmail, NewEvent, NewProject, NewTask, Priority, ProjectStatus, ProjectType,
10 };
11 use quasi_http::Serves as _;
12 use quasi_router::Outcome;
13 use quasi_router::{Params, Request, Response};
14
15 use super::super::router;
16 use crate::state::{AppState, DESKTOP_USER_ID};
17
18 /// State with the desktop user in place, which is who the handlers read as.
19 async fn state() -> Arc<AppState> {
20 let (state, _) = crate::test_utils::setup_test_state().await;
21 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
22 state
23 .db
24 .conn()
25 .unwrap()
26 .execute(
27 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
28 VALUES (?, ?, ?, ?, ?)",
29 rusqlite::params![
30 DESKTOP_USER_ID.to_string(),
31 "desktop@localhost",
32 "x",
33 "Desktop User",
34 &now,
35 ],
36 )
37 .unwrap();
38 state
39 }
40
41 /// One of each of the five kinds the index holds, all matching "kestrel".
42 fn seed_all_five(state: &AppState) {
43 state
44 .tasks
45 .create(
46 DESKTOP_USER_ID,
47 NewTask::builder("Kestrel migration")
48 .priority(Priority::High)
49 .build(),
50 )
51 .unwrap();
52 state
53 .projects
54 .create(
55 DESKTOP_USER_ID,
56 NewProject {
57 name: "Kestrel".to_owned(),
58 description: String::new(),
59 project_type: ProjectType::SideProject,
60 status: ProjectStatus::Active,
61 },
62 )
63 .unwrap();
64 state
65 .events
66 .create(
67 DESKTOP_USER_ID,
68 NewEvent::builder("Kestrel planning", chrono::Utc::now()).build(),
69 )
70 .unwrap();
71 state
72 .contacts
73 .create(
74 DESKTOP_USER_ID,
75 NewContact {
76 display_name: "Kestrel Vendor".to_owned(),
77 nickname: None,
78 company: None,
79 title: None,
80 notes: String::new(),
81 tags: Vec::new(),
82 birthday: None,
83 timezone: None,
84 is_implicit: false,
85 },
86 )
87 .unwrap();
88 state
89 .emails
90 .create(
91 DESKTOP_USER_ID,
92 NewEmail {
93 project_id: None,
94 from_address: "someone@example.com".to_owned(),
95 to_address: "desktop@localhost".to_owned(),
96 subject: "Kestrel delivery".to_owned(),
97 body: "The kestrel ships Tuesday.".to_owned(),
98 is_read: false,
99 received_at: None,
100 },
101 )
102 .unwrap();
103 }
104
105 fn get(state: &AppState, path: &str, params: Params) -> Response {
106 router()
107 .handle(state, Request::get(path).carrying(params))
108 .expect("the route answers")
109 }
110
111 fn screen_html(response: Response) -> String {
112 let Outcome::Screen(screen) = (response).outcome else {
113 panic!("the route answers with a screen");
114 };
115 quasi_webview::Webview::new().screen(&screen)
116 }
117
118 fn fragment_html(response: Response) -> String {
119 let Outcome::Fragment { node, .. } = (response).outcome else {
120 panic!("the route answers with a fragment");
121 };
122 quasi_webview::Webview::new().fragment(&node)
123 }
124
125 #[tokio::test]
126 async fn an_empty_query_teaches_the_grammar_rather_than_searching() {
127 // The colon filters are the feature, and a screen that showed nothing until
128 // a query arrived would keep them secret.
129 let state = state().await;
130 let html = screen_html(get(&state, "/search", Params::new()));
131 assert!(html.contains("is:overdue"), "{html}");
132 assert!(html.contains("type:contact"), "{html}");
133 assert!(html.contains("-tag:name"), "{html}");
134 }
135
136 #[tokio::test]
137 async fn a_blank_query_is_the_same_as_no_query() {
138 // An emptied box sends the param with nothing in it. Searching for the empty
139 // string instead would answer with the whole database.
140 let state = state().await;
141 seed_all_five(&state);
142
143 let html = screen_html(get(&state, "/search", Params::new().with("q", " ")));
144 assert!(html.contains("is:overdue"), "{html}");
145 assert!(!html.contains("Kestrel migration"), "{html}");
146 }
147
148 #[tokio::test]
149 async fn the_screen_searches_across_all_five_kinds() {
150 let state = state().await;
151 seed_all_five(&state);
152
153 let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel")));
154 for hit in [
155 "Kestrel migration",
156 "Kestrel",
157 "Kestrel planning",
158 "Kestrel Vendor",
159 "Kestrel delivery",
160 ] {
161 assert!(html.contains(hit), "missing {hit}: {html}");
162 }
163 }
164
165 #[tokio::test]
166 async fn a_row_says_which_kind_it_is() {
167 // The results are heterogeneous, so a row that only said its title would
168 // leave the reader to guess whether "Kestrel" is a project or a contact.
169 let state = state().await;
170 seed_all_five(&state);
171
172 let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel")));
173 for kind in ["Task", "Project", "Event", "Contact", "Email"] {
174 assert!(html.contains(kind), "missing the {kind} tag: {html}");
175 }
176 }
177
178 #[tokio::test]
179 async fn the_parsed_filters_are_shown_back() {
180 // What makes a colon grammar learnable: the parser saying which words it
181 // took as filters, from the labels the command already collects.
182 let state = state().await;
183 seed_all_five(&state);
184
185 let html = fragment_html(get(
186 &state,
187 "/search/results",
188 Params::new().with("q", "kestrel is:pending priority:high type:task"),
189 ));
190 assert!(html.contains("is:pending"), "{html}");
191 assert!(html.contains("priority:high"), "{html}");
192 assert!(html.contains("type:task"), "{html}");
193 }
194
195 #[tokio::test]
196 async fn a_type_filter_narrows_to_that_kind() {
197 let state = state().await;
198 seed_all_five(&state);
199
200 let html = fragment_html(get(
201 &state,
202 "/search/results",
203 Params::new().with("q", "kestrel type:contact"),
204 ));
205 assert!(html.contains("Kestrel Vendor"), "{html}");
206 assert!(!html.contains("Kestrel migration"), "{html}");
207 }
208
209 #[tokio::test]
210 async fn a_query_that_matches_nothing_says_so() {
211 let state = state().await;
212 seed_all_five(&state);
213
214 let html = fragment_html(get(
215 &state,
216 "/search/results",
217 Params::new().with("q", "peregrine"),
218 ));
219 assert!(html.contains("Nothing matches that."), "{html}");
220 }
221
222 #[tokio::test]
223 async fn the_box_asks_the_results_route_as_it_is_typed() {
224 // `Field::consults` is what makes this a search screen rather than a form:
225 // no submit, and the answer lands in the results region.
226 let state = state().await;
227 let html = screen_html(get(&state, "/search", Params::new()));
228 assert!(html.contains("/search/results"), "{html}");
229 assert!(html.contains("search-results"), "{html}");
230 }
231
232 #[tokio::test]
233 async fn a_search_has_an_address() {
234 // The query is a param rather than state the field holds privately, so the
235 // view a reader is looking at can be linked to and reloaded.
236 let state = state().await;
237 seed_all_five(&state);
238
239 let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel")));
240 assert!(html.contains("Kestrel migration"), "{html}");
241 assert!(
242 html.contains("kestrel"),
243 "the box holds the query back: {html}"
244 );
245 }
246
247 #[tokio::test]
248 async fn search_is_a_place_in_the_nav() {
249 // The entry point, decided on goingson `6b3aa22b`: a nav entry serves
250 // pointer, touch and keyboard alike, and the app can bind no keys.
251 let state = state().await;
252 let html = screen_html(get(&state, "/search", Params::new()));
253 assert!(html.contains("/search"), "{html}");
254 assert!(html.contains("Search"), "{html}");
255 }
256