Skip to main content

max / goingson

14.4 KB · 445 lines History Blame Raw
1 //! The monthly review, driven through the router against a real database.
2 //!
3 //! Same standard as the screens before it: no Tauri runtime and no window, the
4 //! description asserted, and the markup only where the markup is the point.
5 //! Every workaround this port had to take is asserted here rather than left to
6 //! be noticed, so closing a finding is a test that has to change.
7
8 use std::sync::Arc;
9
10 use goingson_core::{MonthlyGoalStatus, NewTask, Priority, monthly_review};
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 /// The month the tests write into, which is the one anything created now lands
42 /// in.
43 fn this_month() -> String {
44 monthly_review::current_month_start()
45 .format("%Y-%m")
46 .to_string()
47 }
48
49 fn get(state: &AppState, path: &str, params: Params) -> Response {
50 router()
51 .handle(state, Request::get(path).carrying(params))
52 .expect("the route answers")
53 }
54
55 fn post(state: &AppState, path: &str, params: Params) -> Response {
56 router()
57 .handle(state, Request::post(path).sending(params))
58 .expect("the route answers")
59 }
60
61 fn html(response: Response) -> String {
62 match response.outcome {
63 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
64 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
65 // Deliberately not a wildcard, for the reason the task tests give: a
66 // redirect has no body, and a fallback returning empty markup would
67 // read as a screen that rendered nothing.
68 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
69 // Same reasoning one step along: an overlay is a screen, but it is not
70 // the screen this route was asked for. Rendering it here would let a
71 // route that answered with the command palette pass as the page.
72 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
73 Outcome::Anchored { .. } => {
74 panic!("expected content, got a screen drawn at a point on it")
75 }
76 Outcome::Suggestions { field, .. } => {
77 panic!("expected content, got a suggestion list for `{field}`")
78 }
79 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
80 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
81 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
82 // not content, and not a place either.
83 Outcome::Started { region, .. } => {
84 panic!("expected content, got work started in `{region}`")
85 }
86 }
87 }
88
89 /// The review for this month.
90 fn review(state: &AppState) -> String {
91 html(get(state, "/monthly-review", Params::new()))
92 }
93
94 /// A goal on this month, through the described route.
95 fn add_goal(state: &AppState, text: &str) -> Response {
96 post(
97 state,
98 "/monthly-review/goals",
99 Params::new().with("month", this_month()).with("text", text),
100 )
101 }
102
103 fn goals(state: &AppState) -> Vec<goingson_core::MonthlyGoal> {
104 state
105 .monthly_reviews
106 .list_goals(DESKTOP_USER_ID, &this_month())
107 .expect("goals read")
108 }
109
110 #[tokio::test]
111 async fn the_review_carries_the_sections_that_have_something_to_say() {
112 let state = state().await;
113 let page = review(&state);
114
115 // The Numbers, Goals and Reflection are unconditional: a month with nothing
116 // in it still has counts of zero, still offers a goal, and is still a month
117 // you can write about.
118 for section in ["The Month", "The Numbers", "Goals", "Reflection"] {
119 assert!(page.contains(section), "missing section: {section}");
120 }
121 // Accomplished, Project Pulse, Project Health and Patterns are absent
122 // rather than empty. A heading over nothing is a claim the screen cannot
123 // support, which is what `Node::empty` exists to say instead.
124 for section in ["Accomplished", "Project Pulse", "Patterns"] {
125 assert!(
126 !page.contains(section),
127 "an empty month should not claim: {section}"
128 );
129 }
130 }
131
132 #[tokio::test]
133 async fn an_empty_month_says_so_as_a_stand_in() {
134 let state = state().await;
135 let page = review(&state);
136
137 assert!(page.contains("Nothing recorded this month yet."));
138 assert!(page.contains(r#"data-state="empty""#));
139 }
140
141 #[tokio::test]
142 async fn a_day_carries_its_counts_as_numbers_rather_than_as_a_shade() {
143 // The first finding, and the second consumer of the weekly review's. Core
144 // computes `intensity` as a 0-3 bucket and the JS turns it into one of four
145 // background shades. A shade runs out of room at 3; the number does not.
146 let state = state().await;
147 for i in 0..5 {
148 let task = state
149 .tasks
150 .create(
151 DESKTOP_USER_ID,
152 NewTask::builder(format!("Done {i}"))
153 .title(format!("Done {i}"))
154 .priority(Priority::High)
155 .build(),
156 )
157 .unwrap();
158 state
159 .tasks
160 .complete(task.id, DESKTOP_USER_ID)
161 .expect("completed");
162 }
163
164 let page = review(&state);
165
166 assert!(
167 page.contains("5 done"),
168 "the real count, not a bucket: {page}"
169 );
170 }
171
172 #[tokio::test]
173 async fn the_month_is_an_address_and_every_control_carries_it() {
174 // Decision 2, and the consequence the weekly review recorded: an action
175 // offered under a month has to carry it, or acting moves the user to this
176 // month and writes there.
177 let state = state().await;
178 let past = "2026-01";
179 let page = html(get(
180 &state,
181 "/monthly-review",
182 Params::new().with("month", past),
183 ));
184
185 assert!(page.contains("January 2026"), "got: {page}");
186 // The add-goal form posts under the month being looked at.
187 assert!(
188 page.contains(&format!("month={past}")) || page.contains(past),
189 "the month rides on the controls: {page}"
190 );
191 }
192
193 #[tokio::test]
194 async fn this_month_is_the_one_control_that_drops_the_month() {
195 // Every other control carries the month it was offered under. This one's
196 // whole job is to leave it, so it is bare on purpose.
197 let state = state().await;
198 let page = html(get(
199 &state,
200 "/monthly-review",
201 Params::new().with("month", "2026-01"),
202 ));
203
204 assert!(page.contains("This month"), "got: {page}");
205 }
206
207 #[tokio::test]
208 async fn a_hand_typed_month_lands_on_this_month_rather_than_an_error() {
209 // Same tolerance the week has, and for the same reason: this is an address
210 // a person can type, and the useful answer is a screen.
211 let state = state().await;
212 let page = html(get(
213 &state,
214 "/monthly-review",
215 Params::new().with("month", "not-a-month"),
216 ));
217
218 let expected = monthly_review::format_month_display(monthly_review::current_month_start());
219 assert!(page.contains(&expected), "got: {page}");
220 }
221
222 #[tokio::test]
223 async fn stepping_back_from_january_lands_in_december() {
224 // Months are not a fixed number of days, which is why the arrows do not use
225 // `Duration`. Stepping 31 days back from 1 March lands in January.
226 let state = state().await;
227 let page = html(get(
228 &state,
229 "/monthly-review",
230 Params::new().with("month", "2026-01"),
231 ));
232
233 assert!(
234 page.contains("2025-12"),
235 "previous crosses the year: {page}"
236 );
237 assert!(page.contains("2026-02"), "next stays in it: {page}");
238 }
239
240 #[tokio::test]
241 async fn adding_a_goal_puts_it_on_the_month() {
242 let state = state().await;
243 add_goal(&state, "Ship the thing");
244
245 let page = review(&state);
246 assert!(page.contains("Ship the thing"), "got: {page}");
247 assert_eq!(goals(&state).len(), 1);
248 }
249
250 #[tokio::test]
251 async fn a_goal_names_the_move_it_offers_rather_than_the_state_it_is_in() {
252 // The second finding. The JS used to read the goal from module state, look
253 // the next status up in a table the user cannot see, and write it. Described,
254 // each goal offers the move by name, so the label is the outcome; the JS was
255 // ported onto the same shape afterwards (`setGoalStatus`).
256 let state = state().await;
257 add_goal(&state, "Ship the thing");
258
259 let page = review(&state);
260 assert!(page.contains("Mark done"), "got: {page}");
261
262 let id = goals(&state)[0].id;
263 post(
264 &state,
265 &format!("/monthly-review/goals/{id}/status"),
266 Params::new()
267 .with("month", this_month())
268 .with("status", "done"),
269 );
270
271 assert_eq!(goals(&state)[0].status, MonthlyGoalStatus::Done);
272 let page = review(&state);
273 assert!(page.contains("Give up on it"), "the next move: {page}");
274 }
275
276 #[tokio::test]
277 async fn the_target_status_is_named_so_two_windows_cannot_race() {
278 // The half of the second finding that is a real defect rather than a
279 // description gap: the JS derived the next status from a copy read at
280 // render time, so a second window wrote a status derived from what it saw.
281 // The route takes the target explicitly, so a stale screen cannot invent
282 // one.
283 let state = state().await;
284 add_goal(&state, "Ship the thing");
285 let id = goals(&state)[0].id;
286
287 // Two writes naming the same target, as two stale windows would send.
288 for _ in 0..2 {
289 post(
290 &state,
291 &format!("/monthly-review/goals/{id}/status"),
292 Params::new()
293 .with("month", this_month())
294 .with("status", "abandoned"),
295 );
296 }
297
298 assert_eq!(goals(&state)[0].status, MonthlyGoalStatus::Abandoned);
299 }
300
301 #[tokio::test]
302 async fn a_fourth_goal_is_refused_rather_than_stored() {
303 let state = state().await;
304 for i in 0..3 {
305 add_goal(&state, &format!("Goal {i}"));
306 }
307 assert_eq!(goals(&state).len(), 3);
308
309 let refused = router().handle(
310 &state,
311 Request::post("/monthly-review/goals").sending(
312 Params::new()
313 .with("month", this_month())
314 .with("text", "One too many"),
315 ),
316 );
317
318 assert!(refused.is_err(), "the fourth is refused");
319 assert_eq!(goals(&state).len(), 3);
320 }
321
322 #[tokio::test]
323 async fn the_form_disappears_once_the_month_is_full() {
324 let state = state().await;
325 assert!(review(&state).contains("Add goal"));
326
327 for i in 0..3 {
328 add_goal(&state, &format!("Goal {i}"));
329 }
330
331 assert!(
332 !review(&state).contains("Add goal"),
333 "no offer that cannot be taken"
334 );
335 }
336
337 #[tokio::test]
338 async fn a_deleted_middle_goal_frees_its_own_position() {
339 // The position is the first one nothing holds, not one past the count. A
340 // month whose middle goal was deleted has a free slot in the middle, and
341 // counting would collide with the last one.
342 let state = state().await;
343 for i in 0..3 {
344 add_goal(&state, &format!("Goal {i}"));
345 }
346 let middle = goals(&state)
347 .iter()
348 .find(|goal| goal.position == 2)
349 .expect("a second goal")
350 .id;
351
352 post(
353 &state,
354 &format!("/monthly-review/goals/{middle}/delete"),
355 Params::new().with("month", this_month()),
356 );
357 add_goal(&state, "Back in the middle");
358
359 let written = goals(&state);
360 assert_eq!(written.len(), 3);
361 let refilled = written
362 .iter()
363 .find(|goal| goal.text == "Back in the middle")
364 .expect("the new goal");
365 assert_eq!(refilled.position, 2, "took the free slot, not a fourth");
366 }
367
368 #[tokio::test]
369 async fn deleting_a_goal_asks_first() {
370 let state = state().await;
371 add_goal(&state, "Ship the thing");
372
373 let page = review(&state);
374 assert!(
375 page.contains("hx-confirm=\"Are you sure you want to delete this goal?"),
376 "got: {page}"
377 );
378 }
379
380 #[tokio::test]
381 async fn deleting_something_that_is_not_there_is_a_not_found() {
382 let state = state().await;
383 let missing = uuid::Uuid::new_v4();
384
385 let answer = router().handle(
386 &state,
387 Request::post(format!("/monthly-review/goals/{missing}/delete"))
388 .sending(Params::new().with("month", this_month())),
389 );
390
391 assert!(answer.is_err());
392 }
393
394 #[tokio::test]
395 async fn the_reflection_round_trips_and_the_banner_follows_it() {
396 let state = state().await;
397 assert!(review(&state).contains("Complete review"));
398
399 post(
400 &state,
401 "/monthly-review/complete",
402 Params::new()
403 .with("month", this_month())
404 .with("highlight", "Shipped the port")
405 .with("change", "Fewer small tasks")
406 .with("_", ""),
407 );
408
409 let page = review(&state);
410 assert!(page.contains("Shipped the port"), "got: {page}");
411 assert!(page.contains("Fewer small tasks"), "got: {page}");
412 // The submit changes meaning once the month is reviewed, and the banner
413 // says why: the notes stay editable.
414 assert!(page.contains("Save notes"), "got: {page}");
415 assert!(page.contains("already reviewed"), "got: {page}");
416 }
417
418 #[tokio::test]
419 async fn an_empty_reflection_still_marks_the_month_reviewed() {
420 // The weekly review's rule: the completion is the act and the writing is
421 // optional.
422 let state = state().await;
423 post(
424 &state,
425 "/monthly-review/complete",
426 Params::new()
427 .with("month", this_month())
428 .with("highlight", " ")
429 .with("change", ""),
430 );
431
432 assert!(review(&state).contains("already reviewed"));
433 }
434
435 #[tokio::test]
436 async fn a_goals_text_cannot_become_markup() {
437 // The reason a description carries text and the renderer owns escaping.
438 let state = state().await;
439 add_goal(&state, "<script>alert(1)</script>");
440
441 let page = review(&state);
442 assert!(!page.contains("<script>alert(1)</script>"), "got: {page}");
443 assert!(page.contains("&lt;script&gt;"), "got: {page}");
444 }
445