Skip to main content

max / goingson

32.8 KB · 961 lines History Blame Raw
1 //! The task overview, driven through the router against a real database.
2 //!
3 //! Same standard as the other two screens: no Tauri runtime and no window, the
4 //! description asserted, and the markup only where the markup is the point.
5 //! Every workaround this port had to take is asserted here rather than left to
6 //! be noticed, so closing a finding is a test that has to change.
7
8 use std::sync::Arc;
9
10 use goingson_core::{NewTask, Priority};
11 use quasi_http::Serves as _;
12 use quasi_router::Outcome;
13 use quasi_router::{Params, Request, Response};
14
15 use super::super::router;
16 use crate::state::{AppState, DESKTOP_USER_ID};
17
18 /// State with the desktop user in place, which is who the handlers read as.
19 async fn state() -> Arc<AppState> {
20 let (state, _) = crate::test_utils::setup_test_state().await;
21 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
22 state
23 .db
24 .conn()
25 .unwrap()
26 .execute(
27 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
28 VALUES (?, ?, ?, ?, ?)",
29 rusqlite::params![
30 DESKTOP_USER_ID.to_string(),
31 "desktop@localhost",
32 "x",
33 "Desktop User",
34 &now,
35 ],
36 )
37 .unwrap();
38 state
39 }
40
41 fn add(state: &AppState, title: &str) -> goingson_core::Task {
42 state
43 .tasks
44 .create(
45 DESKTOP_USER_ID,
46 NewTask::builder(title)
47 .title(title)
48 .priority(Priority::High)
49 .build(),
50 )
51 .unwrap()
52 }
53
54 fn get(state: &AppState, path: &str) -> Response {
55 router()
56 .handle(state, Request::get(path))
57 .expect("the route answers")
58 }
59
60 fn post(state: &AppState, path: &str, params: Params) -> Response {
61 router()
62 .handle(state, Request::post(path).sending(params))
63 .expect("the route answers")
64 }
65
66 fn html(response: Response) -> String {
67 match response.outcome {
68 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
69 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
70 // Deliberately not a wildcard. See the same helper in the dashboard
71 // tests: a redirect has no body, and a fallback returning empty markup
72 // would read as a screen that rendered nothing.
73 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
74 // Same reasoning one step along: an overlay is a screen, but it is not
75 // the screen this route was asked for. Rendering it here would let a
76 // route that answered with the command palette pass as the page.
77 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
78 Outcome::Anchored { .. } => {
79 panic!("expected content, got a screen drawn at a point on it")
80 }
81 Outcome::Suggestions { field, .. } => {
82 panic!("expected content, got a suggestion list for `{field}`")
83 }
84 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
85 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
86 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
87 // not content, and not a place either.
88 Outcome::Started { region, .. } => {
89 panic!("expected content, got work started in `{region}`")
90 }
91 }
92 }
93
94 #[tokio::test]
95 async fn the_overview_carries_the_task_and_its_badges() {
96 let state = state().await;
97 let task = add(&state, "Write the port");
98
99 let page = html(get(&state, &format!("/tasks/{}", task.id)));
100
101 assert!(page.contains("Write the port"));
102 // Status and priority are tokens, so they keep their tone. `H` is the short
103 // form `Priority::as_str` gives, which is what the JS chip shows too.
104 assert!(page.contains("Pending"));
105 assert!(page.contains(">H<"));
106 assert!(page.contains("data-tone=\"danger\""));
107 }
108
109 #[tokio::test]
110 async fn a_missing_task_is_a_not_found_rather_than_a_panic() {
111 let state = state().await;
112 let error = router()
113 .handle(
114 &state,
115 Request::get(format!("/tasks/{}", uuid::Uuid::nil())),
116 )
117 .expect_err("no such task");
118 assert_eq!(error.class.http_status(), 404);
119 }
120
121 #[tokio::test]
122 async fn a_task_title_cannot_become_markup() {
123 let state = state().await;
124 let task = add(&state, "<script>alert(1)</script>");
125 let page = html(get(&state, &format!("/tasks/{}", task.id)));
126 assert!(!page.contains("<script>alert"));
127 assert!(page.contains("&lt;script&gt;"));
128 }
129
130 #[tokio::test]
131 async fn the_heatmap_is_a_place_and_the_description_says_nothing_about_it() {
132 // Decision 4 working rather than a shortfall. A month grid of completion
133 // counts is not describable and is not going to become describable; the
134 // screen says a thing called `task-heatmap` goes here and stops.
135 let state = state().await;
136 let task = state
137 .tasks
138 .create(
139 DESKTOP_USER_ID,
140 NewTask::builder("Daily")
141 .title("Daily")
142 .recurrence(goingson_core::Recurrence::Daily)
143 .build(),
144 )
145 .unwrap();
146
147 let page = html(get(&state, &format!("/tasks/{}", task.id)));
148
149 assert!(page.contains("Completion History"));
150 assert!(page.contains("data-bespoke=\"task-heatmap\""));
151 // A place and nothing else: no cells, no counts, no month.
152 assert!(!page.contains("month-heatmap"));
153 }
154
155 #[tokio::test]
156 async fn a_one_off_task_has_no_completion_history_at_all() {
157 // Absent rather than empty. A streak of zero on a task that does not repeat
158 // is a section saying nothing four times.
159 let state = state().await;
160 let task = add(&state, "Once");
161 let page = html(get(&state, &format!("/tasks/{}", task.id)));
162 assert!(!page.contains("Completion History"));
163 assert!(!page.contains("data-bespoke"));
164 }
165
166 #[tokio::test]
167 async fn a_stat_is_a_figure_with_a_caption_and_not_a_row_read_backwards() {
168 // The first finding, closed. It asserted the workaround until 2026-08-09:
169 // `Row` is a list item, so the caption landed in the primary slot and the
170 // figure in the trailing one, the opposite of the tile the JS draws.
171 // `93c6a174`.
172 let state = state().await;
173 let task = state
174 .tasks
175 .create(
176 DESKTOP_USER_ID,
177 NewTask::builder("Daily")
178 .title("Daily")
179 .recurrence(goingson_core::Recurrence::Daily)
180 .build(),
181 )
182 .unwrap();
183
184 let page = html(get(&state, &format!("/tasks/{}", task.id)));
185
186 assert!(page.contains("Current Streak"));
187 // A strip of figures, not a list of rows read backwards.
188 assert!(page.contains(r#"<div class="figures">"#));
189 assert!(page.contains(r#"aria-label="Current Streak: 0d""#));
190 // Four of them, which is the arrangement a renderer could not have inferred
191 // from four separate nodes.
192 assert_eq!(page.matches(r#"class="figure""#).count(), 4);
193 }
194
195 #[tokio::test]
196 async fn a_subtask_tick_is_the_write_rather_than_a_button_beside_it() {
197 // The third finding, closed. It asserted the workaround until 2026-08-09 —
198 // a tick drawn inert with the route on a button next to it — and
199 // `Row::toggle` is what changed. `14612ed8`.
200 let state = state().await;
201 let task = add(&state, "Has subtasks");
202 state
203 .tasks
204 .add_subtask(task.id, DESKTOP_USER_ID, "First step")
205 .unwrap();
206
207 let page = html(get(&state, &format!("/tasks/{}", task.id)));
208
209 assert!(page.contains("type=\"checkbox\""));
210 assert!(page.contains("/subtasks/"));
211 assert!(page.contains("/toggle"));
212 // The route is on the tick itself, and it fires on the value changing
213 // rather than on the row being activated.
214 assert!(page.contains("hx-trigger=\"change\""));
215 // The button the workaround needed is gone, so there is one affordance for
216 // one write rather than two for one.
217 assert!(!page.contains(">Done<"));
218 }
219
220 #[tokio::test]
221 async fn a_linked_subtask_says_it_cannot_be_toggled() {
222 // Not a workaround: `Act::disabled` says exactly what the JS's `disabled`
223 // attribute says, so this one is described rather than approximated.
224 let state = state().await;
225 let task = add(&state, "Parent");
226 let linked = add(&state, "Child");
227 state
228 .tasks
229 .add_subtask_link(task.id, DESKTOP_USER_ID, linked.id)
230 .unwrap();
231
232 let page = html(get(&state, &format!("/tasks/{}", task.id)));
233 assert!(page.contains("Linked"));
234 assert!(page.contains("disabled"));
235 }
236
237 #[tokio::test]
238 async fn a_proportion_is_drawn_as_a_bar_and_not_only_counted() {
239 // The fourth finding, closed by makeover-layout 0.10.0's `Meter`. This
240 // asserted the loss until then: the bar and its tone were gone and only the
241 // concatenated heading survived. Both are back, and the heading keeps its
242 // count because that half was a separate gap and stays open.
243 let state = state().await;
244 let task = add(&state, "Has subtasks");
245 state
246 .tasks
247 .add_subtask(task.id, DESKTOP_USER_ID, "First step")
248 .unwrap();
249
250 let page = html(get(&state, &format!("/tasks/{}", task.id)));
251
252 assert!(page.contains("Subtasks 0/1"));
253 assert!(page.contains("progress-fill"));
254 assert!(page.contains(r#"data-tone="success""#));
255 // The ratio reaches a reader that cannot see the bar, which is the whole
256 // reason the description carries both numbers instead of a percentage.
257 assert!(page.contains(r#"aria-label="0 of 1 subtasks""#));
258 }
259
260 #[tokio::test]
261 async fn an_over_run_estimate_is_toned_and_says_it_ran_over() {
262 // The site that decided `Meter`'s shape. `Task::time_progress` would report
263 // this as exactly 100%, because it clamps; the description carries 45 and
264 // 30, so the bar is full AND the over-run survives in two places a
265 // percentage could not have kept it.
266 let state = state().await;
267 let task = state
268 .tasks
269 .create(
270 DESKTOP_USER_ID,
271 NewTask::builder("Ran long")
272 .title("Ran long")
273 .priority(Priority::High)
274 .estimated_minutes(30)
275 .build(),
276 )
277 .unwrap();
278 // `actual_minutes` is a cache the timer maintains, so there is no setter to
279 // call; a test that wanted one through the front door would have to let
280 // wall-clock time pass.
281 state
282 .db
283 .conn()
284 .unwrap()
285 .execute(
286 "UPDATE tasks SET actual_minutes = 45 WHERE id = ?",
287 rusqlite::params![task.id.to_string()],
288 )
289 .unwrap();
290
291 let page = html(get(&state, &format!("/tasks/{}", task.id)));
292
293 assert!(page.contains("width: 100%"));
294 assert!(page.contains(r#"data-over="true""#));
295 assert!(page.contains(r#"data-tone="danger""#));
296 assert!(page.contains(r#"aria-label="45 of 30 minutes""#));
297 }
298
299 #[tokio::test]
300 async fn adding_a_subtask_answers_with_the_screen_it_happened_on() {
301 let state = state().await;
302 let task = add(&state, "Empty");
303
304 let response = post(
305 &state,
306 &format!("/tasks/{}/subtasks", task.id),
307 Params::new().with("text", "Added here"),
308 );
309 let Outcome::Screen(_) = &response.outcome else {
310 panic!("a write answers with the whole screen");
311 };
312 let page = html(response);
313
314 assert!(page.contains("Added here"));
315 assert!(page.contains("Subtasks 0/1"));
316 }
317
318 #[tokio::test]
319 async fn an_empty_add_changes_nothing_rather_than_failing() {
320 // The JS returns early on an empty box. A 400 here would be the described
321 // screen inventing an error the shipped one does not have.
322 let state = state().await;
323 let task = add(&state, "Empty");
324
325 let page = html(post(
326 &state,
327 &format!("/tasks/{}/subtasks", task.id),
328 Params::new().with("text", " "),
329 ));
330 assert!(page.contains("Subtasks 0/0"));
331 }
332
333 #[tokio::test]
334 async fn toggling_a_subtask_ticks_it_and_the_tick_is_the_way_back() {
335 let state = state().await;
336 let task = add(&state, "Has subtasks");
337 let subtask = state
338 .tasks
339 .add_subtask(task.id, DESKTOP_USER_ID, "First step")
340 .unwrap()
341 .unwrap();
342
343 let page = html(post(
344 &state,
345 &format!("/tasks/{}/subtasks/{}/toggle", task.id, subtask.id),
346 Params::new(),
347 ));
348
349 assert!(page.contains("Subtasks 1/1"));
350 assert!(page.contains(" checked"));
351 // The way back was an "Undo" button until `14612ed8` closed. It is the same
352 // tick, unticked, which is what the shipped screen has always offered.
353 assert!(page.contains("/toggle"));
354 assert!(!page.contains(">Undo<"));
355 }
356
357 #[tokio::test]
358 async fn adding_a_note_puts_it_on_the_screen() {
359 let state = state().await;
360 let task = add(&state, "Needs a note");
361
362 let page = html(post(
363 &state,
364 &format!("/tasks/{}/notes", task.id),
365 Params::new().with("note", "Remember the thing"),
366 ));
367
368 assert!(page.contains("Remember the thing"));
369 assert!(page.contains("Notes 1"));
370 }
371
372 #[tokio::test]
373 async fn completing_a_task_takes_the_complete_control_away() {
374 let state = state().await;
375 let task = add(&state, "Nearly done");
376
377 let page = html(post(
378 &state,
379 &format!("/tasks/{}/complete", task.id),
380 Params::new(),
381 ));
382
383 assert!(page.contains("Completed"));
384 // Offering to complete something already complete is a control that does
385 // nothing, which is what this whole line of work is about not doing.
386 assert!(!page.contains(">Complete<"));
387 assert!(page.contains(">Delete<"));
388 }
389
390 #[tokio::test]
391 async fn deleting_a_task_sends_the_user_to_the_list_and_says_so() {
392 // The sixth finding, closed 2026-08-09 (`80afd652`). This asserted the
393 // workaround until then: a tombstone screen, addressed by the id of the
394 // thing that no longer existed, because every answer was content. The
395 // workaround is gone, so the assertion had to change rather than be relaxed.
396 let state = state().await;
397 let task = add(&state, "Going");
398
399 let answer = post(&state, &format!("/tasks/{}/delete", task.id), Params::new());
400
401 let Outcome::Goto(action) = &answer.outcome else {
402 panic!("a delete answers with somewhere to go, not with content");
403 };
404 assert_eq!(action.destination.route(), Some("/tasks"));
405
406 // And it says what happened, which the redirect alone cannot: the list it
407 // lands on looks the same whether a task was deleted or the user navigated.
408 let notice = answer.notice.as_ref().expect("a delete says so");
409 assert_eq!(notice.text, "Task deleted");
410 assert_eq!(notice.kind, quasi_router::layout::Notice::Toast);
411
412 // And it is really gone.
413 let error = router()
414 .handle(&state, Request::get(format!("/tasks/{}", task.id)))
415 .expect_err("deleted");
416 assert_eq!(error.class.http_status(), 404);
417 }
418
419 #[tokio::test]
420 async fn deleting_something_that_is_not_there_is_a_not_found() {
421 let state = state().await;
422 let error = router()
423 .handle(
424 &state,
425 Request::post(format!("/tasks/{}/delete", uuid::Uuid::nil())),
426 )
427 .expect_err("no such task");
428 assert_eq!(error.class.http_status(), 404);
429 }
430
431 #[tokio::test]
432 async fn a_description_renders_as_markdown_and_still_cannot_become_arbitrary_markup() {
433 // The second finding, closed. It asserted the lossy form until 2026-08-09 —
434 // the user read `**bold**` rather than bold — and `Node::Rich` is what
435 // changed. `25822137`.
436 let state = state().await;
437 let task = state
438 .tasks
439 .create(
440 DESKTOP_USER_ID,
441 NewTask::builder("Documented")
442 .title("Documented")
443 .description("A **bold** claim\n\n<script>alert(1)</script>")
444 .build(),
445 )
446 .unwrap();
447
448 let page = html(get(&state, &format!("/tasks/{}", task.id)));
449
450 assert!(page.contains("<strong>bold</strong>"));
451 // The member carries source, so what a user typed is still the renderer's
452 // to decide about. This is the same guarantee `Node::Text` gives, kept.
453 // The body of the script rather than the tag: the document shell carries
454 // htmx's own `<script>`, so the tag alone is not the question.
455 assert!(!page.contains("alert(1)"));
456 }
457
458 #[tokio::test]
459 async fn edit_is_an_address_rather_than_an_overlay() {
460 // The fifth finding, and this assertion is its inverse. It read
461 // `!page.contains(">Edit<")` while the control was left out, because
462 // `tasks.openEdit` opens a modal form over the drawer and that is a second
463 // arrangement this screen would have to describe. The form has its own
464 // address now, so the control is a link to a screen rather than an overlay
465 // drawn on this one.
466 let state = state().await;
467 let task = add(&state, "Editable elsewhere");
468 let page = html(get(&state, &format!("/tasks/{}", task.id)));
469 assert!(page.contains(">Edit<"), "{page}");
470 assert!(page.contains(&format!("/tasks/{}/edit", task.id)), "{page}");
471 }
472
473 // The dependencies section. It exists because `338aa9f` added the blocking
474 // graph to `task-overview.js` after this screen was ported and nothing checked
475 // the two against each other; these are the assertions that make the drift a
476 // test failure rather than something noticed a second time by hand.
477
478 #[tokio::test]
479 async fn a_task_with_no_edges_says_so_and_reads_as_ready() {
480 let state = state().await;
481 let task = add(&state, "Standalone");
482
483 let page = html(get(&state, &format!("/tasks/{}", task.id)));
484
485 assert!(page.contains("Dependencies"), "{page}");
486 assert!(page.contains("Ready"), "{page}");
487 assert!(
488 page.contains("Nothing blocks this task and nothing waits on it."),
489 "{page}"
490 );
491 }
492
493 #[tokio::test]
494 async fn a_blocked_task_says_how_far_away_it_is_and_names_what_it_waits_on() {
495 let state = state().await;
496 let blocker = add(&state, "Do this first");
497 let blocked = add(&state, "Then this");
498 state
499 .tasks
500 .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id)
501 .unwrap();
502
503 let page = html(get(&state, &format!("/tasks/{}", blocked.id)));
504
505 // The depth, which is the fact `blockDepth` carries and a bare "Blocked"
506 // does not.
507 assert!(page.contains("Blocked, 1 step away"), "{page}");
508 assert!(page.contains("Blocked by"), "{page}");
509 assert!(page.contains("Do this first"), "{page}");
510
511 // And the other end of the same edge, from the other side.
512 let upstream = html(get(&state, &format!("/tasks/{}", blocker.id)));
513 assert!(upstream.contains("Blocks"), "{upstream}");
514 assert!(upstream.contains("unblocks 1 task"), "{upstream}");
515 }
516
517 #[tokio::test]
518 async fn a_satisfied_edge_is_drawn_rather_than_hidden() {
519 // A completed blocker is the record of what this task waited for. Dropping
520 // it from the view would make a finished chain look like it never existed.
521 let state = state().await;
522 let blocker = add(&state, "Already done");
523 let blocked = add(&state, "Now available");
524 state
525 .tasks
526 .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id)
527 .unwrap();
528 state.tasks.complete(blocker.id, DESKTOP_USER_ID).unwrap();
529
530 let page = html(get(&state, &format!("/tasks/{}", blocked.id)));
531
532 assert!(page.contains("Ready"), "{page}");
533 assert!(page.contains("Already done"), "{page}");
534 }
535
536 #[tokio::test]
537 async fn adding_a_blocker_draws_the_edge_and_answers_the_screen() {
538 let state = state().await;
539 let blocker = add(&state, "Prerequisite");
540 let blocked = add(&state, "Dependent work");
541
542 let page = html(post(
543 &state,
544 &format!("/tasks/{}/blockers", blocked.id),
545 Params::new().with("blocker", blocker.id.to_string()),
546 ));
547
548 assert!(page.contains("Blocked, 1 step away"), "{page}");
549 assert!(page.contains("Prerequisite"), "{page}");
550 }
551
552 #[tokio::test]
553 async fn an_edge_that_would_close_a_cycle_is_refused_with_the_chain() {
554 // The repository is the authority and its message names the chain already
555 // in the way, which is the only useful thing to say. The described screen
556 // passes it through rather than replacing it with a generic failure.
557 let state = state().await;
558 let first = add(&state, "First");
559 let second = add(&state, "Second");
560 state
561 .tasks
562 .add_dependency(DESKTOP_USER_ID, second.id, first.id)
563 .unwrap();
564
565 let error = router()
566 .handle(
567 &state,
568 Request::post(format!("/tasks/{}/blockers", first.id))
569 .sending(Params::new().with("blocker", second.id.to_string())),
570 )
571 .expect_err("a cycle is refused");
572
573 assert_eq!(error.class, quasi_router::error::Class::Conflict);
574 assert!(!error.message.is_empty(), "{error}");
575 }
576
577 #[tokio::test]
578 async fn an_edge_can_be_cut_from_either_side_of_it() {
579 let state = state().await;
580 let blocker = add(&state, "Upstream");
581 let blocked = add(&state, "Downstream");
582 state
583 .tasks
584 .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id)
585 .unwrap();
586
587 // From the blocked task's screen, where the other end is a blocker.
588 let page = html(post(
589 &state,
590 &format!("/tasks/{}/dependencies/{}/remove", blocked.id, blocker.id),
591 Params::new().with("role", "blocker"),
592 ));
593 assert!(page.contains("Ready"), "{page}");
594
595 // And back the other way, cut from the blocker's screen this time, where
596 // the same edge reads as a dependent.
597 state
598 .tasks
599 .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id)
600 .unwrap();
601 let page = html(post(
602 &state,
603 &format!("/tasks/{}/dependencies/{}/remove", blocker.id, blocked.id),
604 Params::new().with("role", "dependent"),
605 ));
606 // The blocker's own screen, which is where the user was standing.
607 assert!(page.contains("Upstream"), "{page}");
608 assert!(
609 page.contains("Nothing blocks this task and nothing waits on it."),
610 "{page}"
611 );
612 }
613
614 #[tokio::test]
615 async fn a_completed_task_is_not_offered_a_new_blocker() {
616 let state = state().await;
617 let task = add(&state, "Done with it");
618 let _other = add(&state, "Some other task");
619 state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap();
620
621 let page = html(get(&state, &format!("/tasks/{}", task.id)));
622
623 assert!(page.contains("Dependencies"), "{page}");
624 assert!(!page.contains("Add blocker"), "{page}");
625 }
626
627 #[tokio::test]
628 async fn a_blockers_title_cannot_become_markup() {
629 let state = state().await;
630 let blocker = add(&state, "<script>alert('x')</script>");
631 let blocked = add(&state, "Ordinary");
632 state
633 .tasks
634 .add_dependency(DESKTOP_USER_ID, blocked.id, blocker.id)
635 .unwrap();
636
637 let page = html(get(&state, &format!("/tasks/{}", blocked.id)));
638
639 assert!(!page.contains("<script>"), "{page}");
640 assert!(page.contains("&lt;script&gt;"), "{page}");
641 }
642
643 /// What a response said on the way, which is not part of its content.
644 fn notice(response: &Response) -> String {
645 response
646 .notice
647 .as_ref()
648 .map(|message| message.text.clone())
649 .unwrap_or_default()
650 }
651
652 fn reread(state: &AppState, id: goingson_core::TaskId) -> goingson_core::Task {
653 state
654 .tasks
655 .get_by_id(id, DESKTOP_USER_ID)
656 .unwrap()
657 .expect("the task is there")
658 }
659
660 /// Everything the edit form asks for, filled from a task, as a submission.
661 ///
662 /// Overrides replace rather than append: `Params::get` answers with the first
663 /// value under a name, so a second entry for a field the form already carries
664 /// is a value nothing reads.
665 fn edit_params(task: &goingson_core::Task, overrides: &[(&str, &str)]) -> Params {
666 let title = task.title.clone();
667 let description = task.description.clone();
668 let base: Vec<(&str, &str)> = vec![
669 ("title", &title),
670 ("description", &description),
671 ("project_id", ""),
672 ("status", "Pending"),
673 ("priority", "High"),
674 ("due", ""),
675 ("tags", ""),
676 ("recurrence", "None"),
677 ("estimated_minutes", ""),
678 ("contact_id", ""),
679 ("milestone_id", ""),
680 ];
681 base.into_iter()
682 .fold(Params::new(), |params, (name, value)| {
683 let chosen = overrides
684 .iter()
685 .find(|(over, _)| *over == name)
686 .map_or(value, |(_, over)| *over);
687 params.with(name, chosen)
688 })
689 }
690
691 #[tokio::test]
692 async fn the_overview_offers_edit_as_an_address_rather_than_an_overlay() {
693 let state = state().await;
694 let task = add(&state, "Write the port");
695
696 let page = html(get(&state, &format!("/tasks/{}", task.id)));
697 assert!(page.contains(&format!("/tasks/{}/edit", task.id)), "{page}");
698
699 // And the address answers with a form carrying what the task holds.
700 let form = html(get(&state, &format!("/tasks/{}/edit", task.id)));
701 assert!(form.contains("Write the port"), "{form}");
702 assert!(form.contains("Save task"), "{form}");
703 // Cancel is the overview's own address; the modal it replaces had nothing
704 // to go back to but memory.
705 assert!(form.contains("Cancel"), "{form}");
706 }
707
708 #[tokio::test]
709 async fn saving_writes_every_field_the_form_asked_for() {
710 let state = state().await;
711 let task = add(&state, "Rough title");
712
713 let response = post(
714 &state,
715 &format!("/tasks/{}", task.id),
716 edit_params(
717 &task,
718 &[
719 ("title", "Sharper title"),
720 ("description", "With detail."),
721 ("status", "Started"),
722 ("priority", "Low"),
723 ("tags", " work , , urgent "),
724 ("estimated_minutes", "45"),
725 ("due", "2026-12-25"),
726 ],
727 ),
728 );
729
730 assert_eq!(notice(&response), "Task saved");
731 let saved = reread(&state, task.id);
732 assert_eq!(saved.title, "Sharper title");
733 assert_eq!(saved.description, "With detail.");
734 assert_eq!(saved.status, goingson_core::TaskStatus::Started);
735 assert_eq!(saved.priority, Priority::Low);
736 // The blank entry between the commas is dropped, as `normalizeTags` does.
737 assert_eq!(saved.tags, vec!["work".to_owned(), "urgent".to_owned()]);
738 assert_eq!(saved.estimated_minutes, Some(45));
739 assert!(saved.due.is_some());
740 // Urgency is recalculated rather than carried, which is what makes an edit
741 // that adds a due date move the task up the list.
742 assert!(saved.urgency > 0.0);
743 }
744
745 #[tokio::test]
746 async fn a_refused_edit_hands_back_what_was_typed() {
747 let state = state().await;
748 let task = add(&state, "Fine as it is");
749
750 let long = "x".repeat(81);
751 let page = html(post(
752 &state,
753 &format!("/tasks/{}", task.id),
754 edit_params(
755 &task,
756 &[("title", &long), ("description", "Worth keeping.")],
757 ),
758 ));
759
760 // The complaint, the 81 characters back in the box, and the rest of the
761 // submission still there. A form that reports a length and empties the
762 // field is how a user retypes to shorten.
763 assert!(page.contains("Maximum 80 characters"), "{page}");
764 assert!(page.contains(&long), "{page}");
765 assert!(page.contains("Worth keeping."), "{page}");
766 assert_eq!(reread(&state, task.id).title, "Fine as it is");
767 }
768
769 #[tokio::test]
770 async fn an_unparseable_due_date_is_refused_rather_than_dropped() {
771 let state = state().await;
772 let task = add(&state, "Has a deadline");
773 post(
774 &state,
775 &format!("/tasks/{}", task.id),
776 edit_params(&task, &[("due", "friday 3pm")]),
777 );
778 let due = reread(&state, task.id).due.expect("friday 3pm parsed");
779
780 let page = html(post(
781 &state,
782 &format!("/tasks/{}", task.id),
783 edit_params(&task, &[("due", "someday")]),
784 ));
785
786 assert!(page.contains("Date not recognized"), "{page}");
787 // Still the deadline it had. Dropping it would lose a date on an edit that
788 // was about something else.
789 assert_eq!(reread(&state, task.id).due, Some(due));
790 }
791
792 #[tokio::test]
793 async fn a_blank_due_date_clears_it() {
794 let state = state().await;
795 let task = add(&state, "Not urgent after all");
796 post(
797 &state,
798 &format!("/tasks/{}", task.id),
799 edit_params(&task, &[("due", "tomorrow")]),
800 );
801 assert!(reread(&state, task.id).due.is_some());
802
803 post(
804 &state,
805 &format!("/tasks/{}", task.id),
806 edit_params(&task, &[]),
807 );
808 assert!(reread(&state, task.id).due.is_none());
809 }
810
811 #[tokio::test]
812 async fn the_prefilled_due_date_is_one_the_form_accepts() {
813 let state = state().await;
814 let task = add(&state, "Round trips");
815 post(
816 &state,
817 &format!("/tasks/{}", task.id),
818 edit_params(&task, &[("due", "2026-12-25 3pm")]),
819 );
820 let due = reread(&state, task.id).due.expect("parsed");
821
822 // The value the form offers, submitted back unchanged. A prefill the parser
823 // would reject is a field that cannot be left alone.
824 let prefilled = super::due_value(&reread(&state, task.id));
825 let response = post(
826 &state,
827 &format!("/tasks/{}", task.id),
828 edit_params(&task, &[("due", &prefilled)]),
829 );
830
831 assert_eq!(notice(&response), "Task saved");
832 assert_eq!(reread(&state, task.id).due, Some(due));
833 }
834
835 #[tokio::test]
836 async fn an_edit_leaves_the_recurrence_rule_alone() {
837 let state = state().await;
838 let task = add(&state, "Every other Tuesday");
839 let rule = goingson_core::RecurrenceRule {
840 pattern: goingson_core::Recurrence::Weekly,
841 interval: 2,
842 weekdays: vec![1],
843 monthly_spec: None,
844 until: None,
845 };
846 let mut with_rule = task.clone();
847 with_rule.recurrence = goingson_core::Recurrence::Weekly;
848 with_rule.recurrence_rule = Some(rule.clone());
849 state
850 .tasks
851 .update(
852 task.id,
853 DESKTOP_USER_ID,
854 goingson_core::UpdateTask {
855 project_id: None,
856 milestone_id: None,
857 contact_id: None,
858 title: with_rule.title.clone(),
859 description: with_rule.description.clone(),
860 status: goingson_core::TaskStatus::Pending,
861 priority: Priority::High,
862 due: None,
863 tags: Vec::new(),
864 recurrence: goingson_core::Recurrence::Weekly,
865 recurrence_rule: Some(rule.clone()),
866 urgency: 0.0,
867 scheduled_start: None,
868 scheduled_duration: None,
869 estimated_minutes: None,
870 },
871 )
872 .unwrap();
873
874 // A title edit, through a form that cannot ask about weekdays at all.
875 post(
876 &state,
877 &format!("/tasks/{}", task.id),
878 edit_params(
879 &task,
880 &[
881 ("title", "Every other Tuesday, renamed"),
882 ("recurrence", "Weekly"),
883 ],
884 ),
885 );
886
887 let saved = reread(&state, task.id);
888 assert_eq!(saved.title, "Every other Tuesday, renamed");
889 let kept = saved.recurrence_rule.expect("the rule survived the edit");
890 assert_eq!(kept.interval, 2);
891 assert_eq!(kept.weekdays, vec![1]);
892 }
893
894 #[tokio::test]
895 async fn a_milestone_of_another_project_is_refused() {
896 let state = state().await;
897 let task = add(&state, "Filed somewhere");
898 let project = state
899 .projects
900 .create(
901 DESKTOP_USER_ID,
902 goingson_core::NewProject {
903 name: "Elsewhere".to_owned(),
904 description: String::new(),
905 project_type: goingson_core::ProjectType::Other,
906 status: goingson_core::ProjectStatus::Active,
907 },
908 )
909 .unwrap();
910 let milestone = state
911 .milestones
912 .create(
913 DESKTOP_USER_ID,
914 goingson_core::NewMilestone {
915 project_id: project.id,
916 name: "Phase one".to_owned(),
917 description: String::new(),
918 position: 0,
919 target_date: None,
920 },
921 )
922 .unwrap();
923
924 // Moving the task and filing it under the new project's milestone in one
925 // submission, which is the pairing the form never offered.
926 let page = html(post(
927 &state,
928 &format!("/tasks/{}", task.id),
929 edit_params(
930 &task,
931 &[
932 ("project_id", &project.id.to_string()),
933 ("milestone_id", &milestone.id.to_string()),
934 ],
935 ),
936 ));
937
938 assert!(page.contains("Move the task first"), "{page}");
939 let saved = reread(&state, task.id);
940 assert_eq!(saved.project_id, None);
941 assert_eq!(saved.milestone_id, None);
942 }
943
944 #[tokio::test]
945 async fn a_status_control_cannot_delete_a_task() {
946 let state = state().await;
947 let task = add(&state, "Still here");
948
949 let page = html(post(
950 &state,
951 &format!("/tasks/{}", task.id),
952 edit_params(&task, &[("status", "Deleted")]),
953 ));
954
955 assert!(page.contains("Not a status a control can set"), "{page}");
956 assert_eq!(
957 reread(&state, task.id).status,
958 goingson_core::TaskStatus::Pending
959 );
960 }
961