194 lines
6.9 KiB
Rust
194 lines
6.9 KiB
Rust
//! The golden lines of the task parser and the round trip of a task line.
|
||
|
||
use notescraft_tasks::{OnCompletion, Priority, Task, format_line, parse_line};
|
||
use notescraft_templates::Date;
|
||
|
||
/// Longest line the parser accepts, in characters.
|
||
const MAX_LINE_CHARS: usize = 2000;
|
||
|
||
/// The summary of a task, the way the golden fixture writes it.
|
||
fn summary(task: &Task) -> String {
|
||
let mut parts: Vec<String> = Vec::with_capacity(16);
|
||
parts.push(format!("indent={}", task.indent));
|
||
parts.push(format!("marker={}", task.marker));
|
||
parts.push(format!("status={}", status_word(task.status)));
|
||
if task.priority != Priority::Normal {
|
||
parts.push(format!("pri={:?}", task.priority));
|
||
}
|
||
if let Some(date) = task.created {
|
||
parts.push(format!("created={}", date_text(date)));
|
||
}
|
||
if let Some(date) = task.start {
|
||
parts.push(format!("start={}", date_text(date)));
|
||
}
|
||
if let Some(date) = task.scheduled {
|
||
parts.push(format!("scheduled={}", date_text(date)));
|
||
}
|
||
if let Some(date) = task.due {
|
||
parts.push(format!("due={}", date_text(date)));
|
||
}
|
||
if let Some(date) = task.cancelled {
|
||
parts.push(format!("cancelled={}", date_text(date)));
|
||
}
|
||
if let Some(date) = task.done {
|
||
parts.push(format!("done={}", date_text(date)));
|
||
}
|
||
if let Some(rule) = &task.recurrence {
|
||
parts.push(format!("recur={rule}"));
|
||
}
|
||
if task.on_completion == OnCompletion::Delete {
|
||
parts.push("oncomplete=Delete".to_owned());
|
||
}
|
||
if let Some(id) = &task.id {
|
||
parts.push(format!("id={id}"));
|
||
}
|
||
if !task.depends_on.is_empty() {
|
||
parts.push(format!("depends={}", task.depends_on.join(",")));
|
||
}
|
||
if !task.tags.is_empty() {
|
||
parts.push(format!("tags={}", task.tags.join(",")));
|
||
}
|
||
parts.push(format!("desc={}", task.description));
|
||
parts.join(";")
|
||
}
|
||
|
||
/// The status character, spelled out when it is a space.
|
||
fn status_word(status: char) -> String {
|
||
if status == ' ' { "space".to_owned() } else { status.to_string() }
|
||
}
|
||
|
||
/// A date as `YYYY-MM-DD`.
|
||
fn date_text(date: Date) -> String {
|
||
format!("{:04}-{:02}-{:02}", date.year(), date.month(), date.day())
|
||
}
|
||
|
||
#[test]
|
||
fn the_golden_lines_match_the_fixture() {
|
||
let fixture = include_str!("fixtures/lines.txt");
|
||
let mut rows = 0;
|
||
for (number, row) in fixture.lines().enumerate() {
|
||
if row.trim().is_empty() {
|
||
continue;
|
||
}
|
||
let (input, expected) = row.split_once(" ||| ").unwrap_or_else(|| panic!("row {} has no separator", number + 1));
|
||
let actual = parse_line(input).as_ref().map_or_else(|| "none".to_owned(), summary);
|
||
assert_eq!(actual, expected, "row {}: {input}", number + 1);
|
||
rows += 1;
|
||
}
|
||
assert!(rows > 20, "the fixture has only {rows} rows");
|
||
}
|
||
|
||
#[test]
|
||
fn a_line_that_is_not_a_task_is_refused() {
|
||
for line in ["", "Buy milk", "- Buy milk", "* [x", "- [] Buy milk", "text 📅 2026-10-20", "# [ ] Buy milk"] {
|
||
assert!(parse_line(line).is_none(), "{line}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_line_over_the_limit_is_refused() {
|
||
let line = format!("- [ ] {}", "a".repeat(MAX_LINE_CHARS));
|
||
assert!(parse_line(&line).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_line_of_the_limit_is_a_task() {
|
||
let line = format!("- [ ] {}", "a".repeat(MAX_LINE_CHARS - 6));
|
||
let task = parse_line(&line).expect("a task line");
|
||
assert_eq!(task.description.len(), MAX_LINE_CHARS - 6);
|
||
}
|
||
|
||
#[test]
|
||
fn the_status_is_kept_verbatim() {
|
||
let task = parse_line("- [z] Buy milk").expect("a task line");
|
||
assert_eq!(task.status, 'z');
|
||
assert_eq!(task.description, "Buy milk");
|
||
}
|
||
|
||
#[test]
|
||
fn the_dates_are_read() {
|
||
let task = parse_line("- [ ] Buy milk ➕ 2026-01-01 🛫 2026-10-01 ⏳ 2026-10-05 📅 2026-10-20 ✅ 2026-10-19 ❌ 2026-10-21").expect("a task line");
|
||
assert_eq!(task.created, Some(Date::new(2026, 1, 1).unwrap()));
|
||
assert_eq!(task.start, Some(Date::new(2026, 10, 1).unwrap()));
|
||
assert_eq!(task.scheduled, Some(Date::new(2026, 10, 5).unwrap()));
|
||
assert_eq!(task.due, Some(Date::new(2026, 10, 20).unwrap()));
|
||
assert_eq!(task.done, Some(Date::new(2026, 10, 19).unwrap()));
|
||
assert_eq!(task.cancelled, Some(Date::new(2026, 10, 21).unwrap()));
|
||
assert_eq!(task.description, "Buy milk");
|
||
}
|
||
|
||
#[test]
|
||
fn a_date_that_does_not_parse_stays_in_the_description() {
|
||
let task = parse_line("- [ ] Buy milk 📅 2026-13-40").expect("a task line");
|
||
assert_eq!(task.description, "Buy milk 📅 2026-13-40");
|
||
assert_eq!(task.due, None);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unknown_emoji_stays_in_the_description() {
|
||
let task = parse_line("- [ ] Buy milk 🚀 rocket").expect("a task line");
|
||
assert_eq!(task.description, "Buy milk 🚀 rocket");
|
||
assert_eq!(task.tags, Vec::<String>::new());
|
||
}
|
||
|
||
#[test]
|
||
fn the_priority_is_read() {
|
||
for (emoji, priority) in [("🔺", Priority::Highest), ("⏫", Priority::High), ("🔼", Priority::Medium), ("🔽", Priority::Low), ("⏬", Priority::Lowest)]
|
||
{
|
||
let task = parse_line(&format!("- [ ] Buy milk {emoji}")).expect("a task line");
|
||
assert_eq!(task.priority, priority);
|
||
assert_eq!(task.description, "Buy milk");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_tags_are_collected() {
|
||
let task = parse_line("- [ ] Buy #milk and #a/b").expect("a task line");
|
||
assert_eq!(task.tags, vec!["milk".to_owned(), "a/b".to_owned()]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_recurrence_rule_is_kept_whole() {
|
||
let task = parse_line("- [ ] Buy milk 🔁 every week on Tuesday, Friday").expect("a task line");
|
||
assert_eq!(task.recurrence.as_deref(), Some("every week on Tuesday, Friday"));
|
||
assert_eq!(task.description, "Buy milk");
|
||
}
|
||
|
||
#[test]
|
||
fn the_dependencies_are_split() {
|
||
let task = parse_line("- [ ] Buy milk ⛔ abc,def").expect("a task line");
|
||
assert_eq!(task.depends_on, vec!["abc".to_owned(), "def".to_owned()]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_round_trip_keeps_a_canonical_line() {
|
||
for line in [
|
||
"- [ ] Buy milk",
|
||
"- [x] Buy milk",
|
||
"* [ ] Buy milk 📅 2026-10-20",
|
||
" - [ ] Nested",
|
||
"- [ ] Buy milk 🆔 abc123 ⛔ abc,def 🔺 🔁 every week 🏁 delete ➕ 2026-01-01 🛫 2026-10-01 ⏳ 2026-10-05 📅 2026-10-20 ❌ 2026-10-21 ✅ 2026-10-19",
|
||
] {
|
||
let task = parse_line(line).expect("a task line");
|
||
assert_eq!(format_line(&task), line);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_format_uses_the_fixed_order() {
|
||
let mut task = Task { marker: "-".to_owned(), description: "Buy milk".to_owned(), ..Task::default() };
|
||
task.id = Some("abc123".to_owned());
|
||
task.depends_on = vec!["def".to_owned()];
|
||
task.priority = Priority::High;
|
||
task.recurrence = Some("every week".to_owned());
|
||
task.on_completion = OnCompletion::Delete;
|
||
task.due = Some(Date::new(2026, 10, 20).unwrap());
|
||
assert_eq!(format_line(&task), "- [ ] Buy milk 🆔 abc123 ⛔ def ⏫ 🔁 every week 🏁 delete 📅 2026-10-20");
|
||
}
|
||
|
||
#[test]
|
||
fn the_empty_task_is_formatted_without_a_description() {
|
||
let task = Task { marker: "-".to_owned(), ..Task::default() };
|
||
assert_eq!(format_line(&task), "- [ ]");
|
||
}
|