Projects: the snapshot of a project's figures
check / check (push) Canceled after 0s

The project page opens on a snapshot: revenue, direct costs, the
contribution before overhead and its margin of revenue (a segment
margin: revenue less every cost traced to the project, fixed and
variable; B-37 item 7), in the black or red, and for on-spec work the
cost unrecovered if repaid. To date or this fiscal year. By month,
revenue and costs with the net to date in its own strip; costs by
category; the list colors each net by its sign. books.project carries
the same figures, and a parity test runs the viewer and the MCP on the
same books to the cent. Tests honor TMPDIR.

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-22 06:54:26 -06:00
1 parent d24653558c
commit 3eda95eb3c
23 files changed
+2231 -246

No files matched your search

+29 -11
View File
@@ -22,18 +22,36 @@ use std::path::{Path, PathBuf};
const SSN: &str = "620-37-1111";
/// This test binary's downloads folder: its own, never the human's.
static DOWNLOADS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
unsafe extern "C" {
/// The C library's: runs `f` as the process exits, which libtest's
/// main does pass or fail (it returns, or calls `exit`).
safe fn atexit(f: extern "C" fn()) -> std::ffi::c_int;
}
/// Removes the downloads folder as the test binary exits: its files hold
/// (made-up) TINs, and every test's dir is removed when it ends (the
/// justfile's sweep-test-tmp catches a run killed first: it is `wb-*`).
extern "C" fn remove_downloads() {
if let Some(d) = DOWNLOADS.get() {
let _ = std::fs::remove_dir_all(d);
}
}
fn downloads() -> PathBuf {
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let d = tempfile::Builder::new()
.prefix("wb-gui-downloads-")
.tempdir()
.unwrap()
.keep();
ledger_gui::export::set_downloads_dir(d.clone());
d
})
.clone()
DOWNLOADS
.get_or_init(|| {
let d = tempfile::Builder::new()
.prefix("wb-gui-downloads-")
.tempdir()
.unwrap()
.keep();
assert_eq!(atexit(remove_downloads), 0, "atexit refused");
ledger_gui::export::set_downloads_dir(d.clone());
d
})
.clone()
}
/// A fresh folder in the downloads folder for one test (tests run side by
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 9afb8bbc956b20e53a67ecdd13e018dc139152a98f0d94057929cfeea935e4e2 # shrinks to lines = [(0, 1, false, true, true)]
+676 -12
View File
@@ -11,7 +11,7 @@ use ledger_core::{AccountType, ClassId, Currency, Money, ProjectFacts, ProjectKi
use ledger_gui::books::Books;
use ledger_gui::nav::{Route, Section};
use ledger_gui::pages::journal::describe;
use ledger_gui::pages::projects::listed;
use ledger_gui::pages::projects::{CONTRIBUTION_NOTE, Snap, Standing, chart_span, listed};
use ledger_gui::{App, Source, data};
use ledger_store::ops::ViewerTarget;
use ledger_store::{Store, Subject};
@@ -224,7 +224,8 @@ fn project_figures_are_ledger_cores() {
assert_eq!(row.to_date, Some(t.clone()));
let h = harness(&f, Route::Project(f.zeta));
h.get_by_label("$5,000.00");
h.get_by_label("$1,800.00");
// Direct costs, and the costs card's total.
assert_eq!(h.get_all_by_label("$1,800.00").count(), 2);
h.get_by_label("$3,200.00");
// By account to date: the same statement lines.
let fig = ledger_core::project_figures(
@@ -234,10 +235,21 @@ fn project_figures_are_ledger_cores() {
today(),
)
.unwrap();
for l in fig.period.revenue.iter().chain(&fig.period.expenses) {
assert_eq!(fig.to_date.revenue_by_account, fig.period.revenue);
assert_eq!(fig.to_date.expenses_by_account, fig.period.expenses);
for l in &fig.period.revenue {
h.get_by_role_and_label(Role::Button, &format!("{} {}", l.code, l.name));
}
h.get_by_label("Margin to date");
// An expense account is a row and a bar on Costs by category.
for l in &fig.period.expenses {
let label = format!("{} {}", l.code, l.name);
assert_eq!(
h.get_all_by_role_and_label(Role::Button, &label).count(),
2,
"{label}"
);
}
h.get_by_label("Contribution margin");
h.get_by_label("64.0%");
}
@@ -247,9 +259,10 @@ fn unrecovered_cost_shows_only_on_spec() {
let h = harness(&f, Route::Project(f.alpha));
h.get_by_label("Unrecovered cost");
h.get_by_label("if repaid: not a receivable");
// The devices (an asset) count nothing: spending and the unrecovered
// cost are both the prototype's $300.00.
assert_eq!(h.get_all_by_label("$300.00").count(), 2);
// The devices (an asset) count nothing: direct costs, the costs
// card's total and the unrecovered cost are all the prototype's
// $300.00.
assert_eq!(h.get_all_by_label("$300.00").count(), 3);
let h = harness(&f, Route::Project(f.zeta));
assert!(h.query_by_label("Unrecovered cost").is_none());
}
@@ -464,8 +477,8 @@ fn a_long_link_stays_in_its_card() {
decisions.left()
);
}
let net = h.get_by_label("Net to date").rect();
assert!(net.right() <= w, "{w}: Net to date at {net:?}");
let net = h.get_by_label("Contribution").rect();
assert!(net.right() <= w, "{w}: Contribution at {net:?}");
}
}
@@ -478,9 +491,10 @@ fn a_long_name_keeps_the_page_in_the_window() {
("H".repeat(200), "name-nospace"),
] {
let (_dir, h) = edge(tag, &name, "https://harbor.example", 1440.0, 2000.0);
for tile in ["Income to date", "Net to date", "Margin to date"] {
let r = h.get_by_label(tile).rect();
assert!(r.right() <= 1440.0, "{tag}: {tile} at {r:?}");
for tile in ["Revenue", "Contribution", "Contribution margin"] {
for r in h.get_all_by_label(tile).map(|n| n.rect()) {
assert!(r.right() <= 1440.0, "{tag}: {tile} at {r:?}");
}
}
let decisions = h.get_by_label("Decisions").rect();
assert!(
@@ -588,3 +602,653 @@ fn a_long_party_name_keeps_to_its_card() {
}
}
}
// ------------------------------------------------- the snapshot (B-37 item 7)
/// Books holding one project of `kind`, started `started_on`, with
/// `posts`: (date, debit code, credit code, cents), the line that isn't
/// cash tagged to it. Codes: 1000 Cash, 4000 Design Services, 6000
/// Contractors, 6100 Software. Rent (6200) is no project's.
struct Mini {
dir: TempDir,
id: ClassId,
tag: String,
}
fn mini(
tag: &str,
kind: ProjectKind,
started_on: Option<NaiveDate>,
posts: &[(NaiveDate, &str, &str, i64)],
) -> Mini {
let dir = TempDir(std::env::temp_dir().join(format!(
"ledger-gui-projects-snap-{tag}-{}",
std::process::id()
)));
let _ = std::fs::remove_dir_all(&dir.0);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
for (code, name, ty) in [
("1000", "Cash", AccountType::Asset),
("4000", "Design Services", AccountType::Revenue),
("6000", "Contractors", AccountType::Expense),
("6100", "Software", AccountType::Expense),
("6200", "Rent", AccountType::Expense),
] {
s.add_account(code, name, ty, None, false).unwrap();
}
let id = s
.add_project(
"Kestrel",
&ProjectFacts {
kind: Some(kind),
started_on,
..Default::default()
},
)
.unwrap();
let code = |s: &Store, c: &str| s.ledger().chart().find_by_code(c).unwrap().id;
for &(date, dr, cr, c) in posts {
let e = s.create_entry(date, "Line").unwrap();
let (dr_id, cr_id) = (code(&s, dr), code(&s, cr));
s.add_line(e, dr_id, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr_id, Side::Credit, usd(c)).unwrap();
s.set_line_class(e, if dr == "1000" { 1 } else { 0 }, Some(id))
.unwrap();
s.post_entry(e).unwrap();
}
// Overhead, no project's: never in the snapshot.
let e = s.create_entry(d(2026, 4, 30), "Rent").unwrap();
let (rent, cash) = (code(&s, "6200"), code(&s, "1000"));
s.add_line(e, rent, Side::Debit, usd(150_000)).unwrap();
s.add_line(e, cash, Side::Credit, usd(150_000)).unwrap();
s.post_entry(e).unwrap();
drop(s);
Mini {
dir,
id,
tag: format!("snap-{tag}"),
}
}
fn mini_harness(m: &Mini) -> Harness<'static, App> {
let books = load(&m.dir);
let id = m.id;
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 2000.0))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Project(id)));
h.run_steps(3);
shot(&mut h, &m.tag);
h
}
/// For eyes: SHOT_DIR keeps a picture of the page.
fn shot(h: &mut Harness<'static, App>, tag: &str) {
if let Ok(out) = std::env::var("SHOT_DIR") {
let _ = std::fs::create_dir_all(&out);
if let Ok(img) = h.render() {
img.save(std::path::Path::new(&out).join(format!("{tag}.png")))
.unwrap();
}
}
}
/// Every text painted this frame.
fn painted(h: &Harness<'static, App>) -> Vec<String> {
painted_colored(h).into_iter().map(|(t, _)| t).collect()
}
/// Every text painted this frame, with its color.
fn painted_colored(h: &Harness<'static, App>) -> Vec<(String, egui::Color32)> {
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Color32)>) {
match s {
egui::Shape::Text(t) => {
let color = t
.galley
.job
.sections
.first()
.map_or(t.fallback_color, |s| s.format.color);
let color = t.override_text_color.unwrap_or(color);
out.push((t.galley.text().to_string(), color));
}
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let mut out = Vec::new();
for c in &h.output().shapes {
walk(&c.shape, &mut out);
}
out
}
/// The value a snapshot tile labelled `label` shows: the label directly
/// under that tile's own (the same left edge, just below), so a figure
/// elsewhere on the page can't stand in for it. Exactly one such tile.
fn tile(h: &Harness<'static, App>, label: &str) -> String {
let nodes: Vec<(egui::Rect, String)> = h
.query_all(egui_kittest::kittest::By::new().role(Role::Label))
.filter_map(|n| Some((n.rect(), n.value()?)))
.collect();
let found: Vec<String> = nodes
.iter()
.filter(|(_, v)| v == label)
.filter_map(|(r, _)| {
nodes
.iter()
.filter(|(x, _)| {
(x.left() - r.left()).abs() < 1.0
&& x.top() >= r.bottom() - 1.0
&& x.top() <= r.bottom() + 12.0
})
.min_by(|a, b| a.0.top().total_cmp(&b.0.top()))
.map(|(_, v)| v.clone())
})
.filter(|v| v.contains('$') || v.ends_with('%'))
.collect();
assert_eq!(found.len(), 1, "one tile {label}: {found:?}");
found[0].clone()
}
/// The tiles' figures are ledger-core's, the same books.project returns
/// (to_date's revenue, expenses, net and margin_pct; its tests check the
/// server's side, and ledger-cli's projects_parity both on one book).
fn assert_tiles(h: &Harness<'static, App>, s: &Snap) {
use ledger_gui::fmt::dollars;
assert_eq!(tile(h, "Revenue"), dollars(s.revenue), "revenue");
assert_eq!(tile(h, "Direct costs"), dollars(s.expenses), "direct costs");
assert_eq!(
tile(h, "Contribution"),
dollars(s.contribution),
"contribution"
);
match s.margin_pct {
Some(m) => assert_eq!(tile(h, "Contribution margin"), format!("{m}%")),
None => assert!(h.query_by_label("Contribution margin").is_none()),
}
}
/// [`assert_tiles`], and the pill says where it stands (to date, whichever
/// view: `standing`).
fn assert_snapshot(h: &Harness<'static, App>, s: &Snap, standing: Standing) {
assert_tiles(h, s);
h.get_by_label(standing.words());
}
/// The tooltip shown after hovering `at` a while: every label containing
/// `text` once it shows.
fn hover_shows(h: &mut Harness<'static, App>, at: egui::Pos2, text: &str) -> bool {
h.hover_at(at);
for _ in 0..60 {
h.run_steps(1);
if h.query_all_by_label_contains(text).next().is_some()
|| painted(h).iter().any(|t| t.contains(text))
{
return true;
}
}
false
}
#[test]
fn a_profitable_projects_snapshot_is_ledger_cores() {
let f = fixture("snap-profit");
let t = ledger_core::project_to_date(&f.books.ledger, f.zeta, today()).unwrap();
let s = Snap::to_date(&t);
assert_eq!(
s,
Snap {
revenue: cents(500_000),
expenses: cents(180_000),
contribution: cents(320_000),
margin_pct: Some(Decimal::new(640, 1)),
standing: Standing::Black,
}
);
assert_eq!(s.contribution, t.net());
let mut h = harness(&f, Route::Project(f.zeta));
shot(&mut h, "snap-profit");
assert_snapshot(&h, &s, s.standing);
h.get_by_label("before overhead");
assert!(h.query_all_by_label("Snapshot").next().is_some());
// The Contribution tile says what it leaves out, on hover.
let at = h
.query_all_by_label("Contribution")
.map(|n| n.rect())
.min_by(|a, b| a.top().total_cmp(&b.top()))
.unwrap()
.center();
assert!(
hover_shows(&mut h, at, CONTRIBUTION_NOTE),
"the Contribution tile's hover note"
);
// Costs by category: its one expense account, a bar that opens it.
h.get_by_label("Costs by category, to date");
assert_eq!(
h.get_all_by_role_and_label(Role::Button, "6000 Contractors")
.count(),
2,
"a bar and By account's row"
);
}
#[test]
fn a_loss_making_project_is_in_the_red() {
let m = mini(
"loss",
ProjectKind::Client,
None,
&[
(d(2026, 3, 2), "1000", "4000", 20_000),
(d(2026, 3, 9), "6000", "1000", 10_000),
(d(2026, 4, 9), "6100", "1000", 50_000),
],
);
let b = load(&m.dir);
let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap();
let s = Snap::to_date(&t);
assert_eq!(s.contribution, cents(-40_000));
assert_eq!(s.standing, Standing::Red);
assert_eq!(s.margin_pct, Some(Decimal::new(-2000, 1)));
let h = mini_harness(&m);
assert_snapshot(&h, &s, Standing::Red);
// Rent, overhead, is no part of it.
assert!(h.query_by_label("$1,500.00").is_none());
// Both cost accounts, largest first: the costs card's bars (the
// first buttons so named; By account has the others, by code), so
// Software's $500 is above Contractors' $100 though its code is after.
let first = |label: &str| {
h.get_all_by_role_and_label(Role::Button, label)
.next()
.unwrap()
.rect()
};
assert!(first("6100 Software").top() < first("6000 Contractors").top());
}
#[test]
fn an_on_spec_projects_snapshot_has_its_unrecovered_cost() {
let f = fixture("snap-spec");
let t = ledger_core::project_to_date(&f.books.ledger, f.alpha, today()).unwrap();
let s = Snap::to_date(&t);
assert_eq!(s.standing, Standing::Red);
assert_eq!(t.unrecovered_cost, Some(cents(30_000)));
let mut h = harness(&f, Route::Project(f.alpha));
shot(&mut h, "snap-spec");
assert_snapshot(&h, &s, Standing::Red);
h.get_by_label("Unrecovered cost");
h.get_by_label("if repaid: not a receivable");
}
#[test]
fn no_revenue_means_no_margin() {
let m = mini(
"no-revenue",
ProjectKind::Internal,
None,
&[(d(2026, 5, 1), "6100", "1000", 9_900)],
);
let h = mini_harness(&m);
assert!(h.query_by_label("Contribution margin").is_none());
h.get_by_label("In the red");
h.get_by_label("−$99.00");
}
#[test]
fn a_project_with_no_entries_has_nothing_tagged_yet() {
let m = mini("empty", ProjectKind::Client, None, &[]);
let b = load(&m.dir);
let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap();
assert_eq!(Snap::to_date(&t).standing, Standing::Nothing);
assert_eq!(chart_span(today(), None, None), None);
let h = mini_harness(&m);
h.get_by_label("Nothing tagged yet");
h.get_by_label("nothing tagged yet");
h.get_by_label("No tagged lines yet");
h.get_by_label("No costs tagged");
assert!(h.query_by_label("Contribution margin").is_none());
}
#[test]
fn a_reimbursement_above_its_cost_leaves_costs_below_zero() {
let m = mini(
"reimbursed",
ProjectKind::Client,
None,
&[
(d(2026, 2, 9), "6000", "1000", 20_000),
// The client repays the contractor and more, credited to it.
(d(2026, 3, 1), "1000", "6000", 30_000),
],
);
let b = load(&m.dir);
let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap();
assert_eq!(t.expenses, cents(-10_000));
assert_eq!(t.expenses_by_account[0].amount, cents(-10_000));
let s = Snap::to_date(&t);
assert_eq!(
(s.contribution, s.standing),
(cents(10_000), Standing::Black)
);
assert_eq!(s.margin_pct, None, "no revenue");
let h = mini_harness(&m);
// Direct costs, and the costs card's total.
assert_eq!(h.get_all_by_label("−$100.00").count(), 2);
h.get_by_label("Below zero: an account credited more than it cost (a reimbursement).");
assert_eq!(
h.get_all_by_role_and_label(Role::Button, "6000 Contractors")
.count(),
2
);
}
#[test]
fn a_project_started_this_month_charts_one_month() {
let m = mini(
"this-month",
ProjectKind::Client,
Some(d(2026, 10, 1)),
&[(d(2026, 10, 5), "1000", "4000", 50_000)],
);
let b = load(&m.dir);
assert_eq!(
chart_span(today(), Some(d(2026, 10, 1)), Some(d(2026, 10, 5))),
Some((d(2026, 10, 1), today()))
);
let f = ledger_core::project_figures(&b.ledger, m.id, d(2026, 10, 1), today()).unwrap();
assert_eq!(f.months.len(), 1);
assert_eq!(f.months_net_to_date, vec![cents(50_000)]);
let h = mini_harness(&m);
h.get_by_label("since Oct 2026");
h.get_by_label("In the black");
h.get_by_label("100.0%");
}
#[test]
fn the_chart_runs_from_the_start_or_first_entry_and_caps() {
// The earlier of the two, from its month's first day.
assert_eq!(
chart_span(today(), Some(d(2026, 6, 20)), Some(d(2026, 3, 9))),
Some((d(2026, 3, 1), today()))
);
assert_eq!(
chart_span(today(), Some(d(2026, 2, 2)), Some(d(2026, 3, 9))),
Some((d(2026, 2, 1), today()))
);
assert_eq!(
chart_span(today(), Some(d(2026, 2, 2)), None),
Some((d(2026, 2, 1), today()))
);
// A start after today charts only this month.
assert_eq!(
chart_span(today(), Some(d(2027, 1, 1)), None),
Some((d(2026, 10, 1), today()))
);
// Ten years back: the last 24 months.
assert_eq!(
chart_span(today(), Some(d(2016, 1, 1)), None),
Some((d(2024, 11, 1), today()))
);
}
/// The fiscal year switch shows books.project's default period (B-30's
/// omitted dates: the fiscal year to date), and switching writes nothing.
#[test]
fn the_fiscal_year_switch_shows_the_default_period() {
let m = mini(
"fiscal",
ProjectKind::Client,
None,
&[
(d(2025, 11, 3), "1000", "4000", 100_000),
(d(2025, 12, 1), "6100", "1000", 25_000),
(d(2026, 2, 9), "6000", "1000", 40_000),
],
);
let b = load(&m.dir);
let (start, end) = b.periods().statement(None, None).unwrap();
assert!(
start > d(2025, 12, 1),
"the sale and the software are before this fiscal year"
);
let fy = Snap::period(&ledger_core::project_figures(&b.ledger, m.id, start, end).unwrap());
let all = Snap::to_date(&ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap());
assert_eq!(
(all.expenses, all.contribution, all.standing),
(cents(65_000), cents(35_000), Standing::Black)
);
assert_eq!(
(fy.revenue, fy.expenses, fy.contribution, fy.standing),
(Decimal::ZERO, cents(40_000), cents(-40_000), Standing::Red)
);
let journal = std::fs::read(m.dir.0.join("books-data").join("journal.jsonl")).unwrap();
let mut h = mini_harness(&m);
assert_snapshot(&h, &all, Standing::Black);
h.get_by_label("This fiscal year").click();
h.run_steps(3);
assert!(h.state_mut().state_mut().projects.fiscal);
shot(&mut h, "snap-fiscal");
// The tiles are the fiscal year's; where it stands is still to date,
// as the to-date cards beside it and the list say.
assert_snapshot(&h, &fy, Standing::Black);
assert!(h.query_by_label("In the red").is_none());
assert_eq!(
h.query_all_by_label(&format!("since {}", ledger_gui::fmt::date(start)))
.count(),
2,
"Revenue's and Direct costs' span"
);
h.get_by_label("To date").click();
h.run_steps(3);
assert!(!h.state_mut().state_mut().projects.fiscal);
assert_snapshot(&h, &all, Standing::Black);
assert_eq!(
std::fs::read(m.dir.0.join("books-data").join("journal.jsonl")).unwrap(),
journal,
"the viewer wrote nothing"
);
}
/// The list colors each net by its sign and says where it stands.
#[test]
fn the_list_cues_in_the_black_and_in_the_red() {
let f = fixture("list-cue");
let rows = listed(&f.books);
let standing = |id| {
rows.iter()
.find(|r| r.id == id)
.and_then(|r| r.to_date.as_ref())
.map(|t| Snap::to_date(t).standing)
};
assert_eq!(standing(f.zeta), Some(Standing::Black));
assert_eq!(standing(f.alpha), Some(Standing::Red));
assert_eq!(standing(f.beta), Some(Standing::Nothing));
let mut h = harness(&f, Route::Projects);
shot(&mut h, "snap-list");
let texts = painted_colored(&h);
let color = |t: &str| {
texts
.iter()
.find(|(x, _)| x.ends_with(t))
.map(|(_, c)| *c)
.unwrap_or_else(|| panic!("{t} painted: {texts:?}"))
};
// Each net in its standing's color, as its cue: never the names'.
let (black, red, name) = (color("3,200.00"), color("(300.00)"), color("Zeta site"));
assert_eq!(black, color("in the black"));
assert_eq!(red, color("in the red"));
assert!(
black != name && red != name && black != red,
"{black:?} {red:?} {name:?}"
);
// Alphabetical still.
let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["alpha app", "Beta", "Zeta site"]);
}
/// A cost tagged and then repaid exactly (or reversed) leaves no account
/// with a balance, but it was tagged: break-even, never "nothing tagged".
#[test]
fn a_cost_repaid_exactly_breaks_even() {
let m = mini(
"even",
ProjectKind::Client,
None,
&[
(d(2026, 3, 9), "6000", "1000", 40_000),
(d(2026, 4, 1), "1000", "6000", 40_000),
],
);
let b = load(&m.dir);
let t = ledger_core::project_to_date(&b.ledger, m.id, today()).unwrap();
assert!(t.expenses_by_account.is_empty(), "the account nets to zero");
assert_eq!((t.revenue_lines, t.expenses_lines), (0, 2));
let s = Snap::to_date(&t);
assert_eq!(
(s.contribution, s.standing),
(Decimal::ZERO, Standing::Even)
);
let mut h = mini_harness(&m);
assert_snapshot(&h, &s, Standing::Even);
h.get_by_label("before overhead");
h.get_by_label("Tagged costs net to zero");
h.get_by_label("Tagged lines net to zero");
assert!(h.query_by_label("Nothing tagged yet").is_none());
assert!(h.query_by_label("No costs tagged").is_none());
// The list: 0.00 and its cue, not a dash.
h.state_mut().goto(Route::Projects);
h.run_steps(3);
let texts = painted(&h);
assert!(texts.iter().any(|t| t.ends_with("break-even")), "{texts:?}");
assert!(texts.iter().any(|t| t == "0.00"), "{texts:?}");
}
/// Lines only in earlier years: this fiscal year has nothing, said so,
/// while the pill keeps the standing to date.
#[test]
fn a_fiscal_year_with_nothing_says_so() {
let m = mini(
"fiscal-empty",
ProjectKind::Client,
None,
&[(d(2025, 6, 3), "1000", "4000", 70_000)],
);
let mut h = mini_harness(&m);
h.get_by_label("This fiscal year").click();
h.run_steps(3);
shot(&mut h, "snap-fiscal-empty");
h.get_by_label("nothing this fiscal year");
h.get_by_label("In the black");
assert!(h.query_by_label("Nothing tagged yet").is_none());
}
/// The by-month chart's running net is ledger-core's months_net_to_date:
/// hovering each month shows its "Net to date", in the chart's own strip.
#[test]
fn the_running_net_is_each_months_net_to_date() {
let m = mini(
"running",
ProjectKind::Client,
None,
&[
(d(2026, 3, 2), "1000", "4000", 120_000),
(d(2026, 3, 9), "6000", "1000", 10_000),
(d(2026, 5, 9), "6100", "1000", 30_000),
(d(2026, 8, 1), "1000", "4000", 45_000),
],
);
let b = load(&m.dir);
let (s, e) = chart_span(today(), None, Some(d(2026, 3, 2))).unwrap();
let f = ledger_core::project_figures(&b.ledger, m.id, s, e).unwrap();
assert_eq!(f.months.len(), 8);
// In month order, a month repeating the one before it (nothing new
// that month) told once: what sweeping the pointer across shows.
let mut want: Vec<String> = f
.months_net_to_date
.iter()
.map(|v| format!("Net to date: {}", ledger_gui::fmt::dollars(*v)))
.collect();
want.dedup();
assert_eq!(want.len(), 3, "{want:?}");
let mut h = mini_harness(&m);
let title = h.get_by_label("By month").rect();
let right = h.get_by_label("Costs by category, to date").rect().left();
// Over the bars, and over the strip under them.
for dy in [100.0, 230.0] {
let mut seen = Vec::new();
let mut x = title.left();
while x < right {
h.hover_at(egui::pos2(x, title.bottom() + dy));
h.run_steps(2);
for t in painted(&h) {
seen.extend(
t.lines()
.filter(|l| {
l.starts_with("Net to date: $") || l.starts_with("Net to date: −$")
})
.map(str::to_string),
);
}
x += 12.0;
}
seen.dedup();
assert_eq!(seen, want, "at {dy}");
}
shot(&mut h, "snap-running");
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
/// The snapshot's contribution is project_to_date's net, whatever the
/// lines (refunds, reimbursements), and its standing is the net's sign.
#[test]
fn prop_snapshot_contribution_is_the_net_to_date(
lines in proptest::collection::vec((0usize..3, 1i64..500_000, proptest::bool::ANY, proptest::bool::ANY, proptest::bool::ANY), 0..12),
) {
let mut l = ledger_core::Ledger::new(Currency::USD);
let cash = l.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap();
let accts = [
l.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap(),
l.add_account("6000", "Costs", AccountType::Expense, None, false).unwrap(),
l.add_account("6100", "Tools", AccountType::Expense, None, false).unwrap(),
];
let k = l.add_class("K").unwrap();
let mut tagged = 0usize;
for (a, c, debit, tag, reverse) in lines {
let e = l.create_entry(d(2026, 3, 1), "e");
let (dr, cr) = if debit { (accts[a], cash) } else { (cash, accts[a]) };
l.add_line(e, dr, Side::Debit, usd(c)).unwrap();
l.add_line(e, cr, Side::Credit, usd(c)).unwrap();
if tag {
l.set_line_class(e, if debit { 0 } else { 1 }, Some(k)).unwrap();
tagged += 1;
}
l.post_entry(e).unwrap();
// A reversal: its tagged line too, netting the account to
// zero (it was tagged all the same).
if reverse {
l.reverse_entry(e, d(2026, 3, 2), "undo").unwrap();
if tag {
tagged += 1;
}
}
}
let t = ledger_core::project_to_date(&l, k, today()).unwrap();
proptest::prop_assert_eq!(t.revenue_lines + t.expenses_lines, tagged);
let s = Snap::to_date(&t);
proptest::prop_assert_eq!(s.contribution, t.net());
proptest::prop_assert_eq!(s.revenue - s.expenses, s.contribution);
proptest::prop_assert_eq!(s.margin_pct, t.margin_pct());
let want = if t.net() > Decimal::ZERO {
Standing::Black
} else if t.net() < Decimal::ZERO {
Standing::Red
} else if tagged == 0 {
Standing::Nothing
} else {
Standing::Even
};
proptest::prop_assert_eq!(s.standing, want);
}
}
+2
View File
@@ -105,6 +105,7 @@ fn screenshots() {
.and_then(|p| p.parse().ok())
.unwrap_or_else(|| project_named("Tidepool app"));
let project_client = project_named("Client work");
let project_templates = project_named("Templates");
drop(loaded);
let routes: Vec<(&str, Route)> = vec![
("overview", Route::Overview),
@@ -141,6 +142,7 @@ fn screenshots() {
("projects", Route::Projects),
("project", Route::Project(project)),
("project-client", Route::Project(project_client)),
("project-templates", Route::Project(project_templates)),
("document", Route::Document(last_doc)),
("document-linked", Route::Document(linked_doc)),
("reconciliation", Route::Reconciliation(reconciliation)),