Skip to main content

max / goingson

13.3 KB · 477 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4 use crate::id_types::{SubtaskId, TaskId};
5 use crate::models::shared::{CssClass, DbValue, Recurrence};
6 use chrono::{Duration, Utc};
7 use std::str::FromStr;
8
9 use super::test_task as task;
10
11 fn subtask(is_completed: bool) -> Subtask {
12 Subtask {
13 id: SubtaskId::new(),
14 task_id: TaskId::new(),
15 text: "sub".to_string(),
16 linked_task_id: None,
17 is_completed,
18 position: 0,
19 }
20 }
21
22 // TaskStatus
23
24 #[test]
25 fn task_status_as_str_and_css_and_db() {
26 assert_eq!(TaskStatus::Started.as_str(), "Started");
27 assert_eq!(TaskStatus::Completed.css_class(), "task-completed");
28 assert_eq!(TaskStatus::Deleted.db_value(), "Deleted");
29 assert_eq!(TaskStatus::default(), TaskStatus::Pending);
30 }
31
32 #[test]
33 fn task_status_from_str() {
34 assert_eq!(
35 TaskStatus::from_str("Completed").unwrap(),
36 TaskStatus::Completed
37 );
38 assert!(TaskStatus::from_str("nonsense").is_err());
39 }
40
41 // Priority
42
43 #[test]
44 fn priority_as_str_is_short_form() {
45 assert_eq!(Priority::High.as_str(), "H");
46 assert_eq!(Priority::Medium.as_str(), "M");
47 assert_eq!(Priority::Low.as_str(), "L");
48 }
49
50 #[test]
51 fn priority_from_str_or_default_accepts_variants() {
52 for s in ["High", "H", "high", "h"] {
53 assert_eq!(Priority::from_str_or_default(s), Priority::High, "{s}");
54 }
55 for s in ["Low", "L", "low", "l"] {
56 assert_eq!(Priority::from_str_or_default(s), Priority::Low, "{s}");
57 }
58 for s in ["Medium", "M", "Med", "med", "m"] {
59 assert_eq!(Priority::from_str_or_default(s), Priority::Medium, "{s}");
60 }
61 }
62
63 #[test]
64 fn priority_from_str_or_default_falls_back_to_medium() {
65 assert_eq!(Priority::from_str_or_default(""), Priority::Medium);
66 assert_eq!(Priority::from_str_or_default("URGENT"), Priority::Medium);
67 assert_eq!(Priority::default(), Priority::Medium);
68 }
69
70 #[test]
71 fn priority_db_value_is_long_form() {
72 assert_eq!(Priority::High.db_value(), "High");
73 assert_eq!(Priority::Low.css_class(), "priority-low");
74 }
75
76 // TaskSortColumn
77
78 #[test]
79 fn sort_column_parses_case_insensitively() {
80 assert_eq!(
81 TaskSortColumn::from_str_or_default("DUE"),
82 TaskSortColumn::Due
83 );
84 assert_eq!(
85 TaskSortColumn::from_str_or_default("Project"),
86 TaskSortColumn::Project
87 );
88 assert_eq!(
89 TaskSortColumn::from_str_or_default("priority"),
90 TaskSortColumn::Priority
91 );
92 // unknown falls back to the default (Urgency)
93 assert_eq!(
94 TaskSortColumn::from_str_or_default("xyz"),
95 TaskSortColumn::Urgency
96 );
97 assert_eq!(TaskSortColumn::default(), TaskSortColumn::Urgency);
98 }
99
100 // due_formatted
101
102 #[test]
103 fn due_formatted_none_is_dash() {
104 assert_eq!(task().due_formatted(), "-");
105 }
106
107 #[test]
108 fn due_formatted_relative_buckets() {
109 let mut t = task();
110
111 t.due = Some(Utc::now());
112 assert_eq!(t.due_formatted(), "today");
113
114 t.due = Some(Utc::now() + Duration::days(1));
115 assert_eq!(t.due_formatted(), "tomorrow");
116
117 t.due = Some(Utc::now() + Duration::days(3));
118 assert_eq!(t.due_formatted(), "+3d");
119
120 t.due = Some(Utc::now() - Duration::days(2));
121 assert_eq!(t.due_formatted(), "2d ago");
122 }
123
124 #[test]
125 fn due_formatted_far_future_is_iso_date() {
126 let mut t = task();
127 let far = Utc::now() + Duration::days(30);
128 t.due = Some(far);
129 assert_eq!(t.due_formatted(), far.format("%Y-%m-%d").to_string());
130 }
131
132 // overdue / urgency_class
133
134 #[test]
135 fn is_overdue_reads_due_vs_now() {
136 let mut t = task();
137 assert!(!t.is_overdue(), "no due date is never overdue");
138 t.due = Some(Utc::now() - Duration::hours(1));
139 assert!(t.is_overdue());
140 t.due = Some(Utc::now() + Duration::hours(1));
141 assert!(!t.is_overdue());
142 }
143
144 #[test]
145 fn urgency_class_thresholds() {
146 let mut t = task();
147 t.urgency = 9.0;
148 assert_eq!(t.urgency_class(), "urgency-high");
149 t.urgency = 5.0;
150 assert_eq!(t.urgency_class(), "urgency-medium");
151 t.urgency = 4.9;
152 assert_eq!(t.urgency_class(), "urgency-low");
153 }
154
155 #[test]
156 fn urgency_class_overdue_wins_over_score() {
157 let mut t = task();
158 t.urgency = 9.9; // would be "high"
159 t.due = Some(Utc::now() - Duration::days(1));
160 assert_eq!(t.urgency_class(), "urgency-overdue");
161 }
162
163 #[test]
164 fn urgency_formatted_one_decimal() {
165 let mut t = task();
166 t.urgency = 8.34;
167 assert_eq!(t.urgency_formatted(), "8.3");
168 t.urgency = 0.0;
169 assert_eq!(t.urgency_formatted(), "0.0");
170 }
171
172 #[test]
173 fn due_timestamp_defaults_to_zero() {
174 let mut t = task();
175 assert_eq!(t.due_timestamp(), 0);
176 let d = Utc::now();
177 t.due = Some(d);
178 assert_eq!(t.due_timestamp(), d.timestamp());
179 }
180
181 // subtasks / annotations
182
183 #[test]
184 fn subtask_counts_and_progress() {
185 let mut t = task();
186 assert!(!t.has_subtasks());
187 assert_eq!(t.subtasks_progress(), "0/0");
188 t.subtasks = vec![subtask(true), subtask(false), subtask(true)];
189 assert!(t.has_subtasks());
190 assert_eq!(t.subtask_count(), 3);
191 assert_eq!(t.subtasks_completed(), 2);
192 assert_eq!(t.subtasks_progress(), "2/3");
193 }
194
195 #[test]
196 fn project_name_fallbacks() {
197 let mut t = task();
198 assert_eq!(t.project_name_or_dash(), "-");
199 assert_eq!(t.project_name_or_empty(), "");
200 t.project_name = Some("Website".to_string());
201 assert_eq!(t.project_name_or_dash(), "Website");
202 assert_eq!(t.project_name_or_empty(), "Website");
203 }
204
205 fn token(t: &Task, reference: &str, state: TokenState, primary: bool, pos: i32) -> StatusToken {
206 StatusToken {
207 id: StatusToken::deterministic_id(t.id, TOKEN_KIND_COMMIT, reference),
208 task_id: t.id,
209 kind: TOKEN_KIND_COMMIT.to_string(),
210 reference: reference.to_string(),
211 state,
212 is_primary: primary,
213 position: pos,
214 }
215 }
216
217 #[test]
218 fn status_token_deterministic_id_is_stable_and_content_derived() {
219 let tid = TaskId::new();
220 let a = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
221 let b = StatusToken::deterministic_id(tid, "commit", "deox@7c236fca8");
222 assert_eq!(a, b, "same task+kind+ref must yield the same id");
223 assert_ne!(
224 a,
225 StatusToken::deterministic_id(tid, "commit", "deox@a19f0011"),
226 "ref differs"
227 );
228 assert_ne!(
229 a,
230 StatusToken::deterministic_id(tid, "attachment", "deox@7c236fca8"),
231 "kind differs"
232 );
233 assert_ne!(
234 a,
235 StatusToken::deterministic_id(TaskId::new(), "commit", "deox@7c236fca8"),
236 "task differs"
237 );
238 assert_eq!(a.as_uuid().get_version_num(), 5);
239 }
240
241 #[test]
242 fn primary_token_finds_the_flagged_one() {
243 let mut t = task();
244 assert!(!t.has_status_tokens());
245 assert!(t.primary_token().is_none());
246 t.status_tokens = vec![
247 token(&t, "deox@aaa", TokenState::Pending, false, 0),
248 token(&t, "deox@bbb", TokenState::Complete, true, 1),
249 ];
250 assert!(t.has_status_tokens());
251 assert_eq!(
252 t.primary_token().map(|c| c.reference.as_str()),
253 Some("deox@bbb")
254 );
255 }
256
257 #[test]
258 fn status_token_summary_rolls_up_states() {
259 let mut t = task();
260 assert_eq!(t.status_token_summary(), "neutral");
261 t.status_tokens = vec![token(&t, "deox@aaa", TokenState::Pending, false, 0)];
262 assert_eq!(t.status_token_summary(), "pending");
263 t.status_tokens = vec![
264 token(&t, "deox@aaa", TokenState::Complete, false, 0),
265 token(&t, "deox@bbb", TokenState::Pending, true, 1),
266 ];
267 assert_eq!(
268 t.status_token_summary(),
269 "pending",
270 "any pending keeps the rollup pending"
271 );
272 t.status_tokens = vec![
273 token(&t, "deox@aaa", TokenState::Complete, false, 0),
274 token(&t, "deox@bbb", TokenState::Complete, true, 1),
275 ];
276 assert_eq!(
277 t.status_token_summary(),
278 "complete",
279 "all complete rolls up complete"
280 );
281 }
282
283 #[test]
284 fn recurrence_and_source_flags() {
285 let mut t = task();
286 assert!(!t.has_recurrence());
287 assert!(t.effective_recurrence_rule().is_none());
288 t.recurrence = Recurrence::Weekly;
289 assert!(t.has_recurrence());
290 assert!(!t.has_source_email());
291 }
292
293 // snooze / waiting / focus
294
295 #[test]
296 fn is_snoozed_only_when_future() {
297 let mut t = task();
298 assert!(!t.is_snoozed());
299 t.snoozed_until = Some(Utc::now() + Duration::hours(1));
300 assert!(t.is_snoozed());
301 t.snoozed_until = Some(Utc::now() - Duration::hours(1));
302 assert!(!t.is_snoozed());
303 }
304
305 #[test]
306 fn response_overdue_requires_waiting_and_past_date() {
307 let mut t = task();
308 assert!(!t.is_response_overdue());
309 // past expected date but not waiting -> false
310 t.expected_response_date = Some(Utc::now() - Duration::days(1));
311 assert!(!t.is_response_overdue());
312 // waiting + past -> true
313 t.waiting_for_response = true;
314 assert!(t.is_waiting());
315 assert!(t.is_response_overdue());
316 // waiting but future -> false
317 t.expected_response_date = Some(Utc::now() + Duration::days(1));
318 assert!(!t.is_response_overdue());
319 }
320
321 #[test]
322 fn is_focused_reads_flag() {
323 let mut t = task();
324 assert!(!t.is_focused());
325 t.is_focus = true;
326 assert!(t.is_focused());
327 }
328
329 // time progress
330
331 #[test]
332 fn time_progress_none_without_estimate() {
333 assert_eq!(task().time_progress(), None);
334 }
335
336 #[test]
337 fn time_progress_percentage_and_clamp() {
338 let mut t = task();
339 t.estimated_minutes = Some(100);
340 t.actual_minutes = 50;
341 assert_eq!(t.time_progress(), Some(50));
342 // clamps at 100 even when over
343 t.actual_minutes = 250;
344 assert_eq!(t.time_progress(), Some(100));
345 // zero estimate is treated as 0%, never divides by zero
346 t.estimated_minutes = Some(0);
347 assert_eq!(t.time_progress(), Some(0));
348 }
349
350 #[test]
351 fn is_over_estimate_rules() {
352 let mut t = task();
353 assert!(!t.is_over_estimate(), "no estimate -> not over");
354 t.estimated_minutes = Some(60);
355 t.actual_minutes = 61;
356 assert!(t.is_over_estimate());
357 t.actual_minutes = 60;
358 assert!(!t.is_over_estimate(), "equal is not over");
359 t.estimated_minutes = Some(0);
360 t.actual_minutes = 5;
361 assert!(!t.is_over_estimate(), "zero estimate is never over");
362 }
363
364 #[test]
365 fn has_active_timer_reads_session() {
366 assert!(!task().has_active_timer());
367 }
368
369 // NewTaskBuilder
370
371 #[test]
372 #[allow(
373 clippy::float_cmp,
374 reason = "asserting the builder stored the exact, directly-set f64 default"
375 )]
376 fn builder_defaults() {
377 let nt = NewTask::builder("Write tests").build();
378 assert_eq!(nt.title, "Write tests");
379 assert_eq!(nt.description, "");
380 assert_eq!(nt.priority, Priority::Medium);
381 assert_eq!(nt.urgency, 0.0);
382 assert_eq!(nt.recurrence, Recurrence::None);
383 assert!(nt.tags.is_empty());
384 assert!(nt.due.is_none());
385 assert!(nt.estimated_minutes.is_none());
386 }
387
388 #[test]
389 #[allow(
390 clippy::float_cmp,
391 reason = "asserting the builder stored the exact, directly-set f64 value"
392 )]
393 fn builder_sets_fields() {
394 let due = Utc::now();
395 let nt = NewTask::builder("Fix bug")
396 .priority(Priority::High)
397 .due(due)
398 .tag("urgent")
399 .tag("backend")
400 .urgency(8.0)
401 .estimated_minutes(45)
402 .recurrence(Recurrence::Daily)
403 .build();
404 assert_eq!(nt.priority, Priority::High);
405 assert_eq!(nt.due, Some(due));
406 assert_eq!(nt.tags, vec!["urgent".to_string(), "backend".to_string()]);
407 assert_eq!(nt.urgency, 8.0);
408 assert_eq!(nt.estimated_minutes, Some(45));
409 assert_eq!(nt.recurrence, Recurrence::Daily);
410 }
411
412 #[test]
413 fn builder_tags_replaces_accumulated() {
414 let nt = NewTask::builder("t")
415 .tag("a")
416 .tags(vec!["x".to_string(), "y".to_string()])
417 .build();
418 assert_eq!(nt.tags, vec!["x".to_string(), "y".to_string()]);
419 }
420
421 // ── split_description ──
422
423 #[test]
424 fn a_short_single_line_is_all_title() {
425 let (title, body) = split_description("Fix the bug");
426 assert_eq!(title, "Fix the bug");
427 assert_eq!(body, "", "a one-line task has no detail to show");
428 }
429
430 #[test]
431 fn the_first_line_becomes_the_title_and_the_rest_the_body() {
432 let (title, body) = split_description("Ship the release\n\nTag it, then push tags.");
433 assert_eq!(title, "Ship the release");
434 assert_eq!(body, "Tag it, then push tags.");
435 }
436
437 #[test]
438 fn a_long_first_line_truncates_on_a_word_boundary() {
439 let first = "Walk the human_todo sign-off table on the live server and record every row that still fails";
440 let (title, body) = split_description(first);
441 assert!(title.chars().count() <= MAX_TITLE_LEN);
442 assert!(title.ends_with("..."));
443 assert!(
444 !title.trim_end_matches("...").ends_with(char::is_whitespace),
445 "no dangling space before the ellipsis: {title:?}"
446 );
447 assert!(
448 first.starts_with(title.trim_end_matches("...")),
449 "the title is a prefix of the text it summarizes"
450 );
451 assert_eq!(body, first, "truncating must not lose the tail");
452 }
453
454 #[test]
455 fn a_single_unbreakable_word_still_yields_a_title() {
456 let word = "x".repeat(MAX_TITLE_LEN * 2);
457 let (title, body) = split_description(&word);
458 assert_eq!(title.chars().count(), MAX_TITLE_LEN);
459 assert!(title.ends_with("..."));
460 assert_eq!(body, word);
461 }
462
463 #[test]
464 fn a_title_of_exactly_the_limit_is_not_truncated() {
465 let exact = "y".repeat(MAX_TITLE_LEN);
466 let (title, body) = split_description(&exact);
467 assert_eq!(title, exact);
468 assert_eq!(body, "");
469 }
470
471 #[test]
472 fn surrounding_whitespace_is_not_carried_into_either_field() {
473 let (title, body) = split_description("\n Trim me \n\n body text\n\n");
474 assert_eq!(title, "Trim me");
475 assert_eq!(body, "body text");
476 }
477