generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 4s
Slice 5 (tech-spec §4.4). Completing a recurring task rolls it forward in place instead of marking it done — the Todoist-corner-avoiding model. Pure recurrence module: - next_occurrence(rrule, anchor, after): lazy RRULE expansion (rrule + chrono/UTC) returning the next instance strictly after `after`, skipping missed occurrences; None when a finite series is exhausted. - reset_checkboxes(body): the fresh-checklist transform — unchecks every `- [x]`, idempotent, preserves indentation/bullet/line-endings. Storage roll-forward (one transaction, on set_state(done) of a recurring task): reset the canonical context doc's checklist, append the completed occurrence to the task's log, advance do_date to the next instance after now (skipping misses); finite series finally goes done. `skip` advances the same way without logging. Non-recurring done is unchanged. Per-task append-only log (`log-of` doc): log_append / log_tail — the resumption breadcrumb + recurring-completion narrative ([[design]] §6.4). Tests: 7 recurrence unit + 2 proptests (no checked marker survives reset; reset idempotent for any body) + 6 end-to-end incl. five-occurrence no-carry-forward and missed-collapse-to-one. 53 tests green. This completes the heph-core library layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
175 lines
5.4 KiB
Rust
175 lines
5.4 KiB
Rust
//! End-to-end recurrence: completing a recurring task rolls it forward in
|
|
//! place — fresh checklist, logged occurrence, advanced do-date, never
|
|
//! carrying completion forward (tech-spec §4.4, slice 5).
|
|
|
|
use std::sync::atomic::{AtomicI64, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use heph_core::{Clock, LinkType, LocalStore, NewTask, Store, TaskState};
|
|
|
|
const JAN1: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
|
|
const ONE_DAY: i64 = 86_400_000;
|
|
|
|
/// A clock the test can advance between completions. Cloneable so the test
|
|
/// keeps a handle while the store owns its own `Box<dyn Clock>`.
|
|
#[derive(Clone)]
|
|
struct StepClock(Arc<AtomicI64>);
|
|
|
|
impl StepClock {
|
|
fn new(ms: i64) -> Self {
|
|
StepClock(Arc::new(AtomicI64::new(ms)))
|
|
}
|
|
fn set(&self, ms: i64) {
|
|
self.0.store(ms, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
impl Clock for StepClock {
|
|
fn now_ms(&self) -> i64 {
|
|
self.0.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
fn store_at(ms: i64) -> (LocalStore, StepClock) {
|
|
let clock = StepClock::new(ms);
|
|
let store = LocalStore::open_in_memory(Box::new(clock.clone())).unwrap();
|
|
(store, clock)
|
|
}
|
|
|
|
fn canonical_doc_id(s: &LocalStore, task_id: &str) -> String {
|
|
s.outgoing_links(task_id)
|
|
.unwrap()
|
|
.into_iter()
|
|
.find(|l| l.link_type == LinkType::CanonicalContext)
|
|
.unwrap()
|
|
.dst_id
|
|
}
|
|
|
|
#[test]
|
|
fn completing_a_recurring_task_rolls_forward_with_a_fresh_checklist() {
|
|
let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2);
|
|
|
|
let task = s
|
|
.create_task(NewTask {
|
|
title: "Morning routine".into(),
|
|
do_date: Some(JAN1),
|
|
recurrence: Some("FREQ=DAILY".into()),
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
let doc_id = canonical_doc_id(&s, &task.node_id);
|
|
|
|
// Put a checklist in the context doc and check items off.
|
|
s.update_node(
|
|
&doc_id,
|
|
None,
|
|
Some("- [x] brush teeth\n- [x] feed birds\n- [ ] coffee\n".into()),
|
|
)
|
|
.unwrap();
|
|
|
|
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
|
|
|
// It stays outstanding and the do-date advanced to the next day.
|
|
assert_eq!(rolled.state, TaskState::Outstanding);
|
|
assert_eq!(rolled.do_date, Some(JAN1 + ONE_DAY));
|
|
|
|
// The checklist is fresh — every item unchecked. Completion did NOT carry.
|
|
let doc = s.get_node(&doc_id).unwrap().unwrap();
|
|
assert_eq!(
|
|
doc.body.as_deref(),
|
|
Some("- [ ] brush teeth\n- [ ] feed birds\n- [ ] coffee\n")
|
|
);
|
|
|
|
// The completion was logged (the narrative breadcrumb).
|
|
let log = s.log_tail(&task.node_id, 10).unwrap();
|
|
assert_eq!(log.len(), 1);
|
|
assert!(log[0].contains("completed occurrence"));
|
|
}
|
|
|
|
#[test]
|
|
fn missed_occurrences_collapse_to_one_not_a_pile() {
|
|
// Complete 3.5 days late → next instance is day 4, not a backlog of 4.
|
|
let (mut s, _clock) = store_at(JAN1 + 3 * ONE_DAY + ONE_DAY / 2);
|
|
|
|
let task = s
|
|
.create_task(NewTask {
|
|
title: "Water plants".into(),
|
|
do_date: Some(JAN1),
|
|
recurrence: Some("FREQ=DAILY".into()),
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
|
|
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
|
assert_eq!(rolled.do_date, Some(JAN1 + 4 * ONE_DAY));
|
|
assert_eq!(rolled.state, TaskState::Outstanding);
|
|
}
|
|
|
|
#[test]
|
|
fn completion_never_carries_forward_across_many_occurrences() {
|
|
let (mut s, clock) = store_at(JAN1 + ONE_DAY / 2);
|
|
|
|
let task = s
|
|
.create_task(NewTask {
|
|
title: "Daily standup notes".into(),
|
|
do_date: Some(JAN1),
|
|
recurrence: Some("FREQ=DAILY".into()),
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
let doc_id = canonical_doc_id(&s, &task.node_id);
|
|
|
|
for occurrence in 0..5 {
|
|
// Check everything off this occurrence.
|
|
s.update_node(&doc_id, None, Some("- [x] note A\n- [x] note B\n".into()))
|
|
.unwrap();
|
|
let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
|
assert_eq!(rolled.state, TaskState::Outstanding);
|
|
|
|
// Fresh checklist after every single completion.
|
|
let doc = s.get_node(&doc_id).unwrap().unwrap();
|
|
assert_eq!(
|
|
doc.body.as_deref(),
|
|
Some("- [ ] note A\n- [ ] note B\n"),
|
|
"occurrence {occurrence} did not present a fresh checklist"
|
|
);
|
|
|
|
// Advance "now" past the new do-date for the next loop.
|
|
clock.set(rolled.do_date.unwrap() + ONE_DAY / 2);
|
|
}
|
|
|
|
// Five completions → five log entries.
|
|
assert_eq!(s.log_tail(&task.node_id, 100).unwrap().len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn skip_advances_without_logging() {
|
|
let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2);
|
|
|
|
let task = s
|
|
.create_task(NewTask {
|
|
title: "Optional chore".into(),
|
|
do_date: Some(JAN1),
|
|
recurrence: Some("FREQ=DAILY".into()),
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
|
|
let skipped = s.skip_recurrence(&task.node_id).unwrap();
|
|
assert_eq!(skipped.do_date, Some(JAN1 + ONE_DAY));
|
|
assert_eq!(skipped.state, TaskState::Outstanding);
|
|
assert!(s.log_tail(&task.node_id, 10).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn non_recurring_task_done_is_just_done() {
|
|
let (mut s, _clock) = store_at(JAN1);
|
|
let task = s
|
|
.create_task(NewTask {
|
|
title: "One-off".into(),
|
|
..Default::default()
|
|
})
|
|
.unwrap();
|
|
let t = s.set_task_state(&task.node_id, TaskState::Done).unwrap();
|
|
assert_eq!(t.state, TaskState::Done);
|
|
}
|