hephaestus/crates/heph-core/tests/views.rs
Erich Blume a5fc578525
Some checks failed
Build / validate (pull_request) Failing after 18m44s
feat(views): filter views (§8.2) — saved agenda slices
Make the owner's saved filters first-class so the agenda isn't one flat
list. `list` now takes a ListFilter predicate-as-data (heph-core::filter):
attention include/exclude sets, project-id scope, exclude_projects, and an
actionable do-date gate. New Store::view(name) resolves a built-in ViewSpec
— looking project names up to ids and subtree-expanding them through parent
links — then lists.

Five built-ins seeded from the Todoist queries (design §6.2.1): tom, ondeck,
chores, work, tasks (Schedule dropped — time-of-day isn't modeled on
date-grained do-dates). Surfaced as `heph view <name>` (no name lists them),
the `view` RPC + RemoteStore forward, and `:Heph view <name>` in nvim.

The list RPC/RemoteStore/CLI/heph.nvim migrate to the filter wire; legacy
--scope/--attention/--no-blue map onto it (nvim view.lua updated).

Tests: filter unit predicate, a views integration suite (subtree
scope+exclude, actionable gate, unknown-view error, absent-project empties),
a socket list/view dispatch test, two nvim e2e specs. 154 Rust tests + 18
nvim e2e green; clippy/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:39:07 -07:00

161 lines
4.5 KiB
Rust

//! Filter views — the five built-in saved agenda slices (tech-spec §8.2).
//!
//! Builds a store mirroring the owner's project shape (Work + a Work subproject,
//! Chores / Camano Chores, the routine projects) with tasks across the attention
//! bands, then asserts each `view` returns exactly the right slice — including
//! project-subtree scope/exclude and the `actionable` do-date gate.
use heph_core::{Attention, FixedClock, LinkType, LocalStore, NewNode, NewTask, NodeKind, Store};
const NOW: i64 = 1_700_000_000_000;
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(NOW))).unwrap()
}
fn project(s: &mut LocalStore, title: &str) -> String {
s.create_node(NewNode {
kind: NodeKind::Project,
title: title.into(),
body: None,
})
.unwrap()
.id
}
/// Capture a task with an attention, optional project, and optional do_date.
fn task(
s: &mut LocalStore,
title: &str,
attention: Attention,
project_id: Option<&str>,
do_date: Option<i64>,
) -> String {
s.create_task(NewTask {
title: title.into(),
attention: Some(attention),
project_id: project_id.map(str::to_string),
do_date,
..Default::default()
})
.unwrap()
.node_id
}
fn titles(rows: &[heph_core::RankedTask]) -> Vec<String> {
let mut t: Vec<String> = rows.iter().map(|r| r.title.clone()).collect();
t.sort();
t
}
/// A store with the full project shape and one task per interesting case.
fn seeded() -> LocalStore {
let mut s = store();
let work = project(&mut s, "Work");
let work_sub = project(&mut s, "Work Sub");
s.add_link(&work_sub, &work, LinkType::Parent).unwrap(); // child → parent
let chores = project(&mut s, "Chores");
let camano = project(&mut s, "Camano Chores");
let work_routine = project(&mut s, "Work Routine");
project(&mut s, "Daily Routine");
task(&mut s, "red", Attention::Red, None, None);
task(&mut s, "orange", Attention::Orange, None, None);
task(&mut s, "white", Attention::White, None, None);
task(&mut s, "blue", Attention::Blue, None, None);
task(&mut s, "work task", Attention::White, Some(&work), None);
task(
&mut s,
"work sub task",
Attention::White,
Some(&work_sub),
None,
);
task(&mut s, "chore", Attention::White, Some(&chores), None);
task(
&mut s,
"camano chore",
Attention::Orange,
Some(&camano),
None,
);
task(
&mut s,
"routine",
Attention::White,
Some(&work_routine),
None,
);
// A red task whose do_date is in the future — excluded by the actionable gate.
task(
&mut s,
"future red",
Attention::Red,
None,
Some(NOW + 86_400_000),
);
s
}
#[test]
fn top_of_mind_is_red_and_orange_and_actionable() {
let s = seeded();
// Every red/orange task across all projects (the orange Camano chore counts
// too — ToM has no project scope), but NOT the future-dated red (actionable
// gate).
assert_eq!(
titles(&s.view("tom").unwrap()),
vec!["camano chore", "orange", "red"]
);
}
#[test]
fn on_deck_is_blue_only() {
let s = seeded();
assert_eq!(titles(&s.view("ondeck").unwrap()), vec!["blue"]);
}
#[test]
fn chores_scopes_to_the_two_chore_projects() {
let s = seeded();
assert_eq!(
titles(&s.view("chores").unwrap()),
vec!["camano chore", "chore"]
);
}
#[test]
fn work_scopes_to_the_work_subtree() {
let s = seeded();
// Both the direct Work task and the Work-Sub task (subtree expansion).
assert_eq!(
titles(&s.view("work").unwrap()),
vec!["work sub task", "work task"]
);
}
#[test]
fn tasks_excludes_routine_chore_and_work_subtree_projects() {
let s = seeded();
// Non-blue, actionable, and not in any excluded project (incl. the Work
// subtree) → just the three project-less, present-dated tasks.
assert_eq!(
titles(&s.view("tasks").unwrap()),
vec!["orange", "red", "white"]
);
}
#[test]
fn unknown_view_is_an_error() {
let s = store();
assert!(s.view("nope").is_err());
}
#[test]
fn scoped_view_is_empty_when_its_projects_are_absent() {
// A store with no Chores/Camano projects: the chores view scopes to nothing,
// and must return empty rather than widening to "any project".
let mut s = store();
task(&mut s, "loose", Attention::White, None, None);
assert!(s.view("chores").unwrap().is_empty());
}