Skip to main content

max / goingson

20.8 KB · 644 lines History Blame Raw
1 //! The day view, driven through the router against a real database.
2 //!
3 //! Same property as its siblings: no Tauri runtime and no window. What is new
4 //! here is that the assertions are about *placement*, which is the thing the
5 //! vocabulary could not carry until `makeover-layout` 0.24.0.
6
7 use std::sync::Arc;
8
9 use chrono::{Duration, Local, NaiveDate, TimeZone, Utc};
10 use goingson_core::NewEvent;
11 use quasi_http::Serves as _;
12 use quasi_router::{Outcome, Params, Request, Response};
13
14 use super::super::router;
15 use crate::state::{AppState, DESKTOP_USER_ID};
16
17 /// State with the desktop user in place, which is who the handlers read as.
18 async fn state() -> Arc<AppState> {
19 let (state, _) = crate::test_utils::setup_test_state().await;
20 let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
21 state
22 .db
23 .conn()
24 .unwrap()
25 .execute(
26 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
27 VALUES (?, ?, ?, ?, ?)",
28 rusqlite::params![
29 DESKTOP_USER_ID.to_string(),
30 "desktop@localhost",
31 "x",
32 "Desktop User",
33 &now,
34 ],
35 )
36 .unwrap();
37 state
38 }
39
40 /// The day every test in this file draws.
41 ///
42 /// Fixed rather than "today", because a test whose fixtures are relative to the
43 /// clock passes at 09:00 and fails at 23:50 when the local day rolls over
44 /// between building the event and reading the screen.
45 const DAY: &str = "2026-03-04";
46
47 fn day() -> NaiveDate {
48 NaiveDate::parse_from_str(DAY, "%Y-%m-%d").unwrap()
49 }
50
51 /// An event on the test day, at a local wall-clock time.
52 ///
53 /// Local rather than UTC because the screen's whole job is placing things on a
54 /// *local* day, and a fixture written in UTC would silently test a different
55 /// hour on any machine not on UTC.
56 fn event(state: &AppState, title: &str, from: (u32, u32), minutes: i64) {
57 let start = Local
58 .from_local_datetime(&day().and_hms_opt(from.0, from.1, 0).unwrap())
59 .unwrap()
60 .with_timezone(&Utc);
61 state
62 .events
63 .create(
64 DESKTOP_USER_ID,
65 NewEvent::builder(title, start)
66 .end_time(start + Duration::minutes(minutes))
67 .build(),
68 )
69 .unwrap();
70 }
71
72 fn get(state: &AppState, path: &str) -> Response {
73 router()
74 .handle(state, Request::get(path).carrying(Params::new()))
75 .expect("the route answers")
76 }
77
78 fn html(response: Response) -> String {
79 match response.outcome {
80 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
81 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
82 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
83 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
84 Outcome::Anchored { .. } => {
85 panic!("expected content, got a screen drawn at a point on it")
86 }
87 Outcome::Suggestions { field, .. } => {
88 panic!("expected content, got a suggestion list for `{field}`")
89 }
90 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
91 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
92 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
93 // not content, and not a place either.
94 Outcome::Started { region, .. } => {
95 panic!("expected content, got work started in `{region}`")
96 }
97 }
98 }
99
100 fn screen(state: &AppState) -> String {
101 html(get(state, &format!("/day/{DAY}")))
102 }
103
104 #[tokio::test]
105 async fn an_item_is_placed_at_the_hour_it_starts() {
106 let state = state().await;
107 event(&state, "Standup", (9, 0), 60);
108
109 let markup = screen(&state);
110
111 // 09:00 on a midnight-to-midnight axis is 540/1440 = 37.5%, and an hour is
112 // 60/1440 = 4.1667%. This is the assertion the whole vocabulary change
113 // exists for: before Track there was no way to say either number.
114 assert!(markup.contains("--track-at:37.5000%"), "{markup}");
115 assert!(markup.contains("--track-for:4.1667%"), "{markup}");
116 assert!(markup.contains("Standup"), "{markup}");
117 }
118
119 #[tokio::test]
120 async fn two_things_at_once_take_lanes_and_say_they_clash() {
121 let state = state().await;
122 event(&state, "Standup", (9, 0), 60);
123 event(&state, "Interview", (9, 30), 60);
124
125 let markup = screen(&state);
126
127 // The geometry: two lanes, so neither is drawn on top of the other.
128 assert!(markup.contains("--track-lane:0"), "{markup}");
129 assert!(markup.contains("--track-lane:1"), "{markup}");
130 assert!(markup.contains("--track-lanes:2"), "{markup}");
131
132 // And the judgment, which is a different thing from the geometry and comes
133 // from the backend's own `detect_conflicts` rather than from the overlap.
134 assert!(markup.contains("clashes"), "{markup}");
135 }
136
137 #[tokio::test]
138 async fn back_to_back_items_are_not_a_clash() {
139 let state = state().await;
140 event(&state, "First", (9, 0), 60);
141 event(&state, "Second", (10, 0), 60);
142
143 let markup = screen(&state);
144
145 // The off-by-one this guards is the classic day-view bug: every appointment
146 // on the hour reading as a conflict with the one before it.
147 assert!(markup.contains("--track-lanes:1"), "{markup}");
148 assert!(!markup.contains("clashes"), "{markup}");
149 }
150
151 #[tokio::test]
152 async fn the_axis_carries_an_hourly_ruler() {
153 let state = state().await;
154 let markup = screen(&state);
155
156 // 96 quarter-hour slots, labelled every fourth. Matches
157 // `day-planning-render.js:56`'s `for (let hour = 0; hour < 24; hour++)`.
158 assert!(markup.contains(">00:00<"), "{markup}");
159 assert!(markup.contains(">09:00<"), "{markup}");
160 assert!(markup.contains(">23:00<"), "{markup}");
161 assert!(!markup.contains(">09:15<"), "labelled hourly, not per slot");
162 }
163
164 #[tokio::test]
165 async fn the_interesting_hour_travels_as_a_moment() {
166 let state = state().await;
167 let markup = screen(&state);
168
169 // What replaces `day-planning-render.js:321`'s `const targetHour = 9`. The
170 // app says which hour matters; the host decides how to get there.
171 assert!(markup.contains("data-focus=\"540\""), "{markup}");
172 }
173
174 #[tokio::test]
175 async fn an_events_title_cannot_become_markup() {
176 let state = state().await;
177 event(&state, "<script>alert('x')</script>", (9, 0), 60);
178
179 let markup = screen(&state);
180
181 // The property this whole port exists for. Being placed on an axis is not a
182 // way around the renderer's escaping.
183 assert!(!markup.contains("<script>"), "{markup}");
184 assert!(markup.contains("&lt;script&gt;"), "{markup}");
185 }
186
187 #[tokio::test]
188 async fn a_day_with_nothing_on_it_still_draws_its_axis() {
189 let state = state().await;
190 let markup = screen(&state);
191
192 // An empty day is a day, not an empty state. The ruler is what makes it
193 // legible as one, and the JS draws it the same way.
194 assert!(markup.contains(">12:00<"), "{markup}");
195 assert!(markup.contains("Nothing else due today."), "{markup}");
196 }
197
198 #[tokio::test]
199 async fn a_date_that_names_nothing_is_a_404_rather_than_today() {
200 let state = state().await;
201
202 // `/day/yesterday` addresses nothing. Answering it with today's plan would
203 // be the screen deciding it knows better than the URL, which is the failure
204 // an address exists to prevent.
205 let answered = router().handle(
206 &state,
207 Request::get("/day/yesterday").carrying(Params::new()),
208 );
209 assert!(answered.is_err(), "an unparseable date should not resolve");
210 }
211
212 #[tokio::test]
213 async fn stepping_a_day_answers_the_axis_alone() {
214 let state = state().await;
215 event(&state, "Standup", (9, 0), 60);
216
217 let response = get(&state, &format!("/day/{DAY}/timeline"));
218 let Outcome::Fragment { region, .. } = &response.outcome else {
219 panic!("stepping a day replaces one region, not the screen");
220 };
221 assert_eq!(region, "day-timeline");
222
223 let markup = html(response);
224 assert!(markup.contains("Standup"), "{markup}");
225 }
226
227 /// A task due on the test day and not on the axis, which is what the pool holds.
228 fn due_task(state: &AppState, title: &str) -> goingson_core::TaskId {
229 let due = Local
230 .from_local_datetime(&day().and_hms_opt(12, 0, 0).unwrap())
231 .unwrap()
232 .with_timezone(&Utc);
233 state
234 .tasks
235 .create(
236 DESKTOP_USER_ID,
237 goingson_core::NewTask::builder(title).due(due).build(),
238 )
239 .unwrap()
240 .id
241 }
242
243 #[tokio::test]
244 async fn the_pool_says_which_task_is_worth_scheduling_first() {
245 let state = state().await;
246 let blocker = due_task(&state, "Do this first");
247 let blocked = due_task(&state, "Then this");
248 state
249 .tasks
250 .add_dependency(DESKTOP_USER_ID, blocked, blocker)
251 .unwrap();
252
253 let markup = screen(&state);
254
255 // The frees-work half only, which is `0df3488`'s reading and the pool's
256 // own: the gate already refuses to offer a blocked task until its blockers
257 // are in the day, so a "Blocked" badge here would contradict the plan.
258 assert!(markup.contains("Unblocks 1"), "{markup}");
259 assert!(!markup.contains("Blocked"), "{markup}");
260 }
261
262 /// A task scheduled onto the test day at a local wall-clock time.
263 ///
264 /// Written through the same function the described route writes through, so the
265 /// fixture and the thing under test agree about what "scheduled" means -- a task
266 /// row with a start, and a linked event carrying the block.
267 fn scheduled_task(state: &AppState, title: &str, at: (u32, u32)) -> goingson_core::TaskId {
268 let id = due_task(state, title);
269 let start = Local
270 .from_local_datetime(&day().and_hms_opt(at.0, at.1, 0).unwrap())
271 .unwrap()
272 .with_timezone(&Utc);
273 crate::commands::day_planning::schedule_task_now(state, id, start, Some(30)).unwrap();
274 id
275 }
276
277 fn post(state: &AppState, path: &str, params: Params) -> Response {
278 router()
279 .handle(state, Request::post(path).sending(params))
280 .expect("the route answers")
281 }
282
283 fn started_at(state: &AppState, id: goingson_core::TaskId) -> chrono::DateTime<Utc> {
284 state
285 .tasks
286 .get_by_id(id, DESKTOP_USER_ID)
287 .unwrap()
288 .unwrap()
289 .scheduled_start
290 .expect("still scheduled")
291 }
292
293 #[tokio::test]
294 async fn a_scheduled_row_carries_the_writes_it_used_to_have_none_of() {
295 let state = state().await;
296 let id = scheduled_task(&state, "Write the thing", (10, 0));
297
298 let page = screen(&state);
299 assert!(
300 page.contains(&format!("/day/{DAY}/schedule/{id}/move")),
301 "{page}"
302 );
303 assert!(
304 page.contains(&format!("/day/{DAY}/schedule/{id}/unschedule")),
305 "{page}"
306 );
307 }
308
309 #[tokio::test]
310 async fn moving_a_task_later_moves_it_by_the_step_it_was_sent() {
311 let state = state().await;
312 let id = scheduled_task(&state, "Write the thing", (10, 0));
313 let before = started_at(&state, id);
314
315 post(
316 &state,
317 &format!("/day/{DAY}/schedule/{id}/move"),
318 Params::new().with("by", "15"),
319 );
320
321 assert_eq!(started_at(&state, id) - before, Duration::minutes(15));
322 }
323
324 #[tokio::test]
325 async fn moving_a_task_earlier_takes_a_negative_step() {
326 let state = state().await;
327 let id = scheduled_task(&state, "Write the thing", (10, 0));
328 let before = started_at(&state, id);
329
330 post(
331 &state,
332 &format!("/day/{DAY}/schedule/{id}/move"),
333 Params::new().with("by", "-15"),
334 );
335
336 assert_eq!(started_at(&state, id) - before, Duration::minutes(-15));
337 }
338
339 #[tokio::test]
340 async fn a_move_that_would_leave_the_day_is_a_no_op_rather_than_an_error() {
341 // `moveScheduledTask` refuses to cross midnight and so does this. The
342 // control that sent it is on screen either way, so the answer is a fresh
343 // axis rather than a 404.
344 let state = state().await;
345 let id = scheduled_task(&state, "Late one", (23, 45));
346 let before = started_at(&state, id);
347
348 let page = html(post(
349 &state,
350 &format!("/day/{DAY}/schedule/{id}/move"),
351 Params::new().with("by", "30"),
352 ));
353
354 assert_eq!(started_at(&state, id), before);
355 assert!(page.contains("Late one"), "{page}");
356 }
357
358 #[tokio::test]
359 async fn moving_a_task_keeps_its_linked_block_in_step() {
360 // The reason the route calls the command's own body rather than writing the
361 // task row itself: the linked event has to move with it, and a second copy
362 // of that would drift.
363 let state = state().await;
364 let id = scheduled_task(&state, "Write the thing", (10, 0));
365
366 post(
367 &state,
368 &format!("/day/{DAY}/schedule/{id}/move"),
369 Params::new().with("by", "60"),
370 );
371
372 let block = state
373 .events
374 .get_by_linked_task(DESKTOP_USER_ID, id)
375 .unwrap()
376 .expect("the block is still there");
377 assert_eq!(block.start_time, started_at(&state, id));
378 }
379
380 #[tokio::test]
381 async fn unscheduling_takes_the_task_off_the_axis_and_deletes_its_block() {
382 let state = state().await;
383 let id = scheduled_task(&state, "Not today", (10, 0));
384
385 post(
386 &state,
387 &format!("/day/{DAY}/schedule/{id}/unschedule"),
388 Params::new(),
389 );
390
391 let task = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
392 assert!(task.scheduled_start.is_none());
393 assert!(
394 state
395 .events
396 .get_by_linked_task(DESKTOP_USER_ID, id)
397 .unwrap()
398 .is_none()
399 );
400 }
401
402 #[tokio::test]
403 async fn moving_a_task_that_is_not_on_the_day_is_a_not_found() {
404 let state = state().await;
405 let id = due_task(&state, "Never scheduled");
406
407 let error = router()
408 .handle(
409 &state,
410 Request::post(format!("/day/{DAY}/schedule/{id}/move"))
411 .sending(Params::new().with("by", "15")),
412 )
413 .expect_err("that task is not on the day");
414 assert_eq!(error.class.http_status(), 404);
415 }
416
417 /// A task due on the test day, carrying an estimate.
418 ///
419 /// The estimate is what gives the block its size, so a fixture without one is
420 /// testing the other half of `fa9fe9ed`'s ruling.
421 fn estimated_task(state: &AppState, title: &str, minutes: i32) -> goingson_core::TaskId {
422 let due = Local
423 .from_local_datetime(&day().and_hms_opt(12, 0, 0).unwrap())
424 .unwrap()
425 .with_timezone(&Utc);
426 state
427 .tasks
428 .create(
429 DESKTOP_USER_ID,
430 goingson_core::NewTask::builder(title)
431 .due(due)
432 .estimated_minutes(minutes)
433 .build(),
434 )
435 .unwrap()
436 .id
437 }
438
439 fn task(state: &AppState, id: goingson_core::TaskId) -> goingson_core::Task {
440 state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap()
441 }
442
443 #[tokio::test]
444 async fn a_pool_row_carries_the_control_that_places_it() {
445 let state = state().await;
446 let id = estimated_task(&state, "Write the thing", 45);
447
448 let page = screen(&state);
449
450 assert!(
451 page.contains(&format!("/day/{DAY}/schedule/{id}/place")),
452 "{page}"
453 );
454 // The argument is which slot, offered as the day's own 96, not typed.
455 assert!(page.contains(">09:00<"), "{page}");
456 assert!(page.contains(">23:45<"), "{page}");
457 }
458
459 #[tokio::test]
460 async fn a_task_that_carries_an_estimate_is_not_asked_for_one() {
461 let state = state().await;
462 estimated_task(&state, "Write the thing", 45);
463
464 let page = screen(&state);
465
466 // Asking would be asking the user to confirm what the task already says.
467 assert!(!page.contains("How long it takes"), "{page}");
468 }
469
470 #[tokio::test]
471 async fn a_task_with_no_estimate_is_asked_how_long_it_takes() {
472 let state = state().await;
473 due_task(&state, "Never estimated");
474
475 let page = screen(&state);
476
477 // Placeable, and placing it is what fills the missing data in: `fa9fe9ed`
478 // ruled (d) over refusing the placement outright.
479 assert!(page.contains("How long it takes"), "{page}");
480 assert!(page.contains("Placing it records this"), "{page}");
481 }
482
483 #[tokio::test]
484 async fn placing_a_task_gives_it_a_block_the_size_of_its_estimate() {
485 let state = state().await;
486 let id = estimated_task(&state, "Write the thing", 45);
487
488 post(
489 &state,
490 &format!("/day/{DAY}/schedule/{id}/place"),
491 Params::new().with("at", "540"),
492 );
493
494 let placed = task(&state, id);
495 assert_eq!(placed.scheduled_duration, Some(45));
496 // 540 is 09:00 local on the day the address names, not on any other day.
497 assert_eq!(
498 placed.scheduled_start.unwrap(),
499 Local
500 .from_local_datetime(&day().and_hms_opt(9, 0, 0).unwrap())
501 .unwrap()
502 .with_timezone(&Utc)
503 );
504 }
505
506 #[tokio::test]
507 async fn placing_a_task_hangs_its_block_off_the_task() {
508 let state = state().await;
509 let id = estimated_task(&state, "Write the thing", 45);
510
511 post(
512 &state,
513 &format!("/day/{DAY}/schedule/{id}/place"),
514 Params::new().with("at", "540"),
515 );
516
517 let block = state
518 .events
519 .get_by_linked_task(DESKTOP_USER_ID, id)
520 .unwrap()
521 .expect("placing writes the linked block too");
522 assert_eq!(block.start_time, started_at(&state, id));
523 }
524
525 #[tokio::test]
526 async fn placing_an_unestimated_task_is_what_estimates_it() {
527 // The whole of `fa9fe9ed`'s ruling: the missing data gets filled rather
528 // than defaulted around.
529 let state = state().await;
530 let id = due_task(&state, "Never estimated");
531
532 post(
533 &state,
534 &format!("/day/{DAY}/schedule/{id}/place"),
535 Params::new().with("at", "600").with("minutes", "45"),
536 );
537
538 let placed = task(&state, id);
539 assert_eq!(placed.estimated_minutes, Some(45));
540 assert_eq!(placed.scheduled_duration, Some(45));
541 }
542
543 #[tokio::test]
544 async fn placing_an_unestimated_task_keeps_everything_else_it_had() {
545 // The estimate write is a whole-task update, so the risk it carries is
546 // clearing a field nobody meant to touch.
547 let state = state().await;
548 let id = due_task(&state, "Never estimated");
549 let before = task(&state, id);
550
551 post(
552 &state,
553 &format!("/day/{DAY}/schedule/{id}/place"),
554 Params::new().with("at", "600").with("minutes", "45"),
555 );
556
557 let after = task(&state, id);
558 assert_eq!(after.title, before.title);
559 assert_eq!(after.due, before.due);
560 assert_eq!(after.tags, before.tags);
561 assert_eq!(after.priority, before.priority);
562 }
563
564 #[tokio::test]
565 async fn a_dismissed_ask_places_nothing_and_writes_nothing() {
566 // The distinction between (d) and the silent default it beat, and the
567 // half that is easy to lose: no answer means no placement, not 15 minutes.
568 let state = state().await;
569 let id = due_task(&state, "Never estimated");
570
571 let refused = router().handle(
572 &state,
573 Request::post(format!("/day/{DAY}/schedule/{id}/place"))
574 .sending(Params::new().with("at", "540")),
575 );
576
577 assert!(refused.is_err(), "an unanswered length should not place");
578 let untouched = task(&state, id);
579 assert!(untouched.scheduled_start.is_none());
580 assert!(untouched.estimated_minutes.is_none());
581 }
582
583 #[tokio::test]
584 async fn a_slot_outside_the_day_is_refused() {
585 let state = state().await;
586 let id = estimated_task(&state, "Write the thing", 45);
587
588 let refused = router().handle(
589 &state,
590 Request::post(format!("/day/{DAY}/schedule/{id}/place"))
591 .sending(Params::new().with("at", "1440")),
592 );
593
594 assert!(refused.is_err(), "1440 is tomorrow's midnight");
595 assert!(task(&state, id).scheduled_start.is_none());
596 }
597
598 #[tokio::test]
599 async fn placing_answers_with_the_axis_and_the_pool_it_emptied() {
600 let state = state().await;
601 let id = estimated_task(&state, "Write the thing", 45);
602
603 let response = post(
604 &state,
605 &format!("/day/{DAY}/schedule/{id}/place"),
606 Params::new().with("at", "540"),
607 );
608
609 let Outcome::Fragment { region, .. } = &response.outcome else {
610 panic!("a placement replaces regions, not the screen");
611 };
612 assert_eq!(region, "day-timeline");
613
614 // The pool just lost the row, and a fragment that refreshed only the axis
615 // would leave it showing a task that is now on the day.
616 let pool = response
617 .invalidates
618 .iter()
619 .find(|stale| stale.region == "day-pool")
620 .expect("the pool is stale too");
621 let markup = quasi_webview::Webview::new().fragment(&pool.node);
622 assert!(markup.contains("Nothing else due today."), "{markup}");
623 }
624
625 #[tokio::test]
626 async fn unscheduling_puts_the_task_back_in_the_pool_it_answers_with() {
627 let state = state().await;
628 let id = scheduled_task(&state, "Not today", (10, 0));
629
630 let response = post(
631 &state,
632 &format!("/day/{DAY}/schedule/{id}/unschedule"),
633 Params::new(),
634 );
635
636 let pool = response
637 .invalidates
638 .iter()
639 .find(|stale| stale.region == "day-pool")
640 .expect("the pool is stale too");
641 let markup = quasi_webview::Webview::new().fragment(&pool.node);
642 assert!(markup.contains("Not today"), "{markup}");
643 }
644