Files
wanderbooks/crates/ledger-cli/tests/spec_projects.rs
T
swaitsandClaude Fable 5.1 a319a64c3a refactor(mcp): the party_id, document_id and cash_account aliases are removed (P5)
Agent reason: one name for one thing. S4 and S6 kept the old spellings "until
P5" so an agent that learned them did not break; P5 removes them, so the
schema an agent reads has one parameter for each fact and a call cannot name
the same thing twice with different answers.

books.create_invoice takes customer and books.create_bill vendor (a record's
id, its party's id or its name; required), no party_id.
books.receive_payment and books.pay_bill take invoice or bill and
deposit_to or paid_from, no document_id or cash_account; a payment names its
document and its account or is refused saying which to pass. A number that is
one record's id and another's name is still refused, never guessed (INVARIANT
6).

The over-the-wire tests that used the old spellings now use the new
(document_id becomes invoice or bill, cash_account becomes deposit_to or
paid_from, party_id becomes customer or vendor given as the id's text); the
one test that named the alias, party_id_alias_still_works, is now
a_customer_by_id_and_by_name_is_one_record and checks the same two spellings
of a customer, by id and by name, name one record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00

1520 lines
52 KiB
Rust

//! Spec-blind tests for the project tools of the books server (rules.md
//! B-37), driven through a real `wanderbooks server` as an agent reaches
//! it (common.rs).
//!
//! Expected behavior comes only from the tool descriptions and parameter /
//! result doc comments in `src/server.rs` and `src/types.rs` (the declared
//! interface, as `tools/list` serves it) and `docs/mcp-interface.md`
//! ("Tools — parties"). The harness is the minimal subset of
//! `tests/spec_mcp.rs` (copied from `tests/spec_assets.rs`): every success
//! validates against the tool's `outputSchema`, and every refusal leaves
//! `journal.jsonl` byte-identical (server invariant 2: a refused call
//! changes nothing).
use common::{Place, Wire};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod common;
const TIMEOUT: Duration = common::TIMEOUT;
// ---------------------------------------------------------------- harness
#[derive(Debug, Clone)]
enum Out {
Ok(Value),
ToolErr(String),
RpcErr(Value),
}
impl Out {
fn refused(&self) -> bool {
!matches!(self, Out::Ok(_))
}
fn err_text(&self) -> String {
match self {
Out::Ok(v) => panic!("expected a refusal, got {v}"),
Out::ToolErr(t) => t.clone(),
Out::RpcErr(e) => e.to_string(),
}
}
}
struct Mcp {
wire: Wire,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
/// The test's place, when the session owns it (its server is closed
/// and its home removed after the session).
books_dir: Option<TestDir>,
}
/// A test's place (common.rs): its home, its server, its books.
type TestDir = Place;
fn test_dir(name: &str) -> TestDir {
Place::new(name)
}
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
/// receipt threshold is high so ordinary test entries need no receipt.
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
let path = dir.join("wanderbooks.toml");
std::fs::write(
&path,
format!(
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
),
)
.unwrap();
path
}
impl Mcp {
fn start(dir: &TestDir, toml: &Path) -> Self {
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
let mut mcp = Self {
books_dir: None,
wire,
next_id: 1,
data: dir.books_data(),
validators: HashMap::new(),
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "spec-projects", "version": "0.1.0"},
}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
mcp.send_line(
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
);
let list = mcp.rpc("tools/list", json!({}));
for tool in list["result"]["tools"].as_array().expect("tools") {
let name = tool["name"].as_str().unwrap().to_string();
let validator = jsonschema::validator_for(&tool["outputSchema"])
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
mcp.validators.insert(name, validator);
}
mcp
}
fn journal(&self) -> Vec<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// The journal's line types, in order.
fn journal_types(&self) -> Vec<String> {
String::from_utf8(self.journal())
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let v: Value = serde_json::from_str(l).unwrap();
v["type"]
.as_str()
.or_else(|| v["line_type"].as_str())
.unwrap_or_default()
.to_string()
})
.collect()
}
fn send_line(&mut self, line: &str) {
self.wire.send_line(line);
}
fn recv(&mut self) -> Option<Value> {
self.wire.recv(TIMEOUT)
}
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});
self.send_line(&msg.to_string());
let resp = self
.recv()
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
resp
}
/// Call a tool; a refusal must leave the journal unchanged, a success
/// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal();
let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone());
let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone())
} else {
let result = &resp["result"];
if result.get("isError") == Some(&json!(true)) {
Out::ToolErr(
result["content"]
.as_array()
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
.unwrap_or_default()
.to_string(),
)
} else {
let sc = result["structuredContent"].clone();
let v = self
.validators
.get(name)
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
assert!(
errors.is_empty(),
"{name} violates its outputSchema: {errors:?}\n{sc}"
);
Out::Ok(sc)
}
};
if out.refused() {
assert!(
self.journal() == before,
"refused {name} {args} changed the journal ({out:?})"
);
}
out
}
fn ok(&mut self, name: &str, args: Value) -> Value {
let a = args.to_string();
match self.tool(name, args) {
Out::Ok(v) => v,
other => panic!("{name} {a}: expected success, got {other:?}"),
}
}
/// `tool`, expecting a refusal that says why.
fn refuse(&mut self, name: &str, args: Value) -> String {
let a = args.to_string();
let out = self.tool(name, args);
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
let why = out.err_text();
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
why
}
/// `ok`, and the journal must not change (read-only tools).
fn read_only(&mut self, name: &str, args: Value) -> Value {
let before = self.journal();
let v = self.ok(name, args);
assert!(
self.journal() == before,
"read-only {name} changed the journal"
);
v
}
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
for (code, name, ty) in accts {
self.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
}
}
/// How many journal lines have this type.
fn count(&self, line_type: &str) -> usize {
self.journal_types()
.iter()
.filter(|t| t.as_str() == line_type)
.count()
}
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1200", "Equipment", "asset"),
("3000", "Capital", "equity"),
("4000", "Revenue", "revenue"),
("6000", "Contract Labor", "expense"),
("6200", "Rent", "expense"),
];
/// Books with the chart; cash is 1000.
fn books(name: &str) -> Mcp {
books_in(name, false)
}
/// [`books`]; `p2`: as P2 left them (format 2, no P3 feature), waiting
/// for their format upgrade.
fn books_in(name: &str, p2: bool) -> Mcp {
let dir = test_dir(name);
if p2 {
ledger_store::Store::create_in_format(
&dir.books_data(),
ledger_core::Currency::USD,
&ledger_store::JournalFormat {
format: 2,
features: Vec::new(),
},
)
.unwrap();
}
let toml = books_toml(&dir, "Spec Co", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.books_dir = Some(dir);
m
}
impl Mcp {
/// Draft with lines (account, side, amount, class), post it; the id.
fn tagged(
&mut self,
date: &str,
what: &str,
lines: &[(&str, &str, &str, Option<&str>)],
) -> u64 {
let id = self.ok(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
)["entry_id"]
.as_u64()
.unwrap();
for (account, side, amount, class) in lines {
let mut a = json!({"entry_id": id, "account": account, "side": side, "amount": amount});
if let Some(c) = class {
a["class"] = json!(c);
}
self.ok("books.add_line", a);
}
self.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
id
}
/// File `bytes` as a contract, uploaded as an agent uploads them.
fn doc(&mut self, bytes: &[u8], link_to: Value) -> Out {
let upload = self
.wire
.upload(bytes, "spec.pdf", "application/pdf", "document");
self.tool(
"books.file_document",
json!({"category": "contract", "upload_id": upload, "link_to": link_to}),
)
}
}
/// Harbor app (on spec): $1,400.00 + $60.00 of costs, repaid $1,000.00;
/// Zeta site (client): $5,000.00 earned, $1,800.00 of contractors, one
/// line in 2025; rent is no project's; test devices tagged Harbor.
fn studio(name: &str) -> (Mcp, u64, u64) {
let mut m = books(name);
m.ok(
"books.add_project",
json!({"name": "Harbor app", "kind": "on_spec", "started_on": "2026-04-01"}),
);
m.ok(
"books.add_project",
json!({"name": "Zeta site", "kind": "client"}),
);
m.tagged(
"2025-12-10",
"Zeta deposit",
&[
("1000", "debit", "500.00", None),
("4000", "credit", "500.00", Some("Zeta site")),
],
);
let proto = m.tagged(
"2026-04-02",
"Prototype",
&[
("6000", "debit", "1400.00", Some("Harbor app")),
("1000", "credit", "1400.00", None),
],
);
m.tagged(
"2026-04-20",
"Hosting",
&[
("6000", "debit", "60.00", Some("Harbor app")),
("1000", "credit", "60.00", None),
],
);
m.tagged(
"2026-04-21",
"Devices",
&[
("1200", "debit", "900.00", Some("Harbor app")),
("1000", "credit", "900.00", None),
],
);
m.tagged(
"2026-06-15",
"Harbor repays",
&[
("1000", "debit", "1000.00", None),
("4000", "credit", "1000.00", Some("Harbor app")),
],
);
m.tagged(
"2026-03-02",
"Zeta milestone",
&[
("1000", "debit", "4500.00", None),
("4000", "credit", "4500.00", Some("Zeta site")),
],
);
let zc = m.tagged(
"2026-03-09",
"Zeta contractor",
&[
("6000", "debit", "1800.00", Some("Zeta site")),
("1000", "credit", "1800.00", None),
],
);
m.tagged(
"2026-03-31",
"Rent",
&[
("6200", "debit", "1500.00", None),
("1000", "credit", "1500.00", None),
],
);
(m, proto, zc)
}
// ------------------------------------------------- add, update
#[test]
fn add_project_then_project_returns_its_facts_and_figures() {
let mut m = books("add");
let party = m.ok("books.add_party", json!({"name": "Harbor Co"}))["party_id"]
.as_u64()
.unwrap();
let p = m.ok(
"books.add_project",
json!({"name": " Harbor app ", "description": "An app", "kind": "on_spec",
"party": "harbor co", "started_on": "2026-04-01",
"links": ["https://harbor.example"], "notes": "pitched in March"}),
);
assert_eq!(p["name"], "Harbor app");
assert_eq!(p["status"], "active");
assert_eq!(p["kind"], "on_spec");
assert_eq!(p["party"]["party_id"], party);
assert_eq!(p["party"]["label"], "Harbor Co");
assert_eq!(p["links"], json!(["https://harbor.example"]));
assert_eq!(m.count("class.added"), 1);
assert_eq!(m.count("project.set"), 1);
let d = m.read_only(
"books.project",
json!({"project": "harbor app", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert_eq!(d["project"]["notes"], "pitched in March");
assert_eq!(d["period"]["net"], "0.00");
assert_eq!(d["period"]["by_month"].as_array().unwrap().len(), 12);
assert_eq!(d["to_date"]["unrecovered_cost"], "0.00");
// The class tools see it.
let classes = m.read_only("books.list_classes", json!({}));
assert_eq!(classes["classes"][0]["name"], "Harbor app");
}
#[test]
fn a_name_only_project_journals_only_class_added() {
let mut m = books("name-only");
let p = m.ok("books.add_project", json!({"name": "Dock"}));
assert_eq!(p["kind"], Value::Null);
assert_eq!(m.count("class.added"), 1);
assert_eq!(m.count("project.set"), 0);
// add_class refuses a taken name now.
let why = m.refuse("books.add_class", json!({"name": "DOCK"}));
assert!(why.contains("already exists"), "{why}");
}
#[test]
fn update_project_checks_every_field_before_writing() {
let mut m = books("update-checks");
m.ok("books.add_project", json!({"name": "Harbor"}));
m.ok("books.add_project", json!({"name": "Dock"}));
for (args, needle) in [
(json!({"project": "Harbor", "name": " "}), "blank"),
(
json!({"project": "Harbor", "name": "dock"}),
"already exists",
),
(json!({"project": "Harbor", "status": "done"}), "status"),
(json!({"project": "Harbor", "kind": "grant"}), "kind"),
(
json!({"project": "Harbor", "party": "Nobody"}),
"unknown party",
),
(
json!({"project": "Harbor", "ended_on": "2026-09-30"}),
"ended_on",
),
(
json!({"project": "Harbor", "status": "finished", "started_on": "2026-10-01", "ended_on": "2026-09-30"}),
"before",
),
(
json!({"project": "Harbor", "notes": "SSN 123-45-6789"}),
"TIN",
),
(
json!({"project": "Nowhere", "status": "paused"}),
"unknown project",
),
(json!({"project": "Harbor"}), "at least one"),
] {
let why = m.refuse("books.update_project", args.clone());
assert!(why.contains(needle), "{args}: {why}");
}
assert_eq!(m.count("project.set"), 0);
let p = m.ok(
"books.update_project",
json!({"project": "Harbor", "status": "finished", "ended_on": "2026-09-30", "name": "Harbor app"}),
);
assert_eq!(p["status"], "finished");
assert_eq!(p["name"], "Harbor app");
// "" clears.
let p = m.ok(
"books.update_project",
json!({"project": "Harbor app", "ended_on": "", "status": "active", "kind": "client"}),
);
assert_eq!(p["ended_on"], Value::Null);
let p = m.ok(
"books.update_project",
json!({"project": "Harbor app", "kind": ""}),
);
assert_eq!(p["kind"], Value::Null);
}
#[test]
fn update_project_with_nothing_new_journals_nothing() {
let mut m = books("update-same");
m.ok(
"books.add_project",
json!({"name": "Harbor", "kind": "client"}),
);
let before = m.journal();
m.ok(
"books.update_project",
json!({"project": "Harbor", "kind": "client"}),
);
m.ok(
"books.update_project",
json!({"project": "harbor", "name": "Harbor"}),
);
assert_eq!(m.journal(), before);
}
// ------------------------------------------------- figures
#[test]
fn project_figures_match_profit_and_loss_by_class() {
let (mut m, _, _) = studio("by-class");
let period = json!({"start": "2026-01-01", "end": "2026-12-31"});
let by_class = m.read_only("books.profit_and_loss_by_class", period.clone());
for name in ["Harbor app", "Zeta site"] {
let mut a = period.clone();
a["project"] = json!(name);
let p = m.read_only("books.project", a);
let row = by_class["classes"]
.as_array()
.unwrap()
.iter()
.find(|r| r["class"] == name)
.unwrap();
assert_eq!(p["period"]["net"], row["net_income"], "{name}");
assert_eq!(p["period"]["revenue"], row["revenue"], "{name}");
assert_eq!(p["period"]["expenses"], row["expenses"], "{name}");
}
let z = m.read_only(
"books.project",
json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}),
);
let accounts: Vec<(&str, &str, &str)> = z["period"]["by_account"]
.as_array()
.unwrap()
.iter()
.map(|a| {
(
a["code"].as_str().unwrap(),
a["type"].as_str().unwrap(),
a["amount"].as_str().unwrap(),
)
})
.collect();
assert_eq!(
accounts,
[
("4000", "revenue", "4500.00"),
("6000", "expense", "1800.00")
]
);
let march = &z["period"]["by_month"][2];
assert_eq!(march["month"], "2026-03");
assert_eq!(march["net"], "2700.00");
}
#[test]
fn project_to_date_counts_lines_before_the_period() {
let (mut m, _, _) = studio("to-date");
let z = m.read_only(
"books.project",
json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert_eq!(z["period"]["revenue"], "4500.00");
assert_eq!(z["to_date"]["revenue"], "5000.00");
assert_eq!(z["to_date"]["through"], "2026-12-31");
let list = m.read_only("books.projects", json!({}));
let zeta = list["projects"]
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "Zeta site")
.unwrap()
.clone();
assert_eq!(zeta["to_date"]["net"], "3200.00");
}
/// The snapshot's figures (rules.md B-37 item 7), as the viewer shows
/// them: net is the contribution before overhead, margin_pct its share
/// of revenue, to_date.by_account the direct costs by category, and each
/// month's net_to_date the running net, the last being to_date's.
#[test]
fn project_snapshot_margin_by_account_and_running_net() {
let (mut m, _, _) = studio("snapshot");
let z = m.read_only(
"books.project",
json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}),
);
// The period: 4,500 earned, 1,800 spent: 2,700, 60.0% of revenue.
assert_eq!(z["period"]["net"], "2700.00");
assert_eq!(z["period"]["margin_pct"], "60.0");
// To date, the December deposit too: 3,200 of 5,000, 64.0%.
assert_eq!(z["to_date"]["net"], "3200.00");
assert_eq!(z["to_date"]["margin_pct"], "64.0");
let accounts: Vec<(&str, &str, &str)> = z["to_date"]["by_account"]
.as_array()
.unwrap()
.iter()
.map(|a| {
(
a["code"].as_str().unwrap(),
a["type"].as_str().unwrap(),
a["amount"].as_str().unwrap(),
)
})
.collect();
assert_eq!(
accounts,
[
("4000", "revenue", "5000.00"),
("6000", "expense", "1800.00")
]
);
let running: Vec<&str> = z["period"]["by_month"]
.as_array()
.unwrap()
.iter()
.map(|m| m["net_to_date"].as_str().unwrap())
.collect();
assert_eq!(running.len(), 12);
// January opens on the December deposit; March adds the milestone
// less the contractor; the year ends on the net to date.
assert_eq!(&running[..3], ["500.00", "500.00", "3200.00"]);
assert_eq!(running[11], z["to_date"]["net"].as_str().unwrap());
// On spec, repaid in part: a loss for the year, margin below zero.
let h = m.read_only(
"books.project",
json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert_eq!(h["period"]["net"], "-460.00");
assert_eq!(h["period"]["margin_pct"], "-46.0");
// No revenue yet: no margin.
let h = m.read_only(
"books.project",
json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-04-30"}),
);
assert_eq!(h["to_date"]["margin_pct"], Value::Null);
assert_eq!(h["period"]["margin_pct"], Value::Null);
assert_eq!(
h["to_date"]["by_account"][0]["amount"], "1460.00",
"the devices, an asset, count nothing"
);
// Nothing tagged: books.project still carries to_date.by_account,
// empty, and no margin.
m.ok(
"books.add_project",
json!({"name": "Quiet one", "kind": "internal"}),
);
let q = m.read_only("books.project", json!({"project": "Quiet one"}));
assert_eq!(q["to_date"]["by_account"], json!([]), "{q}");
assert_eq!(q["period"]["by_account"], json!([]), "{q}");
assert_eq!(q["to_date"]["margin_pct"], Value::Null);
// The list carries the margin, not the accounts.
let list = m.read_only("books.projects", json!({}));
for p in list["projects"].as_array().unwrap() {
assert!(p["to_date"].get("margin_pct").is_some(), "{p}");
assert!(p["to_date"].get("by_account").is_none(), "{p}");
}
}
#[test]
fn on_spec_project_shows_unrecovered_cost_and_others_null() {
let (mut m, _, _) = studio("unrecovered");
let h = m.read_only(
"books.project",
json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-04-30"}),
);
assert_eq!(
h["to_date"]["expenses"], "1460.00",
"the devices count nothing"
);
assert_eq!(h["to_date"]["unrecovered_cost"], "1460.00");
let h = m.read_only(
"books.project",
json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert_eq!(h["to_date"]["unrecovered_cost"], "460.00");
let z = m.read_only(
"books.project",
json!({"project": "Zeta site", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert_eq!(z["to_date"]["unrecovered_cost"], Value::Null);
// Nothing receivable: the balance sheet has no receivable for it.
let names: Vec<String> = m.read_only("books.projects", json!({"status": "active"}))["projects"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names, ["Harbor app", "Zeta site"]);
assert!(
m.read_only("books.projects", json!({"status": "paused"}))["projects"]
.as_array()
.unwrap()
.is_empty()
);
m.refuse("books.projects", json!({"status": "done"}));
}
#[test]
fn project_lists_entries_documents_decisions_and_party() {
let (mut m, proto, _) = studio("detail");
m.ok("books.add_party", json!({"name": "Harbor Co"}));
m.ok(
"books.update_project",
json!({"project": "Harbor app", "party": "Harbor Co"}),
);
let doc = match m.doc(
b"%PDF spec",
json!([{"kind": "project", "id": "Harbor app"}]),
) {
Out::Ok(v) => v["attachment_id"].as_u64().unwrap(),
o => panic!("{o:?}"),
};
m.ok(
"books.record_decision",
json!({"subject": "Harbor", "decision_md": "Build it on spec.",
"decided_by": ["Tobin Harlow"], "decided_on": "2026-04-01",
"projects": ["Harbor app"]}),
);
let h = m.read_only(
"books.project",
json!({"project": "Harbor app", "start": "2026-01-01", "end": "2026-12-31"}),
);
assert_eq!(h["project"]["party"]["label"], "Harbor Co");
let entries = h["entries"].as_array().unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[0]["description"], "Harbor repays", "newest first");
let p = entries.iter().find(|e| e["entry_id"] == proto).unwrap();
assert_eq!(p["project_amount"], "-1400.00");
assert_eq!(p["total"], "1400.00");
let devices = entries
.iter()
.find(|e| e["description"] == "Devices")
.unwrap();
assert_eq!(devices["project_amount"], "0.00");
assert_eq!(h["documents"][0]["attachment_id"], doc);
assert_eq!(h["decisions"][0]["projects"][0]["name"], "Harbor app");
}
// ------------------------------------------------- links
#[test]
fn link_attachment_to_a_project_by_name_and_id() {
let mut m = books("link");
let id = m.ok("books.add_project", json!({"name": "Harbor"}))["id"]
.as_u64()
.unwrap();
let a = match m.doc(b"%PDF a", json!([])) {
Out::Ok(v) => v["attachment_id"].as_u64().unwrap(),
o => panic!("{o:?}"),
};
let out = m.ok(
"books.link_attachment",
json!({"attachment_id": a, "subject": {"kind": "project", "id": "harbor"}}),
);
assert!(out.to_string().contains("project"), "{out}");
m.refuse(
"books.link_attachment",
json!({"attachment_id": a, "subject": {"kind": "project", "id": id}}),
);
let files = m.read_only(
"books.attachments",
json!({"subject": {"kind": "project", "id": id}}),
);
assert_eq!(files["subject"]["label"], format!("project {id} Harbor"));
assert_eq!(files["attachments"][0]["attachment_id"], a);
m.refuse(
"books.link_attachment",
json!({"attachment_id": a, "subject": {"kind": "project", "id": 99}}),
);
m.ok(
"books.unlink_attachment",
json!({"attachment_id": a, "subject": {"kind": "project", "id": id}, "reason": "wrong"}),
);
}
#[test]
fn file_document_with_a_bad_project_files_nothing() {
let mut m = books("bad-link");
m.ok("books.add_project", json!({"name": "Harbor"}));
let out = m.doc(b"%PDF b", json!([{"kind": "project", "id": "Nowhere"}]));
assert!(out.refused());
assert_eq!(m.count("document.filed"), 0);
}
#[test]
fn record_decision_names_projects_and_decisions_filter_by_project() {
let mut m = books("decisions");
m.ok("books.add_project", json!({"name": "Harbor"}));
m.ok("books.add_project", json!({"name": "Dock"}));
let base =
json!({"decision_md": "x", "decided_by": ["Tobin Harlow"], "decided_on": "2026-04-01"});
let with = |subject: &str, projects: Value| {
let mut a = base.clone();
a["subject"] = json!(subject);
a["projects"] = projects;
a
};
m.ok("books.record_decision", with("One", json!(["Harbor"])));
m.ok("books.record_decision", with("Two", json!([])));
m.refuse("books.record_decision", with("Bad", json!(["Nowhere"])));
m.refuse(
"books.record_decision",
with("Twice", json!(["Harbor", "harbor"])),
);
let all = m.read_only("books.decisions", json!({}));
assert_eq!(all["decisions"].as_array().unwrap().len(), 2);
assert!(
all["decisions"][1].get("projects").is_none(),
"none: left out"
);
let harbor = m.read_only("books.decisions", json!({"project": "Harbor"}));
assert_eq!(harbor["decisions"].as_array().unwrap().len(), 1);
assert_eq!(harbor["decisions"][0]["subject"], "One");
assert!(
m.read_only("books.decisions", json!({"project": "Dock"}))["decisions"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn party_lists_its_projects() {
let mut m = books("party");
m.ok("books.add_party", json!({"name": "Riley Chen"}));
m.ok(
"books.add_project",
json!({"name": "Zeta", "party": "Riley Chen"}),
);
m.ok(
"books.add_project",
json!({"name": "alpha", "party": "Riley Chen"}),
);
m.ok("books.add_project", json!({"name": "Other"}));
let p = m.read_only("books.party", json!({"party": "Riley Chen"}));
let names: Vec<&str> = p["projects"]
.as_array()
.unwrap()
.iter()
.map(|x| x["name"].as_str().unwrap())
.collect();
assert_eq!(names, ["alpha", "Zeta"]);
}
#[test]
fn start_lists_active_and_paused_projects() {
let mut m = books("start");
m.ok(
"books.add_project",
json!({"name": "Zeta", "kind": "client"}),
);
m.ok(
"books.add_project",
json!({"name": "alpha", "status": "paused"}),
);
m.ok(
"books.add_project",
json!({"name": "Done", "status": "finished", "ended_on": "2026-01-31"}),
);
let s = m.read_only("books.start", json!({}));
let got: Vec<(String, String)> = s["projects"]
.as_array()
.unwrap()
.iter()
.map(|p| {
(
p["name"].as_str().unwrap().to_string(),
p["status"].as_str().unwrap().to_string(),
)
})
.collect();
assert_eq!(
got,
[
("alpha".to_string(), "paused".to_string()),
("Zeta".into(), "active".into())
]
);
assert_eq!(s["projects"][1]["kind"], "client");
}
#[test]
fn show_project_sends_its_target() {
let mut m = books("show");
let id = m.ok("books.add_project", json!({"name": "Harbor"}))["id"]
.as_u64()
.unwrap();
let s = m.read_only("books.show", json!({"project": "harbor"}));
assert!(s["shown"].as_str().unwrap().contains("Harbor"), "{s}");
let cmd = ledger_store::ops::read(&m.data).expect("request written");
assert_eq!(
cmd.target,
ledger_store::ops::ViewerTarget::Project {
class_id: id as u32
}
);
m.read_only("books.show", json!({"page": "projects"}));
m.refuse("books.show", json!({"project": "Nowhere"}));
m.refuse(
"books.show",
json!({"project": "Harbor", "page": "projects"}),
);
}
#[test]
fn project_writes_wait_for_the_format_upgrade() {
let mut m = books_in("upgrade", true);
// A name alone: a plain class, accepted.
m.ok("books.add_project", json!({"name": "Harbor"}));
let a = match m.doc(b"%PDF c", json!([])) {
Out::Ok(v) => v["attachment_id"].as_u64().unwrap(),
o => panic!("{o:?}"),
};
let head = m.journal();
for (tool, args) in [
(
"books.add_project",
json!({"name": "Dock", "kind": "client"}),
),
(
"books.update_project",
json!({"project": "Harbor", "kind": "client"}),
),
(
"books.link_attachment",
json!({"attachment_id": a, "subject": {"kind": "project", "id": "Harbor"}}),
),
(
"books.record_decision",
json!({"subject": "s", "decision_md": "x", "decided_by": ["Tobin Harlow"],
"decided_on": "2026-04-01", "projects": ["Harbor"]}),
),
] {
let why = m.refuse(tool, args);
assert!(
why.starts_with("format_upgrade_pending:") && why.contains("projects"),
"{tool}: {why}"
);
}
// Filing with a link to a project is refused before the file is filed.
let out = m.doc(b"%PDF d", json!([{"kind": "project", "id": "Harbor"}]));
assert!(out.refused());
assert!(out.err_text().contains("projects"), "{out:?}");
assert_eq!(m.journal(), head, "nothing journaled");
}
#[test]
fn add_line_notes_a_finished_project() {
let mut m = books("late");
m.ok(
"books.add_project",
json!({"name": "Harbor", "status": "finished", "ended_on": "2026-09-30"}),
);
m.ok("books.add_project", json!({"name": "Dock"}));
let id = m.ok(
"books.draft_entry",
json!({"date": "2026-10-05", "description": "late cost", "no_party_reason": "test"}),
)["entry_id"]
.as_u64()
.unwrap();
let out = m.ok(
"books.add_line",
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": "10.00", "class": "Harbor"}),
);
let note = out["note"].as_str().unwrap();
assert!(
note.contains("Harbor is finished (ended 2026-09-30)"),
"{note}"
);
let out = m.ok(
"books.add_line",
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": "10.00", "class": "Dock"}),
);
assert!(out.get("note").is_none());
}
#[test]
fn record_payout_notes_a_finished_project() {
let mut m = books("late-payout");
m.ok(
"books.add_project",
json!({"name": "Harbor", "status": "abandoned", "ended_on": "2026-09-30"}),
);
m.ok("books.add_project", json!({"name": "Dock"}));
let out = m.ok(
"books.record_payout",
json!({"date": "2026-10-05", "description": "App Store payout",
"deposit_account": "1000", "deposit": "70.00",
"revenue": [{"account": "4000", "amount": "60.00", "class": "Harbor"},
{"account": "4000", "amount": "40.00", "class": "Harbor"},
{"account": "4000", "amount": "10.00", "class": "Dock"}],
"fees": [{"account": "6000", "amount": "40.00", "class": "Harbor"}]}),
);
let notes = out["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "one note per project: {notes:?}");
assert!(
notes[0]
.as_str()
.unwrap()
.contains("Harbor is abandoned (ended 2026-09-30)"),
"{notes:?}"
);
// No project over: no notes.
let out = m.ok(
"books.record_payout",
json!({"date": "2026-10-06", "description": "App Store payout",
"deposit_account": "1000", "deposit": "10.00",
"revenue": [{"account": "4000", "amount": "10.00", "class": "Dock"}]}),
);
assert!(out.get("notes").is_none(), "{out}");
}
// ------------------------------------------------- names
#[test]
fn a_name_resolves_as_the_duplicate_check_compares() {
let mut m = books("fold");
m.ok("books.add_project", json!({"name": "Éclair"}));
let why = m.refuse("books.add_project", json!({"name": "éCLAIR"}));
assert!(why.contains("already exists"), "{why}");
for name in ["éclair", "ÉCLAIR", " Éclair ", " 1 "] {
let p = m.read_only("books.project", json!({"project": name}));
assert_eq!(p["project"]["name"], "Éclair", "{name:?}");
}
let p = m.ok(
"books.update_project",
json!({"project": " éclair ", "status": "paused"}),
);
assert_eq!(
(p["name"].clone(), p["status"].clone()),
(json!("Éclair"), json!("paused"))
);
let why = m.refuse("books.add_project", json!({"name": "42"}));
assert!(why.contains("digits only"), "{why}");
}
/// Books an earlier version wrote with these class names (replay never
/// checks names): no uniqueness, digits allowed.
fn old_classes(name: &str, names: &[&str]) -> Mcp {
let dir = test_dir(name);
let books = dir.books_data();
drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap());
let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap();
for (i, n) in names.iter().enumerate() {
w.append("class.added", json!({"id": i + 1, "name": n}))
.unwrap();
}
drop(w);
let toml = books_toml(&dir, "Spec Co", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.books_dir = Some(dir);
m
}
#[test]
fn old_books_duplicates_take_fact_only_changes() {
let mut m = old_classes("old-dup", &["Alpha", "alpha", "Harbor"]);
// The lowest id answers the shared name; each changes by id.
assert_eq!(
m.read_only("books.project", json!({"project": "ALPHA"}))["project"]["id"],
1
);
for id in ["1", "2"] {
let p = m.ok(
"books.update_project",
json!({"project": id, "status": "paused"}),
);
assert_eq!(p["status"], "paused");
}
let why = m.refuse(
"books.update_project",
json!({"project": "2", "name": "harbor"}),
);
assert!(why.contains("already exists"), "{why}");
}
#[test]
fn a_digit_name_that_shadows_an_id_says_what_to_pass() {
let mut m = old_classes("old-digits", &["Éclair", "Dock", "1"]);
let why = m.refuse("books.project", json!({"project": "1"}));
assert!(
why.contains("pass 3 for project 3") && why.contains("\"Éclair\" (project 1's name)"),
"{why}"
);
assert_eq!(
m.read_only("books.project", json!({"project": "3"}))["project"]["name"],
"1"
);
assert_eq!(
m.read_only("books.project", json!({"project": "éclair"}))["project"]["id"],
1
);
// A JSON number in a subject is an id, never a name.
let a = match m.doc(b"%PDF digits", json!([])) {
Out::Ok(v) => v["attachment_id"].as_u64().unwrap(),
o => panic!("{o:?}"),
};
m.ok(
"books.link_attachment",
json!({"attachment_id": a, "subject": {"kind": "project", "id": 1}}),
);
let files = m.read_only(
"books.attachments",
json!({"subject": {"kind": "project", "id": 1}}),
);
assert_eq!(files["subject"]["label"], "project 1 Éclair");
}
// ------------------------------------------------- invoices and bills
/// [`books`] as the live books stand until their owner consents again:
/// this version's format without `document_line_projects` (they declared
/// `projects`).
fn books_before_document_projects(name: &str) -> Mcp {
let dir = test_dir(name);
let mut format = ledger_store::format::current();
format
.features
.retain(|f| f != ledger_store::format::DOCUMENT_LINE_PROJECTS);
ledger_store::Store::create_in_format(&dir.books_data(), ledger_core::Currency::USD, &format)
.unwrap();
let toml = books_toml(&dir, "Spec Co", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.books_dir = Some(dir);
m
}
impl Mcp {
/// Each line of the entry as books.get_entry shows it: (code, side,
/// amount, project).
fn entry_lines(&mut self, entry: u64) -> Vec<(String, String, String, Option<String>)> {
self.read_only("books.get_entry", json!({"entry_id": entry}))["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| {
(
l["code"].as_str().unwrap().to_string(),
l["side"].as_str().unwrap().to_string(),
l["amount"].as_str().unwrap().to_string(),
l["project"].as_str().map(str::to_string),
)
})
.collect()
}
/// Each line's `class_id` as books.get_entry shows it (absent: None).
fn entry_class_ids(&mut self, entry: u64) -> Vec<Option<u64>> {
let lines = self.read_only("books.get_entry", json!({"entry_id": entry}))["lines"].clone();
lines
.as_array()
.unwrap()
.iter()
.map(|l| {
assert!(
l.get("class_id").is_none_or(|c| !c.is_null()),
"an untagged line omits class_id: {l}"
);
l["class_id"].as_u64()
})
.collect()
}
/// The projects handbook section served as a resource.
fn handbook_resource(&mut self) -> String {
let r = self.rpc(
"resources/read",
json!({"uri": "books://handbook/projects"}),
);
r["result"]["contents"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("{r}"))
.to_string()
}
}
fn l4(
code: &str,
side: &str,
amount: &str,
project: Option<&str>,
) -> (String, String, String, Option<String>) {
(
code.into(),
side.into(),
amount.into(),
project.map(str::to_string),
)
}
/// rules.md B-37 item 8: each invoice and bill line takes a project (by
/// name or id); the entry's revenue or expense side is one line per
/// project, the untagged lines' as one, the receivable or payable line
/// and the payments untagged; books.project, profit_and_loss_by_class,
/// the 1099 worksheet and the party's totals all read them.
#[test]
fn invoice_and_bill_lines_count_toward_their_projects() {
let mut m = books("doc-lines");
let zeta = m.ok(
"books.add_project",
json!({"name": "Zeta site", "kind": "client"}),
)["id"]
.as_u64()
.unwrap();
let harbor = m.ok(
"books.add_project",
json!({"name": "Harbor app", "kind": "client"}),
)["id"]
.as_u64()
.unwrap();
let cedar = m.ok("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
let dana = m.ok("books.add_vendor", json!({"name": "Dana Illustration"}));
let (dana_vendor, dana_party) = (dana["id"].clone(), dana["party_id"].clone());
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Dana Illustration", "form_1099": "nec", "decided_by": "Tobin Harlow", "decided_on": "2026-01-01"}),
);
let inv = m.ok(
"books.create_invoice",
json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "Design", "amount": "3000.00", "class": "Zeta site"},
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
{"description": "Rush fee", "amount": "100.00"},
{"description": "Revisions", "amount": "500.00", "class": " zeta SITE "},
], "confirmed": true}),
);
assert_eq!(inv["total"], "3800.00");
assert!(inv.get("notes").is_none(), "{inv}");
let inv_entry = inv["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(inv_entry),
vec![
l4("1200", "debit", "3800.00", None),
l4("4000", "credit", "3500.00", Some("Zeta site")),
l4("4000", "credit", "200.00", Some("Harbor app")),
l4("4000", "credit", "100.00", None),
]
);
// books.get_entry's class_id is the project's id on a tagged line and
// absent on the others.
assert_eq!(
m.entry_class_ids(inv_entry),
vec![None, Some(zeta), Some(harbor), None]
);
let dana_invoice = m.wire.upload(
b"Dana's invoice",
"dana-invoice.pdf",
"application/pdf",
"receipt",
);
let bill = m.ok(
"books.create_bill",
json!({"vendor": (dana_vendor).to_string(), "date": "2026-03-09", "due_date": "2026-04-08",
"vendor_ref": "DANA-77",
"lines": [
{"description": "Illustration", "amount": "1200.00", "account": "6000", "class": "Zeta site"},
{"description": "Postage", "amount": "50.00", "account": "6000"},
], "upload_id": dana_invoice, "confirmed": true}),
);
let bill_entry = bill["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(bill_entry),
vec![
l4("6000", "debit", "1200.00", Some("Zeta site")),
l4("6000", "debit", "50.00", None),
l4("2000", "credit", "1250.00", None),
]
);
assert_eq!(m.entry_class_ids(bill_entry), vec![Some(zeta), None, None]);
// Payments are balance-sheet only: no project.
let got = m.ok(
"books.receive_payment",
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-03-20", "amount": "3800.00", "deposit_to": "1000"}),
);
let paid = m.ok(
"books.pay_bill",
json!({"confirmed": true, "bill": bill["id"], "date": "2026-03-30", "amount": "1250.00", "paid_from": "1000"}),
);
for e in [&got["entry_id"], &paid["entry_id"]] {
let e = e.as_u64().unwrap();
assert!(m.entry_lines(e).iter().all(|l| l.3.is_none()), "entry {e}");
}
// The project's figures, and the class P&L, to the cent.
let year = json!({"start": "2026-01-01", "end": "2026-12-31"});
let mut a = year.clone();
a["project"] = json!("Zeta site");
let z = m.read_only("books.project", a);
assert_eq!(
(
&z["period"]["revenue"],
&z["period"]["expenses"],
&z["period"]["net"]
),
(&json!("3500.00"), &json!("1200.00"), &json!("2300.00"))
);
assert_eq!(z["to_date"]["net"], "2300.00");
assert_eq!(z["period"]["by_month"][2]["net"], "2300.00");
let entries: Vec<u64> = z["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
assert_eq!(entries, vec![bill_entry, inv_entry]);
let by_class = m.read_only("books.profit_and_loss_by_class", year.clone());
let row = |name: &str| {
by_class["classes"]
.as_array()
.unwrap()
.iter()
.find(|r| r["class"] == name)
.unwrap_or_else(|| panic!("{name}: {by_class}"))
.clone()
};
assert_eq!(row("Zeta site")["net_income"], "2300.00");
assert_eq!(row("Harbor app")["net_income"], "200.00");
// The 1099 worksheet and the party's totals follow the money: the
// bill's payment, 1,250.00, whatever its lines' projects.
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
let dana_line = w["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == "Dana Illustration")
.unwrap()
.clone();
assert_eq!(dana_line["paid"], "1250.00");
let p = m.read_only(
"books.party",
json!({"party": dana_party.to_string(), "year": 2026}),
);
assert_eq!(p["totals"]["paid"], "1250.00", "{p}");
assert_eq!(p["totals"]["amount_1099"], "1250.00", "{p}");
}
#[test]
fn a_document_naming_an_unknown_project_is_refused_whole() {
let mut m = books("doc-unknown");
m.ok("books.add_project", json!({"name": "Zeta site"}));
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
let why = m.refuse(
tool,
json!({"customer": (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00", "class": "Zeta site"},
{"description": "b", "amount": "10.00", "class": "Nowhere"},
]}),
);
assert!(why.contains("unknown project \"Nowhere\""), "{tool}: {why}");
}
}
#[test]
fn a_document_for_a_finished_project_says_so() {
let mut m = books("doc-late");
m.ok(
"books.add_project",
json!({"name": "Harbor", "status": "finished", "ended_on": "2026-09-30"}),
);
m.ok(
"books.add_project",
json!({"name": "Dock", "status": "abandoned"}),
);
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let out = m.ok(
"books.create_invoice",
json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Final", "amount": "10.00", "class": "Harbor"},
{"description": "Again", "amount": "10.00", "class": "harbor"},
{"description": "Old", "amount": "10.00", "class": "Dock"},
], "confirmed": true}),
);
let notes: Vec<&str> = out["notes"]
.as_array()
.unwrap()
.iter()
.map(|n| n.as_str().unwrap())
.collect();
assert_eq!(notes.len(), 2, "{notes:?}");
assert!(
notes[0].contains("Harbor is finished (ended 2026-09-30)"),
"{notes:?}"
);
assert!(notes[1].contains("Dock is abandoned"), "{notes:?}");
// Tagged anyway.
let e = out["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(e)[1],
l4("4000", "credit", "20.00", Some("Harbor"))
);
}
/// B-37 items 4 and 8: an invoice line naming an on-spec project is taken
/// and its result asks whether there is now an agreement (a hoped-for
/// payment is no receivable); a bill's cost for one is an expense like any
/// other, so a bill gets no such note.
#[test]
fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
let mut m = books("doc-on-spec");
m.ok(
"books.add_project",
json!({"name": "Kestrel", "kind": "on_spec"}),
);
m.ok(
"books.add_project",
json!({"name": "Zeta", "kind": "client"}),
);
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
let out = m.ok(
"books.create_invoice",
json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Build", "amount": "10.00", "class": "Kestrel"},
{"description": "More", "amount": "5.00", "class": "kestrel"},
{"description": "Site", "amount": "7.00", "class": "Zeta"},
], "confirmed": true}),
);
let notes = out["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "{out}");
let note = notes[0].as_str().unwrap();
assert!(
note.contains("project Kestrel is on spec") && note.contains("is there now an agreement"),
"{note}"
);
let e = out["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(e)[1],
l4("4000", "credit", "15.00", Some("Kestrel"))
);
let icons_invoice = m.wire.upload(
b"icons invoice",
"icons-invoice.pdf",
"application/pdf",
"receipt",
);
let bill = m.ok(
"books.create_bill",
json!({"vendor": (v).to_string(), "date": "2026-10-05", "due_date": "2026-11-04",
"vendor_ref": "V-512",
"lines": [
{"description": "Icons", "amount": "10.00", "account": "6000", "class": "Kestrel"},
], "upload_id": icons_invoice, "confirmed": true}),
);
assert!(bill.get("notes").is_none(), "{bill}");
}
/// D-39: books that declared `projects` but not
/// `document_line_projects` refuse a document naming a project, journaling
/// nothing, and take one naming none; the handbook tells them so.
#[test]
fn books_without_the_feature_refuse_a_tagged_document() {
let mut m = books_before_document_projects("doc-gate");
m.ok(
"books.add_project",
json!({"name": "Zeta site", "kind": "client"}),
);
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
let head = m.journal();
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
let why = m.refuse(
tool,
json!({"customer": (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00", "class": "Zeta site"},
]}),
);
assert!(
why.starts_with("format_upgrade_pending:") && why.contains("document_line_projects"),
"{tool}: {why}"
);
}
assert_eq!(m.journal(), head, "nothing journaled");
m.ok(
"books.create_invoice",
json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00"},
], "confirmed": true}),
);
let text = m.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(
text.contains("takes no project in these books until their format upgrade"),
"{text}"
);
// The same section read as a resource tells the same books the same.
let served = m.handbook_resource();
assert!(
served.contains("takes no project in these books until their format upgrade"),
"{served}"
);
assert!(!served.contains("take class on each line"), "{served}");
let mut fresh = books("doc-gate-fresh");
let served = fresh.handbook_resource();
assert!(served.contains("take class on each line"), "{served}");
assert!(!served.contains("takes no project"), "{served}");
let text = fresh.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(text.contains("take class on each line"), "{text}");
assert!(!text.contains("takes no project"), "{text}");
}