Files
wanderbooks/crates/ledger-gui/tests/projects.rs
T
swaitsandClaude Opus 5.5 d7fa16e974
check / check (push) Canceled after 0s
feat(arap)!: documents read their figures from standing lines; control accounts
refuse hand entries (P4b S1, part A)

Plan P4b (D-45), slice S1, the engine half: rules.md B-38.

- ledger-core: every receivables or payables line carries its document
  (Ledger.line_docs: DocRef, Movement, DocTag), written by the document
  commands in the step that posts it, derived on replay from the existing
  document events (the role's account as it stood then; payments by the
  ordinal rule), copied onto a reversal's mirror. Ledger::stands_on;
  doc_state / invoice_state / bill_state (total, paid, outstanding,
  status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day;
  ar_aging / ap_aging read them, so reversed payments and voids are right.
  The control guard (add_line, add_line_fx, post_entry,
  register_recurring) refuses untagged lines on receivables/payables,
  exempting a reversal's mirror of a legacy line; replay never refuses.
  Bounds (kernel::running_bounds, bounds_kept) on payments and reversals;
  a document reversal never dated before its entry; deposit/payer accounts
  must be cash-like and hold no role. New API: receive_payment_with /
  pay_bill_with (PaymentDetails: instrument, reference), void_document /
  check_void_document, control_check, payments_recorded,
  standing_payments, replay_reverse_entry. Invoice.amount_paid/status
  remain only as derived caches for the MCP layer until part B.
  cashflow bill_splits reads payments from the tags.
- ledger-store: journal feature document_standing (D-39 gated) for a
  payment accepted only because an earlier one was reversed;
  receive_payment_with / pay_bill_with (instrument journaled as
  entry.instrument_set, reference in the description), void_document;
  replay uses replay_reverse_entry; schema 15 drops the projection's
  amount_paid/status. Fixture p4-arap-before + golden figures written by
  the version before S1, adjudicated in arap_standing.rs.
- ledger-gui: party card, cash "coming up" and company owed totals read
  doc_state as of today (late from the derived state).
- Lean Documents.lean (control_agrees and friends); Kani
  bounds_check_matches_definition,
bounds_kept_is_the_definition_on_books_in_bounds;
  rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand
  lines; tests that hand-post to 1200/2000 post as replay or renumbered.

Review fixes (both reviews of part A):
- control_role_of guards only an account the role is assigned to or one
  holding its documents' lines (a chart's own 1200 Equipment or card at
  2000 is free until a document posts there); payment accounts check
  other roles by holds_role (type-checked). The MCP tests that failed on
  the guard (oracle_mcp, spec_projects) pass unchanged.
- standing_payments leaves out reversals (sorted, deduped); replayed
  tags match the movement's side; payments_recorded counts every payment
  event (tagged or not, Ledger.payment_events) and refuses overflow.
- The record caches settled (paid + discounted + written off) and owed
  (Invoice/Bill.owed, never serialized): a void never reads as paid.
- assign_role: receivables/payables refuse moving onto an account with
  untagged lines (RoleAccountHasHandLines) and, on their control account
  holding a balance, say DocumentRoleHoldsBalance (no hand entry named);
  B-23 reworded. The reversal-mirror exemption only takes a hand line
  off (even chain depth); putting it back is refused.
- DocStatus Overpaid and Unattached; aging leaves out credit balances;
  ControlCheck.attention lists out-of-bounds and unattached documents.
- stands_on counts a chain in order; indexes (doc_index, doc_accounts,
  order_pos, reversed_by) keep the derived figures linear.
- Viewer reads a post-dated document as of its own date; one state per
  row. bounds_kept's side-crossing documented as intended.
- Tests: ten new spec_documents scenarios, stronger proptests, store
  proptest arap_props (live = replay = snapshot at every date, closed
  months), fuzz ops void/payments with details, a GUI post-dated test;
  Lean Step.assign / Step.release with unattached_zero kept; model
  limits stated in docs/testing.md and B-38.
Review fixes (CPA and engineering lenses): only an assigned role's
account or one holding document lines is a control account (a chart's
own 1200 asset stays free); a role can't move onto untagged lines or off
document lines (Lean Step.assign); a void's cache never reads as paid;
no Decimal sum panics; per-document indexes keep figures linear; a
replayed tag matches its side; the feature gate counts payment events;
a payment's reversal is never listed as standing; an overpaid document
has its own status; the viewer keeps post-dated documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 09:17:42 -06:00

1418 lines
48 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("1500", "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);
}
}
/// rules.md B-37 item 8: an invoice's or bill's page (its entry's) lists
/// the document's lines, each project a link that opens it; an untagged
/// line has none. The entry's own lines show the tagged revenue or cost
/// line too.
#[test]
fn an_invoice_and_a_bill_show_each_lines_project() {
use ledger_core::arap::DocumentLine;
let dir = TempDir(std::env::temp_dir().join(format!(
"ledger-gui-projects-documents-{}",
std::process::id()
)));
let _ = std::fs::remove_dir_all(&dir.0);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let zeta = s
.add_project(
"Zeta site",
&ProjectFacts {
kind: Some(ProjectKind::Client),
..Default::default()
},
)
.unwrap();
let harbor = s.add_class("Harbor app").unwrap();
let c = s.add_customer("Cedar Client").unwrap();
let v = s.add_vendor("Dana Illustration").unwrap();
let line = |what: &str, c: i64, class| DocumentLine {
description: what.into(),
amount: cents(c),
class_id: class,
};
let inv = s
.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(zeta)),
line("Rush fee", 10_000, None),
],
)
.unwrap();
let bill = s
.create_bill(
v,
d(2026, 3, 9),
d(2026, 4, 8),
vec![
line("Illustration", 120_000, Some(harbor)),
line("Postage", 5_000, None),
],
)
.unwrap();
let inv_entry = s.ledger().get_invoice(inv).unwrap().entry_id;
let bill_entry = s.ledger().get_bill(bill).unwrap().entry_id;
drop(s);
let tag = |name: &str| format!("{} {name}", egui_phosphor::regular::TAG);
for (entry, title, kept, untagged, project, id, shot_name) in [
(
inv_entry,
"INVOICE 1, DUE APR 1, 2026",
"Design",
"Rush fee",
"Zeta site",
zeta,
"invoice-lines",
),
(
bill_entry,
"BILL 1, DUE APR 8, 2026",
"Illustration",
"Postage",
"Harbor app",
harbor,
"bill-lines",
),
] {
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::Entry(entry))
});
h.run_steps(3);
shot(&mut h, shot_name);
h.get_by_label(title);
h.get_by_label(kept);
h.get_by_label(untagged);
// The entry's tagged line and the document's line: two links, one
// project; the untagged lines, none.
assert_eq!(h.query_all_by_label(&tag(project)).count(), 2, "{title}");
// The two links and the history's "tagged project" event: nothing
// else wears a tag, so the untagged lines have none.
assert_eq!(
h.query_all_by_label_contains(egui_phosphor::regular::TAG)
.count(),
3,
"{title}"
);
assert_eq!(
h.query_all_by_label_contains(&format!("tagged project {project}"))
.count(),
1,
"{title}"
);
// The document's line, the lower of the two, opens the project.
h.query_all_by_label(&tag(project))
.max_by(|a, b| a.rect().top().total_cmp(&b.rect().top()))
.unwrap()
.click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(id), "{title}");
}
}
/// The demo's "Client work" project carries every client invoice (rules.md
/// B-37 item 8): its revenue to date is the invoices' total, its direct
/// costs the contractors tagged to it.
#[test]
fn the_demos_client_work_counts_every_client_invoice() {
let dir = TempDir(
std::env::temp_dir().join(format!("ledger-gui-projects-demo-{}", std::process::id())),
);
let _ = std::fs::remove_dir_all(&dir.0);
let today = d(2026, 10, 12);
ledger_gui::demo::build(&dir.0, today).unwrap();
let b = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(today),
})
.unwrap();
let l = &b.ledger;
let client = l.find_class_by_name("Client work").unwrap().id;
let invoices = l.invoices();
assert!(!invoices.is_empty());
assert!(
invoices
.iter()
.all(|i| i.lines.iter().all(|x| x.class_id == Some(client))),
"every client invoice is tagged"
);
let invoiced: Decimal = invoices.iter().map(|i| i.total().unwrap()).sum();
let t = ledger_core::project_to_date(l, client, today).unwrap();
assert_eq!(t.revenue, invoiced);
let tagged_costs: Decimal = l
.posted_entries()
.flat_map(|e| e.lines().iter())
.filter(|x| x.class_id == Some(client))
.filter(|x| {
l.chart()
.get(x.account)
.is_some_and(|a| a.account_type == AccountType::Expense)
})
.map(|x| match x.side {
Side::Debit => x.amount.amount,
Side::Credit => -x.amount.amount,
})
.sum();
assert_eq!(t.expenses, tagged_costs);
assert!(t.expenses > Decimal::ZERO && t.net() > Decimal::ZERO);
}