check / check (push) Canceled after 0s
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
1418 lines
48 KiB
Rust
1418 lines
48 KiB
Rust
//! 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);
|
||
}
|