Files
wanderbooks/crates/ledger-store/tests/snapshot.rs
T
swaitsandClaude Opus 5.5 105fd52694 feat: ledger-gui — a read-only, live desktop view of the books
egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:20:14 -07:00

146 lines
4.9 KiB
Rust

//! Snapshot: a read-only view that follows a live writer.
use chrono::NaiveDate;
use ledger_core::reports::snapshot;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JOURNAL_FILE, Snapshot, Store};
use rust_decimal::Decimal;
use std::io::Write;
use std::path::PathBuf;
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn usd(n: i64) -> Money {
Money::new(Decimal::from(n), Currency::USD)
}
fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 {
let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
let e = store.create_entry(d, "x").unwrap();
store.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
store.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
store.post_entry(e).unwrap();
e
}
#[test]
fn a_snapshot_follows_the_writer_and_never_writes() {
let dir = test_dir("follow");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
let e = post(&mut store, 1, cash, equity, 1000);
store
.attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None)
.unwrap();
let listing = |dir: &std::path::Path| {
let mut v: Vec<_> = walk(dir);
v.sort();
v
};
let before = listing(&dir);
let mut snap = Snapshot::load(&dir).unwrap();
assert_eq!(listing(&dir), before, "loading writes nothing");
assert_eq!(snap.journal_seq(), store.journal_seq());
assert_eq!(
snapshot(snap.ledger()).unwrap(),
snapshot(store.ledger()).unwrap()
);
let atts = snap.attachments(Some(e)).unwrap();
assert_eq!(atts.len(), 1);
assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes");
// The writer keeps going; refresh picks up exactly the new lines.
assert_eq!(snap.refresh().unwrap(), 0);
let seq = store.journal_seq();
post(&mut store, 2, cash, equity, 250);
let added = snap.refresh().unwrap();
assert_eq!(added as u64, store.journal_seq() - seq);
assert_eq!(
snapshot(snap.ledger()).unwrap(),
snapshot(store.ledger()).unwrap()
);
}
#[test]
fn a_half_written_line_waits_for_the_next_refresh() {
let dir = test_dir("torn");
let mut store = Store::create(&dir, Currency::USD).unwrap();
store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
drop(store);
let mut snap = Snapshot::load(&dir).unwrap();
let seq = snap.journal_seq();
// Simulate the writer mid-append: half a line, no newline yet.
let journal = dir.join(JOURNAL_FILE);
let full = std::fs::read(&journal).unwrap();
let mut store = Store::open(&dir).unwrap();
store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
drop(store);
let grown = std::fs::read(&journal).unwrap();
let new_line = &grown[full.len()..];
std::fs::write(&journal, &full).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&journal)
.unwrap();
f.write_all(&new_line[..new_line.len() / 2]).unwrap();
f.flush().unwrap();
assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption");
f.write_all(&new_line[new_line.len() / 2..]).unwrap();
f.flush().unwrap();
assert_eq!(snap.refresh().unwrap(), 1);
assert_eq!(snap.journal_seq(), seq + 1);
assert!(snap.ledger().chart().find_by_code("3000").is_some());
}
#[test]
fn a_rewritten_journal_reloads() {
let dir = test_dir("rewrite");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
let journal = dir.join(JOURNAL_FILE);
let early = std::fs::read(&journal).unwrap();
post(&mut store, 1, cash, equity, 10);
drop(store);
let mut snap = Snapshot::load(&dir).unwrap();
let later = snap.journal_seq();
// Restored from an earlier backup: shorter file.
std::fs::write(&journal, &early).unwrap();
snap.refresh().unwrap();
assert!(snap.journal_seq() < later);
assert!(snap.ledger().posted_entries().next().is_none());
}
fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
let mut out = Vec::new();
for e in std::fs::read_dir(dir).unwrap() {
let e = e.unwrap();
let m = e.metadata().unwrap();
if m.is_dir() {
out.extend(walk(&e.path()));
} else {
out.push((e.path().display().to_string(), m.len()));
}
}
out
}