1use super::{LabelMatch, apply_effective_points, open_board};
4use crate::backlog::{BacklogItem, Status, Workflow};
5use crate::error::{Error, Result};
6use crate::storage::BacklogItemRepository;
7use std::path::Path;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SortKey {
12 Rank,
14 Done,
16 Created,
18}
19
20#[derive(Debug, Default, Clone)]
22pub struct BoardQuery {
23 pub roots_only: bool,
25 pub sprint: Option<String>,
27 pub assignee: Option<String>,
29 pub labels: Vec<String>,
31 pub label_match: LabelMatch,
33 pub statuses: Vec<String>,
36 pub sort: Option<SortKey>,
39 pub reverse: bool,
41 pub search: Option<super::SearchFilter>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct BoardColumn {
48 pub status: Status,
50 pub items: Vec<BacklogItem>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Board {
57 pub columns: Vec<BoardColumn>,
59 pub orphaned: Vec<BacklogItem>,
64}
65
66pub async fn board(project_dir: &Path, query: &BoardQuery) -> Result<Board> {
85 let (_board_dir, repo, config) = open_board(project_dir).await?;
86 let mut items = repo.list().await?; apply_effective_points(
88 &mut items,
89 config.points.aggregate_children,
90 &Status::new(&config.done_column),
91 );
92 if query.roots_only {
93 items.retain(|item| item.parent.is_none());
94 }
95 let sprints = if query.search.is_some() {
96 crate::storage::SprintRepository::list(&repo).await?
97 } else {
98 Vec::new()
99 };
100
101 for name in &query.statuses {
103 if !config.columns.iter().any(|c| c == name) {
104 return Err(Error::UnknownStatus(name.clone()));
105 }
106 }
107 let shown: Vec<&String> = if query.statuses.is_empty() {
109 config.columns.iter().collect()
110 } else {
111 config
112 .columns
113 .iter()
114 .filter(|c| query.statuses.iter().any(|s| &s == c))
115 .collect()
116 };
117
118 if let Some(sprint) = &query.sprint {
120 items.retain(|it| it.sprint.as_deref() == Some(sprint.as_str()));
121 }
122 if let Some(assignee) = &query.assignee {
123 items.retain(|it| it.assignee.as_deref() == Some(assignee.as_str()));
124 }
125 if !query.labels.is_empty() {
126 items.retain(|it| query.label_match.matches(&it.labels, &query.labels));
127 }
128 if let Some(search) = &query.search {
129 items.retain(|item| {
130 let sprint = item
131 .sprint
132 .as_deref()
133 .and_then(|id| sprints.iter().find(|sprint| sprint.id.as_str() == id));
134 search.matches(item, sprint)
135 });
136 }
137
138 let workflow = Workflow::new(config.columns.iter().map(Status::new));
139 let terminal = Status::new(&config.done_column);
142
143 let columns = shown
144 .iter()
145 .map(|name| {
146 let status = Status::new(*name);
147 let mut col_items: Vec<BacklogItem> = items
148 .iter()
149 .filter(|it| it.status == status)
150 .cloned()
151 .collect();
152 match query.sort {
153 Some(key) => sort_items(&mut col_items, key, query.reverse),
155 None if terminal == status => {
157 sort_items(&mut col_items, SortKey::Done, true);
158 }
159 None => {}
160 }
161 BoardColumn {
164 status,
165 items: crate::service::hierarchical(col_items),
166 }
167 })
168 .collect();
169
170 let orphaned = if query.statuses.is_empty() {
172 let orphans: Vec<BacklogItem> = items
173 .into_iter()
174 .filter(|it| !workflow.contains(&it.status))
175 .collect();
176 crate::service::hierarchical(orphans)
177 } else {
178 Vec::new()
179 };
180
181 Ok(Board { columns, orphaned })
182}
183
184fn sort_items(items: &mut [BacklogItem], key: SortKey, reverse: bool) {
189 use std::cmp::Ordering;
190 let flip = |o: Ordering| if reverse { o.reverse() } else { o };
191 match key {
192 SortKey::Rank => items.sort_by(|a, b| flip(a.backlog_cmp(b))),
194 SortKey::Created => items.sort_by(|a, b| flip(a.created.cmp(&b.created))),
195 SortKey::Done => items.sort_by(|a, b| match (a.done_at, b.done_at) {
196 (Some(x), Some(y)) => flip(x.cmp(&y)), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater,
199 (None, None) => Ordering::Equal, }),
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::error::Error;
208 use crate::service::test_support::{init_temp, set_columns};
209 use crate::service::{ItemEdit, NewItem, add_item, edit_item, move_item};
210 use tempfile::TempDir;
211
212 #[tokio::test]
213 async fn board_groups_items_by_column_in_config_order() {
214 let dir = init_temp().await;
215 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
216 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
217 move_item(dir.path(), &b.id, "in-progress").await.unwrap();
219
220 let board = board(dir.path(), &BoardQuery::default())
221 .await
222 .expect("board succeeds");
223
224 let names: Vec<_> = board
226 .columns
227 .iter()
228 .map(|c| c.status.as_str().to_string())
229 .collect();
230 assert_eq!(names, ["todo", "in-progress", "review", "done"]);
231
232 assert_eq!(
234 board.columns[0]
235 .items
236 .iter()
237 .map(|it| &it.id)
238 .collect::<Vec<_>>(),
239 [&a.id]
240 );
241 assert_eq!(
242 board.columns[1]
243 .items
244 .iter()
245 .map(|it| &it.id)
246 .collect::<Vec<_>>(),
247 [&b.id]
248 );
249 assert!(board.columns[2].items.is_empty());
250 assert!(board.columns[3].items.is_empty());
251 }
252
253 #[tokio::test]
254 async fn board_items_within_column_are_rank_ordered() {
255 let dir = init_temp().await;
256 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
257 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
258 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
259
260 let board = board(dir.path(), &BoardQuery::default()).await.unwrap();
261 let ids: Vec<_> = board.columns[0]
262 .items
263 .iter()
264 .map(|it| it.id.clone())
265 .collect();
266 assert_eq!(ids, [a.id, b.id, c.id]);
267 }
268
269 #[tokio::test]
270 async fn board_on_uninitialized_dir_prompts_init() {
271 let dir = TempDir::new().expect("temp dir");
272 let err = board(dir.path(), &BoardQuery::default()).await.unwrap_err();
273 assert!(
274 matches!(err, Error::NotInitialized { .. }),
275 "expected NotInitialized, got {err:?}"
276 );
277 }
278
279 #[tokio::test]
280 async fn board_reflects_custom_column_order_and_additions() {
281 let dir = init_temp().await;
282 set_columns(dir.path(), &["backlog", "doing", "done", "blocked"]).await;
284 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
285 move_item(dir.path(), &a.id, "blocked").await.unwrap();
287
288 let board = board(dir.path(), &BoardQuery::default())
289 .await
290 .expect("board succeeds");
291
292 let names: Vec<_> = board
293 .columns
294 .iter()
295 .map(|c| c.status.as_str().to_string())
296 .collect();
297 assert_eq!(names, ["backlog", "doing", "done", "blocked"]);
298 assert_eq!(board.columns[0].items, []);
300 assert_eq!(
301 board.columns[3]
302 .items
303 .iter()
304 .map(|it| &it.id)
305 .collect::<Vec<_>>(),
306 [&a.id]
307 );
308 assert!(board.orphaned.is_empty(), "no undefined-column items");
309 }
310
311 #[tokio::test]
312 async fn move_to_newly_added_column_succeeds() {
313 let dir = init_temp().await;
314 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
315 set_columns(dir.path(), &["todo", "in-progress", "review", "done", "qa"]).await;
316
317 let moved = move_item(dir.path(), &a.id, "qa")
318 .await
319 .expect("move to added column succeeds");
320 assert_eq!(moved.status, Status::new("qa"));
321 }
322
323 #[tokio::test]
324 async fn board_collects_items_in_undefined_columns_as_orphaned() {
325 let dir = init_temp().await;
326 let stay = add_item(dir.path(), "Stay", NewItem::default())
327 .await
328 .unwrap();
329 let orphan = add_item(dir.path(), "Orphan", NewItem::default())
330 .await
331 .unwrap();
332 move_item(dir.path(), &orphan.id, "review").await.unwrap();
333
334 set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
336
337 let board = board(dir.path(), &BoardQuery::default())
338 .await
339 .expect("board succeeds");
340
341 let in_columns: Vec<_> = board
343 .columns
344 .iter()
345 .flat_map(|c| &c.items)
346 .map(|it| it.id.clone())
347 .collect();
348 assert_eq!(in_columns, [stay.id]);
349
350 assert_eq!(
352 board.orphaned.iter().map(|it| &it.id).collect::<Vec<_>>(),
353 [&orphan.id]
354 );
355 assert_eq!(board.orphaned[0].status, Status::new("review"));
356 }
357
358 #[tokio::test]
359 async fn board_scoped_to_sprint_shows_only_assigned_items() {
360 use crate::service::{assign_sprint, create_sprint};
361 use crate::sprint::SprintId;
362
363 let dir = init_temp().await;
364 let sprint = SprintId::new("S-1").unwrap();
365 create_sprint(dir.path(), &sprint, "Sprint 1", None, None)
366 .await
367 .unwrap();
368 let in_sprint = add_item(dir.path(), "In sprint", NewItem::default())
369 .await
370 .unwrap();
371 let _out = add_item(dir.path(), "Not in sprint", NewItem::default())
372 .await
373 .unwrap();
374 assign_sprint(dir.path(), &sprint, &in_sprint.id)
375 .await
376 .unwrap();
377
378 let board = board(
379 dir.path(),
380 &BoardQuery {
381 sprint: Some("S-1".to_string()),
382 ..Default::default()
383 },
384 )
385 .await
386 .expect("scoped board succeeds");
387
388 let ids: Vec<_> = board
390 .columns
391 .iter()
392 .flat_map(|c| &c.items)
393 .map(|it| it.id.clone())
394 .collect();
395 assert_eq!(ids, [in_sprint.id]);
396 }
397
398 #[tokio::test]
399 async fn board_filters_by_label() {
400 let dir = init_temp().await;
401 let backend = NewItem {
402 labels: vec!["backend".to_string()],
403 ..Default::default()
404 };
405 let matching = add_item(dir.path(), "Backend item", backend).await.unwrap();
406 let frontend = NewItem {
407 labels: vec!["frontend".to_string()],
408 ..Default::default()
409 };
410 add_item(dir.path(), "Frontend item", frontend)
411 .await
412 .unwrap();
413
414 let board = board(
415 dir.path(),
416 &BoardQuery {
417 labels: vec!["backend".to_string()],
418 ..Default::default()
419 },
420 )
421 .await
422 .expect("label-scoped board succeeds");
423
424 let ids: Vec<_> = board
425 .columns
426 .iter()
427 .flat_map(|column| &column.items)
428 .map(|item| item.id.clone())
429 .collect();
430 assert_eq!(ids, [matching.id]);
431 }
432
433 #[tokio::test]
434 async fn board_filters_by_assignee_across_workflow_columns() {
435 let dir = init_temp().await;
436 let alice_todo = add_item(dir.path(), "Alice todo", NewItem::default())
437 .await
438 .unwrap();
439 let bob_todo = add_item(dir.path(), "Bob todo", NewItem::default())
440 .await
441 .unwrap();
442 let alice_progress = add_item(dir.path(), "Alice progress", NewItem::default())
443 .await
444 .unwrap();
445 let bob_done = add_item(dir.path(), "Bob done", NewItem::default())
446 .await
447 .unwrap();
448
449 for (item, assignee) in [
450 (&alice_todo, "alice"),
451 (&bob_todo, "bob"),
452 (&alice_progress, "alice"),
453 (&bob_done, "bob"),
454 ] {
455 edit_item(
456 dir.path(),
457 &item.id,
458 ItemEdit {
459 assignee: Some(assignee.to_string()),
460 ..Default::default()
461 },
462 )
463 .await
464 .unwrap();
465 }
466 move_item(dir.path(), &alice_progress.id, "in-progress")
467 .await
468 .unwrap();
469 move_item(dir.path(), &bob_done.id, "done").await.unwrap();
470
471 let board = board(
472 dir.path(),
473 &BoardQuery {
474 assignee: Some("alice".to_string()),
475 ..Default::default()
476 },
477 )
478 .await
479 .expect("assignee-scoped board succeeds");
480
481 assert_eq!(
482 board.columns[0]
483 .items
484 .iter()
485 .map(|item| item.id.clone())
486 .collect::<Vec<_>>(),
487 [alice_todo.id]
488 );
489 assert_eq!(
490 board.columns[1]
491 .items
492 .iter()
493 .map(|item| item.id.clone())
494 .collect::<Vec<_>>(),
495 [alice_progress.id]
496 );
497 assert_eq!(board.columns[0].items[0].assignee.as_deref(), Some("alice"));
498 assert_eq!(board.columns[1].items[0].assignee.as_deref(), Some("alice"));
499 assert!(board.columns[2].items.is_empty());
500 assert!(board.columns[3].items.is_empty());
501 assert!(board.orphaned.is_empty());
502 }
503
504 #[tokio::test]
505 async fn board_filters_by_multiple_labels_with_any_or_all_matching() {
506 let dir = init_temp().await;
507 let backend = add_item(
508 dir.path(),
509 "Backend item",
510 NewItem {
511 labels: vec!["backend".to_string()],
512 ..Default::default()
513 },
514 )
515 .await
516 .unwrap();
517 let frontend = add_item(
518 dir.path(),
519 "Frontend item",
520 NewItem {
521 labels: vec!["frontend".to_string()],
522 ..Default::default()
523 },
524 )
525 .await
526 .unwrap();
527 let both = add_item(
528 dir.path(),
529 "Both labels",
530 NewItem {
531 labels: vec!["backend".to_string(), "frontend".to_string()],
532 ..Default::default()
533 },
534 )
535 .await
536 .unwrap();
537 let labels = vec!["backend".to_string(), "frontend".to_string()];
538
539 let any = board(
540 dir.path(),
541 &BoardQuery {
542 labels: labels.clone(),
543 ..Default::default()
544 },
545 )
546 .await
547 .unwrap();
548 let any_ids: Vec<_> = any
549 .columns
550 .iter()
551 .flat_map(|column| &column.items)
552 .map(|item| item.id.clone())
553 .collect();
554 assert_eq!(
555 any_ids,
556 [backend.id.clone(), frontend.id.clone(), both.id.clone()]
557 );
558
559 let all = board(
560 dir.path(),
561 &BoardQuery {
562 labels,
563 label_match: LabelMatch::All,
564 ..Default::default()
565 },
566 )
567 .await
568 .unwrap();
569 let all_ids: Vec<_> = all
570 .columns
571 .iter()
572 .flat_map(|column| &column.items)
573 .map(|item| item.id.clone())
574 .collect();
575 assert_eq!(all_ids, [both.id]);
576 }
577
578 #[tokio::test]
581 async fn board_status_filter_shows_only_requested_columns_in_config_order() {
582 let dir = init_temp().await;
583 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
584 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
585 move_item(dir.path(), &b.id, "done").await.unwrap();
586
587 let query = BoardQuery {
589 statuses: vec!["done".to_string(), "todo".to_string()],
590 ..Default::default()
591 };
592 let board = board(dir.path(), &query).await.expect("board succeeds");
593
594 let names: Vec<_> = board
595 .columns
596 .iter()
597 .map(|c| c.status.as_str().to_string())
598 .collect();
599 assert_eq!(
600 names,
601 ["todo", "done"],
602 "only requested columns, config order"
603 );
604 assert_eq!(
605 board.columns[0]
606 .items
607 .iter()
608 .map(|i| &i.id)
609 .collect::<Vec<_>>(),
610 [&a.id]
611 );
612 assert_eq!(
613 board.columns[1]
614 .items
615 .iter()
616 .map(|i| &i.id)
617 .collect::<Vec<_>>(),
618 [&b.id]
619 );
620 }
621
622 #[tokio::test]
623 async fn board_status_filter_rejects_unknown_column() {
624 let dir = init_temp().await;
625 let query = BoardQuery {
626 statuses: vec!["nonexistent".to_string()],
627 ..Default::default()
628 };
629 let err = board(dir.path(), &query).await.unwrap_err();
630 assert!(
631 matches!(err, Error::UnknownStatus(ref s) if s == "nonexistent"),
632 "got {err:?}"
633 );
634 }
635
636 #[tokio::test]
637 async fn board_status_filter_combines_with_sprint_scope() {
638 use crate::service::{assign_sprint, create_sprint};
639 use crate::sprint::SprintId;
640
641 let dir = init_temp().await;
642 let sprint = SprintId::new("S-1").unwrap();
643 create_sprint(dir.path(), &sprint, "Sprint", None, None)
644 .await
645 .unwrap();
646 let a = add_item(dir.path(), "In sprint todo", NewItem::default())
647 .await
648 .unwrap();
649 let b = add_item(dir.path(), "In sprint done", NewItem::default())
650 .await
651 .unwrap();
652 let c = add_item(dir.path(), "Off sprint todo", NewItem::default())
653 .await
654 .unwrap();
655 assign_sprint(dir.path(), &sprint, &a.id).await.unwrap();
656 assign_sprint(dir.path(), &sprint, &b.id).await.unwrap();
657 move_item(dir.path(), &b.id, "done").await.unwrap();
658
659 let query = BoardQuery {
661 sprint: Some("S-1".to_string()),
662 statuses: vec!["todo".to_string()],
663 ..Default::default()
664 };
665 let board = board(dir.path(), &query).await.expect("board succeeds");
666
667 assert_eq!(board.columns.len(), 1);
668 assert_eq!(board.columns[0].status.as_str(), "todo");
669 let ids: Vec<_> = board.columns[0]
670 .items
671 .iter()
672 .map(|i| i.id.clone())
673 .collect();
674 assert_eq!(ids, [a.id]);
675 let _ = c;
676 }
677
678 #[tokio::test]
679 async fn board_status_filter_suppresses_orphaned() {
680 let dir = init_temp().await;
681 let orphan = add_item(dir.path(), "Orphan", NewItem::default())
682 .await
683 .unwrap();
684 move_item(dir.path(), &orphan.id, "review").await.unwrap();
685 set_columns(dir.path(), &["todo", "in-progress", "done"]).await;
687
688 let query = BoardQuery {
689 statuses: vec!["todo".to_string()],
690 ..Default::default()
691 };
692 let board = board(dir.path(), &query).await.expect("board succeeds");
693
694 assert!(
695 board.orphaned.is_empty(),
696 "orphaned suppressed when filtering columns"
697 );
698 }
699
700 async fn set_done_at(dir: &Path, id: &crate::backlog::ItemId, done_at: Option<i64>) {
704 use crate::storage::{BacklogItemRepository, FileRepository};
705 let repo = FileRepository::new(dir.join(".pinto"));
706 let mut item = repo.load(id).await.expect("load");
707 item.done_at = done_at.map(|s| chrono::DateTime::from_timestamp(s, 0).expect("ts"));
708 repo.save(&item).await.expect("save");
709 }
710
711 #[tokio::test]
712 async fn board_done_column_orders_by_completion_desc_and_undated_last() {
713 let dir = init_temp().await;
714 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
715 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
716 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
717 let d = add_item(dir.path(), "D", NewItem::default()).await.unwrap();
718 let e = add_item(dir.path(), "E", NewItem::default()).await.unwrap();
719 for it in [&a, &b, &c, &d, &e] {
720 move_item(dir.path(), &it.id, "done").await.unwrap();
721 }
722 set_done_at(dir.path(), &a.id, Some(100)).await;
724 set_done_at(dir.path(), &b.id, Some(300)).await;
725 set_done_at(dir.path(), &c.id, Some(200)).await;
726 set_done_at(dir.path(), &d.id, None).await;
727 set_done_at(dir.path(), &e.id, None).await;
728
729 let board = board(dir.path(), &BoardQuery::default())
730 .await
731 .expect("board succeeds");
732 let done = board
733 .columns
734 .iter()
735 .find(|c| c.status.as_str() == "done")
736 .expect("done column");
737 let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
738
739 assert_eq!(ids, [b.id, c.id, a.id, d.id, e.id]);
741 }
742
743 #[tokio::test]
744 async fn board_non_done_columns_remain_rank_ordered() {
745 let dir = init_temp().await;
746 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
747 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
748 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
749 for it in [&a, &b, &c] {
750 move_item(dir.path(), &it.id, "in-progress").await.unwrap();
751 }
752
753 let board = board(dir.path(), &BoardQuery::default())
754 .await
755 .expect("board succeeds");
756 let wip = board
757 .columns
758 .iter()
759 .find(|c| c.status.as_str() == "in-progress")
760 .expect("in-progress column");
761 let ids: Vec<_> = wip.items.iter().map(|i| i.id.clone()).collect();
762 assert_eq!(
763 ids,
764 [a.id, b.id, c.id],
765 "non-terminal columns keep rank order"
766 );
767 }
768
769 #[tokio::test]
770 async fn board_done_column_setting_is_position_independent() {
771 let dir = init_temp().await;
772 set_columns(dir.path(), &["todo", "done", "archived"]).await;
774
775 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
776 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
777 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
778 for it in [&a, &b, &c] {
779 move_item(dir.path(), &it.id, "done").await.unwrap();
780 }
781 set_done_at(dir.path(), &a.id, Some(100)).await;
782 set_done_at(dir.path(), &b.id, Some(300)).await;
783 set_done_at(dir.path(), &c.id, Some(200)).await;
784
785 let board = board(dir.path(), &BoardQuery::default())
786 .await
787 .expect("board succeeds");
788 let done = board
789 .columns
790 .iter()
791 .find(|c| c.status.as_str() == "done")
792 .expect("done column");
793 let ids: Vec<_> = done.items.iter().map(|i| i.id.clone()).collect();
794 assert_eq!(ids, [b.id, c.id, a.id]);
796 }
797
798 #[tokio::test]
801 async fn board_sort_created_applies_to_all_columns() {
802 let dir = init_temp().await;
803 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
804 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
805 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
806 set_created(dir.path(), &a.id, 300).await;
808 set_created(dir.path(), &b.id, 100).await;
809 set_created(dir.path(), &c.id, 200).await;
810
811 let asc = BoardQuery {
813 sort: Some(SortKey::Created),
814 ..Default::default()
815 };
816 let asc_board = board(dir.path(), &asc).await.unwrap();
817 assert_eq!(
818 asc_board.columns[0]
819 .items
820 .iter()
821 .map(|i| i.id.clone())
822 .collect::<Vec<_>>(),
823 [b.id.clone(), c.id.clone(), a.id.clone()]
824 );
825
826 let desc = BoardQuery {
828 sort: Some(SortKey::Created),
829 reverse: true,
830 ..Default::default()
831 };
832 let desc_board = board(dir.path(), &desc).await.unwrap();
833 assert_eq!(
834 desc_board.columns[0]
835 .items
836 .iter()
837 .map(|i| i.id.clone())
838 .collect::<Vec<_>>(),
839 [a.id, c.id, b.id]
840 );
841 }
842
843 #[tokio::test]
844 async fn board_sort_rank_overrides_default_done_ordering() {
845 let dir = init_temp().await;
846 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
847 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
848 let c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
849 for it in [&a, &b, &c] {
850 move_item(dir.path(), &it.id, "done").await.unwrap();
851 }
852 set_done_at(dir.path(), &a.id, Some(100)).await;
854 set_done_at(dir.path(), &b.id, Some(200)).await;
855 set_done_at(dir.path(), &c.id, Some(300)).await;
856
857 let query = BoardQuery {
858 sort: Some(SortKey::Rank),
859 ..Default::default()
860 };
861 let board = board(dir.path(), &query).await.unwrap();
862 let done = board
863 .columns
864 .iter()
865 .find(|c| c.status.as_str() == "done")
866 .unwrap();
867 assert_eq!(
868 done.items.iter().map(|i| i.id.clone()).collect::<Vec<_>>(),
869 [a.id, b.id, c.id],
870 "explicit --sort rank overrides the default done ordering"
871 );
872 }
873
874 #[test]
875 fn done_sort_places_completed_items_before_undated_items() {
876 let now = chrono::DateTime::from_timestamp(0, 0).expect("ts");
877 let mut completed = BacklogItem::new(
878 crate::backlog::ItemId::new("T", 1),
879 "Completed",
880 Status::new("done"),
881 crate::rank::Rank::after(None),
882 now,
883 )
884 .expect("item");
885 completed.done_at = Some(now);
886 let undated = BacklogItem::new(
887 crate::backlog::ItemId::new("T", 2),
888 "Undated",
889 Status::new("done"),
890 crate::rank::Rank::after(None),
891 now,
892 )
893 .expect("item");
894 let mut items = vec![completed, undated];
895
896 sort_items(&mut items, SortKey::Done, false);
897
898 assert!(items[0].done_at.is_some());
899 assert!(items[1].done_at.is_none());
900 }
901
902 async fn set_created(dir: &Path, id: &crate::backlog::ItemId, created: i64) {
904 use crate::storage::{BacklogItemRepository, FileRepository};
905 let repo = FileRepository::new(dir.join(".pinto"));
906 let mut item = repo.load(id).await.expect("load");
907 item.created = chrono::DateTime::from_timestamp(created, 0).expect("ts");
908 repo.save(&item).await.expect("save");
909 }
910}