From 0cf8fb842fe719862ebb1f8089b8213ae9ad00ca Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 19:32:32 -0600 Subject: [PATCH] store: tests for mutation survivors; drop pre-checks the rehearsal covers From the first store mutation run (324 caught of 396 viable): - receive_payment and pay_bill repeated the engine's refusals by hand after rehearsing the engine itself, so those checks could never fire (their mutants survived). Removed: the rehearsal is the check. - New hand-computed tests: a disposal's final catch-up after some depreciation was posted (200 of 300, gain 100); a disposal on the purchase date (allowed) or the day before (refused); a retained earnings close with a sweep replays it; a float inside an array is refused; the viewer's journal and head hash. - The concurrency test's reader stops when the writer thread ends, not only when it says it's done, so a mutant that kills the writer fails the test at once instead of spinning to the five-minute timeout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-store/src/store.rs | 49 +------ crates/ledger-store/tests/concurrency.rs | 3 +- crates/ledger-store/tests/store.rs | 156 +++++++++++++++++++++++ 3 files changed, 160 insertions(+), 48 deletions(-) diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 60bfcba..f8290ab 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1474,31 +1474,8 @@ impl Store { ensure_on(l, &ledger_core::RECEIVABLES)?; l.receive_payment(invoice_id, date, amount, cash_account) })?; - // Pure pre-validation mirroring Ledger::receive_payment, plus the - // account checks post_entry would perform: a failed post after the - // journal writes would leave the journal ahead of the ledger. - if amount <= Decimal::ZERO { - return Err(LedgerError::InvalidPaymentAmount(amount).into()); - } - let balance = self - .ledger - .get_invoice(invoice_id) - .ok_or(LedgerError::UnknownInvoice(invoice_id))? - .balance(); - if amount > balance { - return Err(LedgerError::Overpayment(amount, balance).into()); - } - if let Some(pid) = self.ledger.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); - } - let cash = self - .ledger - .chart() - .get(cash_account) - .ok_or(LedgerError::UnknownAccount(cash_account))?; - if cash.placeholder { - return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into()); - } + // The rehearsal above is the whole check: hand-written mirrors of + // the engine's refusals drift from it (and were never reached). let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; let description = format!("Payment on invoice {invoice_id}"); let (entry_id, mut journaled) = @@ -1536,28 +1513,6 @@ impl Store { ensure_on(l, &ledger_core::PAYABLES)?; l.pay_bill(bill_id, date, amount, cash_account) })?; - if amount <= Decimal::ZERO { - return Err(LedgerError::InvalidPaymentAmount(amount).into()); - } - let balance = self - .ledger - .get_bill(bill_id) - .ok_or(LedgerError::UnknownBill(bill_id))? - .balance(); - if amount > balance { - return Err(LedgerError::Overpayment(amount, balance).into()); - } - if let Some(pid) = self.ledger.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); - } - let cash = self - .ledger - .chart() - .get(cash_account) - .ok_or(LedgerError::UnknownAccount(cash_account))?; - if cash.placeholder { - return Err(LedgerError::PlaceholderAccount(cash.code.clone()).into()); - } let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; let description = format!("Payment on bill {bill_id}"); let (entry_id, mut journaled) = diff --git a/crates/ledger-store/tests/concurrency.rs b/crates/ledger-store/tests/concurrency.rs index b433962..a8c2fe8 100644 --- a/crates/ledger-store/tests/concurrency.rs +++ b/crates/ledger-store/tests/concurrency.rs @@ -79,7 +79,8 @@ fn a_viewer_reading_while_the_books_are_written_sees_only_whole_books() { start.wait(); let (mut last_seq, mut last_posted, mut refreshes) = (0u64, 0usize, 0u64); loop { - let finished = done.load(Ordering::SeqCst); + // Done, or the writer died (its panic surfaces at the join below). + let finished = done.load(Ordering::SeqCst) || writer.is_finished(); snapshot.refresh().unwrap(); refreshes += 1; let l = snapshot.ledger(); diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 4086a70..5511c65 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1587,6 +1587,13 @@ fn a_journal_line_with_a_float_is_refused() { JournalLine::parse(br#"{"payload":{"amount":1.5},"prev":"x","seq":1,"ts":"t","type":"a"}"#) .is_err() ); + // Anywhere in the line: inside an array too (an entry's lines). + assert!( + JournalLine::parse( + br#"{"payload":{"lines":[{"amount":"1.00"},{"amount":2.5}]},"prev":"x","seq":1,"ts":"t","type":"a"}"# + ) + .is_err() + ); let ok = br#"{"payload":{"amount":"1.50","id":7},"prev":"x","seq":1,"ts":"t","type":"a"}"#; let line = JournalLine::parse(ok).unwrap(); let canon = line.to_canonical(); @@ -1776,3 +1783,152 @@ fn members_closes_replay_under_the_rule_they_were_journaled_with() { assert_eq!(bal(caps[1]), b.parse::().unwrap(), "{rule}"); } } + +/// The viewer's journal accessors: the lines it has read, in order, and +/// the hash the next line must chain onto (the SHA-256 of the last line's +/// canonical form; docs/receipt-store.md "Line format"). +#[test] +fn the_viewer_exposes_the_lines_it_read_and_their_head() { + let dir = test_dir("viewer-head"); + let store = build_books(&dir); + drop(store); + let snap = ledger_store::Snapshot::load(&dir).unwrap(); + let lines = + ledger_store::journal::read_and_verify_journal(&dir.join(ledger_store::JOURNAL_FILE)) + .unwrap(); + assert_eq!(snap.journal().len(), lines.len()); + assert_eq!(snap.journal_seq(), lines.last().unwrap().seq); + assert_eq!( + snap.head_hash(), + ledger_store::journal::sha256_hex(lines.last().unwrap().to_canonical().as_bytes()) + ); +} + +/// The scrap test's books: a 1,200 laptop, 12-month life, bought Jan 1, +/// paid in cash. Returns (store, asset, cash, gain/loss account). +fn laptop(dir: &std::path::Path) -> (Store, u64, u64, u64) { + let mut store = Store::create(dir, Currency::USD).unwrap(); + let mut add = |code: &str, t| store_add(&mut store, code, t); + let cash = add("1000", AccountType::Asset); + let equipment = add("1500", AccountType::Asset); + let accum = add("1510", AccountType::Asset); + let gain = add("4900", AccountType::Revenue); + let depr = add("6200", AccountType::Expense); + let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); + let buy = store.create_entry(day(1, 1), "laptop").unwrap(); + store + .add_line(buy, equipment, Side::Debit, usd(1200)) + .unwrap(); + store.add_line(buy, cash, Side::Credit, usd(1200)).unwrap(); + store.post_entry(buy).unwrap(); + let asset = store + .register_asset(ledger_core::NewAsset { + description: "laptop".into(), + purchase_date: day(1, 1), + cost: usd(1200), + salvage_value: usd(0), + useful_life_months: 12, + cost_account: equipment, + accum_depr_account: accum, + expense_account: depr, + }) + .unwrap(); + (store, asset, cash, gain) +} + +/// Hand-computed: January's 100 posted, sold Mar 31 for 1,000. Through +/// Mar 31 the schedule says 300, so the final catch-up is 300 - 100 = 200, +/// accumulated 300, book value 900, a gain of 100. +#[test] +fn a_disposal_catches_up_exactly_what_was_not_yet_posted() { + let dir = test_dir("dispose-catch-up"); + let (mut store, asset, cash, gain) = laptop(&dir); + store.run_depreciation(asset, "2026-01").unwrap(); + let day = NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(); + let p = store.dispose_preview(asset, day, usd(1000)).unwrap(); + assert_eq!( + ( + p.final_depreciation, + p.accumulated, + p.book_value, + p.gain_loss + ), + ( + Decimal::from(200), + Decimal::from(300), + Decimal::from(900), + Decimal::from(100) + ) + ); + store + .dispose_asset(asset, day, usd(1000), cash, gain) + .unwrap(); + assert_eq!( + store.ledger().account_balance(gain).unwrap(), + Decimal::from(100) + ); + store.verify().unwrap(); +} + +/// A disposal may be dated the day the asset was bought, never before it. +#[test] +fn a_disposal_may_fall_on_the_purchase_date_but_not_before() { + let dir = test_dir("dispose-dates"); + let (mut store, asset, cash, gain) = laptop(&dir); + let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); + let before = jan1.pred_opt().unwrap(); + assert!(store.dispose_preview(asset, before, usd(1200)).is_err()); + assert!( + store + .dispose_asset(asset, before, usd(1200), cash, gain) + .is_err() + ); + let p = store.dispose_preview(asset, jan1, usd(1200)).unwrap(); + assert_eq!(p.book_value, Decimal::from(1200) - p.accumulated); + store + .dispose_asset(asset, jan1, usd(1200), cash, gain) + .unwrap(); + // Disposed: a second disposal is refused. + assert!(store.dispose_preview(asset, jan1, usd(1)).is_err()); + store.verify().unwrap(); +} + +/// A close to retained earnings that sweeps a draws account journals the +/// sweep, so replay zeroes the draws again (the bare "retained_earnings" +/// form is only for a close with nothing to sweep). +#[test] +fn a_retained_earnings_close_with_a_sweep_replays_it() { + let dir = test_dir("re-sweep"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let mut add = |code: &str, t| store_add(&mut store, code, t); + let cash = add("1000", AccountType::Asset); + let draws = add("3100", AccountType::Equity); + let sales = add("4000", AccountType::Revenue); + let day = |d| NaiveDate::from_ymd_opt(2026, 1, d).unwrap(); + for (dr, cr, n) in [(cash, sales, 500u64), (draws, cash, 200)] { + let e = store.create_entry(day(10), "x").unwrap(); + store.add_line(e, dr, Side::Debit, usd(n)).unwrap(); + store.add_line(e, cr, Side::Credit, usd(n)).unwrap(); + store.post_entry(e).unwrap(); + } + let p = store.create_period("2026-01", day(1), day(31)).unwrap(); + store + .close_period_to( + p, + &ledger_core::CloseTo::RetainedEarnings { sweep: vec![draws] }, + ) + .unwrap(); + let re = store.ledger().chart().find_by_code("3999").unwrap().id; + let state = |s: &Store| { + ( + s.ledger().account_balance(draws).unwrap(), + s.ledger().account_balance(re).unwrap(), + ) + }; + // Net income 500 in, draws 200 swept out: retained earnings 300. + assert_eq!(state(&store), (Decimal::ZERO, Decimal::from(300))); + drop(store); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert_eq!(state(&store), (Decimal::ZERO, Decimal::from(300))); +}