bookspec is a specification first: every rule normative and sourced,
every rule demonstrated by concrete examples, and any accounting engine
checked against them through one interface (D-34). This moves the
examples' schema out of the test that happened to parse them and makes
the interface explicit.
- `bookspec/checker` (its own workspace, inside this repository until
the harness is stable): `Example`, `Setup`, `Event`, `Expect`, `Query`,
`Refusal` and the TOML parser, every structure `deny_unknown_fields`,
with `Example::from_toml` checking the cross-field shape (closed lists,
exact decimal amounts, dates, one event kind, `reverses` counting from
1, the shares summing to 1, commands accepted or refused and not both).
- `Engine` (`load`, `apply`, `query`, `claims`) and `Refusal { rule,
reason }`; the refusal's rule is the bookspec identifier, so a refusal
names the rule it belongs to. `Claims` also carries the option an
engine picks for a CHOICE rule.
- `bookspec/runner` drives any `Engine` through the examples and reports
per rule: pass, fail, not claimed. It compares the answer itself; an
engine is never asked whether it agrees with itself.
- `crates/ledger-core/tests/bookspec.rs` is the wanderbooks adapter over
`Store`: the loader parses through the checker, its own checks are
unchanged, and the new `the_runner_reports_every_claimed_rule_green`
runs all 56 examples through the trait. The IIF appendix is answered
through `ledger_package::export_iif`, the code its own test checks.
- `just check` runs the bookspec workspace's fmt, clippy, rustdoc and
tests too, so an example cannot rot (D-34, Z-17).
The plan's trait has four methods and no path for an example's
`[[changes]]` (a rename, the policy, an asset correction), which post no
entry: `change` is the fifth, required so an engine cannot skip those
examples silently. This is reported to the EM as the one departure.
One money rule holds here too: an answer's figures are exact decimal
strings, never JSON numbers, so no engine can round a cent through a
float.
241 lines
6.6 KiB
Rust
241 lines
6.6 KiB
Rust
//! The runner's own tests, with an engine that only pretends: the runner
|
|
//! must judge the answer, never the engine's belief about it.
|
|
|
|
use bookspec_checker::{
|
|
Area, ChangeSpec, Claims, Engine, Event, Example, Query, Refusal, Rule, RuleId, Setup, Status,
|
|
};
|
|
use bookspec_runner::Outcome;
|
|
|
|
const EXAMPLE: &str = r#"
|
|
id = "fake.example"
|
|
title = "A fake engine answers"
|
|
rule = "The chart's names MUST be the account_names the example expects."
|
|
status = "AUTHORITY"
|
|
sources = ["bookspec/README.md"]
|
|
tags = ["runner"]
|
|
|
|
events = []
|
|
|
|
[setup]
|
|
currency = "USD"
|
|
fiscal_year_start = "01-01"
|
|
basis = "accrual"
|
|
rounding = "half-even"
|
|
|
|
[[setup.accounts]]
|
|
code = "1000"
|
|
name = "Cash"
|
|
type = "asset"
|
|
|
|
[[setup.accounts]]
|
|
code = "3000"
|
|
name = "Capital"
|
|
type = "equity"
|
|
|
|
[[expect]]
|
|
query = "account_names"
|
|
|
|
[expect.values]
|
|
"1000" = "Cash"
|
|
"3000" = "Capital"
|
|
"#;
|
|
|
|
fn example() -> Example {
|
|
Example::from_toml(EXAMPLE).expect("the fake example is well formed")
|
|
}
|
|
|
|
fn catalog() -> Vec<Rule> {
|
|
vec![Rule {
|
|
b: 1,
|
|
id: RuleId::new(Area::Entry, 1),
|
|
area: Area::Entry,
|
|
title: "Every entry is a double entry: debits equal credits",
|
|
status: Some(Status::Authority),
|
|
examples: &["fake.example"],
|
|
}]
|
|
}
|
|
|
|
/// The rules an engine that claims the catalogue's example states.
|
|
fn claim() -> Claims {
|
|
Claims::new([RuleId::new(Area::Entry, 1)])
|
|
}
|
|
|
|
struct Fake {
|
|
claims: Claims,
|
|
names: serde_json::Value,
|
|
accept: bool,
|
|
refuse_query: Option<Refusal>,
|
|
change_ok: bool,
|
|
}
|
|
|
|
impl Default for Fake {
|
|
fn default() -> Self {
|
|
Self {
|
|
claims: claim(),
|
|
names: serde_json::json!({"1000": "Cash", "3000": "Capital"}),
|
|
accept: true,
|
|
refuse_query: None,
|
|
change_ok: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Engine for Fake {
|
|
fn load(&mut self, _setup: &Setup) -> Result<(), Refusal> {
|
|
Ok(())
|
|
}
|
|
|
|
fn apply(&mut self, event: &Event) -> Result<(), Refusal> {
|
|
if let Some(reason) = &event.refused {
|
|
return Err(Refusal::of_reason(reason));
|
|
}
|
|
if self.accept {
|
|
Ok(())
|
|
} else {
|
|
Err(Refusal::of_reason("entry_irreversible"))
|
|
}
|
|
}
|
|
|
|
fn query(&self, q: &Query) -> Result<serde_json::Value, Refusal> {
|
|
if let Some(r) = &self.refuse_query {
|
|
return Err(r.clone());
|
|
}
|
|
match q.name.as_str() {
|
|
"account_names" => Ok(self.names.clone()),
|
|
other => Err(Refusal::new(
|
|
RuleId::new(Area::Entry, 1),
|
|
format!("the fake engine answers no {other}"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn claims(&self) -> Claims {
|
|
self.claims.clone()
|
|
}
|
|
|
|
fn change(&mut self, _change: &ChangeSpec) -> Result<(), Refusal> {
|
|
if self.change_ok {
|
|
Ok(())
|
|
} else {
|
|
Err(Refusal::of_reason("unchanged"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An engine that answers what the example expects passes, and its rule is
|
|
/// claimed and passed.
|
|
#[test]
|
|
fn a_passing_engine_passes() {
|
|
let examples = [example()];
|
|
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake::default());
|
|
assert!(report.passed(), "{:?}", report.failures());
|
|
assert_eq!(
|
|
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
|
Some(&Outcome::Pass)
|
|
);
|
|
}
|
|
|
|
/// The runner judges the answer: a wrong figure fails the rule, whatever
|
|
/// the engine says about itself.
|
|
#[test]
|
|
fn a_wrong_answer_fails() {
|
|
let examples = [example()];
|
|
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
|
names: serde_json::json!({"1000": "Coin", "3000": "Capital"}),
|
|
..Fake::default()
|
|
});
|
|
assert!(!report.passed());
|
|
assert_eq!(
|
|
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
|
Some(&Outcome::Fail)
|
|
);
|
|
assert!(
|
|
report.failures().iter().any(|f| f.contains("1000")),
|
|
"{:?}",
|
|
report.failures()
|
|
);
|
|
}
|
|
|
|
/// An engine that claims nothing is not claimed, not failed; and the
|
|
/// example it does not claim is still run (its answer is compared).
|
|
#[test]
|
|
fn an_engine_that_claims_nothing_is_not_claimed() {
|
|
let examples = [example()];
|
|
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
|
claims: Claims::default(),
|
|
..Fake::default()
|
|
});
|
|
assert!(report.passed(), "{:?}", report.failures());
|
|
assert_eq!(
|
|
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
|
Some(&Outcome::NotClaimed)
|
|
);
|
|
}
|
|
|
|
/// A claim with no example behind it is a failure: a claim needs evidence.
|
|
#[test]
|
|
fn a_claim_without_an_example_fails() {
|
|
let mut catalog = catalog();
|
|
catalog[0].examples = &[];
|
|
let examples = [example()];
|
|
let report = bookspec_runner::run(&examples, &catalog, |_| Fake::default());
|
|
assert!(!report.passed());
|
|
assert_eq!(
|
|
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
|
Some(&Outcome::Fail)
|
|
);
|
|
}
|
|
|
|
/// A query the engine refuses fails, and says which rule it refused under.
|
|
#[test]
|
|
fn a_refused_query_fails() {
|
|
let examples = [example()];
|
|
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
|
refuse_query: Some(Refusal::new(
|
|
RuleId::new(Area::Ledger, 1),
|
|
"not implemented",
|
|
)),
|
|
..Fake::default()
|
|
});
|
|
assert!(!report.passed());
|
|
let failures = report.failures().join("\n");
|
|
assert!(failures.contains("not implemented"), "{failures}");
|
|
assert!(failures.contains("BS-ledger-1"), "{failures}");
|
|
}
|
|
|
|
/// An example no rule lists is a failure: it proves nothing.
|
|
#[test]
|
|
fn an_example_listed_under_no_rule_fails() {
|
|
let examples = [example()];
|
|
let report = bookspec_runner::run(&examples, &[], |_| Fake::default());
|
|
assert!(!report.passed());
|
|
assert!(
|
|
report
|
|
.failures()
|
|
.iter()
|
|
.any(|f| f.contains("listed under no rule")),
|
|
"{:?}",
|
|
report.failures()
|
|
);
|
|
}
|
|
|
|
/// A change the engine refuses fails the example.
|
|
#[test]
|
|
fn a_refused_change_fails() {
|
|
let text = EXAMPLE.replace("events = []\n", "").replace(
|
|
"[[expect]]",
|
|
"[[events]]\ndate = \"2026-01-02\"\ndescription = \"A\"\nlines = [\n { account = \"1000\", side = \"debit\", amount = \"1.00\" },\n { account = \"3000\", side = \"credit\", amount = \"1.00\" },\n]\n\n[[changes]]\nafter = 1\nrequire_tax_category = \"refuse\"\n\n[[expect]]",
|
|
);
|
|
let examples = [Example::from_toml(&text).unwrap()];
|
|
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
|
change_ok: false,
|
|
..Fake::default()
|
|
});
|
|
assert!(!report.passed());
|
|
assert!(
|
|
report.failures().iter().any(|f| f.contains("unchanged")),
|
|
"{:?}",
|
|
report.failures()
|
|
);
|
|
}
|