Skip to main content

max / goingson

10.0 KB · 285 lines History Blame Raw
1 //! The board, driven through the router against a real database.
2 //!
3 //! The assertions worth reading are the ones about *peers*: that the three
4 //! columns are one region of equals rather than a list and a detail, which is
5 //! the single member `makeover-layout` 0.25.0 added for this screen.
6
7 use std::sync::Arc;
8
9 use goingson_core::{NewTask, Priority, TaskStatus};
10 use quasi_http::Serves as _;
11 use quasi_router::{Outcome, Params, Request, Response};
12
13 use super::super::router;
14 use crate::state::{AppState, DESKTOP_USER_ID};
15
16 async fn state() -> Arc<AppState> {
17 let (state, _) = crate::test_utils::setup_test_state().await;
18 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
19 state
20 .db
21 .conn()
22 .unwrap()
23 .execute(
24 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
25 VALUES (?, ?, ?, ?, ?)",
26 rusqlite::params![
27 DESKTOP_USER_ID.to_string(),
28 "desktop@localhost",
29 "x",
30 "Desktop User",
31 &now,
32 ],
33 )
34 .unwrap();
35 state
36 }
37
38 /// A pending task, which is where every card starts.
39 fn task(state: &AppState, title: &str) -> goingson_core::TaskId {
40 state
41 .tasks
42 .create(
43 DESKTOP_USER_ID,
44 NewTask::builder(title).priority(Priority::Medium).build(),
45 )
46 .unwrap()
47 .id
48 }
49
50 fn get(state: &AppState, path: &str) -> Response {
51 router()
52 .handle(state, Request::get(path).carrying(Params::new()))
53 .expect("the route answers")
54 }
55
56 fn html(response: Response) -> String {
57 match response.outcome {
58 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
59 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
60 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
61 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
62 Outcome::Anchored { .. } => {
63 panic!("expected content, got a screen drawn at a point on it")
64 }
65 Outcome::Suggestions { field, .. } => {
66 panic!("expected content, got a suggestion list for `{field}`")
67 }
68 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
69 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
70 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
71 // not content, and not a place either.
72 Outcome::Started { region, .. } => {
73 panic!("expected content, got work started in `{region}`")
74 }
75 }
76 }
77
78 fn board(state: &AppState) -> String {
79 html(get(state, "/board"))
80 }
81
82 fn move_to(state: &AppState, id: goingson_core::TaskId, to: &str) -> Response {
83 router()
84 .handle(
85 state,
86 Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", to)),
87 )
88 .expect("the route answers")
89 }
90
91 #[tokio::test]
92 async fn the_three_columns_are_peers_and_not_a_list_and_a_detail() {
93 let state = state().await;
94 let markup = board(&state);
95
96 // The whole point of the member. Before 0.25.0 this screen could only have
97 // been described as list-detail, which says the left column chooses what
98 // the right shows -- a lie about a board.
99 //
100 // `RegionKind::Columns` said it until that variant was retired
101 // (quasicoherent `cf981aaa`). Three peers is now three members of one row
102 // that each ask to fill, which divide the room equally by `Width::Fill`'s
103 // own rule and choose nothing about each other. Counted rather than
104 // matched, because the count is the "peers" half: two fills and a content
105 // member would be a master-detail wearing a board's markup.
106 assert_eq!(markup.matches("data-width=\"fill\"").count(), 3, "{markup}");
107 for label in ["Pending", "Started", "Completed"] {
108 assert!(markup.contains(label), "{markup}");
109 }
110 }
111
112 #[tokio::test]
113 async fn a_card_carries_the_facts_the_js_card_carried() {
114 let state = state().await;
115 task(&state, "Write the thing");
116
117 let markup = board(&state);
118
119 assert!(markup.contains("Write the thing"), "{markup}");
120 assert!(markup.contains("Medium"), "{markup}");
121 }
122
123 #[tokio::test]
124 async fn a_card_offers_the_moves_it_is_not_already_in() {
125 let state = state().await;
126 let id = task(&state, "Movable");
127
128 let markup = board(&state);
129
130 // Two moves, not three. Dropping a card where it already is is the case
131 // `onDrop` bails on, so offering it would be an act that does nothing.
132 assert!(markup.contains("Move to Started"), "{markup}");
133 assert!(markup.contains("Move to Completed"), "{markup}");
134 assert!(!markup.contains("Move to Pending"), "{markup}");
135
136 // And the move is an ordinary posted action, which is why the drag never
137 // needed describing.
138 assert!(markup.contains(&format!("/board/{id}/status")), "{markup}");
139 }
140
141 #[tokio::test]
142 async fn moving_a_card_moves_it_and_answers_the_board() {
143 let state = state().await;
144 let id = task(&state, "Movable");
145
146 let response = move_to(&state, id, "Started");
147 let Outcome::Fragment { region, .. } = &response.outcome else {
148 panic!("a move replaces the board, not the screen");
149 };
150 assert_eq!(region, "board");
151
152 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
153 assert_eq!(after.status, TaskStatus::Started);
154
155 // Now it offers the way back and no longer offers the way it came.
156 let markup = html(response);
157 assert!(markup.contains("Move to Pending"), "{markup}");
158 assert!(!markup.contains("Move to Started"), "{markup}");
159 }
160
161 #[tokio::test]
162 async fn moving_a_card_to_the_column_it_is_in_writes_nothing() {
163 let state = state().await;
164 let id = task(&state, "Stationary");
165
166 // Matters more through a route than through a drop. A repeated POST must
167 // not complete a task twice and mint a second recurrence, and the JS's
168 // `task.status === newStatus` bail is not reachable from a URL.
169 let response = move_to(&state, id, "Pending");
170 assert!(matches!(response.outcome, Outcome::Fragment { .. }));
171
172 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
173 assert_eq!(after.status, TaskStatus::Pending);
174 // No toast, because nothing happened.
175 assert!(response.notice.is_none(), "{:?}", response.notice);
176 }
177
178 #[tokio::test]
179 async fn moving_back_to_pending_keeps_everything_else_about_the_task() {
180 let state = state().await;
181 let id = state
182 .tasks
183 .create(
184 DESKTOP_USER_ID,
185 NewTask::builder("Tagged")
186 .priority(Priority::High)
187 .tags(vec!["alpha".into(), "beta".into()])
188 .build(),
189 )
190 .unwrap()
191 .id;
192 move_to(&state, id, "Started");
193
194 move_to(&state, id, "Pending");
195
196 // `UpdateTask` replaces rather than patches, so the Pending path resends
197 // every field. Anything left out of that struct is silently cleared, and
198 // moving a card left is not a reason to lose its tags.
199 let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
200 assert_eq!(after.status, TaskStatus::Pending);
201 assert_eq!(after.priority, Priority::High);
202 assert_eq!(after.tags, vec!["alpha".to_string(), "beta".to_string()]);
203 assert_eq!(after.title, "Tagged");
204 }
205
206 #[tokio::test]
207 async fn a_column_that_does_not_exist_is_a_404() {
208 let state = state().await;
209 let id = task(&state, "Movable");
210
211 // A control naming a column that is not there is a wiring mistake, and
212 // answering it with an unchanged board hides it.
213 let answered = router().handle(
214 &state,
215 Request::post(format!("/board/{id}/status")).sending(Params::new().with("to", "Archived")),
216 );
217 assert!(answered.is_err(), "an unknown column should not resolve");
218 }
219
220 #[tokio::test]
221 async fn a_cards_title_cannot_become_markup() {
222 let state = state().await;
223 task(&state, "<script>alert('x')</script>");
224
225 let markup = board(&state);
226
227 assert!(!markup.contains("<script>"), "{markup}");
228 assert!(markup.contains("&lt;script&gt;"), "{markup}");
229 }
230
231 #[tokio::test]
232 async fn an_empty_column_says_so_rather_than_drawing_nothing() {
233 let state = state().await;
234 let markup = board(&state);
235
236 // Three empty columns on a fresh board. An empty column that draws nothing
237 // reads as a broken board rather than an empty one.
238 assert_eq!(markup.matches("No tasks").count(), 3, "{markup}");
239 }
240
241 #[tokio::test]
242 async fn a_card_says_whether_the_task_is_available() {
243 let state = state().await;
244 let blocker = task(&state, "Do this first");
245 let blocked = task(&state, "Then this");
246 state
247 .tasks
248 .add_dependency(DESKTOP_USER_ID, blocked, blocker)
249 .unwrap();
250
251 let markup = board(&state);
252
253 // The blocked card says so, and the one that frees it says what finishing
254 // it buys. `tasks-kanban.js` has drawn both since `0df3488`; this port was
255 // written after that and said neither.
256 assert!(markup.contains("Blocked"), "{markup}");
257 assert!(markup.contains("Unblocks 1"), "{markup}");
258
259 // And the detail behind each label, which `tasks-kanban.js` carried in a
260 // `title` and the port dropped until quasicoherent `436bc223` gave `Tag` a
261 // hint. Both are longer forms of a label already on the card, so a renderer
262 // that drops them loses nothing but the length.
263 assert!(
264 markup.contains("title=\"1 step away: one task has to finish first.\""),
265 "{markup}"
266 );
267 assert!(
268 markup.contains("title=\"Finishing this frees 1 other task.\""),
269 "{markup}"
270 );
271 }
272
273 #[tokio::test]
274 async fn an_ordinary_card_carries_no_dependency_marker() {
275 let state = state().await;
276 task(&state, "Nothing in its way");
277
278 let markup = board(&state);
279
280 // A ready task with nothing downstream is the ordinary case. Marking it
281 // would put a badge on nearly every card.
282 assert!(!markup.contains("Blocked"), "{markup}");
283 assert!(!markup.contains("Unblocks"), "{markup}");
284 }
285