P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s

books.rename_account; account names trimmed, bounded, refused when a
sibling's or TIN-shaped; books.show refuses what its target ignores. A
report asked for without dates covers this fiscal year so far, everywhere
(one rule for tools, descriptions and the viewer; the IIF and CPA package
keep their own dates). policy.require_tax_category warns or refuses; a
Form 4797 gain is shown apart on every return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-21 10:41:24 -06:00
1 parent 8255ac2103
commit 9ab003d0d1
69 files changed
+8063 -570

No files matched your search

+2 -1
View File
@@ -30,6 +30,7 @@ use chrono::NaiveDate;
use egui_kittest::Harness;
use ledger_gui::books::{Books, PartyPeriod};
use ledger_gui::nav::{ReportKind, Route};
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::{Columns, Compare, Range};
use ledger_gui::{App, Source, data};
use serde_json::json;
@@ -360,7 +361,7 @@ fn every_page_renders(shard: usize, of: usize) {
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));
h.state_mut().state_mut().accounts.range = RegisterRange::Dates(NaiveDate::MIN, at);
for &a in &accounts {
show(&mut h, Route::Account(a));
}
+8 -1
View File
@@ -1,6 +1,7 @@
//! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir
//! (containing `books-data/`, its settings in its journal) and `SHOT_DIR` to
//! where the PNGs go; without them this test does nothing.
//! where the PNGs go; without them this test does nothing. `SHOT_REGISTER=all`
//! shows an account's register on every date instead of its default.
use egui_kittest::Harness;
use ledger_gui::nav::{ReportKind, Route};
@@ -153,6 +154,12 @@ fn screenshots() {
if std::env::var("SHOT_LIVE").is_ok() {
harness.state_mut().show_live_label();
}
// SHOT_REGISTER=all: a register showing every date (after "Show
// all dates"); otherwise it opens on the default period.
if std::env::var("SHOT_REGISTER").is_ok_and(|r| r == "all") {
harness.state_mut().state_mut().accounts.range =
ledger_gui::pages::accounts::RegisterRange::All;
}
harness.run_steps(4);
let ctx = harness.ctx.clone();
harness.state_mut().settle(&ctx);
+191
View File
@@ -118,3 +118,194 @@ fn show_requests_navigate_the_viewer() {
});
assert_eq!(h.state_mut().route(), &Route::Transactions);
}
/// The demo books (calendar fiscal year) in a viewer, at `today`.
fn demo(
tag: &str,
today: chrono::NaiveDate,
) -> (TestDir, Harness<'static, App>, ledger_gui::books::Books) {
demo_sized(tag, today, 920.0)
}
/// [`demo`] in a window `height` tall (the register's date row sits below
/// an account's cards: tall enough to click it).
fn demo_sized(
tag: &str,
today: chrono::NaiveDate,
height: f32,
) -> (TestDir, Harness<'static, App>, ledger_gui::books::Books) {
let dir = std::env::temp_dir().join(format!("ledger-gui-show-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let guard = TestDir(dir.clone());
ledger_gui::demo::build(&dir, today).unwrap();
let source = Source {
data_dir: dir.join("books-data"),
today: Some(today),
};
let books = data::load_once(&source).unwrap();
let copy = data::load_once(&source).unwrap();
let h = Harness::builder()
.with_size(egui::vec2(1440.0, height))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, true, Route::Overview));
(guard, h, copy)
}
fn day(y: i32, m: u32, d: u32) -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// Defect 15, repros 1.1.1-1.1.2 (rules.md B-30): a target the viewer
/// can't honour is ignored whole, never swapped or guessed: a start after
/// the end (an earlier writer's, or a hand-edited ops/show.jsonl), text
/// that isn't a date, an unknown split or comparison. The page, the
/// report's range and split, and the register's range stay as they were.
#[test]
fn show_never_swaps_an_inverted_target_defect_15() {
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::{Columns, Compare, Range};
let (_dir, mut h, _) = demo("inverted", day(2026, 10, 20));
h.state_mut().state_mut().reports.range = Range::LastMonth;
h.state_mut().state_mut().reports.columns = Columns::Quarter;
h.state_mut().state_mut().accounts.range = RegisterRange::All;
let report =
|start: Option<&str>, end: Option<&str>, columns: Option<&str>, compare: Option<&str>| {
ViewerTarget::Report {
report: "profit_and_loss".into(),
start: start.map(Into::into),
end: end.map(Into::into),
columns: columns.map(Into::into),
compare: compare.map(Into::into),
}
};
for target in [
report(Some("2026-12-31"), Some("2026-01-01"), None, None),
report(Some("2027-01-01"), None, None, None),
report(Some("January"), None, None, None),
report(None, Some("2026-13-01"), None, None),
report(None, None, Some("weekly"), None),
report(None, None, None, Some("prior_decade")),
ViewerTarget::Account {
code: "1000".into(),
start: Some("2026-12-31".into()),
end: Some("2026-01-01".into()),
},
ViewerTarget::Account {
code: "1000".into(),
start: Some("2027-01-01".into()),
end: None,
},
] {
h.state_mut().show(target.clone());
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Overview, "{target:?}");
let st = h.state_mut().state_mut();
assert_eq!(st.reports.range, Range::LastMonth, "{target:?}");
assert_eq!(st.reports.columns, Columns::Quarter, "{target:?}");
assert_eq!(st.reports.compare, Compare::None, "{target:?}");
assert_eq!(st.accounts.range, RegisterRange::All, "{target:?}");
}
}
/// Defect 16, repro 1.1.4 (rules.md B-30): a report target with only an
/// end covers that end's fiscal year to it (2025-01-01 to 2025-06-30, not
/// the swapped 2025-06-30 to 2026-01-01); with no dates, the default (the
/// fiscal year to date, named "Year to date"); dates that are a preset's
/// show under its name; an account target with no dates opens the
/// register on the default.
#[test]
fn show_report_with_only_end_uses_that_years_start_defect_16() {
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::Range;
let today = day(2026, 10, 20);
let (_dir, mut h, books) = demo("only-end", today);
let report = |start: Option<&str>, end: Option<&str>| ViewerTarget::Report {
report: "profit_and_loss".into(),
start: start.map(Into::into),
end: end.map(Into::into),
columns: None,
compare: None,
};
h.state_mut().show(report(None, Some("2025-06-30")));
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().reports.range,
Range::Custom(day(2025, 1, 1), day(2025, 6, 30))
);
h.state_mut().state_mut().reports.range = Range::LastMonth;
h.state_mut().show(report(None, None));
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().reports.range, Range::YearToDate);
assert_eq!(Range::YearToDate.dates(&books), (day(2026, 1, 1), today));
// What books.show sends for no dates: the same, by name.
h.state_mut()
.show(report(Some("2026-01-01"), Some("2026-10-20")));
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().reports.range, Range::YearToDate);
h.state_mut()
.show(report(Some("2026-09-01"), Some("2026-09-30")));
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().reports.range, Range::LastMonth);
// Only a start: through today.
h.state_mut().show(report(Some("2026-03-15"), None));
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().reports.range,
Range::Custom(day(2026, 3, 15), today)
);
// An account with no dates (an earlier writer's): the default.
h.state_mut().state_mut().accounts.range = RegisterRange::All;
h.state_mut().show(ViewerTarget::Account {
code: "1000".into(),
start: None,
end: None,
});
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().accounts.range,
RegisterRange::Default
);
assert_eq!(
RegisterRange::Default.dates(&books),
(day(2026, 1, 1), today)
);
}
/// Settled in design (P3, defect 16): an account's register opens on the
/// fiscal year to date with "Show all dates" one click away: after
/// books.show of an account (which sends the resolved dates), the button
/// shows every date, the books' first entry through today; from there,
/// "Fiscal year to date" goes back.
#[test]
fn show_all_dates_is_one_click_from_a_shown_register() {
use egui_kittest::kittest::Queryable;
use ledger_gui::pages::accounts::RegisterRange;
let today = day(2026, 10, 20);
let (_dir, mut h, books) = demo_sized("all-dates", today, 3000.0);
let id = books.ledger.chart().find_by_code("1000").unwrap().id;
h.state_mut().show(ViewerTarget::Account {
code: "1000".into(),
start: Some("2026-01-01".into()),
end: Some("2026-10-20".into()),
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Account(id));
assert_eq!(
h.state_mut().state_mut().accounts.range,
RegisterRange::Dates(day(2026, 1, 1), today)
);
h.get_by_label("Show all dates").click();
h.run_steps(2);
assert_eq!(h.state_mut().state_mut().accounts.range, RegisterRange::All);
let first = books.kpis.first_entry.unwrap();
assert_eq!(RegisterRange::All.dates(&books), (first.min(today), today));
assert!(
first < day(2026, 1, 1),
"the demo's books began before this year"
);
h.get_by_label("Fiscal year to date").click();
h.run_steps(2);
assert_eq!(
h.state_mut().state_mut().accounts.range,
RegisterRange::Default
);
}
+11 -1
View File
@@ -1064,8 +1064,18 @@ fn small_books(tag: &str) -> Small {
let cash = store
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
// Mapped to the return, so books with nothing else to do have
// nothing in Needs attention (rules.md B-32 lists every expense
// account without a category, used or not).
let rent = store
.add_account("6100", "Rent", AccountType::Expense, None, false)
.add_account_with_tax(
"6100",
"Rent",
AccountType::Expense,
None,
false,
Some(("rent_property", None)),
)
.unwrap();
Small {
dir,
+148
View File
@@ -163,6 +163,22 @@ proptest! {
fy = fy - Months::new(12);
}
prop_assert_eq!(b.fiscal_ytd(), (fy, t));
// One default period everywhere (rules.md B-30): what a report or
// register opens on is the fiscal year to date, the server's
// resolver's own answer for no dates, and party totals count this
// calendar year.
prop_assert_eq!(b.default_period(), (fy, t));
prop_assert_eq!(b.periods().statement(None, None), Ok((fy, t)));
prop_assert_eq!(ledger_gui::pages::reports::Range::default().dates(&b), (fy, t));
prop_assert_eq!(
ledger_gui::pages::accounts::RegisterRange::default().dates(&b),
(fy, t)
);
let jan1 = NaiveDate::from_ymd_opt(t.year(), 1, 1).unwrap();
prop_assert_eq!(
b.party_span(ledger_gui::books::PartyPeriod::ThisYear).0,
jan1
);
let ytd = pl(fy, t);
prop_assert_eq!(k.net_ytd, ytd.total_revenue - ytd.total_expenses);
let first = p.entries.iter().map(|e| t - Days::new(e.0)).min();
@@ -669,3 +685,135 @@ fn more_origins_and_parties() {
assert_eq!(r.origin, "Payroll taxes");
assert!(!r.missing_receipt, "a remittance isn't a hand-made entry");
}
/// Defect 8, repros 1.4.2 and 1.4.3 (rules.md B-32): Needs attention lists
/// every revenue and expense account without a tax category, with
/// activity or not: one never used, one used only last fiscal year; never
/// a header, an account with a category, or an asset. Info under warn,
/// Todo under refuse.
#[test]
fn needs_attention_flags_every_uncategorized_pl_account_defect_8() {
let today = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap();
let d = dir("attention-tax");
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let ops = s
.add_account("6000", "Operating", AccountType::Expense, None, true)
.unwrap();
s.add_account("6150", "Software", AccountType::Expense, Some(ops), false)
.unwrap();
let hosting = s
.add_account("6160", "Hosting", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account_with_tax(
"4000",
"Sales",
AccountType::Revenue,
None,
false,
Some(("gross_receipts", None)),
)
.unwrap();
let e = s
.create_entry(NaiveDate::from_ymd_opt(2025, 6, 1).unwrap(), "last year")
.unwrap();
let m = Money::new(Decimal::new(1_200, 2), Currency::USD);
s.add_line(e, hosting, Side::Debit, m).unwrap();
s.add_line(e, cash, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
journal_settings(
&mut s,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\ncash_accounts = [\"1000\"]\n",
);
let source = Source {
data_dir: d.join("books-data"),
today: Some(today),
};
let item = |b: &ledger_gui::books::Books| {
ledger_gui::attention::find(b)
.into_iter()
.find(|a| a.title.contains("without a tax category"))
};
let b = data::load_once(&source).unwrap();
let a = item(&b).expect("listed");
assert_eq!(a.title, "2 accounts without a tax category");
assert!(
a.detail.starts_with("6150 Software, 6160 Hosting. Each revenue and expense account maps to a line of the return"),
"{}",
a.detail
);
assert_eq!(a.severity, ledger_gui::books::Severity::Info);
assert!(
a.prompt.contains("books.tax_categories") && a.prompt.contains("books.set_tax_category")
);
// Under refuse, a to-do.
s.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(serde_json::json!("refuse")),
}],
&ledger_core::settings::Source {
decided_by: "Tobin Harlow".into(),
decided_on: today,
citation: None,
note: None,
},
)
.unwrap();
let b = data::load_once(&source).unwrap();
assert_eq!(
item(&b).unwrap().severity,
ledger_gui::books::Severity::Todo
);
// Categorized: no item at all.
for code in ["6150", "6160"] {
let id = s.ledger().chart().find_by_code(code).unwrap().id;
s.set_account_tax(id, Some("other_deductions"), None)
.unwrap();
}
let b = data::load_once(&source).unwrap();
assert!(item(&b).is_none());
}
/// "Show all dates" is every date (P3 settled in design, rules.md B-30):
/// the register's and the reports' all-dates range runs from the first
/// posted entry through the later of today and the last, so an entry
/// dated after today is never left out of it (as party totals' "All time"
/// already ends); with no such entry it ends today.
#[test]
fn all_dates_holds_an_entry_dated_after_today() {
use ledger_gui::pages::accounts::RegisterRange;
use ledger_gui::pages::reports::Range;
let today = NaiveDate::from_ymd_opt(2026, 10, 20).unwrap();
let d = dir("all-dates-future");
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let source = Source {
data_dir: d.join("books-data"),
today: Some(today),
};
let post = |s: &mut Store, day: NaiveDate| {
let e = s.create_entry(day, "x").unwrap();
let m = Money::new(Decimal::new(500, 2), Currency::USD);
s.add_line(e, cash, Side::Debit, m).unwrap();
s.add_line(e, capital, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
};
let first = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap();
post(&mut s, first);
let b = data::load_once(&source).unwrap();
assert_eq!(b.all_dates(), (first, today));
let later = NaiveDate::from_ymd_opt(2026, 12, 1).unwrap();
post(&mut s, later);
let b = data::load_once(&source).unwrap();
assert_eq!(b.kpis.last_entry, Some(later));
assert_eq!(RegisterRange::All.dates(&b), (first, later));
assert_eq!(Range::AllTime.dates(&b), (first, later));
}