P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s
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:
69 files changed
+8063
-570
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user