//! The chrome panel, driven through the router against a real database. //! //! The assertions worth reading are the two about *movement*. A panel drawn //! once and never again is the whole failure this port can have: the JS //! interval is gone, so if the readout is a string the route formatted, or if //! the answered region drops the call that fetched it, the timer freezes and //! every other assertion here still passes. So one test renders the same //! running timer twice a second apart and demands the readouts differ, and one //! reads the answer's own markup for the call that brings the next one. use std::sync::Arc; use goingson_core::{NewTask, Priority, TaskId}; use quasi_http::Serves as _; use quasi_router::{Outcome, Params, Request, Response}; use super::super::router; use super::{BODY, PANEL}; use crate::state::{AppState, DESKTOP_USER_ID}; async fn state() -> Arc { let (state, _) = crate::test_utils::setup_test_state().await; let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); state .db .conn() .unwrap() .execute( "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ VALUES (?, ?, ?, ?, ?)", rusqlite::params![ DESKTOP_USER_ID.to_string(), "desktop@localhost", "x", "Desktop User", &now, ], ) .unwrap(); state } fn task(state: &AppState, title: &str) -> TaskId { state .tasks .create( DESKTOP_USER_ID, NewTask::builder(title).priority(Priority::Medium).build(), ) .unwrap() .id } fn html(response: &Response) -> String { match &response.outcome { Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen), Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node), Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), Outcome::Anchored { .. } => { panic!("expected content, got a screen drawn at a point on it") } Outcome::Suggestions { field, .. } => { panic!("expected content, got a suggestion list for `{field}`") } Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"), Outcome::Locate(_) => panic!("expected content, got a place on a map"), // `cb62a9dc`. Work that runs somewhere else and a region that says so: // not content, and not a place either. Outcome::Started { region, .. } => { panic!("expected content, got work started in `{region}`") } } } fn get(state: &AppState, path: &str) -> Response { router() .handle(state, Request::get(path).carrying(Params::new())) .expect("the route answers") } fn post(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::post(path).sending(params)) .expect("the route answers") } fn panel(state: &AppState) -> String { html(&get(state, "/timer/panel")) } /// The words inside the readout, or `None` when the panel is not showing one. fn readout(markup: &str) -> Option { let at = markup.find("data-clock=\"since\"")?; let opens = markup[at..].find('>')? + at + 1; let closes = markup[opens..].find('<')? + opens; Some(markup[opens..closes].to_owned()) } #[tokio::test] async fn the_panel_is_chrome_rather_than_a_region_every_screen_repeats() { // Max, 2026-08-20. The point of the ruling: no screen mentions the timer, // and every screen shows it. let chrome = crate::quasi::shell::chrome(); let declared = chrome.panel(PANEL).expect("a panel is declared"); assert_eq!(declared.id, PANEL); let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); // A screen that knows nothing about timers, drawn through a renderer // carrying this chrome. let board = get(&state, "/board"); let Outcome::Screen(screen) = &board.outcome else { panic!("the board answers a screen"); }; let with_chrome = quasi_webview::Webview::new() .with_shell(quasi_webview::Shell::default().with_chrome(crate::quasi::shell::chrome())) .screen(screen); assert!( with_chrome.contains(&format!("id=\"{PANEL}\"")), "{with_chrome}" ); assert!(with_chrome.contains("chrome-panel"), "{with_chrome}"); // And an app that declares none draws what it drew before, which is what // makes the panel additive rather than a change to every screen. let bare = quasi_webview::Webview::new().screen(screen); assert!(!bare.contains("chrome-panel"), "{bare}"); } #[tokio::test] async fn nothing_running_is_an_empty_panel_rather_than_a_bar_saying_so() { let state = state().await; let markup = panel(&state); // No readout, which is what the stylesheet hides the band by. A panel that // said "No timer running" would be furniture across the bottom of every // screen all day. assert!(readout(&markup).is_none(), "{markup}"); assert!(!markup.contains("Stop"), "{markup}"); assert!(!markup.contains("Discard"), "{markup}"); } #[tokio::test] async fn a_running_timer_names_the_task_and_offers_stop_and_discard() { let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let markup = panel(&state); assert!(markup.contains("Write the thing"), "{markup}"); assert!(markup.contains("/timer/stop"), "{markup}"); assert!(markup.contains("/timer/discard"), "{markup}"); // Discarding throws away time already spent and has no undo behind it. assert!(markup.contains("Discard the time"), "{markup}"); } #[tokio::test] async fn the_readout_is_an_instant_the_renderer_ticks() { // Not a formatted elapsed string. `f00244a6`: the app carries the instant, // the renderer carries the words and the cadence. let state = state().await; let id = task(&state, "Write the thing"); let session = state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let markup = panel(&state); assert!(markup.contains("data-clock=\"since\""), "{markup}"); let at = u128::try_from(session.started_at.timestamp()).expect("after the epoch") * 1000; assert!(markup.contains(&format!("data-at=\"{at}\"")), "{markup}"); } #[tokio::test] async fn the_readout_advances() { // The one that matters. Every other assertion here passes just as well // against a timer that renders once and stops, which is what deleting a JS // interval without wiring the renderer's tick produces. let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let first = readout(&panel(&state)).expect("a readout"); // The renderer's granularity is a second, so a second is what it takes to // observe one pass. tokio::time::sleep(std::time::Duration::from_millis(1100)).await; let second = readout(&panel(&state)).expect("a readout"); assert_ne!( first, second, "the readout did not move between two renders a second apart" ); } #[tokio::test] async fn the_panel_keeps_asking_after_it_has_been_answered() { // The other half of advancing, and the half a swap silently removes: the // answer replaces the element, so an answer that dropped the call would be // a panel that updated once. Both the declaration and the answer carry it. let state = state().await; let declared = crate::quasi::shell::chrome(); let quasi_router::Node::Region(slot) = &declared.panel(PANEL).expect("declared").content else { panic!("the panel holds a region"); }; assert_eq!(slot.id, BODY); assert!(slot.live); assert!(slot.fed_by.is_some()); let markup = panel(&state); assert!(markup.contains("hx-get=\"/timer/panel\""), "{markup}"); assert!(markup.contains("hx-trigger=\"load, every"), "{markup}"); } #[tokio::test] async fn stopping_records_the_time_and_leaves_the_panel_empty() { let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let stopped = post(&state, "/timer/stop", Params::new()); let notice = stopped.notice.as_ref().expect("it says what it recorded"); assert!(notice.text.starts_with("Tracked "), "{}", notice.text); let markup = html(&stopped); assert!(readout(&markup).is_none(), "{markup}"); assert!( state .tasks .get_active_timer(DESKTOP_USER_ID) .expect("read") .is_none() ); } #[tokio::test] async fn discarding_leaves_the_panel_empty_and_records_nothing() { let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let discarded = post(&state, "/timer/discard", Params::new()); let markup = html(&discarded); assert!(readout(&markup).is_none(), "{markup}"); let sessions = state .tasks .list_time_sessions(id, DESKTOP_USER_ID) .expect("read"); assert!(sessions.is_empty(), "{sessions:?}"); } #[tokio::test] async fn stopping_what_is_no_longer_running_answers_the_panel_rather_than_failing() { // The timer was stopped somewhere else and the panel is the thing out of // date, which is exactly what `stopActive`'s early return handles. let state = state().await; let stopped = post(&state, "/timer/stop", Params::new()); assert!(readout(&html(&stopped)).is_none()); let discarded = post(&state, "/timer/discard", Params::new()); assert!(readout(&html(&discarded)).is_none()); } #[tokio::test] async fn starting_a_second_timer_is_a_complaint_and_leaves_the_first_running() { let state = state().await; let first = task(&state, "Write the thing"); let second = task(&state, "Write the other thing"); let started = post( &state, "/timer/start", Params::new().with("task", first.to_string()), ); assert!(readout(&html(&started)).is_some()); let refused = post( &state, "/timer/start", Params::new().with("task", second.to_string()), ); let notice = refused.notice.as_ref().expect("it says why"); assert_eq!(notice.tone, makeover_layout::Tone::Warning); let markup = html(&refused); assert!(markup.contains("Write the thing"), "{markup}"); assert!(!markup.contains("Write the other thing"), "{markup}"); } #[tokio::test] async fn a_start_aimed_at_nothing_is_refused_rather_than_guessed_at() { let state = state().await; assert!( router() .handle( &state, Request::post("/timer/start").sending(Params::new().with("task", "not-a-uuid")), ) .is_err() ); assert!( router() .handle(&state, Request::post("/timer/start").sending(Params::new())) .is_err() ); } #[tokio::test] async fn the_drawer_is_where_a_timer_starts_and_stops_offering_it_while_one_runs() { // The panel offers Stop and Discard and nothing that starts anything, so // without this the described app can watch a timer it cannot begin. let state = state().await; let id = task(&state, "Write the thing"); let before = html(&get(&state, &format!("/tasks/{id}"))); assert!(before.contains("Track time"), "{before}"); assert!(before.contains("/timer/start"), "{before}"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let during = html(&get(&state, &format!("/tasks/{id}"))); assert!(!during.contains("Track time"), "{during}"); } // The Timer screen. /// The screen, under the default view. fn screen(state: &AppState) -> String { html(&get(state, "/timer")) } /// The screen, under an address that says something. fn screen_under(state: &AppState, carried: Params) -> String { html( &router() .handle(state, Request::get("/timer").carrying(carried)) .expect("the route answers"), ) } /// A task carrying an estimate, for the report's other half. fn estimated(state: &AppState, title: &str, minutes: i32) -> TaskId { state .tasks .create( DESKTOP_USER_ID, NewTask::builder(title) .priority(Priority::Medium) .estimated_minutes(minutes) .build(), ) .unwrap() .id } /// Time recorded against a task without a timer having run. fn logged(state: &AppState, id: TaskId, minutes: i32) { state .tasks .log_manual_time( id, DESKTOP_USER_ID, goingson_core::PositiveMinutes::try_new(minutes).unwrap(), chrono::Utc::now(), ) .unwrap(); } #[tokio::test] async fn the_screen_offers_a_timer_on_every_task_that_could_take_one() { let state = state().await; task(&state, "Write the thing"); let markup = screen(&state); assert!(markup.contains("Write the thing"), "{markup}"); assert!(markup.contains("/timer/view/track"), "{markup}"); assert!(markup.contains("/timer/view/log"), "{markup}"); } #[tokio::test] async fn a_row_keeps_every_trailing_fact_rather_than_only_the_last() { // `0c540c6f`. `Row::meta` sets rather than appends, so the row's three // settings kept only whichever was placed last: a task with tracked time // said its total and nothing else, and never named its project. One joined // string is the fix, and this is what would catch it coming back. let state = state().await; let id = estimated(&state, "Write the thing", 30); logged(&state, id, 45); let markup = screen(&state); assert!(markup.contains("30m est ยท 45m tracked"), "{markup}"); } #[tokio::test] async fn the_task_being_timed_is_the_band_rather_than_a_row_offering_to_start_it() { let state = state().await; let id = task(&state, "Write the thing"); let other = task(&state, "Write the other thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let markup = screen(&state); assert!(markup.contains("data-clock=\"since\""), "{markup}"); assert!(markup.contains("/timer/view/stop"), "{markup}"); // Once, in the band. `loadTimerView` drops it from the list for the same // reason: the band above is already offering to stop it. assert_eq!(markup.matches("Write the thing").count(), 1, "{markup}"); assert!(markup.contains("Write the other thing"), "{markup}"); assert!(markup.contains("disabled"), "{markup}"); let _ = other; } #[tokio::test] async fn the_screens_stop_answers_the_screen_and_leaves_the_panel_to_catch_up() { // Two elements with two addresses. The panel is `live`, so it re-reads on // its own cadence; answering it from here would swap the wrong region. let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let stopped = post(&state, "/timer/view/stop", Params::new()); let Outcome::Fragment { region, .. } = &stopped.outcome else { panic!("the screen's stop answers a region"); }; assert_eq!(region, super::SESSION); let notice = stopped.notice.as_ref().expect("it says what it recorded"); assert!(notice.text.starts_with("Tracked "), "{}", notice.text); // The rows and the report move with it: every Track control is live again // and the tracked total has changed. let also: Vec<&str> = stopped .invalidates .iter() .map(|stale| stale.region.as_str()) .collect(); assert_eq!(also, vec![super::CHOICES, super::REPORT]); } #[tokio::test] async fn the_split_is_the_address_and_the_focus_card_reads_it() { let state = state().await; let markup = screen_under(&state, Params::new().with("work", "50").with("break", "10")); assert!( markup.contains("A countdown of 50 minutes, then a 10 minute break."), "{markup}" ); assert!(markup.contains("value=\"50\""), "{markup}"); assert!(markup.contains("value=\"10\""), "{markup}"); // Each half carries the other and not itself, or the address would answer // with the value the reader has just replaced. assert!( markup.contains("hx-get=\"/timer?break=10&days=7\""), "{markup}" ); assert!( markup.contains("hx-get=\"/timer?work=50&days=7\""), "{markup}" ); } #[tokio::test] async fn a_silly_split_is_clamped_rather_than_refused() { // `updateFocusSplit`'s `Math.max`/`Math.min`, and a view control's rule: // an unusable number should show a usable one, not an error page. let state = state().await; let markup = screen_under( &state, Params::new() .with("work", "9000") .with("break", "0") .with("days", "100000"), ); assert!( markup.contains("A countdown of 240 minutes, then a 1 minute break."), "{markup}" ); // The window is clamped on the same read, and the report's own call is // where the clamped value shows. assert!(markup.contains("days=365"), "{markup}"); } #[tokio::test] async fn the_row_offers_focus_and_says_what_it_will_spend() { // Left out until migration 068, on the grounds that a Focus button whose // only difference from Track is the sentence in its toast is worse than its // absence. The session records the mode now, so the difference is real. let state = state().await; task(&state, "Write the thing"); let markup = screen(&state); assert!(markup.contains("/timer/view/focus"), "{markup}"); // The split is in the label. The shipped row puts it in a `title`, and a // description has no hint to put it in. assert!(markup.contains("Focus 25m"), "{markup}"); } #[tokio::test] async fn a_focus_session_counts_down_where_a_tracked_one_counts_up() { // The whole of what the two columns bought. Both bands are an instant the // renderer ticks; which instant, and which way, is the difference. let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let tracked = panel(&state); assert!(tracked.contains("Tracking"), "{tracked}"); assert!(tracked.contains("data-clock=\"since\""), "{tracked}"); assert!(!tracked.contains("data-clock=\"until\""), "{tracked}"); state .tasks .discard_timer(id, DESKTOP_USER_ID) .expect("the timer is discarded"); let ends_at = chrono::Utc::now() + chrono::Duration::minutes(25); state .tasks .start_focus_session(id, DESKTOP_USER_ID, ends_at) .expect("the focus session starts"); let focused = panel(&state); assert!(focused.contains("Focus session"), "{focused}"); assert!(focused.contains("data-clock=\"until\""), "{focused}"); let at = u128::try_from(ends_at.timestamp()).expect("after the epoch") * 1000; assert!(focused.contains(&format!("data-at=\"{at}\"")), "{focused}"); } #[tokio::test] async fn the_screen_starts_a_focus_session_carrying_the_split_it_was_pressed_under() { // "Carrying the split it was started under" is the point: the instant is // recorded, so moving the split afterwards moves the next countdown rather // than this one. let state = state().await; let id = task(&state, "Write the thing"); let before = chrono::Utc::now(); post( &state, "/timer/view/focus", Params::new() .with("task", id.to_string()) .with("work", "50"), ); let (session, _) = state .tasks .get_active_timer(DESKTOP_USER_ID) .expect("read") .expect("one is running"); assert_eq!(session.mode, goingson_core::TimeSessionMode::Focus); let ends_at = session.ends_at.expect("a countdown has an end"); let ran_for = (ends_at - before).num_minutes(); assert!((49..=51).contains(&ran_for), "{ran_for} minutes"); } #[tokio::test] async fn the_log_control_asks_for_a_duration_and_a_day_before_it_calls() { let state = state().await; let id = task(&state, "Write the thing"); let markup = screen(&state); assert!(markup.contains("name=\"minutes\""), "{markup}"); assert!(markup.contains("name=\"date\""), "{markup}"); assert!(markup.contains("type=\"date\""), "{markup}"); let logged = post( &state, "/timer/view/log", Params::new() .with("task", id.to_string()) .with("minutes", "90") .with("date", "2026-08-18"), ); assert_eq!( logged.notice.as_ref().expect("it says so").text, "Logged 1h 30m" ); let sessions = state .tasks .list_time_sessions(id, DESKTOP_USER_ID) .expect("read"); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].duration_minutes, Some(90)); // Noon on the day asked for, which is `submitLogTime`'s own conversion. assert_eq!( sessions[0].started_at.format("%Y-%m-%d").to_string(), "2026-08-18" ); } #[tokio::test] async fn logging_nothing_is_refused_rather_than_recorded_as_an_empty_session() { let state = state().await; let id = task(&state, "Write the thing"); assert!( router() .handle( &state, Request::post("/timer/view/log").sending( Params::new() .with("task", id.to_string()) .with("minutes", "0") ), ) .is_err() ); assert!( router() .handle( &state, Request::post("/timer/view/log") .sending(Params::new().with("task", id.to_string())), ) .is_err() ); } #[tokio::test] async fn the_report_says_where_the_time_went_and_offers_three_windows() { let state = state().await; let id = estimated(&state, "Write the thing", 60); logged(&state, id, 90); let markup = screen(&state); assert!(markup.contains("Where the time went"), "{markup}"); assert!( markup.contains("1h 30m tracked in the last 7 days"), "{markup}" ); for window in ["7d", "30d", "90d"] { assert!(markup.contains(&format!(">{window}<")), "{markup}"); } // The estimate half: 90 actual against 60 estimated is an overrun, and the // percentage is the fact the shipped row tones. assert!(markup.contains("1h est / 1h 30m actual"), "{markup}"); assert!(markup.contains("150%"), "{markup}"); } #[tokio::test] async fn a_project_with_no_estimates_says_so_rather_than_showing_a_percentage_of_nothing() { let state = state().await; let id = task(&state, "Write the thing"); logged(&state, id, 30); let markup = screen(&state); assert!(markup.contains("no estimates"), "{markup}"); assert!(!markup.contains(" est / "), "{markup}"); } #[tokio::test] async fn changing_the_window_re_reads_the_report_and_moves_the_address() { let state = state().await; let id = task(&state, "Write the thing"); logged(&state, id, 30); let answered = router() .handle( &state, Request::get("/timer/report").carrying(Params::new().with("days", "30")), ) .expect("the route answers"); let Outcome::Fragment { region, .. } = &answered.outcome else { panic!("the window answers the report alone"); }; assert_eq!(region, super::REPORT); // The window is a fact about the view rather than about the region it is // drawn in, so a reload lands on the same one. assert_eq!( answered.address, Some(quasi_router::Address::Enters( "/timer?work=25&break=5&days=30".to_owned() )) ); let markup = html(&answered); assert!(markup.contains("last 30 days"), "{markup}"); // Re-declared on the answer, the panel's rule: the swap replaces the // element, so an answer that dropped the call is a region that stops asking. assert!(markup.contains("hx-get=\"/timer/report"), "{markup}"); } #[tokio::test] async fn the_day_view_carries_the_tracked_time_panel() { // `time-summary.js` renders into the day sidebar, and the described day // view said one line about today until this arrived. let state = state().await; let id = task(&state, "Write the thing"); logged(&state, id, 45); let day = html(&get(&state, "/day")); assert!(day.contains(&format!("id=\"{}\"", super::SUMMARY)), "{day}"); assert!(day.contains("hx-get=\"/timer/summary\""), "{day}"); assert!(day.contains("Time tracked"), "{day}"); assert!(day.contains("45m"), "{day}"); assert!(!day.contains("Tracked today:"), "{day}"); } #[tokio::test] async fn the_summary_answers_at_its_own_address_too() { let state = state().await; let answered = get(&state, "/timer/summary"); let Outcome::Fragment { region, .. } = &answered.outcome else { panic!("the summary answers a region"); }; assert_eq!(region, super::SUMMARY); assert!(html(&answered).contains("hx-get=\"/timer/summary\"")); }