Files
wanderbooks/crates/ledger-gui/tests/edges.rs
T
swaitsandClaude Opus 5.5 b0a9fd8ad4 P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days;
a disposed asset stops on its disposal day. A skipped month is caught up
in the month the run is made; a reversed run un-posts its month. New
schedules spread by day; equal-months schedules must start on the 1st.
Amounts round to the currency's own minor unit. Kani and Lean proofs; an
exhaustive search over runs, reversals and disposals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:11 -06:00

279 lines
9.0 KiB
Rust

//! The viewer's figures at the edges P0 review round 4 checked (rules.md
//! B-28): the party period of a year chrono can't hold, the party chart's
//! last months, one party's part of a split entry, an amortization
//! schedule due, and the page state tests set.
//!
//! ```text
//! GOAL: what the viewer derives from books is the books' answer, at the
//! edges too: never a panic, never another party's money.
//! SOURCE: books.rs party_span (years out of chrono clip to its edges),
//! last_months, party_what_for, share_of_what_for; attention.rs
//! (an amortization schedule with months due is a to-do).
//! ```
use chrono::{Datelike, NaiveDate};
use egui_kittest::Harness;
use ledger_core::parties::{EntryFlow, entry_flow, money_accounts};
use ledger_core::{AccountType, Currency, Money, PartyShare, Role, Share, Side};
use ledger_gui::books::{Books, PartyPeriod, share_of_what_for};
use ledger_gui::nav::Route;
use ledger_gui::{App, Source, attention, data};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::path::PathBuf;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(c: i64) -> Money {
Money::new(Decimal::new(c, 2), Currency::USD)
}
fn today() -> NaiveDate {
d(2026, 10, 15)
}
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Books with cash, rent and fees, an insurance schedule from January
/// (months due by today), and a $100.00 payment from cash, rent $70 and
/// fees $30, split $60 to Sam and $40 to Riley (neither a member).
struct Fixture {
_dir: TempDir,
books: Books,
entry: u64,
sam: u64,
riley: u64,
cash: u64,
rent: u64,
fees: u64,
}
fn fixture(tag: &str) -> Fixture {
let dir = TempDir(
std::env::temp_dir().join(format!("ledger-gui-edges-{tag}-{}", std::process::id())),
);
let _ = std::fs::remove_dir_all(&dir.0);
let data = dir.0.join("books-data");
let mut s = Store::create(&data, Currency::USD).unwrap();
let mut add = |code: &str, ty| s.add_account(code, code, ty, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let prepaid = add("1300", AccountType::Asset);
let rent = add("6000", AccountType::Expense);
let fees = add("6010", AccountType::Expense);
let insurance = add("6300", AccountType::Expense);
s.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd(120_000),
start: d(2026, 1, 1),
months: 12,
balance_account: prepaid,
pl_account: insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
let sam = s.add_party("Sam", &[Role::Contractor]).unwrap();
let riley = s.add_party("Riley", &[Role::Contractor]).unwrap();
// The cash line first: a part that lands on the entry's first line
// instead of on what it paid for is told apart.
let entry = s.create_entry(d(2026, 3, 1), "shared").unwrap();
s.add_line(entry, cash, Side::Credit, usd(10_000)).unwrap();
s.add_line(entry, rent, Side::Debit, usd(7_000)).unwrap();
s.add_line(entry, fees, Side::Debit, usd(3_000)).unwrap();
s.attach_receipt(Some(entry), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
s.post_entry(entry).unwrap();
s.set_entry_parties(
entry,
&[
PartyShare {
party: sam,
share: Share::Amount(Decimal::new(6_000, 2)),
},
PartyShare {
party: riley,
share: Share::Amount(Decimal::new(4_000, 2)),
},
],
)
.unwrap();
drop(s);
let books = data::load_once(&Source {
data_dir: data,
today: Some(today()),
})
.unwrap();
Fixture {
_dir: dir,
books,
entry,
sam,
riley,
cash,
rent,
fees,
}
}
/// party_span: a year chrono can't hold is clipped to its edge (a year
/// past its last starts at its last day; one before its first is its
/// first day), never a panic.
#[test]
fn a_party_year_past_chrono_is_clipped_to_its_edge() {
let b = fixture("span").books;
let after = NaiveDate::MAX.year() + 1;
let before = NaiveDate::MIN.year() - 1;
assert_eq!(b.party_span(PartyPeriod::Year(after)).0, NaiveDate::MAX);
assert_eq!(
b.party_span(PartyPeriod::Year(before)),
(NaiveDate::MIN, NaiveDate::MIN)
);
// Years chrono holds, year 0 included, are their own.
assert_eq!(
b.party_span(PartyPeriod::Year(0)),
(d(0, 1, 1), d(0, 12, 31))
);
assert_eq!(
b.party_span(PartyPeriod::Year(2025)),
(d(2025, 1, 1), d(2025, 12, 31))
);
}
/// last_months: the last n months of a span, from the first of the
/// month n - 1 months before its end; the whole span when shorter; and
/// at chrono's first months, from its first day.
#[test]
fn the_last_months_of_a_span() {
let span = (d(2026, 1, 15), d(2026, 10, 17));
assert_eq!(
Books::last_months(span, 1),
(d(2026, 10, 1), d(2026, 10, 17))
);
assert_eq!(
Books::last_months(span, 3),
(d(2026, 8, 1), d(2026, 10, 17))
);
assert_eq!(Books::last_months(span, 24), span);
let first = (NaiveDate::MIN, d(NaiveDate::MIN.year(), 3, 9));
assert_eq!(Books::last_months(first, 24), first);
}
/// party_what_for: on an entry two parties split, each one's part of
/// what it paid for is its fraction of every line that isn't a member's
/// capital, summing to its share.
#[test]
fn a_partys_part_of_a_split_entry_is_its_fraction_of_what_was_bought() {
let f = fixture("what-for");
let e = f.books.ledger.get_entry(f.entry).unwrap();
let money = money_accounts(&f.books.ledger, &[f.cash], &[]);
let flow = entry_flow(e, &money).unwrap();
let dec = |c: i64| Decimal::new(c, 2);
let sam = f.books.party_what_for(e, f.sam);
let riley = f.books.party_what_for(e, f.riley);
assert_eq!(sam, vec![(f.rent, dec(4_200)), (f.fees, dec(1_800))]);
assert_eq!(riley, vec![(f.rent, dec(2_800)), (f.fees, dec(1_200))]);
// Together, the entry's whole breakdown.
let total: Decimal = sam.iter().chain(&riley).map(|(_, v)| *v).sum();
assert_eq!(total, flow.net);
}
/// share_of_what_for doc: the whole what-for for the whole net; nothing
/// for a zero share or a zero net; else each account times share / net,
/// toward zero to the cent, the leftover cents to the first, summing to
/// the share; None past exact arithmetic.
#[test]
fn a_share_of_what_for_sums_to_the_share() {
let dec = |c: i64| Decimal::new(c, 2);
let flow = EntryFlow {
net: dec(10_000),
what_for: vec![(6, dec(3_333)), (7, dec(6_667))],
};
assert_eq!(
share_of_what_for(&flow, dec(10_000)),
Some(flow.what_for.clone())
);
assert_eq!(share_of_what_for(&flow, Decimal::ZERO), Some(vec![]));
assert_eq!(
share_of_what_for(
&EntryFlow {
net: Decimal::ZERO,
..flow.clone()
},
dec(5)
),
Some(vec![])
);
assert_eq!(
share_of_what_for(
&EntryFlow {
net: dec(5),
what_for: vec![]
},
dec(1)
),
Some(vec![])
);
// A third: 11.11 and 22.22, rounded down, and the cent left over to
// the first.
assert_eq!(
share_of_what_for(&flow, dec(3_334)),
Some(vec![(6, dec(1_112)), (7, dec(2_222))])
);
let huge = EntryFlow {
net: Decimal::ONE,
what_for: vec![(6, Decimal::MAX)],
};
assert_eq!(share_of_what_for(&huge, Decimal::TWO), None);
}
/// attention: an amortization schedule with months due by today is a
/// to-do, totalling them.
#[test]
fn a_schedule_with_months_due_is_a_todo() {
let f = fixture("schedule");
let items = attention::find(&f.books);
let due: Vec<_> = items
.iter()
.filter(|a| a.title == "Amortization due: Insurance")
.collect();
assert_eq!(
due.len(),
1,
"{:?}",
items.iter().map(|a| &a.title).collect::<Vec<_>>()
);
// January through September: nine months of $100.00.
assert!(
due[0].detail.starts_with("9 months totaling $900"),
"{}",
due[0].detail
);
}
/// App::state_mut is the app's own state: what a test sets there is
/// what the pages read.
#[test]
fn the_page_state_a_test_sets_is_the_apps() {
let f = fixture("state");
let mut h = Harness::builder()
.with_size(egui::vec2(800.0, 600.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
h.state_mut().state_mut().parties.period = PartyPeriod::Year(2025);
assert_eq!(
h.state_mut().state_mut().parties.period,
PartyPeriod::Year(2025)
);
assert_eq!(
h.state_mut().state_mut().parties.period,
PartyPeriod::Year(2025)
);
}