generated from eblume/project-template
feat(tui): heph-tui T3 — single-line NL quick-add (§8.1)
`a` is now Todoist-style one-line capture: parse a line like `Water plants tomorrow p2 #Camano Chores every 3 days` into title + attention (p1 red / p2 orange / p3 blue / p4 white) + do-date (today/tomorrow/+3d/fri/ISO) + recurrence (`every …`, longest suffix that parses) + project (`#Name`, greedy multi-word match against existing projects). An unresolved `#tag` stays in the title verbatim (no surprise project creation); with no `#project`, the task is filed under the selected sidebar project. The parser (`quickadd::parse`) is pure — `today` and the project list are passed in — reusing hephd::datespec for dates/recurrence, so it's exhaustively unit-tested (priority, relative/weekday dates, single + multi-word projects, recurrence extraction, unresolved tags, the all-at-once case, and the "every"-not-a-recurrence fallback). `Backend::create_task` gained a recurrence arg. The multi-step guided add it replaces is gone. 181 workspace tests; clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2e0e37f76d
commit
b4624af021
6 changed files with 570 additions and 6 deletions
|
|
@ -3,10 +3,36 @@
|
|||
//! lives in [`crate::ui`]; the terminal/event loop in `main.rs`.
|
||||
|
||||
use anyhow::Result;
|
||||
use heph_core::{Attention, RankedTask, BUILTIN_VIEWS};
|
||||
use heph_core::{Attention, RankedTask, SchedulePatch, BUILTIN_VIEWS};
|
||||
|
||||
use crate::backend::{Backend, Project};
|
||||
|
||||
/// The interaction mode: normal navigation, or collecting a line of text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Normal,
|
||||
Input(InputState),
|
||||
}
|
||||
|
||||
/// A single-line text prompt overlay (guided add / reschedule). `prompt` labels
|
||||
/// it; `buffer` is what the user has typed; `kind` says what submit does (and,
|
||||
/// for the multi-step add, carries the fields collected so far).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InputState {
|
||||
pub prompt: String,
|
||||
pub buffer: String,
|
||||
kind: InputKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum InputKind {
|
||||
/// Single-line natural-language capture (parsed by [`crate::quickadd`]).
|
||||
QuickAdd,
|
||||
Reschedule {
|
||||
task_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
|
||||
/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression.
|
||||
pub fn next_attention(current: Option<Attention>) -> Attention {
|
||||
|
|
@ -66,6 +92,7 @@ pub struct App<B: Backend> {
|
|||
pub task_cursor: usize,
|
||||
pub preview: Preview,
|
||||
pub focus: Focus,
|
||||
pub mode: Mode,
|
||||
pub status: String,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
|
@ -101,6 +128,7 @@ impl<B: Backend> App<B> {
|
|||
task_cursor: 0,
|
||||
preview: Preview::default(),
|
||||
focus: Focus::Sidebar,
|
||||
mode: Mode::Normal,
|
||||
status: String::new(),
|
||||
should_quit: false,
|
||||
};
|
||||
|
|
@ -302,4 +330,160 @@ impl<B: Backend> App<B> {
|
|||
b.set_attention(&t.node_id, Attention::Blue)
|
||||
});
|
||||
}
|
||||
|
||||
// --- input modal (T2c: guided add + reschedule) ---
|
||||
|
||||
fn current_project_id(&self) -> Option<String> {
|
||||
match self.sidebar.get(self.sidebar_cursor) {
|
||||
Some(SidebarEntry::Project { id, .. }) => Some(id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The projects known to the sidebar (for quick-add `#project` resolution).
|
||||
fn project_list(&self) -> Vec<Project> {
|
||||
self.sidebar
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
SidebarEntry::Project { id, title } => Some(Project {
|
||||
id: id.clone(),
|
||||
title: title.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Start single-line natural-language capture. If no `#project` is given and
|
||||
/// a project is the current sidebar selection, the task is filed there.
|
||||
pub fn begin_add(&mut self) {
|
||||
self.mode = Mode::Input(InputState {
|
||||
prompt: "Add (e.g. Buy milk tomorrow p2 #Work every week)".into(),
|
||||
buffer: String::new(),
|
||||
kind: InputKind::QuickAdd,
|
||||
});
|
||||
}
|
||||
|
||||
/// Start rescheduling the highlighted task's do-date (blank = clear it).
|
||||
pub fn begin_reschedule(&mut self) {
|
||||
let Some(t) = self.selected_task() else {
|
||||
return;
|
||||
};
|
||||
self.mode = Mode::Input(InputState {
|
||||
prompt: format!("Do-date for \"{}\" (blank clears)", t.title),
|
||||
buffer: String::new(),
|
||||
kind: InputKind::Reschedule {
|
||||
task_id: t.node_id.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Append a typed character to the active input.
|
||||
pub fn input_push(&mut self, c: char) {
|
||||
if let Mode::Input(state) = &mut self.mode {
|
||||
state.buffer.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the last character of the active input.
|
||||
pub fn input_backspace(&mut self) {
|
||||
if let Mode::Input(state) = &mut self.mode {
|
||||
state.buffer.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Abandon the active input.
|
||||
pub fn input_cancel(&mut self) {
|
||||
self.mode = Mode::Normal;
|
||||
self.status = "cancelled".into();
|
||||
}
|
||||
|
||||
/// Re-enter an input step (used to keep state after a parse error).
|
||||
fn reenter(&mut self, prompt: String, buffer: String, kind: InputKind) {
|
||||
self.mode = Mode::Input(InputState {
|
||||
prompt,
|
||||
buffer,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
/// Commit the active input — advancing the add flow, capturing the task, or
|
||||
/// applying the reschedule. Parse errors keep the step (so typed text isn't
|
||||
/// lost) and show the error in the status line.
|
||||
pub fn input_submit(&mut self) {
|
||||
let Mode::Input(state) = std::mem::replace(&mut self.mode, Mode::Normal) else {
|
||||
return;
|
||||
};
|
||||
let buf = state.buffer.trim().to_string();
|
||||
match state.kind {
|
||||
InputKind::QuickAdd => {
|
||||
if buf.is_empty() {
|
||||
self.status = "add cancelled".into();
|
||||
return;
|
||||
}
|
||||
let projects = self.project_list();
|
||||
let parsed = crate::quickadd::parse(&buf, crate::fmt::today_local(), &projects);
|
||||
if parsed.title.is_empty() {
|
||||
self.status = "add cancelled (no title)".into();
|
||||
return;
|
||||
}
|
||||
// An explicit #project wins; otherwise file under the selected one.
|
||||
let project = parsed.project_id.or_else(|| self.current_project_id());
|
||||
match self.backend.create_task(
|
||||
&parsed.title,
|
||||
parsed.attention,
|
||||
parsed.do_date,
|
||||
parsed.recurrence.as_deref(),
|
||||
project.as_deref(),
|
||||
) {
|
||||
Ok(_) => {
|
||||
self.status = format!("added: {}", parsed.title);
|
||||
self.reload();
|
||||
}
|
||||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
InputKind::Reschedule { task_id } => {
|
||||
let patch = if buf.is_empty() {
|
||||
SchedulePatch {
|
||||
do_date: Some(None), // clear
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
match parse_optional_date(&buf) {
|
||||
Ok(ms) => SchedulePatch {
|
||||
do_date: Some(ms),
|
||||
..Default::default()
|
||||
},
|
||||
Err(e) => {
|
||||
self.status = format!("error: {e}");
|
||||
self.reenter(
|
||||
"Do-date (blank clears)".into(),
|
||||
buf,
|
||||
InputKind::Reschedule { task_id },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
match self.backend.set_schedule(&task_id, patch) {
|
||||
Ok(()) => {
|
||||
self.status = "rescheduled".into();
|
||||
self.reload();
|
||||
}
|
||||
Err(e) => self.status = format!("error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a do-date input to `Some(epoch_ms)`, or `None` when blank.
|
||||
fn parse_optional_date(s: &str) -> Result<Option<i64>> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(hephd::datespec::parse_date_ms(s)?))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub trait Backend {
|
|||
title: &str,
|
||||
attention: Option<Attention>,
|
||||
do_date: Option<i64>,
|
||||
recurrence: Option<&str>,
|
||||
project_id: Option<&str>,
|
||||
) -> Result<String>;
|
||||
}
|
||||
|
|
@ -142,6 +143,7 @@ impl Backend for ClientBackend {
|
|||
title: &str,
|
||||
attention: Option<Attention>,
|
||||
do_date: Option<i64>,
|
||||
recurrence: Option<&str>,
|
||||
project_id: Option<&str>,
|
||||
) -> Result<String> {
|
||||
let v = self.call(
|
||||
|
|
@ -150,6 +152,7 @@ impl Backend for ClientBackend {
|
|||
"title": title,
|
||||
"attention": attention,
|
||||
"do_date": do_date,
|
||||
"recurrence": recurrence,
|
||||
"project_id": project_id,
|
||||
}),
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ pub mod app;
|
|||
pub mod backend;
|
||||
pub mod editor;
|
||||
pub mod fmt;
|
||||
pub mod quickadd;
|
||||
pub mod ui;
|
||||
|
||||
pub use app::{App, Focus};
|
||||
|
|
|
|||
223
crates/heph-tui/src/quickadd.rs
Normal file
223
crates/heph-tui/src/quickadd.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style
|
||||
//! capture: `Water plants tomorrow p2 #Chores every 3 days`.
|
||||
//!
|
||||
//! Pure and deterministic: `today` and the known projects are passed in, so the
|
||||
//! whole parser is unit-testable. Recognized inline tokens are extracted and the
|
||||
//! remainder is the title (order preserved). The recognized forms mirror the
|
||||
//! owner's Todoist usage ([[design]] §6.2.1):
|
||||
//!
|
||||
//! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white).
|
||||
//! - **Project** `#Name` — resolved against existing projects, greedily matching
|
||||
//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the
|
||||
//! title verbatim (no surprise project creation).
|
||||
//! - **Do-date** a `datespec` token: `today`/`tomorrow`/`+3d`/`fri`/ISO.
|
||||
//! - **Recurrence** an `every …` phrase (the longest suffix that parses), e.g.
|
||||
//! `every 3 days`, `every workday`, `every other wed`.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use heph_core::Attention;
|
||||
|
||||
use crate::backend::Project;
|
||||
|
||||
/// The structured result of parsing a quick-add line.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Parsed {
|
||||
pub title: String,
|
||||
pub attention: Option<Attention>,
|
||||
pub do_date: Option<i64>,
|
||||
/// An RFC-5545 RRULE, if a recurrence phrase was recognized.
|
||||
pub recurrence: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
}
|
||||
|
||||
fn priority_attention(token: &str) -> Option<Attention> {
|
||||
match token.to_ascii_lowercase().as_str() {
|
||||
"p1" => Some(Attention::Red),
|
||||
"p2" => Some(Attention::Orange),
|
||||
"p3" => Some(Attention::Blue),
|
||||
"p4" => Some(Attention::White),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a quick-add line against `today` and the `projects` known to the store.
|
||||
pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed {
|
||||
let mut tokens: Vec<String> = input.split_whitespace().map(str::to_string).collect();
|
||||
let mut out = Parsed::default();
|
||||
|
||||
extract_recurrence(&mut tokens, &mut out);
|
||||
|
||||
let mut title: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
let tok = &tokens[i];
|
||||
|
||||
if let Some(a) = priority_attention(tok) {
|
||||
out.attention = Some(a);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(stripped) = tok.strip_prefix('#') {
|
||||
if let Some((id, consumed)) = match_project(stripped, &tokens[i + 1..], projects) {
|
||||
out.project_id = Some(id);
|
||||
i += 1 + consumed;
|
||||
continue;
|
||||
}
|
||||
// Unresolved #tag: keep the word (with the #) in the title.
|
||||
}
|
||||
|
||||
if out.do_date.is_none() {
|
||||
if let Ok(date) = hephd::datespec::parse_date(tok, today) {
|
||||
out.do_date = Some(hephd::datespec::to_epoch_ms(date));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
title.push(tok.clone());
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out.title = title.join(" ");
|
||||
out
|
||||
}
|
||||
|
||||
/// Find the first `every` token and consume the longest suffix starting there
|
||||
/// that `datespec::parse_recurrence` accepts, recording its RRULE.
|
||||
fn extract_recurrence(tokens: &mut Vec<String>, out: &mut Parsed) {
|
||||
let Some(start) = tokens.iter().position(|t| t.eq_ignore_ascii_case("every")) else {
|
||||
return;
|
||||
};
|
||||
for end in (start + 1..=tokens.len()).rev() {
|
||||
let phrase = tokens[start..end].join(" ");
|
||||
if let Ok(rrule) = hephd::datespec::parse_recurrence(&phrase) {
|
||||
out.recurrence = Some(rrule);
|
||||
tokens.drain(start..end);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Greedily match `first` (+ following words) against a known project title,
|
||||
/// case-insensitively, longest-first. Returns `(project_id, extra_words_taken)`.
|
||||
fn match_project(first: &str, rest: &[String], projects: &[Project]) -> Option<(String, usize)> {
|
||||
// Try the longest candidate (up to 4 trailing words) down to just `first`.
|
||||
let max_extra = rest.len().min(4);
|
||||
for extra in (0..=max_extra).rev() {
|
||||
let mut candidate = first.to_string();
|
||||
for w in &rest[..extra] {
|
||||
candidate.push(' ');
|
||||
candidate.push_str(w);
|
||||
}
|
||||
if let Some(p) = projects
|
||||
.iter()
|
||||
.find(|p| p.title.eq_ignore_ascii_case(&candidate))
|
||||
{
|
||||
return Some((p.id.clone(), extra));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 6, 3).unwrap()
|
||||
}
|
||||
|
||||
fn projects() -> Vec<Project> {
|
||||
vec![
|
||||
Project {
|
||||
id: "work".into(),
|
||||
title: "Work".into(),
|
||||
},
|
||||
Project {
|
||||
id: "camano".into(),
|
||||
title: "Camano Chores".into(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn p(input: &str) -> Parsed {
|
||||
parse(input, today(), &projects())
|
||||
}
|
||||
|
||||
fn ms(y: i32, m: u32, d: u32) -> i64 {
|
||||
hephd::datespec::to_epoch_ms(NaiveDate::from_ymd_opt(y, m, d).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_title() {
|
||||
let r = p("Buy milk");
|
||||
assert_eq!(r.title, "Buy milk");
|
||||
assert_eq!(r.attention, None);
|
||||
assert_eq!(r.do_date, None);
|
||||
assert_eq!(r.recurrence, None);
|
||||
assert_eq!(r.project_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_maps_to_attention() {
|
||||
assert_eq!(p("Email boss p1").attention, Some(Attention::Red));
|
||||
assert_eq!(p("Email boss p2").attention, Some(Attention::Orange));
|
||||
assert_eq!(p("Email boss p3").attention, Some(Attention::Blue));
|
||||
assert_eq!(p("Email boss p4").attention, Some(Attention::White));
|
||||
assert_eq!(p("Email boss p1").title, "Email boss");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_date_is_extracted() {
|
||||
let r = p("Call dentist tomorrow");
|
||||
assert_eq!(r.title, "Call dentist");
|
||||
assert_eq!(r.do_date, Some(ms(2026, 6, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_word_project_resolves() {
|
||||
let r = p("Standup #Work");
|
||||
assert_eq!(r.title, "Standup");
|
||||
assert_eq!(r.project_id.as_deref(), Some("work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_word_project_resolves_greedily() {
|
||||
let r = p("Sweep deck #Camano Chores");
|
||||
assert_eq!(r.title, "Sweep deck");
|
||||
assert_eq!(r.project_id.as_deref(), Some("camano"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_tag_stays_in_title() {
|
||||
let r = p("Buy #groceries milk");
|
||||
assert_eq!(r.title, "Buy #groceries milk");
|
||||
assert_eq!(r.project_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurrence_phrase_is_extracted() {
|
||||
let r = p("Water plants every 3 days");
|
||||
assert_eq!(r.title, "Water plants");
|
||||
assert_eq!(r.recurrence.as_deref(), Some("FREQ=DAILY;INTERVAL=3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_at_once() {
|
||||
let r = p("Plan trip p2 friday #Work every week");
|
||||
assert_eq!(r.title, "Plan trip");
|
||||
assert_eq!(r.attention, Some(Attention::Orange));
|
||||
assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday
|
||||
assert_eq!(r.project_id.as_deref(), Some("work"));
|
||||
assert_eq!(r.recurrence.as_deref(), Some("FREQ=WEEKLY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_recurrence_every_stays_in_title() {
|
||||
// "every report" isn't a recurrence; leave it alone.
|
||||
let r = p("Review every report");
|
||||
assert_eq!(r.title, "Review every report");
|
||||
assert_eq!(r.recurrence, None);
|
||||
}
|
||||
}
|
||||
|
|
@ -153,6 +153,51 @@ fn completing_a_task_removes_it_from_top_of_mind() {
|
|||
assert!(screen(&app).contains("nothing here"));
|
||||
}
|
||||
|
||||
fn type_and_submit<B: heph_tui::Backend>(app: &mut App<B>, s: &str) {
|
||||
for ch in s.chars() {
|
||||
app.input_push(ch);
|
||||
}
|
||||
app.input_submit();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_add_captures_a_task_that_appears_in_the_view() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
||||
assert!(app.tasks.is_empty());
|
||||
|
||||
app.begin_add();
|
||||
// Single-line NL: p1 → red, so it lands in Top of Mind (the default view).
|
||||
type_and_submit(&mut app, "Call the plumber p1");
|
||||
|
||||
assert!(app.status.contains("added"), "status: {}", app.status);
|
||||
assert!(
|
||||
app.tasks.iter().any(|t| t.title == "Call the plumber"),
|
||||
"added task missing from Top of Mind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_sets_a_do_date_on_the_task() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
let mut c = client(&socket);
|
||||
let task = c
|
||||
.call(
|
||||
"task.create",
|
||||
json!({ "title": "Mail the form", "attention": "red" }),
|
||||
)
|
||||
.unwrap();
|
||||
let id = task["node_id"].as_str().unwrap().to_string();
|
||||
|
||||
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
|
||||
app.begin_reschedule();
|
||||
type_and_submit(&mut app, "today");
|
||||
|
||||
// Verify directly via the daemon (the view may drop it depending on clocks).
|
||||
let got = c.call("task.get", json!({ "id": id })).unwrap();
|
||||
assert!(!got["do_date"].is_null(), "do_date was not set: {got}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_to_blue_moves_a_task_out_of_top_of_mind() {
|
||||
let (socket, _dir) = spawn_daemon();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! Navigation/selection logic against an in-memory fake backend — no terminal,
|
||||
//! no daemon. Asserts the App's cursor + reload behavior (tech-spec §8.1).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::Result;
|
||||
use heph_core::{Attention, ListFilter, RankedTask, SchedulePatch, TaskState};
|
||||
|
|
@ -10,6 +12,23 @@ use heph_tui::{
|
|||
backend::{Backend, Project},
|
||||
};
|
||||
|
||||
/// A recorded `create_task`: (title, attention, do_date, recurrence, project_id).
|
||||
type CreatedTask = (
|
||||
String,
|
||||
Option<Attention>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
/// Records mutations the App makes, so tests can assert on them after the App
|
||||
/// has consumed the backend.
|
||||
#[derive(Default)]
|
||||
struct Recorder {
|
||||
created: Vec<CreatedTask>,
|
||||
scheduled: Vec<(String, SchedulePatch)>,
|
||||
}
|
||||
|
||||
fn task(id: &str, title: &str, attention: Attention, ctx: Option<&str>) -> RankedTask {
|
||||
RankedTask {
|
||||
node_id: id.into(),
|
||||
|
|
@ -31,6 +50,7 @@ struct Fake {
|
|||
projects: Vec<Project>,
|
||||
by_project: HashMap<String, Vec<RankedTask>>,
|
||||
bodies: HashMap<String, String>,
|
||||
rec: Rc<RefCell<Recorder>>,
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
|
|
@ -59,16 +79,25 @@ impl Backend for Fake {
|
|||
fn set_attention(&mut self, _t: &str, _a: Attention) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn set_schedule(&mut self, _t: &str, _p: SchedulePatch) -> Result<()> {
|
||||
fn set_schedule(&mut self, t: &str, p: SchedulePatch) -> Result<()> {
|
||||
self.rec.borrow_mut().scheduled.push((t.into(), p));
|
||||
Ok(())
|
||||
}
|
||||
fn create_task(
|
||||
&mut self,
|
||||
_title: &str,
|
||||
_a: Option<Attention>,
|
||||
_d: Option<i64>,
|
||||
_p: Option<&str>,
|
||||
title: &str,
|
||||
a: Option<Attention>,
|
||||
d: Option<i64>,
|
||||
recur: Option<&str>,
|
||||
p: Option<&str>,
|
||||
) -> Result<String> {
|
||||
self.rec.borrow_mut().created.push((
|
||||
title.into(),
|
||||
a,
|
||||
d,
|
||||
recur.map(str::to_string),
|
||||
p.map(str::to_string),
|
||||
));
|
||||
Ok("new".into())
|
||||
}
|
||||
}
|
||||
|
|
@ -157,3 +186,82 @@ fn attention_cycles_white_orange_red_blue() {
|
|||
assert_eq!(next_attention(Some(Attention::Blue)), Attention::White);
|
||||
assert_eq!(next_attention(None), Attention::White);
|
||||
}
|
||||
|
||||
fn type_and_submit<B: Backend>(app: &mut App<B>, s: &str) {
|
||||
for c in s.chars() {
|
||||
app.input_push(c);
|
||||
}
|
||||
app.input_submit();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_add_files_under_the_current_project_when_no_tag_given() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
// Select the project so the new task is filed there.
|
||||
for _ in 0..5 {
|
||||
app.move_sidebar(1);
|
||||
}
|
||||
assert_eq!(app.task_pane_title(), "Camano");
|
||||
|
||||
app.begin_add();
|
||||
type_and_submit(&mut app, "Fix the dock p2");
|
||||
|
||||
let created = &rec.borrow().created;
|
||||
assert_eq!(created.len(), 1);
|
||||
assert_eq!(created[0].0, "Fix the dock");
|
||||
assert_eq!(created[0].1, Some(Attention::Orange)); // p2
|
||||
assert_eq!(created[0].2, None); // no do-date
|
||||
assert_eq!(created[0].3, None); // no recurrence
|
||||
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_add_passes_inline_recurrence_and_project_through() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
app.begin_add();
|
||||
// #Camano resolves to the fixture project id "p1"; "every week" → weekly.
|
||||
type_and_submit(&mut app, "Water the ferns #Camano every week");
|
||||
|
||||
let created = &rec.borrow().created;
|
||||
assert_eq!(created.len(), 1);
|
||||
assert_eq!(created[0].0, "Water the ferns");
|
||||
assert_eq!(created[0].3.as_deref(), Some("FREQ=WEEKLY"));
|
||||
assert_eq!(created[0].4.as_deref(), Some("p1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_title_cancels_the_add() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
app.begin_add();
|
||||
type_and_submit(&mut app, ""); // empty title aborts
|
||||
assert!(rec.borrow().created.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_with_blank_clears_the_do_date() {
|
||||
let rec = Rc::new(RefCell::new(Recorder::default()));
|
||||
let mut fake = fixture();
|
||||
fake.rec = rec.clone();
|
||||
let mut app = App::new(fake).unwrap();
|
||||
|
||||
app.begin_reschedule(); // on the first ToM task (t1)
|
||||
type_and_submit(&mut app, ""); // blank => clear
|
||||
|
||||
let scheduled = &rec.borrow().scheduled;
|
||||
assert_eq!(scheduled.len(), 1);
|
||||
assert_eq!(scheduled[0].0, "t1");
|
||||
// do_date present-and-null = "clear" (the double-option).
|
||||
assert_eq!(scheduled[0].1.do_date, Some(None));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue