//! The search screen, driven through the router against a real database. //! //! Same property as the contacts tests: no Tauri runtime and no window, because //! a route is a function from state and params to a description. use std::sync::Arc; use goingson_core::{ NewContact, NewEmail, NewEvent, NewProject, NewTask, Priority, ProjectStatus, ProjectType, }; 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 } /// One of each of the five kinds the index holds, all matching "kestrel". fn seed_all_five(state: &AppState) { state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Kestrel migration") .priority(Priority::High) .build(), ) .unwrap(); state .projects .create( DESKTOP_USER_ID, NewProject { name: "Kestrel".to_owned(), description: String::new(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }, ) .unwrap(); state .events .create( DESKTOP_USER_ID, NewEvent::builder("Kestrel planning", chrono::Utc::now()).build(), ) .unwrap(); state .contacts .create( DESKTOP_USER_ID, NewContact { display_name: "Kestrel Vendor".to_owned(), nickname: None, company: None, title: None, notes: String::new(), tags: Vec::new(), birthday: None, timezone: None, is_implicit: false, }, ) .unwrap(); state .emails .create( DESKTOP_USER_ID, NewEmail { project_id: None, from_address: "someone@example.com".to_owned(), to_address: "desktop@localhost".to_owned(), subject: "Kestrel delivery".to_owned(), body: "The kestrel ships Tuesday.".to_owned(), is_read: false, received_at: None, }, ) .unwrap(); } fn get(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::get(path).carrying(params)) .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_query_teaches_the_grammar_rather_than_searching() { // The colon filters are the feature, and a screen that showed nothing until // a query arrived would keep them secret. let state = state().await; let html = screen_html(get(&state, "/search", Params::new())); assert!(html.contains("is:overdue"), "{html}"); assert!(html.contains("type:contact"), "{html}"); assert!(html.contains("-tag:name"), "{html}"); } #[tokio::test] async fn a_blank_query_is_the_same_as_no_query() { // An emptied box sends the param with nothing in it. Searching for the empty // string instead would answer with the whole database. let state = state().await; seed_all_five(&state); let html = screen_html(get(&state, "/search", Params::new().with("q", " "))); assert!(html.contains("is:overdue"), "{html}"); assert!(!html.contains("Kestrel migration"), "{html}"); } #[tokio::test] async fn the_screen_searches_across_all_five_kinds() { let state = state().await; seed_all_five(&state); let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel"))); for hit in [ "Kestrel migration", "Kestrel", "Kestrel planning", "Kestrel Vendor", "Kestrel delivery", ] { assert!(html.contains(hit), "missing {hit}: {html}"); } } #[tokio::test] async fn a_row_says_which_kind_it_is() { // The results are heterogeneous, so a row that only said its title would // leave the reader to guess whether "Kestrel" is a project or a contact. let state = state().await; seed_all_five(&state); let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel"))); for kind in ["Task", "Project", "Event", "Contact", "Email"] { assert!(html.contains(kind), "missing the {kind} tag: {html}"); } } #[tokio::test] async fn the_parsed_filters_are_shown_back() { // What makes a colon grammar learnable: the parser saying which words it // took as filters, from the labels the command already collects. let state = state().await; seed_all_five(&state); let html = fragment_html(get( &state, "/search/results", Params::new().with("q", "kestrel is:pending priority:high type:task"), )); assert!(html.contains("is:pending"), "{html}"); assert!(html.contains("priority:high"), "{html}"); assert!(html.contains("type:task"), "{html}"); } #[tokio::test] async fn a_type_filter_narrows_to_that_kind() { let state = state().await; seed_all_five(&state); let html = fragment_html(get( &state, "/search/results", Params::new().with("q", "kestrel type:contact"), )); assert!(html.contains("Kestrel Vendor"), "{html}"); assert!(!html.contains("Kestrel migration"), "{html}"); } #[tokio::test] async fn a_query_that_matches_nothing_says_so() { let state = state().await; seed_all_five(&state); let html = fragment_html(get( &state, "/search/results", Params::new().with("q", "peregrine"), )); assert!(html.contains("Nothing matches that."), "{html}"); } #[tokio::test] async fn the_box_asks_the_results_route_as_it_is_typed() { // `Field::consults` is what makes this a search screen rather than a form: // no submit, and the answer lands in the results region. let state = state().await; let html = screen_html(get(&state, "/search", Params::new())); assert!(html.contains("/search/results"), "{html}"); assert!(html.contains("search-results"), "{html}"); } #[tokio::test] async fn a_search_has_an_address() { // The query is a param rather than state the field holds privately, so the // view a reader is looking at can be linked to and reloaded. let state = state().await; seed_all_five(&state); let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel"))); assert!(html.contains("Kestrel migration"), "{html}"); assert!( html.contains("kestrel"), "the box holds the query back: {html}" ); } #[tokio::test] async fn search_is_a_place_in_the_nav() { // The entry point, decided on goingson `6b3aa22b`: a nav entry serves // pointer, touch and keyboard alike, and the app can bind no keys. let state = state().await; let html = screen_html(get(&state, "/search", Params::new())); assert!(html.contains("/search"), "{html}"); assert!(html.contains("Search"), "{html}"); }