check / check (push) Waiting to run
MCP: books.link_attachment, unlink_attachment, attachments; link_to on attach_receipt and file_document; record_statement, statements; books.reconcile previews and, confirmed against a recorded statement, records the reconciliation; void_reconciliation, reconciliations; import_bank_csv links its source file; digest lists unreconciled accounts. TIN vault: books.set_w9 takes the TIN and the form into the encrypted vault (last four in the books); books.vendor_1099 include_tins and save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak test scans every tool output, the journal, the database, and the blobs. [books] parties = off | suggest | require (default suggest): every preview names the entry's party or suggests likely ones; require refuses to post until it names one or is marked as having none (entry.no_party). Viewer: documents on every subject page; statements and reconciliation history per account; reconciliation pages; reconcile and no-counterparty attention items; party money by period (paid out, received in, net, what for, by month); Needs attention gets its own full-width section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
606 lines
20 KiB
Rust
606 lines
20 KiB
Rust
//! Attachment links, statements, and reconciliations in the viewer: each
|
|
//! subject shows exactly the files linked to it, each account its
|
|
//! statements and reconciliations, "Needs attention" asks for a
|
|
//! reconciliation until one covers last month, the journal says what
|
|
//! happened and opens it, and search finds statements and
|
|
//! reconciliations.
|
|
|
|
use chrono::NaiveDate;
|
|
use egui_kittest::Harness;
|
|
use ledger_core::{AccountType, Currency, EntryId, Money, NewAsset, PartyId, Role, Side};
|
|
use ledger_gui::books::Books;
|
|
use ledger_gui::nav::Route;
|
|
use ledger_gui::pages::journal::describe;
|
|
use ledger_gui::{App, Source, attention, data, palette};
|
|
use ledger_store::{Statement, Store, Subject};
|
|
use rust_decimal::Decimal;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
static DIRS: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
struct TempDir(PathBuf);
|
|
|
|
impl TempDir {
|
|
fn new(tag: &str) -> TempDir {
|
|
let n = DIRS.fetch_add(1, Ordering::Relaxed);
|
|
let d = std::env::temp_dir().join(format!(
|
|
"ledger-gui-attachments-{tag}-{}-{n}",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
TempDir(d)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
fn load(dir: &Path) -> Books {
|
|
data::load_once(&Source {
|
|
data_dir: dir.join("books-data"),
|
|
books_toml: None,
|
|
today: Some(today()),
|
|
})
|
|
.unwrap()
|
|
}
|
|
|
|
struct Fixture {
|
|
dir: TempDir,
|
|
store: Store,
|
|
checking: u64,
|
|
card: u64,
|
|
party: PartyId,
|
|
asset: u64,
|
|
contribution: EntryId,
|
|
software: EntryId,
|
|
linked_only: EntryId,
|
|
receipt: u64,
|
|
contract: u64,
|
|
photo: u64,
|
|
}
|
|
|
|
/// Checking funded in July; software paid in August (its receipt filed on
|
|
/// it, and also linked to a second August entry that has no receipt of
|
|
/// its own); a contract linked to a party, the company, and the software
|
|
/// entry; a photo linked to an asset; a card account with no activity.
|
|
fn fixture(tag: &str) -> Fixture {
|
|
let dir = TempDir::new(tag);
|
|
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
|
let acct = |s: &mut Store, code: &str, name: &str, t| {
|
|
s.add_account(code, name, t, None, false).unwrap()
|
|
};
|
|
let checking = acct(&mut s, "1000", "Checking", AccountType::Asset);
|
|
let equipment = acct(&mut s, "1500", "Equipment", AccountType::Asset);
|
|
let accum = acct(
|
|
&mut s,
|
|
"1510",
|
|
"Accumulated Depreciation",
|
|
AccountType::Asset,
|
|
);
|
|
let card = acct(&mut s, "2050", "Business Card", AccountType::Liability);
|
|
let capital = acct(&mut s, "3000", "Capital", AccountType::Equity);
|
|
let software_acct = acct(&mut s, "6300", "Software", AccountType::Expense);
|
|
let depreciation = acct(&mut s, "6800", "Depreciation", AccountType::Expense);
|
|
let post = |s: &mut Store, date, what: &str, dr, cr, c, receipt: 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 receipt {
|
|
s.attach_receipt(
|
|
Some(e),
|
|
&format!("{what}.pdf"),
|
|
"application/pdf",
|
|
what.as_bytes(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
s.post_entry(e).unwrap();
|
|
e
|
|
};
|
|
let contribution = post(
|
|
&mut s,
|
|
d(2026, 7, 5),
|
|
"Contribution",
|
|
checking,
|
|
capital,
|
|
500_000,
|
|
true,
|
|
);
|
|
let software = post(
|
|
&mut s,
|
|
d(2026, 8, 10),
|
|
"Software",
|
|
software_acct,
|
|
checking,
|
|
12_000,
|
|
true,
|
|
);
|
|
// Its receipt also covers this one (one invoice, two entries).
|
|
let e = s
|
|
.create_entry(d(2026, 8, 12), "Software, second seat")
|
|
.unwrap();
|
|
s.add_line(e, software_acct, Side::Debit, usd(3_000))
|
|
.unwrap();
|
|
s.add_line(e, checking, Side::Credit, usd(3_000)).unwrap();
|
|
let receipt = s.list_attachments(Some(software)).unwrap()[0].id;
|
|
s.link_attachment(receipt, Subject::Entry(e)).unwrap();
|
|
s.post_entry(e).unwrap();
|
|
let linked_only = e;
|
|
|
|
let party = s.add_party("Acme Software", &[Role::Contractor]).unwrap();
|
|
let (contract, _, _) = s
|
|
.file_document(
|
|
"contract",
|
|
"acme-agreement.pdf",
|
|
"application/pdf",
|
|
b"%PDF agreement",
|
|
Some("Signed agreement"),
|
|
)
|
|
.unwrap();
|
|
s.link_attachment(contract, Subject::Party(party)).unwrap();
|
|
s.link_attachment(contract, Subject::Company).unwrap();
|
|
s.link_attachment(contract, Subject::Entry(software))
|
|
.unwrap();
|
|
let asset = s
|
|
.register_asset(NewAsset {
|
|
description: "Laptop".into(),
|
|
purchase_date: d(2026, 7, 6),
|
|
cost: usd(200_000),
|
|
salvage_value: usd(0),
|
|
useful_life_months: 36,
|
|
cost_account: equipment,
|
|
accum_depr_account: accum,
|
|
expense_account: depreciation,
|
|
})
|
|
.unwrap();
|
|
let (photo, _, _) = s
|
|
.file_document("photo", "laptop.png", "image/png", b"png bytes", None)
|
|
.unwrap();
|
|
s.link_attachment(photo, Subject::Asset(asset)).unwrap();
|
|
Fixture {
|
|
dir,
|
|
store: s,
|
|
checking,
|
|
card,
|
|
party,
|
|
asset,
|
|
contribution,
|
|
software,
|
|
linked_only,
|
|
receipt,
|
|
contract,
|
|
photo,
|
|
}
|
|
}
|
|
|
|
/// Clear every line on `account` through `through`, file and record its
|
|
/// statement for `month_start..=through`, and reconcile it. Returns the
|
|
/// statement's attachment id and the reconciliation id.
|
|
fn reconcile(
|
|
s: &mut Store,
|
|
account: u64,
|
|
month_start: NaiveDate,
|
|
through: NaiveDate,
|
|
) -> (u64, u64) {
|
|
let mut lines = Vec::new();
|
|
for e in s.ledger().posted_entries() {
|
|
if e.date() <= through {
|
|
for (i, l) in e.lines().iter().enumerate() {
|
|
if l.account == account && !s.ledger().is_cleared(e.id(), i) {
|
|
lines.push((e.id(), i));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (e, i) in lines {
|
|
s.mark_cleared(e, i, true).unwrap();
|
|
}
|
|
let report = |s: &Store, date| {
|
|
s.ledger()
|
|
.reconciliation_report(account, date, Decimal::ZERO)
|
|
.unwrap()
|
|
.cleared_balance
|
|
};
|
|
let opening = report(s, month_start.pred_opt().unwrap());
|
|
let closing = report(s, through);
|
|
let (att, _, _) = s
|
|
.file_document(
|
|
"statement",
|
|
&format!("statement-{through}.pdf"),
|
|
"application/pdf",
|
|
format!("statement {through}").as_bytes(),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
s.record_statement(Statement {
|
|
attachment_id: att,
|
|
account_id: account,
|
|
period_start: month_start,
|
|
period_end: through,
|
|
opening_balance: opening,
|
|
closing_balance: closing,
|
|
})
|
|
.unwrap();
|
|
let r = s.complete_reconciliation(att).unwrap();
|
|
(att, r.id)
|
|
}
|
|
|
|
fn ids(docs: Vec<&ledger_store::Attachment>) -> Vec<u64> {
|
|
docs.into_iter().map(|a| a.id).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn each_subject_shows_exactly_its_links() {
|
|
let f = fixture("links");
|
|
let b = load(&f.dir.0);
|
|
// An entry: filed on it, and linked to it.
|
|
assert_eq!(
|
|
ids(b.documents_for(&[Subject::Entry(f.software)])),
|
|
vec![f.receipt, f.contract]
|
|
);
|
|
assert_eq!(
|
|
ids(b.documents_for(&[Subject::Entry(f.linked_only)])),
|
|
vec![f.receipt]
|
|
);
|
|
assert_eq!(
|
|
ids(b.documents_for(&[Subject::Party(f.party)])),
|
|
vec![f.contract]
|
|
);
|
|
assert_eq!(ids(b.documents_for(&[Subject::Company])), vec![f.contract]);
|
|
assert_eq!(
|
|
ids(b.documents_for(&[Subject::Asset(f.asset)])),
|
|
vec![f.photo]
|
|
);
|
|
assert!(b.documents_for(&[Subject::Account(f.checking)]).is_empty());
|
|
// Every link of the contract, in the store's order (by kind, id).
|
|
assert_eq!(
|
|
b.links_of(f.contract),
|
|
f.store.attachment_links(f.contract).unwrap().as_slice()
|
|
);
|
|
assert_eq!(b.links_of(f.receipt)[0], Subject::Entry(f.software));
|
|
assert!(
|
|
b.links_of(f.receipt)
|
|
.contains(&Subject::Entry(f.linked_only))
|
|
);
|
|
// The receipt policy counts a linked receipt (the server's rule).
|
|
let only = b.entry(f.linked_only).unwrap();
|
|
assert!(!only.missing_receipt);
|
|
assert_eq!(only.receipts.len(), 1);
|
|
assert_eq!(b.attachments[only.receipts[0]].id, f.receipt);
|
|
// Every subject the books hold matches the store's own list.
|
|
for sub in [
|
|
Subject::Entry(f.contribution),
|
|
Subject::Entry(f.software),
|
|
Subject::Entry(f.linked_only),
|
|
Subject::Party(f.party),
|
|
Subject::Company,
|
|
Subject::Asset(f.asset),
|
|
Subject::Account(f.checking),
|
|
] {
|
|
assert_eq!(
|
|
ids(b.documents_for(&[sub])),
|
|
f.store
|
|
.attachments_for(sub)
|
|
.unwrap()
|
|
.iter()
|
|
.map(|a| a.id)
|
|
.collect::<Vec<_>>(),
|
|
"{sub:?}"
|
|
);
|
|
}
|
|
// Each chip opens its subject.
|
|
assert_eq!(
|
|
b.subject_route(Subject::Party(f.party)),
|
|
Some(Route::Party(f.party))
|
|
);
|
|
assert_eq!(b.subject_route(Subject::Company), Some(Route::Company));
|
|
assert_eq!(
|
|
b.subject_route(Subject::Asset(f.asset)),
|
|
Some(Route::Asset(f.asset))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unlinking_and_withdrawing_take_a_file_off_its_subjects() {
|
|
let mut f = fixture("unlink");
|
|
f.store
|
|
.unlink_attachment(f.contract, Subject::Company, "Not company-wide")
|
|
.unwrap();
|
|
// A file comes off its links before it is withdrawn.
|
|
f.store
|
|
.unlink_attachment(f.photo, Subject::Asset(f.asset), "Blurry")
|
|
.unwrap();
|
|
f.store
|
|
.withdraw_attachment(f.photo, "Blurry; retaking it")
|
|
.unwrap();
|
|
let b = load(&f.dir.0);
|
|
assert!(b.documents_for(&[Subject::Company]).is_empty());
|
|
assert!(b.documents_for(&[Subject::Asset(f.asset)]).is_empty());
|
|
assert_eq!(
|
|
ids(b.documents_for(&[Subject::Party(f.party)])),
|
|
vec![f.contract]
|
|
);
|
|
assert!(!b.links_of(f.contract).contains(&Subject::Company));
|
|
assert!(b.links_of(f.photo).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn statements_and_reconciliations_per_account() {
|
|
let mut f = fixture("recs");
|
|
let (jul_st, jul) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
|
|
f.store.void_reconciliation(jul, "Wrong statement").unwrap();
|
|
let (_, jul2) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
|
|
let (aug_st, aug) = reconcile(&mut f.store, f.checking, d(2026, 8, 1), d(2026, 8, 31));
|
|
let b = load(&f.dir.0);
|
|
let st = b.statements_for(f.checking);
|
|
assert_eq!(st.len(), 3);
|
|
assert_eq!(st[0].attachment_id, jul_st);
|
|
assert_eq!(st.last().unwrap().attachment_id, aug_st);
|
|
assert_eq!(
|
|
st.iter().map(|s| (*s).clone()).collect::<Vec<_>>(),
|
|
f.store.statements(Some(f.checking)).unwrap()
|
|
);
|
|
let recs = b.reconciliations_for(f.checking);
|
|
assert_eq!(
|
|
recs.iter().map(|r| r.id).collect::<Vec<_>>(),
|
|
vec![jul, jul2, aug]
|
|
);
|
|
assert_eq!(recs[0].voided.as_deref(), Some("Wrong statement"));
|
|
// Cleared lines: the contribution's, then August's two payments.
|
|
let a = b.reconciliation(aug).unwrap();
|
|
assert_eq!(a.cleared.len(), 3);
|
|
assert_eq!(a.statement_balance, cents(500_000 - 12_000 - 3_000));
|
|
assert!(b.reconciliations_for(f.card).is_empty());
|
|
assert_eq!(b.reconciled_through(f.checking), Some(d(2026, 8, 31)));
|
|
assert_eq!(b.reconciled_through(f.card), None);
|
|
use ledger_gui::pages::reconciliation::{Standing, lines, standing};
|
|
assert_eq!(standing(&b, a), Standing::Current);
|
|
assert_eq!(
|
|
standing(&b, b.reconciliation(jul2).unwrap()),
|
|
Standing::Superseded
|
|
);
|
|
assert_eq!(
|
|
standing(&b, b.reconciliation(jul).unwrap()),
|
|
Standing::Voided("Wrong statement".into())
|
|
);
|
|
// Its lines are the account's through the date, each marked.
|
|
let ls = lines(&b, a);
|
|
assert_eq!(ls.len(), 3);
|
|
assert!(ls.iter().all(|l| l.5));
|
|
let total: Decimal = ls.iter().map(|l| l.4).sum();
|
|
assert_eq!(total, a.statement_balance);
|
|
// The statement is linked to its account and its reconciliation.
|
|
assert!(b.links_of(aug_st).contains(&Subject::Account(f.checking)));
|
|
assert!(b.links_of(aug_st).contains(&Subject::Reconciliation(aug)));
|
|
assert_eq!(
|
|
b.subject_route(Subject::Reconciliation(aug)),
|
|
Some(Route::Reconciliation(aug))
|
|
);
|
|
}
|
|
|
|
fn reconcile_items(b: &Books) -> Vec<String> {
|
|
attention::find(b)
|
|
.into_iter()
|
|
.filter(|a| a.title.starts_with("Reconcile "))
|
|
.map(|a| a.title)
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn attention_asks_for_a_reconciliation_until_last_month_is_covered() {
|
|
let mut f = fixture("attention");
|
|
let b = load(&f.dir.0);
|
|
// Checking has activity; the card has none (no item).
|
|
assert_eq!(b.last_month_end(), d(2026, 9, 30));
|
|
assert_eq!(
|
|
reconcile_items(&b),
|
|
vec!["Reconcile Checking through Sep 30, 2026".to_string()]
|
|
);
|
|
let item = attention::find(&b)
|
|
.into_iter()
|
|
.find(|a| a.title.starts_with("Reconcile "))
|
|
.unwrap();
|
|
assert!(item.prompt.starts_with("In wanderbooks,"));
|
|
assert!(item.prompt.contains("books.record_statement"));
|
|
assert!(item.prompt.contains("books.reconcile"));
|
|
assert!(item.prompt.contains("1000 Checking"));
|
|
assert!(item.prompt.contains("2026-09-30"));
|
|
assert!(item.prompt.contains("never been reconciled"));
|
|
assert_eq!(item.target, Some(Route::Account(f.checking)));
|
|
|
|
// Through August only: still due.
|
|
reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 8, 31));
|
|
let b = load(&f.dir.0);
|
|
assert_eq!(reconcile_items(&b).len(), 1);
|
|
assert!(
|
|
attention::find(&b)
|
|
.iter()
|
|
.any(|a| a.prompt.contains("reconciled through 2026-08-31"))
|
|
);
|
|
|
|
// Through September (last month): clear.
|
|
let (_, sep) = reconcile(&mut f.store, f.checking, d(2026, 9, 1), d(2026, 9, 30));
|
|
let b = load(&f.dir.0);
|
|
assert!(reconcile_items(&b).is_empty(), "{:?}", reconcile_items(&b));
|
|
assert_eq!(b.reconciliation_due(f.checking), None);
|
|
|
|
// Voided, it's due again.
|
|
f.store.void_reconciliation(sep, "Redo").unwrap();
|
|
let b = load(&f.dir.0);
|
|
assert_eq!(reconcile_items(&b).len(), 1);
|
|
|
|
// Card activity before this month makes the card due too.
|
|
let e = f.store.create_entry(d(2026, 9, 3), "Card charge").unwrap();
|
|
let software = f.store.ledger().chart().find_by_code("6300").unwrap().id;
|
|
f.store
|
|
.add_line(e, software, Side::Debit, usd(1_000))
|
|
.unwrap();
|
|
f.store
|
|
.add_line(e, f.card, Side::Credit, usd(1_000))
|
|
.unwrap();
|
|
f.store
|
|
.attach_receipt(Some(e), "c.pdf", "application/pdf", b"card", None)
|
|
.unwrap();
|
|
f.store.post_entry(e).unwrap();
|
|
let b = load(&f.dir.0);
|
|
assert!(
|
|
reconcile_items(&b).contains(&"Reconcile Business Card through Sep 30, 2026".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn journal_text_names_and_opens_the_subjects() {
|
|
let mut f = fixture("journal");
|
|
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
|
|
f.store.void_reconciliation(r, "Wrong statement").unwrap();
|
|
f.store
|
|
.unlink_attachment(f.contract, Subject::Company, "Not company-wide")
|
|
.unwrap();
|
|
let b = load(&f.dir.0);
|
|
let find = |t: &str| {
|
|
b.journal
|
|
.iter()
|
|
.filter(|l| l.line_type == t)
|
|
.map(|l| describe(&b, l).unwrap())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let linked = find("attachment.linked");
|
|
let party_link = linked
|
|
.iter()
|
|
.find(|e| e.also == Some(Route::Party(f.party)))
|
|
.unwrap();
|
|
assert_eq!(
|
|
party_link.text,
|
|
"Linked acme-agreement.pdf to Acme Software"
|
|
);
|
|
assert_eq!(party_link.route, Some(Route::Document(f.contract)));
|
|
assert!(
|
|
linked
|
|
.iter()
|
|
.any(|e| e.text == "Linked laptop.png to Laptop"
|
|
&& e.also == Some(Route::Asset(f.asset)))
|
|
);
|
|
assert!(
|
|
linked
|
|
.iter()
|
|
.any(|e| e.text == format!("Linked acme-agreement.pdf to #{} Software", f.software))
|
|
);
|
|
assert!(
|
|
linked
|
|
.iter()
|
|
.any(|e| e.also == Some(Route::Reconciliation(r)))
|
|
);
|
|
let unlinked = &find("attachment.unlinked")[0];
|
|
assert_eq!(
|
|
unlinked.text,
|
|
"Unlinked acme-agreement.pdf from the company: Not company-wide"
|
|
);
|
|
assert_eq!(unlinked.also, Some(Route::Company));
|
|
let recorded = &find("statement.recorded")[0];
|
|
assert!(
|
|
recorded.text.contains("1000 Checking") && recorded.text.contains("Jul 31, 2026"),
|
|
"{}",
|
|
recorded.text
|
|
);
|
|
assert_eq!(recorded.route, Some(Route::Document(st)));
|
|
assert_eq!(recorded.also, Some(Route::Account(f.checking)));
|
|
let done = &find("reconciliation.completed")[0];
|
|
assert!(
|
|
done.text
|
|
.starts_with("Reconciled 1000 Checking through Jul 31, 2026"),
|
|
"{}",
|
|
done.text
|
|
);
|
|
assert_eq!(done.route, Some(Route::Reconciliation(r)));
|
|
let voided = &find("reconciliation.voided")[0];
|
|
assert_eq!(
|
|
voided.text,
|
|
"Voided the reconciliation of 1000 Checking through Jul 31, 2026: Wrong statement"
|
|
);
|
|
assert_eq!(voided.route, Some(Route::Reconciliation(r)));
|
|
}
|
|
|
|
#[test]
|
|
fn search_finds_statements_and_reconciliations() {
|
|
let mut f = fixture("search");
|
|
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
|
|
let b = load(&f.dir.0);
|
|
let routes = |q: &str| -> Vec<Route> {
|
|
palette::search(&b, q)
|
|
.into_iter()
|
|
.map(|i| i.route)
|
|
.collect()
|
|
};
|
|
assert!(routes("reconciliation checking").contains(&Route::Reconciliation(r)));
|
|
assert!(routes("checking 2026-07-31").contains(&Route::Reconciliation(r)));
|
|
assert!(routes("statement checking jul").contains(&Route::Document(st)));
|
|
assert!(!routes("statement savings").contains(&Route::Document(st)));
|
|
}
|
|
|
|
#[test]
|
|
fn every_new_page_renders() {
|
|
let mut f = fixture("render");
|
|
let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
|
|
for route in [
|
|
Route::Overview,
|
|
Route::Account(f.checking),
|
|
Route::Account(f.card),
|
|
Route::Reconciliation(r),
|
|
Route::Reconciliation(999),
|
|
Route::Document(st),
|
|
Route::Document(f.contract),
|
|
Route::Entry(f.software),
|
|
Route::Entry(f.linked_only),
|
|
Route::Party(f.party),
|
|
Route::Asset(f.asset),
|
|
Route::Company,
|
|
Route::Parties,
|
|
] {
|
|
let books = load(&f.dir.0);
|
|
let mut h = Harness::builder()
|
|
.with_size(egui::vec2(1100.0, 900.0))
|
|
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
|
|
h.run_steps(3);
|
|
assert_eq!(h.state_mut().route(), &route);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn books_show_opens_a_reconciliation() {
|
|
use ledger_store::viewer::ViewerTarget;
|
|
let mut f = fixture("show");
|
|
let (_, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31));
|
|
let books = load(&f.dir.0);
|
|
let mut h = Harness::builder()
|
|
.with_size(egui::vec2(1100.0, 900.0))
|
|
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview));
|
|
h.state_mut().show(ViewerTarget::Reconciliation { id: r });
|
|
h.run_steps(2);
|
|
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
|
|
// An unknown one goes nowhere.
|
|
h.state_mut().show(ViewerTarget::Reconciliation { id: 999 });
|
|
assert_eq!(h.state_mut().route(), &Route::Reconciliation(r));
|
|
}
|