M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
//! 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
|
||||
//! analytics functions (SPEC §14.5.4).
|
||||
|
||||
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); their files are parsed but not run here.
|
||||
const KOTLIN_ONLY: &[&str] = &["spend_series", "resolve_range"];
|
||||
/// 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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user