//! The task list, driven through the router against a real database. //! //! Two groups are worth reading. The first is the table: this is the vocabulary's //! first `Node::Table`, so the assertions are about columns being columns and a //! heading carrying the address that reorders by it. The second is the view: a //! filter here is an address, where `tasks-filter.js` holds it in module scope //! and mirrors it into the query string by hand, so the tests state what a //! mistyped address answers rather than letting it fall back to a different list //! than the one asked for. use std::sync::Arc; use goingson_core::{ NewProject, NewTask, Priority, ProjectStatus, ProjectType, Recurrence, TaskId, 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 } fn task(state: &AppState, title: &str) -> TaskId { state .tasks .create( DESKTOP_USER_ID, NewTask::builder(title).priority(Priority::Medium).build(), ) .unwrap() .id } 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, payload: Params, carried: Params) -> Response { router() .handle( state, Request::post(path).sending(payload).carrying(carried), ) .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}`") } } } /// The whole screen under the default view. fn screen(state: &AppState) -> String { html(get(state, "/tasks", Params::new())) } // The screen #[tokio::test] async fn the_drawers_delete_redirect_now_lands_somewhere() { let state = state().await; let id = task(&state, "Doomed"); // `super::super::tasks::remove` has answered `goto /tasks` since 2026-08-09 // and nothing served that route until this module. The old test asserted the // destination; this one follows it. let response = router() .handle(&state, Request::post(format!("/tasks/{id}/delete"))) .expect("the route answers"); let Outcome::Goto(action) = response.outcome else { panic!("deleting from the drawer redirects"); }; let route = action.destination.route().expect("a route to follow"); assert_eq!(route, "/tasks"); let followed = router().handle(&state, Request::get(route).carrying(Params::new())); assert!(followed.is_ok(), "the redirect's destination answers"); } #[tokio::test] async fn the_table_is_the_columns_the_screen_describes_in_that_order() { let state = state().await; task(&state, "Write the thing"); let markup = screen(&state); // The seven the described table names, in the order it names them. Read off // COLUMNS, not off `build.rs`: this checks the described screen against // itself, and it keeps working after TASK_COLUMNS is deleted at the flip. let mut at = 0; for name in [ "description", "project", "priority", "due", "recurrence", "progress", "actions", ] { let found = markup[at..] .find(name) .unwrap_or_else(|| panic!("no {name} column in {markup}")); at += found + name.len(); } } #[tokio::test] async fn a_row_carries_the_facts_the_js_row_carried() { let state = state().await; let project = state .projects .create( DESKTOP_USER_ID, NewProject { name: "Housekeeping".to_owned(), description: String::new(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }, ) .unwrap(); state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Write the thing") .priority(Priority::High) .project_id(project.id) .build(), ) .unwrap(); let markup = screen(&state); assert!(markup.contains("Write the thing"), "{markup}"); assert!(markup.contains("Housekeeping"), "{markup}"); // The priority column is the single letter the shipped cell shows. assert!(markup.contains(">H<"), "{markup}"); // And the title opens the drawer, which is where the eight controls this // row does not offer actually live. assert!(markup.contains("/tasks/"), "{markup}"); } #[tokio::test] async fn a_row_says_whether_the_task_is_available() { let state = state().await; let blocker = task(&state, "First"); let blocked = task(&state, "Second"); state .tasks .add_dependency(DESKTOP_USER_ID, blocked, blocker) .unwrap(); let markup = screen(&state); // `Availability`, shared with the board, the project dashboard and the day // plan. Three surfaces drifted apart by each drawing this themselves; a // fourth copy here would have been the fourth. assert!(markup.contains("Blocked"), "{markup}"); assert!(markup.contains("Unblocks 1"), "{markup}"); } #[tokio::test] async fn the_subtask_meter_is_the_progress_column_and_not_also_a_badge() { let state = state().await; let id = task(&state, "Has subtasks"); state.tasks.add_subtask(id, DESKTOP_USER_ID, "one").unwrap(); state.tasks.add_subtask(id, DESKTOP_USER_ID, "two").unwrap(); let markup = screen(&state); // `renderTaskRow` draws `0/2` in the description cell *and* a progress bar // in the progress column: one fact twice, computed twice, able to disagree. // The meter is the column's, so the badge is gone. assert!(markup.contains("progress"), "{markup}"); assert!(!markup.contains("0/2"), "{markup}"); } // The order #[tokio::test] async fn the_default_order_is_the_screens_and_not_the_commands() { let state = state().await; task(&state, "Anything"); let markup = screen(&state); // `list_tasks_filtered` defaults to urgency descending and this screen has // opened on due ascending since `tasks-filter.js` was written. A port of a // screen keeps the screen's default. assert!(markup.contains("ascending"), "{markup}"); // Nothing in the default address says so, because a default is never // written: two addresses for one view cannot exist. assert!(!markup.contains("sort=due"), "{markup}"); } #[tokio::test] async fn a_heading_carries_the_address_that_reorders_by_it() { let state = state().await; task(&state, "Anything"); let markup = screen(&state); // `Column::reorder`'s first consumer. The shipped screen writes `aria-sort` // onto the heading from module state and rebuilds the list in JS; here the // heading is an address and the order it names is what the next read gets. assert!(markup.contains("sort=project"), "{markup}"); assert!(markup.contains("sort=priority"), "{markup}"); // Recurrence and progress are not sortable in the build script's // description, so they carry nothing. assert!(!markup.contains("sort=recurrence"), "{markup}"); } #[tokio::test] async fn pressing_the_column_already_sorted_flips_it() { let state = state().await; task(&state, "Anything"); // Due is the default and is ascending, so its own heading offers descending. // Asserted on the heading's own link rather than on the markup: every row // control also carries the view it was drawn under, so a bare search for // `direction=desc` finds the Start button too. let markup = screen(&state); assert!( markup.contains(r#"class="table-sort" href="/tasks/list?direction=desc""#), "{markup}" ); assert!(markup.contains(r#"aria-sort="ascending""#), "{markup}"); // Under descending the same heading offers the way back, which is the // default view and therefore an address with nothing written on it. let flipped = html(get( &state, "/tasks/list", Params::new().with("direction", "desc"), )); assert!( flipped.contains(r#"class="table-sort" href="/tasks/list""#), "{flipped}" ); assert!(flipped.contains(r#"aria-sort="descending""#), "{flipped}"); } // The view is an address #[tokio::test] async fn a_filter_answers_the_list_alone() { let state = state().await; task(&state, "Pending one"); let response = get( &state, "/tasks/list", Params::new().with("status", "Completed"), ); let Outcome::Fragment { region, .. } = &response.outcome else { panic!("a filter replaces the list, not the screen"); }; assert_eq!(region, "tasks-list"); } #[tokio::test] async fn the_status_filter_cuts_the_list_and_latches_the_chip() { let state = state().await; let done = task(&state, "Finished"); task(&state, "Outstanding"); state.tasks.complete(done, DESKTOP_USER_ID).unwrap(); let pending = screen(&state); assert!(pending.contains("Outstanding"), "{pending}"); assert!(!pending.contains("Finished"), "{pending}"); let completed = html(get( &state, "/tasks", Params::new().with("status", "Completed"), )); assert!(completed.contains("Finished"), "{completed}"); assert!(!completed.contains("Outstanding"), "{completed}"); } #[tokio::test] async fn a_word_the_filter_does_not_know_is_a_404_and_not_a_different_list() { let state = state().await; task(&state, "Anything"); // The problems inbox's rule. `TaskStatus::from_str_or_default` would answer // Pending for "Pendign" and the screen would look like it worked. for (name, value) in [ ("status", "Pendign"), ("priority", "Highest"), ("sort", "urgncy"), ("project", "not-a-uuid"), ] { let answer = router().handle( &state, Request::get("/tasks/list").carrying(Params::new().with(name, value)), ); assert!(answer.is_err(), "{name}={value} answered something"); } } #[tokio::test] async fn the_priority_chip_clears_itself_when_it_is_the_one_latched() { let state = state().await; task(&state, "Anything"); let filtered = html(get( &state, "/tasks", Params::new().with("priority", "High"), )); // Pressing a latched chip clears it, so the way back is always on screen. // The contacts tag filter's rule, and the problems source filter's. Its // own href is what carries the clearing, not the markup at large: every // other control on the screen preserves the filter it was drawn under, so // `priority=High` appears all over it and should. assert!( filtered.contains(r#"High"#), "{filtered}" ); assert!(filtered.contains("priority=Medium"), "{filtered}"); } #[tokio::test] async fn a_filter_change_goes_back_to_one_page() { let state = state().await; task(&state, "Anything"); let deep = html(get(&state, "/tasks", Params::new().with("shown", "600"))); // Carrying `shown` onto a filter would ask for 600 rows of a priority // holding nine. The mail list's rule. assert!(!deep.contains("shown=600&"), "{deep}"); assert!(deep.contains("priority=High"), "{deep}"); } #[tokio::test] async fn the_milestone_control_appears_only_under_a_chosen_project() { let state = state().await; let project = state .projects .create( DESKTOP_USER_ID, NewProject { name: "Housekeeping".to_owned(), description: String::new(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }, ) .unwrap(); state .milestones .create( DESKTOP_USER_ID, goingson_core::NewMilestone { project_id: project.id, name: "Beta".to_owned(), description: String::new(), target_date: None, position: 0, }, ) .unwrap(); let unfiltered = screen(&state); assert!(!unfiltered.contains("Beta"), "{unfiltered}"); let scoped = html(get( &state, "/tasks", Params::new().with("project", project.id.to_string()), )); // `populateMilestoneFilter`'s rule: a milestone belongs to a project, so // every project's milestones at once is a control whose options mean // nothing together. assert!(scoped.contains("Beta"), "{scoped}"); } // Paging #[tokio::test] async fn the_way_to_more_rows_appears_only_when_there_are_more() { let state = state().await; task(&state, "The only one"); let short = screen(&state); assert!(!short.contains("rest-position"), "{short}"); // `shown` is clamped to at least a page, so a short list never offers it // however the address is typed. let typed_low = html(get(&state, "/tasks/list", Params::new().with("shown", "1"))); assert!(!typed_low.contains("rest-position"), "{typed_low}"); // Past one page it does, and it names how far along the reader is, which is // what the shipped count chip says while pages stream in. // // Finding 2 in the module header, closed in quasi 0.15.0: this used to be a // `Node::Act` under the table because a table could not say it for itself, // and it is the table's own `Rest` now. So the assertion is on the pager the // renderer draws from the description rather than on an act's label. for n in 0..super::PAGE { task(&state, &format!("Filler {n}")); } let long = screen(&state); assert!(long.contains("200 of 201"), "{long}"); assert!(long.contains("shown=400"), "{long}"); assert!(long.contains("rest-position"), "{long}"); } // Writes #[tokio::test] async fn starting_a_task_from_the_list_answers_the_list() { let state = state().await; let id = task(&state, "Startable"); let response = post( &state, &format!("/tasks/list/{id}/status"), Params::new().with("status", "Started"), Params::new(), ); let Outcome::Fragment { region, .. } = &response.outcome else { panic!("a row's control replaces the list, not the screen"); }; assert_eq!(region, "tasks-list"); let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.status, TaskStatus::Started); } #[tokio::test] async fn the_write_and_the_filter_are_both_called_status_and_cannot_reach_each_other() { let state = state().await; let id = task(&state, "Startable"); // The arrangement the problems inbox and the mail screen paid for on one // afternoon: `payload` and `carried` are different bags, so the target of // the write is not read back as the view it happened in. let response = post( &state, &format!("/tasks/list/{id}/status"), Params::new().with("status", "Completed"), Params::new().with("status", "Pending"), ); let markup = html(response); // Answered with the Pending list it was pressed in, which no longer holds // the completed task -- not with the Completed list the write named. assert!(!markup.contains("Startable"), "{markup}"); } #[tokio::test] async fn pressing_a_status_a_task_is_already_in_writes_nothing() { let state = state().await; let id = task(&state, "Stationary"); let response = post( &state, &format!("/tasks/list/{id}/status"), Params::new().with("status", "Pending"), Params::new(), ); let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.status, TaskStatus::Pending); // No toast, because nothing happened. A row can be pressed while the list // it was drawn in is stale, and a repeated press must not complete a task // twice and mint a second recurrence. assert!(response.notice.is_none(), "{:?}", response.notice); } #[tokio::test] async fn completing_a_recurring_task_shows_the_successor_it_minted() { let state = state().await; let id = state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Water the plants") .priority(Priority::Low) .due(chrono::Utc::now()) .recurrence(Recurrence::Weekly) .build(), ) .unwrap() .id; let markup = html(post( &state, &format!("/tasks/list/{id}/status"), Params::new().with("status", "Completed"), Params::new(), )); // Re-read rather than patched: only the store knows the list holds a new // task now. The JS reaches the same answer by reloading when the completed // task had a recurrence. assert!(markup.contains("Water the plants"), "{markup}"); } #[tokio::test] async fn deleting_from_the_list_answers_the_list_rather_than_redirecting() { let state = state().await; let id = task(&state, "Doomed"); let response = post( &state, &format!("/tasks/list/{id}/delete"), Params::new(), Params::new(), ); let Outcome::Fragment { region, .. } = &response.outcome else { panic!("the row was already on the list; there is nowhere to send anyone"); }; assert_eq!(region, "tasks-list"); // A soft delete: the row moves to `Deleted`, which `list_filtered` never // returns and which the status filter does not offer. Gone from every // surface, still on disk, which is what the repository's `delete` means. let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.status, TaskStatus::Deleted); assert!(!html(get(&state, "/tasks", Params::new())).contains("Doomed")); } #[tokio::test] async fn deleting_is_confirmed_because_this_one_cannot_be_undone() { let state = state().await; task(&state, "Doomed"); let markup = screen(&state); // `tasks.js` has no confirm, and is right not to: its delete is optimistic // and the undo toast is the recovery. A description has no undo window, so // the deletion here is immediate and is confirmed. assert!(markup.contains("cannot be undone"), "{markup}"); } // Bulk actions /// A bulk write: the ticks it acts on, plus the view it was pressed under. fn over(state: &AppState, path: &str, ticks: &[TaskId], mut payload: Params) -> Response { for id in ticks { payload = payload.with(quasi_router::Node::TICKED, id.to_string()); } post(state, path, payload, Params::new()) } #[tokio::test] async fn every_row_joins_the_screens_selection() { let state = state().await; let id = task(&state, "Tickable"); let markup = screen(&state); // `Row::ticking`, which quasi 0.13.0 added for this screen. Before it a // described table could draw a bulk bar with nothing for it to act on, so // the port shipped without one. assert!( markup.contains(&format!("name=\"ticked\" value=\"{id}\"")), "{markup}" ); // And the controls say which set they act on, so a renderer gathers the // boxes without the app binding anything. assert!(markup.contains("hx-include=\".row-select\""), "{markup}"); } #[tokio::test] async fn select_all_is_an_address_rather_than_a_script() { let state = state().await; task(&state, "One"); task(&state, "Two"); let plain = screen(&state); assert_eq!(plain.matches(" checked").count(), 0, "{plain}"); // A webview select-all needs a script quasi-webview does not ship and a // terminal one needs a key it would have to invent. Answered from the // server it costs one query, works in every host, and survives the // fragment swap -- which the client-side version does not, because the // swap replaces the boxes. let all = html(get(&state, "/tasks", Params::new().with("ticked", "all"))); assert_eq!(all.matches(" checked").count(), 2, "{all}"); assert!(all.contains("Clear selection"), "{all}"); } #[tokio::test] async fn a_filter_change_does_not_carry_select_all_through_it() { let state = state().await; task(&state, "One"); let all = html(get(&state, "/tasks", Params::new().with("ticked", "all"))); // `tasks-filter.js` clears the selection on every filter change by hand, on // the rule that bulk actions must not target rows the user can no longer // see. Carrying `ticked=all` through a filter would make "everything" mean // a different everything, silently. assert!(!all.contains("priority=High&ticked=all"), "{all}"); assert!(!all.contains("ticked=all&priority=High"), "{all}"); } #[tokio::test] async fn completing_a_selection_completes_each_one_properly() { let state = state().await; let plain = task(&state, "Plain"); let recurring = state .tasks .create( DESKTOP_USER_ID, NewTask::builder("Water the plants") .priority(Priority::Low) .due(chrono::Utc::now()) .recurrence(Recurrence::Weekly) .build(), ) .unwrap() .id; let response = over( &state, "/tasks/list/complete", &[plain, recurring], Params::new(), ); assert_eq!( state .tasks .get_by_id(plain, DESKTOP_USER_ID) .unwrap() .unwrap() .status, TaskStatus::Completed ); // Through the same `move_to` a row's own Complete takes, so the recurring // one still mints its successor. A bulk loop calling the repository's // `complete` would have ended the chain on every task in the set at once, // which is the single-task bug multiplied. let markup = html(response); assert!(markup.contains("Water the plants"), "{markup}"); } #[tokio::test] async fn a_bulk_write_says_how_many_it_touched() { let state = state().await; let one = task(&state, "One"); let two = task(&state, "Two"); let response = over(&state, "/tasks/list/delete", &[one, two], Params::new()); assert_eq!( response.notice.as_ref().map(|notice| notice.text.as_str()), Some("2 tasks deleted.") ); // Singular is its own sentence, because "1 tasks" is how a screen tells you // nobody read it. let three = task(&state, "Three"); let single = over(&state, "/tasks/list/delete", &[three], Params::new()); assert_eq!( single.notice.as_ref().map(|notice| notice.text.as_str()), Some("1 task deleted.") ); } #[tokio::test] async fn a_picker_sets_the_value_on_the_whole_selection() { let state = state().await; let one = task(&state, "One"); let two = task(&state, "Two"); // The picker half of the bar. Said with acts alone the project picker // would be one button per project; `Act::asks` is what answers it. over( &state, "/tasks/list/priority", &[one, two], Params::new().with("priority", "High"), ); for id in [one, two] { let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.priority, Priority::High); } } #[tokio::test] async fn the_pickers_resting_state_writes_nothing() { let state = state().await; let id = task(&state, "Untouched"); // A select opens on its first option, and that option is a label rather // than a value. Choosing it must not set a priority nobody asked for. let response = over( &state, "/tasks/list/priority", &[id], Params::new().with("priority", ""), ); assert!(response.notice.is_none(), "{:?}", response.notice); let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap(); assert_eq!(after.priority, Priority::Medium); } #[tokio::test] async fn a_bulk_write_over_nothing_is_answered_rather_than_refused() { let state = state().await; task(&state, "Untouched"); // Every one of the shipped bar's five paths opens with // `if (selectedTaskIds.size === 0) return;`. Pressing a control with an // empty selection is a thing users do, not a wiring mistake. let response = over(&state, "/tasks/list/complete", &[], Params::new()); assert!(matches!(response.outcome, Outcome::Fragment { .. })); assert_eq!( response.notice.as_ref().map(|notice| notice.text.as_str()), Some("0 tasks completed.") ); } #[tokio::test] async fn a_bulk_write_clears_the_selection_it_acted_on() { let state = state().await; let one = task(&state, "One"); task(&state, "Two"); // Pressed from a select-all view. The answer must not re-tick what is left, // or "everything" quietly means something new after every press -- // `tasks.js` clears the set in each of its five bulk paths for the same // reason. let response = post( &state, "/tasks/list/delete", Params::new().with(quasi_router::Node::TICKED, one.to_string()), Params::new().with("ticked", "all"), ); let markup = html(response); assert!(markup.contains("Two"), "{markup}"); assert_eq!(markup.matches(" checked").count(), 0, "{markup}"); } #[tokio::test] async fn the_bar_travels_with_the_list_it_acts_on() { let state = state().await; task(&state, "One"); // Two regions in one answer. A bar moved without its rows would offer to // clear a selection the rows no longer have. let response = get(&state, "/tasks/list", Params::new().with("ticked", "all")); let regions: Vec<&str> = response .invalidates .iter() .map(|other| other.region.as_str()) .collect(); assert_eq!(regions, ["tasks-bulk"]); } // Nothing to show #[tokio::test] async fn an_empty_list_says_which_kind_of_empty_it_is() { let state = state().await; // Nothing at all. let fresh = screen(&state); assert!(fresh.contains("No tasks yet"), "{fresh}"); // Everything done, which is a different sentence and the reason this costs // a second query. let id = task(&state, "The last one"); state.tasks.complete(id, DESKTOP_USER_ID).unwrap(); let cleared = screen(&state); assert!(cleared.contains("All clear"), "{cleared}"); // Narrowed to nothing, which offers the way back out. let narrowed = html(get( &state, "/tasks", Params::new().with("priority", "High"), )); assert!(narrowed.contains("No tasks match"), "{narrowed}"); assert!(narrowed.contains("Clear filters"), "{narrowed}"); } #[tokio::test] async fn a_running_timer_says_when_it_started_rather_than_how_long_it_has_run() { // quasicoherent `f00244a6`: the row carries the instant and which way the // readout runs, and the renderer does the subtraction on its own clock. // Before the ruling this cell held a "Timer running" badge, because a // description saying "18m" would have been describing the moment it was // built. let state = state().await; let id = task(&state, "Write the thing"); state .tasks .start_timer(id, DESKTOP_USER_ID) .expect("the timer starts"); let html = screen(&state); assert!(html.contains("data-clock=\"since\""), "{html}"); assert!(!html.contains("Timer running"), "{html}"); }