Files
wanderbooks/crates/ledger-core/tests/analysis.rs
T
swaitsandClaude Opus 5.5 d0490bf106
check / check (push) Canceled after 0s
test(core): every surviving mutant killed or argued equivalent
Max-testing pass, step 8. The final mutation run left 112 survivors
(89% caught). Three agents triaged them by file, by the playbook's
table: spec the boundary then test it, a law rather than one more
pinned value, assert the real value, never weaken a test. Every expected
value is hand-computed.

- reports.rs (35): 28 killed, chiefly cash flow (inclusive start date,
  each line's sign and label, a loss on disposal moving to investing,
  financing designation), general ledger and members' equity date
  boundaries, and the balanced() checks (a real report nudged by a cent
  must stop balancing). 7 equivalent: four >/>= after an early return on
  zero; two in dead code (removed in this commit); one sign of a zero
  cleaned on output.
- ledger.rs, kernel.rs, journal.rs, money.rs (52, plus one new): 49
  killed, including every replay_* mutant at the core level (ids,
  accumulation, status, vendor tags, out-of-order refusal), FX rates,
  0%/100% deductible boundaries, a dividend sweep with no income, the
  legacy member rounding at close, a bill paid on its own date, a
  liability-account reconciliation, mixed-currency entries (via serde),
  ISO 4217 minor units, Display/Debug. 3 equivalent: zero refused before
  a </<= check; a period whose end is in a closed period refused earlier;
  columns(0).
- schedules, tax, analysis, payouts, assets, form1099 (25): 23 killed
  (schedule length 1-600, recurring end on its start, frequency parsing
  and stepping, cost of goods in tax net, a zero payout, disposal on the
  purchase date, quarter starts, labels); 2 equivalent (zero adds zero;
  days_held is provably >= 1).

Two small production cleanups the triage surfaced: kernel::columns(0)
returned a negative-zero credit that would print "-0" (regression
a_zero_movement_has_no_negative_zero_column, shown to fail before); and
cash_flow computed a book value it never used.

Spec lines added where a boundary wasn't specified (cash flow's
inclusive start and line signs, members' equity start date, payout with
no deposit, disposal on the purchase date, bill paid on its own date).
`just mutants` now runs core and store tests (the store's replay tests),
not the whole workspace, which rebuilt the viewer for every mutant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:00:45 -06:00

116 lines
4.2 KiB
Rust

//! Time series. Numbers computed by hand.
//!
//! Cash 1000 and card 2100. Jan 5: owner puts in 5,000. Jan 20: 300 of
//! software on the card. Feb 3: 1,200 of sales into cash. Feb 10: pay the
//! card 300. Mar 15: 800 rent from cash.
use chrono::NaiveDate;
use ledger_core::analysis::{
Interval, accounts_of_type, balance_history, balance_series, flows_by_interval, interval_start,
pnl_by_interval,
};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(n: i64) -> Decimal {
Decimal::from(n)
}
#[test]
fn balances_pnl_and_flows_over_time() {
let mut l = Ledger::new(Currency::USD);
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
let cash = a("1000", AccountType::Asset);
let card = a("2100", AccountType::Liability);
let equity = a("3000", AccountType::Equity);
let sales = a("4000", AccountType::Revenue);
let software = a("6300", AccountType::Expense);
let rent = a("6600", AccountType::Expense);
for (date, dr, cr, amt) in [
(d(1, 5), cash, equity, 5000),
(d(1, 20), software, card, 300),
(d(2, 3), cash, sales, 1200),
(d(2, 10), card, cash, 300),
(d(3, 15), rent, cash, 800),
] {
let e = l.create_entry(date, "x");
let m = Money::new(dec(amt), Currency::USD);
l.add_line(e, dr, Side::Debit, m).unwrap();
l.add_line(e, cr, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
}
assert_eq!(
balance_series(&l, &[cash], &[d(1, 1), d(1, 31), d(2, 28), d(3, 31)]),
[dec(0), dec(5000), dec(5900), dec(5100)]
);
// The card is a liability: owed 300, then paid off.
assert_eq!(
balance_series(&l, &[card], &[d(1, 31), d(2, 28)]),
[dec(300), dec(0)]
);
assert_eq!(
balance_history(&l, &[cash]),
[
(d(1, 5), dec(5000)),
(d(2, 3), dec(6200)),
(d(2, 10), dec(5900)),
(d(3, 15), dec(5100))
]
);
let pnl = pnl_by_interval(&l, d(1, 1), d(3, 31), Interval::Month).unwrap();
let nets: Vec<Decimal> = pnl.iter().map(|p| p.net()).collect();
assert_eq!(nets, [dec(-300), dec(1200), dec(-800)]);
let flows = flows_by_interval(&l, &[cash], d(1, 1), d(3, 31), Interval::Month);
let io: Vec<(Decimal, Decimal)> = flows.iter().map(|f| (f.2, f.3)).collect();
assert_eq!(
io,
[
(dec(5000), dec(0)),
(dec(1200), dec(300)),
(dec(0), dec(800))
]
);
// Cash and card together: a card charge is money out when it's made,
// and paying the card moves nothing out of the pair.
let both = flows_by_interval(&l, &[cash, card], d(1, 1), d(2, 28), Interval::Month);
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
}
#[test]
fn intervals_start_on_calendar_boundaries() {
for (day, want) in [
(d(2, 14), d(1, 1)),
(d(5, 31), d(4, 1)),
(d(11, 30), d(10, 1)),
] {
assert_eq!(interval_start(day, Interval::Quarter), want, "{day}");
assert_eq!(interval_start(day, Interval::Year), d(1, 1), "{day}");
}
assert_eq!(interval_start(d(11, 30), Interval::Month), d(11, 1));
}
#[test]
fn accounts_of_type_picks_from_the_chart_in_order() {
let mut l = Ledger::new(Currency::USD);
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
let cash = a("1000", AccountType::Asset);
let card = a("2100", AccountType::Liability);
let rent = a("6600", AccountType::Expense);
let bank = a("1010", AccountType::Asset);
let assets = accounts_of_type(&l, &[AccountType::Asset]);
assert_eq!(assets.len(), 2);
assert!(assets.contains(&cash) && assets.contains(&bank));
let mut cash_like = accounts_of_type(&l, &[AccountType::Asset, AccountType::Liability]);
cash_like.sort();
let mut want = vec![cash, card, bank];
want.sort();
assert_eq!(cash_like, want);
assert!(!cash_like.contains(&rent));
assert!(accounts_of_type(&l, &[AccountType::Revenue]).is_empty());
}