Settings, conventions (posting templates, approval rules, policies, close steps, calendar, mappings, notes), decisions, open items and entry approvals are journal events (journal format 2). Books that keep their settings never read wanderbooks.toml or config.json; the server and the viewer take the vault key from --vault-key-file instead. books.import_settings moves a settings file in once, exactly as the server reads it. Approval rules, when enforced, gate every posting path. Replay refuses what it can't read as newer, never as corrupt. Lean: a record changes no figure. Mutation on the whole diff: every survivor killed by a test but two equivalent (docs/testing.md). The Company page says a late open item is overdue in words, not colour alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
71 files changed
+12249
-461
No files matched your search
@@ -0,0 +1,271 @@
|
||||
//! The Company page shows how the books are kept: live conventions (not
|
||||
//! retired ones), standing decisions (not superseded ones), and open items
|
||||
//! (not resolved ones), each with who decided it and when; an open item
|
||||
//! past due says so in words. Books that keep their settings but hold no
|
||||
//! company facts say where the facts will come from.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::Currency;
|
||||
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_gui::nav::Route;
|
||||
use ledger_gui::{App, data, fmt};
|
||||
use ledger_store::Store;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
let d = std::env::temp_dir().join(format!("ledger-gui-kept-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TempDir(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
d(2026, 10, 17)
|
||||
}
|
||||
|
||||
fn src(by: &str, on: NaiveDate) -> Source {
|
||||
Source {
|
||||
decided_by: by.into(),
|
||||
decided_on: on,
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn note(id: &str, title: &str, body: &str, on: NaiveDate) -> NewConvention {
|
||||
NewConvention {
|
||||
id: id.into(),
|
||||
kind: ConventionKind::Policy,
|
||||
title: title.into(),
|
||||
body_md: body.into(),
|
||||
structured: None,
|
||||
source: src("Stephen Waits", on),
|
||||
}
|
||||
}
|
||||
|
||||
fn item(title: &str, due: Option<NaiveDate>) -> NewOpenItem {
|
||||
NewOpenItem {
|
||||
title: title.into(),
|
||||
detail_md: None,
|
||||
due,
|
||||
source: src("Stephen Waits", d(2026, 10, 16)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the Company page of the books in `dir`.
|
||||
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();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 2400.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Company));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn shows(h: &Harness<'_, App>, text: &str) -> bool {
|
||||
h.query_all_by_label_contains(text).next().is_some()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_company_page_shows_how_the_books_are_kept() {
|
||||
let dir = TempDir::new("cards");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
s.set_convention(¬e(
|
||||
"joint-money",
|
||||
"Joint money",
|
||||
"Owner funds are 50/50.\nUnless Stephen says otherwise.",
|
||||
d(2026, 10, 11),
|
||||
))
|
||||
.unwrap();
|
||||
s.set_convention(¬e(
|
||||
"old-way",
|
||||
"The old way",
|
||||
"Gone now.",
|
||||
d(2026, 10, 12),
|
||||
))
|
||||
.unwrap();
|
||||
s.retire_convention(
|
||||
"old-way",
|
||||
"replaced",
|
||||
&src("Stephen Waits", d(2026, 10, 14)),
|
||||
)
|
||||
.unwrap();
|
||||
let first = s
|
||||
.record_decision(&NewDecision {
|
||||
subject: "Materiality, first cut".into(),
|
||||
decision_md: "Expense up to $2,500.".into(),
|
||||
decided_by: vec!["Stephen Waits".into()],
|
||||
decided_on: d(2026, 10, 11),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
})
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
subject: "Materiality policy".into(),
|
||||
decision_md: "Tangible property up to $2,500; prepaids by term.".into(),
|
||||
decided_by: vec!["Stephen Waits".into(), "Katherine Pendergast".into()],
|
||||
decided_on: d(2026, 10, 16),
|
||||
evidence_attachment: None,
|
||||
supersedes: Some(first),
|
||||
citation: None,
|
||||
})
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Mail the IRS letter", Some(d(2026, 10, 16))))
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Close October", Some(today())))
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Ask about the domains", None))
|
||||
.unwrap();
|
||||
let done = s
|
||||
.raise_open_item(&item("File the EIN letter", None))
|
||||
.unwrap();
|
||||
s.resolve_open_item(done, "filed", &src("Stephen Waits", d(2026, 10, 17)))
|
||||
.unwrap();
|
||||
drop(s);
|
||||
|
||||
let h = company(&dir);
|
||||
// Live conventions, their kind and summary (the first line), and who
|
||||
// decided them; never a retired one.
|
||||
assert!(shows(&h, "How the books are kept"));
|
||||
assert!(shows(&h, "Joint money"));
|
||||
assert!(shows(&h, "policy"));
|
||||
assert!(shows(&h, "Owner funds are 50/50."));
|
||||
assert!(
|
||||
!shows(&h, "Unless Stephen says otherwise"),
|
||||
"a summary is its first line"
|
||||
);
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("Stephen Waits · {}", fmt::date(d(2026, 10, 11)))
|
||||
));
|
||||
assert!(
|
||||
!shows(&h, "The old way"),
|
||||
"a retired convention is not shown"
|
||||
);
|
||||
// The standing decision, by both members; never the one it replaced.
|
||||
assert!(shows(&h, "Decisions"));
|
||||
assert!(shows(&h, "Materiality policy"));
|
||||
assert!(shows(
|
||||
&h,
|
||||
"Tangible property up to $2,500; prepaids by term."
|
||||
));
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!(
|
||||
"Stephen Waits and Katherine Pendergast · {}",
|
||||
fmt::date(d(2026, 10, 16))
|
||||
)
|
||||
));
|
||||
assert!(
|
||||
!shows(&h, "Materiality, first cut"),
|
||||
"a superseded decision is not shown"
|
||||
);
|
||||
// Open items: late in words, due today is not late, no due no date;
|
||||
// never a resolved one.
|
||||
assert!(shows(&h, "Open items"));
|
||||
assert!(shows(&h, "Mail the IRS letter"));
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("overdue since {}", fmt::date(d(2026, 10, 16)))
|
||||
));
|
||||
assert!(shows(&h, "Close October"));
|
||||
assert!(
|
||||
h.query_by_label(&format!("due {}", fmt::date(today())))
|
||||
.is_some()
|
||||
);
|
||||
assert!(!shows(&h, &format!("overdue since {}", fmt::date(today()))));
|
||||
assert!(shows(&h, "Ask about the domains"));
|
||||
assert!(
|
||||
!shows(&h, "File the EIN letter"),
|
||||
"a resolved item is not shown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_kept_shows_no_cards() {
|
||||
let dir = TempDir::new("none");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
s.set_convention(¬e(
|
||||
"old-way",
|
||||
"The old way",
|
||||
"Gone now.",
|
||||
d(2026, 10, 12),
|
||||
))
|
||||
.unwrap();
|
||||
s.retire_convention(
|
||||
"old-way",
|
||||
"replaced",
|
||||
&src("Stephen Waits", d(2026, 10, 14)),
|
||||
)
|
||||
.unwrap();
|
||||
let only = s.raise_open_item(&item("Done already", None)).unwrap();
|
||||
s.resolve_open_item(only, "done", &src("Stephen Waits", d(2026, 10, 17)))
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let h = company(&dir);
|
||||
for card in ["How the books are kept", "Decisions", "Open items"] {
|
||||
assert!(!shows(&h, card), "{card} shown with nothing in it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn books_that_keep_their_settings_say_where_facts_come_from() {
|
||||
let dir = TempDir::new("facts");
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
// Journaled, with no company fact yet.
|
||||
s.change_settings(
|
||||
&[Change {
|
||||
key: "books.asset_tag_prefix".into(),
|
||||
value: Some(serde_json::json!("WL")),
|
||||
}],
|
||||
&src("Stephen Waits", today()),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let h = company(&dir);
|
||||
assert!(shows(
|
||||
&h,
|
||||
"No company facts set yet: the agent records them as you give them."
|
||||
));
|
||||
drop(h);
|
||||
let mut s = Store::open(&dir.0.join("books-data")).unwrap();
|
||||
s.change_settings(
|
||||
&[Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!("Wanderland Labs LLC")),
|
||||
}],
|
||||
&src("Stephen Waits", today()),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let h = company(&dir);
|
||||
assert!(!shows(&h, "No company facts"), "the facts are there");
|
||||
assert!(shows(&h, "Wanderland Labs LLC"));
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("Stephen Waits · {}", fmt::date(today()))
|
||||
));
|
||||
}
|
||||
@@ -62,11 +62,11 @@ fn says_update(text: &str) {
|
||||
#[test]
|
||||
fn the_viewer_says_update_for_newer_books_and_corrupt_for_damaged_ones() {
|
||||
for (tag, line_type, payload) in [
|
||||
("type", "settings.set", json!({"key": "company.name"})),
|
||||
("type", "period.attested", json!({"key": "company.name"})),
|
||||
(
|
||||
"format",
|
||||
"journal.format_set",
|
||||
json!({"format": 2, "features": []}),
|
||||
json!({"format": 3, "features": []}),
|
||||
),
|
||||
] {
|
||||
let data = books(tag);
|
||||
|
||||
@@ -202,25 +202,48 @@ fn show(h: &mut Harness<'_, App>, route: Route) {
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
/// INVARIANTS 1-2, in four shards run side by side.
|
||||
/// How many shards the old books are rendered in, side by side.
|
||||
const SHARDS: usize = 8;
|
||||
|
||||
/// INVARIANTS 1-2, in eight shards run side by side.
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_0() {
|
||||
every_page_renders(0, 4);
|
||||
every_page_renders(0, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_1() {
|
||||
every_page_renders(1, 4);
|
||||
every_page_renders(1, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_2() {
|
||||
every_page_renders(2, 4);
|
||||
every_page_renders(2, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_3() {
|
||||
every_page_renders(3, 4);
|
||||
every_page_renders(3, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_4() {
|
||||
every_page_renders(4, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_5() {
|
||||
every_page_renders(5, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_6() {
|
||||
every_page_renders(6, SHARDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_renders_over_books_an_earlier_version_wrote_7() {
|
||||
every_page_renders(7, SHARDS);
|
||||
}
|
||||
|
||||
/// Every page over the old books whose index is `shard` mod `of`.
|
||||
@@ -396,3 +419,42 @@ fn figures_that_cant_be_computed_are_said() {
|
||||
b.attention
|
||||
);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[test]
|
||||
fn books_that_keep_their_settings_never_read_the_file() {
|
||||
let dir = stage("p1_record");
|
||||
let b = load(&dir);
|
||||
assert_eq!(b.settings.company.name, "Fixture Labs LLC");
|
||||
assert_eq!(b.settings.company.fiscal_year_start, (1, 1));
|
||||
assert_eq!(b.receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
assert_eq!(b.settings.timezone, None, "the fixture cleared it");
|
||||
assert_eq!(b.settings.company.members.len(), 2);
|
||||
assert!(
|
||||
b.settings
|
||||
.company
|
||||
.profile
|
||||
.iter()
|
||||
.any(|(k, v)| k == "ein" && v == "98-7654321"),
|
||||
"the viewer is the human's: the EIN in full"
|
||||
);
|
||||
// Every new event has its own sentence in the journal page.
|
||||
for line in b.journal.iter().filter(|l| {
|
||||
[
|
||||
"settings.set",
|
||||
"convention.set",
|
||||
"convention.retired",
|
||||
"decision.recorded",
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"entry.approved",
|
||||
]
|
||||
.contains(&l.line_type.as_str())
|
||||
}) {
|
||||
let e = ledger_gui::pages::journal::describe(&b, line).expect("described");
|
||||
assert_ne!(e.text, line.line_type, "{} has no sentence", line.line_type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! The viewer's command line: `--vault-key-file` names the vault key and
|
||||
//! wins over a settings file's `[vault] key_file`, as it does for the
|
||||
//! server; an unknown argument stops the viewer with status 2. (Its own
|
||||
//! test binary: the key file is named once per process.)
|
||||
|
||||
use ledger_core::{Role, TinFacts, TinType};
|
||||
use ledger_gui::{Source, data};
|
||||
use ledger_store::Store;
|
||||
use ledger_vault::{Tin, Vault};
|
||||
|
||||
const SSN: &str = "123-45-6789";
|
||||
|
||||
#[test]
|
||||
fn the_key_named_on_the_command_line_wins() {
|
||||
let root = std::env::temp_dir().join(format!("ledger-gui-key-flag-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let books = root.join("books");
|
||||
let key = root.join("keys/vault.key");
|
||||
std::fs::create_dir_all(&books).unwrap();
|
||||
let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap();
|
||||
let party = s.add_party("Pat Lee", &[Role::Member]).unwrap();
|
||||
let v = Vault::open(&books.join("vault"), &key, true).unwrap();
|
||||
let hash = v
|
||||
.put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap())
|
||||
.unwrap();
|
||||
s.set_party_tin(
|
||||
party,
|
||||
Some(TinFacts {
|
||||
tin_type: TinType::Ssn,
|
||||
last4: "6789".into(),
|
||||
vault_record: Some(hash),
|
||||
purged: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
|
||||
// Named first, so the default key location is never consulted.
|
||||
assert_eq!(ledger_gui::vault_key_file(), None);
|
||||
ledger_gui::set_vault_key_file(key.clone());
|
||||
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
|
||||
// Named once: a second naming changes nothing.
|
||||
ledger_gui::set_vault_key_file(root.join("elsewhere.key"));
|
||||
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
|
||||
|
||||
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();
|
||||
// The settings name a key that isn't there; the command line wins.
|
||||
b.settings.vault_key_file = Some(root.join("missing/vault.key"));
|
||||
assert_eq!(b.tin_from_vault(party).unwrap(), SSN);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_argument_stops_the_viewer() {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
|
||||
.arg("--no-such-flag")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let help = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(help.status.success());
|
||||
assert!(String::from_utf8_lossy(&help.stdout).contains("--vault-key-file PATH"));
|
||||
}
|
||||
Reference in new issue
Block a user