//! The contacts screen, driven through the router against a real database. //! //! Same property as the projects tests: no Tauri runtime and no window, because //! a route is a function from state and params to a description. What is //! asserted is the description, and the markup only where the markup is the //! point. use std::sync::Arc; use goingson_core::{NewContact, NewContactEmail, NewSocialHandle}; 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 } /// A contact carrying nothing but its name. /// /// `NewContact` has no `Default`, and giving it one here would be adding a trait /// to the core crate for the convenience of one test module. fn blank(name: &str) -> NewContact { NewContact { display_name: name.to_owned(), nickname: None, company: None, title: None, notes: String::new(), tags: Vec::new(), birthday: None, timezone: None, is_implicit: false, } } fn add(state: &AppState, name: &str) -> goingson_core::Contact { state.contacts.create(DESKTOP_USER_ID, blank(name)).unwrap() } 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) -> Response { router() .handle(state, Request::post(path)) .expect("the route answers") } fn screen_html(response: Response) -> String { let Outcome::Screen(screen) = (response).outcome else { panic!("the route answers with a screen"); }; quasi_webview::Webview::new().screen(&screen) } fn fragment_html(response: Response) -> String { let Outcome::Fragment { node, .. } = (response).outcome else { panic!("the route answers with a fragment"); }; quasi_webview::Webview::new().fragment(&node) } #[tokio::test] async fn an_empty_database_says_so_rather_than_rendering_nothing() { let state = state().await; let html = screen_html(get(&state, "/contacts", Params::new())); assert!(html.contains("No contacts yet.")); } #[tokio::test] async fn an_empty_result_says_which_filter_emptied_it() { // Three different sentences, because "nothing here" after a search means // something different from "nothing here" on a fresh install, and the JS // screen already distinguishes them. let state = state().await; add(&state, "Ada"); let searched = screen_html(get(&state, "/contacts", Params::new().with("q", "zzz"))); assert!(searched.contains("No contacts match that search.")); let tagged = screen_html(get(&state, "/contacts", Params::new().with("tag", "zzz"))); assert!(tagged.contains("No contacts carry that tag.")); } #[tokio::test] async fn a_blank_search_is_the_same_as_no_search() { // An emptied search box sends the param with nothing in it. Treating that as // a search for the empty string is how a screen goes blank when a user // deletes what they typed. let state = state().await; add(&state, "Ada"); let html = screen_html(get(&state, "/contacts", Params::new().with("q", " "))); assert!(html.contains("Ada")); assert!(!html.contains("No contacts match")); } #[tokio::test] async fn search_is_an_address_not_a_piece_of_module_state() { let state = state().await; add(&state, "Ada Lovelace"); add(&state, "Grace Hopper"); let html = fragment_html(get( &state, "/contacts/list", Params::new().with("q", "Ada"), )); assert!(html.contains("Ada Lovelace")); assert!(!html.contains("Grace Hopper")); } #[tokio::test] async fn a_filter_swaps_the_grid_alone() { let state = state().await; add(&state, "Ada"); let response = get(&state, "/contacts/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("contacts-grid")); let html = fragment_html(response); assert!(html.starts_with("ada@example.com")); // The tag is a token, in the token strip, and clicking it filters the grid. assert!(html.contains("class=\"row-tokens\"")); assert!(html.contains("friend")); assert!(html.contains("hx-get=\"/contacts/list?tag=friend\"")); // And no longer joined into one string. assert!(!html.contains("ada@example.com · friend")); } #[tokio::test] async fn a_contact_row_can_be_ticked_without_being_the_current_one() { // The other closed gap. `Row::selected` used to mean "the detail pane is // showing this", so a bulk checkbox had no way to be described. It is now // the user's tick, and `current` is the app's pointer. let state = state().await; add(&state, "Ada"); let html = fragment_html(get(&state, "/contacts/list", Params::new())); assert!(html.contains("type=\"checkbox\"")); // Selectable but not ticked, and not the current row either. assert!(!html.contains(" checked")); assert!(!html.contains("aria-current")); } #[tokio::test] async fn a_nickname_joins_the_name_because_a_row_has_nowhere_else_to_put_it() { let state = state().await; state .contacts .create( DESKTOP_USER_ID, NewContact { nickname: Some("Countess".to_owned()), ..blank("Ada Lovelace") }, ) .unwrap(); let html = fragment_html(get(&state, "/contacts/list", Params::new())); assert!(html.contains("Ada Lovelace "Countess"")); } #[tokio::test] async fn selecting_a_row_addresses_the_detail_pane() { let state = state().await; let contact = add(&state, "Ada"); let html = fragment_html(get(&state, "/contacts/list", Params::new())); assert!(html.contains(&format!("hx-get=\"/contacts/{}\"", contact.id))); let response = get(&state, &format!("/contacts/{}", contact.id), Params::new()); assert_eq!(response.target(), Some("contacts-detail")); } #[tokio::test] async fn an_empty_sub_collection_says_so_rather_than_rendering_a_bare_heading() { let state = state().await; let contact = add(&state, "Ada"); let html = fragment_html(get( &state, &format!("/contacts/{}", contact.id), Params::new(), )); assert!(html.contains("No email addresses")); assert!(html.contains("No phone numbers")); assert!(html.contains("No social handles")); assert!(html.contains("No custom fields")); } #[tokio::test] async fn a_social_handles_url_is_a_real_link_that_leaves_the_app() { // Was `a_social_handles_url_is_text_because_a_row_cannot_carry_a_link`. // Worth noting how that test would have survived this change: it asserted // `!html.contains("alert(1)"); let html = screen_html(get(&state, "/contacts", Params::new())); assert!(!html.contains("".to_owned()), }, ) .unwrap(); let html = fragment_html(get( &state, &format!("/contacts/{}", contact.id), Params::new(), )); assert!(!html.contains("