Files
wanderbooks/crates/ledger-gui/tests/spec_gui.rs
T
swaitsandClaude Opus 5.5 ae927c3f73
check / check (push) Waiting to run
Attachments, statements, reconciliations, TIN vault, counterparty policy
MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 15:42:37 -06:00

1226 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spec-blind tests of the viewer's pure logic. Every expected value here
//! comes from a written spec (the doc comments' GOAL/SOURCE/INVARIANTS
//! blocks, docs/spec/sources/file-names.md) or from `ledger-core` itself,
//! never from reading the code under test.
use chrono::NaiveDate;
use ledger_core::{AccountId, AccountType, Currency, EntryId, EntryStatus, Money, Side};
use ledger_gui::books::{Books, Severity};
use ledger_gui::data::{Source, load_once};
use ledger_gui::nav::{Nav, ReportKind, Route};
use ledger_gui::{attention, charts, export, fmt, palette};
use ledger_store::{Snapshot, Store};
use proptest::prelude::*;
use rust_decimal::Decimal;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn neg_zero(scale: u32) -> Decimal {
Decimal::from_parts(0, 0, 0, true, scale)
}
/// Read formatted money back: strip commas and `$`, parentheses and U+2212
/// become a minus sign.
fn read_back(s: &str) -> Decimal {
let mut t: String = s.chars().filter(|&c| c != ',' && c != '$').collect();
let mut negative = false;
if t.starts_with('(') && t.ends_with(')') {
negative = true;
t = t[1..t.len() - 1].to_string();
}
if let Some(rest) = t.strip_prefix('\u{2212}') {
negative = !negative;
t = rest.to_string();
}
let v = Decimal::from_str(&t).unwrap_or_else(|e| panic!("{s:?} does not read back: {e}"));
if negative { -v } else { v }
}
/// The digits part of a formatted amount: everything but sign, `$`, and
/// parentheses.
fn digits_part(s: &str) -> String {
s.chars()
.filter(|&c| !matches!(c, '$' | '(' | ')' | '\u{2212}'))
.collect()
}
/// Check INVARIANT 1's shape: thousands grouped in threes, at least two
/// decimals.
fn check_grouping(s: &str) -> Result<(), String> {
let body = digits_part(s);
let (int, frac) = body
.split_once('.')
.ok_or_else(|| format!("{s:?}: no decimal point"))?;
if frac.len() < 2 || !frac.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("{s:?}: fewer than two decimals"));
}
let groups: Vec<&str> = int.split(',').collect();
let first = groups[0];
if first.is_empty() || first.len() > 3 || !first.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("{s:?}: bad leading group"));
}
if groups.len() > 1 && first.starts_with('0') {
return Err(format!("{s:?}: leading zero before a comma"));
}
for g in &groups[1..] {
if g.len() != 3 || !g.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("{s:?}: group {g:?} isn't three digits"));
}
}
if int.len() > 1 && int.starts_with('0') {
return Err(format!("{s:?}: leading zero"));
}
Ok(())
}
/// A random decimal: magnitude below 1e15, scale 0..=10, either sign.
fn arb_decimal() -> impl Strategy<Value = Decimal> {
(0u32..=10, any::<bool>(), 0u32..=16).prop_flat_map(|(scale, neg, mag_digits)| {
let max: i128 = 10i128.pow(mag_digits.min(15)) * 10i128.pow(scale);
(0..=max).prop_map(move |m| {
let mut d = Decimal::from_i128_with_scale(m, scale);
d.set_sign_negative(neg);
d
})
})
}
// ---------------------------------------------------------------------------
// 1. fmt
// ---------------------------------------------------------------------------
#[test]
fn fmt_doc_examples() {
assert_eq!(fmt::money(dec("-1234.56")), "\u{2212}1,234.56");
assert_eq!(fmt::accounting(dec("-1234.56")), "(1,234.56)");
assert_eq!(fmt::dollars(dec("1234.56")), "$1,234.56");
assert_eq!(fmt::dollars(dec("-12")), "\u{2212}$12.00");
assert_eq!(fmt::dollars(dec("-12.00")), "\u{2212}$12.00");
assert_eq!(fmt::compact(dec("950")), "$950");
assert_eq!(fmt::compact(dec("1200")), "$1.2k");
assert_eq!(fmt::compact(dec("25000")), "$25k");
assert_eq!(fmt::compact(dec("3400000")), "$3.4M");
}
#[test]
fn fmt_never_rounds_and_keeps_two_decimals() {
assert_eq!(fmt::money(dec("0.001")), "0.001");
assert_eq!(
fmt::money(dec("1234567.1234567891")),
"1,234,567.1234567891"
);
assert_eq!(fmt::money(dec("5")), "5.00");
assert_eq!(fmt::money(dec("5.1")), "5.10");
assert_eq!(fmt::money(dec("999.995")), "999.995");
assert_eq!(fmt::accounting(dec("-0.005")), "(0.005)");
assert_eq!(fmt::dollars(dec("1000000")), "$1,000,000.00");
}
#[test]
fn fmt_zero_and_negative_zero_are_unsigned() {
for z in [
Decimal::ZERO,
neg_zero(0),
neg_zero(2),
neg_zero(7),
dec("-0.00"),
] {
for s in [fmt::money(z), fmt::accounting(z), fmt::dollars(z)] {
assert!(
!s.contains('\u{2212}') && !s.contains('-') && !s.contains('('),
"zero {z:?} shown signed: {s:?}"
);
assert_eq!(read_back(&s), Decimal::ZERO);
}
assert_eq!(fmt::money(z), "0.00");
assert_eq!(fmt::dollars(z), "$0.00");
let c = fmt::compact(z);
assert!(!c.contains('\u{2212}') && !c.contains('-'), "{c:?}");
}
}
#[test]
fn fmt_compact_never_signs_what_rounds_to_zero() {
for s in ["-0.001", "-0.004", "-0.01", "-0.4", "-0.49", "0.4", "-0"] {
let c = fmt::compact(dec(s));
let shows_zero = c.chars().filter(|c| c.is_ascii_digit()).all(|c| c == '0');
if shows_zero {
assert!(
!c.contains('\u{2212}') && !c.contains('-') && !c.contains('('),
"compact({s}) = {c:?}"
);
}
}
}
#[test]
fn fmt_other_helpers_doc_examples() {
let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
assert_eq!(fmt::date(d), "Oct 12, 2026");
assert_eq!(fmt::date_short(d), "Oct 12");
assert_eq!(fmt::month(d), "Oct 2026");
assert_eq!(fmt::count(1, "receipt", "receipts"), "1 receipt");
assert_eq!(fmt::count(3, "receipt", "receipts"), "3 receipts");
assert_eq!(
fmt::timestamp("2026-10-11T18:04:00Z"),
"Oct 11, 2026 18:04 UTC"
);
assert!(
fmt::bytes(12_300).ends_with(" KB"),
"{}",
fmt::bytes(12_300)
);
assert_eq!(fmt::label("snake_case_key"), "Snake case key");
assert_eq!(fmt::label("ein"), "EIN");
assert_eq!(fmt::words("PrepaidExpense"), "Prepaid expense");
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
/// fmt INVARIANTS 1 and 2 for money, accounting, and dollars.
#[test]
fn fmt_exact_round_trip(d in arb_decimal()) {
let m = fmt::money(d);
let a = fmt::accounting(d);
let s = fmt::dollars(d);
for t in [&m, &a, &s] {
prop_assert_eq!(read_back(t), d, "{:?}", t);
if let Err(e) = check_grouping(t) {
prop_assert!(false, "{}", e);
}
prop_assert!(!t.contains('-'), "ASCII minus in {:?}", t);
// Every decimal place the amount has is shown.
let frac = digits_part(t).split_once('.').unwrap().1.len();
prop_assert!(frac as u32 >= d.normalize().scale(), "{:?} for {}", t, d);
}
prop_assert!(s.contains('$'));
if d.is_zero() {
for t in [&m, &a, &s] {
prop_assert!(!t.contains('\u{2212}') && !t.contains('('), "{:?}", t);
}
} else if d.is_sign_negative() {
prop_assert!(m.starts_with('\u{2212}') && !m.contains('('), "{:?}", m);
prop_assert!(s.starts_with('\u{2212}') && !s.contains('('), "{:?}", s);
prop_assert!(a.starts_with('(') && a.ends_with(')') && !a.contains('\u{2212}'), "{:?}", a);
} else {
for t in [&m, &a, &s] {
prop_assert!(!t.contains('\u{2212}') && !t.contains('('), "{:?}", t);
}
}
}
/// fmt INVARIANT 3: compact never signs what it shows as zero.
#[test]
fn fmt_compact_unsigned_zero(d in arb_decimal()) {
let c = fmt::compact(d);
let shows_zero = c.chars().filter(|c| c.is_ascii_digit()).all(|c| c == '0');
if shows_zero {
prop_assert!(!c.contains('\u{2212}') && !c.contains('-') && !c.contains('('), "compact({}) = {:?}", d, c);
}
if d.is_zero() {
prop_assert!(!c.contains('\u{2212}') && !c.contains('-'), "{:?}", c);
}
}
}
// ---------------------------------------------------------------------------
// 2. export::file_name
// ---------------------------------------------------------------------------
const RESERVED_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
const DEVICE_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "COM¹", "COM²", "COM³", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
"LPT9", "LPT¹", "LPT²", "LPT³",
];
fn is_device_name(stem: &str) -> bool {
let up = stem.to_uppercase();
DEVICE_NAMES.iter().any(|d| d.to_uppercase() == up)
}
fn check_file_name(name: &str, ext: &str) -> Result<String, String> {
let out = export::file_name(name, ext);
let suffix = format!(".{ext}");
let base = out
.strip_suffix(&suffix)
.ok_or_else(|| format!("{name:?} -> {out:?}: doesn't end with {suffix:?}"))?;
if let Some(c) = base
.chars()
.find(|c| RESERVED_CHARS.contains(c) || (*c as u32) <= 31)
{
return Err(format!("{name:?} -> {out:?}: holds {c:?}"));
}
if base.is_empty() || base == "." || base == ".." {
return Err(format!("{name:?} -> {out:?}: bad base {base:?}"));
}
if base.ends_with(' ') || base.ends_with('.') {
return Err(format!("{name:?} -> {out:?}: base ends with space/period"));
}
let stem = base.split('.').next().unwrap_or("");
if is_device_name(stem) {
return Err(format!("{name:?} -> {out:?}: device name {stem:?}"));
}
if out.len() > 255 {
return Err(format!("{name:?} -> {} bytes", out.len()));
}
let dir = Path::new("/tmp/downloads-dir");
let joined = dir.join(&out);
if joined.parent() != Some(dir) {
return Err(format!("{name:?} -> {out:?}: escapes the directory"));
}
if joined.file_name().and_then(|f| f.to_str()) != Some(out.as_str()) {
return Err(format!("{name:?} -> {out:?}: not one path component"));
}
Ok(out)
}
#[test]
fn file_name_adversarial_examples() {
let long_multibyte = "é".repeat(300);
let long_cjk = "帳".repeat(300);
let long_emoji = "📒".repeat(100);
let long_ascii = "a".repeat(400);
let names: Vec<&str> = vec![
"",
".",
"..",
"...",
"../../etc/passwd",
"/etc/passwd",
"..\\..\\windows",
"CON",
"con",
"con.report",
"Con.Tar.Gz",
"NUL",
"nul.txt",
"PRN",
"AUX",
"COM1",
"com9",
"COM¹",
"LPT1",
"lpt²",
"LPT¹",
"LPT³.x",
"report. ",
"report...",
"report ",
" . . ",
"a<b>c:d\"e/f\\g|h?i*j",
"tab\there\nnewline\r\u{0}nul\u{1f}",
"\u{1}\u{2}\u{3}",
"P&L — Wanderland Labs LLC, Jan–Sep 2026",
&long_multibyte,
&long_cjk,
&long_emoji,
&long_ascii,
];
for n in names {
for ext in ["pdf", "csv"] {
if let Err(e) = check_file_name(n, ext) {
panic!("{e}");
}
}
}
}
#[test]
fn file_name_device_names_get_a_leading_underscore() {
for n in ["CON", "con", "LPT¹", "nul", "COM1"] {
assert_eq!(export::file_name(n, "pdf"), format!("_{n}.pdf"));
}
assert_eq!(export::file_name("con.report", "pdf"), "_con.report.pdf");
}
#[test]
fn file_name_long_names_cut_at_char_boundary() {
let n = "é".repeat(300);
let out = export::file_name(&n, "pdf");
assert!(out.len() <= 255, "{} bytes", out.len());
assert!(out.ends_with(".pdf"));
let base = out.strip_suffix(".pdf").unwrap();
assert!(base.chars().all(|c| c == 'é'), "{base:?}");
assert!(!base.is_empty());
}
/// The spec says "no control character"; the cited source names only 0-31.
/// DEL and C1 controls are checked here separately (spec gap).
#[test]
fn file_name_strips_other_control_characters() {
let out = export::file_name("a\u{7f}b\u{85}c\u{9b}d", "pdf");
assert!(!out.chars().any(char::is_control), "{out:?}");
}
fn arb_name() -> impl Strategy<Value = String> {
let piece = prop_oneof![
any::<String>(),
"[ .a-zA-Z0-9]{0,8}",
Just("CON".to_string()),
Just("lpt¹".to_string()),
Just("nul".to_string()),
Just("Com3".to_string()),
Just("..".to_string()),
Just("/".to_string()),
Just("\\".to_string()),
Just(" ".to_string()),
Just(".".to_string()),
"[\\x00-\\x1f<>:\"/\\\\|?*]{1,4}",
"[é帳📒]{50,120}",
];
prop::collection::vec(piece, 0..5).prop_map(|v| v.concat())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2048))]
/// export::file_name INVARIANTS 1-3, and the GOAL: never outside the
/// directory.
#[test]
fn file_name_invariants(name in arb_name(), ext in prop_oneof![Just("pdf"), Just("csv"), Just("txt")]) {
if let Err(e) = check_file_name(&name, ext) {
prop_assert!(false, "{}", e);
}
}
}
// ---------------------------------------------------------------------------
// 3. Nav
// ---------------------------------------------------------------------------
/// Reference browser history, from Nav's INVARIANTS.
#[derive(Debug)]
struct ModelNav {
back: Vec<Route>,
current: Route,
forward: Vec<Route>,
}
impl ModelNav {
fn go(&mut self, r: Route) {
if r != self.current {
let old = std::mem::replace(&mut self.current, r);
self.back.push(old);
self.forward.clear();
}
}
fn back(&mut self) {
if let Some(p) = self.back.pop() {
let old = std::mem::replace(&mut self.current, p);
self.forward.push(old);
}
}
fn forward(&mut self) {
if let Some(n) = self.forward.pop() {
let old = std::mem::replace(&mut self.current, n);
self.back.push(old);
}
}
}
fn routes() -> Vec<Route> {
vec![
Route::Overview,
Route::Cash,
Route::Transactions,
Route::Entry(1),
Route::Entry(2),
Route::Reports(ReportKind::ProfitAndLoss),
Route::Account(7),
]
}
#[derive(Clone, Debug)]
enum NavOp {
Go(usize),
Back,
Forward,
}
fn arb_nav_op() -> impl Strategy<Value = NavOp> {
prop_oneof![
3 => (0usize..7).prop_map(NavOp::Go),
2 => Just(NavOp::Back),
2 => Just(NavOp::Forward),
]
}
#[test]
fn nav_examples() {
let mut n = Nav::new(Route::Overview);
assert!(!n.can_back() && !n.can_forward());
n.go(Route::Overview);
assert!(!n.can_back(), "go to the current route does nothing");
n.go(Route::Cash);
n.go(Route::Transactions);
n.back();
assert_eq!(n.current, Route::Cash);
n.go(Route::Entry(3));
assert!(!n.can_forward(), "going somewhere new drops forward");
n.back();
n.forward();
assert_eq!(n.current, Route::Entry(3));
n.forward();
assert_eq!(n.current, Route::Entry(3));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1024))]
/// Nav INVARIANTS 1-3 against a two-stack model.
#[test]
fn nav_matches_browser_model(start in 0usize..7, ops in prop::collection::vec(arb_nav_op(), 0..60)) {
let rs = routes();
let mut nav = Nav::new(rs[start].clone());
let mut model = ModelNav { back: vec![], current: rs[start].clone(), forward: vec![] };
for op in ops {
match op {
NavOp::Go(i) => { nav.go(rs[i].clone()); model.go(rs[i].clone()); }
NavOp::Back => { nav.back(); model.back(); }
NavOp::Forward => { nav.forward(); model.forward(); }
}
prop_assert_eq!(&nav.current, &model.current, "after {:?}", op);
prop_assert_eq!(nav.can_back(), !model.back.is_empty());
prop_assert_eq!(nav.can_forward(), !model.forward.is_empty());
}
}
}
// ---------------------------------------------------------------------------
// 4. palette::fuzzy
// ---------------------------------------------------------------------------
/// INVARIANT 1's reference: the query's non-space characters, lower-cased,
/// appear in order in the lower-cased hay.
fn is_subsequence(hay: &str, query: &str) -> bool {
let hay = hay.to_lowercase();
let q: Vec<char> = query.to_lowercase().chars().filter(|&c| c != ' ').collect();
let mut it = hay.chars();
q.iter().all(|qc| it.any(|h| h == *qc))
}
fn non_space_count(query: &str) -> i32 {
query.to_lowercase().chars().filter(|&c| c != ' ').count() as i32
}
#[test]
fn fuzzy_examples() {
assert_eq!(palette::fuzzy("anything at all", ""), Some(0));
assert_eq!(palette::fuzzy("", ""), Some(0));
assert!(palette::fuzzy("Office Rent", "rent").is_some());
assert!(palette::fuzzy("Office Rent", "OR").is_some());
assert!(palette::fuzzy("Office Rent", "o r").is_some());
assert!(palette::fuzzy("Office Rent", "tner").is_none());
assert!(palette::fuzzy("", "a").is_none());
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2048))]
/// INVARIANT 1 (against the reference) and 2 (the lower bound).
#[test]
fn fuzzy_matches_subsequence(
hay in "[abcdeABCDE éÉ._-]{0,16}",
query in "[abcdeABCDE éÉ._-]{0,5}",
) {
let got = palette::fuzzy(&hay, &query);
prop_assert_eq!(got.is_some(), is_subsequence(&hay, &query), "hay {:?} query {:?}", hay, query);
if let Some(score) = got {
prop_assert!(score >= non_space_count(&query), "score {} for {:?} in {:?}", score, query, hay);
}
if query.is_empty() {
prop_assert_eq!(got, Some(0));
}
}
/// INVARIANT 2: a word-start prefix (the hay starting with the query)
/// scores at least as high as any other match of the same query.
#[test]
fn fuzzy_leading_prefix_scores_highest(
query in "[a-e]{1,4}",
tail in "[a-e ]{0,8}",
other in "[a-e ]{0,16}",
) {
let prefixed = format!("{query}{tail}");
let best = palette::fuzzy(&prefixed, &query).expect("a prefix matches");
if let Some(s) = palette::fuzzy(&other, &query) {
prop_assert!(best >= s, "{:?} scored {} but {:?} scored {}", prefixed, best, other, s);
}
}
/// INVARIANT 2 with the prefix at a later word start.
#[test]
fn fuzzy_word_start_prefix_scores_highest(
query in "[a-e]{1,4}",
head in "[a-e]{1,6}",
tail in "[a-e ]{0,6}",
other in "[a-e ]{0,16}",
) {
let prefixed = format!("{head} {query}{tail}");
let best = palette::fuzzy(&prefixed, &query).expect("a prefix matches");
if let Some(s) = palette::fuzzy(&other, &query) {
prop_assert!(best >= s, "{:?} scored {} but {:?} scored {}", prefixed, best, other, s);
}
}
}
// ---------------------------------------------------------------------------
// 5. charts::ticks
// ---------------------------------------------------------------------------
/// `strict`: INVARIANT 2 to the bit. Otherwise coverage may miss by 1e-9 of
/// the axis's magnitude (see the ignored `ticks_cover_exactly`).
fn check_ticks_with(lo: f64, hi: f64, n: usize, strict: bool) -> Result<(), String> {
let t = charts::ticks(lo, hi, n);
let ctx = format!("ticks({lo:e}, {hi:e}, {n}) = {t:?}");
if t.is_empty() {
return Err(format!("{ctx}: empty"));
}
if t.iter().any(|x| !x.is_finite()) {
return Err(format!("{ctx}: non-finite tick"));
}
if t.len() > n + 2 {
return Err(format!("{ctx}: more than n + 2"));
}
// The spec's fallback, allowed only where f64 has no usable step: a
// span past f64's range, or one too small for the magnitude.
let fallback = if lo == hi { vec![lo] } else { vec![lo, hi] };
let no_step = !(hi - lo).is_finite()
|| lo.abs().max(hi.abs()) > f64::MAX / 10.0
|| (hi - lo) < 1e-12 * lo.abs().max(hi.abs());
if t == fallback && no_step {
return Ok(());
}
let scale = t
.iter()
.chain([lo, hi].iter())
.fold(0f64, |m, x| m.max(x.abs()));
let slack = if strict { 0.0 } else { 1e-9 * scale };
if t[0] > lo + slack || *t.last().unwrap() < hi - slack {
return Err(format!("{ctx}: doesn't cover"));
}
if t.len() >= 2 {
let step = t[1] - t[0];
if step <= 0.0 {
return Err(format!("{ctx}: not ascending"));
}
for w in t.windows(2) {
if ((w[1] - w[0]) - step).abs() > 1e-9 * scale.max(step) {
return Err(format!("{ctx}: uneven"));
}
}
// The step, up to the rounding of differences at this magnitude.
let tol = 1e-12 * scale + 1e-9 * step;
let k = step.log10().floor() as i32;
let nice = (k.saturating_sub(1)..=k.saturating_add(1)).any(|e| {
[1.0, 2.0, 2.5, 5.0]
.iter()
.any(|m| (step - m * 10f64.powi(e)).abs() <= tol)
});
if !nice {
return Err(format!("{ctx}: step {step:e} isn't 1/2/2.5/5 x 10^k"));
}
}
Ok(())
}
fn check_ticks(lo: f64, hi: f64, n: usize) -> Result<(), String> {
check_ticks_with(lo, hi, n, false)
}
#[test]
fn ticks_examples() {
for (lo, hi, n) in [
(0.0, 100.0, 5),
(0.0, 0.0, 1),
(5.0, 5.0, 4),
(-5.0, -5.0, 4),
(-1234.5, 987.0, 6),
(0.0, 1e-9, 3),
(1.0, 1.0 + 1e-6, 12),
(-1e15, 1e15, 12),
(1e15, 1e15 + 1.0, 4),
] {
if let Err(e) = check_ticks(lo, hi, n) {
panic!("{e}");
}
}
}
#[test]
fn ticks_extreme_finite_ranges() {
for (lo, hi, n) in [
(-1e300, 1e300, 5),
(f64::MIN, f64::MAX, 5),
(0.0, f64::MAX, 3),
(f64::MIN_POSITIVE, f64::MIN_POSITIVE * 2.0, 3),
] {
if let Err(e) = check_ticks(lo, hi, n) {
panic!("{e}");
}
}
}
fn arb_f64() -> impl Strategy<Value = f64> {
prop_oneof![
-1e6f64..1e6,
-1.0f64..1.0,
(-1e15f64..1e15),
(-12i32..15, -1.0f64..1.0).prop_map(|(e, m)| m * 10f64.powi(e)),
Just(0.0),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(4096))]
/// charts::ticks INVARIANTS 1-3.
#[test]
fn ticks_invariants(a in arb_f64(), b in arb_f64(), n in 1usize..=12, same in prop::bool::weighted(0.1)) {
let (lo, hi) = if same { (a, a) } else if a <= b { (a, b) } else { (b, a) };
if let Err(e) = check_ticks(lo, hi, n) {
prop_assert!(false, "{}", e);
}
}
/// INVARIANT 2 exactly: the last tick at or above hi, bit for bit.
#[test]
fn ticks_cover_exactly(a in arb_f64(), b in arb_f64(), n in 1usize..=12) {
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
if let Err(e) = check_ticks_with(lo, hi, n, true) {
prop_assert!(false, "{}", e);
}
}
/// Tiny spans (down to 1e-10 of the center).
#[test]
fn ticks_tiny_spans(center in -1e4f64..1e4, span_exp in -6i32..0, n in 1usize..=12) {
let lo = center;
let hi = center + 10f64.powi(span_exp);
if let Err(e) = check_ticks(lo, hi, n) {
prop_assert!(false, "{}", e);
}
}
/// Tiny spans around a large center, near f64's resolution.
#[test]
fn ticks_tiny_spans_large_center(center in -1e9f64..1e9, span_exp in -6i32..0, n in 1usize..=12) {
let lo = center;
let hi = center + 10f64.powi(span_exp);
if let Err(e) = check_ticks(lo, hi, n) {
prop_assert!(false, "{}", e);
}
}
}
// ---------------------------------------------------------------------------
// Books on disk
// ---------------------------------------------------------------------------
static DIRS: AtomicU64 = AtomicU64::new(0);
/// A fresh, empty temporary directory; removed on drop.
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> TempDir {
let n = DIRS.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("ledger-gui-spec-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn today() -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, 12).unwrap()
}
fn usd(d: Decimal) -> Money {
Money::new(d, Currency::USD)
}
fn write_toml(dir: &Path, threshold: &str, cash: &[&str]) -> PathBuf {
let cash: Vec<String> = cash.iter().map(|c| format!("\"{c}\"")).collect();
let p = dir.join("wanderbooks.toml");
std::fs::write(
&p,
format!(
"[company]\nname = \"Spec Test LLC\"\n\n[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [{}]\n",
cash.join(", ")
),
)
.unwrap();
p
}
fn load(dir: &Path, toml: Option<PathBuf>) -> Books {
load_once(&Source {
data_dir: dir.join("books-data"),
books_toml: toml,
today: Some(today()),
})
.unwrap()
}
/// The chart used by the random books: (code, name, type).
const CHART: &[(&str, &str, AccountType)] = &[
("1000", "Checking", AccountType::Asset),
("1010", "Savings", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("2100", "Due to Shareholders", AccountType::Liability),
("2200", "Loan", AccountType::Liability),
("3000", "Capital", AccountType::Equity),
("4000", "App Revenue", AccountType::Revenue),
("6000", "Software", AccountType::Expense),
("6100", "Rent", AccountType::Expense),
];
const CASH_CODES: &[&str] = &["1000", "1010"];
#[derive(Clone, Debug)]
struct EntryPlan {
day: u32,
debits: Vec<(usize, u64)>,
credit_accounts: Vec<usize>,
split_pct: u64,
scale: u32,
post: bool,
receipt: bool,
}
#[derive(Clone, Debug)]
struct BooksPlan {
threshold: &'static str,
entries: Vec<EntryPlan>,
reverse: Option<usize>,
}
fn arb_entry() -> impl Strategy<Value = EntryPlan> {
(
0u32..285,
prop::collection::vec((0usize..CHART.len(), 1u64..1_000_000), 1..=3),
prop::collection::vec(0usize..CHART.len(), 1..=2),
1u64..100,
prop_oneof![Just(2u32), Just(0u32)],
prop::bool::weighted(0.75),
prop::bool::weighted(0.4),
)
.prop_map(
|(day, debits, credit_accounts, split_pct, scale, post, receipt)| EntryPlan {
day,
debits,
credit_accounts,
split_pct,
scale,
post,
receipt,
},
)
}
fn arb_books() -> impl Strategy<Value = BooksPlan> {
(
prop_oneof![Just("0.00"), Just("25.00"), Just("1000.00")],
prop::collection::vec(arb_entry(), 0..10),
prop::option::of(0usize..10),
)
.prop_map(|(threshold, entries, reverse)| BooksPlan {
threshold,
entries,
reverse,
})
}
struct Built {
_dir: TempDir,
books: Books,
data: PathBuf,
entry_ids: Vec<EntryId>,
receipts: HashMap<EntryId, usize>,
reversal: Option<(EntryId, EntryId)>,
}
fn build_books(plan: &BooksPlan) -> Built {
let dir = TempDir::new("books");
let data = dir.0.join("books-data");
let toml = write_toml(&dir.0, plan.threshold, CASH_CODES);
let mut store = Store::create(&data, Currency::USD).unwrap();
let ids: Vec<AccountId> = CHART
.iter()
.map(|(c, n, t)| store.add_account(c, n, *t, None, false).unwrap())
.collect();
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut entry_ids = vec![];
let mut posted = vec![];
let mut receipts = HashMap::new();
for (i, e) in plan.entries.iter().enumerate() {
let date = start + chrono::Days::new(u64::from(e.day));
let id = store
.create_entry(date, &format!("Entry {i} for spec"))
.unwrap();
let mut total = Decimal::ZERO;
for (acct, cents) in &e.debits {
let amt = Decimal::new(*cents as i64, e.scale);
total += amt;
store
.add_line(id, ids[*acct], Side::Debit, usd(amt))
.unwrap();
}
// Split the total across the credit accounts, every part nonzero.
let unit = Decimal::new(1, e.scale);
let mut parts = vec![];
if e.credit_accounts.len() == 2 && total > unit {
let mut first =
(total * Decimal::from(e.split_pct) / Decimal::from(100)).round_dp(e.scale);
if first <= Decimal::ZERO {
first = unit;
}
if first >= total {
first = total - unit;
}
parts.push((e.credit_accounts[0], first));
parts.push((e.credit_accounts[1], total - first));
} else {
parts.push((e.credit_accounts[0], total));
}
for (acct, amt) in parts {
store
.add_line(id, ids[acct], Side::Credit, usd(amt))
.unwrap();
}
if e.receipt {
store
.attach_receipt(
Some(id),
"receipt.pdf",
"application/pdf",
format!("%PDF receipt for entry {id}").as_bytes(),
Some("spec receipt"),
)
.unwrap();
receipts.insert(id, 1);
}
if e.post {
store.post_entry(id).unwrap();
posted.push(id);
}
entry_ids.push(id);
}
let mut reversal = None;
if let Some(k) = plan.reverse
&& !posted.is_empty()
{
let orig = posted[k % posted.len()];
let rev = store
.reverse_entry(orig, today(), &format!("Reverse {orig}"))
.unwrap();
entry_ids.push(rev);
reversal = Some((orig, rev));
}
drop(store);
let books = load(&dir.0, Some(toml));
Built {
_dir: dir,
books,
data,
entry_ids,
receipts,
reversal,
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(24))]
/// Books::build arranges ledger-core's numbers and changes none of them.
#[test]
fn books_build_agrees_with_ledger_core(plan in arb_books()) {
let built = build_books(&plan);
let b = &built.books;
let snap = Snapshot::load(&built.data).unwrap();
let ledger = snap.ledger();
let chart = ledger.chart();
let cash: Vec<AccountId> = CASH_CODES
.iter()
.map(|c| chart.find_by_code(c).unwrap().id)
.collect();
let cash_set: HashSet<AccountId> = cash.iter().copied().collect();
// Cash accounts come from `[books] cash_accounts`.
let got_cash: HashSet<AccountId> = b.cash_accounts.iter().copied().collect();
prop_assert_eq!(&got_cash, &cash_set);
// Newest first: date, then id.
for w in b.entries.windows(2) {
prop_assert!(
(w[0].date, w[0].id) > (w[1].date, w[1].id),
"out of order: #{} {} before #{} {}", w[0].id, w[0].date, w[1].id, w[1].date
);
}
let policy = ledger_config::effective_receipt_policy(
&built.data.join("config.json"),
&b.settings,
)
.unwrap();
for &id in &built.entry_ids {
let info = b.entry(id);
prop_assert!(info.is_some(), "entry #{} not indexed", id);
let info = info.unwrap();
prop_assert_eq!(info.id, id);
let e = ledger.get_entry(id).unwrap();
prop_assert_eq!(info.date, e.date());
prop_assert_eq!(info.status, e.status());
prop_assert_eq!(info.reversal_of, e.reversal_of());
let debits: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit)
.map(|l| l.amount.amount)
.sum();
prop_assert_eq!(info.amount, debits, "amount of #{}", id);
prop_assert_eq!(
info.receipts.len(),
built.receipts.get(&id).copied().unwrap_or(0),
"receipts of #{}", id
);
if e.status() == EntryStatus::Posted {
let net: Decimal = e
.lines()
.iter()
.filter(|l| cash_set.contains(&l.account))
.map(|l| match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
})
.sum();
prop_assert_eq!(info.cash_effect, net, "cash effect of #{}", id);
let has_receipt = built.receipts.contains_key(&id);
if e.reversal_of().is_some() {
prop_assert!(!info.missing_receipt, "reversal #{} flagged", id);
} else {
let expect = !has_receipt && policy.receipt_required(ledger, e);
prop_assert_eq!(info.missing_receipt, expect, "missing_receipt of #{}", id);
}
}
}
if let Some((orig, rev)) = built.reversal {
prop_assert_eq!(b.entry(rev).unwrap().reversal_of, Some(orig));
}
// Kpis.cash: the cash accounts' balance at the end of today.
let want = ledger_core::analysis::balance_series(ledger, &cash, &[b.today])[0];
prop_assert_eq!(b.kpis.cash, want);
let by_account: Decimal = cash.iter().map(|&a| b.balance_on(a, b.today)).sum();
prop_assert_eq!(by_account, want);
prop_assert_eq!(
cash.iter().map(|&a| ledger.account_balance(a).unwrap()).sum::<Decimal>(),
want
);
}
}
// ---------------------------------------------------------------------------
// 7. attention::find
// ---------------------------------------------------------------------------
struct Small {
dir: TempDir,
store: Store,
cash: AccountId,
rent: AccountId,
}
fn small_books(tag: &str) -> Small {
let dir = TempDir::new(tag);
let mut store = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let cash = store
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rent = store
.add_account("6100", "Rent", AccountType::Expense, None, false)
.unwrap();
Small {
dir,
store,
cash,
rent,
}
}
fn add_rent(s: &mut Small, i: u32, post: bool) -> EntryId {
let d = NaiveDate::from_ymd_opt(2026, 9, 1 + i).unwrap();
let id = s.store.create_entry(d, &format!("Rent {i}")).unwrap();
let m = usd(Decimal::new(10_000 + i64::from(i), 2));
s.store.add_line(id, s.rent, Side::Debit, m).unwrap();
s.store.add_line(id, s.cash, Side::Credit, m).unwrap();
if post {
s.store.post_entry(id).unwrap();
}
id
}
fn finish(s: Small) -> (TempDir, Books) {
let Small { dir, store, .. } = s;
drop(store);
let toml = write_toml(&dir.0, "0.00", &["1000"]);
let b = load(&dir.0, Some(toml));
(dir, b)
}
fn check_prompts(b: &Books) {
for item in attention::find(b) {
assert!(
item.prompt.contains("wanderbooks"),
"prompt doesn't name wanderbooks: {:?}",
item.prompt
);
}
}
fn titles(b: &Books) -> Vec<String> {
attention::find(b).into_iter().map(|a| a.title).collect()
}
#[test]
fn attention_empty_books_have_nothing() {
let s = small_books("att-empty");
let (_d, b) = finish(s);
let items = attention::find(&b);
assert!(
items.is_empty(),
"{:?}",
items.iter().map(|a| &a.title).collect::<Vec<_>>()
);
let dir = TempDir::new("att-bare");
drop(Store::create(&dir.0.join("books-data"), Currency::USD).unwrap());
let b = load(&dir.0, None);
assert!(attention::find(&b).is_empty());
}
#[test]
fn attention_one_draft_is_one_todo() {
let mut s = small_books("att-draft");
let id = add_rent(&mut s, 0, false);
let (_d, b) = finish(s);
let items = attention::find(&b);
let todos: Vec<_> = items
.iter()
.filter(|a| a.severity == Severity::Todo)
.collect();
assert_eq!(todos.len(), 1, "{:?}", titles(&b));
let target = todos[0].target.clone();
assert!(
target.is_none() || target == Some(Route::Entry(id)),
"{target:?}"
);
// A draft is not posted, so nothing is missing a receipt yet.
assert!(!items.iter().any(|a| a.severity == Severity::Problem));
check_prompts(&b);
assert_eq!(titles(&b), titles(&b));
}
#[test]
fn attention_missing_receipt_is_one_problem() {
let mut s = small_books("att-missing");
let id = add_rent(&mut s, 0, true);
let (_d, b) = finish(s);
let problems: Vec<_> = attention::find(&b)
.into_iter()
.filter(|a| a.severity == Severity::Problem)
.collect();
assert_eq!(problems.len(), 1, "{:?}", titles(&b));
assert!(b.entry(id).unwrap().missing_receipt);
check_prompts(&b);
assert_eq!(titles(&b), titles(&b));
}
#[test]
fn attention_receipt_attached_is_no_problem() {
let mut s = small_books("att-ok");
let id = add_rent(&mut s, 0, false);
s.store
.attach_receipt(Some(id), "r.pdf", "application/pdf", b"%PDF rent", None)
.unwrap();
s.store.post_entry(id).unwrap();
let (_d, b) = finish(s);
assert!(
!attention::find(&b)
.iter()
.any(|a| a.severity == Severity::Problem),
"{:?}",
titles(&b)
);
assert!(!b.entry(id).unwrap().missing_receipt);
}
#[test]
fn attention_missing_receipts_combine_at_four() {
for (n, want) in [(2u32, 2usize), (3, 3), (4, 1), (6, 1)] {
let mut s = small_books("att-many");
for i in 0..n {
add_rent(&mut s, i, true);
}
let (_d, b) = finish(s);
let problems = attention::find(&b)
.into_iter()
.filter(|a| a.severity == Severity::Problem)
.count();
assert_eq!(problems, want, "{n} missing: {:?}", titles(&b));
check_prompts(&b);
assert_eq!(titles(&b), titles(&b));
}
}
#[test]
fn attention_is_pure_on_mixed_books() {
let mut s = small_books("att-mixed");
for i in 0..5 {
add_rent(&mut s, i, i % 2 == 0);
}
let (_d, b) = finish(s);
let first = attention::find(&b);
let second = attention::find(&b);
assert_eq!(first.len(), second.len());
for (x, y) in first.iter().zip(&second) {
assert_eq!(x.title, y.title);
assert_eq!(x.detail, y.detail);
assert_eq!(x.prompt, y.prompt);
assert_eq!(x.severity, y.severity);
}
// Two drafts, three posted without receipts (and the checking
// account, with activity before this month, due its reconciliation).
let todos = first
.iter()
.filter(|a| a.severity == Severity::Todo && !a.title.starts_with("Reconcile "))
.count();
let problems = first
.iter()
.filter(|a| a.severity == Severity::Problem)
.count();
assert_eq!((todos, problems), (2, 3), "{:?}", titles(&b));
check_prompts(&b);
}