//! The board, driven through the router against a real database. //! //! The assertions worth reading are the ones about *peers*: that the three //! columns are one region of equals rather than a list and a detail, which is //! the single member `makeover-layout` 0.25.0 added for this screen. use std::sync::Arc; use goingson_core::{NewTask, Priority, TaskStatus}; use quasi_http::Serves as _; use quasi_router::{Outcome, Params, Request, Response}; use super::super::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 } /// A pending task, which is where every card starts. fn task(state: &AppState, title: &str) -> goingson_core::TaskId { state .tasks .create( DESKTOP_USER_ID, NewTask::builder(title).priority(Priority::Medium).build(), ) .unwrap() .id } fn get(state: &AppState, path: &str) -> Response { router() .handle(state, Request::get(path).carrying(Params::new())) .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), 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 board(state: &AppState) -> String { html(get(state, "/board")) } fn move_to(state: &AppState, id: goingson_core::TaskId, to: &str) -> Response { router() .handle( state, Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", to)), ) .expect("the route answers") } #[tokio::test] async fn the_three_columns_are_peers_and_not_a_list_and_a_detail() { let state = state().await; let markup = board(&state); // The whole point of the member. Before 0.25.0 this screen could only have // been described as list-detail, which says the left column chooses what // the right shows -- a lie about a board. // // `RegionKind::Columns` said it until that variant was retired // (quasicoherent `cf981aaa`). Three peers is now three members of one row // that each ask to fill, which divide the room equally by `Width::Fill`'s // own rule and choose nothing about each other. Counted rather than // matched, because the count is the "peers" half: two fills and a content // member would be a master-detail wearing a board's markup. assert_eq!(markup.matches("data-width=\"fill\"").count(), 3, "{markup}"); for label in ["Pending", "Started", "Completed"] { assert!(markup.contains(label), "{markup}"); } } #[tokio::test] async fn a_card_carries_the_facts_the_js_card_carried() { let state = state().await; task(&state, "Write the thing"); let markup = board(&state); assert!(markup.contains("Write the thing"), "{markup}"); assert!(markup.contains("Medium"), "{markup}"); } #[tokio::test] async fn a_card_offers_the_moves_it_is_not_already_in() { let state = state().await; let id = task(&state, "Movable"); let markup = board(&state); // Two moves, not three. Dropping a card where it already is is the case // `onDrop` bails on, so offering it would be an act that does nothing. assert!(markup.contains("Move to Started"), "{markup}"); assert!(markup.contains("Move to Completed"), "{markup}"); assert!(!markup.contains("Move to Pending"), "{markup}"); // And the move is an ordinary posted action, which is why the drag never // needed describing. assert!(markup.contains(&format!("/board/{id}/status")), "{markup}"); } #[tokio::test] async fn moving_a_card_moves_it_and_answers_the_board() { let state = state().await; let id = task(&state, "Movable"); let response = move_to(&state, id, "Started"); let Outcome::Fragment { region, .. } = &response.outcome else { panic!("a move replaces the board, not the screen"); }; assert_eq!(region, "board"); let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.status, TaskStatus::Started); // Now it offers the way back and no longer offers the way it came. let markup = html(response); assert!(markup.contains("Move to Pending"), "{markup}"); assert!(!markup.contains("Move to Started"), "{markup}"); } #[tokio::test] async fn moving_a_card_to_the_column_it_is_in_writes_nothing() { let state = state().await; let id = task(&state, "Stationary"); // Matters more through a route than through a drop. A repeated POST must // not complete a task twice and mint a second recurrence, and the JS's // `task.status === newStatus` bail is not reachable from a URL. let response = move_to(&state, id, "Pending"); assert!(matches!(response.outcome, Outcome::Fragment { .. })); let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.status, TaskStatus::Pending); // No toast, because nothing happened. assert!(response.notice.is_none(), "{:?}", response.notice); } #[tokio::test] async fn moving_back_to_pending_keeps_everything_else_about_the_task() { let state = state().await; let id = state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Tagged") .priority(Priority::High) .tags(vec!["alpha".into(), "beta".into()]) .build(), ) .unwrap() .id; move_to(&state, id, "Started"); move_to(&state, id, "Pending"); // `UpdateTask` replaces rather than patches, so the Pending path resends // every field. Anything left out of that struct is silently cleared, and // moving a card left is not a reason to lose its tags. let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.status, TaskStatus::Pending); assert_eq!(after.priority, Priority::High); assert_eq!(after.tags, vec!["alpha".to_string(), "beta".to_string()]); assert_eq!(after.title, "Tagged"); } #[tokio::test] async fn a_column_that_does_not_exist_is_a_404() { let state = state().await; let id = task(&state, "Movable"); // A control naming a column that is not there is a wiring mistake, and // answering it with an unchanged board hides it. let answered = router().handle( &state, Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", "Archived")), ); assert!(answered.is_err(), "an unknown column should not resolve"); } #[tokio::test] async fn a_cards_title_cannot_become_markup() { let state = state().await; task(&state, ""); let markup = board(&state); assert!(!markup.contains("