//! The projects screen, driven through the router against a real database. //! //! No Tauri runtime and no window: a route is a function from state and params //! to a description, which is the property that makes the router testable at //! all. What is asserted here is the description, and the markup only where the //! markup is the point. use std::sync::Arc; use goingson_core::{NewProject, ProjectStatus, ProjectType}; use quasi_http::Serves as _; use quasi_router::Outcome; use quasi_router::{Params, Request, Response}; use super::super::{protocol, 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 } fn add(state: &AppState, name: &str, status: ProjectStatus) -> goingson_core::Project { state .projects .create( DESKTOP_USER_ID, NewProject { name: name.to_owned(), description: String::new(), project_type: ProjectType::SideProject, status, }, ) .unwrap() } fn answer(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::get(path).carrying(params)) .expect("the route answers") } /// A write, which is the shape every action on this screen arrives in. fn post(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::post(path).sending(params)) .expect("the route answers") } /// A write made from a filtered view: what the control sent, and where it was /// sent from. The two never merge, which is why a screen may filter on the same /// name it writes. fn viewing_post(state: &AppState, path: &str, carried: Params) -> Response { router() .handle(state, Request::post(path).carrying(carried)) .expect("the route answers") } /// The four the create form asks for. /// /// Every value spelled out at each call site rather than overridden on top of a /// valid default: `Params::get` answers with the *first* value under a name, so /// a `.with("status", ...)` after a valid one is silently ignored and the test /// passes for the wrong reason. fn a_project(name: &str, project_type: &str, status: &str) -> Params { Params::new() .with("name", name) .with("description", "") .with("project_type", project_type) .with("status", status) } /// A valid submission. fn valid(name: &str) -> Params { a_project(name, "SideProject", "Active") } #[tokio::test] async fn an_empty_database_says_so_as_a_stand_in_with_a_way_out() { // `703f4cd2`. This asserted a bare line of text until 2026-08-09, because // nothing named an empty state and the description had only `Node::text` to // say it with. `Node::StandIn` is what changed. let state = state().await; let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else { panic!("the index answers with a screen"); }; let html = quasi_webview::Webview::new().screen(&screen); assert!(html.contains("No projects yet.")); assert!(html.contains(r#"data-state="empty""#)); // Not a fault: an empty database is the normal state of a new install. assert!(!html.contains(r#"role="alert""#)); // The way out, which is the half `Node::text` could never carry. assert!(html.contains("Create your first project")); assert!(html.contains("hx-get=\"/projects/new")); } #[tokio::test] async fn deleting_a_project_asks_first() { // `524a63fe`. The JS confirms through `confirmDelete` and the described // screen did not, so the description was worse than what it replaces on // the one path where that matters most. let state = state().await; let project = add(&state, "Doomed", ProjectStatus::Active); let Outcome::Fragment { node, .. } = answer(&state, &format!("/projects/{}", project.id), Params::new()).outcome else { panic!("the detail pane answers with a fragment"); }; let html = quasi_webview::Webview::new().fragment(&node); assert!(html.contains("Delete project")); assert!(html.contains("hx-confirm=\"Are you sure you want to delete this project?")); } #[tokio::test] async fn the_grid_lists_live_projects_and_holds_the_retired_ones_back() { let state = state().await; add(&state, "Live one", ProjectStatus::Active); add(&state, "Paused", ProjectStatus::OnHold); add(&state, "Finished", ProjectStatus::Completed); let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else { panic!("a screen"); }; let html = quasi_webview::Webview::new().screen(&screen); assert!(html.contains("Live one")); assert!(html.contains("Paused")); assert!(!html.contains("Finished")); // The control that reveals them names how many there are, which is what // `projects.js` puts on its retired toggle. assert!(html.contains("Show 1 completed or archived")); } #[tokio::test] async fn the_retired_toggle_is_an_address_not_a_piece_of_module_state() { let state = state().await; add(&state, "Finished", ProjectStatus::Completed); let shown = answer(&state, "/projects", Params::new().with("retired", "1")); let Outcome::Screen(screen) = shown.outcome else { panic!("a screen"); }; let html = quasi_webview::Webview::new().screen(&screen); assert!(html.contains("Finished")); assert!(html.contains("Hide completed and archived")); } #[tokio::test] async fn a_filter_toggle_swaps_the_grid_alone() { let state = state().await; add(&state, "Live one", ProjectStatus::Active); let response = answer(&state, "/projects/list", Params::new()); // Decision 7: the response names the region, so the whole document is not // reflowed to change one pane. assert_eq!(response.target(), Some("projects-grid")); let Outcome::Fragment { node, .. } = (response).outcome else { panic!("a fragment"); }; let html = quasi_webview::Webview::new().fragment(&node); assert!(html.starts_with("alert(1)", ProjectStatus::Active); let Outcome::Screen(screen) = answer(&state, "/projects", Params::new()).outcome else { panic!("a screen"); }; let html = quasi_webview::Webview::new().screen(&screen); assert!(!html.contains("