hephaestus/crates/heph-tui/tests/agenda.rs
Erich Blume 11aa25c9f4
All checks were successful
Build / validate (pull_request) Successful in 6m11s
feat(heph-tui,hephd): surface sync health (last-sync age, conflicts, auth failure)
A spoke could be silently failing to sync (expired token → 401, or hub
unreachable) with the only signal buried in the daemon log. Now:

- hephd tracks SyncHealth (last attempt/success time, last error, auth-failure
  flag) from the background sync loop and sync.now, classifying a 401 as an auth
  failure. sync.status returns it plus the pending merge-conflict count.
- heph-tui shows a live status-line indicator (spoke only): '⟳ <age>' since the
  last good sync, red '⚠ auth' when re-login is needed, '⚠ offline' when the hub
  is unreachable, and '⚠ N conflicts' when conflicts are pending. The event loop
  polls on a 2s tick so the age advances and failures appear while idle.
- docs: recommended Authentik access/refresh token validity to stop frequent
  re-logins (with the iOS PWA localStorage-eviction caveat).

Closes the 'Add hub connection status to heph-tui' and 'Spoke sync health:
surface unhealthy state instead of silent 401 spam' backlog items.

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

315 lines
10 KiB
Rust

//! Headless render test (tech-spec §8.1/§9): a real `hephd` over a real unix
//! socket against a temp DB, driven by the TUI's `ClientBackend`, rendered to
//! ratatui's `TestBackend` — asserting the agenda actually paints seeded data.
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
use heph_core::{FixedClock, LocalStore};
use heph_tui::{app::App, backend::ClientBackend};
use hephd::{Client, Daemon};
use ratatui::{backend::TestBackend, Terminal};
use serde_json::json;
use tokio::net::UnixListener;
const NOW: i64 = 1_717_400_000_000; // ~2024-06-03
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)
}
fn client(socket: &Path) -> Client {
Client::connect(socket).unwrap()
}
/// Render the app to a fixed-size TestBackend and return the screen as text.
fn screen<B: heph_tui::Backend>(app: &App<B>) -> String {
let mut terminal = Terminal::new(TestBackend::new(110, 24)).unwrap();
terminal.draw(|f| heph_tui::ui::render(f, app)).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn agenda_renders_views_projects_and_tasks() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
// Seed: a red task (Top of Mind) and a blue one (On Deck), plus a project.
c.call(
"task.create",
json!({ "title": "Pay the water bill", "attention": "red" }),
)
.unwrap();
c.call(
"task.create",
json!({ "title": "Someday backlog item", "attention": "blue" }),
)
.unwrap();
c.call(
"node.create",
json!({ "kind": "project", "title": "Camano" }),
)
.unwrap();
let app = App::new(ClientBackend::new(client(&socket))).unwrap();
let s = screen(&app);
// Sidebar shows the built-in views and the project.
assert!(s.contains("Top of Mind"), "missing view in sidebar:\n{s}");
assert!(s.contains("On Deck"), "missing On Deck view:\n{s}");
assert!(s.contains("Camano"), "missing project in sidebar:\n{s}");
// The default selection (Top of Mind) lists the red task, not the blue one.
assert!(s.contains("Pay the water bill"), "red task missing:\n{s}");
assert!(
!s.contains("Someday backlog item"),
"blue task should not be in Top of Mind:\n{s}"
);
// The red/orange tasks carry a flag glyph in the leading column (§8.1).
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
// A standalone daemon (no hub) shows no sync indicator — the `sync.status`
// RPC round-trips and reports `hub_url: null`.
assert!(
!s.contains('⟳'),
"sync indicator should be hidden without a hub:\n{s}"
);
}
#[test]
fn preview_shows_the_selected_tasks_context_body() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let task = c
.call(
"task.create",
json!({ "title": "Renew passport", "attention": "orange" }),
)
.unwrap();
// Find the canonical-context doc and give it a body.
let links = c
.call("links.outgoing", json!({ "id": task["node_id"] }))
.unwrap();
let ctx = links
.as_array()
.unwrap()
.iter()
.find(|l| l["link_type"] == "canonical-context")
.unwrap()["dst_id"]
.as_str()
.unwrap()
.to_string();
c.call(
"node.update",
json!({ "id": ctx, "body": "- [ ] fill form\n- [ ] passport photo" }),
)
.unwrap();
let app = App::new(ClientBackend::new(client(&socket))).unwrap();
let s = screen(&app);
assert!(s.contains("fill form"), "context body not previewed:\n{s}");
}
#[test]
fn completing_a_task_removes_it_from_top_of_mind() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
c.call(
"task.create",
json!({ "title": "Pay the bill", "attention": "red" }),
)
.unwrap();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1);
app.complete_selected();
assert!(app.status.contains("done"), "status: {}", app.status);
assert!(app.tasks.is_empty(), "completed task still listed");
// ...and the screen reflects the empty list.
assert!(screen(&app).contains("nothing here"));
}
fn type_and_submit<B: heph_tui::Backend>(app: &mut App<B>, s: &str) {
for ch in s.chars() {
app.input_push(ch);
}
app.input_submit();
}
#[test]
fn quick_add_captures_a_task_that_appears_in_the_view() {
let (socket, _dir) = spawn_daemon();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert!(app.tasks.is_empty());
app.begin_add();
// Single-line NL: p1 → red, so it lands in Top of Mind (the default view).
type_and_submit(&mut app, "Call the plumber p1");
assert!(app.status.contains("added"), "status: {}", app.status);
assert!(
app.tasks.iter().any(|t| t.title == "Call the plumber"),
"added task missing from Top of Mind"
);
}
#[test]
fn recurring_task_shows_glyph_and_selected_detail_block() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let proj = c
.call(
"node.create",
json!({ "kind": "project", "title": "Routines" }),
)
.unwrap();
c.call(
"task.create",
json!({
"title": "Daily standup",
"attention": "red",
"recurrence": "FREQ=DAILY",
"project_id": proj["id"],
}),
)
.unwrap();
let app = App::new(ClientBackend::new(client(&socket))).unwrap();
let s = screen(&app);
// Recurrence glyph on the row...
assert!(s.contains('↻'), "recurrence glyph missing:\n{s}");
// ...and the selected task's inline detail block (cursor starts on row 0).
assert!(s.contains("recurs:"), "no recurrence detail:\n{s}");
// The RRULE is humanized for display (§8.1), not shown raw.
assert!(s.contains("daily"), "recurrence not humanized:\n{s}");
assert!(
!s.contains("FREQ=DAILY"),
"raw rrule leaked into detail:\n{s}"
);
assert!(s.contains("project:"), "no project detail:\n{s}");
assert!(s.contains("Routines"), "project name missing:\n{s}");
}
#[test]
fn search_finds_a_matching_node_and_overlays_results() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
c.call(
"node.create",
json!({ "kind": "doc", "title": "Plumbing notes", "body": "shutoff valve is in the garage" }),
)
.unwrap();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
app.begin_search();
type_and_submit(&mut app, "plumbing");
let s = app.search.as_ref().expect("search active");
assert!(
s.results.iter().any(|h| h.title == "Plumbing notes"),
"search missed the doc: {:?}",
s.results
);
assert!(screen(&app).contains("Search:"), "search pane not rendered");
}
#[test]
fn reschedule_sets_a_do_date_on_the_task() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let task = c
.call(
"task.create",
json!({ "title": "Mail the form", "attention": "red" }),
)
.unwrap();
let id = task["node_id"].as_str().unwrap().to_string();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
app.begin_reschedule();
type_and_submit(&mut app, "today");
// Verify directly via the daemon (the view may drop it depending on clocks).
let got = c.call("task.get", json!({ "id": id })).unwrap();
assert!(!got["do_date"].is_null(), "do_date was not set: {got}");
}
#[test]
fn deleting_a_recurring_task_tombstones_it_and_drops_it_from_the_view() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let task = c
.call(
"task.create",
json!({ "title": "Daily ritual", "attention": "red", "recurrence": "FREQ=DAILY" }),
)
.unwrap();
let id = task["node_id"].as_str().unwrap().to_string();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1);
app.begin_delete();
app.confirm_delete();
assert!(app.status.contains("deleted"), "status: {}", app.status);
assert!(app.tasks.is_empty(), "deleted task still listed");
// The node is tombstoned, not merely rolled forward (node.get includes
// tombstoned rows, with the flag set).
let got = c.call("node.get", json!({ "id": id })).unwrap();
assert_eq!(got["tombstoned"], true, "task node not tombstoned: {got}");
}
#[test]
fn pushing_to_blue_moves_a_task_out_of_top_of_mind() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
c.call(
"task.create",
json!({ "title": "Cool it down", "attention": "orange" }),
)
.unwrap();
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1);
app.push_to_blue_selected();
assert!(app.tasks.is_empty(), "blue task should leave Top of Mind");
// It now appears under On Deck (the last of the five views).
while app.task_pane_title() != "On Deck" {
app.move_sidebar(1);
}
assert_eq!(app.selected_task().unwrap().title, "Cool it down");
}