Files
kvitt/server/crates/kvitt-domain/tests/vectors.rs
T
jacobh2 aa765bbb48 Contracts for 14 default categories and the category suggestion (UX-3, UX-8)
- contracts/schema/migrations/0002_categories_v2.sql: server migration 0002 rebuilds categories with the
  14-key default_key CHECK, inserts housing, insurance, children and fees with server_versions taken from
  the global sequence, sets schema_version 2. server-v1.sql unchanged.
- contracts/category-keywords.csv: the keyword table of the suggestion (148 entries, from docs/ux).
- contracts/test-vectors/suggest_category.json: 65 Kotlin-only cases of CS-4 to CS-6.
- contracts/examples/E-1-health: schema_version 2.
- tools/contracts-check.py: reference suggestCategory, keyword-table checks, 0002 load checks on a fresh
  and an in-use database, plain icon/color check, new icon and colour tokens.
- android/domain: suggestCategory (CS-4 to CS-6, code points, platform NFC and categories through
  UnicodeText), vector runner for suggest_category, eight vector files.
- server/crates/kvitt-domain/tests/vectors.rs: suggest_category is Kotlin-only.

The OpenAPI DefaultCategoryKey extension and the two new category examples are held back until the
server's wire types know the new keys (M12b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-18 08:10:57 +02:00

194 lines
6.0 KiB
Rust

//! Runs every case of every file in `contracts/test-vectors/` (SPEC §14.5.5) against `kvitt-domain`.
//!
//! The directory is read at test time (path from `CARGO_MANIFEST_DIR`), so a new vector file is picked up without a
//! code change; a file for a function this crate does not know fails the test unless it is one of the Kotlin-only
//! functions (SPEC §14.5.4 analytics and the category suggestion).
use std::collections::BTreeMap;
use std::path::PathBuf;
use kvitt_domain::{
AccountPosition, Allocation, BalanceExpense, BalanceSettlement, SplitSpec, compute_balances,
compute_split, over_settled_accounts, propose_allocation, validate_settlement,
};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
/// Functions that exist only in Kotlin (SPEC §14.5.4, the category suggestion); their files are parsed but not run here.
const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range", "suggest_category"];
/// Every Rust function of SPEC §14.5.2; each must have a vector file.
const RUST_FUNCTIONS: &[&str] = &[
"compute_split",
"compute_balances",
"propose_allocation",
"validate_settlement",
"over_settled_accounts",
];
#[derive(Deserialize)]
struct VectorFile {
format: u32,
function: String,
cases: Vec<Case>,
}
#[derive(Deserialize)]
struct Case {
name: String,
input: Value,
expected: Value,
}
#[derive(Deserialize)]
struct SplitInput {
amount_ore: i64,
payer_user_id: String,
user_ids: Vec<String>,
spec: SplitSpec,
}
#[derive(Deserialize)]
struct BalancesInput {
user_ids: Vec<String>,
account_ids: Vec<String>,
expenses: Vec<BalanceExpense>,
settlements: Vec<BalanceSettlement>,
}
#[derive(Deserialize)]
struct ProposalInput {
amount_ore: i64,
accounts: Vec<AccountPosition>,
}
#[derive(Deserialize)]
struct SettlementInput {
amount_ore: i64,
from_user_id: String,
to_user_id: String,
user_ids: Vec<String>,
allocations: Vec<Allocation>,
}
#[derive(Deserialize)]
struct OverSettledInput {
allocations: Vec<Allocation>,
accounts: Vec<AccountPosition>,
}
fn vectors_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/test-vectors")
}
fn input<T: DeserializeOwned>(file: &str, case: &Case) -> T {
serde_json::from_value(case.input.clone())
.unwrap_or_else(|e| panic!("{file} / {}: bad input: {e}", case.name))
}
fn outcome<T: serde::Serialize, E: serde::Serialize>(result: Result<T, E>) -> Value {
match result {
Ok(v) => json!({ "ok": v }),
Err(e) => json!({ "error": e }),
}
}
fn run(function: &str, file: &str, case: &Case) -> Value {
match function {
"compute_split" => {
let i: SplitInput = input(file, case);
outcome(compute_split(
i.amount_ore,
&i.payer_user_id,
&i.user_ids,
&i.spec,
))
}
"compute_balances" => {
let i: BalancesInput = input(file, case);
let b = compute_balances(&i.user_ids, &i.account_ids, &i.expenses, &i.settlements);
json!({ "ok": b })
}
"propose_allocation" => {
let i: ProposalInput = input(file, case);
outcome(propose_allocation(i.amount_ore, &i.accounts))
}
"validate_settlement" => {
let i: SettlementInput = input(file, case);
outcome(validate_settlement(
i.amount_ore,
&i.from_user_id,
&i.to_user_id,
&i.user_ids,
&i.allocations,
))
}
"over_settled_accounts" => {
let i: OverSettledInput = input(file, case);
json!({ "ok": over_settled_accounts(&i.allocations, &i.accounts) })
}
other => panic!("{file}: no Rust implementation for function {other}"),
}
}
#[test]
fn every_vector_file_passes() {
let dir = vectors_dir();
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
.map(|entry| entry.expect("directory entry").path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.collect();
entries.sort();
assert!(!entries.is_empty(), "no vector files in {}", dir.display());
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut failures: Vec<String> = Vec::new();
for path in &entries {
let file = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let text = std::fs::read_to_string(path).expect("read vector file");
let vectors: VectorFile = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("{file}: not a vector file (SPEC §14.5.5): {e}"));
assert_eq!(vectors.format, 1, "{file}: unknown format");
assert_eq!(
file,
format!("{}.json", vectors.function),
"{file}: file name must be <function>.json"
);
assert!(!vectors.cases.is_empty(), "{file}: no cases");
if KOTLIN_ONLY.contains(&vectors.function.as_str()) {
seen.insert(vectors.function.clone(), 0);
continue;
}
for case in &vectors.cases {
let actual = run(&vectors.function, &file, case);
if actual != case.expected {
failures.push(format!(
"{file} / {}: expected {}, got {}",
case.name, case.expected, actual
));
}
}
seen.insert(vectors.function.clone(), vectors.cases.len());
}
for function in RUST_FUNCTIONS {
assert!(
seen.get(*function).is_some_and(|n| *n > 0),
"no vector cases for {function}"
);
}
for (function, cases) in &seen {
println!("{function}: {cases} cases run");
}
assert!(
failures.is_empty(),
"{} failing cases:\n{}",
failures.len(),
failures.join("\n")
);
}