use cfait::config::{PausedSortBehavior, SortPreset};
use cfait::context::TestContext;
use cfait::model::{Task, TaskStatus};
use cfait::store::{FilterOptions, TaskStore};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
fn make_store() -> TaskStore {
let ctx = Arc::new(TestContext::new());
TaskStore::new(ctx)
}
#[test]
fn test_filter_by_tag() {
let mut store = make_store();
let mut t1 = Task::new("Work Task #work", &HashMap::new(), None);
t1.uid = "1".to_string();
t1.calendar_href = "cal1".to_string();
let mut t2 = Task::new("Home Task #home", &HashMap::new(), None);
t2.uid = "2".to_string();
t2.calendar_href = "cal1".to_string();
store.add_task(t1);
store.add_task(t2);
let mut cats = HashSet::new();
cats.insert("work".to_string());
let empty_set = HashSet::new();
let filter_res = store.filter(FilterOptions {
active_cal_href: None,
hidden_calendars: &empty_set,
selected_categories: &cats,
selected_locations: &empty_set,
match_all_categories: false,
search_term: "",
hide_completed_global: false,
hide_fully_completed_tags: false,
hide_aliases_in_sidebar: false,
cutoff_date: None,
min_duration: None,
max_duration: None,
include_unset_duration: true,
urgent_days: 1,
urgent_prio: 1,
default_priority: 5,
start_grace_period_days: 1,
sort_standard_by_priority: false,
sort_preset: SortPreset::default(),
expanded_done_groups: &empty_set,
expanded_tags: &empty_set,
expanded_locations: &empty_set,
max_done_roots: usize::MAX,
max_done_subtasks: usize::MAX,
tag_aliases: &HashMap::new(),
search_collapsed_tasks: &HashSet::new(),
focused_task_uid: None,
paused_sort_behavior: PausedSortBehavior::default(),
sort_tiebreak_recent: false,
});
let results = filter_res.items;
assert_eq!(results.len(), 1);
if let cfait::store::TaskListItem::Task(task) = &results[0] {
assert_eq!(task.summary, "Work Task");
} else {
panic!("Expected Task variant");
}
}
#[test]
fn test_filter_hierarchical_tags() {
let mut store = make_store();
let mut t1 = Task::new("Backend #dev:backend", &HashMap::new(), None);
t1.uid = "1".to_string();
t1.calendar_href = "cal1".to_string();
store.add_task(t1);
let mut cats = HashSet::new();
cats.insert("dev".to_string());
let empty_set = HashSet::new();
let filter_res = store.filter(FilterOptions {
active_cal_href: None,
hidden_calendars: &empty_set,
selected_categories: &cats,
selected_locations: &empty_set,
match_all_categories: false,
search_term: "",
hide_completed_global: false,
hide_fully_completed_tags: false,
hide_aliases_in_sidebar: false,
cutoff_date: None,
min_duration: None,
max_duration: None,
include_unset_duration: true,
urgent_days: 1,
urgent_prio: 1,
default_priority: 5,
start_grace_period_days: 1,
sort_standard_by_priority: false,
sort_preset: SortPreset::default(),
expanded_done_groups: &empty_set,
expanded_tags: &empty_set,
expanded_locations: &empty_set,
max_done_roots: usize::MAX,
max_done_subtasks: usize::MAX,
tag_aliases: &HashMap::new(),
search_collapsed_tasks: &HashSet::new(),
focused_task_uid: None,
paused_sort_behavior: PausedSortBehavior::default(),
sort_tiebreak_recent: false,
});
let results = filter_res.items;
assert_eq!(results.len(), 1);
if let cfait::store::TaskListItem::Task(task) = &results[0] {
assert_eq!(task.summary, "Backend");
} else {
panic!("Expected Task variant");
}
}
#[test]
fn test_hide_hidden_calendars() {
let mut store = make_store();
let mut t1 = Task::new("Cal 1 Task", &HashMap::new(), None);
t1.uid = "1".to_string();
t1.calendar_href = "cal1".to_string();
let mut t2 = Task::new("Cal 2 Task", &HashMap::new(), None);
t2.uid = "2".to_string();
t2.calendar_href = "cal2".to_string();
store.add_task(t1);
store.add_task(t2);
let mut hidden = HashSet::new();
hidden.insert("cal2".to_string());
let empty_set = HashSet::new();
let filter_res = store.filter(FilterOptions {
active_cal_href: None,
hidden_calendars: &hidden,
selected_categories: &empty_set,
selected_locations: &empty_set,
match_all_categories: false,
search_term: "",
hide_completed_global: false,
hide_fully_completed_tags: false,
hide_aliases_in_sidebar: false,
cutoff_date: None,
min_duration: None,
max_duration: None,
include_unset_duration: true,
urgent_days: 1,
urgent_prio: 1,
default_priority: 5,
start_grace_period_days: 1,
sort_standard_by_priority: false,
sort_preset: SortPreset::default(),
expanded_done_groups: &empty_set,
expanded_tags: &empty_set,
expanded_locations: &empty_set,
max_done_roots: usize::MAX,
max_done_subtasks: usize::MAX,
tag_aliases: &HashMap::new(),
search_collapsed_tasks: &HashSet::new(),
focused_task_uid: None,
paused_sort_behavior: PausedSortBehavior::default(),
sort_tiebreak_recent: false,
});
let results = filter_res.items;
assert_eq!(results.len(), 1);
if let cfait::store::TaskListItem::Task(task) = &results[0] {
assert_eq!(task.summary, "Cal 1 Task");
} else {
panic!("Expected Task variant");
}
}
#[test]
fn test_toggle_task_shift_advances_from_today() {
let mut store = make_store();
let mut t = Task::new("Overdue Weekly", &HashMap::new(), None);
t.uid = "shift_test".to_string();
t.calendar_href = "cal1".to_string();
let now = chrono::Utc::now();
let ten_days_ago = now - chrono::Duration::days(10);
t.dtstart = Some(cfait::model::DateType::Specific(ten_days_ago));
t.due = Some(cfait::model::DateType::Specific(ten_days_ago));
t.rrule = Some("FREQ=WEEKLY".to_string());
t.status = TaskStatus::NeedsAction;
store.add_task(t);
let res = store.toggle_task_shift("shift_test");
assert!(res.is_some());
let (history, secondary, _children) = res.unwrap();
assert_eq!(history.status, TaskStatus::Completed);
let advanced = secondary.expect("Expected advanced task");
let next_due = match advanced.due.unwrap() {
cfait::model::DateType::Specific(dt) => dt,
_ => panic!("Expected specific date"),
};
let diff = next_due - now;
assert!(
diff.num_days() >= 6 && diff.num_days() <= 7,
"Task should be shifted to next week from today"
);
}
#[test]
fn test_set_status_cancelled_advances_recurring_task() {
let mut store = make_store();
let mut t = Task::new("Daily Task", &HashMap::new(), None);
t.uid = "recurring-1".to_string();
t.calendar_href = "cal1".to_string();
let original_due = chrono::Utc::now() - chrono::Duration::days(1);
t.due = Some(cfait::model::DateType::Specific(original_due));
t.rrule = Some("FREQ=DAILY".to_string());
t.status = TaskStatus::NeedsAction;
store.add_task(t);
let updated = store.set_status("recurring-1", TaskStatus::Cancelled, false);
assert!(updated.is_some());
let (history, secondary, _children) = updated.unwrap();
let recycled_task = secondary.expect("Recurring task should recycle into a secondary task");
assert_eq!(recycled_task.status, TaskStatus::NeedsAction);
assert_eq!(recycled_task.exdates.len(), 1);
assert_eq!(
recycled_task.exdates[0],
cfait::model::DateType::Specific(original_due)
);
assert_eq!(history.status, TaskStatus::Cancelled);
assert!(history.rrule.is_none());
}
#[test]
fn test_set_status_cancelled_non_recurring_task() {
let mut store = make_store();
let mut t = Task::new("One-time Task", &HashMap::new(), None);
t.uid = "one-time-1".to_string();
t.calendar_href = "cal1".to_string();
t.status = TaskStatus::NeedsAction;
store.add_task(t);
let updated = store.set_status("one-time-1", TaskStatus::Cancelled, false);
assert!(updated.is_some());
let (task, _secondary, _children) = updated.unwrap();
assert_eq!(task.status, TaskStatus::Cancelled);
assert!(task.rrule.is_none());
}
#[test]
fn test_toggle_status_cancelled_back_to_needs_action() {
let mut store = make_store();
let mut t = Task::new("Task", &HashMap::new(), None);
t.uid = "toggle-1".to_string();
t.calendar_href = "cal1".to_string();
t.status = TaskStatus::NeedsAction;
store.add_task(t);
let updated = store.set_status("toggle-1", TaskStatus::Cancelled, false);
assert!(updated.is_some());
let (primary, _sec, _children) = updated.unwrap();
assert_eq!(primary.status, TaskStatus::Cancelled);
let updated = store.set_status("toggle-1", TaskStatus::Cancelled, false);
assert!(updated.is_some());
let (primary, _sec, _children) = updated.unwrap();
assert_eq!(primary.status, TaskStatus::NeedsAction);
}
#[test]
fn test_taskstore_clear_and_remove() {
let ctx = Arc::new(TestContext::new());
let mut store = TaskStore::new(ctx);
let mut task = Task::new("Test", &HashMap::new(), None);
task.uid = "123".to_string();
task.calendar_href = "cal1".to_string();
store.add_task(task);
assert!(store.has_any_tasks());
store.remove("cal1");
assert!(!store.has_any_tasks());
let mut task2 = Task::new("Test2", &HashMap::new(), None);
task2.uid = "456".to_string();
task2.calendar_href = "cal1".to_string();
store.add_task(task2);
assert!(store.has_any_tasks());
store.clear();
assert!(!store.has_any_tasks());
}
#[test]
fn test_get_descendant_uids() {
let ctx = Arc::new(TestContext::new());
let mut store = TaskStore::new(ctx);
let mut parent = Task::new("Parent", &HashMap::new(), None);
parent.uid = "p1".to_string();
store.add_task(parent);
let mut child = Task::new("Child", &HashMap::new(), None);
child.uid = "c1".to_string();
child.parent_uid = Some("p1".to_string());
store.add_task(child);
let mut grandchild = Task::new("Grandchild", &HashMap::new(), None);
grandchild.uid = "g1".to_string();
grandchild.parent_uid = Some("c1".to_string());
store.add_task(grandchild);
let descendants = store.get_descendant_uids("p1");
assert_eq!(descendants.len(), 2);
assert!(descendants.contains(&"c1".to_string()));
assert!(descendants.contains(&"g1".to_string()));
}
#[test]
fn test_task_time_tracking_intents() {
let ctx = Arc::new(TestContext::new());
let mut store = TaskStore::new(ctx);
let config = cfait::config::Config::default();
let mut task = Task::new("Test", &HashMap::new(), None);
task.uid = "123".to_string();
store.add_task(task);
let intent_start = cfait::model::AppIntent::StartTask {
uid: "123".to_string(),
};
store.apply_task_intent(&intent_start, &config);
let t = store.get_task_ref("123").unwrap();
assert_eq!(t.status, TaskStatus::InProcess);
assert!(t.last_started_at.is_some());
let intent_pause = cfait::model::AppIntent::PauseTask {
uid: "123".to_string(),
};
store.apply_task_intent(&intent_pause, &config);
let t2 = store.get_task_ref("123").unwrap();
assert_eq!(t2.status, TaskStatus::NeedsAction);
assert!(t2.last_started_at.is_none());
store.apply_task_intent(&intent_start, &config);
let intent_stop = cfait::model::AppIntent::StopTask {
uid: "123".to_string(),
};
store.apply_task_intent(&intent_stop, &config);
let t3 = store.get_task_ref("123").unwrap();
assert_eq!(t3.status, TaskStatus::NeedsAction);
assert!(t3.last_started_at.is_none());
}
#[test]
fn test_apply_task_intent_comprehensive() {
let ctx = Arc::new(TestContext::new());
let mut store = TaskStore::new(ctx);
let config = cfait::config::Config {
trash_retention_days: 0,
..Default::default()
};
let mut t1 = Task::new("T1", &HashMap::new(), None);
t1.uid = "1".to_string();
t1.calendar_href = "cal1".to_string();
store.add_task(t1);
let mut t2 = Task::new("T2", &HashMap::new(), None);
t2.uid = "2".to_string();
t2.calendar_href = "cal1".to_string();
store.add_task(t2);
store.apply_task_intent(
&cfait::model::AppIntent::MakeChild {
uid: "2".to_string(),
parent_uid: "1".to_string(),
},
&config,
);
assert_eq!(
store.get_task_ref("2").unwrap().parent_uid,
Some("1".to_string())
);
store.apply_task_intent(
&cfait::model::AppIntent::RemoveParent {
uid: "2".to_string(),
},
&config,
);
assert_eq!(store.get_task_ref("2").unwrap().parent_uid, None);
store.apply_task_intent(
&cfait::model::AppIntent::AddDependency {
uid: "1".to_string(),
blocker_uid: "2".to_string(),
},
&config,
);
assert!(
store
.get_task_ref("1")
.unwrap()
.dependencies
.contains(&"2".to_string())
);
store.apply_task_intent(
&cfait::model::AppIntent::RemoveDependency {
uid: "1".to_string(),
blocker_uid: "2".to_string(),
},
&config,
);
assert!(
!store
.get_task_ref("1")
.unwrap()
.dependencies
.contains(&"2".to_string())
);
store.apply_task_intent(
&cfait::model::AppIntent::AddRelatedTo {
uid: "1".to_string(),
related_uid: "2".to_string(),
},
&config,
);
assert!(
store
.get_task_ref("1")
.unwrap()
.related_to
.contains(&"2".to_string())
);
store.apply_task_intent(
&cfait::model::AppIntent::RemoveRelatedTo {
uid: "1".to_string(),
related_uid: "2".to_string(),
},
&config,
);
assert!(
!store
.get_task_ref("1")
.unwrap()
.related_to
.contains(&"2".to_string())
);
store.apply_task_intent(
&cfait::model::AppIntent::ChangePriority {
uid: "1".to_string(),
delta: 1,
},
&config,
);
store.apply_task_intent(
&cfait::model::AppIntent::MakeChild {
uid: "2".to_string(),
parent_uid: "1".to_string(),
},
&config,
);
store.apply_task_intent(
&cfait::model::AppIntent::ToggleTreeCollapse {
uid: "1".to_string(),
},
&config,
);
assert!(store.get_task_ref("1").unwrap().collapsed);
store.apply_task_intent(
&cfait::model::AppIntent::MoveTask {
uid: "1".to_string(),
target_href: "cal2".to_string(),
},
&config,
);
assert_eq!(store.get_task_ref("1").unwrap().calendar_href, "cal2");
store.apply_task_intent(
&cfait::model::AppIntent::CancelTask {
uid: "2".to_string(),
},
&config,
);
assert_eq!(
store.get_task_ref("2").unwrap().status,
TaskStatus::Cancelled
);
store.apply_task_intent(
&cfait::model::AppIntent::DuplicateTaskTree {
uid: "1".to_string(),
},
&config,
);
store.apply_task_intent(
&cfait::model::AppIntent::DeleteTask {
uid: "1".to_string(),
},
&config,
);
assert!(store.get_task_ref("1").is_none());
let mut t3 = Task::new("T3", &HashMap::new(), None);
t3.uid = "3".to_string();
store.add_task(t3);
store.apply_task_intent(
&cfait::model::AppIntent::DeleteTaskTree {
uid: "3".to_string(),
},
&config,
);
assert!(store.get_task_ref("3").is_none());
}
#[test]
fn test_delete_task_tree_removes_descendants() {
let mut store = make_store();
let config = cfait::config::Config {
trash_retention_days: 0,
..Default::default()
};
let mut parent = Task::new("Parent", &HashMap::new(), None);
parent.uid = "parent".to_string();
parent.calendar_href = "cal1".to_string();
let mut child = Task::new("Child", &HashMap::new(), None);
child.uid = "child".to_string();
child.calendar_href = "cal1".to_string();
child.parent_uid = Some("parent".to_string());
let mut grandchild = Task::new("Grandchild", &HashMap::new(), None);
grandchild.uid = "grandchild".to_string();
grandchild.calendar_href = "cal1".to_string();
grandchild.parent_uid = Some("child".to_string());
store.add_task(parent);
store.add_task(child);
store.add_task(grandchild);
assert!(store.children_index.contains_key("parent"));
assert!(store.children_index.contains_key("child"));
store.apply_task_intent(
&cfait::model::AppIntent::DeleteTaskTree {
uid: "parent".to_string(),
},
&config,
);
assert!(store.get_task_ref("parent").is_none());
assert!(store.get_task_ref("child").is_none());
assert!(store.get_task_ref("grandchild").is_none());
}
#[test]
fn test_extract_markdown_tasks_full() {
let input = "Root description.\n\n- [ ] Subtask 1\n Details for subtask 1\n* [x] Subtask 2\n1. [ ] Numbered 1\n2. [ ] Numbered 2\n";
let (root_desc, tasks) = cfait::model::extract_markdown_tasks(input, false);
assert_eq!(root_desc, "Root description.");
assert_eq!(tasks.len(), 4);
assert_eq!(tasks[0].raw_text, "Subtask 1");
assert_eq!(tasks[0].status, cfait::model::TaskStatus::NeedsAction);
assert_eq!(tasks[0].description, "Details for subtask 1");
assert_eq!(tasks[1].raw_text, "Subtask 2");
assert_eq!(tasks[1].status, cfait::model::TaskStatus::Completed);
assert_eq!(tasks[1].description, "");
assert_eq!(tasks[2].raw_text, "Numbered 1");
assert_eq!(tasks[3].raw_text, "Numbered 2");
assert_eq!(tasks[3].dependencies.len(), 1);
}
#[test]
fn test_task_display_logic() {
let mut task = Task::new("Test", &HashMap::new(), None);
assert_eq!(task.checkbox_symbol(), "[ ]");
assert!(!task.is_paused());
assert_eq!(task.format_duration_short(None), "");
task.status = TaskStatus::InProcess;
assert_eq!(task.checkbox_symbol(), "[â–¶]");
task.status = TaskStatus::Completed;
assert_eq!(task.checkbox_symbol(), "[✔]");
task.status = TaskStatus::Cancelled;
assert_eq!(task.checkbox_symbol(), "[✘]");
task.status = TaskStatus::NeedsAction;
task.time_spent_seconds = 3600;
assert!(task.is_paused());
assert_eq!(task.checkbox_symbol(), "[‖]");
assert!(task.format_duration_short(None).contains("1h"));
}
#[test]
fn test_color_utils_hex_parsing() {
let floats = cfait::color_utils::parse_hex_to_floats("#FF0000").unwrap();
assert_eq!(floats, (1.0, 0.0, 0.0));
let u8s = cfait::color_utils::parse_hex_to_u8("00FF00").unwrap();
assert_eq!(u8s, (0, 255, 0));
assert!(cfait::color_utils::parse_hex_to_floats("invalid").is_none());
let color = cfait::color_utils::generate_color("test_tag");
assert!(color.0 >= 0.0 && color.0 <= 1.0);
assert!(color.1 >= 0.0 && color.1 <= 1.0);
assert!(color.2 >= 0.0 && color.2 <= 1.0);
let _dark = cfait::color_utils::is_dark(color.0, color.1, color.2);
}
#[test]
fn test_session_state_intents() {
let mut session = cfait::model::SessionState::default();
session.apply_session_intent(&cfait::model::AppIntent::SetSearchTerm {
term: "test".to_string(),
});
assert_eq!(session.search_term, "test");
session.apply_session_intent(&cfait::model::AppIntent::ToggleTagFilter {
tag: "work".to_string(),
});
assert!(session.selected_categories.contains(&"work".to_string()));
session.apply_session_intent(&cfait::model::AppIntent::ToggleTagFilter {
tag: "work".to_string(),
});
assert!(!session.selected_categories.contains(&"work".to_string()));
session.apply_session_intent(&cfait::model::AppIntent::ToggleLocationFilter {
location: "home".to_string(),
});
assert!(session.selected_locations.contains(&"home".to_string()));
session.apply_session_intent(&cfait::model::AppIntent::ToggleMatchAllCategories);
assert!(session.match_all_categories);
session.apply_session_intent(&cfait::model::AppIntent::ClearFilters);
assert!(session.search_term.is_empty());
assert!(session.selected_categories.is_empty());
assert!(session.selected_locations.is_empty());
session.apply_session_intent(&cfait::model::AppIntent::ToggleDoneGroup {
key: "group".to_string(),
});
assert!(session.expanded_done_groups.contains(&"group".to_string()));
}
#[test]
fn test_alarm_index_empty_and_default() {
let ctx = Arc::new(TestContext::new());
let idx = cfait::alarm_index::AlarmIndex::load(ctx.as_ref());
assert!(idx.is_empty());
assert_eq!(idx.len(), 0);
let next = idx.get_next_alarm_timestamp();
assert!(next.is_none());
idx.save(ctx.as_ref()).unwrap();
let idx2 = cfait::alarm_index::AlarmIndex::load(ctx.as_ref());
assert!(idx2.is_empty());
}
#[test]
fn test_config_errors_and_save() {
let ctx = Arc::new(TestContext::new());
let err = cfait::config::Config::load(ctx.as_ref()).unwrap_err();
assert!(cfait::config::Config::is_missing_config_error(&err));
let config = cfait::config::Config {
url: "http://example.com".to_string(),
..Default::default()
};
config.save_with_credentials(ctx.as_ref()).unwrap();
let loaded = cfait::config::Config::load(ctx.as_ref()).unwrap();
assert_eq!(loaded.url, "http://example.com");
let loaded_cred = cfait::config::Config::load_with_credentials(ctx.as_ref()).unwrap();
assert_eq!(loaded_cred.url, "http://example.com");
assert_eq!(
cfait::config::LogLevel::Debug.to_level_filter(),
log::LevelFilter::Debug
);
assert!(!cfait::config::TaskAction::OpenUrl.label().is_empty());
}
#[test]
fn test_system_logging_and_keyring() {
let ctx = Arc::new(TestContext::new());
cfait::system::init_logging(
ctx.as_ref(),
false,
Some(cfait::config::LogLevel::Debug.to_level_filter()),
);
cfait::system::set_log_level(log::LevelFilter::Trace);
cfait::system::init_keyring();
}
#[tokio::test]
async fn test_companion_events_batching() {
let ctx = Arc::new(TestContext::new());
let mut server = mockito::Server::new_async().await;
let url = server.url();
let client = cfait::client::RustyClient::new(ctx.clone(), &url, "u", "p", true, None).unwrap();
let mut task1 = Task::new("T1", &HashMap::new(), None);
task1.uid = "t1".to_string();
task1.calendar_href = format!("{}/cal/", url);
task1.href = format!("{}/cal/t1.ics", url);
let _mock_report_empty = server
.mock("REPORT", "/cal/")
.with_status(207)
.with_body(r#"<d:multistatus xmlns:d="DAV:"></d:multistatus>"#)
.create_async()
.await;
let count = client
.delete_all_companion_events(&task1.calendar_href)
.await
.unwrap();
assert_eq!(count, 0);
let _mock_report_legacy = server
.mock("REPORT", "/cal/")
.with_status(207)
.with_body(r#"<d:multistatus xmlns:d="DAV:"><d:response><d:href>/cal/evt-t1-reminder.ics</d:href><d:propstat><d:prop><d:getetag>"abc123"</d:getetag></d:prop></d:propstat></d:response><d:response><d:href>/cal/evt-t1-deadline.ics</d:href><d:propstat><d:prop><d:getetag>"abc123"</d:getetag></d:prop></d:propstat></d:response><d:response><d:href>/cal/evt-t1-session.ics</d:href><d:propstat><d:prop><d:getetag>"abc123"</d:getetag></d:prop></d:propstat></d:response></d:multistatus>"#)
.create_async()
.await;
let count = client
.sync_multiple_companion_events(&[task1], true, false)
.await
.unwrap();
assert_eq!(count, 3); }