M12b: category contract v2 on the server (schema 2, fourteen defaults, finer import mapping)

- migrations/0002_categories_v2.sql, byte-identical to contracts/schema/migrations/0002_categories_v2.sql
  (SPEC §14.1.1), with an identity test; SCHEMA_VERSION = 2 (E-1 reports it, the importer's check follows).
- Seed test on a fresh database (the fourteen rows of AC-7.2, server_version 11 to 14) and an upgrade test of a
  v1 database in use (0001 applied through the migrator, then users, a custom, a renamed and an archived default,
  an account, a recurring template, expenses): every earlier row unchanged, the four rows above the old
  sequence, foreign_key_check empty, schema_version 2, a pull from the old cursor returns exactly the four rows.
- DefaultCategoryKey with the fourteen keys (wire enum and contracts/openapi.yaml); icons 33 and colours 14
  (§9.7) in validation.
- The two category examples M12a held back (E-26 response-default, sync/pull-page-categories-v2) and its
  contracts-check patch for them and the enum.
- Importer: splitwise_categories.csv per the §10.8 v0.8 table (D-053), DEFAULT_KEYS of fourteen, synthetic
  fixtures for the finer mapping (Insurance/Försäkringar to insurance among them), a schema-1 refusal test.
- Test harnesses seed users at server_version 15/16 (after the fourteen seeds); pull counts 10 -> 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:23:37 +02:00
parent b09f3220a1
commit 6eaccec7ac
21 changed files with 790 additions and 57 deletions
+38 -2
View File
@@ -7,8 +7,8 @@ use std::collections::BTreeMap;
/// The committed table, embedded at build time from the crate's data file.
pub const TABLE_CSV: &str = include_str!("../data/splitwise_categories.csv");
/// The Kvitt default category keys (SPEC §14.1 `categories.default_key`).
pub const DEFAULT_KEYS: [&str; 10] = [
/// The Kvitt default category keys (SPEC AC-7.2; the `categories.default_key` CHECK of migration 0002, §14.1.1).
pub const DEFAULT_KEYS: [&str; 14] = [
"groceries",
"restaurants",
"home",
@@ -19,6 +19,10 @@ pub const DEFAULT_KEYS: [&str; 10] = [
"health",
"shopping",
"other",
"housing",
"insurance",
"children",
"fees",
];
/// The key unmapped names fall back to.
@@ -137,6 +141,38 @@ mod tests {
assert_eq!(table.lookup("Payment"), None);
}
#[test]
fn finer_mapping_of_d053() {
let table = CategoryTable::committed().unwrap();
for (name, key) in [
("Rent", "housing"),
("Mortgage", "housing"),
("Hyra", "housing"),
("Avbetalning/Amortering", "housing"),
("Insurance", "insurance"),
("försäkringar", "insurance"),
("Childcare", "children"),
("Barnomsorg", "children"),
("Taxes", "fees"),
("Skatter", "fees"),
("El", "utilities"),
("Värme/gas", "utilities"),
("Vatten", "utilities"),
("Sophämtning", "utilities"),
("Husdjur", "home"),
("Pets", "home"),
] {
assert_eq!(table.lookup(name), Some(key), "{name}");
}
// Every key the table uses is a default key, and every default key is reachable.
for key in DEFAULT_KEYS {
assert!(
table.entries.iter().any(|e| e.default_key == key),
"{key} has no Splitwise name"
);
}
}
#[test]
fn rejects_bad_tables() {
let header = "splitwise_name,language,default_key\n";