generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Has been cancelled
Add a list-by-kind primitive so projects (and later tags) can be enumerated.
- core: Store::list_nodes(kind?) — owner-scoped, non-tombstoned, title-sorted;
sqlite nodes::list; LocalStore/RemoteStore impls
- rpc: node.list {kind?} dispatch
- cli: `heph project list`
- tests: core list_nodes (kind filter, case-insensitive sort, tombstone
exclusion) + cli project_list (projects only, not tasks)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
251 lines
7.3 KiB
Rust
251 lines
7.3 KiB
Rust
//! CLI tests (tech-spec §9): run the real `heph` binary against a real `hephd`
|
|
//! over a unix socket, and assert output + side effects (export files).
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use tokio::net::UnixListener;
|
|
|
|
use heph_core::{FixedClock, LocalStore};
|
|
use hephd::Daemon;
|
|
|
|
const NOW: i64 = 1_704_067_200_000;
|
|
|
|
/// Spawn a daemon on its own thread+runtime; return (socket, tempdir).
|
|
fn spawn_daemon() -> (PathBuf, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db = dir.path().join("heph.db");
|
|
let socket = dir.path().join("d.sock");
|
|
|
|
let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap();
|
|
let socket_for_thread = socket.clone();
|
|
thread::spawn(move || {
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap();
|
|
rt.block_on(async move {
|
|
let listener = UnixListener::bind(&socket_for_thread).unwrap();
|
|
let _ = Daemon::new(store).serve(listener).await;
|
|
});
|
|
});
|
|
for _ in 0..200 {
|
|
if socket.exists() {
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(5));
|
|
}
|
|
(socket, dir)
|
|
}
|
|
|
|
/// Run the `heph` binary against `socket`; return (stdout, success).
|
|
fn heph(socket: &std::path::Path, args: &[&str]) -> (String, bool) {
|
|
let out = Command::new(env!("CARGO_BIN_EXE_heph"))
|
|
.arg("--socket")
|
|
.arg(socket)
|
|
.args(args)
|
|
.output()
|
|
.expect("run heph");
|
|
(
|
|
String::from_utf8_lossy(&out.stdout).into_owned(),
|
|
out.status.success(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn task_then_next_shows_the_task() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
|
|
let (out, ok) = heph(&socket, &["task", "Buy milk", "--attention", "red"]);
|
|
assert!(ok, "task create failed: {out}");
|
|
assert!(out.contains("Created task"), "{out}");
|
|
|
|
let (out, ok) = heph(&socket, &["next"]);
|
|
assert!(ok);
|
|
assert!(out.contains("[red]"), "{out}");
|
|
assert!(out.contains("Buy milk"), "{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn next_on_empty_store_is_friendly() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
let (out, ok) = heph(&socket, &["next"]);
|
|
assert!(ok);
|
|
assert!(out.contains("Nothing actionable"), "{out}");
|
|
}
|
|
|
|
/// The first whitespace token of a "Created task <id> …" line.
|
|
fn created_id(out: &str) -> String {
|
|
out.split_whitespace()
|
|
.nth(2)
|
|
.expect("id in output")
|
|
.to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn task_with_iso_do_date_shows_formatted_date_in_next() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
// A do-date in the past (relative to the daemon's fixed 2024-01-01 clock)
|
|
// so it is actionable and appears in `next`.
|
|
let (out, ok) = heph(
|
|
&socket,
|
|
&[
|
|
"task",
|
|
"Renew passport",
|
|
"-a",
|
|
"orange",
|
|
"--do-date",
|
|
"2023-12-25",
|
|
],
|
|
);
|
|
assert!(ok, "{out}");
|
|
|
|
let (out, ok) = heph(&socket, &["next"]);
|
|
assert!(ok);
|
|
assert!(out.contains("Renew passport"), "{out}");
|
|
// Within 2023 vs the 2024 clock → full YYYY-MM-DD form.
|
|
assert!(
|
|
out.contains("do:2023-12-25"),
|
|
"expected formatted do-date: {out}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn edit_reschedules_and_clears_fields() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
let (out, _) = heph(
|
|
&socket,
|
|
&[
|
|
"task",
|
|
"Taxes",
|
|
"--do-date",
|
|
"2023-01-01",
|
|
"--recur",
|
|
"yearly",
|
|
],
|
|
);
|
|
let id = created_id(&out);
|
|
|
|
// Reschedule do-date and clear recurrence.
|
|
let (out, ok) = heph(
|
|
&socket,
|
|
&["edit", &id, "--do-date", "2023-06-01", "--recur", "none"],
|
|
);
|
|
assert!(ok, "{out}");
|
|
|
|
let (out, ok) = heph(&socket, &["show", &id]);
|
|
assert!(ok, "{out}");
|
|
assert!(
|
|
out.contains("\"recurrence\": null"),
|
|
"recurrence cleared: {out}"
|
|
);
|
|
assert!(out.contains("\"do_date\""), "{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn list_and_done_and_health() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
let (out, _) = heph(&socket, &["task", "Mow lawn", "-a", "red"]);
|
|
let id = created_id(&out);
|
|
|
|
let (out, ok) = heph(&socket, &["list"]);
|
|
assert!(ok, "{out}");
|
|
assert!(out.contains("Mow lawn"), "{out}");
|
|
|
|
let (out, ok) = heph(&socket, &["health"]);
|
|
assert!(ok, "{out}");
|
|
assert!(out.contains("active"), "{out}");
|
|
|
|
let (out, ok) = heph(&socket, &["done", &id]);
|
|
assert!(ok, "{out}");
|
|
assert!(out.contains("done"), "{out}");
|
|
|
|
// Done tasks drop out of `next`.
|
|
let (out, ok) = heph(&socket, &["next"]);
|
|
assert!(ok);
|
|
assert!(
|
|
!out.contains("Mow lawn"),
|
|
"completed task should be gone: {out}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recur_preset_makes_a_recurring_task() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
let (out, _) = heph(&socket, &["task", "Standup", "--recur", "weekdays"]);
|
|
let id = created_id(&out);
|
|
let (out, ok) = heph(&socket, &["show", &id]);
|
|
assert!(ok, "{out}");
|
|
assert!(out.contains("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"), "{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn project_add_then_file_a_task_under_it() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
let (out, ok) = heph(&socket, &["project", "add", "Maintenance"]);
|
|
assert!(ok, "{out}");
|
|
|
|
// A task can be filed under the project by name.
|
|
let (out, ok) = heph(
|
|
&socket,
|
|
&["task", "Replace filter", "--project", "Maintenance"],
|
|
);
|
|
assert!(ok, "{out}");
|
|
|
|
// An unknown project name is a clear error, not a silent miss.
|
|
let (out, ok) = heph(&socket, &["task", "Whatever", "--project", "Nonexistent"]);
|
|
assert!(!ok, "expected failure for unknown project: {out}");
|
|
}
|
|
|
|
#[test]
|
|
fn project_list_shows_projects_only() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
heph(&socket, &["project", "add", "Maintenance"]);
|
|
heph(&socket, &["project", "add", "Coding"]);
|
|
// A task (and its context doc) must not show up in the project list.
|
|
heph(&socket, &["task", "Some task"]);
|
|
|
|
let (out, ok) = heph(&socket, &["project", "list"]);
|
|
assert!(ok, "{out}");
|
|
assert!(out.contains("Maintenance"), "{out}");
|
|
assert!(out.contains("Coding"), "{out}");
|
|
assert!(!out.contains("Some task"), "tasks are not projects: {out}");
|
|
}
|
|
|
|
#[test]
|
|
fn log_append_then_tail() {
|
|
let (socket, _dir) = spawn_daemon();
|
|
let (out, _) = heph(&socket, &["task", "Investigate bug"]);
|
|
let id = created_id(&out);
|
|
|
|
let (_, ok) = heph(
|
|
&socket,
|
|
&["log", &id, "looked", "at", "the", "stack", "trace"],
|
|
);
|
|
assert!(ok);
|
|
let (out, ok) = heph(&socket, &["log", &id]);
|
|
assert!(ok, "{out}");
|
|
assert!(out.contains("looked at the stack trace"), "{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn export_writes_markdown_files() {
|
|
let (socket, dir) = spawn_daemon();
|
|
heph(&socket, &["doc", "Roof log", "--body", "# Roof"]);
|
|
|
|
let export_dir = dir.path().join("export");
|
|
let (out, ok) = heph(&socket, &["export", export_dir.to_str().unwrap()]);
|
|
assert!(ok, "export failed: {out}");
|
|
assert!(out.contains("Exported 1 nodes"), "{out}");
|
|
|
|
// The doc landed as a .md file under doc/.
|
|
let docs: Vec<_> = std::fs::read_dir(export_dir.join("doc"))
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.collect();
|
|
assert_eq!(docs.len(), 1);
|
|
let text = std::fs::read_to_string(docs[0].path()).unwrap();
|
|
assert!(text.contains("title: \"Roof log\""), "{text}");
|
|
}
|