Files
wanderbooks/crates/ledger-gui/tests/projects.rs
T
swaitsandClaude Opus 5.5 3eda95eb3c
check / check (push) Canceled after 0s
Projects: the snapshot of a project's figures
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
2026-10-22 06:54:26 -06:00

1255 lines
42 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.
//! Projects in the viewer (rules.md B-37): the list and a project's page
//! show ledger-core's figures; rows, links and bars open what they name;
//! the journal, books.show and the party page know projects.
use chrono::NaiveDate;
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use ledger_core::conventions::NewDecision;
use ledger_core::{AccountType, ClassId, Currency, Money, ProjectFacts, ProjectKind, Side};
use ledger_gui::books::Books;
use ledger_gui::nav::{Route, Section};
use ledger_gui::pages::journal::describe;
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};
use rust_decimal::Decimal;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
fn today() -> NaiveDate {
d(2026, 10, 15)
}
/// Books with three projects: "Zeta site" (client work for Riley Chen:
/// $5,000.00 earned, $1,800.00 of contractors), "alpha app" (on spec:
/// $300.00 of costs, and test devices bought for it, an asset), and
/// "Beta" (a bare class); rent is no project's. A pitch is linked to
/// alpha, and a decision names it.
struct Fixture {
_dir: TempDir,
books: Books,
zeta: ClassId,
alpha: ClassId,
beta: ClassId,
riley: u64,
alpha_cost: u64,
pitch: u64,
}
fn fixture(tag: &str) -> Fixture {
let dir = TempDir(
std::env::temp_dir().join(format!("ledger-gui-projects-{tag}-{}", std::process::id())),
);
let _ = std::fs::remove_dir_all(&dir.0);
let data_dir = dir.0.join("books-data");
let mut s = Store::create(&data_dir, Currency::USD).unwrap();
let mut add = |code: &str, name: &str, ty| s.add_account(code, name, ty, None, false).unwrap();
let cash = add("1000", "Cash", AccountType::Asset);
let devices = add("1200", "Equipment", AccountType::Asset);
let revenue = add("4000", "Design Services", AccountType::Revenue);
let contractors = add("6000", "Contractors", AccountType::Expense);
let rent = add("6200", "Rent", AccountType::Expense);
let riley = s.add_party("Riley Chen", &[]).unwrap();
let zeta = s
.add_project(
"Zeta site",
&ProjectFacts {
kind: Some(ProjectKind::Client),
party: Some(riley),
description: Some("A site for Riley".into()),
..Default::default()
},
)
.unwrap();
let alpha = s
.add_project(
"alpha app",
&ProjectFacts {
kind: Some(ProjectKind::OnSpec),
links: vec!["https://alpha.example".into()],
..Default::default()
},
)
.unwrap();
let beta = s.add_class("Beta").unwrap();
let post = |s: &mut Store, date, what: &str, dr, cr, c, tag: Option<(ClassId, bool)>| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
if let Some((class, debit)) = tag {
s.set_line_class(e, if debit { 0 } else { 1 }, Some(class))
.unwrap();
}
s.post_entry(e).unwrap();
e
};
post(
&mut s,
d(2026, 3, 2),
"Zeta milestone",
cash,
revenue,
500_000,
Some((zeta, false)),
);
post(
&mut s,
d(2026, 3, 9),
"Zeta contractor",
contractors,
cash,
180_000,
Some((zeta, true)),
);
let alpha_cost = post(
&mut s,
d(2026, 4, 1),
"Alpha prototype",
contractors,
cash,
30_000,
Some((alpha, true)),
);
post(
&mut s,
d(2026, 4, 2),
"Alpha test devices",
devices,
cash,
90_000,
Some((alpha, true)),
);
post(&mut s, d(2026, 4, 30), "Rent", rent, cash, 150_000, None);
let (pitch, _, _) = s
.file_document(
"other",
"alpha pitch.pdf",
"application/pdf",
b"%PDF-1.4 pitch",
None,
)
.unwrap();
s.link_attachment(pitch, Subject::Project(u64::from(alpha)))
.unwrap();
s.record_decision(&NewDecision {
subject: "Alpha on spec".into(),
decision_md: "Build it on spec.".into(),
decided_by: vec!["Tobin Harlow".into()],
decided_on: d(2026, 4, 1),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![alpha],
})
.unwrap();
drop(s);
let books = load(&dir);
Fixture {
_dir: dir,
books,
zeta,
alpha,
beta,
riley,
alpha_cost,
pitch,
}
}
fn load(dir: &TempDir) -> Books {
data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(today()),
})
.unwrap()
}
fn harness(f: &Fixture, route: Route) -> Harness<'static, App> {
let books = load(&f._dir);
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.clone()));
h.run_steps(3);
h
}
#[test]
fn projects_page_lists_every_project_alphabetically() {
let f = fixture("list");
let rows = listed(&f.books);
let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["alpha app", "Beta", "Zeta site"]);
assert_eq!(rows[1].facts, ProjectFacts::default());
assert_eq!(Section::Projects.home(), Route::Projects);
assert_eq!(Route::Project(f.beta).section(), Section::Projects);
let h = harness(&f, Route::Projects);
h.get_by_label("3 projects, all active. Net to date across them: $2,900.00.");
for n in names {
h.get_by_role_and_label(Role::Button, n);
}
assert_eq!(rows[1].facts.kind, None, "Beta: kind not recorded");
}
#[test]
fn project_figures_are_ledger_cores() {
let f = fixture("figures");
let t = ledger_core::project_to_date(&f.books.ledger, f.zeta, today()).unwrap();
assert_eq!((t.revenue, t.expenses), (cents(500_000), cents(180_000)));
let row = listed(&f.books)
.into_iter()
.find(|r| r.id == f.zeta)
.unwrap();
assert_eq!(row.to_date, Some(t.clone()));
let h = harness(&f, Route::Project(f.zeta));
h.get_by_label("$5,000.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(
&f.books.ledger,
f.zeta,
ledger_core::calendar::FIRST_DAY,
today(),
)
.unwrap();
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));
}
// 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%");
}
#[test]
fn unrecovered_cost_shows_only_on_spec() {
let f = fixture("unrecovered");
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: 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());
}
#[test]
fn a_project_row_opens_the_project() {
let f = fixture("row");
let mut h = harness(&f, Route::Projects);
h.get_by_role_and_label(Role::Button, "alpha app").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
}
#[test]
fn an_entry_row_opens_the_entry() {
let f = fixture("entry-row");
let mut h = harness(&f, Route::Project(f.alpha));
h.get_by_role_and_label(Role::Button, "Alpha prototype")
.click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Entry(f.alpha_cost));
}
#[test]
fn a_document_opens_the_document() {
let f = fixture("doc");
let mut h = harness(&f, Route::Project(f.alpha));
h.get_by_label("alpha pitch.pdf").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Document(f.pitch));
}
#[test]
fn the_party_link_opens_the_party() {
let f = fixture("party-link");
let mut h = harness(&f, Route::Project(f.zeta));
h.get_by_label("Riley Chen").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Party(f.riley));
}
#[test]
fn profit_by_project_bar_opens_the_project() {
let f = fixture("profit");
let mut h = harness(&f, Route::Profit);
h.get_by_label("Net income by project, this year");
h.get_by_role_and_label(Role::Button, "Zeta site").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.zeta));
}
#[test]
fn party_page_lists_its_projects() {
let f = fixture("party");
assert_eq!(f.books.ledger.projects_of_party(f.riley), vec![f.zeta]);
let mut h = harness(&f, Route::Party(f.riley));
h.get_by_role_and_label(Role::Button, "Zeta site").click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.zeta));
}
#[test]
fn entry_line_project_opens_the_project() {
let f = fixture("entry-line");
let mut h = harness(&f, Route::Entry(f.alpha_cost));
h.get_by_label(&format!("{} alpha app", egui_phosphor::regular::TAG))
.click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
}
#[test]
fn journal_describes_project_set() {
let f = fixture("journal");
let b = &f.books;
let texts: Vec<(String, Option<Route>)> = b
.journal
.iter()
.filter_map(|l| describe(b, l).map(|e| (e.text, e.route)))
.collect();
let has = |t: &str, r: Route| texts.iter().any(|(x, y)| x == t && *y == Some(r.clone()));
assert!(
has("Added project Zeta site", Route::Project(f.zeta)),
"{texts:?}"
);
assert!(
has(
"Project Zeta site: active, client work",
Route::Project(f.zeta)
),
"{texts:?}"
);
assert!(
has(
"Project alpha app: active, on spec",
Route::Project(f.alpha)
),
"{texts:?}"
);
assert!(
has("Added project Beta", Route::Project(f.beta)),
"{texts:?}"
);
assert!(
texts
.iter()
.any(|(t, _)| t == "Linked alpha pitch.pdf to Project alpha app"),
"{texts:?}"
);
}
#[test]
fn show_project_target_opens_its_page() {
let f = fixture("show");
let mut h = harness(&f, Route::Overview);
h.state_mut()
.show(ViewerTarget::Project { class_id: f.alpha });
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
h.state_mut().show(ViewerTarget::Project { class_id: 999 });
h.run_steps(1);
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
h.state_mut().show(ViewerTarget::Page {
page: "projects".into(),
});
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Projects);
// Every project's page, and one that doesn't exist, render.
for c in [f.zeta, f.alpha, f.beta, 999] {
h.state_mut().goto(Route::Project(c));
h.run_steps(2);
}
}
/// A project named `name` with `link`, a $100.00 sale and a decision, in
/// books of its own; a harness `w` by `h` on its page.
fn edge(tag: &str, name: &str, link: &str, w: f32, h: f32) -> (TempDir, Harness<'static, App>) {
let dir = TempDir(std::env::temp_dir().join(format!(
"ledger-gui-projects-edge-{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();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let revenue = s
.add_account("4000", "Design Services", AccountType::Revenue, None, false)
.unwrap();
let id = s
.add_project(
name,
&ProjectFacts {
kind: Some(ProjectKind::Client),
links: vec![link.to_string()],
..Default::default()
},
)
.unwrap();
let e = s.create_entry(d(2026, 3, 2), "Sale").unwrap();
s.add_line(e, cash, Side::Debit, usd(10_000)).unwrap();
s.add_line(e, revenue, Side::Credit, usd(10_000)).unwrap();
s.set_line_class(e, 1, Some(id)).unwrap();
s.post_entry(e).unwrap();
s.record_decision(&NewDecision {
subject: "Take the job".into(),
decision_md: "Do it.".into(),
decided_by: vec!["Tobin Harlow".into()],
decided_on: d(2026, 3, 1),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![id],
})
.unwrap();
drop(s);
let books = load(&dir);
let mut harness = Harness::builder()
.with_size(egui::vec2(w, h))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Project(id)));
harness.run_steps(4);
// For eyes: SHOT_DIR keeps a picture of each edge page.
if let Ok(out) = std::env::var("SHOT_DIR") {
let _ = std::fs::create_dir_all(&out);
if let Ok(img) = harness.render() {
img.save(std::path::Path::new(&out).join(format!("{tag}.png")))
.unwrap();
}
}
(dir, harness)
}
/// A 200-character link with no space to wrap at stays inside the Facts
/// card: left of the Decisions card beside it, and inside the window.
#[test]
fn a_long_link_stays_in_its_card() {
let link = format!("https://git.example/{}", "a".repeat(180));
for (w, tag) in [(1440.0, "link-1440"), (900.0, "link-900")] {
let (_dir, h) = edge(tag, "Harbor", &link, w, 2600.0);
let shown = h.get_by_label_contains("https://git.example/").rect();
let decisions = h.get_by_label("Decisions").rect();
assert!(
shown.right() <= w,
"{w}: the link runs to {}",
shown.right()
);
if decisions.left() > shown.left() {
assert!(
shown.right() <= decisions.left(),
"{w}: the link runs to {}, under Decisions at {}",
shown.right(),
decisions.left()
);
}
let net = h.get_by_label("Contribution").rect();
assert!(net.right() <= w, "{w}: Contribution at {net:?}");
}
}
/// A 200-character name, with spaces or without, keeps the page inside
/// the window: the title and every tile show.
#[test]
fn a_long_name_keeps_the_page_in_the_window() {
for (name, tag) in [
("Harbor ".repeat(28).trim().to_string(), "name-spaces"),
("H".repeat(200), "name-nospace"),
] {
let (_dir, h) = edge(tag, &name, "https://harbor.example", 1440.0, 2000.0);
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!(
decisions.right() <= 1440.0,
"{tag}: Decisions at {decisions:?}"
);
let named: Vec<_> = h
.query_all_by_label_contains("HHHHHHHHHH")
.chain(h.query_all_by_label_contains("Harbor Harbor"))
.collect();
assert!(!named.is_empty(), "{tag}: the name shows");
for title in named {
assert!(
title.rect().right() <= 1440.0,
"{tag}: {title:?} at {:?}",
title.rect()
);
}
}
}
/// The by-month bars are the project's own: a click opens no report (the
/// reports are the whole company's), and the margin keeps one place.
#[test]
fn a_month_bar_opens_nothing_and_margin_keeps_one_place() {
let (_dir, mut h) = edge("month", "Harbor", "https://harbor.example", 1440.0, 2000.0);
let route = h.state_mut().route().clone();
// All income, no spending: 100.0%, as 64.0% elsewhere.
h.get_by_label("100.0%");
let title = h.get_by_label("By month").rect();
let at = egui::pos2(title.left() + 200.0, title.bottom() + 120.0);
h.hover_at(at);
h.run_steps(2);
for pressed in [true, false] {
h.event(egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(1);
}
h.run_steps(2);
assert_eq!(h.state_mut().route(), &route);
}
/// An entry with no receipt says nothing about receipts.
#[test]
fn an_entry_without_receipts_says_nothing_of_them() {
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
let f = fixture("no-receipts");
let h = harness(&f, Route::Project(f.zeta));
let mut texts = Vec::new();
for c in &h.output().shapes {
walk(&c.shape, &mut texts);
}
let subs: Vec<&String> = texts.iter().filter(|t| t.contains("Contractors")).collect();
assert!(
!subs.is_empty(),
"the entries' sub-lines are painted: {texts:?}"
);
assert!(
!texts.iter().any(|t| t.contains("0 receipts")),
"no \"0 receipts\": {subs:?}"
);
}
/// The party page has the same long-name edge: a 200-character name keeps
/// to its Facts card and the window.
#[test]
fn a_long_party_name_keeps_to_its_card() {
let dir = TempDir(std::env::temp_dir().join(format!(
"ledger-gui-projects-party-long-{}",
std::process::id()
)));
let _ = std::fs::remove_dir_all(&dir.0);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let name = "P".repeat(200);
let party = s.add_party(&name, &[]).unwrap();
drop(s);
let books = load(&dir);
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 1400.0))
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Party(party)));
h.run_steps(4);
let linked = h.get_by_label("Linked records").rect();
let named: Vec<_> = h.query_all_by_label_contains("PPPPPPPPPP").collect();
assert!(!named.is_empty());
for n in named {
let r = n.rect();
assert!(r.right() <= 1440.0, "{n:?} at {r:?}");
// The Facts card's Name row, beside Linked records.
let key = h.get_by_label("Name").rect();
if (r.center().y - key.center().y).abs() < 12.0 && r.left() < linked.left() {
assert!(
r.right() <= linked.left(),
"{n:?} at {r:?} under Linked records"
);
}
}
}
// ------------------------------------------------- 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);
}
}