Files
wanderbooks/crates/ledger-core/tests/settings_units.rs
T
swaits bf91c5a7c2 test(settings): S4.1's Terms canonicalizes a short custom date (P4b S4.2)
The landed Terms::parse reads a date chrono reads and prints it padded, so
"Due 2026-1-5" is one value with "Due 2026-01-05", not a refusal. The
settings key still keeps one spelling; the test now asserts that
canonicalization instead of refusing the short form.
2026-10-27 03:44:24 -06:00

600 lines
21 KiB
Rust

//! The settings registry's small parts, value by value: each accessor, each
//! check boundary, each tax classification, each masked or joined display.
//! `settings.rs`'s own tests cover the invariants; these pin the exact
//! outputs the server and viewer rely on (P1 mutation testing,
//! 2026-10-17).
use chrono::{Datelike, NaiveDate};
use ledger_core::settings::{
Change, Company, Member, PartyPolicy, Settings, Source, check, display,
form_for_classification, holds_printed_tin, key, line_text, long_text, parse_month_day,
parse_share, shape, today_in,
};
use ledger_core::{AccountType, Currency, Ledger, PartyField, Role, TaxForm};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::str::FromStr;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn src() -> Source {
Source {
decided_by: "Owner".into(),
decided_on: date(2026, 10, 17),
citation: None,
note: None,
}
}
/// Settings holding `pairs`, checked then applied as one change.
fn settings(pairs: &[(&str, Value)]) -> Settings {
let changes: Vec<Change> = pairs
.iter()
.map(|(k, v)| Change {
key: (*k).into(),
value: Some(v.clone()),
})
.collect();
let mut s = Settings::default();
let checked = s.check_changes(&changes, &src()).unwrap();
s.apply(&checked, &src(), 1).unwrap();
s
}
fn checked(name: &str, v: Value) -> Result<Value, String> {
check(key(name).unwrap(), &v)
}
#[test]
fn accessors_return_what_was_set() {
let s = settings(&[
("policy.evidence.threshold", json!("0.00")),
("policy.evidence.required_accounts", json!(["1000", "2050"])),
("policy.evidence.exempt_accounts", json!(["6900"])),
("books.cash_accounts", json!(["1000", "1010"])),
("books.financing_accounts", json!(["2500"])),
("books.investing_accounts", json!(["1500", "1510"])),
("books.asset_tag_prefix", json!("FL")),
("policy.parties", json!("off")),
("books.close_sweep", json!(["3100"])),
]);
assert_eq!(s.receipt_threshold(), Some("0.00"));
assert_eq!(s.receipt_required_accounts(), ["1000", "2050"]);
assert_eq!(s.receipt_exempt_accounts(), ["6900"]);
assert_eq!(s.cash_accounts(), ["1000", "1010"]);
assert_eq!(s.financing_accounts(), ["2500"]);
assert_eq!(s.investing_accounts(), ["1500", "1510"]);
assert_eq!(s.asset_tag_prefix(), Some("FL"));
assert_eq!(s.parties(), PartyPolicy::Off);
assert_eq!(s.company("USD").close_sweep, ["3100"]);
let s = settings(&[("policy.parties", json!("require"))]);
assert_eq!(s.parties(), PartyPolicy::Require);
// Unset: nothing, and the default policy.
let s = Settings::default();
assert_eq!(s.receipt_threshold(), None);
assert!(s.cash_accounts().is_empty());
assert_eq!(s.asset_tag_prefix(), None);
assert_eq!(s.parties(), PartyPolicy::Suggest);
}
#[test]
fn the_company_profile_is_its_company_keys_name_first() {
let s = settings(&[
("company.ein", json!("12-3456789")),
("company.name", json!("Acme LLC")),
("company.duns", json!("149976972")),
(
"company.members",
json!([
{"name": "Mo", "share": "50%", "capital_account": "3001"},
{"name": "Tobin", "share": "50%", "capital_account": "3000"},
]),
),
("books.accounting_basis", json!("cash")),
("books.asset_tag_prefix", json!("FL")),
]);
let c = s.company("USD");
let profile: Vec<(&str, &str)> = c
.profile
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
profile,
[
("name", "Acme LLC"),
("base_currency", "USD"),
("ein", "12-3456789"),
("duns", "149976972"),
]
);
assert_eq!(c.name, "Acme LLC");
assert_eq!(c.basis, "Cash");
assert_eq!(c.members.len(), 2);
}
#[test]
fn party_policy_spellings() {
assert_eq!(PartyPolicy::Off.key(), "off");
assert_eq!(PartyPolicy::Suggest.key(), "suggest");
assert_eq!(PartyPolicy::Require.key(), "require");
assert_eq!(PartyPolicy::parse("off"), Some(PartyPolicy::Off));
assert_eq!(PartyPolicy::parse("suggest"), Some(PartyPolicy::Suggest));
assert_eq!(PartyPolicy::parse("require"), Some(PartyPolicy::Require));
assert_eq!(PartyPolicy::parse("required"), Some(PartyPolicy::Require));
assert_eq!(PartyPolicy::parse("Require"), None);
assert_eq!(PartyPolicy::parse(""), None);
}
#[test]
fn fiscal_year_bounds() {
let c = Company {
fiscal_year_start: (7, 1),
..Company::default()
};
assert_eq!(
c.fiscal_year_containing(date(2026, 3, 15)),
(date(2025, 7, 1), date(2026, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(date(2026, 6, 30)),
(date(2025, 7, 1), date(2026, 6, 30))
);
assert_eq!(
c.fiscal_year_containing(date(2026, 7, 1)),
(date(2026, 7, 1), date(2027, 6, 30))
);
let calendar = Company::default();
assert_eq!(
calendar.fiscal_year_containing(date(2024, 2, 29)),
(date(2024, 1, 1), date(2024, 12, 31))
);
}
/// A member's party is the live one of their name, else the most recent
/// archived one of their name: never another archived member's.
#[test]
fn party_members_find_each_members_own_party() {
let mut l = Ledger::new(Currency::USD);
let capital = l
.add_account("3001", "Capital — Mo", AccountType::Equity, None, false)
.unwrap();
l.add_account("3000", "Capital — Tobin", AccountType::Equity, None, false)
.unwrap();
let mo = l.add_party("Mo", &[Role::Member]).unwrap();
let other = l.add_party("Pat", &[Role::Member]).unwrap();
l.update_party(mo, PartyField::Archived(true)).unwrap();
l.update_party(other, PartyField::Archived(true)).unwrap();
let c = Company {
members: vec![Member {
name: "mo ".into(),
share: Decimal::ONE,
capital_account: "3001".into(),
distribution_account: None,
files_jointly_with: None,
}],
..Company::default()
};
let got = c.party_members(&l);
assert_eq!(got.len(), 1);
assert_eq!(got[0].party, mo);
assert_eq!(got[0].capital, capital);
assert_eq!(got[0].share, Decimal::ONE);
assert_eq!(got[0].distributions, None);
}
#[test]
fn printed_tin_joiners() {
// Three spaces is not a joiner; a 3-6 split is no TIN's shape; two
// dashes is not one joiner.
assert!(!holds_printed_tin("123 45 6789"));
assert!(!holds_printed_tin("123 456789"));
assert!(!holds_printed_tin("123--45--6789"));
// Two spaces, and a dash with a space each side, are.
assert!(holds_printed_tin("123 45 6789"));
assert!(holds_printed_tin("123 - 45 - 6789"));
assert!(holds_printed_tin("12 3456789"));
}
#[test]
fn text_length_boundaries() {
assert_eq!(line_text("x", "aaaaa", 5).unwrap(), "aaaaa");
assert_eq!(
line_text("x", "aaaaaa", 5).unwrap_err(),
"x is longer than 5 characters"
);
assert_eq!(long_text("x", "aaaaa", 5).unwrap(), "aaaaa");
assert_eq!(
long_text("x", "aaaaaa", 5).unwrap_err(),
"x is longer than 5 characters"
);
assert_eq!(long_text("x", "a\nb\tc", 10).unwrap(), "a\nb\tc");
assert_eq!(
long_text("x", "a\u{7}b", 10).unwrap_err(),
"x holds a control character"
);
}
#[test]
fn share_boundaries() {
assert_eq!(parse_share("K", "1").unwrap(), Decimal::ONE);
assert_eq!(parse_share("K", "100%").unwrap(), Decimal::ONE);
assert_eq!(parse_share("K", "0.5").unwrap(), dec("0.5"));
assert_eq!(parse_share("K", "50%").unwrap(), dec("0.5"));
assert_eq!(parse_share("K", "0.0001").unwrap(), dec("0.0001"));
for bad in ["0", "0%", "-0.5", "1.0001", "101%", "2"] {
assert_eq!(
parse_share("K", bad).unwrap_err(),
format!("member K: share {bad:?} must be like \"50%\" or \"0.5\""),
);
}
}
#[test]
fn month_day_digits_only() {
assert_eq!(parse_month_day("07-01").unwrap(), (7, 1));
for bad in ["+1-01", "01-+1", "-01", "01-"] {
assert_eq!(
parse_month_day(bad).unwrap_err(),
format!("fiscal_year_start {bad:?}: expected \"MM-DD\", e.g. \"01-01\""),
);
}
}
#[test]
fn every_classification_names_its_return() {
use TaxForm::*;
for (c, want) in [
("partnership", Some(Form1065)),
("General Partnership", Some(Form1065)),
("S corporation", Some(Form1120S)),
("S-corp", Some(Form1120S)),
("s corp", Some(Form1120S)),
("scorp", Some(Form1120S)),
("C corporation", Some(Form1120)),
("ccorp", Some(Form1120)),
("corporation", Some(Form1120)),
("corp", Some(Form1120)),
("association", Some(Form1120)),
("disregarded entity", Some(ScheduleC)),
("sole proprietor", Some(ScheduleC)),
("single-member LLC", Some(ScheduleC)),
("member single", None),
("corporation or S corporation", None),
("partnership or disregarded", None),
("LLC", None),
("", None),
] {
assert_eq!(form_for_classification(c), want, "{c:?}");
}
}
#[test]
fn codes_and_choices_keep_their_spelling() {
assert_eq!(
checked("books.close_into", json!(" 3000 ")),
Ok(json!("3000"))
);
assert_eq!(
checked("books.cash_accounts", json!([" 1000", "1010 "])),
Ok(json!(["1000", "1010"]))
);
for (k, v) in [
("books.accounting_basis", "accrual"),
("books.accounting_basis", "cash"),
("policy.parties", "off"),
("policy.parties", "suggest"),
("policy.parties", "require"),
("policy.approval_rules", "off"),
("policy.approval_rules", "enforce"),
] {
assert_eq!(checked(k, json!(v)), Ok(json!(v)), "{k} {v}");
}
}
#[test]
fn amount_bound_is_inclusive() {
let max = ledger_core::kernel::MAX_LINE_AMOUNT.to_string();
assert_eq!(
checked("policy.evidence.threshold", json!(max)),
Ok(json!(max))
);
let over = format!("{max}.01");
assert_eq!(
checked("policy.evidence.threshold", json!(over)),
Err(format!(
"policy.evidence.threshold {over:?} is out of range"
))
);
}
#[test]
fn ein_shapes() {
let ein = |s: &str| checked("company.ein", json!(s));
assert_eq!(ein("12-3456789"), Ok(json!("12-3456789")));
assert_eq!(ein("123456789"), Ok(json!("12-3456789")));
let refused = Err("company.ein: expected an EIN, NN-NNNNNNN".to_string());
for bad in [
"12345678",
"12-34-567",
"12345678x",
"123-456789",
"1-23456789",
] {
assert_eq!(ein(bad), refused, "{bad:?}");
}
}
#[test]
fn name_control_and_tag_prefix_bounds() {
let nc = |s: &str| checked("company.irs_name_control", json!(s));
for ok in ["WAND", "A&B-", "A&", "A-B", "w"] {
assert_eq!(nc(ok), Ok(json!(ok.to_uppercase())), "{ok:?}");
}
assert_eq!(
nc("WANDE"),
Err("company.irs_name_control \"WANDE\": one to four letters, digits, & or -".into())
);
assert!(nc("W.A").is_err());
let tag = |s: &str| checked("books.asset_tag_prefix", json!(s));
assert_eq!(tag("FL"), Ok(json!("FL")));
assert_eq!(tag("ABCDEFGH"), Ok(json!("ABCDEFGH")));
assert_eq!(
tag("ABCDEFGHI"),
Err("books.asset_tag_prefix \"ABCDEFGHI\": 1 to 8 letters or digits".into())
);
assert!(tag("").is_err());
assert!(tag("W-L").is_err());
}
/// Replay checks an amount and a fiscal year start by their shape.
#[test]
fn replay_shapes_amounts_and_month_starts() {
let threshold = key("policy.evidence.threshold").unwrap();
assert!(shape(threshold, &json!("25.00")).is_ok());
assert_eq!(
shape(threshold, &json!("abc")),
Err("policy.evidence.threshold \"abc\" is not a decimal".into())
);
let fy = key("company.fiscal_year_start").unwrap();
assert!(shape(fy, &json!("07-15")).is_ok());
for bad in ["13-01", "02-30", "07"] {
assert_eq!(
shape(fy, &json!(bad)),
Err(format!("fiscal_year_start {bad:?}: expected \"MM-DD\"")),
);
}
}
#[test]
fn today_is_the_companys_today() {
let utc = chrono::Utc::now().date_naive();
let near = |d: NaiveDate| (d - utc).num_days().abs() <= 1;
let s = settings(&[("company.timezone", json!("America/Denver"))]);
assert!(near(s.today()), "{}", s.today());
assert!(near(today_in(None)));
// UTC+14 and UTC-11 are 25 hours apart: never the same day.
let east = today_in(Some("Pacific/Kiritimati"));
let west = today_in(Some("Pacific/Pago_Pago"));
assert!(near(east) && near(west));
assert!(east > west, "{east} {west}");
assert!(east.year() >= 2026);
}
#[test]
fn display_joins_members_and_lists() {
let members = key("company.members").unwrap();
let v = json!([
{"name": "Mo", "share": "0.5", "capital_account": "3001"},
{"name": "Tobin", "share": "0.5", "capital_account": "3000"},
]);
assert_eq!(display(members, &v, false), "Mo 0.5, Tobin 0.5");
let cash = key("books.cash_accounts").unwrap();
assert_eq!(display(cash, &json!(["1000", "1010"]), false), "1000, 1010");
let ein = key("company.ein").unwrap();
assert_eq!(display(ein, &json!("12-3456789"), false), "**-***6789");
assert_eq!(display(ein, &json!("12-3456789"), true), "12-3456789");
}
/// A date is taken only as written YYYY-MM-DD, one spelling per day.
#[test]
fn dates_take_one_spelling() {
let formed = |s: &str| checked("company.formed", json!(s));
assert_eq!(formed("2026-10-04"), Ok(json!("2026-10-04")));
assert_eq!(formed(" 2026-10-04 "), Ok(json!("2026-10-04")));
for bad in ["2026-1-4", "2026-10-4", "+2026-10-04"] {
assert_eq!(
formed(bad),
Err(format!(
"company.formed {bad:?}: expected a date, YYYY-MM-DD"
)),
);
}
}
// ------------------------------------------------ P4b S4.2: the new kinds
/// `Kind::Count`: a positive whole number, in one canonical JSON number
/// (a settings file writes it as digits; the journal keeps the number).
#[test]
fn counts_are_positive_whole_numbers() {
let days = |v: Value| checked("invoicing.unsent_days", v);
assert_eq!(days(json!(2)), Ok(json!(2)));
assert_eq!(days(json!("14")), Ok(json!(14)));
assert_eq!(
checked("invoicing.first_number", json!(1000)),
Ok(json!(1000))
);
for bad in [
json!(0),
json!(-1),
json!("0"),
json!("-2"),
json!(1.5),
json!("2.0"),
json!("x"),
json!(true),
json!(null),
] {
assert!(days(bad.clone()).is_err(), "{bad}");
}
// Every day-count key takes the same kind.
for k in [
"invoicing.first_number",
"invoicing.unsent_days",
"invoicing.reminder_days",
"bills.remind_days",
"bills.discount_warn_days",
] {
assert_eq!(checked(k, json!(7)), Ok(json!(7)), "{k}");
assert!(checked(k, json!(0)).is_err(), "{k}");
}
}
/// `Kind::Terms`: canonicalized through [`ledger_core::Terms`], so two
/// spellings of one agreement are one value (rules.md B-39).
#[test]
fn terms_keep_one_canonical_spelling() {
let terms = |s: &str| checked("invoicing.default_terms", json!(s));
assert_eq!(terms("net 30"), Ok(json!("Net 30")));
assert_eq!(terms(" NET 15 EOM "), Ok(json!("Net 15 EOM")));
assert_eq!(terms("2/10 net 30"), Ok(json!("2/10 net 30")));
assert_eq!(terms("Due On Receipt"), Ok(json!("Due on receipt")));
assert_eq!(terms("due 2026-11-15"), Ok(json!("Due 2026-11-15")));
// S4.1's parse reads a date chrono can read and canonicalizes it, so a
// single-digit month and day get their zero padding in the one value.
assert_eq!(terms("Due 2026-1-5"), Ok(json!("Due 2026-01-05")));
assert_eq!(terms("2.5/10 net 30"), Ok(json!("2.5/10 net 30")));
for bad in [
"net",
"Net 4000",
"Net -5",
"2/40 net 30",
"0/10 net 30",
"2/10 net 15 eom",
"2/10 net 30 extra",
"Due 9999-13-01",
"",
] {
assert!(terms(bad).is_err(), "{bad:?}");
}
}
/// `Kind::PaymentMethods` (A-3): a routing number is nine digits, so an
/// `ach` or `wire` method's bank-detail lines are the one text exempt from
/// the full-TIN check; every other text in the list is scanned. At most
/// eight methods, each line at most 120 characters.
#[test]
fn payment_methods_scan_every_text_but_ach_and_wire_lines() {
let methods = |v: Value| checked("invoicing.payment_methods", v);
for kind in ["ach", "wire"] {
let got = methods(json!([{
"kind": kind,
"label": "Bank transfer",
"lines": ["Routing 123456789", "Account 000123456789"],
}]))
.unwrap();
assert_eq!(got[0]["lines"][0], json!("Routing 123456789"), "{kind}");
assert_eq!(got[0]["lines"][1], json!("Account 000123456789"), "{kind}");
}
for bad in [
json!([{"kind": "link", "label": "123456789", "lines": []}]),
json!([{"kind": "link", "label": "Pay here", "lines": ["123456789"]}]),
json!([{"kind": "check", "label": "Check", "lines": ["pay to 123456789"]}]),
json!([{"kind": "other", "label": "Cash", "lines": [], "link": "123456789"}]),
] {
assert!(methods(bad.clone()).is_err(), "{bad}");
}
// Canonical: the kind's spelling and the texts are trimmed.
assert_eq!(
methods(json!([{"kind": "ACH", "label": " Bank ", "lines": [" 123456789 "], "qr": true}]))
.unwrap(),
json!([{"kind": "ach", "label": "Bank", "lines": ["123456789"], "qr": true}])
);
// Unknown kind and unknown field are refused.
assert!(methods(json!([{"kind": "barter", "label": "X", "lines": []}])).is_err());
assert!(methods(json!([{"kind": "other", "label": "X", "lines": [], "note": "y"}])).is_err());
// At most eight methods.
let pool = |n: usize| {
Value::Array(
(0..n)
.map(|i| json!({"kind": "other", "label": format!("Method {i}"), "lines": []}))
.collect(),
)
};
assert!(methods(pool(8)).is_ok());
assert!(methods(pool(9)).is_err());
// Each line at most 120 characters.
let long = "x".repeat(121);
assert!(
methods(json!([{"kind": "other", "label": "M", "lines": [long]}])).is_err(),
"121 characters"
);
}
/// `invoicing.number_prefix` is 1 to 12 visible ASCII characters: a number
/// with a space or a control character in it reads wrong, and an empty
/// prefix is no prefix.
#[test]
fn number_prefix_is_short_visible_ascii() {
let prefix = |s: &str| checked("invoicing.number_prefix", json!(s));
for ok in ["INV-", "A", "2026/", "ABCDEFGHIJKL"] {
assert_eq!(prefix(ok), Ok(json!(ok)), "{ok:?}");
}
assert_eq!(prefix(" INV- "), Ok(json!("INV-")));
for bad in ["", " ", "ABCDEFGHIJKLM", "INV 1", "INV\t1", "Ünï"] {
assert!(prefix(bad).is_err(), "{bad:?}");
}
}
/// The company's contact details are simple company keys, so the seller
/// snapshot reads them through `Company::profile`.
#[test]
fn contact_details_are_in_the_company_profile() {
let s = settings(&[
("company.name", json!("Acme LLC")),
("company.email", json!("billing@acme.example")),
("company.website", json!("https://acme.example")),
]);
let profile: Vec<(String, String)> = s.company("USD").profile;
assert!(
profile.contains(&("email".into(), "billing@acme.example".into())),
"{profile:?}"
);
assert!(
profile.contains(&("website".into(), "https://acme.example".into())),
"{profile:?}"
);
}
/// Replay checks the new kinds by shape, never by a later version's
/// bounds: a count is a positive whole number, terms any string, and a
/// method list of objects with their fields (rules.md B-28).
#[test]
fn replay_shapes_the_new_kinds() {
let count = key("invoicing.unsent_days").unwrap();
assert!(shape(count, &json!(2)).is_ok());
assert!(shape(count, &json!("2")).is_ok());
assert!(shape(count, &json!(0)).is_err());
let terms = key("invoicing.default_terms").unwrap();
assert!(shape(terms, &json!("Net 30")).is_ok());
assert!(shape(terms, &json!("a spelling this version doesn't know")).is_ok());
assert!(shape(terms, &json!(30)).is_err());
let methods = key("invoicing.payment_methods").unwrap();
assert!(shape(methods, &json!([])).is_ok());
assert!(
shape(
methods,
&json!([{"kind": "ach", "label": "Bank", "lines": ["123456789"]}])
)
.is_ok()
);
assert!(shape(methods, &json!([{"kind": "ach", "label": "Bank"}])).is_err());
}