viewer: the overview's numbers checked against ledger-core
check / check (push) Canceled after 0s

Kpis gets a spec (cash, liabilities, receivables, month-to-date against
the same days of last month, fiscal year to date, burn). tests/view_model.rs
computes each from ledger-core on random books (month ends, leap days,
any fiscal start) along with the period ranges, cash history, account
labels, journal count, receipt rule, origins and parties, the blob
root, and integrity counts. Burn rounds half away from zero, like the
rest of wanderbooks (it was banker's rounding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-14 15:53:57 -06:00
1 parent eba85f9c77
commit 47d301bcfe
2 files changed
+325 -1

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
//! The viewer's numbers are the books' numbers: the view model
//! (books::Books, books::Kpis) against ledger-core, computed here
//! independently, on random books.
use chrono::{Datelike, Days, Months, NaiveDate};
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period, trial_balance_as_of};
use ledger_core::{AccountType, Currency, DocumentLine, Money, Side};
use ledger_gui::{Source, data, fmt};
use ledger_store::Store;
use proptest::prelude::*;
use rust_decimal::{Decimal, RoundingStrategy};
use std::path::PathBuf;
const CHART: [(&str, &str, AccountType); 9] = [
("1000", "Checking", AccountType::Asset),
("1010", "Savings", AccountType::Asset),
("1200", "Accounts Receivable", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("2000", "Accounts Payable", AccountType::Liability),
("2100", "Due to owners", AccountType::Liability),
("3000", "Capital", AccountType::Equity),
("4000", "Revenue", AccountType::Revenue),
("6000", "Supplies", AccountType::Expense),
];
const CASH: [&str; 2] = ["1000", "1010"];
#[derive(Clone, Debug)]
struct Plan {
today: NaiveDate,
fy_month: u32,
/// (days before today, debit index, credit index, cents)
entries: Vec<(u64, usize, usize, i64)>,
}
fn plan() -> impl Strategy<Value = Plan> {
(
// Month ends and leap days included.
prop_oneof![
Just(NaiveDate::from_ymd_opt(2026, 3, 31).unwrap()),
Just(NaiveDate::from_ymd_opt(2028, 2, 29).unwrap()),
Just(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
(0u64..900).prop_map(|d| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() + Days::new(d)),
],
1u32..=12,
proptest::collection::vec((0u64..200, 0usize..9, 0usize..9, 1i64..5_000_000), 0..25),
)
.prop_map(|(today, fy_month, entries)| Plan {
today,
fy_month,
entries: entries.into_iter().filter(|e| e.1 != e.2).collect(),
})
}
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ledger-gui-vm-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn build(plan: &Plan, tag: &str) -> (Source, ledger_core::Ledger) {
let d = dir(tag);
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let ids: Vec<_> = CHART
.iter()
.map(|(c, n, t)| s.add_account(c, n, *t, None, false).unwrap())
.collect();
for &(back, dr, cr, cents) in &plan.entries {
let e = s.create_entry(plan.today - Days::new(back), "x").unwrap();
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, ids[dr], Side::Debit, m).unwrap();
s.add_line(e, ids[cr], Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
}
std::fs::write(
d.join("wanderbooks.toml"),
format!(
"[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"{:02}-01\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nreceipt_threshold = \"25.00\"\n",
plan.fy_month
),
)
.unwrap();
let ledger = s.ledger().clone();
(
Source {
data_dir: d.join("books-data"),
books_toml: Some(d.join("wanderbooks.toml")),
today: Some(plan.today),
},
ledger,
)
}
/// An account's normal-side balance at the end of `date`, from the trial
/// balance.
fn balance(l: &ledger_core::Ledger, code: &str, date: NaiveDate) -> Decimal {
let t = l.chart().find_by_code(code).unwrap().account_type;
let tb = trial_balance_as_of(l, date);
let net = tb
.lines
.iter()
.find(|x| x.code == code)
.map(|x| x.debit - x.credit)
.unwrap_or_default();
match t {
AccountType::Asset | AccountType::Expense => net,
_ => -net,
}
}
fn cash(l: &ledger_core::Ledger, date: NaiveDate) -> Decimal {
CASH.iter().map(|c| balance(l, c, date)).sum()
}
fn first_of(d: NaiveDate) -> NaiveDate {
NaiveDate::from_ymd_opt(d.year(), d.month(), 1).unwrap()
}
proptest! {
#![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
/// Kpis INVARIANTS 1-7 and the period helpers, against ledger-core.
#[test]
fn the_overview_numbers_are_the_books_numbers(p in plan()) {
let (source, l) = build(&p, "kpi");
let b = data::load_once(&source).unwrap();
let k = &b.kpis;
let t = p.today;
let m0 = first_of(t);
prop_assert_eq!(b.month_start(), m0);
prop_assert_eq!(k.cash, cash(&l, t));
prop_assert_eq!(k.cash_30d_ago, cash(&l, t - Days::new(30)));
prop_assert_eq!(k.liabilities, balance_sheet_as_of(&l, t).unwrap().total_liabilities);
prop_assert_eq!(k.receivables, balance(&l, "1200", t));
let pl = |s, e| profit_and_loss_for_period(&l, s, e, None).unwrap();
let mtd = pl(m0, t);
prop_assert_eq!(k.revenue_mtd, mtd.total_revenue);
prop_assert_eq!(k.expenses_mtd, mtd.total_expenses);
let prev_start = m0 - Months::new(1);
let prev_last = m0 - Days::new(1);
let same = NaiveDate::from_ymd_opt(prev_start.year(), prev_start.month(), t.day())
.unwrap_or(prev_last);
let prev = pl(prev_start, same);
prop_assert_eq!(k.revenue_prev_month, prev.total_revenue);
prop_assert_eq!(k.expenses_prev_month, prev.total_expenses);
// Fiscal year: its first day is the last fiscal-start first-of-month
// on or before today.
let mut fy = NaiveDate::from_ymd_opt(t.year(), p.fy_month, 1).unwrap();
if fy > t {
fy = fy - Months::new(12);
}
prop_assert_eq!(b.fiscal_ytd(), (fy, t));
let ytd = pl(fy, t);
prop_assert_eq!(k.net_ytd, ytd.total_revenue - ytd.total_expenses);
let first = p.entries.iter().map(|e| t - Days::new(e.0)).min();
prop_assert_eq!(k.first_entry, first);
// Burn: the three full months before m0, averaged over those the
// books cover.
let three = pl(m0 - Months::new(3), prev_last);
let loss = three.total_expenses - three.total_revenue;
let covered = match first {
None => 0,
Some(f) => (0..3)
.filter(|i| first_of(m0 - Months::new(i + 1)) >= first_of(f))
.count() as i64,
};
let burn = (loss > Decimal::ZERO && covered > 0).then(|| {
(loss / Decimal::from(covered))
.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
});
prop_assert_eq!(k.monthly_burn, burn);
// Trailing months: n months ending with this one.
for n in 1..=12u32 {
prop_assert_eq!(b.trailing_months(n), (m0 - Months::new(n - 1), t));
}
// Cash history: each point is the cash balance that day, dates
// strictly rising, through the last day cash moved.
let h = b.cash_history();
for w in h.windows(2) {
prop_assert!(w[0].0 < w[1].0);
}
for (d, v) in &h {
prop_assert_eq!(*v, cash(&l, *d), "cash on {}", d);
}
// Names and labels.
for (code, name, _) in CHART {
let id = l.chart().find_by_code(code).unwrap().id;
prop_assert_eq!(b.account_label(id), format!("{code} {name}"));
prop_assert_eq!(b.account_name(id), name);
}
prop_assert_eq!(b.company_name(), "Oracle Co");
let events = std::fs::read_to_string(source.data_dir.join("journal.jsonl"))
.unwrap()
.lines()
.count() as u64;
prop_assert_eq!(b.seq(), events);
prop_assert_eq!(b.receipt_rule(), "over the $25.00 receipt threshold");
let _ = std::fs::remove_dir_all(source.data_dir.parent().unwrap());
}
}
/// Where each entry came from, and who it was with, from the journal.
#[test]
fn origins_and_parties() {
let d = dir("origin");
let today = NaiveDate::from_ymd_opt(2026, 5, 20).unwrap();
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let sup = s
.add_account("6000", "Supplies", AccountType::Expense, None, false)
.unwrap();
let cust = s.add_customer("Acme Buyer").unwrap();
let vend = s.add_vendor("Paper Co").unwrap();
let line = |a: i64| {
vec![DocumentLine {
description: "x".into(),
amount: Decimal::new(a, 2),
}]
};
let inv = s
.create_invoice(cust, today, today + Days::new(30), line(10_000))
.unwrap();
s.create_bill(vend, today, today + Days::new(30), line(5_000))
.unwrap();
let paid = s
.receive_payment(inv, today, Decimal::new(10_000, 2), cash)
.unwrap();
let e = s.create_entry(today, "Paper").unwrap();
let m = Money::new(Decimal::new(1_000, 2), Currency::USD);
s.add_line(e, sup, Side::Debit, m).unwrap();
s.add_line(e, cash, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
let (blob, _, _) = s
.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF receipt", None)
.unwrap();
let rev = s.reverse_entry(e, today, "undo").unwrap();
drop(s);
std::fs::write(
d.join("wanderbooks.toml"),
"[books]\nreceipt_threshold = \"0.00\"\n",
)
.unwrap();
let source = Source {
data_dir: d.join("books-data"),
books_toml: Some(d.join("wanderbooks.toml")),
today: Some(today),
};
let b = data::load_once(&source).unwrap();
let by_origin = |o: &str| b.entries.iter().filter(|x| x.origin == o).count();
assert_eq!(by_origin("Invoice"), 1);
assert_eq!(by_origin("Bill"), 1);
assert_eq!(by_origin("Customer payment"), 1);
assert_eq!(by_origin("Reversal"), 1);
assert_eq!(b.entry(paid).unwrap().party.as_deref(), Some("Acme Buyer"));
let bill_entry = b.entries.iter().find(|x| x.origin == "Bill").unwrap();
assert_eq!(bill_entry.party.as_deref(), Some("Paper Co"));
assert_eq!(b.entry(e).unwrap().reversed_by, Some(rev));
assert_eq!(b.entry(rev).unwrap().reversal_of, Some(e));
// Every entry's journal events name it.
for x in &b.entries {
assert!(!x.events.is_empty(), "entry {} has no events", x.id);
}
assert_eq!(b.receipt_rule(), "every entry needs a receipt");
// The receipt's bytes are where the blob root says.
let a = b.attachments.iter().find(|a| a.id == blob).unwrap();
let path = b
.blob_root()
.join(&a.blob_sha[..2])
.join(&a.blob_sha[2..4])
.join(&a.blob_sha);
assert_eq!(std::fs::read(path).unwrap(), b"%PDF receipt");
assert_eq!(b.integrity.files_ok, 1);
assert!(b.integrity.file_failures.is_empty());
let _ = std::fs::remove_dir_all(&d);
}
/// fmt helpers the viewer shows: percent change and tax forms.
#[test]
fn percent_change_and_tax_forms() {
let d = |s: &str| s.parse::<Decimal>().unwrap();
assert_eq!(fmt::pct_change(d("0"), d("5")), None);
assert_eq!(fmt::pct_change(d("100"), d("150")), Some(50.0));
assert_eq!(fmt::pct_change(d("200"), d("150")), Some(-25.0));
assert_eq!(fmt::pct_change(d("-100"), d("-50")), Some(50.0));
use ledger_core::TaxForm::*;
for (f, number) in [
(ScheduleC, "Schedule C"),
(Form1065, "1065"),
(Form1120S, "1120-S"),
(Form1120, "1120"),
] {
assert!(fmt::tax_form(f).contains(number), "{f:?}");
}
assert_eq!(fmt::compact(d("-1234")), "\u{2212}$1.2k");
}