Skip to main content

max / goingson

28.6 KB · 850 lines History Blame Raw
1 //! The task list, driven through the router against a real database.
2 //!
3 //! Two groups are worth reading. The first is the table: this is the vocabulary's
4 //! first `Node::Table`, so the assertions are about columns being columns and a
5 //! heading carrying the address that reorders by it. The second is the view: a
6 //! filter here is an address, where `tasks-filter.js` holds it in module scope
7 //! and mirrors it into the query string by hand, so the tests state what a
8 //! mistyped address answers rather than letting it fall back to a different list
9 //! than the one asked for.
10
11 use std::sync::Arc;
12
13 use goingson_core::{
14 NewProject, NewTask, Priority, ProjectStatus, ProjectType, Recurrence, TaskId, TaskStatus,
15 };
16 use quasi_http::Serves as _;
17 use quasi_router::{Outcome, Params, Request, Response};
18
19 use super::super::router;
20 use crate::state::{AppState, DESKTOP_USER_ID};
21
22 async fn state() -> Arc<AppState> {
23 let (state, _) = crate::test_utils::setup_test_state().await;
24 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
25 state
26 .db
27 .conn()
28 .unwrap()
29 .execute(
30 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
31 VALUES (?, ?, ?, ?, ?)",
32 rusqlite::params![
33 DESKTOP_USER_ID.to_string(),
34 "desktop@localhost",
35 "x",
36 "Desktop User",
37 &now,
38 ],
39 )
40 .unwrap();
41 state
42 }
43
44 fn task(state: &AppState, title: &str) -> TaskId {
45 state
46 .tasks
47 .create(
48 DESKTOP_USER_ID,
49 NewTask::builder(title).priority(Priority::Medium).build(),
50 )
51 .unwrap()
52 .id
53 }
54
55 fn get(state: &AppState, path: &str, params: Params) -> Response {
56 router()
57 .handle(state, Request::get(path).carrying(params))
58 .expect("the route answers")
59 }
60
61 fn post(state: &AppState, path: &str, payload: Params, carried: Params) -> Response {
62 router()
63 .handle(
64 state,
65 Request::post(path).sending(payload).carrying(carried),
66 )
67 .expect("the route answers")
68 }
69
70 fn html(response: Response) -> String {
71 match response.outcome {
72 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
73 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
74 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
75 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
76 Outcome::Anchored { .. } => {
77 panic!("expected content, got a screen drawn at a point on it")
78 }
79 Outcome::Suggestions { field, .. } => {
80 panic!("expected content, got a suggestion list for `{field}`")
81 }
82 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
83 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
84 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
85 // not content, and not a place either.
86 Outcome::Started { region, .. } => {
87 panic!("expected content, got work started in `{region}`")
88 }
89 }
90 }
91
92 /// The whole screen under the default view.
93 fn screen(state: &AppState) -> String {
94 html(get(state, "/tasks", Params::new()))
95 }
96
97 // The screen
98
99 #[tokio::test]
100 async fn the_drawers_delete_redirect_now_lands_somewhere() {
101 let state = state().await;
102 let id = task(&state, "Doomed");
103
104 // `super::super::tasks::remove` has answered `goto /tasks` since 2026-08-09
105 // and nothing served that route until this module. The old test asserted the
106 // destination; this one follows it.
107 let response = router()
108 .handle(&state, Request::post(format!("/tasks/{id}/delete")))
109 .expect("the route answers");
110 let Outcome::Goto(action) = response.outcome else {
111 panic!("deleting from the drawer redirects");
112 };
113 let route = action.destination.route().expect("a route to follow");
114 assert_eq!(route, "/tasks");
115
116 let followed = router().handle(&state, Request::get(route).carrying(Params::new()));
117 assert!(followed.is_ok(), "the redirect's destination answers");
118 }
119
120 #[tokio::test]
121 async fn the_table_is_the_columns_the_screen_describes_in_that_order() {
122 let state = state().await;
123 task(&state, "Write the thing");
124
125 let markup = screen(&state);
126
127 // The seven the described table names, in the order it names them. Read off
128 // COLUMNS, not off `build.rs`: this checks the described screen against
129 // itself, and it keeps working after TASK_COLUMNS is deleted at the flip.
130 let mut at = 0;
131 for name in [
132 "description",
133 "project",
134 "priority",
135 "due",
136 "recurrence",
137 "progress",
138 "actions",
139 ] {
140 let found = markup[at..]
141 .find(name)
142 .unwrap_or_else(|| panic!("no {name} column in {markup}"));
143 at += found + name.len();
144 }
145 }
146
147 #[tokio::test]
148 async fn a_row_carries_the_facts_the_js_row_carried() {
149 let state = state().await;
150 let project = state
151 .projects
152 .create(
153 DESKTOP_USER_ID,
154 NewProject {
155 name: "Housekeeping".to_owned(),
156 description: String::new(),
157 project_type: ProjectType::SideProject,
158 status: ProjectStatus::Active,
159 },
160 )
161 .unwrap();
162 state
163 .tasks
164 .create(
165 DESKTOP_USER_ID,
166 NewTask::builder("Write the thing")
167 .priority(Priority::High)
168 .project_id(project.id)
169 .build(),
170 )
171 .unwrap();
172
173 let markup = screen(&state);
174
175 assert!(markup.contains("Write the thing"), "{markup}");
176 assert!(markup.contains("Housekeeping"), "{markup}");
177 // The priority column is the single letter the shipped cell shows.
178 assert!(markup.contains(">H<"), "{markup}");
179 // And the title opens the drawer, which is where the eight controls this
180 // row does not offer actually live.
181 assert!(markup.contains("/tasks/"), "{markup}");
182 }
183
184 #[tokio::test]
185 async fn a_row_says_whether_the_task_is_available() {
186 let state = state().await;
187 let blocker = task(&state, "First");
188 let blocked = task(&state, "Second");
189 state
190 .tasks
191 .add_dependency(DESKTOP_USER_ID, blocked, blocker)
192 .unwrap();
193
194 let markup = screen(&state);
195
196 // `Availability`, shared with the board, the project dashboard and the day
197 // plan. Three surfaces drifted apart by each drawing this themselves; a
198 // fourth copy here would have been the fourth.
199 assert!(markup.contains("Blocked"), "{markup}");
200 assert!(markup.contains("Unblocks 1"), "{markup}");
201 }
202
203 #[tokio::test]
204 async fn the_subtask_meter_is_the_progress_column_and_not_also_a_badge() {
205 let state = state().await;
206 let id = task(&state, "Has subtasks");
207 state.tasks.add_subtask(id, DESKTOP_USER_ID, "one").unwrap();
208 state.tasks.add_subtask(id, DESKTOP_USER_ID, "two").unwrap();
209
210 let markup = screen(&state);
211
212 // `renderTaskRow` draws `0/2` in the description cell *and* a progress bar
213 // in the progress column: one fact twice, computed twice, able to disagree.
214 // The meter is the column's, so the badge is gone.
215 assert!(markup.contains("progress"), "{markup}");
216 assert!(!markup.contains("0/2"), "{markup}");
217 }
218
219 // The order
220
221 #[tokio::test]
222 async fn the_default_order_is_the_screens_and_not_the_commands() {
223 let state = state().await;
224 task(&state, "Anything");
225
226 let markup = screen(&state);
227
228 // `list_tasks_filtered` defaults to urgency descending and this screen has
229 // opened on due ascending since `tasks-filter.js` was written. A port of a
230 // screen keeps the screen's default.
231 assert!(markup.contains("ascending"), "{markup}");
232 // Nothing in the default address says so, because a default is never
233 // written: two addresses for one view cannot exist.
234 assert!(!markup.contains("sort=due"), "{markup}");
235 }
236
237 #[tokio::test]
238 async fn a_heading_carries_the_address_that_reorders_by_it() {
239 let state = state().await;
240 task(&state, "Anything");
241
242 let markup = screen(&state);
243
244 // `Column::reorder`'s first consumer. The shipped screen writes `aria-sort`
245 // onto the heading from module state and rebuilds the list in JS; here the
246 // heading is an address and the order it names is what the next read gets.
247 assert!(markup.contains("sort=project"), "{markup}");
248 assert!(markup.contains("sort=priority"), "{markup}");
249 // Recurrence and progress are not sortable in the build script's
250 // description, so they carry nothing.
251 assert!(!markup.contains("sort=recurrence"), "{markup}");
252 }
253
254 #[tokio::test]
255 async fn pressing_the_column_already_sorted_flips_it() {
256 let state = state().await;
257 task(&state, "Anything");
258
259 // Due is the default and is ascending, so its own heading offers descending.
260 // Asserted on the heading's own link rather than on the markup: every row
261 // control also carries the view it was drawn under, so a bare search for
262 // `direction=desc` finds the Start button too.
263 let markup = screen(&state);
264 assert!(
265 markup.contains(r#"class="table-sort" href="/tasks/list?direction=desc""#),
266 "{markup}"
267 );
268 assert!(markup.contains(r#"aria-sort="ascending""#), "{markup}");
269
270 // Under descending the same heading offers the way back, which is the
271 // default view and therefore an address with nothing written on it.
272 let flipped = html(get(
273 &state,
274 "/tasks/list",
275 Params::new().with("direction", "desc"),
276 ));
277 assert!(
278 flipped.contains(r#"class="table-sort" href="/tasks/list""#),
279 "{flipped}"
280 );
281 assert!(flipped.contains(r#"aria-sort="descending""#), "{flipped}");
282 }
283
284 // The view is an address
285
286 #[tokio::test]
287 async fn a_filter_answers_the_list_alone() {
288 let state = state().await;
289 task(&state, "Pending one");
290
291 let response = get(
292 &state,
293 "/tasks/list",
294 Params::new().with("status", "Completed"),
295 );
296 let Outcome::Fragment { region, .. } = &response.outcome else {
297 panic!("a filter replaces the list, not the screen");
298 };
299 assert_eq!(region, "tasks-list");
300 }
301
302 #[tokio::test]
303 async fn the_status_filter_cuts_the_list_and_latches_the_chip() {
304 let state = state().await;
305 let done = task(&state, "Finished");
306 task(&state, "Outstanding");
307 state.tasks.complete(done, DESKTOP_USER_ID).unwrap();
308
309 let pending = screen(&state);
310 assert!(pending.contains("Outstanding"), "{pending}");
311 assert!(!pending.contains("Finished"), "{pending}");
312
313 let completed = html(get(
314 &state,
315 "/tasks",
316 Params::new().with("status", "Completed"),
317 ));
318 assert!(completed.contains("Finished"), "{completed}");
319 assert!(!completed.contains("Outstanding"), "{completed}");
320 }
321
322 #[tokio::test]
323 async fn a_word_the_filter_does_not_know_is_a_404_and_not_a_different_list() {
324 let state = state().await;
325 task(&state, "Anything");
326
327 // The problems inbox's rule. `TaskStatus::from_str_or_default` would answer
328 // Pending for "Pendign" and the screen would look like it worked.
329 for (name, value) in [
330 ("status", "Pendign"),
331 ("priority", "Highest"),
332 ("sort", "urgncy"),
333 ("project", "not-a-uuid"),
334 ] {
335 let answer = router().handle(
336 &state,
337 Request::get("/tasks/list").carrying(Params::new().with(name, value)),
338 );
339 assert!(answer.is_err(), "{name}={value} answered something");
340 }
341 }
342
343 #[tokio::test]
344 async fn the_priority_chip_clears_itself_when_it_is_the_one_latched() {
345 let state = state().await;
346 task(&state, "Anything");
347
348 let filtered = html(get(
349 &state,
350 "/tasks",
351 Params::new().with("priority", "High"),
352 ));
353
354 // Pressing a latched chip clears it, so the way back is always on screen.
355 // The contacts tag filter's rule, and the problems source filter's. Its
356 // own href is what carries the clearing, not the markup at large: every
357 // other control on the screen preserves the filter it was drawn under, so
358 // `priority=High` appears all over it and should.
359 assert!(
360 filtered.contains(r#"<a class="chip latched" data-act aria-current="true" href="/tasks/list" hx-get="/tasks/list" hx-swap="outerMorph">High</a>"#),
361 "{filtered}"
362 );
363 assert!(filtered.contains("priority=Medium"), "{filtered}");
364 }
365
366 #[tokio::test]
367 async fn a_filter_change_goes_back_to_one_page() {
368 let state = state().await;
369 task(&state, "Anything");
370
371 let deep = html(get(&state, "/tasks", Params::new().with("shown", "600")));
372
373 // Carrying `shown` onto a filter would ask for 600 rows of a priority
374 // holding nine. The mail list's rule.
375 assert!(!deep.contains("shown=600&"), "{deep}");
376 assert!(deep.contains("priority=High"), "{deep}");
377 }
378
379 #[tokio::test]
380 async fn the_milestone_control_appears_only_under_a_chosen_project() {
381 let state = state().await;
382 let project = state
383 .projects
384 .create(
385 DESKTOP_USER_ID,
386 NewProject {
387 name: "Housekeeping".to_owned(),
388 description: String::new(),
389 project_type: ProjectType::SideProject,
390 status: ProjectStatus::Active,
391 },
392 )
393 .unwrap();
394 state
395 .milestones
396 .create(
397 DESKTOP_USER_ID,
398 goingson_core::NewMilestone {
399 project_id: project.id,
400 name: "Beta".to_owned(),
401 description: String::new(),
402 target_date: None,
403 position: 0,
404 },
405 )
406 .unwrap();
407
408 let unfiltered = screen(&state);
409 assert!(!unfiltered.contains("Beta"), "{unfiltered}");
410
411 let scoped = html(get(
412 &state,
413 "/tasks",
414 Params::new().with("project", project.id.to_string()),
415 ));
416 // `populateMilestoneFilter`'s rule: a milestone belongs to a project, so
417 // every project's milestones at once is a control whose options mean
418 // nothing together.
419 assert!(scoped.contains("Beta"), "{scoped}");
420 }
421
422 // Paging
423
424 #[tokio::test]
425 async fn the_way_to_more_rows_appears_only_when_there_are_more() {
426 let state = state().await;
427 task(&state, "The only one");
428
429 let short = screen(&state);
430 assert!(!short.contains("rest-position"), "{short}");
431
432 // `shown` is clamped to at least a page, so a short list never offers it
433 // however the address is typed.
434 let typed_low = html(get(&state, "/tasks/list", Params::new().with("shown", "1")));
435 assert!(!typed_low.contains("rest-position"), "{typed_low}");
436
437 // Past one page it does, and it names how far along the reader is, which is
438 // what the shipped count chip says while pages stream in.
439 //
440 // Finding 2 in the module header, closed in quasi 0.15.0: this used to be a
441 // `Node::Act` under the table because a table could not say it for itself,
442 // and it is the table's own `Rest` now. So the assertion is on the pager the
443 // renderer draws from the description rather than on an act's label.
444 for n in 0..super::PAGE {
445 task(&state, &format!("Filler {n}"));
446 }
447 let long = screen(&state);
448 assert!(long.contains("200 of 201"), "{long}");
449 assert!(long.contains("shown=400"), "{long}");
450 assert!(long.contains("rest-position"), "{long}");
451 }
452
453 // Writes
454
455 #[tokio::test]
456 async fn starting_a_task_from_the_list_answers_the_list() {
457 let state = state().await;
458 let id = task(&state, "Startable");
459
460 let response = post(
461 &state,
462 &format!("/tasks/list/{id}/status"),
463 Params::new().with("status", "Started"),
464 Params::new(),
465 );
466
467 let Outcome::Fragment { region, .. } = &response.outcome else {
468 panic!("a row's control replaces the list, not the screen");
469 };
470 assert_eq!(region, "tasks-list");
471
472 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
473 assert_eq!(after.status, TaskStatus::Started);
474 }
475
476 #[tokio::test]
477 async fn the_write_and_the_filter_are_both_called_status_and_cannot_reach_each_other() {
478 let state = state().await;
479 let id = task(&state, "Startable");
480
481 // The arrangement the problems inbox and the mail screen paid for on one
482 // afternoon: `payload` and `carried` are different bags, so the target of
483 // the write is not read back as the view it happened in.
484 let response = post(
485 &state,
486 &format!("/tasks/list/{id}/status"),
487 Params::new().with("status", "Completed"),
488 Params::new().with("status", "Pending"),
489 );
490
491 let markup = html(response);
492 // Answered with the Pending list it was pressed in, which no longer holds
493 // the completed task -- not with the Completed list the write named.
494 assert!(!markup.contains("Startable"), "{markup}");
495 }
496
497 #[tokio::test]
498 async fn pressing_a_status_a_task_is_already_in_writes_nothing() {
499 let state = state().await;
500 let id = task(&state, "Stationary");
501
502 let response = post(
503 &state,
504 &format!("/tasks/list/{id}/status"),
505 Params::new().with("status", "Pending"),
506 Params::new(),
507 );
508
509 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
510 assert_eq!(after.status, TaskStatus::Pending);
511 // No toast, because nothing happened. A row can be pressed while the list
512 // it was drawn in is stale, and a repeated press must not complete a task
513 // twice and mint a second recurrence.
514 assert!(response.notice.is_none(), "{:?}", response.notice);
515 }
516
517 #[tokio::test]
518 async fn completing_a_recurring_task_shows_the_successor_it_minted() {
519 let state = state().await;
520 let id = state
521 .tasks
522 .create(
523 DESKTOP_USER_ID,
524 NewTask::builder("Water the plants")
525 .priority(Priority::Low)
526 .due(chrono::Utc::now())
527 .recurrence(Recurrence::Weekly)
528 .build(),
529 )
530 .unwrap()
531 .id;
532
533 let markup = html(post(
534 &state,
535 &format!("/tasks/list/{id}/status"),
536 Params::new().with("status", "Completed"),
537 Params::new(),
538 ));
539
540 // Re-read rather than patched: only the store knows the list holds a new
541 // task now. The JS reaches the same answer by reloading when the completed
542 // task had a recurrence.
543 assert!(markup.contains("Water the plants"), "{markup}");
544 }
545
546 #[tokio::test]
547 async fn deleting_from_the_list_answers_the_list_rather_than_redirecting() {
548 let state = state().await;
549 let id = task(&state, "Doomed");
550
551 let response = post(
552 &state,
553 &format!("/tasks/list/{id}/delete"),
554 Params::new(),
555 Params::new(),
556 );
557
558 let Outcome::Fragment { region, .. } = &response.outcome else {
559 panic!("the row was already on the list; there is nowhere to send anyone");
560 };
561 assert_eq!(region, "tasks-list");
562
563 // A soft delete: the row moves to `Deleted`, which `list_filtered` never
564 // returns and which the status filter does not offer. Gone from every
565 // surface, still on disk, which is what the repository's `delete` means.
566 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
567 assert_eq!(after.status, TaskStatus::Deleted);
568 assert!(!html(get(&state, "/tasks", Params::new())).contains("Doomed"));
569 }
570
571 #[tokio::test]
572 async fn deleting_is_confirmed_because_this_one_cannot_be_undone() {
573 let state = state().await;
574 task(&state, "Doomed");
575
576 let markup = screen(&state);
577
578 // `tasks.js` has no confirm, and is right not to: its delete is optimistic
579 // and the undo toast is the recovery. A description has no undo window, so
580 // the deletion here is immediate and is confirmed.
581 assert!(markup.contains("cannot be undone"), "{markup}");
582 }
583
584 // Bulk actions
585
586 /// A bulk write: the ticks it acts on, plus the view it was pressed under.
587 fn over(state: &AppState, path: &str, ticks: &[TaskId], mut payload: Params) -> Response {
588 for id in ticks {
589 payload = payload.with(quasi_router::Node::TICKED, id.to_string());
590 }
591 post(state, path, payload, Params::new())
592 }
593
594 #[tokio::test]
595 async fn every_row_joins_the_screens_selection() {
596 let state = state().await;
597 let id = task(&state, "Tickable");
598
599 let markup = screen(&state);
600
601 // `Row::ticking`, which quasi 0.13.0 added for this screen. Before it a
602 // described table could draw a bulk bar with nothing for it to act on, so
603 // the port shipped without one.
604 assert!(
605 markup.contains(&format!("name=\"ticked\" value=\"{id}\"")),
606 "{markup}"
607 );
608 // And the controls say which set they act on, so a renderer gathers the
609 // boxes without the app binding anything.
610 assert!(markup.contains("hx-include=\".row-select\""), "{markup}");
611 }
612
613 #[tokio::test]
614 async fn select_all_is_an_address_rather_than_a_script() {
615 let state = state().await;
616 task(&state, "One");
617 task(&state, "Two");
618
619 let plain = screen(&state);
620 assert_eq!(plain.matches(" checked").count(), 0, "{plain}");
621
622 // A webview select-all needs a script quasi-webview does not ship and a
623 // terminal one needs a key it would have to invent. Answered from the
624 // server it costs one query, works in every host, and survives the
625 // fragment swap -- which the client-side version does not, because the
626 // swap replaces the boxes.
627 let all = html(get(&state, "/tasks", Params::new().with("ticked", "all")));
628 assert_eq!(all.matches(" checked").count(), 2, "{all}");
629 assert!(all.contains("Clear selection"), "{all}");
630 }
631
632 #[tokio::test]
633 async fn a_filter_change_does_not_carry_select_all_through_it() {
634 let state = state().await;
635 task(&state, "One");
636
637 let all = html(get(&state, "/tasks", Params::new().with("ticked", "all")));
638
639 // `tasks-filter.js` clears the selection on every filter change by hand, on
640 // the rule that bulk actions must not target rows the user can no longer
641 // see. Carrying `ticked=all` through a filter would make "everything" mean
642 // a different everything, silently.
643 assert!(!all.contains("priority=High&amp;ticked=all"), "{all}");
644 assert!(!all.contains("ticked=all&amp;priority=High"), "{all}");
645 }
646
647 #[tokio::test]
648 async fn completing_a_selection_completes_each_one_properly() {
649 let state = state().await;
650 let plain = task(&state, "Plain");
651 let recurring = state
652 .tasks
653 .create(
654 DESKTOP_USER_ID,
655 NewTask::builder("Water the plants")
656 .priority(Priority::Low)
657 .due(chrono::Utc::now())
658 .recurrence(Recurrence::Weekly)
659 .build(),
660 )
661 .unwrap()
662 .id;
663
664 let response = over(
665 &state,
666 "/tasks/list/complete",
667 &[plain, recurring],
668 Params::new(),
669 );
670
671 assert_eq!(
672 state
673 .tasks
674 .get_by_id(plain, DESKTOP_USER_ID)
675 .unwrap()
676 .unwrap()
677 .status,
678 TaskStatus::Completed
679 );
680
681 // Through the same `move_to` a row's own Complete takes, so the recurring
682 // one still mints its successor. A bulk loop calling the repository's
683 // `complete` would have ended the chain on every task in the set at once,
684 // which is the single-task bug multiplied.
685 let markup = html(response);
686 assert!(markup.contains("Water the plants"), "{markup}");
687 }
688
689 #[tokio::test]
690 async fn a_bulk_write_says_how_many_it_touched() {
691 let state = state().await;
692 let one = task(&state, "One");
693 let two = task(&state, "Two");
694
695 let response = over(&state, "/tasks/list/delete", &[one, two], Params::new());
696 assert_eq!(
697 response.notice.as_ref().map(|notice| notice.text.as_str()),
698 Some("2 tasks deleted.")
699 );
700
701 // Singular is its own sentence, because "1 tasks" is how a screen tells you
702 // nobody read it.
703 let three = task(&state, "Three");
704 let single = over(&state, "/tasks/list/delete", &[three], Params::new());
705 assert_eq!(
706 single.notice.as_ref().map(|notice| notice.text.as_str()),
707 Some("1 task deleted.")
708 );
709 }
710
711 #[tokio::test]
712 async fn a_picker_sets_the_value_on_the_whole_selection() {
713 let state = state().await;
714 let one = task(&state, "One");
715 let two = task(&state, "Two");
716
717 // The picker half of the bar. Said with acts alone the project picker
718 // would be one button per project; `Act::asks` is what answers it.
719 over(
720 &state,
721 "/tasks/list/priority",
722 &[one, two],
723 Params::new().with("priority", "High"),
724 );
725
726 for id in [one, two] {
727 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
728 assert_eq!(after.priority, Priority::High);
729 }
730 }
731
732 #[tokio::test]
733 async fn the_pickers_resting_state_writes_nothing() {
734 let state = state().await;
735 let id = task(&state, "Untouched");
736
737 // A select opens on its first option, and that option is a label rather
738 // than a value. Choosing it must not set a priority nobody asked for.
739 let response = over(
740 &state,
741 "/tasks/list/priority",
742 &[id],
743 Params::new().with("priority", ""),
744 );
745
746 assert!(response.notice.is_none(), "{:?}", response.notice);
747 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
748 assert_eq!(after.priority, Priority::Medium);
749 }
750
751 #[tokio::test]
752 async fn a_bulk_write_over_nothing_is_answered_rather_than_refused() {
753 let state = state().await;
754 task(&state, "Untouched");
755
756 // Every one of the shipped bar's five paths opens with
757 // `if (selectedTaskIds.size === 0) return;`. Pressing a control with an
758 // empty selection is a thing users do, not a wiring mistake.
759 let response = over(&state, "/tasks/list/complete", &[], Params::new());
760 assert!(matches!(response.outcome, Outcome::Fragment { .. }));
761 assert_eq!(
762 response.notice.as_ref().map(|notice| notice.text.as_str()),
763 Some("0 tasks completed.")
764 );
765 }
766
767 #[tokio::test]
768 async fn a_bulk_write_clears_the_selection_it_acted_on() {
769 let state = state().await;
770 let one = task(&state, "One");
771 task(&state, "Two");
772
773 // Pressed from a select-all view. The answer must not re-tick what is left,
774 // or "everything" quietly means something new after every press --
775 // `tasks.js` clears the set in each of its five bulk paths for the same
776 // reason.
777 let response = post(
778 &state,
779 "/tasks/list/delete",
780 Params::new().with(quasi_router::Node::TICKED, one.to_string()),
781 Params::new().with("ticked", "all"),
782 );
783
784 let markup = html(response);
785 assert!(markup.contains("Two"), "{markup}");
786 assert_eq!(markup.matches(" checked").count(), 0, "{markup}");
787 }
788
789 #[tokio::test]
790 async fn the_bar_travels_with_the_list_it_acts_on() {
791 let state = state().await;
792 task(&state, "One");
793
794 // Two regions in one answer. A bar moved without its rows would offer to
795 // clear a selection the rows no longer have.
796 let response = get(&state, "/tasks/list", Params::new().with("ticked", "all"));
797 let regions: Vec<&str> = response
798 .invalidates
799 .iter()
800 .map(|other| other.region.as_str())
801 .collect();
802 assert_eq!(regions, ["tasks-bulk"]);
803 }
804
805 // Nothing to show
806
807 #[tokio::test]
808 async fn an_empty_list_says_which_kind_of_empty_it_is() {
809 let state = state().await;
810
811 // Nothing at all.
812 let fresh = screen(&state);
813 assert!(fresh.contains("No tasks yet"), "{fresh}");
814
815 // Everything done, which is a different sentence and the reason this costs
816 // a second query.
817 let id = task(&state, "The last one");
818 state.tasks.complete(id, DESKTOP_USER_ID).unwrap();
819 let cleared = screen(&state);
820 assert!(cleared.contains("All clear"), "{cleared}");
821
822 // Narrowed to nothing, which offers the way back out.
823 let narrowed = html(get(
824 &state,
825 "/tasks",
826 Params::new().with("priority", "High"),
827 ));
828 assert!(narrowed.contains("No tasks match"), "{narrowed}");
829 assert!(narrowed.contains("Clear filters"), "{narrowed}");
830 }
831
832 #[tokio::test]
833 async fn a_running_timer_says_when_it_started_rather_than_how_long_it_has_run() {
834 // quasicoherent `f00244a6`: the row carries the instant and which way the
835 // readout runs, and the renderer does the subtraction on its own clock.
836 // Before the ruling this cell held a "Timer running" badge, because a
837 // description saying "18m" would have been describing the moment it was
838 // built.
839 let state = state().await;
840 let id = task(&state, "Write the thing");
841 state
842 .tasks
843 .start_timer(id, DESKTOP_USER_ID)
844 .expect("the timer starts");
845
846 let html = screen(&state);
847 assert!(html.contains("data-clock=\"since\""), "{html}");
848 assert!(!html.contains("Timer running"), "{html}");
849 }
850