P1: the books keep how they are kept
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:
1 parent
e60ecc5317
commit
c587593d78
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("Tobin Harlow", on),
|
||||
}
|
||||
}
|
||||
|
||||
fn item(title: &str, due: Option<NaiveDate>) -> NewOpenItem {
|
||||
NewOpenItem {
|
||||
title: title.into(),
|
||||
detail_md: None,
|
||||
due,
|
||||
source: src("Tobin Harlow", 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 Tobin 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("Tobin Harlow", 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!["Tobin Harlow".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!["Tobin Harlow".into(), "Morgan Ellis".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("Tobin Harlow", 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 Tobin says otherwise"),
|
||||
"a summary is its first line"
|
||||
);
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("Tobin Harlow · {}", 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!(
|
||||
"Tobin Harlow and Morgan Ellis · {}",
|
||||
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("Tobin Harlow", d(2026, 10, 14)),
|
||||
)
|
||||
.unwrap();
|
||||
let only = s.raise_open_item(&item("Done already", None)).unwrap();
|
||||
s.resolve_open_item(only, "done", &src("Tobin Harlow", 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!("FL")),
|
||||
}],
|
||||
&src("Tobin Harlow", 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!("Fernhill Labs LLC")),
|
||||
}],
|
||||
&src("Tobin Harlow", today()),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let h = company(&dir);
|
||||
assert!(!shows(&h, "No company facts"), "the facts are there");
|
||||
assert!(shows(&h, "Fernhill Labs LLC"));
|
||||
assert!(shows(
|
||||
&h,
|
||||
&format!("Tobin Harlow · {}", 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-7814321"),
|
||||
"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