Files
wanderbooks/crates/ledger-gui/tests/live.rs
T
swaitsandClaude Opus 5.5 5bc0633cdd P2 slice 6: the viewer
`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:21 -06:00

163 lines
5.9 KiB
Rust

//! Live updates: a change written to the books (by the MCP server, in
//! practice) reaches the GUI's view model on its own, without a restart,
//! and the changed entry is marked fresh; a show request reaches it, and
//! while it runs it holds the books' viewer presence.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::data::{Loader, Source, Update};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::time::{Duration, Instant};
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_change_in_another_process_shows_up_live() {
let dir = std::env::temp_dir().join(format!("ledger-gui-live-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let _cleanup = TestDir(dir.clone());
let data = dir.join("books-data");
let mut store = Store::create(&data, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
drop(store);
// The server makes the books' ops/ folder when it opens them; no
// viewer has them open yet.
ledger_store::ops::prepare(&data).unwrap();
assert!(!ledger_store::ops::viewer_open(&data));
let ctx = egui::Context::default();
let loader = Loader::spawn(
Source {
data_dir: data.clone(),
today: NaiveDate::from_ymd_opt(2026, 10, 12),
},
ctx,
);
let first = match loader.wait(Duration::from_secs(10)) {
Some(Update::Books(b)) => b,
_ => panic!("no initial load"),
};
assert_eq!(first.kpis.cash, Decimal::ZERO);
// The running viewer holds its presence there (ops INVARIANT 3).
let deadline = Instant::now() + Duration::from_secs(10);
while !ledger_store::ops::viewer_open(&data) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(
ledger_store::ops::viewer_open(&data),
"the running viewer holds no presence"
);
// The writer (the MCP server's store) posts an entry.
let started = Instant::now();
let mut store = Store::open(&data).unwrap();
let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
let e = store.create_entry(d, "Owner contribution").unwrap();
let m = Money::new(Decimal::from(2500), Currency::USD);
store.add_line(e, cash, Side::Debit, m).unwrap();
store.add_line(e, equity, Side::Credit, m).unwrap();
store.post_entry(e).unwrap();
drop(store);
// The GUI sees it, without being asked.
let deadline = Instant::now() + Duration::from_secs(10);
let mut seen = None;
while Instant::now() < deadline {
if let Some(Update::Books(b)) = loader.wait(Duration::from_millis(200))
&& b.kpis.cash == Decimal::from(2500)
{
seen = Some(b);
break;
}
}
let b = seen.expect("the posted entry never reached the GUI");
assert!(b.fresh.contains(&e), "the new entry is highlighted as new");
assert!(b.seq() > first.seq());
eprintln!("live update arrived in {:?}", started.elapsed());
// The agent asks the viewer to show the entry (books.show).
let asked = Instant::now();
ledger_store::ops::request(
&data,
ledger_store::ops::ViewerTarget::Entry { entry_id: e },
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
let mut shown = false;
while Instant::now() < deadline {
if let Some(Update::Show(ledger_store::ops::ViewerTarget::Entry { entry_id })) =
loader.wait(Duration::from_millis(200))
{
assert_eq!(entry_id, e);
shown = true;
break;
}
}
assert!(shown, "the show request never reached the viewer");
eprintln!("show request arrived in {:?}", asked.elapsed());
// The viewer closed: its presence goes with it, within a poll.
drop(loader);
let deadline = Instant::now() + Duration::from_secs(10);
while ledger_store::ops::viewer_open(&data) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
assert!(
!ledger_store::ops::viewer_open(&data),
"a closed viewer still holds its presence"
);
}
/// An older books dir that keeps no settings in its journal and has no
/// settings file: one written while the viewer runs is followed, as its
/// server follows it.
#[test]
fn a_settings_file_made_later_is_followed() {
let dir = std::env::temp_dir().join(format!("ledger-gui-live-toml-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let _cleanup = TestDir(dir.clone());
let data = dir.join("books-data");
drop(Store::create(&data, Currency::USD).unwrap());
let loader = Loader::spawn(
Source {
data_dir: data.clone(),
today: NaiveDate::from_ymd_opt(2026, 10, 12),
},
egui::Context::default(),
);
match loader.wait(Duration::from_secs(10)) {
Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Made Later Co"),
_ => panic!("no initial load"),
}
std::fs::write(
dir.join("wanderbooks.toml"),
"[company]\nname = \"Made Later Co\"\n",
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
assert!(
Instant::now() < deadline,
"the new settings file was never read"
);
match loader.wait(Duration::from_millis(200)) {
Some(Update::Books(b)) if b.settings.company.name == "Made Later Co" => break,
Some(Update::Error(e)) => panic!("the settings file didn't load: {e}"),
_ => {}
}
}
}