Skip to main content

max / goingson

17.6 KB · 504 lines History Blame Raw
1 //! The weekly review, driven through the router against a real database.
2 //!
3 //! Same standard as the screens before it: no Tauri runtime and no window,
4 //! the 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::{NewTask, Priority, weekly_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 fn add(state: &AppState, title: &str) -> goingson_core::Task {
42 state
43 .tasks
44 .create(
45 DESKTOP_USER_ID,
46 NewTask::builder(title)
47 .title(title)
48 .priority(Priority::High)
49 .build(),
50 )
51 .unwrap()
52 }
53
54 /// A task in a project, due on a day that has already gone.
55 fn overdue_in_project(state: &AppState, title: &str, project: &str) {
56 let project = state
57 .projects
58 .create(
59 DESKTOP_USER_ID,
60 goingson_core::NewProject {
61 name: project.to_owned(),
62 description: String::new(),
63 project_type: goingson_core::ProjectType::SideProject,
64 status: goingson_core::ProjectStatus::Active,
65 },
66 )
67 .unwrap();
68 state
69 .tasks
70 .create(
71 DESKTOP_USER_ID,
72 NewTask::builder(title)
73 .priority(Priority::High)
74 .project_id(project.id)
75 .due(chrono::Utc::now() - chrono::Duration::days(3))
76 .build(),
77 )
78 .unwrap();
79 }
80
81 /// The week the tests write into, which is the one anything created now lands
82 /// in.
83 fn this_week() -> String {
84 weekly_review::current_week_start().to_string()
85 }
86
87 fn get(state: &AppState, path: &str, params: Params) -> Response {
88 router()
89 .handle(state, Request::get(path).carrying(params))
90 .expect("the route answers")
91 }
92
93 fn post(state: &AppState, path: &str, params: Params) -> Response {
94 router()
95 .handle(state, Request::post(path).sending(params))
96 .expect("the route answers")
97 }
98
99 fn html(response: Response) -> String {
100 match response.outcome {
101 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
102 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
103 // Deliberately not a wildcard, for the reason the task tests give: a
104 // redirect has no body, and a fallback returning empty markup would
105 // read as a screen that rendered nothing.
106 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
107 // Same reasoning one step along: an overlay is a screen, but it is not
108 // the screen this route was asked for. Rendering it here would let a
109 // route that answered with the command palette pass as the page.
110 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
111 Outcome::Anchored { .. } => {
112 panic!("expected content, got a screen drawn at a point on it")
113 }
114 Outcome::Suggestions { field, .. } => {
115 panic!("expected content, got a suggestion list for `{field}`")
116 }
117 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
118 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
119 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
120 // not content, and not a place either.
121 Outcome::Started { region, .. } => {
122 panic!("expected content, got work started in `{region}`")
123 }
124 }
125 }
126
127 /// The review for this week.
128 fn review(state: &AppState) -> String {
129 html(get(state, "/weekly-review", Params::new()))
130 }
131
132 #[tokio::test]
133 async fn the_review_carries_every_section_it_promises() {
134 let state = state().await;
135 let page = review(&state);
136
137 for section in [
138 "Week at a Glance",
139 "Accomplished",
140 "Needs Attention",
141 "Due This Week",
142 // Escaped, because a heading is text and the renderer is the only thing
143 // here that ever emits markup.
144 "This Week&#39;s Focus",
145 "Days Off",
146 "Reflection",
147 ] {
148 assert!(page.contains(section), "missing section: {section}");
149 }
150 }
151
152 #[tokio::test]
153 async fn an_overdue_row_says_its_project_as_well_as_when_it_slipped() {
154 // `0c540c6f`. `Row::meta` sets rather than appends, so the project this row
155 // placed first was thrown away by the due date placed second and no overdue
156 // task ever named the project it belonged to. One joined string is the fix.
157 let state = state().await;
158 overdue_in_project(&state, "Write the thing", "Ledger");
159
160 let page = review(&state);
161 assert!(page.contains("Ledger · 3d ago"), "{page}");
162 }
163
164 #[tokio::test]
165 async fn a_day_carries_its_counts_as_numbers_rather_than_as_dots() {
166 // The first finding. `renderDayDots` caps completed at three dots and the
167 // rest at two, which is an encoding running out of room rather than a fact
168 // about the week. Seven rows, each saying what it actually holds.
169 let state = state().await;
170 for index in 0..5 {
171 let task = add(&state, &format!("Done {index}"));
172 state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap();
173 }
174
175 let page = review(&state);
176
177 assert!(page.contains("5 done"), "the fifth completion survives");
178 // Seven days, whatever any of them holds.
179 for day in ["Mon", "Sun"] {
180 assert!(page.contains(day));
181 }
182 }
183
184 #[tokio::test]
185 async fn the_focus_draws_three_named_places_and_the_empty_ones_are_reachable() {
186 // The third finding, closed 2026-08-30 (quasicoherent `df57ed16`): three
187 // slots always exist in the JS, and a place awaiting content is a region.
188 // The meter that stood in for them is gone with the finding.
189 let state = state().await;
190 let task = add(&state, "The one thing");
191 post(
192 &state,
193 &format!("/weekly-review/focus/{}", task.id),
194 Params::new().with("focus", "true"),
195 );
196
197 let page = review(&state);
198
199 assert!(page.contains("The one thing"));
200 for slot in 1..=3 {
201 assert!(
202 page.contains(&format!(r#"id="weekly-focus-{slot}""#)),
203 "priority {slot} is a place whether or not it is taken"
204 );
205 assert!(
206 page.contains(&format!(r#"aria-label="Priority {slot}""#)),
207 "priority {slot} says which one it is"
208 );
209 }
210 // A `section` with a name, which is what makes an empty one reachable
211 // rather than a nameless div nothing announces.
212 assert!(page.contains(r#"<section id="weekly-focus-2""#));
213 // The one that is taken holds the task; the two nobody has filled say so
214 // inside their own region rather than as a row standing for nothing.
215 let second = page
216 .split(r#"id="weekly-focus-2""#)
217 .nth(1)
218 .expect("the second place is drawn");
219 assert!(second[..second.find("weekly-focus-3").unwrap_or(second.len())].contains("Open"));
220 // The meter the finding stood in for is gone with it.
221 assert!(!page.contains("aria-valuemax"));
222 }
223
224 #[tokio::test]
225 async fn a_fourth_focused_task_gets_a_fourth_place_rather_than_disappearing() {
226 // `tasks.is_focus` is one column and nothing enforces the three, so the
227 // count of places is the greater of the two. Three regions and four
228 // focused tasks would be a task that stopped existing.
229 let state = state().await;
230 for index in 1..=4 {
231 let task = add(&state, &format!("Priority task {index}"));
232 post(
233 &state,
234 &format!("/weekly-review/focus/{}", task.id),
235 Params::new().with("focus", "true"),
236 );
237 }
238
239 let page = review(&state);
240
241 for index in 1..=4 {
242 assert!(page.contains(&format!("Priority task {index}")));
243 }
244 assert!(page.contains(r#"aria-label="Priority 4""#));
245 // And nothing offers a fifth place nobody is standing in.
246 assert!(!page.contains(r#"aria-label="Priority 5""#));
247 }
248
249 #[tokio::test]
250 async fn focusing_and_unfocusing_go_through_the_route_and_stay_in_the_week() {
251 let state = state().await;
252 let task = add(&state, "Ship the port");
253 let week = this_week();
254
255 let focused = html(post(
256 &state,
257 &format!("/weekly-review/focus/{}", task.id),
258 Params::new().with("focus", "true").with("week", &week),
259 ));
260 assert!(focused.contains("Remove"));
261 assert!(focused.contains(r#"aria-label="Priority 1""#));
262
263 let cleared = html(post(
264 &state,
265 &format!("/weekly-review/focus/{}", task.id),
266 Params::new().with("focus", "false").with("week", &week),
267 ));
268 // Every place is open again, and each still says which one it is.
269 assert!(!cleared.contains("Remove"));
270 assert!(cleared.contains(r#"aria-label="Priority 3""#));
271 }
272
273 #[tokio::test]
274 async fn clear_is_an_address_and_never_arrives_as_a_task_id() {
275 // A static segment outranks a capture, which is the path matcher's rule
276 // and not something the route order here can be relied on to enforce.
277 let state = state().await;
278 let task = add(&state, "Focused");
279 post(
280 &state,
281 &format!("/weekly-review/focus/{}", task.id),
282 Params::new().with("focus", "true"),
283 );
284
285 let page = html(post(&state, "/weekly-review/focus/clear", Params::new()));
286
287 assert!(page.contains(r#"aria-label="Priority 1""#));
288 // Nothing is holding a slot, so nothing offers to give one up. The task
289 // itself comes straight back under Suggested, which is the point of
290 // clearing rather than a leak.
291 assert!(!page.contains("Remove"));
292 assert!(page.contains("Suggested"));
293 }
294
295 #[tokio::test]
296 async fn a_day_off_is_a_latched_chip_and_clicking_it_twice_puts_it_back() {
297 // The one section the vocabulary already had an answer for: seven
298 // independently latched things, each answering a click, is a chip. Not a
299 // Select, which picks one of a set.
300 let state = state().await;
301
302 let plain = review(&state);
303 assert!(plain.contains("Wednesday"));
304 // `latched`, which is the class makeover styles. This asserted
305 // `chip-latched`, a third name that no stylesheet defined, so a latched
306 // chip looked exactly like an unlatched one. quasi@f287ac1.
307 assert!(!plain.contains("latched"));
308
309 let off = html(post(&state, "/weekly-review/vacation/2", Params::new()));
310 assert!(off.contains("latched"));
311 assert!(off.contains(r#"aria-pressed="true""#));
312 // The day it marks off stops reporting counts and says so.
313 assert!(off.contains("Day off"));
314
315 let on_again = html(post(&state, "/weekly-review/vacation/2", Params::new()));
316 assert!(!on_again.contains("latched"));
317 }
318
319 #[tokio::test]
320 async fn an_eighth_weekday_is_a_not_found_rather_than_a_write() {
321 let state = state().await;
322 let error = router()
323 .handle(&state, Request::post("/weekly-review/vacation/7"))
324 .expect_err("there is no eighth day");
325 assert_eq!(error.class.http_status(), 404);
326 }
327
328 #[tokio::test]
329 async fn completing_writes_the_notes_the_js_screen_reads_back() {
330 // The stored format is `completeWeeklyReview`'s, down to the blank line,
331 // because the JS screen still opens these notes.
332 let state = state().await;
333
334 let done = post(
335 &state,
336 "/weekly-review/complete",
337 Params::new()
338 .with("went-well", "Shipped the fourth screen")
339 .with("improve", "Started too late in the day"),
340 );
341 assert!(done.notice.is_some(), "completing says so");
342
343 let stored = state
344 .weekly_reviews
345 .get_for_week(DESKTOP_USER_ID, weekly_review::current_week_start())
346 .unwrap()
347 .expect("the week is written");
348 assert_eq!(
349 stored.notes,
350 "What went well:\nShipped the fourth screen\n\nWhat could be improved:\nStarted too late in the day"
351 );
352
353 // And the screen reads them back into the two fields it asked with.
354 let page = review(&state);
355 assert!(page.contains("Shipped the fourth screen"));
356 assert!(page.contains("Started too late in the day"));
357 assert!(page.contains("already reviewed"));
358 assert!(page.contains("Save notes"));
359 }
360
361 #[tokio::test]
362 async fn an_unanswered_prompt_is_left_out_rather_than_written_empty() {
363 let state = state().await;
364 post(
365 &state,
366 "/weekly-review/complete",
367 Params::new()
368 .with("went-well", " ")
369 .with("improve", "Less rework"),
370 );
371
372 let stored = state
373 .weekly_reviews
374 .get_for_week(DESKTOP_USER_ID, weekly_review::current_week_start())
375 .unwrap()
376 .expect("the week is written");
377 assert_eq!(stored.notes, "What could be improved:\nLess rework");
378 }
379
380 #[tokio::test]
381 async fn the_reflection_shows_what_is_stored_and_cannot_say_it_is_a_draft() {
382 // The fourth finding, asserted as the loss it is. `weekly-review.js` keeps
383 // unsent keystrokes in localStorage against the week and restores them over
384 // the stored notes; a Field carries a value and has no way to say the host
385 // should be holding one that was never sent.
386 let state = state().await;
387 post(
388 &state,
389 "/weekly-review/complete",
390 Params::new().with("went-well", "What is stored"),
391 );
392
393 let page = review(&state);
394
395 assert!(page.contains("What is stored"));
396 assert!(!page.contains("draft"));
397 }
398
399 #[tokio::test]
400 async fn a_task_title_cannot_become_markup() {
401 let state = state().await;
402 let task = add(&state, "<script>alert(1)</script>");
403 post(
404 &state,
405 &format!("/weekly-review/focus/{}", task.id),
406 Params::new().with("focus", "true"),
407 );
408
409 let page = review(&state);
410
411 assert!(!page.contains("<script>alert"));
412 assert!(page.contains("&lt;script&gt;"));
413 }
414
415 #[tokio::test]
416 async fn a_past_week_is_reachable_by_address_and_holds_none_of_this_weeks_work() {
417 // The week is an address rather than a variable, which is the whole reason
418 // every action carries it.
419 let state = state().await;
420 let task = add(&state, "This week only");
421 state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap();
422
423 let long_ago = (weekly_review::current_week_start() - chrono::Duration::days(70)).to_string();
424 let page = html(get(
425 &state,
426 "/weekly-review",
427 Params::new().with("week", &long_ago),
428 ));
429
430 assert!(page.contains("Nothing completed this week"));
431 assert!(!page.contains("This week only"));
432 // And its arrows still point at weeks either side of the one being read.
433 assert!(page.contains("Previous week"));
434 }
435
436 #[tokio::test]
437 async fn an_unparseable_week_lands_on_this_one_rather_than_erroring() {
438 let state = state().await;
439 let page = html(get(
440 &state,
441 "/weekly-review",
442 Params::new().with("week", "last tuesday"),
443 ));
444 assert!(page.contains("Week at a Glance"));
445 }
446
447 #[tokio::test]
448 async fn a_blocked_candidate_is_offered_and_names_what_it_waits_on() {
449 // Decision 143d71b1: a blocked task is a legitimate focus, so the picker
450 // marks it rather than filtering it out. The candidate set is unchanged in
451 // size, which is the half a filter would have taken.
452 let state = state().await;
453 let blocker = add(&state, "Do this first");
454 let blocked = add(&state, "Then this");
455 state
456 .tasks
457 .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id)
458 .unwrap();
459
460 let page = review(&state);
461
462 assert!(page.contains("Then this"), "still offered: {page}");
463 assert!(page.contains("after Do this first"), "{page}");
464 // Naming the blocker is the whole marker. A bare "Blocked" would be the
465 // phrasing this deliberately replaced.
466 assert!(!page.contains(">Blocked<"), "{page}");
467 }
468
469 #[tokio::test]
470 async fn a_cycled_candidate_reads_differently_from_a_merely_blocked_one() {
471 // A cycle never opens, so doing the named blocker would not help. Same
472 // distinction `Availability::marker` draws on every other task surface.
473 let state = state().await;
474 let first = add(&state, "Round one");
475 let second = add(&state, "Round two");
476 state
477 .tasks
478 .add_dependency(DESKTOP_USER_ID, second.id, first.id)
479 .unwrap();
480 // The write path refuses a cycle, so the closing leg goes in the way a sync
481 // pull puts it there: two individually legal edges merged behind the
482 // repository. Same setup as `a_cycle_merged_in_behind_the_repository_...`
483 // in the dependency repo's own tests.
484 state
485 .db
486 .conn()
487 .unwrap()
488 .execute(
489 "INSERT INTO task_dependencies (id, blocked_id, blocker_id, created_at) \
490 VALUES (?, ?, ?, datetime('now'))",
491 rusqlite::params![
492 uuid::Uuid::new_v4().to_string(),
493 first.id.to_string(),
494 second.id.to_string(),
495 ],
496 )
497 .unwrap();
498 state.tasks.recompute_graph(DESKTOP_USER_ID).unwrap();
499
500 let page = review(&state);
501
502 assert!(page.contains("Cycle"), "{page}");
503 }
504