asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs

This commit is contained in:
swaits committed 2026-10-11 02:25:18 +00:00
1 parent 31d3c61e32
commit 99b5b5d0b8
1 file changed
+142 -33
+142 -33
View File
@@ -11,7 +11,9 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance}; use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
use ledger_core::{AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side}; use ledger_core::{
AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side,
};
use rust_decimal::Decimal; use rust_decimal::Decimal;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -49,15 +51,29 @@ struct Company {
fn fixture_company() -> Company { fn fixture_company() -> Company {
let mut ledger = Ledger::new(Currency::USD); let mut ledger = Ledger::new(Currency::USD);
let cash = ledger.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap(); let cash = ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let ar = ledger let ar = ledger
.add_account("1200", "Accounts Receivable", AccountType::Asset, None, false) .add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap(); .unwrap();
let equipment = ledger let equipment = ledger
.add_account("1500", "Equipment", AccountType::Asset, None, false) .add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap(); .unwrap();
let ap = ledger let ap = ledger
.add_account("2000", "Accounts Payable", AccountType::Liability, None, false) .add_account(
"2000",
"Accounts Payable",
AccountType::Liability,
None,
false,
)
.unwrap(); .unwrap();
let equity = ledger let equity = ledger
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false) .add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
@@ -70,32 +86,51 @@ fn fixture_company() -> Company {
.unwrap(); .unwrap();
// Owner invests $10,000 cash. // Owner invests $10,000 cash.
post(&mut ledger, date(2), "Owner investment", &[ post(
(cash, Side::Debit, 10_000), &mut ledger,
(equity, Side::Credit, 10_000), date(2),
]); "Owner investment",
&[(cash, Side::Debit, 10_000), (equity, Side::Credit, 10_000)],
);
// Buy equipment for $2,500 cash. // Buy equipment for $2,500 cash.
post(&mut ledger, date(5), "Buy equipment", &[ post(
(equipment, Side::Debit, 2_500), &mut ledger,
(cash, Side::Credit, 2_500), date(5),
]); "Buy equipment",
&[(equipment, Side::Debit, 2_500), (cash, Side::Credit, 2_500)],
);
// Bill a client $4,000. // Bill a client $4,000.
post(&mut ledger, date(10), "Invoice client", &[ post(
(ar, Side::Debit, 4_000), &mut ledger,
(revenue, Side::Credit, 4_000), date(10),
]); "Invoice client",
&[(ar, Side::Debit, 4_000), (revenue, Side::Credit, 4_000)],
);
// Pay January rent, $800. // Pay January rent, $800.
post(&mut ledger, date(15), "January rent", &[ post(
(rent, Side::Debit, 800), &mut ledger,
(cash, Side::Credit, 800), date(15),
]); "January rent",
&[(rent, Side::Debit, 800), (cash, Side::Credit, 800)],
);
// Collect $1,500 of receivables. // Collect $1,500 of receivables.
post(&mut ledger, date(20), "Client payment", &[ post(
(cash, Side::Debit, 1_500), &mut ledger,
(ar, Side::Credit, 1_500), date(20),
]); "Client payment",
&[(cash, Side::Debit, 1_500), (ar, Side::Credit, 1_500)],
);
Company { ledger, cash, ar, equipment, _ap: ap, equity, revenue, rent } Company {
ledger,
cash,
ar,
equipment,
_ap: ap,
equity,
revenue,
rent,
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -108,7 +143,9 @@ fn unbalanced_entry_is_rejected() {
let mut ledger = c.ledger; let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Bad entry"); let id = ledger.create_entry(date(21), "Bad entry");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(99)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(99))
.unwrap();
let err = ledger.post_entry(id).unwrap_err(); let err = ledger.post_entry(id).unwrap_err();
assert_eq!( assert_eq!(
err, err,
@@ -135,7 +172,9 @@ fn zero_amount_line_is_rejected() {
let id = ledger.create_entry(date(21), "Zero line"); let id = ledger.create_entry(date(21), "Zero line");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, c.cash, Side::Debit, usd(0)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(0)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
assert_eq!( assert_eq!(
ledger.post_entry(id).unwrap_err(), ledger.post_entry(id).unwrap_err(),
LedgerError::ZeroAmountLine LedgerError::ZeroAmountLine
@@ -148,7 +187,9 @@ fn unknown_account_is_rejected() {
let mut ledger = c.ledger; let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Ghost account"); let id = ledger.create_entry(date(21), "Ghost account");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, 999_999, Side::Credit, usd(100)).unwrap(); ledger
.add_line(id, 999_999, Side::Credit, usd(100))
.unwrap();
assert_eq!( assert_eq!(
ledger.post_entry(id).unwrap_err(), ledger.post_entry(id).unwrap_err(),
LedgerError::UnknownAccount(999_999) LedgerError::UnknownAccount(999_999)
@@ -164,7 +205,9 @@ fn placeholder_account_is_rejected() {
.unwrap(); .unwrap();
let id = ledger.create_entry(date(21), "Post to header"); let id = ledger.create_entry(date(21), "Post to header");
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
assert_eq!( assert_eq!(
ledger.post_entry(id).unwrap_err(), ledger.post_entry(id).unwrap_err(),
LedgerError::PlaceholderAccount("100".to_string()) LedgerError::PlaceholderAccount("100".to_string())
@@ -207,12 +250,16 @@ fn posted_entry_is_immutable() {
let mut ledger = c.ledger; let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "To post"); let id = ledger.create_entry(date(21), "To post");
ledger.add_line(id, c.cash, Side::Debit, usd(50)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(50)).unwrap();
ledger.add_line(id, c.equity, Side::Credit, usd(50)).unwrap(); ledger
.add_line(id, c.equity, Side::Credit, usd(50))
.unwrap();
ledger.post_entry(id).unwrap(); ledger.post_entry(id).unwrap();
// Cannot add lines after posting. // Cannot add lines after posting.
assert_eq!( assert_eq!(
ledger.add_line(id, c.cash, Side::Debit, usd(1)).unwrap_err(), ledger
.add_line(id, c.cash, Side::Debit, usd(1))
.unwrap_err(),
LedgerError::PostedEntryImmutable LedgerError::PostedEntryImmutable
); );
// Cannot post twice. // Cannot post twice.
@@ -233,7 +280,9 @@ fn reversal_nets_to_zero_and_links_back() {
ledger.post_entry(id).unwrap(); ledger.post_entry(id).unwrap();
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300)); assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
let rev = ledger.reverse_entry(id, date(22), "Reverse mistake").unwrap(); let rev = ledger
.reverse_entry(id, date(22), "Reverse mistake")
.unwrap();
let rev_entry = ledger.get_entry(rev).unwrap(); let rev_entry = ledger.get_entry(rev).unwrap();
assert_eq!(rev_entry.reversal_of(), Some(id)); assert_eq!(rev_entry.reversal_of(), Some(id));
assert_eq!(rev_entry.status(), EntryStatus::Posted); assert_eq!(rev_entry.status(), EntryStatus::Posted);
@@ -319,7 +368,15 @@ fn random_balanced_journals_preserve_invariants() {
let mut rng = Lcg(0x1234_5678_9abc_def0); let mut rng = Lcg(0x1234_5678_9abc_def0);
let c = fixture_company(); let c = fixture_company();
let mut ledger = c.ledger; let mut ledger = c.ledger;
let accounts = [c.cash, c.ar, c.equipment, c._ap, c.equity, c.revenue, c.rent]; let accounts = [
c.cash,
c.ar,
c.equipment,
c._ap,
c.equity,
c.revenue,
c.rent,
];
for i in 0..300u32 { for i in 0..300u32 {
let total = rng.next(100_000) + 100; let total = rng.next(100_000) + 100;
@@ -362,3 +419,55 @@ fn golden_fixture_company_reports() {
serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap(); serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap();
assert_eq!(snap, golden); assert_eq!(snap, golden);
} }
#[test]
fn parent_account_validation() {
let mut ledger = Ledger::new(Currency::USD);
// Unknown parent is rejected.
let err = ledger
.add_account("1100", "Child", AccountType::Asset, Some(999), false)
.unwrap_err();
assert_eq!(err, LedgerError::UnknownParentAccount(999));
// A real parent works, and the child records it.
let parent = ledger
.add_account("1000", "Parent", AccountType::Asset, None, true)
.unwrap();
let child = ledger
.add_account("1100", "Child", AccountType::Asset, Some(parent), false)
.unwrap();
assert_eq!(ledger.chart().get(child).unwrap().parent, Some(parent));
// Chart accessors report honestly.
assert!(Ledger::new(Currency::USD).chart().is_empty());
assert!(!ledger.chart().is_empty());
assert_eq!(ledger.chart().len(), 2);
assert!(ledger.chart().find_by_code("1000").is_some());
assert!(ledger.chart().find_by_code("nope").is_none());
}
#[test]
fn account_type_balance_sheet_predicate() {
assert!(AccountType::Asset.is_balance_sheet());
assert!(AccountType::Liability.is_balance_sheet());
assert!(AccountType::Equity.is_balance_sheet());
assert!(!AccountType::Revenue.is_balance_sheet());
assert!(!AccountType::Expense.is_balance_sheet());
}
#[test]
fn entry_movements_sum_to_zero() {
let mut c = fixture_company();
let cash = c.cash;
let revenue = c.revenue;
let id = c.ledger.create_entry(date(15), "sale");
c.ledger.add_line(id, cash, Side::Debit, usd(1000)).unwrap();
c.ledger
.add_line(id, revenue, Side::Credit, usd(1000))
.unwrap();
c.ledger.post_entry(id).unwrap();
let entry = c.ledger.get_entry(id).unwrap();
let m = entry.movements();
assert_eq!(m[&cash], Decimal::new(100000, 2));
assert_eq!(m[&revenue], Decimal::new(-100000, 2));
let total: Decimal = m.values().copied().sum();
assert_eq!(total, Decimal::ZERO);
}