aa765bbb48
- 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
194 lines
6.0 KiB
Rust
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")
|
|
);
|
|
}
|