//! The monthly review, driven through the router against a real database. //! //! Same standard as the screens before it: no Tauri runtime and no window, the //! description asserted, and the markup only where the markup is the point. //! Every workaround this port had to take is asserted here rather than left to //! be noticed, so closing a finding is a test that has to change. use std::sync::Arc; use goingson_core::{MonthlyGoalStatus, NewTask, Priority, monthly_review}; use quasi_http::Serves as _; use quasi_router::Outcome; use quasi_router::{Params, Request, Response}; use super::super::router; use crate::state::{AppState, DESKTOP_USER_ID}; /// State with the desktop user in place, which is who the handlers read as. 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 } /// The month the tests write into, which is the one anything created now lands /// in. fn this_month() -> String { monthly_review::current_month_start() .format("%Y-%m") .to_string() } fn get(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::get(path).carrying(params)) .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 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), // Deliberately not a wildcard, for the reason the task tests give: a // redirect has no body, and a fallback returning empty markup would // read as a screen that rendered nothing. Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), // Same reasoning one step along: an overlay is a screen, but it is not // the screen this route was asked for. Rendering it here would let a // route that answered with the command palette pass as the page. 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}`") } } } /// The review for this month. fn review(state: &AppState) -> String { html(get(state, "/monthly-review", Params::new())) } /// A goal on this month, through the described route. fn add_goal(state: &AppState, text: &str) -> Response { post( state, "/monthly-review/goals", Params::new().with("month", this_month()).with("text", text), ) } fn goals(state: &AppState) -> Vec { state .monthly_reviews .list_goals(DESKTOP_USER_ID, &this_month()) .expect("goals read") } #[tokio::test] async fn the_review_carries_the_sections_that_have_something_to_say() { let state = state().await; let page = review(&state); // The Numbers, Goals and Reflection are unconditional: a month with nothing // in it still has counts of zero, still offers a goal, and is still a month // you can write about. for section in ["The Month", "The Numbers", "Goals", "Reflection"] { assert!(page.contains(section), "missing section: {section}"); } // Accomplished, Project Pulse, Project Health and Patterns are absent // rather than empty. A heading over nothing is a claim the screen cannot // support, which is what `Node::empty` exists to say instead. for section in ["Accomplished", "Project Pulse", "Patterns"] { assert!( !page.contains(section), "an empty month should not claim: {section}" ); } } #[tokio::test] async fn an_empty_month_says_so_as_a_stand_in() { let state = state().await; let page = review(&state); assert!(page.contains("Nothing recorded this month yet.")); assert!(page.contains(r#"data-state="empty""#)); } #[tokio::test] async fn a_day_carries_its_counts_as_numbers_rather_than_as_a_shade() { // The first finding, and the second consumer of the weekly review's. Core // computes `intensity` as a 0-3 bucket and the JS turns it into one of four // background shades. A shade runs out of room at 3; the number does not. let state = state().await; for i in 0..5 { let task = state .tasks .create( DESKTOP_USER_ID, NewTask::builder(format!("Done {i}")) .title(format!("Done {i}")) .priority(Priority::High) .build(), ) .unwrap(); state .tasks .complete(task.id, DESKTOP_USER_ID) .expect("completed"); } let page = review(&state); assert!( page.contains("5 done"), "the real count, not a bucket: {page}" ); } #[tokio::test] async fn the_month_is_an_address_and_every_control_carries_it() { // Decision 2, and the consequence the weekly review recorded: an action // offered under a month has to carry it, or acting moves the user to this // month and writes there. let state = state().await; let past = "2026-01"; let page = html(get( &state, "/monthly-review", Params::new().with("month", past), )); assert!(page.contains("January 2026"), "got: {page}"); // The add-goal form posts under the month being looked at. assert!( page.contains(&format!("month={past}")) || page.contains(past), "the month rides on the controls: {page}" ); } #[tokio::test] async fn this_month_is_the_one_control_that_drops_the_month() { // Every other control carries the month it was offered under. This one's // whole job is to leave it, so it is bare on purpose. let state = state().await; let page = html(get( &state, "/monthly-review", Params::new().with("month", "2026-01"), )); assert!(page.contains("This month"), "got: {page}"); } #[tokio::test] async fn a_hand_typed_month_lands_on_this_month_rather_than_an_error() { // Same tolerance the week has, and for the same reason: this is an address // a person can type, and the useful answer is a screen. let state = state().await; let page = html(get( &state, "/monthly-review", Params::new().with("month", "not-a-month"), )); let expected = monthly_review::format_month_display(monthly_review::current_month_start()); assert!(page.contains(&expected), "got: {page}"); } #[tokio::test] async fn stepping_back_from_january_lands_in_december() { // Months are not a fixed number of days, which is why the arrows do not use // `Duration`. Stepping 31 days back from 1 March lands in January. let state = state().await; let page = html(get( &state, "/monthly-review", Params::new().with("month", "2026-01"), )); assert!( page.contains("2025-12"), "previous crosses the year: {page}" ); assert!(page.contains("2026-02"), "next stays in it: {page}"); } #[tokio::test] async fn adding_a_goal_puts_it_on_the_month() { let state = state().await; add_goal(&state, "Ship the thing"); let page = review(&state); assert!(page.contains("Ship the thing"), "got: {page}"); assert_eq!(goals(&state).len(), 1); } #[tokio::test] async fn a_goal_names_the_move_it_offers_rather_than_the_state_it_is_in() { // The second finding. The JS used to read the goal from module state, look // the next status up in a table the user cannot see, and write it. Described, // each goal offers the move by name, so the label is the outcome; the JS was // ported onto the same shape afterwards (`setGoalStatus`). let state = state().await; add_goal(&state, "Ship the thing"); let page = review(&state); assert!(page.contains("Mark done"), "got: {page}"); let id = goals(&state)[0].id; post( &state, &format!("/monthly-review/goals/{id}/status"), Params::new() .with("month", this_month()) .with("status", "done"), ); assert_eq!(goals(&state)[0].status, MonthlyGoalStatus::Done); let page = review(&state); assert!(page.contains("Give up on it"), "the next move: {page}"); } #[tokio::test] async fn the_target_status_is_named_so_two_windows_cannot_race() { // The half of the second finding that is a real defect rather than a // description gap: the JS derived the next status from a copy read at // render time, so a second window wrote a status derived from what it saw. // The route takes the target explicitly, so a stale screen cannot invent // one. let state = state().await; add_goal(&state, "Ship the thing"); let id = goals(&state)[0].id; // Two writes naming the same target, as two stale windows would send. for _ in 0..2 { post( &state, &format!("/monthly-review/goals/{id}/status"), Params::new() .with("month", this_month()) .with("status", "abandoned"), ); } assert_eq!(goals(&state)[0].status, MonthlyGoalStatus::Abandoned); } #[tokio::test] async fn a_fourth_goal_is_refused_rather_than_stored() { let state = state().await; for i in 0..3 { add_goal(&state, &format!("Goal {i}")); } assert_eq!(goals(&state).len(), 3); let refused = router().handle( &state, Request::post("/monthly-review/goals").sending( Params::new() .with("month", this_month()) .with("text", "One too many"), ), ); assert!(refused.is_err(), "the fourth is refused"); assert_eq!(goals(&state).len(), 3); } #[tokio::test] async fn the_form_disappears_once_the_month_is_full() { let state = state().await; assert!(review(&state).contains("Add goal")); for i in 0..3 { add_goal(&state, &format!("Goal {i}")); } assert!( !review(&state).contains("Add goal"), "no offer that cannot be taken" ); } #[tokio::test] async fn a_deleted_middle_goal_frees_its_own_position() { // The position is the first one nothing holds, not one past the count. A // month whose middle goal was deleted has a free slot in the middle, and // counting would collide with the last one. let state = state().await; for i in 0..3 { add_goal(&state, &format!("Goal {i}")); } let middle = goals(&state) .iter() .find(|goal| goal.position == 2) .expect("a second goal") .id; post( &state, &format!("/monthly-review/goals/{middle}/delete"), Params::new().with("month", this_month()), ); add_goal(&state, "Back in the middle"); let written = goals(&state); assert_eq!(written.len(), 3); let refilled = written .iter() .find(|goal| goal.text == "Back in the middle") .expect("the new goal"); assert_eq!(refilled.position, 2, "took the free slot, not a fourth"); } #[tokio::test] async fn deleting_a_goal_asks_first() { let state = state().await; add_goal(&state, "Ship the thing"); let page = review(&state); assert!( page.contains("hx-confirm=\"Are you sure you want to delete this goal?"), "got: {page}" ); } #[tokio::test] async fn deleting_something_that_is_not_there_is_a_not_found() { let state = state().await; let missing = uuid::Uuid::new_v4(); let answer = router().handle( &state, Request::post(format!("/monthly-review/goals/{missing}/delete")) .sending(Params::new().with("month", this_month())), ); assert!(answer.is_err()); } #[tokio::test] async fn the_reflection_round_trips_and_the_banner_follows_it() { let state = state().await; assert!(review(&state).contains("Complete review")); post( &state, "/monthly-review/complete", Params::new() .with("month", this_month()) .with("highlight", "Shipped the port") .with("change", "Fewer small tasks") .with("_", ""), ); let page = review(&state); assert!(page.contains("Shipped the port"), "got: {page}"); assert!(page.contains("Fewer small tasks"), "got: {page}"); // The submit changes meaning once the month is reviewed, and the banner // says why: the notes stay editable. assert!(page.contains("Save notes"), "got: {page}"); assert!(page.contains("already reviewed"), "got: {page}"); } #[tokio::test] async fn an_empty_reflection_still_marks_the_month_reviewed() { // The weekly review's rule: the completion is the act and the writing is // optional. let state = state().await; post( &state, "/monthly-review/complete", Params::new() .with("month", this_month()) .with("highlight", " ") .with("change", ""), ); assert!(review(&state).contains("already reviewed")); } #[tokio::test] async fn a_goals_text_cannot_become_markup() { // The reason a description carries text and the renderer owns escaping. let state = state().await; add_goal(&state, ""); let page = review(&state); assert!(!page.contains(""), "got: {page}"); assert!(page.contains("<script>"), "got: {page}"); }