Skip to main content

max / goingson

25.4 KB · 737 lines History Blame Raw
1 //! The chrome panel, driven through the router against a real database.
2 //!
3 //! The assertions worth reading are the two about *movement*. A panel drawn
4 //! once and never again is the whole failure this port can have: the JS
5 //! interval is gone, so if the readout is a string the route formatted, or if
6 //! the answered region drops the call that fetched it, the timer freezes and
7 //! every other assertion here still passes. So one test renders the same
8 //! running timer twice a second apart and demands the readouts differ, and one
9 //! reads the answer's own markup for the call that brings the next one.
10
11 use std::sync::Arc;
12
13 use goingson_core::{NewTask, Priority, TaskId};
14 use quasi_http::Serves as _;
15 use quasi_router::{Outcome, Params, Request, Response};
16
17 use super::super::router;
18 use super::{BODY, PANEL};
19 use crate::state::{AppState, DESKTOP_USER_ID};
20
21 async fn state() -> Arc<AppState> {
22 let (state, _) = crate::test_utils::setup_test_state().await;
23 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
24 state
25 .db
26 .conn()
27 .unwrap()
28 .execute(
29 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
30 VALUES (?, ?, ?, ?, ?)",
31 rusqlite::params![
32 DESKTOP_USER_ID.to_string(),
33 "desktop@localhost",
34 "x",
35 "Desktop User",
36 &now,
37 ],
38 )
39 .unwrap();
40 state
41 }
42
43 fn task(state: &AppState, title: &str) -> TaskId {
44 state
45 .tasks
46 .create(
47 DESKTOP_USER_ID,
48 NewTask::builder(title).priority(Priority::Medium).build(),
49 )
50 .unwrap()
51 .id
52 }
53
54 fn html(response: &Response) -> String {
55 match &response.outcome {
56 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen),
57 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node),
58 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
59 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
60 Outcome::Anchored { .. } => {
61 panic!("expected content, got a screen drawn at a point on it")
62 }
63 Outcome::Suggestions { field, .. } => {
64 panic!("expected content, got a suggestion list for `{field}`")
65 }
66 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
67 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
68 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
69 // not content, and not a place either.
70 Outcome::Started { region, .. } => {
71 panic!("expected content, got work started in `{region}`")
72 }
73 }
74 }
75
76 fn get(state: &AppState, path: &str) -> Response {
77 router()
78 .handle(state, Request::get(path).carrying(Params::new()))
79 .expect("the route answers")
80 }
81
82 fn post(state: &AppState, path: &str, params: Params) -> Response {
83 router()
84 .handle(state, Request::post(path).sending(params))
85 .expect("the route answers")
86 }
87
88 fn panel(state: &AppState) -> String {
89 html(&get(state, "/timer/panel"))
90 }
91
92 /// The words inside the readout, or `None` when the panel is not showing one.
93 fn readout(markup: &str) -> Option<String> {
94 let at = markup.find("data-clock=\"since\"")?;
95 let opens = markup[at..].find('>')? + at + 1;
96 let closes = markup[opens..].find('<')? + opens;
97 Some(markup[opens..closes].to_owned())
98 }
99
100 #[tokio::test]
101 async fn the_panel_is_chrome_rather_than_a_region_every_screen_repeats() {
102 // Max, 2026-08-20. The point of the ruling: no screen mentions the timer,
103 // and every screen shows it.
104 let chrome = crate::quasi::shell::chrome();
105 let declared = chrome.panel(PANEL).expect("a panel is declared");
106 assert_eq!(declared.id, PANEL);
107
108 let state = state().await;
109 let id = task(&state, "Write the thing");
110 state
111 .tasks
112 .start_timer(id, DESKTOP_USER_ID)
113 .expect("the timer starts");
114
115 // A screen that knows nothing about timers, drawn through a renderer
116 // carrying this chrome.
117 let board = get(&state, "/board");
118 let Outcome::Screen(screen) = &board.outcome else {
119 panic!("the board answers a screen");
120 };
121 let with_chrome = quasi_webview::Webview::new()
122 .with_shell(quasi_webview::Shell::default().with_chrome(crate::quasi::shell::chrome()))
123 .screen(screen);
124 assert!(
125 with_chrome.contains(&format!("id=\"{PANEL}\"")),
126 "{with_chrome}"
127 );
128 assert!(with_chrome.contains("chrome-panel"), "{with_chrome}");
129
130 // And an app that declares none draws what it drew before, which is what
131 // makes the panel additive rather than a change to every screen.
132 let bare = quasi_webview::Webview::new().screen(screen);
133 assert!(!bare.contains("chrome-panel"), "{bare}");
134 }
135
136 #[tokio::test]
137 async fn nothing_running_is_an_empty_panel_rather_than_a_bar_saying_so() {
138 let state = state().await;
139 let markup = panel(&state);
140 // No readout, which is what the stylesheet hides the band by. A panel that
141 // said "No timer running" would be furniture across the bottom of every
142 // screen all day.
143 assert!(readout(&markup).is_none(), "{markup}");
144 assert!(!markup.contains("Stop"), "{markup}");
145 assert!(!markup.contains("Discard"), "{markup}");
146 }
147
148 #[tokio::test]
149 async fn a_running_timer_names_the_task_and_offers_stop_and_discard() {
150 let state = state().await;
151 let id = task(&state, "Write the thing");
152 state
153 .tasks
154 .start_timer(id, DESKTOP_USER_ID)
155 .expect("the timer starts");
156
157 let markup = panel(&state);
158 assert!(markup.contains("Write the thing"), "{markup}");
159 assert!(markup.contains("/timer/stop"), "{markup}");
160 assert!(markup.contains("/timer/discard"), "{markup}");
161 // Discarding throws away time already spent and has no undo behind it.
162 assert!(markup.contains("Discard the time"), "{markup}");
163 }
164
165 #[tokio::test]
166 async fn the_readout_is_an_instant_the_renderer_ticks() {
167 // Not a formatted elapsed string. `f00244a6`: the app carries the instant,
168 // the renderer carries the words and the cadence.
169 let state = state().await;
170 let id = task(&state, "Write the thing");
171 let session = state
172 .tasks
173 .start_timer(id, DESKTOP_USER_ID)
174 .expect("the timer starts");
175
176 let markup = panel(&state);
177 assert!(markup.contains("data-clock=\"since\""), "{markup}");
178 let at = u128::try_from(session.started_at.timestamp()).expect("after the epoch") * 1000;
179 assert!(markup.contains(&format!("data-at=\"{at}\"")), "{markup}");
180 }
181
182 #[tokio::test]
183 async fn the_readout_advances() {
184 // The one that matters. Every other assertion here passes just as well
185 // against a timer that renders once and stops, which is what deleting a JS
186 // interval without wiring the renderer's tick produces.
187 let state = state().await;
188 let id = task(&state, "Write the thing");
189 state
190 .tasks
191 .start_timer(id, DESKTOP_USER_ID)
192 .expect("the timer starts");
193
194 let first = readout(&panel(&state)).expect("a readout");
195 // The renderer's granularity is a second, so a second is what it takes to
196 // observe one pass.
197 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
198 let second = readout(&panel(&state)).expect("a readout");
199
200 assert_ne!(
201 first, second,
202 "the readout did not move between two renders a second apart"
203 );
204 }
205
206 #[tokio::test]
207 async fn the_panel_keeps_asking_after_it_has_been_answered() {
208 // The other half of advancing, and the half a swap silently removes: the
209 // answer replaces the element, so an answer that dropped the call would be
210 // a panel that updated once. Both the declaration and the answer carry it.
211 let state = state().await;
212 let declared = crate::quasi::shell::chrome();
213 let quasi_router::Node::Region(slot) = &declared.panel(PANEL).expect("declared").content else {
214 panic!("the panel holds a region");
215 };
216 assert_eq!(slot.id, BODY);
217 assert!(slot.live);
218 assert!(slot.fed_by.is_some());
219
220 let markup = panel(&state);
221 assert!(markup.contains("hx-get=\"/timer/panel\""), "{markup}");
222 assert!(markup.contains("hx-trigger=\"load, every"), "{markup}");
223 }
224
225 #[tokio::test]
226 async fn stopping_records_the_time_and_leaves_the_panel_empty() {
227 let state = state().await;
228 let id = task(&state, "Write the thing");
229 state
230 .tasks
231 .start_timer(id, DESKTOP_USER_ID)
232 .expect("the timer starts");
233
234 let stopped = post(&state, "/timer/stop", Params::new());
235 let notice = stopped.notice.as_ref().expect("it says what it recorded");
236 assert!(notice.text.starts_with("Tracked "), "{}", notice.text);
237
238 let markup = html(&stopped);
239 assert!(readout(&markup).is_none(), "{markup}");
240 assert!(
241 state
242 .tasks
243 .get_active_timer(DESKTOP_USER_ID)
244 .expect("read")
245 .is_none()
246 );
247 }
248
249 #[tokio::test]
250 async fn discarding_leaves_the_panel_empty_and_records_nothing() {
251 let state = state().await;
252 let id = task(&state, "Write the thing");
253 state
254 .tasks
255 .start_timer(id, DESKTOP_USER_ID)
256 .expect("the timer starts");
257
258 let discarded = post(&state, "/timer/discard", Params::new());
259 let markup = html(&discarded);
260 assert!(readout(&markup).is_none(), "{markup}");
261
262 let sessions = state
263 .tasks
264 .list_time_sessions(id, DESKTOP_USER_ID)
265 .expect("read");
266 assert!(sessions.is_empty(), "{sessions:?}");
267 }
268
269 #[tokio::test]
270 async fn stopping_what_is_no_longer_running_answers_the_panel_rather_than_failing() {
271 // The timer was stopped somewhere else and the panel is the thing out of
272 // date, which is exactly what `stopActive`'s early return handles.
273 let state = state().await;
274 let stopped = post(&state, "/timer/stop", Params::new());
275 assert!(readout(&html(&stopped)).is_none());
276 let discarded = post(&state, "/timer/discard", Params::new());
277 assert!(readout(&html(&discarded)).is_none());
278 }
279
280 #[tokio::test]
281 async fn starting_a_second_timer_is_a_complaint_and_leaves_the_first_running() {
282 let state = state().await;
283 let first = task(&state, "Write the thing");
284 let second = task(&state, "Write the other thing");
285
286 let started = post(
287 &state,
288 "/timer/start",
289 Params::new().with("task", first.to_string()),
290 );
291 assert!(readout(&html(&started)).is_some());
292
293 let refused = post(
294 &state,
295 "/timer/start",
296 Params::new().with("task", second.to_string()),
297 );
298 let notice = refused.notice.as_ref().expect("it says why");
299 assert_eq!(notice.tone, makeover_layout::Tone::Warning);
300
301 let markup = html(&refused);
302 assert!(markup.contains("Write the thing"), "{markup}");
303 assert!(!markup.contains("Write the other thing"), "{markup}");
304 }
305
306 #[tokio::test]
307 async fn a_start_aimed_at_nothing_is_refused_rather_than_guessed_at() {
308 let state = state().await;
309 assert!(
310 router()
311 .handle(
312 &state,
313 Request::post("/timer/start").sending(Params::new().with("task", "not-a-uuid")),
314 )
315 .is_err()
316 );
317 assert!(
318 router()
319 .handle(&state, Request::post("/timer/start").sending(Params::new()))
320 .is_err()
321 );
322 }
323
324 #[tokio::test]
325 async fn the_drawer_is_where_a_timer_starts_and_stops_offering_it_while_one_runs() {
326 // The panel offers Stop and Discard and nothing that starts anything, so
327 // without this the described app can watch a timer it cannot begin.
328 let state = state().await;
329 let id = task(&state, "Write the thing");
330
331 let before = html(&get(&state, &format!("/tasks/{id}")));
332 assert!(before.contains("Track time"), "{before}");
333 assert!(before.contains("/timer/start"), "{before}");
334
335 state
336 .tasks
337 .start_timer(id, DESKTOP_USER_ID)
338 .expect("the timer starts");
339
340 let during = html(&get(&state, &format!("/tasks/{id}")));
341 assert!(!during.contains("Track time"), "{during}");
342 }
343
344 // The Timer screen.
345
346 /// The screen, under the default view.
347 fn screen(state: &AppState) -> String {
348 html(&get(state, "/timer"))
349 }
350
351 /// The screen, under an address that says something.
352 fn screen_under(state: &AppState, carried: Params) -> String {
353 html(
354 &router()
355 .handle(state, Request::get("/timer").carrying(carried))
356 .expect("the route answers"),
357 )
358 }
359
360 /// A task carrying an estimate, for the report's other half.
361 fn estimated(state: &AppState, title: &str, minutes: i32) -> TaskId {
362 state
363 .tasks
364 .create(
365 DESKTOP_USER_ID,
366 NewTask::builder(title)
367 .priority(Priority::Medium)
368 .estimated_minutes(minutes)
369 .build(),
370 )
371 .unwrap()
372 .id
373 }
374
375 /// Time recorded against a task without a timer having run.
376 fn logged(state: &AppState, id: TaskId, minutes: i32) {
377 state
378 .tasks
379 .log_manual_time(
380 id,
381 DESKTOP_USER_ID,
382 goingson_core::PositiveMinutes::try_new(minutes).unwrap(),
383 chrono::Utc::now(),
384 )
385 .unwrap();
386 }
387
388 #[tokio::test]
389 async fn the_screen_offers_a_timer_on_every_task_that_could_take_one() {
390 let state = state().await;
391 task(&state, "Write the thing");
392
393 let markup = screen(&state);
394 assert!(markup.contains("Write the thing"), "{markup}");
395 assert!(markup.contains("/timer/view/track"), "{markup}");
396 assert!(markup.contains("/timer/view/log"), "{markup}");
397 }
398
399 #[tokio::test]
400 async fn a_row_keeps_every_trailing_fact_rather_than_only_the_last() {
401 // `0c540c6f`. `Row::meta` sets rather than appends, so the row's three
402 // settings kept only whichever was placed last: a task with tracked time
403 // said its total and nothing else, and never named its project. One joined
404 // string is the fix, and this is what would catch it coming back.
405 let state = state().await;
406 let id = estimated(&state, "Write the thing", 30);
407 logged(&state, id, 45);
408
409 let markup = screen(&state);
410 assert!(markup.contains("30m est · 45m tracked"), "{markup}");
411 }
412
413 #[tokio::test]
414 async fn the_task_being_timed_is_the_band_rather_than_a_row_offering_to_start_it() {
415 let state = state().await;
416 let id = task(&state, "Write the thing");
417 let other = task(&state, "Write the other thing");
418 state
419 .tasks
420 .start_timer(id, DESKTOP_USER_ID)
421 .expect("the timer starts");
422
423 let markup = screen(&state);
424 assert!(markup.contains("data-clock=\"since\""), "{markup}");
425 assert!(markup.contains("/timer/view/stop"), "{markup}");
426
427 // Once, in the band. `loadTimerView` drops it from the list for the same
428 // reason: the band above is already offering to stop it.
429 assert_eq!(markup.matches("Write the thing").count(), 1, "{markup}");
430 assert!(markup.contains("Write the other thing"), "{markup}");
431 assert!(markup.contains("disabled"), "{markup}");
432 let _ = other;
433 }
434
435 #[tokio::test]
436 async fn the_screens_stop_answers_the_screen_and_leaves_the_panel_to_catch_up() {
437 // Two elements with two addresses. The panel is `live`, so it re-reads on
438 // its own cadence; answering it from here would swap the wrong region.
439 let state = state().await;
440 let id = task(&state, "Write the thing");
441 state
442 .tasks
443 .start_timer(id, DESKTOP_USER_ID)
444 .expect("the timer starts");
445
446 let stopped = post(&state, "/timer/view/stop", Params::new());
447 let Outcome::Fragment { region, .. } = &stopped.outcome else {
448 panic!("the screen's stop answers a region");
449 };
450 assert_eq!(region, super::SESSION);
451 let notice = stopped.notice.as_ref().expect("it says what it recorded");
452 assert!(notice.text.starts_with("Tracked "), "{}", notice.text);
453
454 // The rows and the report move with it: every Track control is live again
455 // and the tracked total has changed.
456 let also: Vec<&str> = stopped
457 .invalidates
458 .iter()
459 .map(|stale| stale.region.as_str())
460 .collect();
461 assert_eq!(also, vec![super::CHOICES, super::REPORT]);
462 }
463
464 #[tokio::test]
465 async fn the_split_is_the_address_and_the_focus_card_reads_it() {
466 let state = state().await;
467 let markup = screen_under(&state, Params::new().with("work", "50").with("break", "10"));
468 assert!(
469 markup.contains("A countdown of 50 minutes, then a 10 minute break."),
470 "{markup}"
471 );
472 assert!(markup.contains("value=\"50\""), "{markup}");
473 assert!(markup.contains("value=\"10\""), "{markup}");
474
475 // Each half carries the other and not itself, or the address would answer
476 // with the value the reader has just replaced.
477 assert!(
478 markup.contains("hx-get=\"/timer?break=10&amp;days=7\""),
479 "{markup}"
480 );
481 assert!(
482 markup.contains("hx-get=\"/timer?work=50&amp;days=7\""),
483 "{markup}"
484 );
485 }
486
487 #[tokio::test]
488 async fn a_silly_split_is_clamped_rather_than_refused() {
489 // `updateFocusSplit`'s `Math.max`/`Math.min`, and a view control's rule:
490 // an unusable number should show a usable one, not an error page.
491 let state = state().await;
492 let markup = screen_under(
493 &state,
494 Params::new()
495 .with("work", "9000")
496 .with("break", "0")
497 .with("days", "100000"),
498 );
499 assert!(
500 markup.contains("A countdown of 240 minutes, then a 1 minute break."),
501 "{markup}"
502 );
503 // The window is clamped on the same read, and the report's own call is
504 // where the clamped value shows.
505 assert!(markup.contains("days=365"), "{markup}");
506 }
507
508 #[tokio::test]
509 async fn the_row_offers_focus_and_says_what_it_will_spend() {
510 // Left out until migration 068, on the grounds that a Focus button whose
511 // only difference from Track is the sentence in its toast is worse than its
512 // absence. The session records the mode now, so the difference is real.
513 let state = state().await;
514 task(&state, "Write the thing");
515 let markup = screen(&state);
516 assert!(markup.contains("/timer/view/focus"), "{markup}");
517 // The split is in the label. The shipped row puts it in a `title`, and a
518 // description has no hint to put it in.
519 assert!(markup.contains("Focus 25m"), "{markup}");
520 }
521
522 #[tokio::test]
523 async fn a_focus_session_counts_down_where_a_tracked_one_counts_up() {
524 // The whole of what the two columns bought. Both bands are an instant the
525 // renderer ticks; which instant, and which way, is the difference.
526 let state = state().await;
527 let id = task(&state, "Write the thing");
528
529 state
530 .tasks
531 .start_timer(id, DESKTOP_USER_ID)
532 .expect("the timer starts");
533 let tracked = panel(&state);
534 assert!(tracked.contains("Tracking"), "{tracked}");
535 assert!(tracked.contains("data-clock=\"since\""), "{tracked}");
536 assert!(!tracked.contains("data-clock=\"until\""), "{tracked}");
537
538 state
539 .tasks
540 .discard_timer(id, DESKTOP_USER_ID)
541 .expect("the timer is discarded");
542
543 let ends_at = chrono::Utc::now() + chrono::Duration::minutes(25);
544 state
545 .tasks
546 .start_focus_session(id, DESKTOP_USER_ID, ends_at)
547 .expect("the focus session starts");
548 let focused = panel(&state);
549 assert!(focused.contains("Focus session"), "{focused}");
550 assert!(focused.contains("data-clock=\"until\""), "{focused}");
551 let at = u128::try_from(ends_at.timestamp()).expect("after the epoch") * 1000;
552 assert!(focused.contains(&format!("data-at=\"{at}\"")), "{focused}");
553 }
554
555 #[tokio::test]
556 async fn the_screen_starts_a_focus_session_carrying_the_split_it_was_pressed_under() {
557 // "Carrying the split it was started under" is the point: the instant is
558 // recorded, so moving the split afterwards moves the next countdown rather
559 // than this one.
560 let state = state().await;
561 let id = task(&state, "Write the thing");
562
563 let before = chrono::Utc::now();
564 post(
565 &state,
566 "/timer/view/focus",
567 Params::new()
568 .with("task", id.to_string())
569 .with("work", "50"),
570 );
571
572 let (session, _) = state
573 .tasks
574 .get_active_timer(DESKTOP_USER_ID)
575 .expect("read")
576 .expect("one is running");
577 assert_eq!(session.mode, goingson_core::TimeSessionMode::Focus);
578 let ends_at = session.ends_at.expect("a countdown has an end");
579 let ran_for = (ends_at - before).num_minutes();
580 assert!((49..=51).contains(&ran_for), "{ran_for} minutes");
581 }
582
583 #[tokio::test]
584 async fn the_log_control_asks_for_a_duration_and_a_day_before_it_calls() {
585 let state = state().await;
586 let id = task(&state, "Write the thing");
587
588 let markup = screen(&state);
589 assert!(markup.contains("name=\"minutes\""), "{markup}");
590 assert!(markup.contains("name=\"date\""), "{markup}");
591 assert!(markup.contains("type=\"date\""), "{markup}");
592
593 let logged = post(
594 &state,
595 "/timer/view/log",
596 Params::new()
597 .with("task", id.to_string())
598 .with("minutes", "90")
599 .with("date", "2026-08-18"),
600 );
601 assert_eq!(
602 logged.notice.as_ref().expect("it says so").text,
603 "Logged 1h 30m"
604 );
605
606 let sessions = state
607 .tasks
608 .list_time_sessions(id, DESKTOP_USER_ID)
609 .expect("read");
610 assert_eq!(sessions.len(), 1);
611 assert_eq!(sessions[0].duration_minutes, Some(90));
612 // Noon on the day asked for, which is `submitLogTime`'s own conversion.
613 assert_eq!(
614 sessions[0].started_at.format("%Y-%m-%d").to_string(),
615 "2026-08-18"
616 );
617 }
618
619 #[tokio::test]
620 async fn logging_nothing_is_refused_rather_than_recorded_as_an_empty_session() {
621 let state = state().await;
622 let id = task(&state, "Write the thing");
623 assert!(
624 router()
625 .handle(
626 &state,
627 Request::post("/timer/view/log").sending(
628 Params::new()
629 .with("task", id.to_string())
630 .with("minutes", "0")
631 ),
632 )
633 .is_err()
634 );
635 assert!(
636 router()
637 .handle(
638 &state,
639 Request::post("/timer/view/log")
640 .sending(Params::new().with("task", id.to_string())),
641 )
642 .is_err()
643 );
644 }
645
646 #[tokio::test]
647 async fn the_report_says_where_the_time_went_and_offers_three_windows() {
648 let state = state().await;
649 let id = estimated(&state, "Write the thing", 60);
650 logged(&state, id, 90);
651
652 let markup = screen(&state);
653 assert!(markup.contains("Where the time went"), "{markup}");
654 assert!(
655 markup.contains("1h 30m tracked in the last 7 days"),
656 "{markup}"
657 );
658 for window in ["7d", "30d", "90d"] {
659 assert!(markup.contains(&format!(">{window}<")), "{markup}");
660 }
661 // The estimate half: 90 actual against 60 estimated is an overrun, and the
662 // percentage is the fact the shipped row tones.
663 assert!(markup.contains("1h est / 1h 30m actual"), "{markup}");
664 assert!(markup.contains("150%"), "{markup}");
665 }
666
667 #[tokio::test]
668 async fn a_project_with_no_estimates_says_so_rather_than_showing_a_percentage_of_nothing() {
669 let state = state().await;
670 let id = task(&state, "Write the thing");
671 logged(&state, id, 30);
672
673 let markup = screen(&state);
674 assert!(markup.contains("no estimates"), "{markup}");
675 assert!(!markup.contains(" est / "), "{markup}");
676 }
677
678 #[tokio::test]
679 async fn changing_the_window_re_reads_the_report_and_moves_the_address() {
680 let state = state().await;
681 let id = task(&state, "Write the thing");
682 logged(&state, id, 30);
683
684 let answered = router()
685 .handle(
686 &state,
687 Request::get("/timer/report").carrying(Params::new().with("days", "30")),
688 )
689 .expect("the route answers");
690 let Outcome::Fragment { region, .. } = &answered.outcome else {
691 panic!("the window answers the report alone");
692 };
693 assert_eq!(region, super::REPORT);
694
695 // The window is a fact about the view rather than about the region it is
696 // drawn in, so a reload lands on the same one.
697 assert_eq!(
698 answered.address,
699 Some(quasi_router::Address::Enters(
700 "/timer?work=25&break=5&days=30".to_owned()
701 ))
702 );
703
704 let markup = html(&answered);
705 assert!(markup.contains("last 30 days"), "{markup}");
706 // Re-declared on the answer, the panel's rule: the swap replaces the
707 // element, so an answer that dropped the call is a region that stops asking.
708 assert!(markup.contains("hx-get=\"/timer/report"), "{markup}");
709 }
710
711 #[tokio::test]
712 async fn the_day_view_carries_the_tracked_time_panel() {
713 // `time-summary.js` renders into the day sidebar, and the described day
714 // view said one line about today until this arrived.
715 let state = state().await;
716 let id = task(&state, "Write the thing");
717 logged(&state, id, 45);
718
719 let day = html(&get(&state, "/day"));
720 assert!(day.contains(&format!("id=\"{}\"", super::SUMMARY)), "{day}");
721 assert!(day.contains("hx-get=\"/timer/summary\""), "{day}");
722 assert!(day.contains("Time tracked"), "{day}");
723 assert!(day.contains("45m"), "{day}");
724 assert!(!day.contains("Tracked today:"), "{day}");
725 }
726
727 #[tokio::test]
728 async fn the_summary_answers_at_its_own_address_too() {
729 let state = state().await;
730 let answered = get(&state, "/timer/summary");
731 let Outcome::Fragment { region, .. } = &answered.outcome else {
732 panic!("the summary answers a region");
733 };
734 assert_eq!(region, super::SUMMARY);
735 assert!(html(&answered).contains("hx-get=\"/timer/summary\""));
736 }
737