store: tests for mutation survivors; drop pre-checks the rehearsal covers
check / check (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 19:32:32 -06:00
1 parent db00b74e82
commit 0cf8fb842f
3 files changed
+160 -48

No files matched your search

+2 -47
View File
@@ -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) =
+2 -1
View File
@@ -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();
+156
View File
@@ -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::<Decimal>().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)));
}