Files
wanderbooks/crates/ledger-mcp/tests/p3_seams.rs
T
swaitsandClaude Opus 5.5 2ae93fb2d6 P3 review: the same figures agree across the pieces
A distribution declared to a payable and then paid is financing when paid,
the declaration disclosed (ASC 230-10-45-15(a)); a disposal's depreciation
lines count as depreciation in the cash flow and the tax-basis K-1; an
as-of asset list says the status of that day. Seam tests tie the members'
statement, K-1, party totals, cash flow, 1099s, depreciation and the CPA
package together on three dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 16:37:47 -06:00

351 lines
13 KiB
Rust

//! P3's seams through the tools: on the seam review's books (a year of a
//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), built
//! in-process with the real `Store`, closed, then served by the real
//! binary over stdio from the same folder and its settings file, every
//! figure a tool reports is the engine's for the same accounts and dates,
//! and the tools agree with each other: the cash flow statement, party
//! totals on both bases, the 1099 worksheet as of a day, the fixed
//! assets, the members' equity statement and the CPA package.
#[path = "../../ledger-package/tests/seams/books.rs"]
mod books;
use books::{books, d, dec};
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(180);
const START: &str = "2026-01-01";
struct Mcp {
stdin: ChildStdin,
rx: Receiver<String>,
child: Child,
next_id: u64,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Mcp {
fn start(dir: &Path) -> Self {
let run = dir.join("run");
std::fs::create_dir_all(&run).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--books-toml")
.arg(dir.join("wanderbooks.toml"))
.env("XDG_RUNTIME_DIR", &run)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let (tx, rx) = channel();
std::thread::spawn(move || {
for line in stdout.lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut mcp = Mcp {
stdin,
rx,
child,
next_id: 1,
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "p3-seams", "version": "0.1.0"},
}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
writeln!(
mcp.stdin,
"{}",
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.unwrap();
mcp
}
fn rpc(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
loop {
let line = self
.rx
.recv_timeout(TIMEOUT)
.unwrap_or_else(|_| panic!("no response to {method}"));
let v: Value = serde_json::from_str(&line).unwrap();
if v.get("id") == Some(&json!(id)) {
return v;
}
}
}
/// A tool's structured result; a refusal fails the test.
fn ok(&mut self, name: &str, args: Value) -> Value {
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
let r = &resp["result"];
assert!(
resp.get("error").is_none() && r.get("isError") != Some(&json!(true)),
"{name} {args}: {resp}"
);
r["structuredContent"].clone()
}
}
fn m(v: &Value) -> rust_decimal::Decimal {
dec(v
.as_str()
.unwrap_or_else(|| panic!("a decimal string: {v}")))
}
/// What the engine says, worked out before the server opens the books.
struct Expected {
end: String,
cash_flow: ledger_core::CashFlowStatement,
members_equity: ledger_core::MembersEquityStatement,
register: ledger_core::AssetRegister,
vendor_1099: ledger_core::Vendor1099Report,
depreciation: rust_decimal::Decimal,
iif: String,
}
/// Every figure the tools report on the seam review's books is the
/// engine's for the same accounts and dates, on every date (B-33's "the
/// tool's for the same accounts and through"), and party totals for the
/// year are the engine's on both bases.
#[test]
fn the_tools_figures_are_the_engines_on_every_date() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let l = b.store.ledger();
let settings = ledger_package::Settings {
company: &b.company,
cash_accounts: &b.cash,
financing_accounts: &b.financing,
investing_accounts: &[],
};
let capitals = ledger_package::member_capitals(l, &b.company.members).unwrap();
let mut expected = Vec::new();
for end in ["2026-06-30", "2026-09-15", "2026-12-31"] {
let e = d(end);
expected.push(Expected {
end: end.to_string(),
cash_flow: ledger_package::cash_flow_for(
l,
&settings,
Some(START),
Some(end),
&[],
&[],
&[],
e,
)
.unwrap(),
members_equity: ledger_core::members_equity(l, d(START), e, &capitals).unwrap(),
register: ledger_core::fixed_asset_register(l, e).unwrap(),
vendor_1099: ledger_package::vendor_1099_report(l, &settings, 2026, None, Some(e))
.unwrap(),
depreciation: ledger_core::profit_and_loss_for_period(l, d(START), e, None)
.unwrap()
.expenses
.iter()
.filter(|x| x.code == "6500")
.map(|x| x.amount)
.sum(),
iif: ledger_package::export_iif(l, &b.cash, e).unwrap().text,
});
}
let money = ledger_core::parties::money_accounts(
l,
&[l.chart().find_by_code("1000").unwrap().id],
&b.financing
.iter()
.map(|c| l.chart().find_by_code(c).unwrap().id)
.collect::<Vec<_>>(),
);
let members = b.company.party_members(l);
let year = (d(START), d("2026-12-31"));
let parties: Vec<(String, ledger_core::parties::Activity)> = l
.parties()
.map(|p| {
(
p.name.clone(),
ledger_core::parties::activity_with(l, p.id, year, &money, &members),
)
})
.collect();
drop(b);
let mut mcp = Mcp::start(dir.path());
for x in &expected {
let end = x.end.as_str();
// The cash flow statement: every line, kind and reconciling item.
let cf = mcp.ok("books.cash_flow", json!({"start": START, "end": end}));
for (key, total) in [
("cash_from_operations", x.cash_flow.cash_from_operations),
("cash_from_investing", x.cash_flow.cash_from_investing),
("cash_from_financing", x.cash_flow.cash_from_financing),
("net_change_in_cash", x.cash_flow.net_change_in_cash),
] {
assert_eq!(m(&cf[key]), total, "{end} {key}");
}
for (key, section) in [
("operating", ledger_core::CashFlowSection::Operating),
("investing", ledger_core::CashFlowSection::Investing),
("financing", ledger_core::CashFlowSection::Financing),
] {
let lines: Vec<(String, rust_decimal::Decimal)> = cf[key]
.as_array()
.unwrap()
.iter()
.map(|l| (l["kind"].as_str().unwrap().to_string(), m(&l["amount"])))
.collect();
let engine: Vec<(String, rust_decimal::Decimal)> = x
.cash_flow
.lines(section)
.iter()
.map(|l| (l.kind.clone(), l.amount))
.collect();
assert_eq!(lines, engine, "{end} {key}");
}
let recon: Vec<(String, rust_decimal::Decimal)> = cf["operating_reconciliation"]["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| (l["label"].as_str().unwrap().to_string(), m(&l["amount"])))
.collect();
let engine: Vec<(String, rust_decimal::Decimal)> = x
.cash_flow
.operating_reconciliation
.lines
.iter()
.map(|l| (l.label.clone(), l.amount))
.collect();
assert_eq!(recon, engine, "{end}");
let depreciation: rust_decimal::Decimal = recon
.iter()
.filter(|(label, _)| label == "Depreciation")
.map(|(_, a)| *a)
.sum();
assert_eq!(
depreciation, x.depreciation,
"{end}: the P&L's depreciation"
);
// The P&L's depreciation.
let pnl = mcp.ok("books.profit_and_loss", json!({"start": START, "end": end}));
let dep: rust_decimal::Decimal = pnl["expense_accounts"]
.as_array()
.unwrap()
.iter()
.filter(|a| a["code"] == "6500")
.map(|a| m(&a["amount"]))
.sum();
assert_eq!(dep, x.depreciation, "{end}");
// The members' equity statement.
let me = mcp.ok(
"books.export",
json!({"report": "members_equity", "format": "json", "start": START, "end": end}),
);
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
for (col, engine) in me["members"]
.as_array()
.unwrap()
.iter()
.zip(&x.members_equity.members)
{
for (key, v) in [
("beginning", engine.beginning),
("contributions", engine.contributions),
("net_income", engine.net_income),
("distributions", engine.distributions),
("other", engine.other),
("ending", engine.ending),
] {
assert_eq!(m(&col[key]), v, "{end} {} {key}", engine.name);
}
}
// The fixed assets, as of the day: list_assets is the register.
let assets = mcp.ok("books.list_assets", json!({"as_of": end}));
for (a, row) in assets["assets"]
.as_array()
.unwrap()
.iter()
.zip(&x.register.rows)
{
assert_eq!(m(&a["accumulated_posted"]), row.accumulated_posted, "{end}");
assert_eq!(
m(&a["accumulated_scheduled_through"]),
row.accumulated_scheduled,
"{end}"
);
assert_eq!(m(&a["book_value"]), row.book_value, "{end}");
let disposed = row.disposed.is_some();
assert_eq!(a["held"], json!(!disposed), "{end}");
assert_eq!(
a["status"],
json!(if disposed { "disposed" } else { "active" }),
"{end}: a disposal after as_of doesn't show"
);
}
// The 1099 worksheet as of the day.
let w = mcp.ok("books.vendor_1099", json!({"year": 2026, "through": end}));
let vendors = w["vendors"].as_array().unwrap();
assert_eq!(vendors.len(), x.vendor_1099.vendors.len(), "{end}");
for (v, engine) in vendors.iter().zip(&x.vendor_1099.vendors) {
assert_eq!(v["vendor_id"], json!(engine.vendor_id));
for (key, amount) in [
("paid", engine.paid),
("excluded_card", engine.excluded_card),
("undetermined", engine.undetermined),
("not_reportable", engine.not_reportable),
] {
assert_eq!(m(&v[key]), amount, "{end} {} {key}", engine.name);
}
}
// The IIF as of the day.
let iif = mcp.ok(
"books.export",
json!({"report": "general_journal", "format": "iif", "as_of": end}),
);
assert_eq!(iif["content"].as_str().unwrap(), x.iif, "{end}");
}
// Party totals for the year, on both bases.
for (name, a) in &parties {
for (basis, key) in [
(ledger_core::parties::Basis::Cash, "cash"),
(ledger_core::parties::Basis::All, "all"),
] {
let p = mcp.ok(
"books.party",
json!({"party": name, "year": 2026, "basis": key}),
);
let (paid, received) = a.totals(basis);
assert_eq!(m(&p["totals"]["paid"]), paid, "{name} {key}");
assert_eq!(m(&p["totals"]["received"]), received, "{name} {key}");
}
}
}