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:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+1 -2
View File
@@ -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()
+1 -2
View File
@@ -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);
-1
View File
@@ -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)
+1 -2
View File
@@ -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
-1
View File
@@ -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();
+490
View File
@@ -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}");
}
-1
View File
@@ -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();
+69 -6
View File
@@ -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}"),
_ => {}
}
}
}
-1
View File
@@ -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),
}
}
+90 -17
View File
@@ -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);
}
+42 -42
View File
@@ -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!(
-1
View File
@@ -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);
-1
View File
@@ -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 -2
View File
@@ -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()
+1 -2
View File
@@ -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();
+30 -24
View File
@@ -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());
}
-1
View File
@@ -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();
+27 -15
View File
@@ -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();