//! The project dashboard, driven through the router against a real database. use std::sync::Arc; use goingson_core::{ MilestoneStatus, NewMilestone, NewProject, NewTask, ProjectId, ProjectStatus, ProjectType, }; use quasi_http::Serves as _; use quasi_router::Outcome; use quasi_router::{Params, Request, Response}; use crate::quasi::router; 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 project(state: &AppState) -> ProjectId { state .projects .create( DESKTOP_USER_ID, NewProject { name: "Ported".to_owned(), description: String::new(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }, ) .unwrap() .id } fn milestone(state: &AppState, project: ProjectId, name: &str) -> goingson_core::Milestone { state .milestones .create( DESKTOP_USER_ID, NewMilestone { project_id: project, name: name.to_owned(), description: String::new(), position: 0, target_date: None, }, ) .unwrap() } 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), // A redirect has no body to render. `Outcome` is deliberately not // `#[non_exhaustive]`, so this arm had to be written rather than falling // into a wildcard that returned empty markup and looked like 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}`") } } } 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") } /// 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") } fn dashboard(state: &AppState, project: ProjectId) -> String { html(get( state, &format!("/projects/{project}/dashboard"), Params::new(), )) } #[tokio::test] async fn an_empty_dashboard_says_so_in_every_column() { let state = state().await; let project = project(&state); let page = dashboard(&state, project); // Four columns, each saying what it has none of. An empty column that // renders nothing reads as a column that failed to load. assert!(page.contains("No tasks linked yet.")); assert!(page.contains("No events linked yet.")); assert!(page.contains("No emails linked yet.")); assert!(page.contains("No attachments yet.")); assert!(page.contains("No milestones yet")); } #[tokio::test] async fn a_dashboard_of_nothing_is_still_four_named_regions() { let state = state().await; let project = project(&state); let page = dashboard(&state, project); for column in ["Tasks", "Events", "Emails", "Attachments"] { assert!(page.contains(column), "{column} is named"); } } #[tokio::test] async fn a_missing_project_is_a_not_found_rather_than_a_panic() { let state = state().await; let error = router() .handle( &state, Request::get(format!("/projects/{}/dashboard", uuid::Uuid::nil())), ) .expect_err("no such project"); assert_eq!(error.class.http_status(), 404); } #[tokio::test] async fn the_dashboard_route_is_not_swallowed_by_the_detail_route() { // `/projects/{id}` and `/projects/{id}/dashboard` are different lengths so // they cannot collide, but the detail route is registered first and this is // the assertion that says the composition order does not matter. let state = state().await; let project = project(&state); let detail = get(&state, &format!("/projects/{project}"), Params::new()); assert_eq!(detail.target(), Some("projects-detail")); let Outcome::Screen(_) = get( &state, &format!("/projects/{project}/dashboard"), Params::new(), ) .outcome else { panic!("the dashboard answers with a screen"); }; } #[tokio::test] async fn all_tasks_complete_is_a_different_thing_from_no_tasks() { let state = state().await; let project = project(&state); let task = state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Done") .title("Done") .project_id(project) .build(), ) .unwrap(); state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap(); let page = dashboard(&state, project); assert!(page.contains("All tasks complete.")); assert!(!page.contains("No tasks linked yet.")); } #[tokio::test] async fn a_linked_task_addresses_the_task_overview() { // The two described screens meet: a dashboard row opens the overview this // same router answers. Nothing wires them together beyond the address. let state = state().await; let project = project(&state); let task = state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Live") .title("Live") .project_id(project) .build(), ) .unwrap(); let page = dashboard(&state, project); assert!(page.contains(&format!("hx-get=\"/tasks/{}\"", task.id))); // And it answers. let Outcome::Screen(_) = get(&state, &format!("/tasks/{}", task.id), Params::new()).outcome else { panic!("the overview answers"); }; } #[tokio::test] async fn a_milestone_draws_its_progress_as_a_bar_in_the_row() { // The proportion finding's third and fourth call sites. `d0b58239` named a // bar at 0.10.0 and could not reach a row; `da5666ae` closed that at 0.11.0 // with `RowPart::Proportion`. This asserted the text form until then. let state = state().await; let project = project(&state); let target = milestone(&state, project, "Phase one"); let done = state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Done") .title("Done") .project_id(project) .milestone_id(target.id) .build(), ) .unwrap(); state.tasks.complete(done.id, DESKTOP_USER_ID).unwrap(); let page = dashboard(&state, project); assert!(page.contains("Phase one")); assert!(page.contains("row-proportion")); assert!(page.contains("progress-fill")); // The ratio is still readable. It moved from the meta slot into the bar's // accessible name, which is what the concatenated text was for. assert!(page.contains(r#"aria-label="1 of 1 tasks""#)); } #[tokio::test] async fn the_reorder_controls_are_disabled_at_the_ends_rather_than_hidden() { // A control that vanishes at the edge of a list is a control the user has to // discover twice. `Act::disabled` says what the JS's hidden spacer means. let state = state().await; let project = project(&state); milestone(&state, project, "First"); milestone(&state, project, "Second"); let page = dashboard(&state, project); assert!(page.contains("Move up")); assert!(page.contains("Move down")); assert!(page.contains("disabled")); } #[tokio::test] async fn moving_a_milestone_changes_the_order_it_comes_back_in() { let state = state().await; let project = project(&state); let first = milestone(&state, project, "First"); milestone(&state, project, "Second"); let before = dashboard(&state, project); assert!(before.find("First").unwrap() < before.find("Second").unwrap()); let page = html(post( &state, &format!("/projects/{project}/milestones/{}/move", first.id), Params::new().with("by", "1"), )); assert!(page.find("Second").unwrap() < page.find("First").unwrap()); } #[tokio::test] async fn moving_off_the_end_is_a_fresh_screen_rather_than_an_error() { // The control that would send this is disabled, so arriving here means a // stale screen, and the answer to a stale screen is a current one. let state = state().await; let project = project(&state); let first = milestone(&state, project, "First"); let page = html(post( &state, &format!("/projects/{project}/milestones/{}/move", first.id), Params::new().with("by", "-1"), )); assert!(page.contains("First")); } #[tokio::test] async fn a_move_that_is_not_one_step_is_refused() { let state = state().await; let project = project(&state); let first = milestone(&state, project, "First"); let error = router() .handle( &state, Request::post(format!("/projects/{project}/milestones/{}/move", first.id)) .sending(Params::new().with("by", "7")), ) .expect_err("only one step at a time"); assert_eq!(error.class.http_status(), 404); } #[tokio::test] async fn deleting_a_milestone_takes_it_off_the_dashboard() { let state = state().await; let project = project(&state); let going = milestone(&state, project, "Going"); milestone(&state, project, "Staying"); let page = html(post( &state, &format!("/projects/{project}/milestones/{}/delete", going.id), Params::new(), )); assert!(!page.contains("Going")); assert!(page.contains("Staying")); } #[tokio::test] async fn the_completed_disclosure_is_an_address_not_module_state() { // `showCompletedMilestones` is a module variable in the JS that a re-render // throws away. Here the expanded dashboard has its own address, so it // survives a reload and can be linked to. let state = state().await; let project = project(&state); milestone(&state, project, "Open one"); let done = milestone(&state, project, "Finished"); state .milestones .update( done.id, DESKTOP_USER_ID, "Finished", "", None, &MilestoneStatus::Completed, ) .unwrap(); let collapsed = dashboard(&state, project); assert!(collapsed.contains("Show 1 completed")); assert!(!collapsed.contains("Complete<")); let expanded = html(get( &state, &format!("/projects/{project}/dashboard"), Params::new().with("completed", "1"), )); assert!(expanded.contains("Hide completed")); assert!(expanded.contains("Finished")); } #[tokio::test] async fn a_write_answers_under_the_disclosure_it_carried() { let state = state().await; let project = project(&state); let going = milestone(&state, project, "Going"); let done = milestone(&state, project, "Finished"); state .milestones .update( done.id, DESKTOP_USER_ID, "Finished", "", None, &MilestoneStatus::Completed, ) .unwrap(); let page = html(viewing_post( &state, &format!("/projects/{project}/milestones/{}/delete", going.id), Params::new().with("completed", "1"), )); // Still expanded afterwards. Dropping it here is how a delete reads as // having collapsed the section. assert!(page.contains("Hide completed")); assert!(page.contains("Finished")); } #[tokio::test] async fn the_attachments_column_asks_the_host_to_pick_a_file() { // `attachments.pickAndAttach` opened the OS file picker, which is neither a // route this app answers nor an external address. This was a // `FieldKind::File` until 2026-08-22 on the belief that a Tauri host could // hand back a path through one; it cannot, because the field renders // `` into a webview and a browser reports a masked // filename. `Action::by_host` is the ruled answer and `frontend/js/host.js` // is the half that opens the dialog. let state = state().await; let project = project(&state); let page = dashboard(&state, project); assert!(page.contains("No attachments yet.")); assert!(!page.contains(r#"type="file""#), "{page}"); assert!(page.contains("data-sends="), "{page}"); assert!(page.contains("Attach a file")); } /// A file on disk to attach, named for the test that wants it. fn a_file(name: &str, contents: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join("goingson-quasi-attach-tests"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join(name); std::fs::write(&path, contents).unwrap(); path } fn attach(state: &AppState, project: ProjectId, path: &std::path::Path) -> Response { post( state, &format!("/projects/{project}/attachments"), Params::new().with("file", path.to_str().unwrap()), ) } #[tokio::test] async fn attaching_a_picked_file_answers_with_the_column_it_landed_in() { let state = state().await; let project = project(&state); let response = attach(&state, project, &a_file("notes.txt", "hello")); // The column alone, not the whole screen: attaching lands in one place, so // an expanded milestones section survives it. assert_eq!(response.target(), Some("dashboard-attachments")); let page = html(response); assert!(page.contains("notes.txt")); assert!(page.contains("5 B")); // Still offering the field, so a second file is one click away rather than // a reload. assert!(page.contains("data-sends="), "{page}"); } #[tokio::test] async fn attaching_nothing_is_refused_on_the_field_it_came_from() { let state = state().await; let project = project(&state); let page = html(post( &state, &format!("/projects/{project}/attachments"), Params::new(), )); assert!(page.contains("Choose a file to attach.")); assert!(page.contains("No attachments yet.")); } #[tokio::test] async fn a_file_that_is_not_there_is_a_refusal_rather_than_a_failure() { // The user's to fix by picking another file, so it comes back on the field // instead of as a 500 that says nothing they can act on. let state = state().await; let project = project(&state); let page = html(attach( &state, project, std::path::Path::new("/nowhere/at/all.txt"), )); assert!(page.contains("File does not exist")); } #[tokio::test] async fn an_attachment_opens_as_a_file_address() { // The other half of the finding: opening is a one-way handoff, so it is a // redirect to somewhere this router does not answer rather than content. // The space in the name is the reason the address is percent-encoded — an // unescaped one truncates it at the first gap. let state = state().await; let project = project(&state); attach(&state, project, &a_file("field notes.txt", "hello")); let attachment = state .attachments .list_for_project(project, DESKTOP_USER_ID) .unwrap() .pop() .expect("the attach landed"); let response = get( &state, &format!("/projects/{project}/attachments/{}/open", attachment.id), Params::new(), ); let Outcome::Goto(action) = response.outcome else { panic!("opening hands the file over rather than answering with a screen"); }; let quasi_router::Destination::External(url) = action.destination else { panic!("a file lives outside anything this router answers"); }; assert!(url.starts_with("file:///")); assert!(url.ends_with("field%20notes.txt")); // And it is really there, under its own name rather than under its hash. let path = url.replace("file://", "").replace("%20", " "); assert_eq!(std::fs::read_to_string(path).unwrap(), "hello"); } #[tokio::test] async fn opening_an_attachment_that_is_not_there_is_a_not_found() { let state = state().await; let project = project(&state); let error = router() .handle( &state, Request::get(format!( "/projects/{project}/attachments/{}/open", uuid::Uuid::nil() )), ) .expect_err("no such attachment"); assert_eq!(error.class.http_status(), 404); } #[tokio::test] async fn a_project_name_cannot_become_markup() { let state = state().await; let project = state .projects .create( DESKTOP_USER_ID, NewProject { name: "".to_owned(), description: String::new(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }, ) .unwrap(); let page = dashboard(&state, project.id); assert!(!page.contains("