use std::collections::HashMap;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap};
use super::{
Action, Context, DETAIL_WORDS, Result, View,
filter::{Filter, Handled},
first_words,
new_task::NewTaskView,
plural,
repos::ReposView,
review::ReviewView,
state_style,
task::TaskView,
task::usage_summary,
todos::TodosView,
};
use crate::brief::{self, Brief};
use crate::domain::{Task, TaskState};
use crate::store::Transition;
use crate::tmux::Tmux;
use crate::worktree::{self, Branches, WorktreeManager};
use crate::{archive, pause};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Arrangement {
Recent,
Oldest,
Repo,
}
impl Arrangement {
pub const ALL: &'static [Arrangement] = &[Self::Recent, Self::Oldest, Self::Repo];
fn label(self) -> &'static str {
match self {
Self::Recent => "newest",
Self::Oldest => "oldest",
Self::Repo => "by repo",
}
}
fn next(self) -> Self {
match self {
Self::Recent => Self::Oldest,
Self::Oldest => Self::Repo,
Self::Repo => Self::Recent,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shown {
Open,
All,
Archive,
}
impl Shown {
fn includes(self, task: &Task) -> bool {
match self {
Self::Open => {
!task.is_archived()
&& !matches!(task.state, TaskState::Committed | TaskState::Cancelled)
}
Self::All => !task.is_archived(),
Self::Archive => task.is_archived(),
}
}
fn label(self) -> &'static str {
match self {
Self::Open => "open",
Self::All => "all",
Self::Archive => "archived",
}
}
fn next(self) -> Self {
match self {
Self::Open => Self::All,
Self::All => Self::Archive,
Self::Archive => Self::Open,
}
}
}
pub struct TasksView {
tasks: Vec<Task>,
repos: HashMap<i64, Vec<String>>,
todos: HashMap<i64, usize>,
table: TableState,
shown: Shown,
arrangement: Arrangement,
filter: Filter,
unfiltered: usize,
showing: Option<Box<Brief>>,
confirming: Option<i64>,
loaded: bool,
dirty: bool,
}
impl Default for TasksView {
fn default() -> Self {
Self::new()
}
}
impl TasksView {
pub fn new() -> Self {
Self {
tasks: Vec::new(),
repos: HashMap::new(),
todos: HashMap::new(),
table: TableState::default().with_selected(Some(0)),
shown: Shown::Open,
arrangement: Arrangement::Recent,
filter: Filter::default(),
unfiltered: 0,
showing: None,
confirming: None,
loaded: false,
dirty: true,
}
}
fn reload(&mut self, ctx: &mut Context) -> Result<()> {
let mut tasks = match self.shown {
Shown::Archive => ctx.store.list_tasks()?,
Shown::Open | Shown::All => ctx.store.list_active_tasks()?,
};
tasks.retain(|task| self.shown.includes(task));
let repos = ctx.store.repo_names_by_task().unwrap_or_default();
let todos = ctx.store.open_todo_counts().unwrap_or_default();
let unfiltered = tasks.len();
if self.filter.is_active() {
tasks.retain(|task| {
let repo = repos
.get(&task.id)
.map(|names| names.join(", "))
.unwrap_or_default();
self.filter.matches_any([
task.title.as_str(),
task.state.as_str(),
repo.as_str(),
task.prompt.as_str(),
])
});
}
self.arrange(&mut tasks);
let selected_id = self.selected().map(|t| t.id);
let index = selected_id
.and_then(|id| tasks.iter().position(|t| t.id == id))
.unwrap_or_else(|| self.table.selected().unwrap_or(0))
.min(tasks.len().saturating_sub(1));
self.dirty |= !self.loaded
|| tasks != self.tasks
|| repos != self.repos
|| todos != self.todos
|| Some(index) != self.table.selected();
self.dirty |= unfiltered != self.unfiltered;
self.unfiltered = unfiltered;
self.tasks = tasks;
self.repos = repos;
self.todos = todos;
self.table.select(Some(index));
self.loaded = true;
Ok(())
}
fn arrange(&self, tasks: &mut [Task]) {
match self.arrangement {
Arrangement::Oldest => {}
Arrangement::Recent => tasks.reverse(),
Arrangement::Repo => {
tasks.sort_by(|a, b| {
self.repo_label(a)
.cmp(&self.repo_label(b))
.then(b.id.cmp(&a.id))
});
}
}
}
fn repo_label(&self, task: &Task) -> (bool, String) {
match self.repos.get(&task.id) {
Some(names) if !names.is_empty() => (false, names.join(", ")),
_ => (true, String::new()),
}
}
pub fn selected(&self) -> Option<&Task> {
self.table.selected().and_then(|i| self.tasks.get(i))
}
fn discard_label(&self) -> &'static str {
match self.selected() {
Some(task) if task.is_archived() => "archived",
Some(task) if task.state.is_terminal() => "archive",
_ => "cancel",
}
}
fn pause_label(&self) -> &'static str {
match self.selected() {
Some(task) if task.state == TaskState::Paused => "resume",
_ => "pause",
}
}
fn set_aside(&mut self, task: &Task, ctx: &mut Context) -> Result<()> {
let tmux = Tmux::new();
let now = chrono::Utc::now();
let outcome = if task.state == TaskState::Paused {
pause::resume(ctx.store, &tmux, task, now).map(|task| match task.state {
TaskState::Queued => format!("task {} is queued again", task.id),
_ => format!("task {} resumed", task.id),
})
} else {
pause::pause(ctx.store, &tmux, task, now).map(|task| format!("paused task {}", task.id))
};
match outcome {
Ok(message) => {
ctx.say(message);
self.reload(ctx)?;
}
Err(err) => ctx.say(err.to_string()),
}
Ok(())
}
fn reclaim(&mut self, task: &Task, confirmed: bool, ctx: &mut Context) -> Result<()> {
if !task.state.is_terminal() {
ctx.say(format!(
"a {} task is still using its worktrees",
task.state
));
return Ok(());
}
let manager = WorktreeManager::new(&ctx.config.workspace_root);
let dirty = worktree::dirty_worktrees(ctx.store, task)?;
if !dirty.is_empty() && !confirmed {
self.confirming = Some(task.id);
ctx.say(format!(
"{} with work no commit would keep — X again to lose it",
plural(dirty.len(), "place")
));
return Ok(());
}
match manager.teardown(ctx.store, task, Branches::Keep) {
Ok(teardown) if teardown.is_clean() => {
ctx.store.clear_worktrees(task.id)?;
ctx.say(format!(
"removed {} from task {}",
plural(teardown.removed.len(), "worktree"),
task.id
));
self.reload(ctx)?;
}
Ok(teardown) => ctx.say(format!(
"{} would not go: {}",
plural(teardown.failed.len(), "worktree"),
teardown.failed[0].1
)),
Err(err) => ctx.say(err.to_string()),
}
Ok(())
}
fn put_away(&mut self, task: &Task, ctx: &mut Context) -> Result<()> {
match archive::archive(ctx.store, &Tmux::new(), task, chrono::Utc::now()) {
Ok(task) => {
ctx.say(format!("archived task {} — a to see the archive", task.id));
self.reload(ctx)?;
}
Err(err) => ctx.say(err.to_string()),
}
Ok(())
}
fn move_by(&mut self, delta: isize) {
if self.tasks.is_empty() {
return;
}
let current = self.table.selected().unwrap_or(0) as isize;
let last = self.tasks.len() as isize - 1;
self.table
.select(Some(current.saturating_add(delta).clamp(0, last) as usize));
}
}
impl View for TasksView {
fn title(&self) -> String {
format!(
"Tasks ({} {}) · {}{}",
Filter::count_label(self.tasks.len(), self.unfiltered),
self.shown.label(),
self.arrangement.label(),
self.filter.label()
)
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context) {
if !self.loaded {
let _ = self.reload(ctx);
}
let [area, field] =
Layout::vertical([Constraint::Min(1), Constraint::Length(self.filter.height())])
.areas(area);
self.filter.render(frame, field);
if self.tasks.is_empty() {
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(if self.filter.is_active() {
" Nothing matches."
} else {
" Nothing queued."
}),
Line::from(""),
Line::from(Span::styled(
if self.filter.is_active() {
" esc clears the filter."
} else {
" Press n to describe a task."
},
Style::default().add_modifier(Modifier::DIM),
)),
]),
area,
);
self.dirty = false;
return;
}
let mut previous: Option<String> = None;
let rows: Vec<Row> = self
.tasks
.iter()
.map(|task| {
let repo = self.repo_label(task).1;
let style = if previous.as_deref() == Some(repo.as_str()) {
Style::default().add_modifier(Modifier::DIM)
} else {
Style::default()
};
previous = Some(repo.clone());
let title = match self.todos.get(&task.id) {
Some(0) | None => Line::from(task.title.clone()),
Some(count) => Line::from(vec![
Span::raw(task.title.clone()),
Span::styled(
format!(" ☐{count}"),
Style::default().add_modifier(Modifier::DIM),
),
]),
};
Row::new(vec![
Cell::from(format!("{}", task.id)),
Cell::from(Span::styled(
task.state.as_str().to_string(),
state_style(task.state),
)),
Cell::from(Span::styled(repo, style)),
Cell::from(title),
Cell::from(detail_for(task)),
])
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(4),
Constraint::Length(16),
Constraint::Length(16),
Constraint::Percentage(35),
Constraint::Min(10),
],
)
.header(
Row::new(vec!["id", "state", "repo", "title", "detail"])
.style(Style::default().add_modifier(Modifier::BOLD | Modifier::DIM)),
)
.row_highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("");
frame.render_stateful_widget(table, area, &mut self.table);
if let Some(brief) = &self.showing {
render_brief(frame, area, brief);
}
self.dirty = false;
}
fn handle_mouse(&mut self, mouse: MouseEvent, ctx: &mut Context) -> Result<Action> {
if self.showing.is_some() {
return Ok(Action::None);
}
let code = match mouse.kind {
MouseEventKind::ScrollUp => KeyCode::Up,
MouseEventKind::ScrollDown => KeyCode::Down,
_ => return Ok(Action::None),
};
self.handle_key(KeyEvent::new(code, KeyModifiers::NONE), ctx)
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
let confirming = self.confirming.take();
if self.showing.is_some() {
self.showing = None;
self.dirty = true;
return Ok(Action::None);
}
self.dirty = true;
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') {
return Ok(Action::Quit);
}
match self.filter.handle_key(key) {
Handled::Changed => {
self.reload(ctx)?;
return Ok(Action::None);
}
Handled::Yes => return Ok(Action::None),
Handled::Leave => return Ok(Action::Quit),
Handled::No => {}
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Ok(match key.code {
KeyCode::Char('c') => Action::Quit,
_ => Action::None,
});
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(Action::Quit),
KeyCode::Char('n') => return Ok(Action::Push(Box::new(NewTaskView::new()))),
KeyCode::Char('j') | KeyCode::Down => self.move_by(1),
KeyCode::Char('k') | KeyCode::Up => self.move_by(-1),
KeyCode::Char('g') | KeyCode::Home => self.table.select(Some(0)),
KeyCode::Char('G') | KeyCode::End => {
self.table.select(Some(self.tasks.len().saturating_sub(1)));
}
KeyCode::Char('a') => {
self.shown = self.shown.next();
self.reload(ctx)?;
ctx.say(self.shown.label());
}
KeyCode::Char('r') => {
self.reload(ctx)?;
ctx.say("refreshed");
}
KeyCode::Char('R') => return Ok(Action::Push(Box::new(ReposView::new()))),
KeyCode::Char('s') => {
self.arrangement = self.arrangement.next();
self.reload(ctx)?;
ctx.say(self.arrangement.label());
}
KeyCode::Enter => {
if let Some(task) = self.selected() {
return Ok(Action::Push(Box::new(TaskView::new(task.id))));
}
}
KeyCode::Char('v') => {
if let Some(task) = self.selected() {
if task.state == TaskState::Queued {
let id = task.id;
ctx.say(format!("task {id} has not started yet"));
} else {
return Ok(Action::Push(Box::new(ReviewView::new(task.id))));
}
}
}
KeyCode::Char('p') => {
if let Some(task) = self.selected().cloned() {
self.set_aside(&task, ctx)?;
}
}
KeyCode::Char('u') => {
if let Some(task) = self.selected().cloned() {
if task.is_archived() {
archive::restore(ctx.store, &task, chrono::Utc::now())?;
ctx.say(format!("task {} is back on the list", task.id));
self.reload(ctx)?;
} else {
ctx.say(format!("task {} is not archived", task.id));
}
}
}
KeyCode::Char('f') => {
if let Some(task) = self.selected().cloned() {
if task.state.can_transition_to(TaskState::AwaitingReview) {
ctx.store.transition(
task.id,
TaskState::AwaitingReview,
Transition::Plain,
chrono::Utc::now(),
)?;
ctx.say(format!("task {} is ready to review", task.id));
self.reload(ctx)?;
} else {
ctx.say(format!("a {} task is not waiting to finish", task.state));
}
}
}
KeyCode::Char('y') => {
if let Some(task) = self.selected() {
match Brief::read(ctx.store, task.id) {
Ok(brief) => self.showing = Some(Box::new(brief)),
Err(err) => ctx.say(err.to_string()),
}
}
}
KeyCode::Char('t') => {
return Ok(Action::Push(Box::new(match self.selected() {
Some(task) => TodosView::for_task(task.id),
None => TodosView::global(),
})));
}
KeyCode::Char('x') => {
if let Some(task) = self.selected().cloned() {
if task.is_archived() {
ctx.say(format!("task {} is archived — u puts it back", task.id));
} else if task.state.can_transition_to(TaskState::Cancelled) {
ctx.store.transition(
task.id,
TaskState::Cancelled,
Transition::Plain,
chrono::Utc::now(),
)?;
ctx.say(format!("cancelled task {}", task.id));
self.reload(ctx)?;
} else {
self.put_away(&task, ctx)?;
}
}
}
KeyCode::Char('X') => {
if let Some(task) = self.selected().cloned() {
let confirmed = confirming == Some(task.id);
self.reclaim(&task, confirmed, ctx)?;
}
}
_ => {}
}
Ok(Action::None)
}
fn tick(&mut self, ctx: &mut Context) -> Result<()> {
self.reload(ctx)
}
fn dirty(&self) -> bool {
self.dirty
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
vec![
("n", "new"),
("↵", "open"),
("v", "review"),
("p", self.pause_label()),
("/", "filter"),
("y", "details"),
("f", "finished"),
("t", "todos"),
("x", self.discard_label()),
("X", "reclaim"),
("a", "shown"),
("s", "sort"),
("r", "refresh"),
("R", "repos"),
("esc", "quit"),
]
}
}
fn render_brief(frame: &mut Frame, area: Rect, brief: &Brief) {
let task = &brief.task;
let now = chrono::Utc::now();
let dim = Style::default().add_modifier(Modifier::DIM);
let mut lines = vec![Line::from(vec![
Span::styled(
format!(" {} ", task.state),
state_style(task.state).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(task.title.clone()),
])];
let mut where_it_is = Vec::new();
if !brief.targets.is_empty() {
where_it_is.push(Span::styled(
brief.repo_line(),
Style::default().fg(Color::Cyan),
));
}
if let Some(branch) = brief.branch() {
where_it_is.push(Span::styled(format!(" {branch}"), dim));
}
if !where_it_is.is_empty() {
lines.push(Line::from(where_it_is));
}
lines.push(Line::from(Span::styled(
format!(
"described {} · {}",
brief::stamp(task.created_at),
brief::ago(task.created_at, now)
),
dim,
)));
lines.push(Line::from(Span::styled(
match brief.started_at {
Some(at) => format!("started {} · {}", brief::stamp(at), brief::ago(at, now)),
None => "started not yet".to_string(),
},
dim,
)));
if let Some(at) = task.archived_at {
lines.push(Line::from(Span::styled(
format!("archived {}", brief::stamp(at)),
dim,
)));
}
if let Some(spent) = usage_summary(&task.usage) {
lines.push(Line::from(Span::styled(
spent,
Style::default().fg(Color::Magenta),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Prompt",
Style::default().add_modifier(Modifier::BOLD),
)));
for line in task.prompt.lines() {
lines.push(Line::from(line.to_string()));
}
let width = BRIEF_WIDTH.min(area.width.saturating_sub(2)).max(4);
let inner = width.saturating_sub(2).max(1) as usize;
let wrapped: usize = lines
.iter()
.map(|line| line.width().div_ceil(inner).max(1))
.sum();
let panel = centred(area, width, wrapped as u16 + 2);
frame.render_widget(Clear, panel);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(format!(" task {} ", task.id))
.title_bottom(" any key closes "),
)
.style(Style::default()),
panel,
);
}
const BRIEF_WIDTH: u16 = 76;
fn centred(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width.saturating_sub(2)).max(1);
let height = height.min(area.height.saturating_sub(2)).max(1);
Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
}
}
fn detail_for(task: &Task) -> String {
let full = match task.state {
TaskState::Blocked => task
.blocked_reason
.clone()
.or_else(|| task.blocked_kind.map(|k| k.to_string()))
.unwrap_or_default(),
TaskState::Failed => task.failure_reason.clone().unwrap_or_default(),
TaskState::Running => task.session_name.clone().unwrap_or_default(),
_ => String::new(),
};
first_words(&full, DETAIL_WORDS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::BlockedKind;
use crate::store::{BlockedInfo, Store};
use crate::tui::testing::{press, render_view, tick_view, wheel};
use chrono::{DateTime, Utc};
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use std::path::Path;
use std::path::PathBuf;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn store_with_a_worktree() -> (Store, i64, PathBuf, tempfile::TempDir) {
store_with_a_worktree_in(TaskState::Cancelled)
}
fn store_with_a_worktree_in(state: TaskState) -> (Store, i64, PathBuf, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let mut store = Store::open_in_memory().unwrap();
let repo_path = tmp.path().join("api");
crate::git::testing::init_repo(&repo_path, "main");
let repo = store.upsert_repo(&repo_path, "api", at(0)).unwrap();
let task = store
.create_task("done", "p", &tmp.path().join("tasks"), &[repo.id], at(0))
.unwrap();
let worktree = task.workspace_dir.join("api");
std::fs::create_dir_all(&task.workspace_dir).unwrap();
crate::git::worktree_add(&repo_path, &worktree, "marver/1", "main").unwrap();
store
.record_worktree(task.id, repo.id, &worktree, "marver/1", "main")
.unwrap();
store
.transition(task.id, state, Transition::Plain, at(1))
.unwrap();
(store, task.id, worktree, tmp)
}
#[test]
fn shift_x_removes_a_finished_tasks_worktrees() {
let (mut store, id, worktree, _tmp) = store_with_a_worktree();
let mut view = TasksView::new();
view.shown = Shown::All;
tick_view(&mut view, &mut store);
assert!(worktree.exists());
press(&mut view, &mut store, key(KeyCode::Char('X')));
assert!(!worktree.exists(), "the worktree should be gone");
assert!(
!store.list_task_repos(id).unwrap()[0].is_provisioned(),
"and nothing should still point at it"
);
}
#[test]
fn shift_x_takes_a_second_press_when_there_is_work_to_lose() {
let (mut store, _, worktree, _tmp) = store_with_a_worktree();
std::fs::write(worktree.join("scratch.txt"), "not committed\n").unwrap();
let mut view = TasksView::new();
view.shown = Shown::All;
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('X')));
assert!(worktree.exists(), "the first press only warns");
press(&mut view, &mut store, key(KeyCode::Char('X')));
assert!(!worktree.exists(), "the second goes through");
}
#[test]
fn a_reclaim_offer_does_not_outlive_the_key_that_made_it() {
let (mut store, _, worktree, _tmp) = store_with_a_worktree();
std::fs::write(worktree.join("scratch.txt"), "not committed\n").unwrap();
let mut view = TasksView::new();
view.shown = Shown::All;
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('X')));
press(&mut view, &mut store, key(KeyCode::Char('j')));
press(&mut view, &mut store, key(KeyCode::Char('X')));
assert!(worktree.exists(), "the offer was withdrawn by the j");
}
#[test]
fn a_reclaim_offer_is_withdrawn_by_keys_that_leave_the_screen_too() {
for escaping in [KeyCode::Char('t'), KeyCode::Enter, KeyCode::Char('/')] {
let (mut store, _, worktree, _tmp) = store_with_a_worktree();
std::fs::write(worktree.join("scratch.txt"), "not committed\n").unwrap();
let mut view = TasksView::new();
view.shown = Shown::All;
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('X')));
press(&mut view, &mut store, key(escaping));
press(&mut view, &mut store, key(KeyCode::Char('X')));
assert!(
worktree.exists(),
"{escaping:?} should have withdrawn the offer"
);
}
}
#[test]
fn the_wheel_leaves_the_brief_alone() {
let (mut store, ids) = store_with(&["one", "two", "three"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('y')));
assert!(view.showing.is_some());
wheel(&mut view, &mut store, false);
assert!(view.showing.is_some(), "the brief is still up");
assert_eq!(
view.selected().map(|t| t.id),
Some(ids[2]),
"and the cursor has not moved under it"
);
}
#[test]
fn shift_x_leaves_a_task_that_is_still_working() {
let (mut store, _, worktree, _tmp) = store_with_a_worktree_in(TaskState::Running);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('X')));
press(&mut view, &mut store, key(KeyCode::Char('X')));
assert!(worktree.exists(), "a running task keeps its worktrees");
}
#[test]
fn the_wheel_moves_the_selection_on_a_list() {
let (mut store, ids) = store_with(&["one", "two", "three"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
assert_eq!(view.selected().map(|t| t.id), Some(ids[2]));
wheel(&mut view, &mut store, false);
assert_eq!(view.selected().map(|t| t.id), Some(ids[1]));
wheel(&mut view, &mut store, true);
assert_eq!(view.selected().map(|t| t.id), Some(ids[2]));
}
#[test]
fn x_cancels_first_and_archives_second() {
let (mut store, ids) = store_with(&["one", "two"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 90, 12);
press(&mut view, &mut store, key(KeyCode::Char('x')));
let task = store.get_task(ids[1]).unwrap();
assert_eq!(
task.state,
TaskState::Cancelled,
"newest first, so this one"
);
assert!(!task.is_archived(), "cancelling does not hide it");
press(&mut view, &mut store, key(KeyCode::Char('a')));
assert_eq!(view.shown, Shown::All);
press(&mut view, &mut store, key(KeyCode::Char('g')));
assert_eq!(view.selected().map(|t| t.id), Some(ids[1]));
press(&mut view, &mut store, key(KeyCode::Char('x')));
let task = store.get_task(ids[1]).unwrap();
assert!(task.is_archived(), "the second press put it away");
assert_eq!(
task.state,
TaskState::Cancelled,
"and the archive still says how it ended"
);
assert!(
!view.tasks.iter().any(|t| t.id == ids[1]),
"it has left the list"
);
}
#[test]
fn the_archive_is_the_third_thing_a_shows() {
let (mut store, ids) = store_with(&["one"]);
store
.transition(ids[0], TaskState::Cancelled, Transition::Plain, at(1))
.unwrap();
store.set_task_archived(ids[0], true, at(2)).unwrap();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 90, 12);
assert!(view.tasks.is_empty(), "not on the open list");
press(&mut view, &mut store, key(KeyCode::Char('a')));
assert!(view.tasks.is_empty(), "nor among everything still current");
press(&mut view, &mut store, key(KeyCode::Char('a')));
assert_eq!(view.shown, Shown::Archive);
assert_eq!(view.tasks.len(), 1, "and here it is");
assert!(view.title().contains("archived"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Char('a')));
assert_eq!(view.shown, Shown::Open, "and round again");
}
#[test]
fn u_puts_an_archived_task_back() {
let (mut store, ids) = store_with(&["one"]);
store
.transition(ids[0], TaskState::Cancelled, Transition::Plain, at(1))
.unwrap();
store.set_task_archived(ids[0], true, at(2)).unwrap();
let mut view = TasksView::new();
view.shown = Shown::Archive;
render_view(&mut view, &mut store, 90, 12);
assert_eq!(view.selected().map(|t| t.id), Some(ids[0]));
press(&mut view, &mut store, key(KeyCode::Char('u')));
assert!(!store.get_task(ids[0]).unwrap().is_archived());
assert!(view.tasks.is_empty(), "and it has left the archive");
}
#[test]
fn a_task_that_is_still_going_is_cancelled_by_x_rather_than_hidden() {
let (mut store, ids) = store_with(&["one"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 90, 12);
press(&mut view, &mut store, key(KeyCode::Char('x')));
let task = store.get_task(ids[0]).unwrap();
assert_eq!(task.state, TaskState::Cancelled);
assert!(!task.is_archived(), "one press, one meaning");
}
#[test]
fn the_footer_says_which_discard_x_is_offering() {
let (mut store, ids) = store_with(&["one"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 90, 12);
assert_eq!(view.discard_label(), "cancel");
store
.transition(ids[0], TaskState::Cancelled, Transition::Plain, at(1))
.unwrap();
view.shown = Shown::All;
tick_view(&mut view, &mut store);
assert_eq!(view.discard_label(), "archive");
store.set_task_archived(ids[0], true, at(2)).unwrap();
view.shown = Shown::Archive;
tick_view(&mut view, &mut store);
assert_eq!(view.discard_label(), "archived");
}
#[test]
fn f_says_an_agent_has_finished_when_nothing_else_will() {
let (mut store, ids) = store_with(&["one"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(20))
.unwrap();
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('f')));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::AwaitingReview
);
}
#[test]
fn f_on_a_task_that_is_not_working_says_so_rather_than_forcing_it() {
let (mut store, ids) = store_with(&["one"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('f')));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Queued,
"a queued task has not started, let alone finished"
);
}
#[test]
fn slash_narrows_the_list_to_what_matches() {
let (mut store, ids) = store_with(&["Fix auth", "Update deps", "Auth tests"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
assert_eq!(view.tasks.len(), 3);
press(&mut view, &mut store, key(KeyCode::Char('/')));
for c in "auth".chars() {
press(&mut view, &mut store, key(KeyCode::Char(c)));
}
assert_eq!(view.tasks.len(), 2, "case-insensitive across titles");
assert!(view.title().contains("2 of 3"), "{}", view.title());
assert!(view.title().contains("/auth"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Enter));
assert_eq!(view.tasks.len(), 2);
press(&mut view, &mut store, key(KeyCode::Char('j')));
assert_eq!(
view.selected().map(|t| t.id),
Some(ids[0]),
"j moves the cursor again rather than typing"
);
}
#[test]
fn a_filter_searches_more_than_the_row_shows() {
let (mut store, _) = store_with(&["One"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('/')));
for c in "queued".chars() {
press(&mut view, &mut store, key(KeyCode::Char(c)));
}
assert_eq!(view.tasks.len(), 1, "state matches too");
}
#[test]
fn esc_clears_the_filter_before_it_leaves_the_screen() {
let (mut store, _) = store_with(&["Fix auth", "Update deps"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('/')));
for c in "auth".chars() {
press(&mut view, &mut store, key(KeyCode::Char(c)));
}
press(&mut view, &mut store, key(KeyCode::Enter));
assert_eq!(view.tasks.len(), 1);
let action = press(&mut view, &mut store, key(KeyCode::Esc));
assert!(matches!(action, Action::None), "the screen stays");
assert_eq!(view.tasks.len(), 2, "and the rows come back");
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Esc)),
Action::Quit
));
}
#[test]
fn a_filter_that_matches_nothing_says_so() {
let (mut store, _) = store_with(&["Fix auth"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('/')));
for c in "zzz".chars() {
press(&mut view, &mut store, key(KeyCode::Char(c)));
}
let screen = render_view(&mut view, &mut store, 90, 12);
let text = screen.join("\n");
assert!(text.contains("Nothing matches"), "{text}");
assert!(text.contains("esc clears the filter"), "{text}");
}
#[test]
fn keys_that_are_bindings_are_typed_into_the_filter_instead() {
let (mut store, ids) = store_with(&["nx thing"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('/')));
for c in "nx".chars() {
let action = press(&mut view, &mut store, key(KeyCode::Char(c)));
assert!(matches!(action, Action::None), "no screen was opened");
}
assert_eq!(view.tasks.len(), 1);
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Queued,
"and nothing was cancelled"
);
}
#[test]
fn y_shows_what_the_task_was_asked_to_do() {
let (mut store, ids) = store_with(&["Fix auth"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(30))
.unwrap();
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('y')));
let screen = render_view(&mut view, &mut store, 100, 20);
let text = screen.join("\n");
assert!(text.contains(&format!("task {}", ids[0])), "{text}");
assert!(text.contains("Prompt"), "{text}");
assert!(text.contains('p'), "the prompt itself: {text}");
assert!(text.contains("described"), "{text}");
assert!(text.contains("started"), "{text}");
assert!(text.contains("any key closes"), "{text}");
}
#[test]
fn a_task_that_has_not_started_says_so_rather_than_leaving_it_out() {
let (mut store, _) = store_with(&["Fix auth"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('y')));
let screen = render_view(&mut view, &mut store, 100, 20);
assert!(
screen.join("\n").contains("started not yet"),
"{screen:?}"
);
}
#[test]
fn the_next_key_closes_it_and_does_nothing_else() {
let (mut store, ids) = store_with(&["Fix auth"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('y')));
assert!(view.showing.is_some());
let action = press(&mut view, &mut store, key(KeyCode::Char('x')));
assert!(matches!(action, Action::None));
assert!(view.showing.is_none(), "it closed");
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Queued,
"and cancelled nothing on the way"
);
}
#[test]
fn the_panel_fits_a_small_terminal() {
let (mut store, _) = store_with(&["Fix auth"]);
let mut view = TasksView::new();
tick_view(&mut view, &mut store);
press(&mut view, &mut store, key(KeyCode::Char('y')));
let screen = render_view(&mut view, &mut store, 40, 8);
assert_eq!(screen.len(), 8);
assert!(
screen.iter().all(|line| line.chars().count() <= 40),
"{screen:?}"
);
}
#[test]
fn a_tick_that_finds_nothing_new_asks_for_no_frame() {
let (mut store, ids) = store_with(&["one", "two"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 90, 12);
assert!(!view.dirty(), "the first frame settled it");
tick_view(&mut view, &mut store);
assert!(!view.dirty(), "nothing has changed");
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
tick_view(&mut view, &mut store);
assert!(view.dirty(), "a task changed state");
render_view(&mut view, &mut store, 90, 12);
assert!(!view.dirty());
press(&mut view, &mut store, key(KeyCode::Char('j')));
assert!(view.dirty(), "the cursor moved");
}
#[test]
fn a_todo_left_on_a_task_still_reaches_the_list() {
let (mut store, ids) = store_with(&["one"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 90, 12);
store
.add_todo(crate::domain::TodoScope::Task(ids[0]), "look", at(1))
.unwrap();
tick_view(&mut view, &mut store);
assert!(view.dirty(), "a new todo changes the row");
}
fn store_with(titles: &[&str]) -> (Store, Vec<i64>) {
let mut store = Store::open_in_memory().unwrap();
let ids = titles
.iter()
.map(|t| {
store
.create_task(t, "p", Path::new("/tmp/tasks"), &[], at(0))
.unwrap()
.id
})
.collect();
(store, ids)
}
fn titles(view: &TasksView) -> Vec<String> {
view.tasks.iter().map(|t| t.title.clone()).collect()
}
fn store_with_repos(rows: &[(&str, &str)]) -> (Store, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().unwrap();
let mut store = Store::open_in_memory().unwrap();
for (index, (title, repo_name)) in rows.iter().enumerate() {
let path = tmp.path().join(repo_name);
let repo = store.upsert_repo(&path, repo_name, at(0)).unwrap();
store
.create_task(
title,
"p",
Path::new("/tmp/tasks"),
&[repo.id],
at(index as i64),
)
.unwrap();
}
(store, tmp)
}
fn repo_column(screen: &[String]) -> Vec<String> {
screen
.iter()
.skip(1)
.filter(|line| !line.trim().is_empty())
.filter_map(|line| line.split_whitespace().nth(2).map(str::to_string))
.collect()
}
#[test]
fn the_list_names_the_repo_each_task_is_for() {
let (mut store, _tmp) = store_with_repos(&[("first", "api"), ("second", "web")]);
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 100, 8);
assert!(screen[0].contains("repo"), "a column for it: {screen:?}");
assert!(screen.iter().any(|l| l.contains("api")), "{screen:?}");
assert!(screen.iter().any(|l| l.contains("web")), "{screen:?}");
}
#[test]
fn sorting_cycles_through_the_arrangements() {
let (mut store, _tmp) =
store_with_repos(&[("oldest", "web"), ("middle", "api"), ("newest", "web")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
assert_eq!(titles(&view), ["newest", "middle", "oldest"]);
assert!(view.title().contains("newest"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["oldest", "middle", "newest"]);
assert!(view.title().contains("oldest"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["middle", "newest", "oldest"]);
assert!(view.title().contains("by repo"), "{}", view.title());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["newest", "middle", "oldest"], "it wraps");
}
#[test]
fn cycling_the_whole_set_comes_back_to_where_it_started() {
let (mut store, _tmp) = store_with_repos(&[("only", "api")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 8);
let start = view.arrangement;
for _ in Arrangement::ALL {
press(&mut view, &mut store, key(KeyCode::Char('s')));
}
assert_eq!(view.arrangement, start);
}
#[test]
fn grouping_puts_a_repo_together() {
let (mut store, _tmp) =
store_with_repos(&[("a", "web"), ("b", "api"), ("c", "web"), ("d", "api")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
press(&mut view, &mut store, key(KeyCode::Char('s')));
press(&mut view, &mut store, key(KeyCode::Char('s')));
let screen = render_view(&mut view, &mut store, 100, 10);
let repos = repo_column(&screen);
let mut runs = repos.clone();
runs.dedup();
assert_eq!(
runs.len(),
2,
"each repo should appear as one run, not scattered: {repos:?}"
);
}
#[test]
fn the_cursor_stays_on_its_task_when_the_order_changes() {
let (mut store, _tmp) =
store_with_repos(&[("first", "api"), ("second", "web"), ("third", "api")]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
press(&mut view, &mut store, key(KeyCode::Char('j')));
let held = view.selected().map(|t| t.id);
assert!(held.is_some());
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(view.selected().map(|t| t.id), held, "the cursor followed");
}
#[test]
fn a_task_with_no_repo_sorts_last_rather_than_first() {
let (mut store, _tmp) = store_with_repos(&[("has one", "api")]);
store
.create_task("has none", "p", Path::new("/tmp/tasks"), &[], at(9))
.unwrap();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 100, 10);
press(&mut view, &mut store, key(KeyCode::Char('s')));
press(&mut view, &mut store, key(KeyCode::Char('s')));
assert_eq!(titles(&view), ["has one", "has none"]);
}
#[test]
fn an_empty_list_says_what_to_do_next() {
let mut store = Store::open_in_memory().unwrap();
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 60, 8);
assert!(
screen.iter().any(|l| l.contains("Press n")),
"an empty screen should not be a dead end: {screen:?}"
);
}
#[test]
fn tasks_are_listed_newest_first_with_their_state() {
let (mut store, _) = store_with(&["older", "newer"]);
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 70, 8);
let body: Vec<&String> = screen.iter().filter(|l| !l.is_empty()).collect();
assert!(body[1].contains("newer"), "{screen:?}");
assert!(body[2].contains("older"), "{screen:?}");
assert!(body[1].contains("queued"), "{screen:?}");
}
#[test]
fn a_blocked_task_shows_its_reason_in_the_list() {
let (mut store, ids) = store_with(&["fix auth"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
store
.transition(
ids[0],
TaskState::Blocked,
Transition::Blocked(BlockedInfo::with_reason(
BlockedKind::PermissionPrompt,
"edit main.rs",
)),
at(2),
)
.unwrap();
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 80, 8);
assert!(
screen.iter().any(|l| l.contains("edit main.rs")),
"the reason is what makes the row actionable: {screen:?}"
);
}
#[test]
fn finished_tasks_are_hidden_until_asked_for() {
let (mut store, ids) = store_with(&["done", "live"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
store
.transition(ids[0], TaskState::AwaitingReview, Transition::Plain, at(2))
.unwrap();
store
.transition(ids[0], TaskState::Committed, Transition::Plain, at(3))
.unwrap();
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 70, 8);
assert!(!screen.iter().any(|l| l.contains("done")), "{screen:?}");
press(&mut view, &mut store, key(KeyCode::Char('a')));
let all = render_view(&mut view, &mut store, 70, 8);
assert!(all.iter().any(|l| l.contains("done")), "{all:?}");
}
#[test]
fn the_cursor_stays_on_the_same_task_across_a_refresh() {
let (mut store, ids) = store_with(&["one", "two", "three"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
press(&mut view, &mut store, key(KeyCode::Char('j')));
let before = view.selected().map(|t| t.id);
assert_eq!(before, Some(ids[1]));
store
.create_task("four", "p", Path::new("/tmp/tasks"), &[], at(4))
.unwrap();
press(&mut view, &mut store, key(KeyCode::Char('r')));
assert_eq!(
view.selected().map(|t| t.id),
before,
"a task appearing elsewhere must not move the selection"
);
}
#[test]
fn navigation_stops_at_the_ends() {
let (mut store, _) = store_with(&["one", "two"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
for _ in 0..5 {
press(&mut view, &mut store, key(KeyCode::Char('k')));
}
assert_eq!(view.table.selected(), Some(0));
for _ in 0..5 {
press(&mut view, &mut store, key(KeyCode::Char('j')));
}
assert_eq!(view.table.selected(), Some(1), "must not run off the end");
}
#[test]
fn a_task_with_todos_says_so_on_the_list() {
use crate::domain::TodoScope;
let (mut store, ids) = store_with(&["fix auth"]);
store
.add_todo(TodoScope::Task(ids[0]), "also handle nulls", at(1))
.unwrap();
let done = store
.add_todo(TodoScope::Task(ids[0]), "already sent", at(2))
.unwrap();
store.set_todo_done(done.id, true).unwrap();
store
.add_todo(TodoScope::Global, "not this task's", at(3))
.unwrap();
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 100, 12).join("\n");
assert!(screen.contains("☐1"), "one outstanding todo: {screen}");
}
#[test]
fn a_task_with_nothing_outstanding_carries_no_badge() {
let (mut store, _) = store_with(&["fix auth"]);
let mut view = TasksView::new();
let screen = render_view(&mut view, &mut store, 100, 12).join("\n");
assert!(!screen.contains("☐"), "{screen}");
}
#[test]
fn cancelling_moves_the_task_and_removes_it_from_the_list() {
let (mut store, ids) = store_with(&["doomed"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
press(&mut view, &mut store, key(KeyCode::Char('x')));
assert_eq!(store.get_task(ids[0]).unwrap().state, TaskState::Cancelled);
assert!(view.tasks.is_empty(), "cancelled tasks are not open work");
}
#[test]
fn ctrl_c_quits_and_bare_c_no_longer_destroys_anything() {
let (mut store, ids) = store_with(&["precious"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
let chord = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(matches!(press(&mut view, &mut store, chord), Action::Quit));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Queued,
"ctrl-c must not cancel anything"
);
press(&mut view, &mut store, key(KeyCode::Char('c')));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Queued,
"`c` is commit in review; here it must do nothing at all"
);
}
#[test]
fn esc_leaves_the_list_the_way_it_leaves_every_other_screen() {
let (mut store, _) = store_with(&["one"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Esc)),
Action::Quit
));
}
#[test]
fn other_modified_letters_do_nothing() {
let (mut store, _) = store_with(&["one"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
for code in [KeyCode::Char('n'), KeyCode::Char('a'), KeyCode::Char('v')] {
let chord = KeyEvent::new(code, KeyModifiers::CONTROL);
assert!(
matches!(press(&mut view, &mut store, chord), Action::None),
"ctrl+{code:?} is not the {code:?} binding"
);
}
}
#[test]
fn cancelling_a_finished_task_is_refused_rather_than_erroring() {
let (mut store, ids) = store_with(&["done"]);
store
.transition(ids[0], TaskState::Running, Transition::Plain, at(1))
.unwrap();
store
.transition(ids[0], TaskState::AwaitingReview, Transition::Plain, at(2))
.unwrap();
store
.transition(ids[0], TaskState::Committed, Transition::Plain, at(3))
.unwrap();
let mut view = TasksView::new();
view.shown = Shown::All;
render_view(&mut view, &mut store, 70, 8);
press(&mut view, &mut store, key(KeyCode::Char('x')));
assert_eq!(
store.get_task(ids[0]).unwrap().state,
TaskState::Committed,
"a committed task must stay committed"
);
}
#[test]
fn n_opens_the_new_task_screen_and_enter_opens_a_task() {
let (mut store, _) = store_with(&["one"]);
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Char('n'))),
Action::Push(_)
));
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Enter)),
Action::Push(_)
));
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Char('q'))),
Action::Quit
));
}
#[test]
fn enter_on_an_empty_list_does_nothing() {
let mut store = Store::open_in_memory().unwrap();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 70, 8);
assert!(matches!(
press(&mut view, &mut store, key(KeyCode::Enter)),
Action::None
));
}
}
#[cfg(test)]
mod pause_tests {
use super::*;
use crate::store::Store;
use crate::tui::testing::{press, render_view, tick_view};
use chrono::{DateTime, Utc};
use ratatui::crossterm::event::KeyModifiers;
use std::path::Path;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn store_with_a_queued_task() -> (Store, i64) {
let mut store = Store::open_in_memory().unwrap();
let id = store
.create_task("not yet", "do it", Path::new("/tmp/tasks"), &[], at(0))
.unwrap()
.id;
(store, id)
}
#[test]
fn p_holds_a_queued_task_and_gives_it_back() {
let (mut store, id) = store_with_a_queued_task();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 80, 12);
press(&mut view, &mut store, key(KeyCode::Char('p')));
assert_eq!(store.get_task(id).unwrap().state, TaskState::Paused);
press(&mut view, &mut store, key(KeyCode::Char('p')));
assert_eq!(
store.get_task(id).unwrap().state,
TaskState::Queued,
"a task that never launched goes back to waiting its turn"
);
}
#[test]
fn the_footer_says_which_way_p_goes() {
let (mut store, id) = store_with_a_queued_task();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 80, 12);
assert!(view.keys().contains(&("p", "pause")));
store
.transition(id, TaskState::Paused, Transition::Plain, at(1))
.unwrap();
tick_view(&mut view, &mut store);
assert!(view.keys().contains(&("p", "resume")));
}
#[test]
fn pausing_something_that_cannot_be_paused_explains_rather_than_throws() {
let (mut store, id) = store_with_a_queued_task();
store
.transition(id, TaskState::Cancelled, Transition::Plain, at(1))
.unwrap();
let mut view = TasksView::new();
view.shown = Shown::All;
render_view(&mut view, &mut store, 80, 12);
press(&mut view, &mut store, key(KeyCode::Char('p')));
assert_eq!(
store.get_task(id).unwrap().state,
TaskState::Cancelled,
"a finished task stays finished"
);
}
#[test]
fn t_opens_the_selected_tasks_todos() {
let (mut store, id) = store_with_a_queued_task();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 80, 12);
let action = press(&mut view, &mut store, key(KeyCode::Char('t')));
let Action::Push(pushed) = action else {
panic!("t should open the todo screen");
};
assert!(
pushed.title().contains(&format!("task {id}")),
"{}",
pushed.title()
);
}
#[test]
fn t_on_an_empty_board_opens_the_global_todos() {
let mut store = Store::open_in_memory().unwrap();
let mut view = TasksView::new();
render_view(&mut view, &mut store, 80, 12);
let action = press(&mut view, &mut store, key(KeyCode::Char('t')));
let Action::Push(pushed) = action else {
panic!("t should still open a todo screen");
};
assert!(pushed.title().contains("global"), "{}", pushed.title());
}
}