cfait 1.0.4

Powerful, fast and elegant task / TODO manager. (GUI & TUI, CalDAV & local)
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
//! Tests for local duplication bug.
use cfait::context::TestContext;
use cfait::model::{Task, TaskStatus};
use cfait::storage::{LOCAL_CALENDAR_HREF, LocalStorage};
use cfait::store::{FilterOptions, TaskStore};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;

#[test]
fn test_reproduce_android_local_revert_bug() {
    let ctx = Arc::new(TestContext::new());

    // 1. SIMULATE CORRUPTION: Create a local storage file with duplicate UIDs.
    // This is what the old import_from_ics logic did.
    let uid = "corrupted-uid-123";
    let mut t1 = Task::new("Original Summary", &HashMap::new(), None);
    t1.uid = uid.to_string();
    t1.calendar_href = LOCAL_CALENDAR_HREF.to_string();

    let mut t2 = t1.clone();
    t2.summary = "Stale Duplicate".to_string();

    // Manually save a list with duplicates to disk
    let corrupted_list = vec![t1.clone(), t2.clone()];
    LocalStorage::save_for_href(ctx.as_ref(), LOCAL_CALENDAR_HREF, &corrupted_list).unwrap();

    // 2. SIMULATE ANDROID BRIDGE UPDATE:
    // This mimics the logic in CfaitMobile::apply_store_mutation or toggle_task.
    let mut local_list = LocalStorage::load_for_href(ctx.as_ref(), LOCAL_CALENDAR_HREF).unwrap();

    // The bridge finds the task to update.
    // .position() finds the FIRST occurrence (the one with "Original Summary").
    if let Some(idx) = local_list.iter().position(|t| t.uid == uid) {
        local_list[idx].summary = "Updated via Bridge".to_string();
        local_list[idx].status = TaskStatus::Completed;

        // Save the list back to disk.
        // The file now contains:
        // [0] UID:123 Summary:"Updated via Bridge"
        // [1] UID:123 Summary:"Stale Duplicate"
        LocalStorage::save_for_href(ctx.as_ref(), LOCAL_CALENDAR_HREF, &local_list).unwrap();
    }

    // 3. SIMULATE UI RELOAD:
    // This mimics how the app starts up or refreshes.
    let mut store = TaskStore::new(ctx.clone());
    let reloaded_list = LocalStorage::load_for_href(ctx.as_ref(), LOCAL_CALENDAR_HREF).unwrap();
    store.insert(LOCAL_CALENDAR_HREF.to_string(), reloaded_list);

    // Filter to get the tasks as the UI would see them
    let filter_res = store.filter(FilterOptions {
        active_cal_href: None,
        hidden_calendars: &HashSet::new(),
        selected_categories: &HashSet::new(),
        selected_locations: &HashSet::new(),
        match_all_categories: false,
        search_term: "",
        hide_completed_global: false, // Don't hide so we can check
        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,
        // Newly required fields in FilterOptions:
        sort_standard_by_priority: false,
        expanded_done_groups: &HashSet::new(),
        expanded_tags: &HashSet::new(),
        expanded_locations: &HashSet::new(),

        max_done_roots: usize::MAX,
        max_done_subtasks: usize::MAX,
        tag_aliases: &HashMap::new(),
    });

    let visible_task = filter_res
        .items
        .iter()
        .find(|t| {
            if let cfait::store::TaskListItem::Task(task) = t {
                task.uid == uid
            } else {
                false
            }
        })
        .expect("Task should exist");

    // VERIFICATION:
    // If the bug is present:
    // The TaskStore (HashMap) took the LAST entry from the file ("Stale Duplicate").
    // The UI shows "Stale Duplicate" and Status::NeedsAction.
    // The user's change ("Updated via Bridge") is effectively hidden/reverted.

    if let cfait::store::TaskListItem::Task(task) = visible_task {
        assert_eq!(
            task.summary, "Updated via Bridge",
            "BUG: Task summary was reverted to the stale duplicate!"
        );
    } else {
        panic!("Expected Task variant");
    }
    if let cfait::store::TaskListItem::Task(task) = visible_task {
        assert!(
            task.status == TaskStatus::Completed,
            "BUG: Task status was reverted to the stale duplicate!"
        );
    } else {
        panic!("Expected Task variant");
    }
}

#[test]
fn test_cross_calendar_deduplication() {
    let ctx = Arc::new(TestContext::new());
    let mut store = TaskStore::new(ctx.clone());

    let uid = "split-brain-uid";

    // Simulate an older ghost task left behind in cal1
    let mut t_old = Task::new("Old Task", &HashMap::new(), None);
    t_old.uid = uid.to_string();
    t_old.calendar_href = "cal1".to_string();
    t_old.sequence = 1;

    // Simulate the newer, correct task living in cal2
    let mut t_new = Task::new("New Task", &HashMap::new(), None);
    t_new.uid = uid.to_string();
    t_new.calendar_href = "cal2".to_string();
    t_new.sequence = 2;

    // The startup flow inserts collections sequentially
    store.insert("cal1".to_string(), vec![t_old.clone()]);

    // During this insert, the store should detect `split-brain-uid` already exists in `cal1`
    // It will compare sequences, see `t_new` is newer, and evict the ghost from `cal1`.
    store.insert("cal2".to_string(), vec![t_new.clone()]);

    // Verify ghost was removed
    assert!(
        store.calendars.get("cal1").unwrap().is_empty(),
        "Ghost task should have been evicted from the incorrect collection"
    );

    // Verify the correct one was retained
    let retained_task = store.calendars.get("cal2").unwrap().get(uid).unwrap();
    assert_eq!(retained_task.summary, "New Task");
    assert_eq!(retained_task.calendar_href, "cal2");

    // Verify the index is consistent
    assert_eq!(
        store.index.get(uid).unwrap(),
        "cal2",
        "Index should point to the correct active collection"
    );
}