//! Books an earlier wanderbooks wrote open in the viewer, and every page //! renders over them, never a panic (rules.md B-28: posted is forever; a //! figure the books can't compute is said, never guessed). //! //! ```text //! GOAL: the viewer shows books written under older rules, whatever they //! hold that the commands now refuse. //! SOURCE: rules.md B-28; DECISION (P0 review round 4: replay accepts what //! main 188c90ea accepted, and derived figures refuse rather than //! panic). //! INVARIANTS: //! 1. Every journal in ledger-store's tests/fixtures/old-books (written //! through main's Store API: dates before 0001 and past 9999 out to //! chrono's edges, draft and recurring lines past the line limit up //! to Decimal's largest, shares and contributor bases near Decimal's //! largest, sub-cent document and payroll lines, statement balances //! and an exchange rate past any sense), and one written under the //! rules before 2026-10-13 (a draft line on an account that doesn't //! exist, a child account of another type, periods that partly //! overlap), loads as the viewer loads it (data::load_once). //! 2. Over each, every page renders: every route the books have ids //! for, every report kind over this and last year, all time, and //! custom ranges out to chrono's edges, split every way and compared //! every way, the //! party pages over every period, the asset register and account //! registers on chrono's first and last days. //! ``` use chrono::NaiveDate; use egui_kittest::Harness; use ledger_gui::books::{Books, PartyPeriod}; use ledger_gui::nav::{ReportKind, Route}; use ledger_gui::pages::reports::{Columns, Compare, Range}; use ledger_gui::{App, Source, data}; use serde_json::json; use std::path::{Path, PathBuf}; const TODAY: (i32, u32, u32) = (2026, 10, 17); fn d(y: i32, m: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, day).unwrap() } /// A scratch directory, removed when dropped. struct TempDir(PathBuf); impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn temp(tag: &str) -> TempDir { let dir = std::env::temp_dir().join(format!("ledger-gui-old-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("books-data")).unwrap(); TempDir(dir) } /// Every file of a fixture's books (journal and blobs, never the expected /// figures beside them) copied into `to`. fn copy_books(from: &Path, to: &Path) { std::fs::create_dir_all(to).unwrap(); for e in std::fs::read_dir(from).unwrap() { let e = e.unwrap(); let name = e.file_name(); if name == "expected.json" { continue; } if e.file_type().unwrap().is_dir() { copy_books(&e.path(), &to.join(&name)); } else { std::fs::copy(e.path(), to.join(&name)).unwrap(); } } } /// The settings the viewer reads beside the books: two members (the /// shares fixtures' parties), the cash account, a fiscal year from July. fn write_settings(dir: &Path) { std::fs::write( dir.join("wanderbooks.toml"), "[company]\nname = \"Old Books Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\ [[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ [[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ [books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\n", ) .unwrap(); } /// The fixtures whose index is `shard` mod `of`, one scratch books /// directory each. fn old_books(shard: usize, of: usize) -> Vec<(String, TempDir)> { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ledger-store/tests/fixtures/old-books"); let mut dirs: Vec = std::fs::read_dir(&root) .unwrap() .map(|e| e.unwrap().path()) .filter(|p| p.is_dir()) .collect(); dirs.sort(); assert!(dirs.len() >= 14, "{} old books", dirs.len()); let mut out: Vec<(String, TempDir)> = dirs .iter() .enumerate() .filter(|(i, _)| i % of == shard) .map(|(_, dir)| { let name = dir.file_name().unwrap().to_string_lossy().to_string(); let t = temp(&name); copy_books(dir, &t.0.join("books-data")); write_settings(&t.0); (name, t) }) .collect(); if shard == of - 1 { out.push(("older_rules".into(), older_rules())); } out } /// What a wanderbooks before 2026-10-13 could have written: a child /// account of another type, partially overlapping periods, a draft line /// on an account that doesn't exist, a foreign line not rounded to the /// cent (ledger-store's books_written_under_older_rules_still_open). fn older_rules() -> TempDir { let t = temp("older-rules"); let data = t.0.join("books-data"); let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap(); let cash = store .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, true) .unwrap(); drop(store); let mut w = ledger_store::JournalWriter::open(&data.join(ledger_store::JOURNAL_FILE)).unwrap(); for (line_type, payload) in [ ( "account.added", json!({"id": 2, "code": "1010", "name": "Rent", "account_type": "Expense", "parent": cash, "placeholder": false, "currency": "USD"}), ), ( "period.created", json!({"id": 1, "name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}), ), ( "period.created", json!({"id": 2, "name": "straddle", "start": "2026-01-15", "end": "2026-02-15"}), ), ( "entry.created", json!({"id": 1, "date": "2026-03-01", "description": "draft"}), ), ( "entry.line_added", json!({"id": 1, "account": 999, "side": "debit", "amount": "10.00", "currency": "USD"}), ), ( "entry.line_added", json!({"id": 1, "account": cash, "side": "credit", "amount": "108.34567", "currency": "USD", "foreign_currency": "EUR", "foreign_amount": "100.00", "fx_rate": "1.0834567"}), ), ] { w.append(line_type, payload).unwrap(); } drop(w); write_settings(&t.0); t } /// Every route the books have something for, and one past each. fn routes(b: &Books) -> Vec { let mut out = vec![ Route::Overview, Route::Transactions, Route::Cash, Route::Profit, Route::Accounts, Route::Assets, Route::Parties, Route::Documents, Route::Company, Route::Journal, ]; out.extend(ReportKind::ALL.map(Route::Reports)); out.extend((1..=b.ledger.next_entry_id()).map(Route::Entry)); out.extend(b.ledger.chart().all().map(|a| Route::Account(a.id))); out.extend(b.ledger.registered_assets().map(|a| Route::Asset(a.id))); out.extend(b.ledger.parties().map(|p| Route::Party(p.id))); out.extend(b.attachments.iter().map(|a| Route::Document(a.id))); out.extend( b.reconciliations .iter() .map(|r| Route::Reconciliation(r.id)), ); out.extend((1..=b.seq()).map(Route::JournalEvent)); out } /// Render `route` (a panic is in its first frame). fn show(h: &mut Harness<'_, App>, route: Route) { h.state_mut().goto(route); h.run_steps(1); } /// INVARIANTS 1-2, in four shards run side by side. #[test] fn every_page_renders_over_books_an_earlier_version_wrote_0() { every_page_renders(0, 4); } #[test] fn every_page_renders_over_books_an_earlier_version_wrote_1() { every_page_renders(1, 4); } #[test] fn every_page_renders_over_books_an_earlier_version_wrote_2() { every_page_renders(2, 4); } #[test] fn every_page_renders_over_books_an_earlier_version_wrote_3() { every_page_renders(3, 4); } /// Every page over the old books whose index is `shard` mod `of`. fn every_page_renders(shard: usize, of: usize) { let today = d(TODAY.0, TODAY.1, TODAY.2); // OLD_BOOKS_ONLY=: only those books // (debugging, and mutation runs). let only = std::env::var("OLD_BOOKS_ONLY").ok(); for (name, dir) in old_books(shard, of) { if only .as_deref() .is_some_and(|o| !o.split(',').any(|part| name.contains(part))) { continue; } let source = Source { data_dir: dir.0.join("books-data"), books_toml: Some(dir.0.join("wanderbooks.toml")), today: Some(today), }; let books = data::load_once(&source) .unwrap_or_else(|e| panic!("{name}: the viewer can't load it: {e}")); let routes = routes(&books); let parties: Vec<_> = books.ledger.parties().map(|p| p.id).collect(); let accounts: Vec<_> = books.ledger.chart().all().map(|a| a.id).collect(); let edges = [ NaiveDate::MIN, d(1, 1, 1), today, d(9999, 12, 31), NaiveDate::MAX, ]; let mut h = Harness::builder() .with_size(egui::vec2(1280.0, 800.0)) .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); h.run_steps(2); for route in routes { show(&mut h, route); } // Every report over the ranges the books' own dates reach (the // other presets are today's, the same in any books), split and // compared every way. let mut ranges = vec![Range::YearToDate, Range::LastYear, Range::AllTime]; for (s, e) in [ (NaiveDate::MIN, NaiveDate::MAX), (d(1, 1, 1), d(9999, 12, 31)), (d(9999, 1, 1), NaiveDate::MAX), (NaiveDate::MIN, d(1, 12, 31)), ] { ranges.push(Range::Custom(s, e)); } for kind in ReportKind::ALL { // The views a report has: split (P&L, balance sheet, cash // flow), or compared (those three and the tax summary, on one // column), else one. let splittable = matches!( kind, ReportKind::ProfitAndLoss | ReportKind::BalanceSheet | ReportKind::CashFlow ); let comparable = splittable || kind == ReportKind::TaxSummary; let mut views = vec![(Columns::Total, Compare::None)]; if comparable { views.push((Columns::Total, Compare::PriorPeriod)); views.push((Columns::Total, Compare::PriorYear)); } if splittable { views.extend( [Columns::Month, Columns::Quarter, Columns::Year].map(|c| (c, Compare::None)), ); } for range in &ranges { for &(columns, compare) in &views { let st = &mut h.state_mut().state_mut().reports; st.range = *range; st.columns = columns; st.compare = compare; show(&mut h, Route::Reports(kind)); } } } // Every party over every period. for period in [ PartyPeriod::ThisYear, PartyPeriod::LastYear, PartyPeriod::AllTime, PartyPeriod::Year(NaiveDate::MIN.format("%Y").to_string().parse().unwrap()), PartyPeriod::Year(0), PartyPeriod::Year(10000), PartyPeriod::Year(262142), ] { h.state_mut().state_mut().parties.period = period; show(&mut h, Route::Parties); for &p in &parties { show(&mut h, Route::Party(p)); } } // The asset register and account registers at chrono's edges. for at in edges { h.state_mut().state_mut().assets.as_of = Some(at); show(&mut h, Route::Assets); h.state_mut().state_mut().accounts.range = Some((NaiveDate::MIN, at)); for &a in &accounts { show(&mut h, Route::Account(a)); } } eprintln!("{name}: every page rendered"); } } /// One fixture's books, staged as [`old_books`] stages them. fn stage(name: &str) -> TempDir { let from = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../ledger-store/tests/fixtures/old-books") .join(name); let t = temp(&format!("said-{name}")); copy_books(&from, &t.0.join("books-data")); write_settings(&t.0); t } fn load(dir: &TempDir) -> Books { data::load_once(&Source { data_dir: dir.0.join("books-data"), books_toml: Some(dir.0.join("wanderbooks.toml")), today: Some(d(TODAY.0, TODAY.1, TODAY.2)), }) .unwrap() } /// A figure the books can't compute is said, never a number: a draft of /// Decimal's largest lines has no total; a schedule past chrono's last day /// and a register whose contributor bases are past exact arithmetic are /// each a Problem in "Needs attention". #[test] fn figures_that_cant_be_computed_are_said() { let dir = stage("x_draft_max_lines"); let b = load(&dir); let draft = b.entry(1).unwrap(); assert_eq!(draft.amount, None); assert_eq!( ledger_gui::fmt::dollars_or(draft.amount), "too large to compute" ); assert!( b.attention .iter() .any(|a| a.detail.contains("too large to compute")), "{:?}", b.attention ); let dir = stage("x_schedule_chrono_end"); let b = load(&dir); let item = b .attention .iter() .find(|a| a.title.starts_with("Amortization schedule")) .unwrap_or_else(|| panic!("{:?}", b.attention)); assert_eq!(item.severity, ledger_gui::books::Severity::Problem); assert!(item.title.ends_with("can't be computed"), "{}", item.title); let dir = stage("x_tax_basis_max"); let b = load(&dir); assert!(matches!( b.asset_register(b.today), Err(ledger_core::LedgerError::TooLargeToCompute(_)) )); assert!( b.attention .iter() .any(|a| a.title == "The fixed asset register can't be computed"), "{:?}", b.attention ); }