From 41acb73b5ba5365d63fe1b8a177ac6561454480e Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 02:25:21 +0000 Subject: [PATCH] asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs --- crates/ledger-core/tests/properties.rs | 272 +++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 crates/ledger-core/tests/properties.rs diff --git a/crates/ledger-core/tests/properties.rs b/crates/ledger-core/tests/properties.rs new file mode 100644 index 0000000..89be657 --- /dev/null +++ b/crates/ledger-core/tests/properties.rs @@ -0,0 +1,272 @@ +//! Property tests for the ledger engine: invariants that must hold for +//! *every* input, not just the hand-picked examples in `invariants.rs`. +//! +//! These are the properties mutation testing will try to break: if a +//! mutant survives the example tests but breaks a property, the property +//! catches it. + +use chrono::NaiveDate; +use ledger_core::{ + AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value, + depreciation_schedule, + reports::{balance_sheet, trial_balance}, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +fn arb_account_type() -> impl Strategy { + prop_oneof![ + Just(AccountType::Asset), + Just(AccountType::Liability), + Just(AccountType::Equity), + Just(AccountType::Revenue), + Just(AccountType::Expense), + ] +} + +/// A random chart: 2..=6 accounts with unique codes, no placeholders. +fn arb_chart() -> impl Strategy> { + prop::collection::vec((1u32..9999u32, arb_account_type()), 2..=6).prop_map(|v| { + v.into_iter() + .enumerate() + .map(|(i, (_, t))| (format!("{:04}", 1000 + i), format!("Account {i}"), t)) + .collect() + }) +} + +#[derive(Clone, Debug)] +struct GenEntry { + lines: Vec<(u64, Side, Decimal)>, + date: NaiveDate, +} + +/// A random *balanced* entry over `num_accounts` (1-based ids): 2..=4 +/// random lines plus a plug line for the imbalance. Amounts are whole +/// cents, so the plug is always exact. +fn arb_entry(num_accounts: usize) -> impl Strategy { + ( + prop::collection::vec( + ( + 1u64..=(num_accounts as u64), + prop_oneof![Just(Side::Debit), Just(Side::Credit)], + 1u32..1_000_000u32, + ), + 2..=4, + ), + 1u32..=12u32, + 1u32..=28u32, + ) + .prop_map(|(raw, month, day)| { + let mut lines: Vec<(u64, Side, Decimal)> = raw + .into_iter() + .map(|(a, s, cents)| (a, s, Decimal::new(cents as i64, 2))) + .collect(); + let net: Decimal = lines + .iter() + .map(|(_, s, amt)| match s { + Side::Debit => *amt, + Side::Credit => -*amt, + }) + .sum(); + if net != Decimal::ZERO { + let (side, amt) = if net > Decimal::ZERO { + (Side::Credit, net) + } else { + (Side::Debit, -net) + }; + lines.push((1, side, amt)); + } + GenEntry { + lines, + date: NaiveDate::from_ymd_opt(2026, month, day).unwrap(), + } + }) +} + +fn arb_ops() -> impl Strategy, Vec)> { + arb_chart().prop_flat_map(|chart| { + let n = chart.len(); + (Just(chart), prop::collection::vec(arb_entry(n), 0..=10)) + }) +} + +fn build_ledger( + chart: &[(String, String, AccountType)], + entries: &[GenEntry], +) -> (Ledger, Vec) { + let mut ledger = Ledger::new(Currency::USD); + for (code, name, typ) in chart { + ledger.add_account(code, name, *typ, None, false).unwrap(); + } + let mut ids = Vec::new(); + for e in entries { + let id = ledger.create_entry(e.date, "prop"); + for (a, s, amt) in &e.lines { + ledger + .add_line(id, *a, *s, Money::new(*amt, Currency::USD)) + .unwrap(); + } + ledger.post_entry(id).unwrap(); + ids.push(id); + } + (ledger, ids) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// The trial balance always balances, no matter the operations. + #[test] + fn prop_trial_balance_balances((chart, entries) in arb_ops()) { + let (ledger, _) = build_ledger(&chart, &entries); + let tb = trial_balance(&ledger); + prop_assert_eq!(tb.total_debits, tb.total_credits); + let debit_sum: Decimal = tb.lines.iter().map(|l| l.debit).sum(); + let credit_sum: Decimal = tb.lines.iter().map(|l| l.credit).sum(); + prop_assert_eq!(debit_sum, tb.total_debits); + prop_assert_eq!(credit_sum, tb.total_credits); + } + + /// The accounting equation holds for every ledger state: + /// assets == liabilities + equity (with current earnings folded in). + #[test] + fn prop_balance_sheet_balances((chart, entries) in arb_ops()) { + let (ledger, _) = build_ledger(&chart, &entries); + let bs = balance_sheet(&ledger).unwrap(); + prop_assert_eq!(bs.assets, bs.liabilities + bs.equity); + } + + /// Every posted entry balances on its own, exactly. + #[test] + fn prop_posted_entries_balance((chart, entries) in arb_ops()) { + let (ledger, ids) = build_ledger(&chart, &entries); + for id in ids { + let e = ledger.get_entry(id).unwrap(); + let debits: Decimal = e.lines().iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount.amount).sum(); + let credits: Decimal = e.lines().iter() + .filter(|l| l.side == Side::Credit) + .map(|l| l.amount.amount).sum(); + prop_assert_eq!(debits, credits); + } + } + + /// Reversing every entry returns the trial balance to zero: + /// reversals are exact inverses. + #[test] + fn prop_reversals_restore_zero((chart, entries) in arb_ops()) { + let (mut ledger, ids) = build_ledger(&chart, &entries); + for id in &ids { + ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "reversal").unwrap(); + } + let tb = trial_balance(&ledger); + prop_assert_eq!(tb.total_debits, Decimal::ZERO); + prop_assert_eq!(tb.total_credits, Decimal::ZERO); + } + + /// Posted entries are immutable: serializing them before and after + /// further operations yields identical bytes. + #[test] + fn prop_posted_entries_immutable((chart, entries) in arb_ops(), extra in prop::collection::vec(arb_entry(2), 0..=5)) { + prop_assume!(chart.len() >= 2); + let (mut ledger, ids) = build_ledger(&chart, &entries); + let before: Vec = ids.iter().map(|id| { + serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap() + }).collect(); + // More operations: entries sized for a 2-account chart would break + // on bigger charts, so rebuild them against this chart. + let n = chart.len(); + let extra_entries: Vec = extra.into_iter().map(|mut e| { + for (a, _, _) in e.lines.iter_mut() { + *a = (*a % n as u64) + 1; + } + // Rebalance after remapping accounts (net may have changed). + let net: Decimal = e.lines.iter().map(|(_, s, amt)| match s { + Side::Debit => *amt, Side::Credit => -*amt, + }).sum(); + if net != Decimal::ZERO { + let (side, amt) = if net > Decimal::ZERO { (Side::Credit, net) } else { (Side::Debit, -net) }; + e.lines.push((1, side, amt)); + } + e + }).collect(); + for e in &extra_entries { + let id = ledger.create_entry(e.date, "extra"); + for (a, s, amt) in &e.lines { + ledger.add_line(id, *a, *s, Money::new(*amt, Currency::USD)).unwrap(); + } + ledger.post_entry(id).unwrap(); + } + for id in &ids { + ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "rev").unwrap(); + } + let after: Vec = ids.iter().map(|id| { + serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap() + }).collect(); + prop_assert_eq!(before, after); + } + + /// A journal entry survives a JSON round-trip unchanged. + #[test] + fn prop_entry_json_roundtrip((chart, entries) in arb_ops()) { + let (ledger, ids) = build_ledger(&chart, &entries); + for id in ids { + let e = ledger.get_entry(id).unwrap(); + let v = serde_json::to_value(e).unwrap(); + let s = serde_json::to_string(&v).unwrap(); + let v2: serde_json::Value = serde_json::from_str(&s).unwrap(); + prop_assert_eq!(v, v2); + } + } + + /// Depreciation schedules are exact: the lifetime total is precisely + /// cost minus salvage, book value plus accumulated always equals cost, + /// and amounts are never negative. + #[test] + fn prop_depreciation_schedule_exact( + cost_cents in 100u64..10_000_000u64, + salvage_cents in 0u64..5_000_000u64, + life in 1u32..360u32, + y in 2000i32..2027i32, + m in 1u32..=12u32, + d in 1u32..=28u32, + ) { + let cost_cents = cost_cents.max(salvage_cents + 1); + let cost = Decimal::new(cost_cents as i64, 2); + let salvage = Decimal::new(salvage_cents as i64, 2); + let mut ledger = Ledger::new(Currency::USD); + let ca = ledger.add_account("1500", "Equipment", AccountType::Asset, None, false).unwrap(); + let aa = ledger.add_account("1501", "Accum", AccountType::Asset, None, false).unwrap(); + let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap(); + let id = ledger.register_asset(NewAsset { + description: "prop".to_string(), + purchase_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(), + cost: Money::new(cost, Currency::USD), + salvage_value: Money::new(salvage, Currency::USD), + useful_life_months: life, + cost_account: ca, + accum_depr_account: aa, + expense_account: ex, + }).unwrap(); + let asset = ledger.get_asset(id).unwrap(); + let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap(); + let sched = depreciation_schedule(asset, far); + // The lifetime total is exactly cost minus salvage: never a penny off. + let total: Decimal = sched.iter().map(|l| l.amount).sum(); + prop_assert_eq!(total, cost - salvage); + for l in &sched { + prop_assert!(l.amount >= Decimal::ZERO); + prop_assert_eq!(l.book_value + l.accumulated, cost); + } + // Accumulated is monotone non-decreasing. + for w in sched.windows(2) { + prop_assert!(w[1].accumulated >= w[0].accumulated); + } + // Book value at any date matches the schedule. + if let Some(last) = sched.last() { + prop_assert_eq!(book_value(asset, far), last.book_value); + prop_assert_eq!(accumulated_depreciation(asset, far), last.accumulated); + } + } +}