From f1bdac4bca52f3f3e68bba89fa74568dae79e46b Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 19:59:37 -0600 Subject: [PATCH] store: last mutation survivors killed; 98% with four equivalent left Final store mutation run: 357 caught + 1 timeout of 365 viable (from 82% this morning). Three of its seven survivors now have tests: - replay refuses a re-chained journal whose schedule posting differs from what it re-derives (check_replayed_entry guards that event); - a disposal refused for an account's type says the type it needed; - a database whose last pages are damaged while its settings still read fails the integrity check and is rebuilt (verified to fail with the check disabled: "database disk image is malformed"). The four left are equivalent: sort_value's two arms, OpenFlags | vs ^, and writing the long form of a sweepless retained-earnings close. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-store/tests/store.rs | 96 ++++++++++++++++++++++++++++++ docs/testing.md | 1 + 2 files changed, 97 insertions(+) diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 0378a38..3326da6 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -2244,3 +2244,99 @@ fn replay_refuses_a_journaled_entry_it_does_not_rederive() { }; assert!(msg.contains("events"), "{msg}"); } + +/// Replay re-derives a schedule's monthly posting and refuses a journal +/// whose recorded entry differs from it. +#[test] +fn replay_refuses_a_schedule_posting_it_does_not_rederive() { + let dir = test_dir("rederive-schedule"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let prepaid = store_add(&mut store, "1300", AccountType::Asset); + let insurance = store_add(&mut store, "6300", AccountType::Expense); + let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); + let id = store + .register_schedule(ledger_core::NewSchedule { + description: "Insurance".into(), + kind: ledger_core::ScheduleKind::PrepaidExpense, + total: usd(1200), + start: day(1, 1), + months: 12, + balance_account: prepaid, + pl_account: insurance, + }) + .unwrap(); + store.post_schedule_month(id, day(1, 31)).unwrap(); + drop(store); + rechain(&dir, |_| {}); + drop(Store::open(&dir).unwrap()); + rechain(&dir, |lines| { + let posted = lines + .iter_mut() + .find(|l| l.line_type == "schedule.posted") + .unwrap(); + for l in posted.payload["entry"]["lines"].as_array_mut().unwrap() { + l["amount"] = serde_json::json!("99.00"); + } + }); + let err = Store::open(&dir).err().expect("refused").to_string(); + assert!(err.contains("differ"), "{err}"); +} + +/// A disposal refused for an account's type says which type it needed. +#[test] +fn a_disposal_account_refusal_names_the_type_needed() { + let dir = test_dir("dispose-type-message"); + let (mut store, asset, cash, gain) = laptop(&dir); + let jan31 = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(); + let proceeds = store + .dispose_asset(asset, jan31, usd(900), gain, gain) + .unwrap_err() + .to_string(); + let gain_loss = store + .dispose_asset(asset, jan31, usd(900), cash, cash) + .unwrap_err() + .to_string(); + assert!(proceeds.contains("asset"), "{proceeds}"); + assert!(gain_loss.contains("revenue or expense"), "{gain_loss}"); +} + +/// A database whose pages are damaged though its settings still read +/// fails SQLite's integrity check, and is kept aside and rebuilt. +#[test] +fn a_database_with_damaged_pages_is_rebuilt() { + let dir = test_dir("damaged-pages"); + let mut store = build_books(&dir); + for n in 0..200u64 { + store + .add_customer(&format!("customer {n} {}", "x".repeat(100))) + .unwrap(); + } + let before = ledger_core::reports::snapshot(store.ledger()).unwrap(); + drop(store); + let db = dir.join(ledger_store::DB_FILE); + let mut bytes = std::fs::read(&db).unwrap(); + // Scramble only the last pages (the customers just added); the header, + // schema, and settings at the front still read. + let tail = bytes.len() - bytes.len() / 4; + for b in bytes.iter_mut().skip(tail).step_by(7) { + *b ^= 0x5a; + } + std::fs::write(&db, &bytes).unwrap(); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert_eq!( + ledger_core::reports::snapshot(store.ledger()).unwrap(), + before + ); + let kept = std::fs::read_dir(&dir) + .unwrap() + .filter(|e| { + e.as_ref() + .unwrap() + .file_name() + .to_string_lossy() + .contains(".corrupt.") + }) + .count(); + assert_eq!(kept, 1); +} diff --git a/docs/testing.md b/docs/testing.md index ee0e508..ddaba7a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -27,6 +27,7 @@ the store's journal safety; the other crates come next (gap list). | Golden | `tests/golden/fixture_company.json` (5) | `invariants.rs` | `just check` | <1s | every change | | Benchmarks | a baseline (6) | `ledger-core/benches/books.rs`, `ledger-store/benches/store.rs` | `just bench` | ~2 min | none (no budget, by decision) | | Mutation | can the tests fail? | `ledger-core` (2026-10-13: 1,011 caught + 13 timeouts of 1,024 viable, 0 missed) | `just mutants` | ~3 h | weekly | +| Mutation (store) | can the tests fail? | `ledger-store` (2026-10-13: 357 caught + 1 timeout of 365 viable, from 82%; three of its seven survivors then killed; four equivalent left: `sort_value`'s two arms, `OpenFlags` `|` vs `^`, the long form of a sweepless close) | `just mutants-store` | ~12 min | weekly | CI: `.github/workflows/check.yml` runs `just check` on every push; `scheduled.yml` runs proofs, the oracle, fuzzing, and mutation weekly.