asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs
This commit is contained in:
1 parent
99b5b5d0b8
commit
a7a8a70496
1 file changed
+381
@@ -0,0 +1,381 @@
|
||||
//! Fixed-asset tests: the depreciation math must be exact to the penny.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset,
|
||||
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn usd(amount: &str) -> Money {
|
||||
Money::new(amount.parse().unwrap(), Currency::USD)
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn test_ledger() -> (Ledger, u64, u64, u64) {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cost = ledger
|
||||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let accum = ledger
|
||||
.add_account(
|
||||
"1501",
|
||||
"Accumulated Depreciation",
|
||||
AccountType::Asset,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let expense = ledger
|
||||
.add_account(
|
||||
"6100",
|
||||
"Depreciation Expense",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
(ledger, cost, accum, expense)
|
||||
}
|
||||
|
||||
/// Register the standard test asset: bought 2026-01-15, 60-month life,
|
||||
/// zero salvage, straight-line.
|
||||
fn van_asset(
|
||||
ledger: &mut Ledger,
|
||||
dollars: u64,
|
||||
cost_acct: u64,
|
||||
accum_acct: u64,
|
||||
expense_acct: u64,
|
||||
) -> u64 {
|
||||
ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Sprinter van".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
cost: usd(&dollars.to_string()),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 60,
|
||||
cost_account: cost_acct,
|
||||
accum_depr_account: accum_acct,
|
||||
expense_account: expense_acct,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_sums_exactly_to_depreciable_amount() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
// Odd numbers chosen to not divide evenly: 100000 / 60.
|
||||
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2031, 12, 31));
|
||||
// 61 calendar months touched (partial first and last), 60 months' worth.
|
||||
assert_eq!(sched.len(), 61);
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
assert_eq!(
|
||||
total,
|
||||
Decimal::from(100_000),
|
||||
"lifetime total must be exact"
|
||||
);
|
||||
assert_eq!(sched.last().unwrap().accumulated, Decimal::from(100_000));
|
||||
assert_eq!(sched.last().unwrap().book_value, Decimal::ZERO);
|
||||
// First month prorated: 17 of 31 days → 100000/60 * 17/31 = 913.98.
|
||||
let first = &sched[0];
|
||||
assert_eq!(first.period, "2026-01");
|
||||
assert_eq!(first.amount, Decimal::new(91398, 2));
|
||||
// Last month is the complement: 14 of 31 days.
|
||||
assert_eq!(sched[60].period, "2031-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_months_complement_to_whole() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2031, 12, 31));
|
||||
// Jan 2026 (17/31) + Jan 2031 (14/31) = exactly one month's charge.
|
||||
let monthly = Decimal::from(100_000) / Decimal::from(60);
|
||||
let monthly_rounded =
|
||||
monthly.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
let pair = sched[0].amount + sched[60].amount;
|
||||
assert!(
|
||||
(pair - monthly_rounded).abs() <= Decimal::new(1, 2),
|
||||
"partial months should complement: {pair} vs {monthly_rounded}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salvage_value_is_never_depreciated() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Machine".to_string(),
|
||||
purchase_date: date(2026, 3, 1),
|
||||
cost: usd("50000"),
|
||||
salvage_value: usd("5000"),
|
||||
useful_life_months: 36,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2030, 12, 31));
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
assert_eq!(total, Decimal::from(45_000));
|
||||
assert_eq!(sched.last().unwrap().book_value, Decimal::from(5_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_value_tracks_accumulated() {
|
||||
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();
|
||||
let acc = accumulated_depreciation(asset, date(2026, 12, 31));
|
||||
// Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000.
|
||||
assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000));
|
||||
assert_eq!(
|
||||
book_value(asset, date(2026, 12, 31)),
|
||||
Decimal::from(60_000) - acc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposal_math() {
|
||||
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();
|
||||
// Sell after one year for 55,000. Book value is 60000 − 11548.39 =
|
||||
// 48451.61, so this is a gain of 6548.39.
|
||||
let d = disposal_amounts(asset, date(2026, 12, 31), usd("55000")).unwrap();
|
||||
assert_eq!(d.book_value, Decimal::new(4845161, 2));
|
||||
assert_eq!(d.accumulated, Decimal::new(1154839, 2));
|
||||
assert_eq!(d.gain_loss, Decimal::new(654839, 2));
|
||||
// Disposing twice fails.
|
||||
ledger
|
||||
.dispose_asset(id, date(2026, 12, 31), usd("55000"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
ledger
|
||||
.dispose_asset(id, date(2027, 1, 1), usd("1"))
|
||||
.is_err()
|
||||
);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
assert!(matches!(asset.status, AssetStatus::Disposed { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_validation() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let liability = ledger
|
||||
.add_account("2000", "Payable", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let base = NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
};
|
||||
// Wrong account type for the expense link.
|
||||
let mut bad = base.clone();
|
||||
bad.expense_account = liability;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Salvage >= cost.
|
||||
let mut bad = base.clone();
|
||||
bad.salvage_value = usd("1000");
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Zero life.
|
||||
let mut bad = base.clone();
|
||||
bad.useful_life_months = 0;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Duplicate account links.
|
||||
let mut bad = base.clone();
|
||||
bad.accum_depr_account = cost;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Unknown account.
|
||||
let mut bad = base.clone();
|
||||
bad.cost_account = 999;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// The good one works.
|
||||
assert!(ledger.register_asset(base).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_is_straight_line() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 12_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
assert_eq!(asset.method, DepreciationMethod::StraightLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_rejects_date_overflow() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
// A purchase date near the end of the calendar with a long life would
|
||||
// overflow the schedule's month arithmetic; registration must refuse.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Time machine".to_string(),
|
||||
purchase_date: NaiveDate::MAX,
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ledger_core::LedgerError::AssetDateOverflow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_through_purchase_date_covers_one_day() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
cost: usd("62000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 62,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
// Through the day before purchase: nothing.
|
||||
assert!(depreciation_schedule(asset, date(2026, 1, 14)).is_empty());
|
||||
// Through the purchase date itself: one day of the first month.
|
||||
let one_day = depreciation_schedule(asset, date(2026, 1, 15));
|
||||
assert_eq!(one_day.len(), 1);
|
||||
assert_eq!(one_day[0].period, "2026-01");
|
||||
// 62000/62 = 1000/month; 1/31 of that = 32.26.
|
||||
assert_eq!(one_day[0].amount, Decimal::new(3226, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_van_depreciation_schedule() {
|
||||
// The canonical van's month-by-month schedule, pinned byte-for-value.
|
||||
// Hand-verified: 61 months, first 548.39 = 1000 x 17/31, last 451.61 =
|
||||
// 1000 x 14/31, lifetime total exactly 60000.00.
|
||||
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();
|
||||
let sched = depreciation_schedule(asset, date(2032, 1, 1));
|
||||
let got = serde_json::to_value(&sched).unwrap();
|
||||
let golden: serde_json::Value =
|
||||
serde_json::from_str(include_str!("golden/van_schedule.json")).unwrap();
|
||||
assert_eq!(got, golden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_rejects_wrong_typed_accounts() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let base = || NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
};
|
||||
// An expense account cannot hold the cost.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
cost_account: expense,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ledger_core::LedgerError::AssetAccountWrongType { .. }
|
||||
));
|
||||
// An asset account cannot be the depreciation expense.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
expense_account: cost,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ledger_core::LedgerError::AssetAccountWrongType { .. }
|
||||
));
|
||||
// The three links must be distinct accounts.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
accum_depr_account: cost,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ledger_core::LedgerError::DuplicateAssetAccount(_)
|
||||
));
|
||||
// Unknown accounts are rejected.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
cost_account: 999,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ledger_core::LedgerError::UnknownAccount(999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposal_accepts_zero_proceeds() {
|
||||
// Scrapping an asset for nothing is legitimate: full loss.
|
||||
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();
|
||||
let d = disposal_amounts(&asset, date(2026, 6, 30), usd("0")).unwrap();
|
||||
assert!(d.gain_loss < Decimal::ZERO);
|
||||
assert_eq!(d.book_value + d.gain_loss, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_assets_get_distinct_ids_and_list() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id1 = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let id2 = van_asset(&mut ledger, 30_000, cost, accum, expense);
|
||||
assert_ne!(id1, id2);
|
||||
let listed: Vec<_> = ledger.registered_assets().collect();
|
||||
assert_eq!(listed.len(), 2);
|
||||
assert!(listed.iter().any(|a| a.id == id1));
|
||||
assert!(listed.iter().any(|a| a.id == id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_month_useful_life_is_valid() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Cheap tool".to_string(),
|
||||
purchase_date: date(2026, 3, 10),
|
||||
cost: usd("300"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 1,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2026, 12, 31));
|
||||
// A one-month life starting Mar 10 spans two calendar months.
|
||||
assert_eq!(sched.len(), 2);
|
||||
// March: 22/31 of 300 = 212.90.
|
||||
assert_eq!(sched[0].amount, Decimal::new(21290, 2));
|
||||
// Lifetime total is exactly 300.00.
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
assert_eq!(total, Decimal::new(30000, 2));
|
||||
}
|
||||
Reference in new issue
Block a user