generated from eblume/project-template
All checks were successful
Build / validate (pull_request) Successful in 6m11s
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>
315 lines
10 KiB
Rust
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");
|
|
}
|