From d0490bf1064c8135b8403f3bd23344996298b205 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 17:00:45 -0600 Subject: [PATCH] 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 = 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 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/assets.rs | 2 + crates/ledger-core/src/kernel.rs | 61 +++++ crates/ledger-core/src/ledger.rs | 2 + crates/ledger-core/src/money.rs | 31 +++ crates/ledger-core/src/payouts.rs | 2 + crates/ledger-core/src/reports.rs | 9 +- crates/ledger-core/tests/analysis.rs | 36 ++- crates/ledger-core/tests/assets.rs | 17 ++ crates/ledger-core/tests/form1099.rs | 10 + crates/ledger-core/tests/invariants.rs | 343 ++++++++++++++++++++++++- crates/ledger-core/tests/payouts.rs | 30 +++ crates/ledger-core/tests/schedules.rs | 97 +++++++ crates/ledger-core/tests/statements.rs | 314 ++++++++++++++++++++++ crates/ledger-core/tests/tax.rs | 36 +++ justfile | 4 +- 15 files changed, 989 insertions(+), 5 deletions(-) diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs index 8c164c2..c6be434 100644 --- a/crates/ledger-core/src/assets.rs +++ b/crates/ledger-core/src/assets.rs @@ -252,6 +252,8 @@ pub struct DisposalAmounts { } /// Compute disposal amounts. Pure math; posting is the caller's job. +/// A disposal may fall on the purchase date (one day held, accrual being +/// inclusive, B-15 in docs/spec/rules.md); one before it is an error. pub fn disposal_amounts( asset: &Asset, date: NaiveDate, diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index eefccd3..db755e2 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -233,6 +233,9 @@ pub fn normal_balance(account_type: AccountType, movement: T) -> Opti pub fn columns(movement: T) -> Option<(T, T)> { if movement > T::ZERO { Some((movement, T::ZERO)) + } else if movement == T::ZERO { + // Never a negative zero: money never prints "-0". + Some((T::ZERO, T::ZERO)) } else { Some((T::ZERO, movement.checked_neg()?)) } @@ -381,6 +384,64 @@ mod tests { assert_eq!(parts, [1805, 0]); } + /// The i64 model's line range: up to 2^40 either way, inclusive. + #[test] + fn i64_line_range_is_two_to_the_fortieth() { + assert!((1i64 << 40).in_line_range()); + assert!((-(1i64 << 40)).in_line_range()); + assert!(!((1i64 << 40) + 1).in_line_range()); + assert!(!i64::MIN.in_line_range()); + } + + /// B-9: a reversal flips every side and keeps accounts and amounts, so + /// with the original it nets every account to zero. + #[test] + fn a_reversal_flips_every_line_and_nets_to_zero() { + let lines = [ + (1u8, Side::Debit, 70i64), + (2, Side::Credit, 50), + (3, Side::Credit, 20), + ]; + let rev = reversal(&lines); + assert_eq!(rev.len(), lines.len()); + for (o, r) in lines.iter().zip(&rev) { + assert_eq!((r.0, r.1, r.2), (o.0, flip(o.1), o.2)); + } + let both = movements(lines.iter().chain(&rev).copied()).unwrap(); + assert_eq!(both, vec![(1, 0), (2, 0), (3, 0)]); + } + + /// Spec scenario 1's preconditions: parts and remainders pair up, and + /// the unit is positive; otherwise nothing is apportioned. + #[test] + fn apportion_refuses_mismatched_inputs_or_a_nonpositive_unit() { + let mut parts = [0i64, 0]; + assert_eq!(apportion(0, &mut parts, &[0], 1), None); + assert_eq!(parts, [0, 0]); + let mut none: [i64; 0] = []; + assert_eq!(apportion(0, &mut none, &[], 0), None); + assert_eq!(apportion(0, &mut none, &[], -1), None); + } + + /// With no parts, only a zero whole is fully apportioned. + #[test] + fn apportion_to_no_parts_accepts_only_zero() { + let mut none: [i64; 0] = []; + assert_eq!(apportion(0, &mut none, &[], 1), Some(())); + assert_eq!(apportion(5, &mut none, &[], 1), None); + } + + /// A zero movement's columns are both a plain zero, never "-0" (found + /// by mutation triage, 2026-10-13: it returned a negative-zero credit). + #[test] + fn a_zero_movement_has_no_negative_zero_column() { + let (d, c) = columns(Decimal::ZERO).unwrap(); + assert_eq!( + (d.to_string(), c.to_string()), + ("0".to_string(), "0".to_string()) + ); + } + #[test] fn credit_line_sides() { assert_eq!(credit_line(5i64), Some((Side::Credit, 5))); diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 01be19f..a81fc4e 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -1892,6 +1892,8 @@ impl Ledger { /// SCENARIOS: /// 1. IF the amount isn't positive, or exceeds what's still owed: SHOULD /// refuse. + /// 2. IF dated before the bill: SHOULD refuse (PaymentBeforeDocument); + /// the bill's own date is fine (DECISION, Stephen 2026-10-13). /// ``` pub fn pay_bill( &mut self, diff --git a/crates/ledger-core/src/money.rs b/crates/ledger-core/src/money.rs index 355d13b..4eeb5fd 100644 --- a/crates/ledger-core/src/money.rs +++ b/crates/ledger-core/src/money.rs @@ -171,6 +171,37 @@ mod tests { ); } + /// ISO 4217 list one (docs/spec/sources/iso-4217.md): one code from + /// each exponent class, and the codes with no minor unit. + #[test] + fn minor_units_follow_iso_4217() { + let units = |c: &str| Currency::new(c).unwrap().minor_units(); + assert_eq!(units("USD"), Some(2)); + assert_eq!(units("JPY"), Some(0)); + assert_eq!(units("BHD"), Some(3)); + assert_eq!(units("KWD"), Some(3)); + assert_eq!(units("CLF"), Some(4)); + assert_eq!(units("UYW"), Some(4)); + assert_eq!(units("XAU"), None); + assert_eq!(units("XDR"), None); + } + + #[test] + fn currency_and_money_print_their_code_and_amount() { + assert_eq!(Currency::new("usd").unwrap().to_string(), "USD"); + assert_eq!(format!("{:?}", Currency::EUR), "Currency(EUR)"); + assert_eq!(usd(d(1250)).to_string(), "12.50 USD"); + assert_eq!(format!("{:?}", usd(d(-5))), "-0.05 USD"); + } + + #[test] + fn is_zero_means_exactly_zero() { + assert!(Money::zero(Currency::USD).is_zero()); + assert!(usd(d(0)).is_zero()); + assert!(!usd(d(1)).is_zero()); + assert!(!usd(d(-1)).is_zero()); + } + #[test] fn checked_arithmetic_rejects_currency_mismatch() { let eur = Money::new(d(100), Currency::EUR); diff --git a/crates/ledger-core/src/payouts.rs b/crates/ledger-core/src/payouts.rs index 57f7677..08a9445 100644 --- a/crates/ledger-core/src/payouts.rs +++ b/crates/ledger-core/src/payouts.rs @@ -84,6 +84,8 @@ pub type PayoutPlanLine = (AccountId, Side, Decimal, Option); /// revenue or liability accounts, e.g. deferred revenue or sales tax /// collected; fees debit expense accounts), and the payout reconciles to /// the cent. +/// A zero deposit (fees and adjustments ate the sales exactly) has no +/// deposit line: every planned line is positive (B-17, docs/spec/rules.md). pub fn payout_plan( payout: &Payout, chart: &ChartOfAccounts, diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index eb134f1..dd941a1 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -826,6 +826,7 @@ pub struct MembersEquityStatement { /// 3. Unclosed earnings and the period's income MUST be split by share, /// to the cent, by largest remainder (DECISION, Stephen 2026-10-13). /// 4. NEVER hide an unexplained change: it goes in "other". +/// 5. An entry dated `start` is period activity, not beginning capital. /// SCENARIOS: /// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse. /// ``` @@ -1111,6 +1112,12 @@ pub struct CashFlowStatement { /// 4. Financing MUST hold changes in equity (other than earnings) and in /// the liabilities named as financing. /// 5. NEVER count closing entries. +/// 6. The period is `[start, end]`, inclusive: an entry dated `start` is +/// period activity, not part of the beginning cash balance. +/// 7. Every line's amount is its cash effect: positive is cash in. Its +/// label names the direction the balance moved ("Increase in" / +/// "Decrease in"; "Proceeds from" / "Repayment of" for financing +/// debt; "Owner contributions" / "Owner distributions" for equity). /// ``` pub fn cash_flow( ledger: &Ledger, @@ -1330,10 +1337,8 @@ pub fn cash_flow( _ => {} } } - let book_value = cost_amount - accum_debit; // Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss. debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero()); - let _ = book_value; disposal_gain_loss += gain - loss; investing.push(CashFlowLine { label: format!("Sale of fixed assets — {proceeds_label}"), diff --git a/crates/ledger-core/tests/analysis.rs b/crates/ledger-core/tests/analysis.rs index ed7efcd..156297e 100644 --- a/crates/ledger-core/tests/analysis.rs +++ b/crates/ledger-core/tests/analysis.rs @@ -6,7 +6,8 @@ use chrono::NaiveDate; use ledger_core::analysis::{ - Interval, balance_history, balance_series, flows_by_interval, pnl_by_interval, + 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; @@ -79,3 +80,36 @@ fn balances_pnl_and_flows_over_time() { 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()); +} diff --git a/crates/ledger-core/tests/assets.rs b/crates/ledger-core/tests/assets.rs index 28e0609..eb54059 100644 --- a/crates/ledger-core/tests/assets.rs +++ b/crates/ledger-core/tests/assets.rs @@ -400,3 +400,20 @@ fn one_month_useful_life_is_valid() { let total: Decimal = sched.iter().map(|l| l.amount).sum(); assert_eq!(total, Decimal::new(30000, 2)); } + +#[test] +fn disposal_on_the_purchase_date_is_allowed() { + let (mut ledger, cost, accum, expense) = test_ledger(); + let id = van_asset(&mut ledger, 60_000, cost, accum, expense); + let asset = ledger.get_asset(id).unwrap().clone(); + // Held one day: 1,000/month × 1/31 = 32.26 of depreciation. + let d = disposal_amounts(&asset, date(2026, 1, 15), usd("60000")).unwrap(); + assert_eq!(d.accumulated, Decimal::new(3226, 2)); + assert_eq!(d.book_value, Decimal::new(5_996_774, 2)); + assert_eq!(d.gain_loss, Decimal::new(3226, 2)); + // The day before purchase is not a disposal. + assert!(matches!( + disposal_amounts(&asset, date(2026, 1, 14), usd("60000")), + Err(ledger_core::LedgerError::DisposalBeforePurchase(_)) + )); +} diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index e3cf814..09ec9ae 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -203,3 +203,13 @@ fn dated_rules_go_stale_on_their_review_date() { ); assert_eq!(stale_rules(d(2030, 6, 1)).len(), 1); } + +#[test] +fn forms_have_their_irs_names() { + assert_eq!(Form1099::Nec.label(), "1099-NEC"); + assert_eq!(Form1099::Misc.label(), "1099-MISC"); + // A label parses back to its form. + for f in [Form1099::Nec, Form1099::Misc] { + assert_eq!(Form1099::parse(f.label()), Some(f)); + } +} diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index b524663..a93e1ad 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -12,7 +12,8 @@ use chrono::NaiveDate; use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance}; use ledger_core::{ - AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side, + AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger, + LedgerError, Money, Side, }; use rust_decimal::Decimal; @@ -302,6 +303,39 @@ fn foreign_currency_line_is_rejected() { ); } +/// R-1 / B-3: one currency throughout an entry. Even when every line +/// matches its own account's currency (a chart the ledger never builds +/// today, R-5), an entry mixing currencies is refused. +#[test] +fn an_entry_mixing_currencies_is_refused_even_on_matching_accounts() { + let c = fixture_company(); + let mut ledger = c.ledger; + let id = ledger.create_entry(date(21), "two moneys"); + ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); + ledger + .add_line(id, c.revenue, Side::Credit, usd(100)) + .unwrap(); + let mut json = serde_json::to_value(ledger.get_entry(id).unwrap()).unwrap(); + json["lines"][1]["amount"]["currency"] = serde_json::to_value(Currency::EUR).unwrap(); + let mixed: JournalEntry = serde_json::from_value(json).unwrap(); + + let mut chart = ChartOfAccounts::default(); + chart + .add(ledger.chart().get(c.cash).unwrap().clone()) + .unwrap(); + let mut revenue = ledger.chart().get(c.revenue).unwrap().clone(); + revenue.currency = Currency::EUR; + chart.add(revenue).unwrap(); + + assert_eq!( + mixed.validate(&chart), + Err(LedgerError::CurrencyMismatch { + expected: "USD".to_string(), + found: "EUR".to_string(), + }) + ); +} + #[test] fn duplicate_account_code_is_rejected() { let c = fixture_company(); @@ -1062,3 +1096,310 @@ fn a_foreign_line_is_booked_in_whole_base_units() { // Rounds to nothing: refused. assert!(book(Currency::USD, Currency::EUR, "0.001", "1").is_err()); } + +// --------------------------------------------------------------------------- +// Ledger bookkeeping surfaces: id allocators, FX table, tax percent, +// reconciliation, and closing variants (mutation-testing gaps, 2026-10-13). +// --------------------------------------------------------------------------- + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +/// `next_*_id` predicts the id the next record receives, and each record +/// gets its own. +#[test] +fn id_allocators_predict_the_next_id_and_never_repeat() { + use ledger_core::{Frequency, NewRecurring, NewSchedule, ScheduleKind}; + let c = fixture_company(); + let mut l = c.ledger; + let schedule = |n: &str| NewSchedule { + description: n.into(), + kind: ScheduleKind::PrepaidExpense, + total: usd(1_200), + start: date(1), + months: 12, + balance_account: c.equipment, + pl_account: c.rent, + }; + let mut seen = Vec::new(); + for n in ["a", "b"] { + let predicted = l.next_schedule_id(); + let id = l.register_schedule(schedule(n)).unwrap(); + assert_eq!(id, predicted); + assert!(!seen.contains(&id)); + seen.push(id); + } + let mut listed: Vec<_> = l.schedules().map(|s| s.id).collect(); + listed.sort(); + assert_eq!(listed, seen); + + let recurring = || NewRecurring { + description: "rent".into(), + lines: vec![ + (c.rent, Side::Debit, usd(800)), + (c.cash, Side::Credit, usd(800)), + ], + frequency: Frequency::Monthly, + start: date(1), + end: None, + }; + let mut seen = Vec::new(); + for _ in 0..2 { + let predicted = l.next_recurring_id(); + let id = l.register_recurring(recurring()).unwrap(); + assert_eq!(id, predicted); + assert!(!seen.contains(&id)); + seen.push(id); + } + + let v = l.add_vendor("Vend").unwrap(); + for _ in 0..2 { + let predicted = l.next_bill_id(); + let id = l.create_bill(v, date(5), date(30), doc(100)).unwrap(); + assert_eq!(id, predicted); + assert_ne!(l.next_bill_id(), id); + } +} + +/// The FX table records positive rates for foreign currencies only, and +/// gives back exactly what was recorded. +#[test] +fn fx_rates_are_positive_foreign_and_read_back_exactly() { + let mut l = Ledger::new(Currency::USD); + let rate: Decimal = "1.0834".parse().unwrap(); + assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), None); + assert_eq!( + l.set_fx_rate(Currency::EUR, date(5), Decimal::ZERO), + Err(LedgerError::InvalidFxRate(Decimal::ZERO)) + ); + assert!(l.set_fx_rate(Currency::EUR, date(5), -rate).is_err()); + assert!(l.set_fx_rate(Currency::USD, date(5), rate).is_err()); + assert_eq!(l.get_fx_rate(Currency::USD, date(5)), None); + l.set_fx_rate(Currency::EUR, date(5), rate).unwrap(); + assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), Some(rate)); + assert_eq!(l.get_fx_rate(Currency::EUR, date(6)), None); +} + +/// A deductible percent is 0 to 100 inclusive (set_account_tax's spec); +/// 0 is real (penalties). +#[test] +fn deductible_percent_bounds_are_inclusive() { + let c = fixture_company(); + let mut l = c.ledger; + let rent = c.rent; + l.set_account_tax(rent, Some("meals"), Some(Decimal::ZERO)) + .unwrap(); + l.set_account_tax(rent, Some("meals"), Some(Decimal::from(100))) + .unwrap(); + assert!(matches!( + l.set_account_tax(rent, Some("meals"), Some(cents(-1))), + Err(LedgerError::InvalidTaxCategory(_)) + )); +} + +/// Spec (pay_bill scenario 2): a payment may not predate its bill; the +/// bill's own date is fine. +#[test] +fn a_bill_can_be_paid_on_its_own_date() { + let c = fixture_company(); + let mut l = c.ledger; + let v = l.add_vendor("Vend").unwrap(); + let bill = l.create_bill(v, date(21), date(31), doc(100)).unwrap(); + l.pay_bill(bill, date(21), cents(1_000), c.cash).unwrap(); +} + +/// Reconciliation invariants 1 and 4: on a credit-normal account the book +/// balance is the account's balance, and a line dated on the statement +/// date is outstanding until cleared. +#[test] +fn reconciling_a_card_reads_its_normal_side_and_includes_the_statement_date() { + let c = fixture_company(); + let mut l = c.ledger; + let card = l + .add_account("2100", "Card", AccountType::Liability, None, false) + .unwrap(); + post( + &mut l, + date(21), + "charge", + &[(c.rent, Side::Debit, 500), (card, Side::Credit, 500)], + ); + post( + &mut l, + date(25), + "pay card", + &[(card, Side::Debit, 200), (c.cash, Side::Credit, 200)], + ); + let r = l + .reconciliation_report(card, date(25), Decimal::from(300)) + .unwrap(); + assert_eq!(r.book_balance, Decimal::from(300)); + assert_eq!(r.book_balance, l.account_balance(card).unwrap()); + let outstanding: Vec<_> = r.uncleared_lines.iter().map(|u| u.date).collect(); + assert_eq!(outstanding, vec![date(21), date(25)]); +} + +/// B-11: closing to retained earnings with nothing to close but a swept +/// account's balance still sweeps it. +#[test] +fn a_close_sweeps_dividends_even_without_income() { + let mut l = books_with("3100", "Dividends", AccountType::Equity, false); + let dividends = 2; + post( + &mut l, + date(10), + "dividend", + &[(dividends, Side::Debit, 100), (1, Side::Credit, 100)], + ); + let jan = l.create_period("2026-01", date(1), date(31)).unwrap(); + let to = ledger_core::CloseTo::RetainedEarnings { + sweep: vec![dividends], + }; + let predicted = l.preview_close_period_to(jan, &to).unwrap(); + assert_eq!(predicted, Some(l.next_entry_id())); + assert_eq!(l.close_period_to(jan, &to).unwrap(), predicted); + assert_eq!(l.account_balance(dividends).unwrap(), Decimal::ZERO); + let re = l.chart().find_by_code("3999").unwrap().id; + assert_eq!(l.account_balance(re).unwrap(), Decimal::from(-100)); +} + +/// `preview_close_period` names the entry a close with income will post. +#[test] +fn preview_close_names_the_closing_entry() { + let mut c = fixture_company(); + let jan = c + .ledger + .create_period("2026-01", date(1), date(31)) + .unwrap(); + let next = c.ledger.next_entry_id(); + assert_eq!(c.ledger.preview_close_period(jan).unwrap(), Some(next)); + assert_eq!(c.ledger.close_period(jan).unwrap(), Some(next)); +} + +/// Closes journaled under the pre-2026-10-13 rule replay under it: each +/// share rounded half away from zero, the last member absorbing the rest. +/// Net 1.05 at 30/30/40 is 0.32, 0.32, 0.41 (largest remainder would give +/// 0.32, 0.31, 0.42). +#[test] +fn a_last_absorbs_close_gives_the_rounding_to_the_last_member() { + let mut l = books_with("4000", "Sales", AccountType::Revenue, false); + let capital: Vec = ["3001", "3002", "3003"] + .iter() + .map(|code| { + l.add_account(code, "Capital", AccountType::Equity, None, false) + .unwrap() + }) + .collect(); + let e = l.create_entry(date(5), "sale"); + let m = Money::new(cents(105), Currency::USD); + l.add_line(e, 1, Side::Debit, m).unwrap(); + l.add_line(e, 2, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + let jan = l.create_period("2026-01", date(1), date(31)).unwrap(); + let members = capital + .iter() + .zip(["0.3", "0.3", "0.4"]) + .map(|(&capital, share)| ledger_core::MemberClose { + capital, + share: share.parse().unwrap(), + distributions: None, + }) + .collect(); + l.close_period_to(jan, &ledger_core::CloseTo::MembersLastAbsorbs(members)) + .unwrap(); + let got: Vec = capital + .iter() + .map(|&a| l.account_balance(a).unwrap()) + .collect(); + assert_eq!(got, vec![cents(32), cents(32), cents(41)]); +} + +/// B-12: a period whose closing date already lies in a closed period +/// can't close; the refusal names that period. +#[test] +fn a_period_ending_inside_a_closed_period_cannot_close() { + let mut c = fixture_company(); + let q1 = c + .ledger + .create_period( + "2026-Q1", + date(1), + NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(), + ) + .unwrap(); + let jan = c + .ledger + .create_period("2026-01", date(1), date(31)) + .unwrap(); + c.ledger.close_period(q1).unwrap(); + assert_eq!( + c.ledger.close_period(jan), + Err(LedgerError::ClosedPeriod(q1, date(31).to_string())) + ); +} + +/// Replay (for journaling stores) rebuilds invoice and bill records +/// exactly: ids follow the allocator, payments accumulate, vendors tag +/// the entries. A record replayed out of order is refused. +#[test] +fn replayed_documents_and_payments_rebuild_the_records() { + use ledger_core::DocumentStatus; + let mut l = books_with("2000", "Accounts Payable", AccountType::Liability, false); + let c = l.add_customer("Acme").unwrap(); + let v = l.add_vendor("Vend").unwrap(); + + let inv = l.next_invoice_id(); + assert_eq!( + l.replay_invoice(inv + 1, c, date(5), date(30), doc(100), 10), + Err(LedgerError::ReplayIdMismatch(inv + 1, inv)) + ); + l.replay_invoice(inv, c, date(5), date(30), doc(100), 10) + .unwrap(); + // Each later record replays at the id the allocator now predicts. + let mut ids = vec![inv]; + for entry in [13, 14] { + let next = l.next_invoice_id(); + l.replay_invoice(next, c, date(5), date(30), doc(1), entry) + .unwrap(); + ids.push(next); + } + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 3); + assert!(ids.iter().all(|&i| l.get_invoice(i).is_some())); + l.replay_invoice_payment(inv, Decimal::from(30), 11) + .unwrap(); + l.replay_invoice_payment(inv, Decimal::from(20), 12) + .unwrap(); + let i = l.get_invoice(inv).unwrap(); + assert_eq!(i.amount_paid, Decimal::from(50)); + assert_eq!(i.balance(), Decimal::from(50)); + assert_eq!(i.status, DocumentStatus::Partial); + + let bill = l.next_bill_id(); + assert_eq!( + l.replay_bill(bill + 1, v, date(5), date(30), doc(100), 20), + Err(LedgerError::ReplayIdMismatch(bill + 1, bill)) + ); + assert_eq!(l.entry_vendor(20), None); + l.replay_bill(bill, v, date(5), date(30), doc(100), 20) + .unwrap(); + assert_eq!(l.entry_vendor(20), Some(v)); + let mut ids = vec![bill]; + for entry in [23, 24] { + let next = l.next_bill_id(); + l.replay_bill(next, v, date(5), date(30), doc(1), entry) + .unwrap(); + ids.push(next); + } + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 3); + l.replay_bill_payment(bill, Decimal::from(30), 21).unwrap(); + l.replay_bill_payment(bill, Decimal::from(70), 22).unwrap(); + let b = l.get_bill(bill).unwrap(); + assert_eq!(b.amount_paid, Decimal::from(100)); + assert_eq!(b.status, DocumentStatus::Paid); + assert_eq!(l.entry_vendor(22), Some(v)); +} diff --git a/crates/ledger-core/tests/payouts.rs b/crates/ledger-core/tests/payouts.rs index a00618b..72b0593 100644 --- a/crates/ledger-core/tests/payouts.rs +++ b/crates/ledger-core/tests/payouts.rs @@ -153,3 +153,33 @@ fn accounts_must_fit_their_roles() { p.revenue.clear(); assert!(invalid(p), "needs revenue"); } + +#[test] +fn a_zero_payout_has_no_bank_line() { + let b = books(); + // 10.00 of sales, 3.00 of fees, a 7.00 refund: nothing deposited. + let p = Payout { + deposit_account: b.bank, + deposit: dec("0.00"), + revenue: vec![line(b.sales, "10.00")], + fees: vec![line(b.fees, "3.00")], + adjustments: vec![PayoutAdjustment { + account: b.refunds, + side: Side::Debit, + amount: dec("7.00"), + class: None, + }], + }; + let (plan, totals) = payout_plan(&p, b.l.chart()).unwrap(); + assert_eq!(totals.deposit, Decimal::ZERO); + assert_eq!( + plan.iter() + .map(|(a, s, m, _)| (*a, *s, *m)) + .collect::>(), + [ + (b.fees, Side::Debit, dec("3.00")), + (b.refunds, Side::Debit, dec("7.00")), + (b.sales, Side::Credit, dec("10.00")), + ] + ); +} diff --git a/crates/ledger-core/tests/schedules.rs b/crates/ledger-core/tests/schedules.rs index 5ccb61a..ff8fc1f 100644 --- a/crates/ledger-core/tests/schedules.rs +++ b/crates/ledger-core/tests/schedules.rs @@ -224,3 +224,100 @@ fn recurring_entries_generate_drafts_never_posts() { assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_)))); let _ = b.revenue; } + +#[test] +fn schedule_length_is_one_to_six_hundred_months() { + let b = books(); + let sched = |months| NewSchedule { + description: "Insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd("600"), + start: d(1, 1), + months, + balance_account: b.prepaid, + pl_account: b.insurance, + }; + let chart = b.l.chart(); + for ok in [1, 600] { + assert!( + ledger_core::schedules::validate_schedule(&sched(ok), chart).is_ok(), + "{ok} months" + ); + } + for bad in [0, 601] { + assert!( + matches!( + ledger_core::schedules::validate_schedule(&sched(bad), chart), + Err(LedgerError::InvalidSchedule(_)) + ), + "{bad} months" + ); + } +} + +#[test] +fn recurring_templates_take_any_number_of_lines_and_may_end_on_their_start() { + let mut b = books(); + // Three balanced lines are a valid template. + b.l.register_recurring(NewRecurring { + description: "Rent and insurance".into(), + lines: vec![ + (b.rent, Side::Debit, usd("2000")), + (b.insurance, Side::Debit, usd("100")), + (b.cash, Side::Credit, usd("2100")), + ], + frequency: Frequency::Monthly, + start: d(1, 1), + end: None, + }) + .unwrap(); + // An end on the start date is one occurrence, not an error. + let once = + b.l.register_recurring(NewRecurring { + description: "One-off".into(), + lines: vec![ + (b.rent, Side::Debit, usd("2000")), + (b.cash, Side::Credit, usd("2000")), + ], + frequency: Frequency::Monthly, + start: d(5, 1), + end: Some(d(5, 1)), + }) + .unwrap(); + let r = b.l.recurring_entries().find(|r| r.id == once).unwrap(); + assert_eq!(r.due(d(12, 31)), [d(5, 1)]); + // An end before the start is. + let bad = b.l.register_recurring(NewRecurring { + description: "Backwards".into(), + lines: vec![ + (b.rent, Side::Debit, usd("2000")), + (b.cash, Side::Credit, usd("2000")), + ], + frequency: Frequency::Monthly, + start: d(5, 2), + end: Some(d(5, 1)), + }); + assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_)))); +} + +#[test] +fn frequencies_parse_and_step_from_the_start() { + assert_eq!(Frequency::parse("weekly"), Some(Frequency::Weekly)); + assert_eq!(Frequency::parse(" Monthly "), Some(Frequency::Monthly)); + assert_eq!(Frequency::parse("QUARTERLY"), Some(Frequency::Quarterly)); + assert_eq!(Frequency::parse("annually"), Some(Frequency::Yearly)); + assert_eq!(Frequency::parse("fortnightly"), None); + // Weekly: the kth occurrence is 7k days on. + let start = d(1, 1); + for (k, want) in [(0, d(1, 1)), (1, d(1, 8)), (2, d(1, 15)), (3, d(1, 22))] { + assert_eq!(Frequency::Weekly.nth(start, k), Some(want), "week {k}"); + } + assert_eq!(Frequency::Quarterly.nth(start, 2), Some(d(7, 1))); + for (k, year) in [(1, 2027), (2, 2028), (3, 2029)] { + assert_eq!( + Frequency::Yearly.nth(start, k), + NaiveDate::from_ymd_opt(year, 1, 1), + "year {k}" + ); + } +} diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index 4bf3e2c..dd7e31c 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -959,3 +959,317 @@ fn closes_journaled_under_the_old_rule_replay_with_it() { [cents("33.33"), cents("33.33"), cents("33.34")] ); } + +// --------------------------------------------------------------------------- +// Boundaries, signs, and labels found untested by mutation testing +// (cargo-mutants on src/reports.rs). Expected values are hand-computed. +// --------------------------------------------------------------------------- + +#[test] +fn the_balance_checks_catch_an_imbalance() { + let b = books(false); + let mut tb = trial_balance_as_of(&b.ledger, d(2, 28)); + assert!(tb.balanced()); + tb.total_credits += cents("0.01"); + assert!(!tb.balanced(), "a one-cent difference is out of balance"); + + let mut bs = balance_sheet(&b.ledger).unwrap(); + assert!(bs.balanced()); + bs.liabilities += cents("0.01"); + assert!(!bs.balanced()); + + let mut bss = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap(); + assert!(bss.balanced()); + bss.total_assets -= cents("0.01"); + assert!(!bss.balanced()); +} + +#[test] +fn a_one_day_general_ledger_is_valid_and_a_backwards_one_is_not() { + let b = books(false); + // Jan 15 only: cash begins at the Jan 2 contribution, then the 2,500 + // client payment lands. + let gl = general_ledger(&b.ledger, d(1, 15), d(1, 15)).unwrap(); + let cash = gl.accounts.iter().find(|a| a.code == "1000").unwrap(); + assert_eq!(cash.beginning_balance, dec(10_000)); + assert_eq!(cash.postings.len(), 1); + assert_eq!(cash.ending_balance, dec(12_500)); + assert!(matches!( + general_ledger(&b.ledger, d(1, 16), d(1, 15)), + Err(LedgerError::InvalidDateRange { .. }) + )); +} + +fn cf_params(start: NaiveDate, end: NaiveDate, cash: AccountId) -> CashFlowParams { + CashFlowParams { + start, + end, + cash_accounts: vec![cash], + financing_accounts: vec![], + } +} + +fn reconciles(cf: &ledger_core::CashFlowStatement) { + // B-14 / cash_flow INVARIANT 1: the three sections sum to the change in + // cash, exactly. + assert_eq!( + cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, + cf.net_change_in_cash + ); + assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash); +} + +#[test] +fn cash_flow_starts_counting_on_the_start_date() { + let b = books(false); + // A one-day period is a valid range. The Jan 15 payment is in it, not + // in the beginning balance. + let cf = cash_flow(&b.ledger, &cf_params(d(1, 15), d(1, 15), b.cash)).unwrap(); + assert_eq!(cf.cash_beginning, dec(10_000)); + assert_eq!(cf.cash_ending, dec(12_500)); + assert_eq!(cf.net_income, dec(2_500)); + reconciles(&cf); + // Backwards is refused. + assert!(matches!( + cash_flow(&b.ledger, &cf_params(d(1, 16), d(1, 15), b.cash)), + Err(LedgerError::InvalidDateRange { .. }) + )); +} + +#[test] +fn cash_flow_labels_say_which_way_each_balance_moved() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let ar = l + .add_account("1100", "Receivable", AccountType::Asset, None, false) + .unwrap(); + let ap = l + .add_account("2000", "Payable", AccountType::Liability, None, false) + .unwrap(); + let loan = l + .add_account("2500", "Loan", AccountType::Liability, None, false) + .unwrap(); + let capital = l + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let rev = l + .add_account("4000", "Revenue", AccountType::Revenue, None, false) + .unwrap(); + let exp = l + .add_account("6000", "Expense", AccountType::Expense, None, false) + .unwrap(); + // January: every account starts February with a balance. + post(&mut l, d(1, 2), "Contribution", cash, capital, 10_000); + post(&mut l, d(1, 3), "Invoice", ar, rev, 1_000); + post(&mut l, d(1, 4), "Bill", exp, ap, 500); + post(&mut l, d(1, 5), "Borrow", cash, loan, 2_000); + // February: every balance goes up. + post(&mut l, d(2, 3), "Invoice", ar, rev, 100); + post(&mut l, d(2, 4), "Bill", exp, ap, 50); + post(&mut l, d(2, 5), "Borrow", cash, loan, 300); + post(&mut l, d(2, 6), "Contribution", cash, capital, 400); + // March: every balance comes back down. + post(&mut l, d(3, 3), "Collect", cash, ar, 100); + post(&mut l, d(3, 4), "Pay bill", ap, cash, 50); + post(&mut l, d(3, 5), "Repay", loan, cash, 300); + post(&mut l, d(3, 6), "Distribution", capital, cash, 400); + + let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> { + v.iter().map(|l| (l.label.clone(), l.amount)).collect() + }; + let s = |x: &str| x.to_string(); + let params = |start, end| CashFlowParams { + start, + end, + cash_accounts: vec![cash], + financing_accounts: vec![loan], + }; + + // February: net income 100 - 50 = 50; receivable up 100 (cash out), + // payable up 50 (cash in): operations 0. Borrowed 300, contributed 400. + let feb = cash_flow(&l, ¶ms(d(2, 1), d(2, 28))).unwrap(); + assert_eq!(feb.net_income, dec(50)); + assert_eq!( + lines(&feb.working_capital), + [ + (s("Increase in 1100 Receivable"), dec(-100)), + (s("Increase in 2000 Payable"), dec(50)), + ] + ); + assert_eq!( + lines(&feb.financing), + [ + (s("Proceeds from 2500 Loan"), dec(300)), + (s("Owner contributions — 3000 Capital"), dec(400)), + ] + ); + assert_eq!(feb.cash_from_operations, dec(0)); + assert_eq!(feb.net_change_in_cash, dec(700)); + reconciles(&feb); + + // March: the mirror image. Collected 100, paid 50, repaid 300, + // distributed 400. + let mar = cash_flow(&l, ¶ms(d(3, 1), d(3, 31))).unwrap(); + assert_eq!(mar.net_income, dec(0)); + assert_eq!( + lines(&mar.working_capital), + [ + (s("Decrease in 1100 Receivable"), dec(100)), + (s("Decrease in 2000 Payable"), dec(-50)), + ] + ); + assert_eq!( + lines(&mar.financing), + [ + (s("Repayment of 2500 Loan"), dec(-300)), + (s("Owner distributions — 3000 Capital"), dec(-400)), + ] + ); + assert_eq!(mar.net_change_in_cash, dec(-650)); + reconciles(&mar); +} + +#[test] +fn a_loss_on_disposal_moves_out_of_operating_into_investing() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let equipment = l + .add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let accum = l + .add_account("1501", "Accum Depr", AccountType::Asset, None, false) + .unwrap(); + let capital = l + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let depr = l + .add_account("6500", "Depreciation", AccountType::Expense, None, false) + .unwrap(); + let loss = l + .add_account( + "6900", + "Loss on Disposal", + AccountType::Expense, + None, + false, + ) + .unwrap(); + l.register_asset(ledger_core::NewAsset { + description: "Laptop".to_string(), + purchase_date: d(1, 10), + cost: usd(1_000), + salvage_value: usd(0), + useful_life_months: 10, + cost_account: equipment, + accum_depr_account: accum, + expense_account: depr, + }) + .unwrap(); + post(&mut l, d(1, 5), "Contribution", cash, capital, 5_000); + post(&mut l, d(1, 10), "Buy laptop", equipment, cash, 1_000); + post(&mut l, d(1, 31), "Depreciation", depr, accum, 100); + // Sold for 600 at a book value of 900: a 300 loss. + let e = l.create_entry(d(2, 15), "Sell laptop"); + l.add_line(e, cash, Side::Debit, usd(600)).unwrap(); + l.add_line(e, accum, Side::Debit, usd(100)).unwrap(); + l.add_line(e, loss, Side::Debit, usd(300)).unwrap(); + l.add_line(e, equipment, Side::Credit, usd(1_000)).unwrap(); + l.post_entry(e).unwrap(); + + let lines = |v: &[ledger_core::CashFlowLine]| -> Vec<(String, Decimal)> { + v.iter().map(|l| (l.label.clone(), l.amount)).collect() + }; + // February: net income -300; the loss is added back (positive, it used + // no cash), so operations is 0; the 600 proceeds are investing. + let feb = cash_flow(&l, &cf_params(d(2, 1), d(2, 28), cash)).unwrap(); + assert_eq!(feb.net_income, dec(-300)); + assert_eq!( + lines(&feb.working_capital), + [( + "Less: gain on sale of fixed assets (in investing)".to_string(), + dec(300) + )] + ); + assert_eq!(feb.cash_from_operations, dec(0)); + assert_eq!( + lines(&feb.investing), + [("Sale of fixed assets — 1000 Cash".to_string(), dec(600))] + ); + assert_eq!(feb.net_change_in_cash, dec(600)); + reconciles(&feb); +} + +#[test] +fn a_contribution_on_the_start_date_is_a_contribution_not_beginning_capital() { + let (l, members) = partnership(false); + // The partnership fixture's contributions are dated Jan 2. Starting the + // statement that day must give the same rows as all of January. + let me = members_equity(&l, d(1, 2), d(1, 31), &members).unwrap(); + let row = |c: &ledger_core::MemberEquityColumn| (c.beginning, c.contributions, c.ending); + assert_eq!( + row(&me.members[0]), + (cents("0"), cents("600"), cents("933.34")) + ); + assert_eq!( + row(&me.members[1]), + (cents("0"), cents("400"), cents("733.33")) + ); +} + +#[test] +fn distribution_accounts_reduce_capital_and_returns_reduce_distributions() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let cap_a = l + .add_account("3000", "Capital — Alice", AccountType::Equity, None, false) + .unwrap(); + let cap_b = l + .add_account("3001", "Capital — Bob", AccountType::Equity, None, false) + .unwrap(); + let dist_a = l + .add_account("3100", "Dist — Alice", AccountType::Equity, None, false) + .unwrap(); + let dist_b = l + .add_account("3101", "Dist — Bob", AccountType::Equity, None, false) + .unwrap(); + post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100); + post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100); + post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20); + // February: Bob takes 30 and hands 10 back: 20 distributed, net. + post(&mut l, d(2, 5), "Bob's distribution", dist_b, cash, 30); + post(&mut l, d(2, 6), "Bob returns some", cash, dist_b, 10); + let half = cents("0.5"); + let members = vec![ + MemberCapital { + name: "Alice".into(), + capital_account: cap_a, + distribution_account: Some(dist_a), + share: half, + }, + MemberCapital { + name: "Bob".into(), + capital_account: cap_b, + distribution_account: Some(dist_b), + share: half, + }, + ]; + // No close: Bob's January distribution is still in his distributions + // account, and it comes off his capital, not the partnership's. + let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap(); + let row = |c: &ledger_core::MemberEquityColumn| { + (c.beginning, c.contributions, c.distributions, c.ending) + }; + assert_eq!(row(&feb.members[0]), (dec(100), dec(0), dec(0), dec(100))); + assert_eq!(row(&feb.members[1]), (dec(80), dec(0), dec(20), dec(60))); + assert_eq!( + feb.total.ending, + balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity + ); + assert!(feb.members.iter().all(|m| m.other.is_zero())); +} diff --git a/crates/ledger-core/tests/tax.rs b/crates/ledger-core/tests/tax.rs index fd788a6..8934602 100644 --- a/crates/ledger-core/tests/tax.rs +++ b/crates/ledger-core/tests/tax.rs @@ -228,3 +228,39 @@ fn forms_parse_from_common_spellings() { } assert_eq!(TaxForm::parse("990"), None); } + +#[test] +fn cost_of_goods_reduces_net_like_a_deduction() { + let mut l = books(); + let cash = l.chart().find_by_code("1000").unwrap().id; + let cogs = l + .add_account("5000", "Cost of sales", AccountType::Expense, None, false) + .unwrap(); + l.set_account_tax(cogs, Some("cost_of_goods_sold"), None) + .unwrap(); + let e = l.create_entry(d(2, 1), "x"); + let m = Money::new(dec("1000"), Currency::USD); + l.add_line(e, cogs, Side::Debit, m).unwrap(); + l.add_line(e, cash, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap(); + assert_eq!(ts.cost_of_goods.len(), 1); + assert_eq!(ts.cost_of_goods[0].book, dec("1000")); + assert_eq!(ts.cost_of_goods[0].tax, dec("1000")); + // The fixture's nets, less 1,000 of cost of goods sold. + assert_eq!(ts.book_net(), dec("4276.67")); + assert_eq!(ts.tax_net(), dec("4483.33")); +} + +#[test] +fn forms_have_their_irs_names() { + let labels: Vec<&str> = TaxForm::ALL.iter().map(|f| f.label()).collect(); + assert_eq!( + labels, + ["Schedule C", "Form 1065", "Form 1120-S", "Form 1120"] + ); + // A label parses back to its form. + for f in TaxForm::ALL { + assert_eq!(TaxForm::parse(f.label()), Some(f)); + } +} diff --git a/justfile b/justfile index ceb488e..a246de8 100644 --- a/justfile +++ b/justfile @@ -62,7 +62,9 @@ miri: # Mutation testing of the core: do the tests catch planted bugs? mutants: - cargo mutants -p ledger-core --exclude crates/ledger-core/src/proofs.rs -j 4 --timeout 120 + # Core and store tests (the store's replay tests reach Ledger::replay_*); + # the whole workspace would rebuild the viewer for every mutant. + cargo mutants -p ledger-core --exclude crates/ledger-core/src/proofs.rs --test-package ledger-core --test-package ledger-store -j 4 --timeout 120 # Benchmarks of the core's hot paths (no budget gates them yet) bench: