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
591 lines
19 KiB
Rust
591 lines
19 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::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"
|
|
);
|
|
}
|
|
}
|
|
}
|