P2 slice 6: the viewer
`wanderbooks gui [BOOK]` finds the data root, reads settings from the journal, the vault of the book, presence through ops/, Save a copy / Open on every document, Save the W-9, the Export menu, and colour schemes. One builder for the CPA package and the 1099 worksheet (ledger-package), shared by the server and the viewer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
b26c099a68
commit
5bc0633cdd
77 files changed
+5833
-2427
No files matched your search
@@ -14,7 +14,7 @@ use ledger_gui::pages::assets::{MonthStatus, TaxFacts, missing_tax_notice, notic
|
||||
use ledger_gui::pages::journal::describe;
|
||||
use ledger_gui::{App, Source, attention, data, fmt, palette};
|
||||
use ledger_store::Store;
|
||||
use ledger_store::viewer::ViewerTarget;
|
||||
use ledger_store::ops::ViewerTarget;
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -54,7 +54,6 @@ fn usd(cents: i64) -> Money {
|
||||
fn load(dir: &Path, today: NaiveDate) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(today),
|
||||
})
|
||||
.unwrap()
|
||||
|
||||
@@ -59,7 +59,6 @@ fn today() -> NaiveDate {
|
||||
fn load(dir: &Path) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
@@ -589,7 +588,7 @@ fn every_new_page_renders() {
|
||||
|
||||
#[test]
|
||||
fn books_show_opens_a_reconciliation() {
|
||||
use ledger_store::viewer::ViewerTarget;
|
||||
use ledger_store::ops::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);
|
||||
|
||||
@@ -34,7 +34,6 @@ fn demo(tag: &str) -> (TestDir, Source) {
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
let source = Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
(TestDir(dir), source)
|
||||
|
||||
@@ -68,7 +68,6 @@ fn clicking_a_value_copies_it() {
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
let source = Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
let open = |route: Route| {
|
||||
@@ -80,7 +79,7 @@ fn clicking_a_value_copies_it() {
|
||||
h
|
||||
};
|
||||
|
||||
// The company's EIN, as written in wanderbooks.toml.
|
||||
// The company's EIN, as the demo journals it.
|
||||
let ein = data::load_once(&source)
|
||||
.unwrap()
|
||||
.settings
|
||||
|
||||
@@ -107,7 +107,6 @@ fn fixture(tag: &str) -> Fixture {
|
||||
drop(s);
|
||||
let books = data::load_once(&Source {
|
||||
data_dir: data,
|
||||
books_toml: None,
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
//! What the viewer saves for the human (plan 3.6, P2's viewer menu,
|
||||
//! D-29): a copy of a document, a party's W-9, the CPA package and the
|
||||
//! 1099 worksheet, each a new file in the downloads folder, never over
|
||||
//! one; and for a book in the data root, the vault is the book's own.
|
||||
//! Nothing here writes the books.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::{AccountType, Currency, Money, Role, Side, TinFacts, TinType};
|
||||
use ledger_gui::App;
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::data::{self, Source};
|
||||
use ledger_gui::menu::Job;
|
||||
use ledger_gui::nav::Route;
|
||||
use ledger_store::Store;
|
||||
use ledger_vault::{Tin, Vault, W9File};
|
||||
use rust_decimal::Decimal;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SSN: &str = "620-37-1111";
|
||||
|
||||
/// This test binary's downloads folder: its own, never the human's.
|
||||
fn downloads() -> PathBuf {
|
||||
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let d = tempfile::Builder::new()
|
||||
.prefix("wb-gui-downloads-")
|
||||
.tempdir()
|
||||
.unwrap()
|
||||
.keep();
|
||||
ledger_gui::export::set_downloads_dir(d.clone());
|
||||
d
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// A fresh folder in the downloads folder for one test (tests run side by
|
||||
/// side): the files saved there are this test's.
|
||||
fn saved_files() -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(downloads())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(s.parse::<Decimal>().unwrap(), Currency::USD)
|
||||
}
|
||||
|
||||
/// A book in a data root (its folder holds the root's marker), with a
|
||||
/// checking account, the member's capital, contract labor, a contractor
|
||||
/// paid past the threshold, a member whose SSN is in the book's own vault
|
||||
/// (key in its own keys/), a contractor's W-9 there too, and a document.
|
||||
struct RootBook {
|
||||
_root: tempfile::TempDir,
|
||||
book: PathBuf,
|
||||
member: u64,
|
||||
payee: u64,
|
||||
document: u64,
|
||||
}
|
||||
|
||||
fn root_book() -> RootBook {
|
||||
let root = tempfile::Builder::new()
|
||||
.prefix("wb-gui-root-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
std::fs::write(root.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
let book = root.path().join("books").join("acme");
|
||||
std::fs::create_dir_all(&book).unwrap();
|
||||
let mut s = Store::create(&book, Currency::USD).unwrap();
|
||||
let import = ledger_config::codec::import(
|
||||
"[company]\nname = \"Acme LLC\"\n\n[[company.members]]\nname = \"Alex Owner\"\nshare = \"1\"\ncapital_account = \"3000\"\n\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
None,
|
||||
s.ledger().settings(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
s.adopt_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "test".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for (code, name, ty) in [
|
||||
("1000", "Checking", AccountType::Asset),
|
||||
("3000", "Capital", AccountType::Equity),
|
||||
("6100", "Contract labor", AccountType::Expense),
|
||||
] {
|
||||
s.add_account(code, name, ty, None, false).unwrap();
|
||||
}
|
||||
let member = s.add_party("Alex Owner", &[Role::Member]).unwrap();
|
||||
let payee = s.add_party("Jo Contractor", &[Role::Contractor]).unwrap();
|
||||
// The vault and its key, the book's own (as the server keeps them).
|
||||
let key = book
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE);
|
||||
std::fs::create_dir_all(key.parent().unwrap()).unwrap();
|
||||
let v = Vault::open(&book.join(ledger_store::root::VAULT_DIR), &key, true).unwrap();
|
||||
let hash = v
|
||||
.put_tin(member, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap())
|
||||
.unwrap();
|
||||
let w9_hash = v
|
||||
.put_w9(
|
||||
payee,
|
||||
&W9File {
|
||||
file_name: "jo-w9.pdf".into(),
|
||||
mime_type: "application/pdf".into(),
|
||||
bytes: b"%PDF-1.4 Jo's W-9".to_vec(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
s.set_party_w9(
|
||||
payee,
|
||||
Some(ledger_core::W9Facts {
|
||||
received: today(),
|
||||
entity_type: "individual/sole proprietor".into(),
|
||||
exempt_payee_code: None,
|
||||
fatca_code: None,
|
||||
tin_last4: "2222".into(),
|
||||
kept_at: "wanderbooks vault".into(),
|
||||
vault_record: Some(w9_hash),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
s.set_party_tin(
|
||||
member,
|
||||
Some(TinFacts {
|
||||
tin_type: TinType::Ssn,
|
||||
last4: "1111".into(),
|
||||
vault_record: Some(hash),
|
||||
purged: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let (document, _, _) = s
|
||||
.file_document(
|
||||
"legal",
|
||||
"operating agreement.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF-1.4 OA",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.create_entry(today(), "Opening contribution").unwrap();
|
||||
for (code, side) in [("1000", Side::Debit), ("3000", Side::Credit)] {
|
||||
let id = s.ledger().chart().find_by_code(code).unwrap().id;
|
||||
s.add_line(e, id, side, usd("5000.00")).unwrap();
|
||||
}
|
||||
s.post_entry(e).unwrap();
|
||||
RootBook {
|
||||
_root: root,
|
||||
book,
|
||||
member,
|
||||
payee,
|
||||
document,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(book: &Path) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: book.to_path_buf(),
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_in_the_root_reads_its_own_vault() {
|
||||
let f = root_book();
|
||||
let b = load(&f.book);
|
||||
assert_eq!(b.tin_from_vault(f.member).unwrap(), SSN);
|
||||
let w9 = b.w9_file(f.payee).unwrap();
|
||||
assert_eq!(
|
||||
(w9.file_name.as_str(), w9.bytes.as_slice()),
|
||||
("jo-w9.pdf", &b"%PDF-1.4 Jo's W-9"[..])
|
||||
);
|
||||
let e = b.w9_file(f.member).unwrap_err();
|
||||
assert!(e.contains("no W-9 file"), "{e}");
|
||||
// Its key is the book's own: one named on the command line is never
|
||||
// used for it (none is named here, and none is needed).
|
||||
assert!(ledger_gui::vault_key_file().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_is_saved_as_a_new_copy_from_its_page() {
|
||||
downloads();
|
||||
let f = root_book();
|
||||
let b = load(&f.book);
|
||||
let name = |n: &str| format!("operating agreement{n}.pdf");
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1200.0, 900.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Document(f.document)));
|
||||
h.run_steps(3);
|
||||
for n in ["", " (1)"] {
|
||||
h.get_by_label_contains("Save a copy").click();
|
||||
h.run_steps(3);
|
||||
assert!(saved_files().contains(&name(n)), "{:?}", saved_files());
|
||||
assert_eq!(
|
||||
std::fs::read(downloads().join(name(n))).unwrap(),
|
||||
b"%PDF-1.4 OA"
|
||||
);
|
||||
}
|
||||
// What came of it shows on the page.
|
||||
let said = h.query_by_label_contains("Saved ").is_some();
|
||||
assert!(said, "the page doesn't say where the copy went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_makes_the_cpa_package_with_full_tins() {
|
||||
downloads();
|
||||
let f = root_book();
|
||||
let b = load(&f.book);
|
||||
let (path, note) = Job::Package.run(&b).unwrap();
|
||||
assert_eq!(path.parent().unwrap(), downloads());
|
||||
assert!(
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("cpa-package-2026-10-15")
|
||||
);
|
||||
assert_eq!(note, None, "every TIN is there");
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
|
||||
let names: Vec<String> = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(names[0], "README.txt");
|
||||
assert!(
|
||||
names.contains(&"general-journal.iif".to_string()),
|
||||
"{names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"documents/legal/operating agreement.pdf".to_string()),
|
||||
"{names:?}"
|
||||
);
|
||||
let mut readme = String::new();
|
||||
zip.by_name("README.txt")
|
||||
.unwrap()
|
||||
.read_to_string(&mut readme)
|
||||
.unwrap();
|
||||
assert!(readme.starts_with("Acme LLC"), "{readme}");
|
||||
// The member's K-1 row carries their full SSN, from the book's vault.
|
||||
let mut k1 = String::new();
|
||||
zip.by_name("k1-members.csv")
|
||||
.unwrap()
|
||||
.read_to_string(&mut k1)
|
||||
.unwrap();
|
||||
assert!(
|
||||
k1.contains(&format!("Alex Owner,{},", f.member)) && k1.contains(SSN),
|
||||
"{k1}"
|
||||
);
|
||||
// The books weren't touched: their head is as it was.
|
||||
assert_eq!(load(&f.book).head, b.head);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_makes_the_1099_worksheet_with_tins() {
|
||||
downloads();
|
||||
let f = root_book();
|
||||
let b = load(&f.book);
|
||||
let (path, _) = Job::Worksheet(2026).run(&b).unwrap();
|
||||
assert!(
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("1099-worksheet-2026-with-tins"),
|
||||
"{}",
|
||||
path.display()
|
||||
);
|
||||
let csv = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(csv.starts_with("vendor_id,name,"), "{csv}");
|
||||
assert!(csv.lines().next().unwrap().contains(",tin,"), "{csv}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_vault_still_makes_the_package_and_says_what_is_missing() {
|
||||
downloads();
|
||||
let f = root_book();
|
||||
std::fs::remove_file(
|
||||
f.book
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE),
|
||||
)
|
||||
.unwrap();
|
||||
let b = load(&f.book);
|
||||
let (_, note) = Job::Package.run(&b).unwrap();
|
||||
let note = note.expect("the missing TIN is noted");
|
||||
assert!(note.contains("1 TIN(s)"), "{note}");
|
||||
// Never a key made by the viewer.
|
||||
assert!(
|
||||
!f.book
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE)
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parties_w9_is_saved_from_its_page() {
|
||||
downloads();
|
||||
let f = root_book();
|
||||
let b = load(&f.book);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1200.0, 1250.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(f.payee)));
|
||||
h.run_steps(3);
|
||||
let before = saved_files();
|
||||
h.get_by_label("Save the W-9").click();
|
||||
h.run_steps(3);
|
||||
// Only this test saves a W-9 (others save into the same folder at the
|
||||
// same time).
|
||||
let new: Vec<String> = saved_files()
|
||||
.into_iter()
|
||||
.filter(|n| !before.contains(n) && n.starts_with("jo-w9"))
|
||||
.collect();
|
||||
assert_eq!(new.len(), 1, "{new:?}");
|
||||
assert!(new[0].starts_with("jo-w9"), "{new:?}");
|
||||
assert_eq!(
|
||||
std::fs::read(downloads().join(&new[0])).unwrap(),
|
||||
b"%PDF-1.4 Jo's W-9"
|
||||
);
|
||||
}
|
||||
|
||||
/// `wanderbooks gui [BOOK]` (plan 3.6): the book named, else the root's
|
||||
/// only or first one by id; never a folder that isn't a data root.
|
||||
#[test]
|
||||
fn the_viewer_finds_its_book_in_the_root() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-gui-home-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let (root, _) = ledger_store::root::Root::setup(home.path(), None, None).unwrap();
|
||||
let r = root.path().to_path_buf();
|
||||
// No books yet.
|
||||
let e = ledger_gui::book_in_root(Some(&r), None).unwrap_err();
|
||||
assert!(e.contains("no books yet"), "{e}");
|
||||
for id in ["zeta", "acme"] {
|
||||
root.create_book(
|
||||
&ledger_store::root::BookId::parse(id).unwrap(),
|
||||
Currency::USD,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let dir = |id: &str| root.book_dir(&ledger_store::root::BookId::parse(id).unwrap());
|
||||
assert_eq!(
|
||||
ledger_gui::book_in_root(Some(&r), Some("zeta")).unwrap(),
|
||||
dir("zeta")
|
||||
);
|
||||
assert_eq!(
|
||||
ledger_gui::book_in_root(Some(&r), None).unwrap(),
|
||||
dir("acme"),
|
||||
"the first by id"
|
||||
);
|
||||
let e = ledger_gui::book_in_root(Some(&r), Some("nope")).unwrap_err();
|
||||
assert!(e.contains("no books called \"nope\""), "{e}");
|
||||
let e = ledger_gui::book_in_root(Some(&r), Some("../x")).unwrap_err();
|
||||
assert!(!e.is_empty());
|
||||
// A folder that isn't a data root is never opened as one.
|
||||
let plain = tempfile::Builder::new()
|
||||
.prefix("wb-gui-plain-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let e = ledger_gui::book_in_root(Some(plain.path()), None).unwrap_err();
|
||||
assert!(e.contains("no books to show"), "{e}");
|
||||
}
|
||||
|
||||
/// The 1099 worksheet the menu makes carries a reportable payee's full
|
||||
/// TIN from the vault.
|
||||
#[test]
|
||||
fn the_menus_1099_worksheet_carries_a_payees_full_tin() {
|
||||
downloads();
|
||||
let f = root_book();
|
||||
{
|
||||
let mut s = Store::open(&f.book).unwrap();
|
||||
// A contractor marked for a 1099-NEC, paid past 2026's threshold.
|
||||
let vendor = s.add_vendor("Pat Payee").unwrap();
|
||||
s.set_vendor_1099(vendor, Some(ledger_core::Form1099::Nec), true)
|
||||
.unwrap();
|
||||
let party = s.ledger().party_of_vendor(vendor).unwrap();
|
||||
let key = f
|
||||
.book
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE);
|
||||
let v = Vault::open(&f.book.join(ledger_store::root::VAULT_DIR), &key, false).unwrap();
|
||||
let hash = v
|
||||
.put_tin(
|
||||
party,
|
||||
&Tin::new("607-97-2222", ledger_vault::TinType::Ssn).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
s.set_party_tin(
|
||||
party,
|
||||
Some(TinFacts {
|
||||
tin_type: TinType::Ssn,
|
||||
last4: "2222".into(),
|
||||
vault_record: Some(hash),
|
||||
purged: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.create_entry(today(), "Design work").unwrap();
|
||||
for (code, side) in [("6100", Side::Debit), ("1000", Side::Credit)] {
|
||||
let id = s.ledger().chart().find_by_code(code).unwrap().id;
|
||||
s.add_line(e, id, side, usd("2500.00")).unwrap();
|
||||
}
|
||||
s.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
}
|
||||
let b = load(&f.book);
|
||||
let (path, note) = Job::Worksheet(2026).run(&b).unwrap();
|
||||
assert_eq!(note, None, "{note:?}");
|
||||
let csv = std::fs::read_to_string(&path).unwrap();
|
||||
let row = csv
|
||||
.lines()
|
||||
.find(|l| l.contains("Pat Payee"))
|
||||
.unwrap_or_else(|| panic!("{csv}"));
|
||||
assert!(row.contains("607-97-2222"), "{csv}");
|
||||
}
|
||||
|
||||
/// The menu reads settings as the rest of the viewer and the server do:
|
||||
/// an older books dir that keeps none in its journal, from its file.
|
||||
#[test]
|
||||
fn the_menu_reads_an_older_books_dirs_settings_file() {
|
||||
downloads();
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-gui-older-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let data = dir.path().join("books-data");
|
||||
{
|
||||
let mut s = Store::create(&data, Currency::USD).unwrap();
|
||||
for (code, name, ty) in [
|
||||
("1000", "Checking", AccountType::Asset),
|
||||
("3000", "Capital", AccountType::Equity),
|
||||
] {
|
||||
s.add_account(code, name, ty, None, false).unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::write(
|
||||
dir.path().join("wanderbooks.toml"),
|
||||
"[company]\nname = \"File Kept Co\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let b = load(&data);
|
||||
assert_eq!(b.settings.company.name, "File Kept Co");
|
||||
let (path, _) = Job::Package.run(&b).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
|
||||
let mut readme = String::new();
|
||||
zip.by_name("README.txt")
|
||||
.unwrap()
|
||||
.read_to_string(&mut readme)
|
||||
.unwrap();
|
||||
assert!(readme.starts_with("File Kept Co"), "{readme}");
|
||||
}
|
||||
|
||||
/// The worksheet says when a reportable payee's TIN isn't in it (the
|
||||
/// vault locked).
|
||||
#[test]
|
||||
fn the_menus_worksheet_says_which_payees_lack_a_tin() {
|
||||
downloads();
|
||||
let f = root_book();
|
||||
{
|
||||
let mut s = Store::open(&f.book).unwrap();
|
||||
let vendor = s.add_vendor("Pat Payee").unwrap();
|
||||
s.set_vendor_1099(vendor, Some(ledger_core::Form1099::Nec), true)
|
||||
.unwrap();
|
||||
let e = s.create_entry(today(), "Design work").unwrap();
|
||||
for (code, side) in [("6100", Side::Debit), ("1000", Side::Credit)] {
|
||||
let id = s.ledger().chart().find_by_code(code).unwrap().id;
|
||||
s.add_line(e, id, side, usd("2500.00")).unwrap();
|
||||
}
|
||||
s.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
}
|
||||
let b = load(&f.book);
|
||||
let (_, note) = Job::Worksheet(2026).run(&b).unwrap();
|
||||
let note = note.expect("the missing TIN is noted");
|
||||
assert!(note.starts_with("1 reportable payee(s)"), "{note}");
|
||||
}
|
||||
@@ -73,7 +73,6 @@ fn item(title: &str, due: Option<NaiveDate>) -> NewOpenItem {
|
||||
fn company(dir: &TempDir) -> Harness<'static, App> {
|
||||
let books = data::load_once(&ledger_gui::Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Live updates: a change written to the books (by the MCP server, in
|
||||
//! practice) reaches the GUI's view model on its own, without a restart,
|
||||
//! and the changed entry is marked fresh.
|
||||
//! and the changed entry is marked fresh; a show request reaches it, and
|
||||
//! while it runs it holds the books' viewer presence.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
@@ -32,12 +33,15 @@ fn a_change_in_another_process_shows_up_live() {
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
// The server makes the books' ops/ folder when it opens them; no
|
||||
// viewer has them open yet.
|
||||
ledger_store::ops::prepare(&data).unwrap();
|
||||
assert!(!ledger_store::ops::viewer_open(&data));
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let loader = Loader::spawn(
|
||||
Source {
|
||||
data_dir: data.clone(),
|
||||
books_toml: None,
|
||||
today: NaiveDate::from_ymd_opt(2026, 10, 12),
|
||||
},
|
||||
ctx,
|
||||
@@ -47,6 +51,15 @@ fn a_change_in_another_process_shows_up_live() {
|
||||
_ => panic!("no initial load"),
|
||||
};
|
||||
assert_eq!(first.kpis.cash, Decimal::ZERO);
|
||||
// The running viewer holds its presence there (ops INVARIANT 3).
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !ledger_store::ops::viewer_open(&data) && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(
|
||||
ledger_store::ops::viewer_open(&data),
|
||||
"the running viewer holds no presence"
|
||||
);
|
||||
|
||||
// The writer (the MCP server's store) posts an entry.
|
||||
let started = Instant::now();
|
||||
@@ -77,15 +90,15 @@ fn a_change_in_another_process_shows_up_live() {
|
||||
|
||||
// The agent asks the viewer to show the entry (books.show).
|
||||
let asked = Instant::now();
|
||||
ledger_store::viewer::request(
|
||||
ledger_store::ops::request(
|
||||
&data,
|
||||
ledger_store::viewer::ViewerTarget::Entry { entry_id: e },
|
||||
ledger_store::ops::ViewerTarget::Entry { entry_id: e },
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut shown = false;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(Update::Show(ledger_store::viewer::ViewerTarget::Entry { entry_id })) =
|
||||
if let Some(Update::Show(ledger_store::ops::ViewerTarget::Entry { entry_id })) =
|
||||
loader.wait(Duration::from_millis(200))
|
||||
{
|
||||
assert_eq!(entry_id, e);
|
||||
@@ -95,5 +108,55 @@ fn a_change_in_another_process_shows_up_live() {
|
||||
}
|
||||
assert!(shown, "the show request never reached the viewer");
|
||||
eprintln!("show request arrived in {:?}", asked.elapsed());
|
||||
let _ = std::fs::remove_file(ledger_store::viewer::command_path(&data));
|
||||
|
||||
// The viewer closed: its presence goes with it, within a poll.
|
||||
drop(loader);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while ledger_store::ops::viewer_open(&data) && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert!(
|
||||
!ledger_store::ops::viewer_open(&data),
|
||||
"a closed viewer still holds its presence"
|
||||
);
|
||||
}
|
||||
|
||||
/// An older books dir that keeps no settings in its journal and has no
|
||||
/// settings file: one written while the viewer runs is followed, as its
|
||||
/// server follows it.
|
||||
#[test]
|
||||
fn a_settings_file_made_later_is_followed() {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-gui-live-toml-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _cleanup = TestDir(dir.clone());
|
||||
let data = dir.join("books-data");
|
||||
drop(Store::create(&data, Currency::USD).unwrap());
|
||||
let loader = Loader::spawn(
|
||||
Source {
|
||||
data_dir: data.clone(),
|
||||
today: NaiveDate::from_ymd_opt(2026, 10, 12),
|
||||
},
|
||||
egui::Context::default(),
|
||||
);
|
||||
match loader.wait(Duration::from_secs(10)) {
|
||||
Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Made Later Co"),
|
||||
_ => panic!("no initial load"),
|
||||
}
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Made Later Co\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the new settings file was never read"
|
||||
);
|
||||
match loader.wait(Duration::from_millis(200)) {
|
||||
Some(Update::Books(b)) if b.settings.company.name == "Made Later Co" => break,
|
||||
Some(Update::Error(e)) => panic!("the settings file didn't load: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ fn books(tag: &str) -> DataDir {
|
||||
fn source(data: &Path) -> Source {
|
||||
Source {
|
||||
data_dir: data.to_path_buf(),
|
||||
books_toml: None,
|
||||
today: NaiveDate::from_ymd_opt(2026, 10, 16),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,17 +75,34 @@ fn copy_books(from: &Path, to: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings the viewer reads beside the books: two members (the
|
||||
/// shares fixtures' parties), the cash account, a fiscal year from July.
|
||||
/// The settings the old books are viewed with: two members (the shares
|
||||
/// fixtures' parties), the cash account, a fiscal year from July.
|
||||
const SETTINGS: &str = "[company]\nname = \"Old Books Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\
|
||||
[[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
|
||||
[books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
/// [`SETTINGS`] journaled into the books in `dir`, as the server adopts a
|
||||
/// settings file, unless they already keep their own (P1's books): the
|
||||
/// viewer reads settings only from the journal (plan 3.6).
|
||||
fn write_settings(dir: &Path) {
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Old Books Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\
|
||||
[[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
|
||||
[books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut store = ledger_store::Store::open(&dir.join("books-data")).unwrap();
|
||||
if store.ledger().settings().is_journaled() {
|
||||
return;
|
||||
}
|
||||
let import =
|
||||
ledger_config::codec::import(SETTINGS, None, store.ledger().settings(), "USD").unwrap();
|
||||
store
|
||||
.adopt_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the test".into(),
|
||||
decided_on: d(2026, 10, 17),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The fixtures whose index is `shard` mod `of`, one scratch books
|
||||
@@ -261,7 +278,6 @@ fn every_page_renders(shard: usize, of: usize) {
|
||||
}
|
||||
let source = Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
books_toml: Some(dir.0.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
let books = data::load_once(&source)
|
||||
@@ -367,7 +383,6 @@ fn stage(name: &str) -> TempDir {
|
||||
fn load(dir: &TempDir) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
books_toml: Some(dir.0.join("wanderbooks.toml")),
|
||||
today: Some(d(TODAY.0, TODAY.1, TODAY.2)),
|
||||
})
|
||||
.unwrap()
|
||||
@@ -420,12 +435,12 @@ fn figures_that_cant_be_computed_are_said() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Books that keep their own settings (written under P1) show them, never
|
||||
/// the settings file beside them, which here says otherwise (another
|
||||
/// name, a July fiscal year, a $25 receipt rule): the viewer reads the
|
||||
/// settings as the server does (plan/served.md P1, Accept).
|
||||
/// Books that keep their own settings (written under P1) show them, as
|
||||
/// the server reads them (plan/served.md P1, Accept; 3.6): never the
|
||||
/// settings the other old books are viewed with (another name, a July
|
||||
/// fiscal year, a $25 receipt rule).
|
||||
#[test]
|
||||
fn books_that_keep_their_settings_never_read_the_file() {
|
||||
fn books_that_keep_their_settings_show_them() {
|
||||
let dir = stage("p1_record");
|
||||
let b = load(&dir);
|
||||
assert_eq!(b.settings.company.name, "Fixture Labs LLC");
|
||||
@@ -458,3 +473,61 @@ fn books_that_keep_their_settings_never_read_the_file() {
|
||||
assert_ne!(e.text, line.line_type, "{} has no sentence", line.line_type);
|
||||
}
|
||||
}
|
||||
|
||||
/// Books that keep no settings in their journal are read as their server
|
||||
/// reads them (plan 3.6): an older books dir from its settings file; a
|
||||
/// book of the data root never from a file, so with the defaults.
|
||||
#[test]
|
||||
fn books_that_keep_no_settings_read_their_file_only_outside_a_root() {
|
||||
let from = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books")
|
||||
.join("shares_big");
|
||||
// An older books dir: its server reads wanderbooks.toml, so the viewer
|
||||
// does too.
|
||||
let t = temp("said-file");
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
|
||||
let b = load(&t);
|
||||
assert_eq!(b.settings.company.name, "Old Books Co");
|
||||
assert!(!b.settings.company.members.is_empty());
|
||||
// The same books in a data root: no file is read, whatever lies
|
||||
// beside them; they show the defaults until their settings are in
|
||||
// their journal.
|
||||
let t = temp("said-defaults");
|
||||
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
|
||||
let b = load(&t);
|
||||
assert_eq!(
|
||||
b.settings,
|
||||
ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD")
|
||||
);
|
||||
}
|
||||
|
||||
/// An older books dir with no settings file but a `config.json`: its
|
||||
/// receipt rule is the server's, from that file (the regression round 2
|
||||
/// of slice 6's review found).
|
||||
#[test]
|
||||
fn an_older_books_dirs_config_json_sets_its_receipt_rule() {
|
||||
let from = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books")
|
||||
.join("shares_big");
|
||||
let t = temp("said-config");
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(
|
||||
t.0.join("books-data/config.json"),
|
||||
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
// In a data root, no file: the books' own rule (the default).
|
||||
let t = temp("said-config-root");
|
||||
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(
|
||||
t.0.join("books-data/config.json"),
|
||||
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use ledger_gui::pages::parties::{
|
||||
};
|
||||
use ledger_gui::{App, Source, attention, data, palette};
|
||||
use ledger_store::Store;
|
||||
use ledger_store::viewer::ViewerTarget;
|
||||
use ledger_store::ops::ViewerTarget;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -65,7 +65,6 @@ fn today() -> NaiveDate {
|
||||
fn load(dir: &Path) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
@@ -892,22 +891,25 @@ fn counterparty_books(
|
||||
.unwrap();
|
||||
let unnamed = post(&mut s, d(2026, 5, 1), "Mystery payment", labor, cash);
|
||||
if let Some(p) = policy {
|
||||
std::fs::write(
|
||||
dir.0.join("wanderbooks.toml"),
|
||||
format!("[books]\nparties = \"{p}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
journal_settings(&mut s, &format!("[books]\nparties = \"{p}\"\n"));
|
||||
}
|
||||
(dir, s, tagged, transfer, unnamed)
|
||||
}
|
||||
|
||||
fn load_with_toml(dir: &Path) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
/// The settings `toml` says, journaled into `s` as the server adopts a
|
||||
/// settings file: the viewer reads settings only from the journal.
|
||||
fn journal_settings(s: &mut Store, toml: &str) {
|
||||
let import = ledger_config::codec::import(toml, None, s.ledger().settings(), "USD").unwrap();
|
||||
s.adopt_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the test".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -920,7 +922,7 @@ fn entries_without_a_counterparty_need_attention_unless_off() {
|
||||
};
|
||||
// Unset is suggest: one note naming just the unmarked entry.
|
||||
let (dir, mut s, _, transfer, unnamed) = counterparty_books("np-suggest", None);
|
||||
let b = load_with_toml(&dir.0);
|
||||
let b = load(&dir.0);
|
||||
let a = item(&b).expect("an item");
|
||||
assert_eq!(a.title, "1 entry names no counterparty");
|
||||
assert_eq!(a.severity, Severity::Info);
|
||||
@@ -961,17 +963,14 @@ fn entries_without_a_counterparty_need_attention_unless_off() {
|
||||
// Tagging the last one clears it.
|
||||
let p = s.add_party("Mystery Co", &[Role::Contractor]).unwrap();
|
||||
s.set_entry_party(unnamed, Some(p)).unwrap();
|
||||
assert!(item(&load_with_toml(&dir.0)).is_none());
|
||||
assert!(item(&load(&dir.0)).is_none());
|
||||
|
||||
// Require: a to-do.
|
||||
let (dir, _s, ..) = counterparty_books("np-require", Some("require"));
|
||||
assert_eq!(
|
||||
item(&load_with_toml(&dir.0)).unwrap().severity,
|
||||
Severity::Todo
|
||||
);
|
||||
assert_eq!(item(&load(&dir.0)).unwrap().severity, Severity::Todo);
|
||||
// Off: never mentioned.
|
||||
let (dir, _s, ..) = counterparty_books("np-off", Some("off"));
|
||||
assert!(item(&load_with_toml(&dir.0)).is_none());
|
||||
assert!(item(&load(&dir.0)).is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- party shares and the capital statement
|
||||
@@ -1108,7 +1107,8 @@ fn member_books(tag: &str) -> MemberBooks {
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
write_member_toml(&dir.0, "cash_accounts = [\"1000\"]\n");
|
||||
drop(s);
|
||||
member_settings(&dir.0, "cash_accounts = [\"1000\"]\n");
|
||||
MemberBooks {
|
||||
dir,
|
||||
tobin,
|
||||
@@ -1119,17 +1119,17 @@ fn member_books(tag: &str) -> MemberBooks {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture's wanderbooks.toml, with `books` as its `[books]` table.
|
||||
fn write_member_toml(dir: &Path, books: &str) {
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
format!(
|
||||
/// The fixture's settings, `books` as their `[books]` table, journaled
|
||||
/// into its books.
|
||||
fn member_settings(dir: &Path, books: &str) {
|
||||
journal_settings(
|
||||
&mut Store::open(&dir.join("books-data")).unwrap(),
|
||||
&format!(
|
||||
"[books]\n{books}[company]\nname = \"Fernhill Labs LLC\"\n\
|
||||
[[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
}
|
||||
|
||||
/// Post one entry to the fixture's books after the fact.
|
||||
@@ -1147,7 +1147,7 @@ fn post_more(m: &MemberBooks, date: NaiveDate, lines: &[(&str, Side, i64)]) -> E
|
||||
#[test]
|
||||
fn entries_list_every_party_with_its_resolved_share() {
|
||||
let m = member_books("shares-entries");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let b = load(&m.dir.0);
|
||||
let e = b.entry(m.reimburse).unwrap();
|
||||
assert_eq!(e.party_id, None, "two parties: no sole party");
|
||||
assert_eq!(
|
||||
@@ -1181,7 +1181,7 @@ fn entries_list_every_party_with_its_resolved_share() {
|
||||
#[test]
|
||||
fn a_members_money_is_their_share() {
|
||||
let m = member_books("shares-flow");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let b = load(&m.dir.0);
|
||||
let span = (d(2026, 1, 1), today());
|
||||
for who in [m.tobin, m.mo] {
|
||||
let f = b.party_flow(who, span, true);
|
||||
@@ -1199,7 +1199,7 @@ fn a_members_money_is_their_share() {
|
||||
#[test]
|
||||
fn a_members_page_is_their_capital_statement() {
|
||||
let m = member_books("capital");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let b = load(&m.dir.0);
|
||||
let st = b
|
||||
.capital_statement(m.tobin, (d(2026, 1, 1), today()))
|
||||
.expect("a member");
|
||||
@@ -1249,7 +1249,7 @@ fn a_members_page_is_their_capital_statement() {
|
||||
let mut s = Store::open(&m.dir.0.join("books-data")).unwrap();
|
||||
let other = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
|
||||
drop(s);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let b = load(&m.dir.0);
|
||||
assert!(
|
||||
b.capital_statement(other, (d(2026, 1, 1), today()))
|
||||
.is_none()
|
||||
@@ -1280,8 +1280,8 @@ fn the_capital_statement_is_the_k1_worksheet_row() {
|
||||
],
|
||||
);
|
||||
for (books, fallback) in [("cash_accounts = [\"1000\"]\n", false), ("", true)] {
|
||||
write_member_toml(&m.dir.0, books);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
member_settings(&m.dir.0, books);
|
||||
let b = load(&m.dir.0);
|
||||
let chart = b.ledger.chart();
|
||||
let id = |c: &str| chart.find_by_code(c).unwrap().id;
|
||||
let members =
|
||||
@@ -1368,7 +1368,7 @@ fn the_capital_statement_counts_income_not_yet_closed() {
|
||||
("4000", Side::Credit, 10_000),
|
||||
],
|
||||
);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let b = load(&m.dir.0);
|
||||
let st = b
|
||||
.capital_statement(m.tobin, (d(2026, 1, 1), today()))
|
||||
.unwrap();
|
||||
@@ -1425,18 +1425,18 @@ fn owed_to_members_reads_the_financing_accounts_first() {
|
||||
&[("6050", Side::Debit, 4_000), ("2200", Side::Credit, 4_000)],
|
||||
);
|
||||
let span = (d(2026, 1, 1), today());
|
||||
write_member_toml(
|
||||
member_settings(
|
||||
&m.dir.0,
|
||||
"cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2200\"]\n",
|
||||
);
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
let b = load(&m.dir.0);
|
||||
let st = b.capital_statement(m.mo, span).unwrap();
|
||||
assert_eq!(
|
||||
(st.due_to_members, st.owed_to_member),
|
||||
(cents(4_000), cents(2_000))
|
||||
);
|
||||
write_member_toml(&m.dir.0, "cash_accounts = [\"1000\"]\n");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
member_settings(&m.dir.0, "cash_accounts = [\"1000\"]\n");
|
||||
let b = load(&m.dir.0);
|
||||
let st = b.capital_statement(m.mo, span).unwrap();
|
||||
assert_eq!(
|
||||
(st.due_to_members, st.owed_to_member),
|
||||
@@ -1452,8 +1452,8 @@ fn owed_to_members_reads_the_financing_accounts_first() {
|
||||
#[test]
|
||||
fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
||||
let m = member_books("capital-no-cash");
|
||||
write_member_toml(&m.dir.0, "");
|
||||
let b = load_with_toml(&m.dir.0);
|
||||
member_settings(&m.dir.0, "");
|
||||
let b = load(&m.dir.0);
|
||||
let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id;
|
||||
assert_eq!(b.money_cash_accounts, vec![id("1000"), id("1500")]);
|
||||
assert!(
|
||||
|
||||
@@ -112,7 +112,6 @@ fn the_viewer_never_writes_into_the_books() {
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
let source = Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
set_read_only(&dir, true);
|
||||
|
||||
@@ -25,7 +25,6 @@ fn demo(tag: &str) -> (TestDir, data::Source) {
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
let source = data::Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
(TestDir(dir), source)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir
|
||||
//! (containing `books-data/` and `wanderbooks.toml`) and `SHOT_DIR` to
|
||||
//! (containing `books-data/`, its settings in its journal) and `SHOT_DIR` to
|
||||
//! where the PNGs go; without them this test does nothing.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
@@ -43,7 +43,6 @@ fn screenshots() {
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let source = Source {
|
||||
data_dir: books.join("books-data"),
|
||||
books_toml: Some(books.join("wanderbooks.toml")),
|
||||
today: fixed_today.or_else(|| {
|
||||
std::env::var("SHOT_BOOKS")
|
||||
.is_err()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use egui_kittest::Harness;
|
||||
use ledger_gui::nav::{ReportKind, Route};
|
||||
use ledger_gui::{App, Source, data};
|
||||
use ledger_store::viewer::ViewerTarget;
|
||||
use ledger_store::ops::ViewerTarget;
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
@@ -23,7 +23,6 @@ fn show_requests_navigate_the_viewer() {
|
||||
ledger_gui::demo::build(&dir, today).unwrap();
|
||||
let source = Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: Some(dir.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
let books = data::load_once(&source).unwrap();
|
||||
|
||||
@@ -759,24 +759,33 @@ fn usd(d: Decimal) -> Money {
|
||||
Money::new(d, Currency::USD)
|
||||
}
|
||||
|
||||
fn write_toml(dir: &Path, threshold: &str, cash: &[&str]) -> PathBuf {
|
||||
/// The company, a receipt threshold and the cash accounts, journaled into
|
||||
/// `store` as the server adopts a settings file: the viewer reads settings
|
||||
/// only from the journal.
|
||||
fn journal_settings(store: &mut Store, threshold: &str, cash: &[&str]) {
|
||||
let cash: Vec<String> = cash.iter().map(|c| format!("\"{c}\"")).collect();
|
||||
let p = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&p,
|
||||
format!(
|
||||
"[company]\nname = \"Spec Test LLC\"\n\n[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [{}]\n",
|
||||
cash.join(", ")
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
p
|
||||
let toml = format!(
|
||||
"[company]\nname = \"Spec Test LLC\"\n\n[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [{}]\n",
|
||||
cash.join(", ")
|
||||
);
|
||||
let import =
|
||||
ledger_config::codec::import(&toml, None, store.ledger().settings(), "USD").unwrap();
|
||||
store
|
||||
.adopt_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the test".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn load(dir: &Path, toml: Option<PathBuf>) -> Books {
|
||||
fn load(dir: &Path) -> Books {
|
||||
load_once(&Source {
|
||||
data_dir: dir.join("books-data"),
|
||||
books_toml: toml,
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
@@ -862,12 +871,12 @@ struct Built {
|
||||
fn build_books(plan: &BooksPlan) -> Built {
|
||||
let dir = TempDir::new("books");
|
||||
let data = dir.0.join("books-data");
|
||||
let toml = write_toml(&dir.0, plan.threshold, CASH_CODES);
|
||||
let mut store = Store::create(&data, Currency::USD).unwrap();
|
||||
let ids: Vec<AccountId> = CHART
|
||||
.iter()
|
||||
.map(|(c, n, t)| store.add_account(c, n, *t, None, false).unwrap())
|
||||
.collect();
|
||||
journal_settings(&mut store, plan.threshold, CASH_CODES);
|
||||
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let mut entry_ids = vec![];
|
||||
let mut posted = vec![];
|
||||
@@ -937,7 +946,7 @@ fn build_books(plan: &BooksPlan) -> Built {
|
||||
reversal = Some((orig, rev));
|
||||
}
|
||||
drop(store);
|
||||
let books = load(&dir.0, Some(toml));
|
||||
let books = load(&dir.0);
|
||||
Built {
|
||||
_dir: dir,
|
||||
books,
|
||||
@@ -977,11 +986,8 @@ proptest! {
|
||||
);
|
||||
}
|
||||
|
||||
let policy = ledger_config::effective_receipt_policy(
|
||||
&built.data.join("config.json"),
|
||||
&b.settings,
|
||||
)
|
||||
.unwrap();
|
||||
// The receipt policy is the books' own, from their journal.
|
||||
let policy = ledger_config::codec::receipt_policy(ledger.settings());
|
||||
|
||||
for &id in &built.entry_ids {
|
||||
let info = b.entry(id);
|
||||
@@ -1082,10 +1088,10 @@ fn add_rent(s: &mut Small, i: u32, post: bool) -> EntryId {
|
||||
}
|
||||
|
||||
fn finish(s: Small) -> (TempDir, Books) {
|
||||
let Small { dir, store, .. } = s;
|
||||
let Small { dir, mut store, .. } = s;
|
||||
journal_settings(&mut store, "0.00", &["1000"]);
|
||||
drop(store);
|
||||
let toml = write_toml(&dir.0, "0.00", &["1000"]);
|
||||
let b = load(&dir.0, Some(toml));
|
||||
let b = load(&dir.0);
|
||||
(dir, b)
|
||||
}
|
||||
|
||||
@@ -1116,7 +1122,7 @@ fn attention_empty_books_have_nothing() {
|
||||
|
||||
let dir = TempDir::new("att-bare");
|
||||
drop(Store::create(&dir.0.join("books-data"), Currency::USD).unwrap());
|
||||
let b = load(&dir.0, None);
|
||||
let b = load(&dir.0);
|
||||
assert!(attention::find(&b).is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ fn fixture(tag: &str) -> Fixture {
|
||||
fn load(f: &Fixture, key: Option<&Path>) -> Books {
|
||||
let mut b = data::load_once(&Source {
|
||||
data_dir: f.books.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -55,7 +55,6 @@ fn the_key_named_on_the_command_line_wins() {
|
||||
|
||||
let mut b = data::load_once(&Source {
|
||||
data_dir: books.join("books-data"),
|
||||
books_toml: None,
|
||||
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -89,18 +89,16 @@ fn build(plan: &Plan, tag: &str) -> (TestDir, Source, ledger_core::Ledger) {
|
||||
s.add_line(e, ids[cr], Side::Credit, m).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
d.join("wanderbooks.toml"),
|
||||
format!(
|
||||
journal_settings(
|
||||
&mut s,
|
||||
&format!(
|
||||
"[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"{:02}-01\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nreceipt_threshold = \"25.00\"\n",
|
||||
plan.fy_month
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
let ledger = s.ledger().clone();
|
||||
let source = Source {
|
||||
data_dir: d.join("books-data"),
|
||||
books_toml: Some(d.join("wanderbooks.toml")),
|
||||
today: Some(plan.today),
|
||||
};
|
||||
(d, source, ledger)
|
||||
@@ -261,15 +259,10 @@ fn origins_and_parties() {
|
||||
.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF receipt", None)
|
||||
.unwrap();
|
||||
let rev = s.reverse_entry(e, today, "undo").unwrap();
|
||||
journal_settings(&mut s, "[books]\nreceipt_threshold = \"0.00\"\n");
|
||||
drop(s);
|
||||
std::fs::write(
|
||||
d.join("wanderbooks.toml"),
|
||||
"[books]\nreceipt_threshold = \"0.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let source = Source {
|
||||
data_dir: d.join("books-data"),
|
||||
books_toml: Some(d.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
};
|
||||
let b = data::load_once(&source).unwrap();
|
||||
@@ -326,11 +319,30 @@ fn store_at(tag: &str) -> (TestDir, Store) {
|
||||
(d, s)
|
||||
}
|
||||
|
||||
/// The settings `toml` says, journaled into `s` as the server adopts a
|
||||
/// settings file: the viewer reads settings only from the journal.
|
||||
fn journal_settings(s: &mut Store, toml: &str) {
|
||||
let import = ledger_config::codec::import(toml, None, s.ledger().settings(), "USD").unwrap();
|
||||
s.adopt_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the test".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The books in `d`, with the settings `toml` says journaled (none when
|
||||
/// it's empty: the defaults).
|
||||
fn source(d: &std::path::Path, toml: &str, today: NaiveDate) -> Source {
|
||||
std::fs::write(d.join("wanderbooks.toml"), toml).unwrap();
|
||||
if !toml.is_empty() {
|
||||
journal_settings(&mut Store::open(&d.join("books-data")).unwrap(), toml);
|
||||
}
|
||||
Source {
|
||||
data_dir: d.join("books-data"),
|
||||
books_toml: Some(d.join("wanderbooks.toml")),
|
||||
today: Some(today),
|
||||
}
|
||||
}
|
||||
@@ -554,7 +566,7 @@ fn indexes_cash_cards_accounts_and_fresh() {
|
||||
let e2 = post(&mut s, today, rent, checking, 999);
|
||||
drop(s);
|
||||
let snap = ledger_store::Snapshot::load(&src.data_dir).unwrap();
|
||||
let settings = ledger_config::load(src.books_toml.as_ref().unwrap()).unwrap();
|
||||
let settings = ledger_config::codec::books_settings(snap.ledger().settings(), "USD");
|
||||
let mut verified = std::collections::HashSet::new();
|
||||
let integrity = ledger_gui::books::Integrity::check(&snap, &mut verified);
|
||||
let b2 = ledger_gui::books::Books::build(&snap, settings, today, Some(seq), integrity).unwrap();
|
||||
|
||||
Reference in new issue
Block a user