hephaestus/crates/heph-core/tests/recurrence.rs
Erich Blume d0debfceb9
Some checks failed
Build / validate (pull_request) Failing after 4s
heph-core: recurrence (roll-forward in place) + per-task logs
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>
2026-05-31 19:14:22 -07:00

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);
}