Files
Marcos Paulo 22b43c1254
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: task-line parser, Templater segmenter, icon/banner property resolver (MiniMax M3.1 Flash)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 07:03:19 -03:00

194 lines
6.9 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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), "- [ ]");
}