Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A project is a class with facts (description, status, kind, party, dates, links, notes) in one project.set event behind the format feature projects. Four tools (books.add_project, update_project, projects, project); documents and decisions link to projects; B-37: on-spec costs are expensed as incurred, repayment is revenue, and the unrecovered cost is never below zero (Kani). Viewer: a Projects list and a project page with its figures, by month and by account. The sidebar measures its footer instead of reserving a fixed 172 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
71 files changed
+7446
-217
No files matched your search
@@ -49,6 +49,7 @@ fn crumbs_link_every_level_but_the_last() {
|
||||
let entry = b.entries.first().unwrap().id;
|
||||
let mut routes = vec![
|
||||
Route::Party(party),
|
||||
Route::Project(b.ledger.list_classes()[0].id),
|
||||
Route::Reconciliation(rec.id),
|
||||
Route::Entry(entry),
|
||||
Route::Reports(ReportKind::ProfitAndLoss),
|
||||
@@ -112,7 +113,11 @@ fn clicking_a_crumb_goes_there() {
|
||||
h.get_all_by_label(&account).next().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().route(), &Route::Account(rec.account_id));
|
||||
h.get_by_label("Accounts").click();
|
||||
// The crumb, not the sidebar's link of the same name (left of it).
|
||||
h.get_all_by_label("Accounts")
|
||||
.max_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().route(), &Route::Accounts);
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::parties::{EntryFlow, entry_flow, money_accounts};
|
||||
use ledger_core::{AccountType, Currency, Money, PartyShare, Role, Share, Side};
|
||||
use ledger_gui::books::{Books, PartyPeriod, share_of_what_for};
|
||||
@@ -276,3 +277,122 @@ fn the_page_state_a_test_sets_is_the_apps() {
|
||||
PartyPeriod::Year(2025)
|
||||
);
|
||||
}
|
||||
|
||||
/// The sidebar link named `name` (the leftmost node of that name).
|
||||
fn sidebar_link(h: &Harness<'_, App>, name: &str) -> egui::Rect {
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, name)
|
||||
.map(|n| n.rect())
|
||||
.min_by(|a, b| a.left().total_cmp(&b.left()))
|
||||
.unwrap_or_else(|| panic!("no {name} link"))
|
||||
}
|
||||
|
||||
/// A short window (owner, 2026-10-21: the links went under the status
|
||||
/// box): scrolled to its end, the last link stops above the box.
|
||||
#[test]
|
||||
fn sidebar_links_never_draw_under_the_status_box() {
|
||||
let f = fixture("sidebar-short");
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 500.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
|
||||
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.scroll_to_me();
|
||||
h.run_steps(4);
|
||||
let journal = sidebar_link(&h, "Journal");
|
||||
let status = h.get_by_label_contains("files verified").rect();
|
||||
assert!(
|
||||
journal.bottom() <= status.top(),
|
||||
"Journal ends at {} under the status box at {}",
|
||||
journal.bottom(),
|
||||
status.top()
|
||||
);
|
||||
}
|
||||
|
||||
/// The rail's equivalent: its last link stops above its footer.
|
||||
#[test]
|
||||
fn rail_links_never_draw_under_its_footer() {
|
||||
let f = fixture("rail-short");
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(900.0, 420.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
|
||||
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.scroll_to_me();
|
||||
h.run_steps(4);
|
||||
let journal = sidebar_link(&h, "Journal");
|
||||
assert!(journal.right() <= 60.0, "the rail is drawn: {journal:?}");
|
||||
let footer = h
|
||||
.ctx
|
||||
.data(|d| d.get_temp::<f32>(egui::Id::new("rail_footer_h")))
|
||||
.expect("the rail's footer was measured");
|
||||
// The rail's frame keeps 16 points under its footer.
|
||||
let footer_top = 420.0 - 16.0 - footer;
|
||||
assert!(
|
||||
journal.bottom() <= footer_top,
|
||||
"Journal ends at {} under the footer at {footer_top}",
|
||||
journal.bottom()
|
||||
);
|
||||
}
|
||||
|
||||
/// A window too short for the links and the status box together keeps
|
||||
/// one link whole above the box (the box gives way, off the bottom),
|
||||
/// sidebar and rail alike.
|
||||
#[test]
|
||||
fn a_very_short_window_keeps_one_link() {
|
||||
for (w, h_px, tag) in [(1440.0, 220.0, "sidebar-tiny"), (900.0, 200.0, "rail-tiny")] {
|
||||
let f = fixture(tag);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(w, h_px))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
let first = sidebar_link(&h, "Overview");
|
||||
assert!(
|
||||
first.height() >= 31.0 && first.bottom() <= h_px,
|
||||
"{tag}: Overview at {first:?}"
|
||||
);
|
||||
if let Some(status) = h.query_by_label_contains("files verified") {
|
||||
assert!(
|
||||
first.bottom() <= status.rect().top(),
|
||||
"{tag}: Overview ends at {} under the status box at {}",
|
||||
first.bottom(),
|
||||
status.rect().top()
|
||||
);
|
||||
}
|
||||
let key = if w < 1000.0 {
|
||||
"rail_footer_h"
|
||||
} else {
|
||||
"sidebar_footer_h"
|
||||
};
|
||||
let footer = h
|
||||
.ctx
|
||||
.data(|d| d.get_temp::<f32>(egui::Id::new(key)))
|
||||
.expect("the footer was measured");
|
||||
assert!(footer > 0.0, "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
/// At the usual height every section's link shows and nothing scrolls.
|
||||
#[test]
|
||||
fn a_normal_window_shows_every_link_unscrolled() {
|
||||
let f = fixture("sidebar-normal");
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 920.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
let first = sidebar_link(&h, "Overview");
|
||||
for s in ledger_gui::nav::Section::ALL {
|
||||
sidebar_link(&h, s.title());
|
||||
}
|
||||
let status = h.get_by_label_contains("files verified").rect();
|
||||
assert!(sidebar_link(&h, "Journal").bottom() <= status.top());
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
|
||||
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.scroll_to_me();
|
||||
h.run_steps(4);
|
||||
assert_eq!(sidebar_link(&h, "Overview"), first, "the links scrolled");
|
||||
}
|
||||
@@ -116,6 +116,7 @@ fn the_company_page_shows_how_the_books_are_kept() {
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
@@ -126,6 +127,7 @@ fn the_company_page_shows_how_the_books_are_kept() {
|
||||
evidence_attachment: None,
|
||||
supersedes: Some(first),
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Mail the IRS letter", Some(d(2026, 10, 16))))
|
||||
|
||||
@@ -198,11 +198,13 @@ fn routes(b: &Books) -> Vec<Route> {
|
||||
Route::Accounts,
|
||||
Route::Assets,
|
||||
Route::Parties,
|
||||
Route::Projects,
|
||||
Route::Documents,
|
||||
Route::Company,
|
||||
Route::Journal,
|
||||
];
|
||||
out.extend(ReportKind::ALL.map(Route::Reports));
|
||||
out.extend(b.ledger.list_classes().iter().map(|c| Route::Project(c.id)));
|
||||
out.extend((1..=b.ledger.next_entry_id()).map(Route::Entry));
|
||||
out.extend(b.ledger.chart().all().map(|a| Route::Account(a.id)));
|
||||
out.extend(b.ledger.registered_assets().map(|a| Route::Asset(a.id)));
|
||||
@@ -535,3 +537,97 @@ fn an_older_books_dirs_config_json_sets_its_receipt_rule() {
|
||||
.unwrap();
|
||||
assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
}
|
||||
|
||||
/// rules.md B-37: the classes an earlier version wrote read as projects
|
||||
/// with the default facts (active, kind not recorded), listed by name,
|
||||
/// their figures ledger-core's.
|
||||
#[test]
|
||||
fn old_books_classes_read_as_projects() {
|
||||
let mut seen = 0;
|
||||
// Copies of their own: the shards above copy the same fixtures under
|
||||
// the fixtures' names, side by side.
|
||||
let root =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ledger-store/tests/fixtures/old-books");
|
||||
let mut fixtures: Vec<PathBuf> = std::fs::read_dir(&root)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
fixtures.sort();
|
||||
let copies = fixtures.iter().map(|f| {
|
||||
let name = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
let t = temp(&format!("projects-{name}"));
|
||||
copy_books(f, &t.0.join("books-data"));
|
||||
write_settings(&t.0);
|
||||
(name, t)
|
||||
});
|
||||
for (name, dir) in copies {
|
||||
let b = data::load_once(&ledger_gui::Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
today: Some(d(2026, 10, 12)),
|
||||
})
|
||||
.unwrap();
|
||||
let listed = ledger_gui::pages::projects::listed(&b);
|
||||
assert_eq!(listed.len(), b.ledger.list_classes().len(), "{name}");
|
||||
for p in &listed {
|
||||
seen += 1;
|
||||
assert_eq!(p.facts, ledger_core::ProjectFacts::default(), "{name}");
|
||||
assert_eq!(
|
||||
p.to_date,
|
||||
ledger_core::project_to_date(&b.ledger, p.id, b.today).ok(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
let names: Vec<String> = listed.iter().map(|p| p.name.to_lowercase()).collect();
|
||||
let mut sorted = names.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(names, sorted, "{name}: alphabetical");
|
||||
}
|
||||
// No fixture holds a class: books with the lines an earlier version
|
||||
// wrote for one (class.added, a line tagged with it), and nothing more.
|
||||
let t = temp("old-class");
|
||||
let data = t.0.join("books-data");
|
||||
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rev = store
|
||||
.add_account(
|
||||
"4000",
|
||||
"Sales",
|
||||
ledger_core::AccountType::Revenue,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let class = store.add_class("Harbor app").unwrap();
|
||||
let e = store.create_entry(d(2026, 3, 1), "sale").unwrap();
|
||||
let usd =
|
||||
|c| ledger_core::Money::new(rust_decimal::Decimal::new(c, 2), ledger_core::Currency::USD);
|
||||
store
|
||||
.add_line(e, cash, ledger_core::Side::Debit, usd(5000))
|
||||
.unwrap();
|
||||
store
|
||||
.add_line(e, rev, ledger_core::Side::Credit, usd(5000))
|
||||
.unwrap();
|
||||
store.set_line_class(e, 1, Some(class)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
let journal = std::fs::read_to_string(data.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
assert!(!journal.contains("project.set"), "nothing new was written");
|
||||
drop(store);
|
||||
write_settings(&t.0);
|
||||
let b = data::load_once(&ledger_gui::Source {
|
||||
data_dir: data,
|
||||
today: Some(d(2026, 10, 12)),
|
||||
})
|
||||
.unwrap();
|
||||
let listed = ledger_gui::pages::projects::listed(&b);
|
||||
assert_eq!(listed.len(), 1);
|
||||
seen += 1;
|
||||
assert_eq!(listed[0].name, "Harbor app");
|
||||
assert_eq!(listed[0].facts, ledger_core::ProjectFacts::default());
|
||||
let t = listed[0].to_date.as_ref().unwrap();
|
||||
assert_eq!(t.revenue, rust_decimal::Decimal::new(5000, 2));
|
||||
assert_eq!(t.unrecovered_cost, None);
|
||||
assert!(seen > 0);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! 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::listed;
|
||||
use ledger_gui::{App, Source, data};
|
||||
use ledger_store::ops::ViewerTarget;
|
||||
use ledger_store::{Store, Subject};
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn cents(c: i64) -> Decimal {
|
||||
Decimal::new(c, 2)
|
||||
}
|
||||
|
||||
fn usd(c: i64) -> Money {
|
||||
Money::new(cents(c), Currency::USD)
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
d(2026, 10, 15)
|
||||
}
|
||||
|
||||
/// Books with three projects: "Zeta site" (client work for Riley Chen:
|
||||
/// $5,000.00 earned, $1,800.00 of contractors), "alpha app" (on spec:
|
||||
/// $300.00 of costs, and test devices bought for it, an asset), and
|
||||
/// "Beta" (a bare class); rent is no project's. A pitch is linked to
|
||||
/// alpha, and a decision names it.
|
||||
struct Fixture {
|
||||
_dir: TempDir,
|
||||
books: Books,
|
||||
zeta: ClassId,
|
||||
alpha: ClassId,
|
||||
beta: ClassId,
|
||||
riley: u64,
|
||||
alpha_cost: u64,
|
||||
pitch: u64,
|
||||
}
|
||||
|
||||
fn fixture(tag: &str) -> Fixture {
|
||||
let dir = TempDir(
|
||||
std::env::temp_dir().join(format!("ledger-gui-projects-{tag}-{}", std::process::id())),
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir.0);
|
||||
let data_dir = dir.0.join("books-data");
|
||||
let mut s = Store::create(&data_dir, Currency::USD).unwrap();
|
||||
let mut add = |code: &str, name: &str, ty| s.add_account(code, name, ty, None, false).unwrap();
|
||||
let cash = add("1000", "Cash", AccountType::Asset);
|
||||
let devices = add("1200", "Equipment", AccountType::Asset);
|
||||
let revenue = add("4000", "Design Services", AccountType::Revenue);
|
||||
let contractors = add("6000", "Contractors", AccountType::Expense);
|
||||
let rent = add("6200", "Rent", AccountType::Expense);
|
||||
let riley = s.add_party("Riley Chen", &[]).unwrap();
|
||||
let zeta = s
|
||||
.add_project(
|
||||
"Zeta site",
|
||||
&ProjectFacts {
|
||||
kind: Some(ProjectKind::Client),
|
||||
party: Some(riley),
|
||||
description: Some("A site for Riley".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let alpha = s
|
||||
.add_project(
|
||||
"alpha app",
|
||||
&ProjectFacts {
|
||||
kind: Some(ProjectKind::OnSpec),
|
||||
links: vec!["https://alpha.example".into()],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let beta = s.add_class("Beta").unwrap();
|
||||
let post = |s: &mut Store, date, what: &str, dr, cr, c, tag: Option<(ClassId, bool)>| {
|
||||
let e = s.create_entry(date, what).unwrap();
|
||||
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
|
||||
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
|
||||
if let Some((class, debit)) = tag {
|
||||
s.set_line_class(e, if debit { 0 } else { 1 }, Some(class))
|
||||
.unwrap();
|
||||
}
|
||||
s.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 3, 2),
|
||||
"Zeta milestone",
|
||||
cash,
|
||||
revenue,
|
||||
500_000,
|
||||
Some((zeta, false)),
|
||||
);
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 3, 9),
|
||||
"Zeta contractor",
|
||||
contractors,
|
||||
cash,
|
||||
180_000,
|
||||
Some((zeta, true)),
|
||||
);
|
||||
let alpha_cost = post(
|
||||
&mut s,
|
||||
d(2026, 4, 1),
|
||||
"Alpha prototype",
|
||||
contractors,
|
||||
cash,
|
||||
30_000,
|
||||
Some((alpha, true)),
|
||||
);
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 4, 2),
|
||||
"Alpha test devices",
|
||||
devices,
|
||||
cash,
|
||||
90_000,
|
||||
Some((alpha, true)),
|
||||
);
|
||||
post(&mut s, d(2026, 4, 30), "Rent", rent, cash, 150_000, None);
|
||||
let (pitch, _, _) = s
|
||||
.file_document(
|
||||
"other",
|
||||
"alpha pitch.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF-1.4 pitch",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.link_attachment(pitch, Subject::Project(u64::from(alpha)))
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
subject: "Alpha on spec".into(),
|
||||
decision_md: "Build it on spec.".into(),
|
||||
decided_by: vec!["Tobin Harlow".into()],
|
||||
decided_on: d(2026, 4, 1),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![alpha],
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let books = load(&dir);
|
||||
Fixture {
|
||||
_dir: dir,
|
||||
books,
|
||||
zeta,
|
||||
alpha,
|
||||
beta,
|
||||
riley,
|
||||
alpha_cost,
|
||||
pitch,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(dir: &TempDir) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn harness(f: &Fixture, route: Route) -> Harness<'static, App> {
|
||||
let books = load(&f._dir);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 2000.0))
|
||||
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_page_lists_every_project_alphabetically() {
|
||||
let f = fixture("list");
|
||||
let rows = listed(&f.books);
|
||||
let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
|
||||
assert_eq!(names, ["alpha app", "Beta", "Zeta site"]);
|
||||
assert_eq!(rows[1].facts, ProjectFacts::default());
|
||||
assert_eq!(Section::Projects.home(), Route::Projects);
|
||||
assert_eq!(Route::Project(f.beta).section(), Section::Projects);
|
||||
let h = harness(&f, Route::Projects);
|
||||
h.get_by_label("3 projects, all active. Net to date across them: $2,900.00.");
|
||||
for n in names {
|
||||
h.get_by_role_and_label(Role::Button, n);
|
||||
}
|
||||
assert_eq!(rows[1].facts.kind, None, "Beta: kind not recorded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_figures_are_ledger_cores() {
|
||||
let f = fixture("figures");
|
||||
let t = ledger_core::project_to_date(&f.books.ledger, f.zeta, today()).unwrap();
|
||||
assert_eq!((t.revenue, t.expenses), (cents(500_000), cents(180_000)));
|
||||
let row = listed(&f.books)
|
||||
.into_iter()
|
||||
.find(|r| r.id == f.zeta)
|
||||
.unwrap();
|
||||
assert_eq!(row.to_date, Some(t.clone()));
|
||||
let h = harness(&f, Route::Project(f.zeta));
|
||||
h.get_by_label("$5,000.00");
|
||||
h.get_by_label("$1,800.00");
|
||||
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();
|
||||
for l in fig.period.revenue.iter().chain(&fig.period.expenses) {
|
||||
h.get_by_role_and_label(Role::Button, &format!("{} {}", l.code, l.name));
|
||||
}
|
||||
h.get_by_label("Margin to date");
|
||||
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: spending and the unrecovered
|
||||
// cost are both the prototype's $300.00.
|
||||
assert_eq!(h.get_all_by_label("$300.00").count(), 2);
|
||||
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("Net to date").rect();
|
||||
assert!(net.right() <= w, "{w}: Net to date 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 ["Income to date", "Net to date", "Margin to date"] {
|
||||
let r = h.get_by_label(tile).rect();
|
||||
assert!(r.right() <= 1440.0, "{tag}: {tile} at {r:?}");
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,9 @@ fn the_viewer_never_writes_into_the_books() {
|
||||
.map(|a| a.id)
|
||||
.unwrap();
|
||||
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
|
||||
// Every project's page too (visiting_project_pages_changes_nothing).
|
||||
let projects: Vec<u32> = books.ledger.list_classes().iter().map(|c| c.id).collect();
|
||||
assert!(!projects.is_empty(), "the demo has projects");
|
||||
drop(books);
|
||||
|
||||
// Follow live for a moment.
|
||||
@@ -178,12 +181,16 @@ fn the_viewer_never_writes_into_the_books() {
|
||||
Route::Asset(asset),
|
||||
Route::Parties,
|
||||
Route::Party(party),
|
||||
Route::Projects,
|
||||
Route::Document(doc),
|
||||
Route::JournalEvent(event),
|
||||
Route::Documents,
|
||||
Route::Company,
|
||||
Route::Journal,
|
||||
];
|
||||
let routes = routes
|
||||
.into_iter()
|
||||
.chain(projects.iter().map(|&c| Route::Project(c)));
|
||||
for route in routes {
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let mut harness = Harness::builder()
|
||||
|
||||
@@ -89,6 +89,22 @@ fn screenshots() {
|
||||
.rev()
|
||||
.find(|a| loaded.links_of(a.id).len() > 1)
|
||||
.map_or(last_doc, |a| a.id);
|
||||
// A project by name (SHOT_PROJECT: an id): the on-spec one, and the
|
||||
// client work, else the first.
|
||||
let project_named = |name: &str| {
|
||||
loaded
|
||||
.ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.find(|c| c.name == name)
|
||||
.or_else(|| loaded.ledger.list_classes().into_iter().next())
|
||||
.map_or(1, |c| c.id)
|
||||
};
|
||||
let project = std::env::var("SHOT_PROJECT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or_else(|| project_named("Tidepool app"));
|
||||
let project_client = project_named("Client work");
|
||||
drop(loaded);
|
||||
let routes: Vec<(&str, Route)> = vec![
|
||||
("overview", Route::Overview),
|
||||
@@ -122,6 +138,9 @@ fn screenshots() {
|
||||
("asset", Route::Asset(first_asset)),
|
||||
("parties", Route::Parties),
|
||||
("party", Route::Party(party)),
|
||||
("projects", Route::Projects),
|
||||
("project", Route::Project(project)),
|
||||
("project-client", Route::Project(project_client)),
|
||||
("document", Route::Document(last_doc)),
|
||||
("document-linked", Route::Document(linked_doc)),
|
||||
("reconciliation", Route::Reconciliation(reconciliation)),
|
||||
|
||||
Reference in new issue
Block a user