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
This commit is contained in:
2026-10-18 08:10:57 +02:00
parent 86e08a12cf
commit aa765bbb48
12 changed files with 8295 additions and 13 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ dependencies {
}
// The shared test vectors (SPEC §14.5.5) are copied from the repository's contracts/ folder into the test resources.
// A missing file is not tolerated: VectorFilesTest fails unless exactly the seven files of §14.5.5 are present.
// A missing file is not tolerated: VectorFilesTest fails unless exactly the eight files of §14.5.5 are present.
val testVectorsRoot = layout.buildDirectory.dir("generated/test-vectors")
val copyTestVectors = tasks.register<Sync>("copyTestVectors") {
description = "Copies contracts/test-vectors/*.json into the :domain test resources."
@@ -0,0 +1,127 @@
package se.hagstedt.kvitt.domain
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.model.CategoryId
// Category suggestion from the description (UX-3, category-suggestion.md CS-4 to CS-6). Pure; the vectors are
// contracts/test-vectors/suggest_category.json (Kotlin only).
//
// The input records live here and not in :core:model: default keys are open strings because the
// :core:model DefaultCategoryKey enum does not yet carry the four keys of schema v2 (housing, insurance,
// children, fees), and the keyword table (contracts/category-keywords.csv) is data, not a UI contract.
/** One non-deleted expense on the phone (every account, either user). */
data class SuggestionHistoryEntry(
val description: String,
val categoryId: CategoryId,
val expenseDate: LocalDate,
/** Fixed RFC 3339 form (`YYYY-MM-DDTHH:MM:SS.mmmZ`), so string order is time order. */
val createdAt: String,
)
enum class KeywordMatch { TOKEN, PREFIX, PHRASE }
/** One row of the keyword table, `keyword` already normalised (CS-6). */
data class CategoryKeyword(val keyword: String, val match: KeywordMatch, val defaultKey: String)
/** A category that is neither archived nor deleted; `defaultKey` null for a user-created one. */
data class ActiveCategory(val id: CategoryId, val defaultKey: String?)
/**
* The Unicode services CS-4 needs that kotlin.* does not offer in a pure module: NFC and the general category of a
* code point. The app binds the platform's (java.text.Normalizer, Character); tests bind the same.
*/
interface UnicodeText {
fun nfc(text: String): String
/** General category Lu, Ll, Lt, Lm or Lo. */
fun isLetter(codePoint: Int): Boolean
/** General category Nd. */
fun isDigit(codePoint: Int): Boolean
}
/**
* CS-4: NFC, lowercase without locale rules, delete `'`, `’` and `:`, every other code point that is not a letter,
* a decimal digit or `&` becomes a space, then whitespace is collapsed and trimmed. Iterates code points.
*/
fun normalizeDescription(text: String, unicode: UnicodeText): String {
val lowered = unicode.nfc(text).lowercase()
val out = StringBuilder(lowered.length)
var i = 0
while (i < lowered.length) {
val c = lowered[i]
val pair = c.isHighSurrogate() && i + 1 < lowered.length && lowered[i + 1].isLowSurrogate()
val width = if (pair) 2 else 1
val codePoint = if (pair) 0x10000 + ((c.code - 0xD800) shl 10) + (lowered[i + 1].code - 0xDC00) else c.code
when {
codePoint == APOSTROPHE || codePoint == RIGHT_SINGLE_QUOTE || codePoint == COLON -> Unit
codePoint == AMPERSAND || unicode.isLetter(codePoint) || unicode.isDigit(codePoint) -> out.append(lowered, i, i + width)
else -> out.append(' ')
}
i += width
}
return out.split(' ').filter { it.isNotEmpty() }.joinToString(" ")
}
/**
* CS-5: history exact, then history first token, then the keyword table (CS-6), then [fallback] (the AC-8.6 default).
* An empty normalised description gives [fallback]. History counts only categories in [activeCategories]; the most
* often used wins, a tie goes to the category whose latest expense is latest (`expenseDate`, then `createdAt`), and a
* remaining tie to the lower category id (TODO(spec): not stated in category-suggestion.md).
*/
fun suggestCategory(
description: String,
history: List<SuggestionHistoryEntry>,
keywords: List<CategoryKeyword>,
activeCategories: List<ActiveCategory>,
fallback: CategoryId?,
unicode: UnicodeText,
): CategoryId? {
val normalised = normalizeDescription(description, unicode)
if (normalised.isEmpty()) return fallback
val tokens = normalised.split(' ')
val active = activeCategories.map { it.id }.toSet()
val stored = history.map { normalizeDescription(it.description, unicode) to it }
pickFromHistory(stored.filter { it.first == normalised }.map { it.second }, active)?.let { return it }
pickFromHistory(stored.filter { it.first.isNotEmpty() && it.first.substringBefore(' ') == tokens[0] }.map { it.second }, active)
?.let { return it }
val byKey = activeCategories.mapNotNull { c -> c.defaultKey?.let { it to c.id } }.toMap()
return keywords.withIndex()
.filter { matches(it.value, tokens) }
.sortedWith(compareByDescending<IndexedValue<CategoryKeyword>> { codePointLength(it.value.keyword) }.thenBy { it.index })
.firstNotNullOfOrNull { byKey[it.value.defaultKey] }
?: fallback
}
private class Tally(val id: CategoryId, val count: Int, val latest: SuggestionHistoryEntry)
private val RECENCY: Comparator<SuggestionHistoryEntry> = compareBy({ it.expenseDate }, { it.createdAt })
private fun pickFromHistory(rows: List<SuggestionHistoryEntry>, active: Set<CategoryId>): CategoryId? = rows
.filter { it.categoryId in active }
.groupBy { it.categoryId }
.map { (id, group) -> Tally(id, group.size, group.maxWith(RECENCY)) }
.sortedWith(
compareByDescending<Tally> { it.count }
.thenComparator { a, b -> RECENCY.compare(b.latest, a.latest) }
.thenBy { it.id.value },
)
.firstOrNull()
?.id
private fun matches(entry: CategoryKeyword, tokens: List<String>): Boolean = when (entry.match) {
KeywordMatch.TOKEN -> entry.keyword in tokens
KeywordMatch.PREFIX -> tokens.any { it.startsWith(entry.keyword) }
KeywordMatch.PHRASE -> {
val phrase = entry.keyword.split(' ')
(0..tokens.size - phrase.size).any { start -> tokens.subList(start, start + phrase.size) == phrase }
}
}
private fun codePointLength(text: String): Int = text.count { !it.isLowSurrogate() }
private const val APOSTROPHE = 0x27
private const val RIGHT_SINGLE_QUOTE = 0x2019
private const val COLON = 0x3A
private const val AMPERSAND = 0x26
@@ -0,0 +1,12 @@
package se.hagstedt.kvitt.domain
import java.text.Normalizer
/** The platform's Unicode services for the tests (the app binds the same in :data). */
object JvmUnicodeText : UnicodeText {
override fun nfc(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC)
override fun isLetter(codePoint: Int): Boolean = Character.isLetter(codePoint)
override fun isDigit(codePoint: Int): Boolean = Character.getType(codePoint) == Character.DECIMAL_DIGIT_NUMBER.toInt()
}
@@ -5,7 +5,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** The vector directory holds exactly the seven files of SPEC §14.5.5; a missing or untested file fails. */
/** The vector directory holds exactly the eight files of SPEC §14.5.5; a missing or untested file fails. */
class VectorFilesTest {
@Test
fun everyVectorFileIsPresentAndTested() {
@@ -12,6 +12,7 @@ import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.analytics.AnalyticsExpense
import se.hagstedt.kvitt.model.analytics.AnalyticsGranularity
@@ -48,6 +49,7 @@ class VectorTest(private val case: VectorCase, private val label: String) {
"over_settled_accounts" -> ok(strings(overSettledAccounts(allocations(input), positions(input))))
"spend_series" -> series(input)
"resolve_range" -> range(input)
"suggest_category" -> suggest(input)
else -> error("no runner for $function")
}
@@ -149,6 +151,19 @@ class VectorTest(private val case: VectorCase, private val label: String) {
)
}
private fun suggest(input: JsonObject): JsonElement {
val history = input.objects("history").map {
SuggestionHistoryEntry(it.str("description"), CategoryId(it.str("category_id")), LocalDate.parse(it.str("expense_date")), it.str("created_at"))
}
val keywords = input.objects("keywords").map {
CategoryKeyword(it.str("keyword"), KeywordMatch.valueOf(it.str("match").uppercase()), it.str("default_key"))
}
val active = input.objects("active_categories").map { ActiveCategory(CategoryId(it.str("id")), it.strOrNull("default_key")) }
val fallback = input.strOrNull("fallback")?.let { CategoryId(it) }
val r = suggestCategory(input.str("description"), history, keywords, active, fallback, JvmUnicodeText)
return ok(r?.let { JsonPrimitive(it.value) } ?: JsonNull)
}
private fun range(input: JsonObject): JsonElement {
val preset = RangePreset.valueOf(input.str("preset").uppercase())
fun date(key: String) = input.strOrNull(key)?.let { LocalDate.parse(it) }
@@ -13,10 +13,10 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
/** The seven vector files of SPEC §14.5.5, keyed by function name. */
/** The eight vector files of SPEC §14.5.5 (with suggest_category, UX-3), keyed by function name. */
val VECTOR_FUNCTIONS: List<String> = listOf(
"compute_split", "compute_balances", "propose_allocation", "validate_settlement", "over_settled_accounts",
"spend_series", "resolve_range",
"spend_series", "resolve_range", "suggest_category",
)
data class VectorCase(val function: String, val name: String, val input: JsonObject, val expected: JsonElement)
+149
View File
@@ -0,0 +1,149 @@
keyword,match,default_key,source
barn,prefix,children,data
blöjor,token,children,curated
fritids,token,children,curated
förskol,prefix,children,data
leksak,prefix,children,curated
napp,prefix,children,data
resevagn,token,children,data
bio,token,entertainment,data
entre,token,entertainment,data
entré,token,entertainment,data
filmstaden,token,entertainment,curated
karaoke,token,entertainment,data
konsert,prefix,entertainment,data
museet,token,entertainment,curated
museum,token,entertainment,data
netflix,token,entertainment,curated
padel,token,entertainment,curated
spotify,token,entertainment,data
teater,prefix,entertainment,curated
bankavgift,token,fees,curated
kvarskatt,token,fees,curated
ränteåterbäring,token,fees,data
skatt,token,fees,curated
skatteverket,token,fees,curated
alkohol,token,groceries,data
bondens marknad,phrase,groceries,data
bröd,token,groceries,data
bubbel,token,groceries,data
city gross,phrase,groceries,curated
coop,token,groceries,data
frukt,token,groceries,curated
grönsaker,token,groceries,data
handling,prefix,groceries,data
hemköp,token,groceries,data
ica,token,groceries,data
julmat,token,groceries,data
lidl,token,groceries,curated
livs,token,groceries,data
livsmedel,prefix,groceries,curated
mat,token,groceries,data
mjölk,token,groceries,data
netto,token,groceries,data
ost,token,groceries,data
snacks,token,groceries,data
storhandling,token,groceries,data
systembolag,prefix,groceries,data
systemet,token,groceries,data
willys,token,groceries,data
apotek,prefix,health,data
kry,token,health,curated
läkare,token,health,curated
medicin,prefix,health,curated
sjukhus,token,health,curated
tandläkare,token,health,curated
tandvård,token,health,curated
vårdcentral,token,health,curated
bauhaus,token,home,data
byggmax,token,home,curated
clas ohlson,phrase,home,data
elgiganten,token,home,curated
färg,token,home,data
garderob,prefix,home,data
gardin,prefix,home,data
hylla,token,home,data
ikea,token,home,data
jysk,token,home,curated
lamp,prefix,home,data
möbler,token,home,curated
ohlson,token,home,data
rusta,token,home,data
amortering,prefix,housing,curated
avgift,token,housing,data
bolån,token,housing,curated
brf,token,housing,data
hyra,token,housing,data
lgh,token,housing,data
ränta,token,housing,data
barnförsäkring,token,insurance,curated
bilförsäkring,token,insurance,curated
folksam,token,insurance,curated
försäkring,prefix,insurance,data
hemförsäkring,token,insurance,data
länsförsäkringar,token,insurance,curated
trygg hansa,phrase,insurance,curated
bagel,prefix,restaurants,data
bageri,prefix,restaurants,data
bar,token,restaurants,curated
burger king,phrase,restaurants,curated
cafe,token,restaurants,curated
café,token,restaurants,data
dumplings,token,restaurants,data
fika,prefix,restaurants,data
foodora,token,restaurants,curated
glass,token,restaurants,data
hamburgare,token,restaurants,curated
kaffe,token,restaurants,data
kebab,token,restaurants,data
lunch,token,restaurants,data
max,token,restaurants,data
mcdonalds,token,restaurants,curated
middag,token,restaurants,data
pizz,prefix,restaurants,data
ramen,token,restaurants,data
restaurang,prefix,restaurants,curated
seml,prefix,restaurants,data
streetfood,token,restaurants,data
sushi,token,restaurants,data
thai,token,restaurants,data
uber eats,phrase,restaurants,curated
amazon,token,shopping,curated
h&m,token,shopping,curated
kappahl,token,shopping,curated
lindex,token,shopping,curated
present,prefix,shopping,data
skor,token,shopping,curated
stadium,token,shopping,curated
zalando,token,shopping,curated
bensin,token,transport,curated
bil,token,transport,data
buss,prefix,transport,curated
flygbuss,token,transport,curated
färj,prefix,transport,data
laddning,prefix,transport,data
okq8,token,transport,curated
parkering,prefix,transport,data
preem,token,transport,curated
sj,token,transport,curated
sl,token,transport,curated
tanka,token,transport,curated
taxi,token,transport,data
trängselskatt,token,transport,curated
tåg,token,transport,data
uber,token,transport,curated
airbnb,token,travel,data
boende,token,travel,data
booking,token,travel,curated
flyg,token,travel,curated
hotel,token,travel,data
hotell,token,travel,data
sas,token,travel,curated
bredband,token,utilities,curated
elräkning,token,utilities,curated
internet,token,utilities,data
städning,prefix,utilities,curated
tele2,token,utilities,curated
telenor,token,utilities,curated
telia,token,utilities,curated
vattenfall,token,utilities,curated
1 keyword match default_key source
2 barn prefix children data
3 blöjor token children curated
4 fritids token children curated
5 förskol prefix children data
6 leksak prefix children curated
7 napp prefix children data
8 resevagn token children data
9 bio token entertainment data
10 entre token entertainment data
11 entré token entertainment data
12 filmstaden token entertainment curated
13 karaoke token entertainment data
14 konsert prefix entertainment data
15 museet token entertainment curated
16 museum token entertainment data
17 netflix token entertainment curated
18 padel token entertainment curated
19 spotify token entertainment data
20 teater prefix entertainment curated
21 bankavgift token fees curated
22 kvarskatt token fees curated
23 ränteåterbäring token fees data
24 skatt token fees curated
25 skatteverket token fees curated
26 alkohol token groceries data
27 bondens marknad phrase groceries data
28 bröd token groceries data
29 bubbel token groceries data
30 city gross phrase groceries curated
31 coop token groceries data
32 frukt token groceries curated
33 grönsaker token groceries data
34 handling prefix groceries data
35 hemköp token groceries data
36 ica token groceries data
37 julmat token groceries data
38 lidl token groceries curated
39 livs token groceries data
40 livsmedel prefix groceries curated
41 mat token groceries data
42 mjölk token groceries data
43 netto token groceries data
44 ost token groceries data
45 snacks token groceries data
46 storhandling token groceries data
47 systembolag prefix groceries data
48 systemet token groceries data
49 willys token groceries data
50 apotek prefix health data
51 kry token health curated
52 läkare token health curated
53 medicin prefix health curated
54 sjukhus token health curated
55 tandläkare token health curated
56 tandvård token health curated
57 vårdcentral token health curated
58 bauhaus token home data
59 byggmax token home curated
60 clas ohlson phrase home data
61 elgiganten token home curated
62 färg token home data
63 garderob prefix home data
64 gardin prefix home data
65 hylla token home data
66 ikea token home data
67 jysk token home curated
68 lamp prefix home data
69 möbler token home curated
70 ohlson token home data
71 rusta token home data
72 amortering prefix housing curated
73 avgift token housing data
74 bolån token housing curated
75 brf token housing data
76 hyra token housing data
77 lgh token housing data
78 ränta token housing data
79 barnförsäkring token insurance curated
80 bilförsäkring token insurance curated
81 folksam token insurance curated
82 försäkring prefix insurance data
83 hemförsäkring token insurance data
84 länsförsäkringar token insurance curated
85 trygg hansa phrase insurance curated
86 bagel prefix restaurants data
87 bageri prefix restaurants data
88 bar token restaurants curated
89 burger king phrase restaurants curated
90 cafe token restaurants curated
91 café token restaurants data
92 dumplings token restaurants data
93 fika prefix restaurants data
94 foodora token restaurants curated
95 glass token restaurants data
96 hamburgare token restaurants curated
97 kaffe token restaurants data
98 kebab token restaurants data
99 lunch token restaurants data
100 max token restaurants data
101 mcdonalds token restaurants curated
102 middag token restaurants data
103 pizz prefix restaurants data
104 ramen token restaurants data
105 restaurang prefix restaurants curated
106 seml prefix restaurants data
107 streetfood token restaurants data
108 sushi token restaurants data
109 thai token restaurants data
110 uber eats phrase restaurants curated
111 amazon token shopping curated
112 h&m token shopping curated
113 kappahl token shopping curated
114 lindex token shopping curated
115 present prefix shopping data
116 skor token shopping curated
117 stadium token shopping curated
118 zalando token shopping curated
119 bensin token transport curated
120 bil token transport data
121 buss prefix transport curated
122 flygbuss token transport curated
123 färj prefix transport data
124 laddning prefix transport data
125 okq8 token transport curated
126 parkering prefix transport data
127 preem token transport curated
128 sj token transport curated
129 sl token transport curated
130 tanka token transport curated
131 taxi token transport data
132 trängselskatt token transport curated
133 tåg token transport data
134 uber token transport curated
135 airbnb token travel data
136 boende token travel data
137 booking token travel curated
138 flyg token travel curated
139 hotel token travel data
140 hotell token travel data
141 sas token travel curated
142 bredband token utilities curated
143 elräkning token utilities curated
144 internet token utilities data
145 städning prefix utilities curated
146 tele2 token utilities curated
147 telenor token utilities curated
148 telia token utilities curated
149 vattenfall token utilities curated
+1 -1
View File
@@ -1,6 +1,6 @@
{
"status": "ok",
"server_version": "0.1.0",
"schema_version": 1,
"schema_version": 2,
"db_ok": true
}
@@ -0,0 +1,64 @@
-- Kvitt server schema v2: four new default categories (housing, insurance, children, fees). Do not edit; add a new
-- migration and bump the schema version (SPEC §14.8). Applied on top of server-v1.sql (0001_init.sql).
--
-- The default_key CHECK of categories cannot be altered in place in SQLite, so the table is rebuilt: copy the rows
-- aside, drop the table, recreate it with the new CHECK and copy the rows back, ids and server_version unchanged.
-- The migration runs inside one transaction (sqlx wraps every migration in one) with foreign_keys = ON (SPEC §8.9).
-- defer_foreign_keys = ON lets expenses and recurring_templates point at categories while the table is briefly
-- absent; the deferred check at commit still fails the migration if any reference is left dangling. It resets
-- itself when the transaction ends. No other table, index or trigger refers to categories by anything but name.
PRAGMA defer_foreign_keys = ON;
CREATE TABLE categories_v1_copy AS SELECT * FROM categories;
DROP TABLE categories;
CREATE TABLE categories (
id TEXT PRIMARY KEY NOT NULL,
default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
('groceries', 'restaurants', 'home', 'utilities', 'transport',
'travel', 'entertainment', 'health', 'shopping', 'other',
'housing', 'insurance', 'children', 'fees')),
name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
icon TEXT NOT NULL,
color TEXT NOT NULL,
sort_order INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
server_version INTEGER NOT NULL UNIQUE,
CHECK (name IS NOT NULL OR default_key IS NOT NULL)
) STRICT;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version
FROM categories_v1_copy;
DROP TABLE categories_v1_copy;
-- Each new row takes the next server_version from the global sequence, as every server write does (SPEC §14.1), so
-- phones receive the rows on their next pull whatever their cursor.
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000011', 'housing', NULL, 'key', 'indigo', 25,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000012', 'insurance', NULL, 'shield', 'slate', 45,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000013', 'children', NULL, 'child_care', 'lime', 85,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000014', 'fees', NULL, 'account_balance', 'amber', 95,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE meta SET value = '2' WHERE key = 'schema_version';
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -2,7 +2,7 @@
//!
//! 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).
//! functions (SPEC §14.5.4 analytics and the category suggestion).
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -15,8 +15,8 @@ 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"];
/// 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",
+407 -5
View File
@@ -9,19 +9,27 @@ Checks, one output line each:
plus the money invariants of §9.1 / §9.4 / §14.3.3 (sums, sort orders);
* contracts/schema/server-v1.sql is byte-identical to the DDL in SPEC §14.1, loads in SQLite, has a clean
foreign_key_check and the seed rows;
* contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2 (text check:
the standard library has no YAML parser).
* contracts/schema/migrations/0002_categories_v2.sql loads on top of server-v1.sql (foreign keys on, one
transaction, as sqlx runs it), on an empty database and on one with rows referring to categories, and leaves the
14 default categories with server_versions from the global sequence and schema_version 2;
* contracts/category-keywords.csv (the keyword table of the category suggestion) is well-formed: header, match
kinds, default keys, keywords already normalised, unique, sorted;
* contracts/openapi.yaml declares exactly the endpoints of SPEC §7 and the error codes of §14.3.2, and icon and
color stay plain strings (text check: the standard library has no YAML parser).
Exit status 0 when every check passes, 1 otherwise.
Usage: python3 tools/contracts-check.py [repository root]
"""
import csv
import datetime
import io
import json
import os
import re
import sqlite3
import sys
import unicodedata
ROOT = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CONTRACTS = os.path.join(ROOT, "contracts")
@@ -283,6 +291,100 @@ def resolve_range(preset, today, earliest_expense, custom_start, custom_end):
return {"start": s.isoformat(), "end_inclusive": e.isoformat()}
# Category suggestion (UX-3, category-suggestion.md CS-4 to CS-6; Kotlin only)
LETTER_CATEGORIES = ("Lu", "Ll", "Lt", "Lm", "Lo")
def normalize_description(s):
"""CS-4: NFC, lowercase without locale rules, delete ' ’ :, every other character that is not a letter, a decimal
digit or & becomes a space, collapse and trim. Works per code point."""
s = unicodedata.normalize("NFC", s).lower()
s = s.replace("'", "").replace("\u2019", "").replace(":", "")
kept = []
for c in s:
cat = unicodedata.category(c)
kept.append(c if (cat in LETTER_CATEGORIES or cat == "Nd" or c == "&") else " ")
return " ".join("".join(kept).split())
def _keyword_matches(entry, tokens):
kw, kind = entry["keyword"], entry["match"]
if kind == "token":
return kw in tokens
if kind == "prefix":
return any(t.startswith(kw) for t in tokens)
if kind == "phrase":
kt = kw.split(" ")
return any(tokens[i:i + len(kt)] == kt for i in range(len(tokens) - len(kt) + 1))
raise ValueError("match %r" % kind)
def _history_pick(rows, active, trace, stage):
"""CS-5 steps 1 and 2: most often among active categories, ties by the latest (expense_date, created_at) of each
tied category. A remaining tie (same count, same latest date and created_at) goes to the lower category id:
TODO(spec), category-suggestion.md does not say."""
stats = {}
for r in rows:
cid = r["category_id"]
if cid not in active:
trace["skipped_inactive"] = True
continue
recency = (r["expense_date"], r["created_at"])
n, latest = stats.get(cid, (0, None))
stats[cid] = (n + 1, recency if latest is None or recency > latest else latest)
if not stats:
return None
ranked = sorted(stats.items(), key=lambda kv: kv[0])
ranked.sort(key=lambda kv: kv[1][1], reverse=True)
ranked.sort(key=lambda kv: kv[1][0], reverse=True)
if len(ranked) > 1 and ranked[0][1][0] == ranked[1][1][0]:
trace["count_tie"] = stage
return ranked[0][0]
def suggest_category_traced(description, history, keywords, active_categories, fallback):
trace = {}
active = set(c["id"] for c in active_categories)
by_key = dict((c["default_key"], c["id"]) for c in active_categories if c["default_key"] is not None)
norm = normalize_description(description)
if not norm:
trace["stage"] = "empty"
return fallback, trace
tokens = norm.split(" ")
normalised = [(normalize_description(r["description"]), r) for r in history]
got = _history_pick([r for n, r in normalised if n == norm], active, trace, "history_exact")
if got is not None:
trace["stage"] = "history_exact"
return got, trace
got = _history_pick([r for n, r in normalised if n and n.split(" ")[0] == tokens[0]], active, trace, "history_first_token")
if got is not None:
trace["stage"] = "history_first_token"
return got, trace
matching = [(i, e) for i, e in enumerate(keywords) if _keyword_matches(e, tokens)]
matching.sort(key=lambda ie: (-len(ie[1]["keyword"]), ie[0]))
if len(matching) > 1 and len(matching[0][1]["keyword"]) == len(matching[1][1]["keyword"]):
trace["equal_length_tie"] = True
for n, (_, e) in enumerate(matching):
cid = by_key.get(e["default_key"])
if cid is not None:
trace["stage"] = "keyword"
trace["match"] = e["match"]
if n > 0:
trace["keyword_fell_through"] = True
return cid, trace
if matching:
trace["keyword_fell_through"] = True
trace["stage"] = "fallback"
return fallback, trace
def suggest_category(description, history, keywords, active_categories, fallback):
"""CS-4 to CS-6 as the pure Kotlin function suggestCategory (UX-3). `history` holds the non-deleted expenses,
`active_categories` the categories that are neither archived nor deleted, `fallback` the AC-8.6 default."""
return suggest_category_traced(description, history, keywords, active_categories, fallback)[0]
# ----------------------------------------------------------------------------------------------------------------------
# Test vectors (§14.5.5)
# ----------------------------------------------------------------------------------------------------------------------
@@ -313,7 +415,11 @@ VECTOR_FUNCTIONS = {
"resolve_range": (["preset", "today", "earliest_expense", "custom_start", "custom_end"], [],
lambda i: resolve_range(i["preset"], i["today"], i["earliest_expense"], i["custom_start"],
i["custom_end"])),
"suggest_category": (["description", "history", "keywords", "active_categories", "fallback"], [],
lambda i: suggest_category(i["description"], i["history"], i["keywords"],
i["active_categories"], i["fallback"])),
}
SUGGEST_MIN_CASES = 30
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
@@ -415,6 +521,28 @@ def check_vector_input(fn, inp):
for k in ("earliest_expense", "custom_start", "custom_end"):
if inp[k] is not None and not valid_date(inp[k]):
return k
elif fn == "suggest_category":
if not isinstance(inp["description"], str):
return "description must be a string"
for h in inp["history"]:
if not (isinstance(h, dict) and set(h) == {"description", "category_id", "expense_date", "created_at"}
and isinstance(h["description"], str) and h["description"] and UUID_RE.match(str(h["category_id"]))
and valid_date(h["expense_date"]) and valid_ts(h["created_at"])):
return "history row %r" % (h,)
for k in inp["keywords"]:
if not (isinstance(k, dict) and set(k) == {"keyword", "match", "default_key"}
and isinstance(k["keyword"], str) and k["match"] in KEYWORD_MATCHES and k["default_key"] in DEFAULT_KEYS):
return "keyword %r" % (k,)
for c in inp["active_categories"]:
if not (isinstance(c, dict) and set(c) == {"id", "default_key"} and UUID_RE.match(str(c["id"]))
and (c["default_key"] is None or c["default_key"] in DEFAULT_KEYS)):
return "active category %r" % (c,)
ids = [c["id"] for c in inp["active_categories"]]
keys = [c["default_key"] for c in inp["active_categories"] if c["default_key"] is not None]
if len(set(ids)) != len(ids) or len(set(keys)) != len(keys):
return "active_categories: duplicate id or default_key"
if inp["fallback"] is not None and not UUID_RE.match(str(inp["fallback"])):
return "fallback must be a category id or null"
return None
@@ -435,6 +563,8 @@ def check_vector_ok_shape(fn, ok):
and all(isinstance(b, dict) and set(b) == {"key", "start", "end_inclusive", "totals", "total"} for b in ok["buckets"]))
if fn == "resolve_range":
return isinstance(ok, dict) and set(ok) == {"start", "end_inclusive"}
if fn == "suggest_category":
return ok is None or (isinstance(ok, str) and bool(UUID_RE.match(ok)))
return False
@@ -510,6 +640,32 @@ def coverage(fn, cases):
missing.append("custom swapped")
if not all(any(c["input"]["preset"] == p and c["input"]["today"] == "2026-10-17" for c in cases) for p in PRESETS if p != "custom"):
missing.append("every preset with today 2026-10-17")
elif fn == "suggest_category":
if len(cases) < SUGGEST_MIN_CASES:
missing.append("at least %d cases (has %d)" % (SUGGEST_MIN_CASES, len(cases)))
seen = set()
for c in cases:
i = c["input"]
_, t = suggest_category_traced(i["description"], i["history"], i["keywords"], i["active_categories"], i["fallback"])
seen.add(t["stage"])
if i["description"] == "":
seen.add("empty description")
if t["stage"] == "keyword":
seen.add("keyword " + t["match"])
for k in ("skipped_inactive", "equal_length_tie", "keyword_fell_through"):
if t.get(k):
seen.add(k)
if t.get("count_tie"):
seen.add("tie " + t["count_tie"])
if c["expected"]["ok"] is None:
seen.add("null result")
if normalize_description(i["description"]) != " ".join(i["description"].split()).lower():
seen.add("normalisation changes the description")
for want in ("empty description", "empty", "history_exact", "history_first_token", "keyword", "fallback",
"keyword token", "keyword prefix", "keyword phrase", "skipped_inactive", "equal_length_tie",
"keyword_fell_through", "tie history_exact", "null result", "normalisation changes the description"):
if want not in seen:
missing.append(want)
return missing
@@ -607,7 +763,10 @@ def E(*vals):
ROLE = E("admin", "member")
INVITE_KIND = E("admin_setup", "partner", "device")
DEFAULT_KEY = E("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other")
DEFAULT_KEYS = ["groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health",
"shopping", "other", "housing", "insurance", "children", "fees"]
DEFAULT_KEY = E(*DEFAULT_KEYS)
KEYWORD_MATCHES = ["token", "prefix", "phrase"]
SPLIT_MODE = E("default", "ratio", "exact", "full")
ENTITY_TYPE = E("expense", "settlement")
REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew")
@@ -621,8 +780,9 @@ PUSH_TYPES = ["expense_created", "expense_updated", "expense_deleted", "settleme
ICONS = ["shopping_cart", "restaurant", "local_cafe", "home", "bolt", "water_drop", "wifi", "directions_car", "train",
"local_gas_station", "flight", "hotel", "luggage", "movie", "music_note", "sports_esports", "fitness_center",
"medical_services", "medication", "shopping_bag", "checkroom", "pets", "child_care", "school", "card_giftcard",
"celebration", "build", "chair", "subscriptions", "category"]
COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"]
"celebration", "build", "chair", "subscriptions", "category", "key", "shield", "account_balance"]
COLORS = ["green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime",
"slate", "amber"]
def S(fields, optional=(), patch=False):
@@ -1044,6 +1204,239 @@ def check_schema():
db.close()
V1_DEFAULT_CATEGORIES = [
("01920000-0000-7000-8000-000000000001", "groceries", "shopping_cart", "green", 10),
("01920000-0000-7000-8000-000000000002", "restaurants", "restaurant", "orange", 20),
("01920000-0000-7000-8000-000000000003", "home", "home", "brown", 30),
("01920000-0000-7000-8000-000000000004", "utilities", "bolt", "yellow", 40),
("01920000-0000-7000-8000-000000000005", "transport", "directions_car", "blue", 50),
("01920000-0000-7000-8000-000000000006", "travel", "flight", "teal", 60),
("01920000-0000-7000-8000-000000000007", "entertainment", "movie", "purple", 70),
("01920000-0000-7000-8000-000000000008", "health", "medical_services", "red", 80),
("01920000-0000-7000-8000-000000000009", "shopping", "shopping_bag", "pink", 90),
("01920000-0000-7000-8000-00000000000a", "other", "category", "grey", 100),
]
V2_NEW_CATEGORIES = [
("01920000-0000-7000-8000-000000000011", "housing", "key", "indigo", 25),
("01920000-0000-7000-8000-000000000012", "insurance", "shield", "slate", 45),
("01920000-0000-7000-8000-000000000013", "children", "child_care", "lime", 85),
("01920000-0000-7000-8000-000000000014", "fees", "account_balance", "amber", 95),
]
MIGRATION_0002 = os.path.join("schema", "migrations", "0002_categories_v2.sql")
def _migrate_0002(db, sql):
"""Applies 0002 the way sqlx does: one transaction, foreign keys on (set before it starts)."""
db.isolation_level = None
db.execute("BEGIN")
try:
for stmt in _split_sql(sql):
db.execute(stmt)
db.execute("COMMIT")
except sqlite3.Error:
db.execute("ROLLBACK")
raise
def _split_sql(sql):
out, buf = [], ""
for line in sql.splitlines(True):
buf += line
if sqlite3.complete_statement(buf):
out.append(buf)
buf = ""
if buf.strip() and not all(l.strip().startswith("--") or not l.strip() for l in buf.splitlines()):
out.append(buf)
return out
def check_migration_0002():
path = os.path.join(CONTRACTS, MIGRATION_0002)
v1 = os.path.join(CONTRACTS, "schema", "server-v1.sql")
if not os.path.exists(path):
report(False, "schema 0002_categories_v2.sql exists", "missing")
return
with open(path, encoding="utf-8") as f:
sql = f.read()
with open(v1, encoding="utf-8") as f:
v1sql = f.read()
if re.search(r"^\s*(BEGIN|COMMIT|END|ROLLBACK)\b", sql, re.M | re.I) or re.search(r"PRAGMA\s+foreign_keys\b", sql, re.I):
report(False, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)", "found one")
else:
report(True, "schema 0002 has no transaction control or foreign_keys pragma (sqlx wraps it)")
def fresh():
db = sqlite3.connect(":memory:")
db.execute("PRAGMA foreign_keys = ON")
db.executescript(v1sql)
return db
# 1. empty v1 database
db = fresh()
try:
_migrate_0002(db, sql)
except sqlite3.Error as e:
report(False, "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)", str(e))
return
fk = db.execute("PRAGMA foreign_key_check").fetchall()
integ = db.execute("PRAGMA integrity_check").fetchone()[0]
report(not fk and integ == "ok", "schema 0002 loads on top of server-v1.sql (foreign keys on, one transaction)",
"foreign_key_check %r, integrity %s" % (fk, integ) if (fk or integ != "ok") else "SQLite %s" % sqlite3.sqlite_version)
meta = dict(db.execute("SELECT key, value FROM meta"))
seq = db.execute("SELECT id, value FROM sync_sequence").fetchall()
rows = db.execute("SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, "
"deleted_at, server_version FROM categories ORDER BY sort_order").fetchall()
want = sorted([(i, k, None, ic, co, so, "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", None, None, n + 1)
for n, (i, k, ic, co, so) in enumerate(V1_DEFAULT_CATEGORIES)]
+ [(i, k, None, ic, co, so, "2026-10-18T00:00:00.000Z", "2026-10-18T00:00:00.000Z", None, None, 11 + n)
for n, (i, k, ic, co, so) in enumerate(V2_NEW_CATEGORIES)], key=lambda r: r[5])
ok = meta.get("schema_version") == "2" and seq == [(1, 14)] and rows == want
report(ok, "schema 0002 on a fresh database: 14 default categories, new server_versions 11..14, sync_sequence 14, schema_version 2",
"meta %r, sequence %r, categories %r" % (meta, seq, rows) if not ok else "")
keys = [r[1] for r in rows]
report(sorted(keys) == sorted(DEFAULT_KEYS) and all(r[3] in ICONS and r[4] in COLORS for r in rows),
"schema 0002 default keys, icons and colours are the checker's §9.7 / AC-7.2 lists", "keys %r" % keys)
try:
db.execute("INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, server_version) "
"VALUES ('x', 'pets', NULL, 'pets', 'lime', 1, 't', 't', 999)")
report(False, "schema 0002 keeps a default_key CHECK", "an unknown default_key was accepted")
except sqlite3.IntegrityError:
report(True, "schema 0002 keeps a default_key CHECK")
seed_sql = (
"INSERT INTO users VALUES ('u1', 'admin', 'A', NULL, 't', 't', 11);"
"INSERT INTO accounts VALUES ('a1', 'Vardag', 'vardag', 'u1', 't', 't', NULL, NULL, 12);"
"INSERT INTO categories VALUES ('0192a007-0000-7000-8000-000000000001', NULL, 'Husdjur', 'pets', 'lime', 110, 't', 't', 't', NULL, 13);"
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, expense_date, payer_user_id, split_mode,"
" created_by_user_id, updated_by_user_id, created_at, updated_at, server_received_at, server_version)"
" VALUES ('e1', 'a1', '01920000-0000-7000-8000-000000000001', 100, 'Ica', '2026-10-01', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 14),"
" ('e2', 'a1', '0192a007-0000-7000-8000-000000000001', 200, 'Hundmat', '2026-10-02', 'u1', 'default', 'u1', 'u1', 't', 't', 't', 15);"
"INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, payer_user_id, split_mode, day_of_month,"
" start_period, created_by_user_id, updated_by_user_id, created_at, updated_at, server_version)"
" VALUES ('r1', 'a1', '01920000-0000-7000-8000-000000000004', 300, 'El', 'u1', 'default', 1, '2026-10', 'u1', 'u1', 't', 't', 16);"
"UPDATE categories SET name = 'Matvaror', server_version = 17 WHERE default_key = 'groceries';"
"UPDATE sync_sequence SET value = 917 WHERE id = 1;")
db.close()
# 2. a v1 database in use: renamed default, custom archived category, expenses and a template referring to categories
db = fresh()
db.executescript(seed_sql)
before = db.execute("SELECT * FROM categories ORDER BY id").fetchall()
try:
_migrate_0002(db, sql)
fk = db.execute("PRAGMA foreign_key_check").fetchall()
after = db.execute("SELECT * FROM categories WHERE id NOT IN (%s) ORDER BY id" % ",".join("'%s'" % c[0] for c in V2_NEW_CATEGORIES)).fetchall()
new = db.execute("SELECT default_key, server_version FROM categories WHERE created_at = '2026-10-18T00:00:00.000Z' ORDER BY server_version").fetchall()
seq = db.execute("SELECT value FROM sync_sequence").fetchone()[0]
refs = db.execute("SELECT count(*) FROM expenses e JOIN categories c ON c.id = e.category_id").fetchone()[0]
defer = db.execute("PRAGMA defer_foreign_keys").fetchone()[0]
ok = (not fk and after == before and new == [("housing", 918), ("insurance", 919), ("children", 920), ("fees", 921)]
and seq == 921 and refs == 2 and defer == 0)
report(ok, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence",
"fk %r, kept %s, new %r, sequence %r, refs %r, defer %r" % (fk, after == before, new, seq, refs, defer) if not ok else "")
except sqlite3.Error as e:
report(False, "schema 0002 on a database in use: rows kept, references intact, new rows take 918..921 from the sequence", str(e))
db.close()
# 3. the deferred check still catches a dangling reference: drop the referenced row inside the same transaction
db = fresh()
db.executescript(seed_sql)
try:
_migrate_0002(db, sql + "\nDELETE FROM categories WHERE id = '0192a007-0000-7000-8000-000000000001';\n")
report(False, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)", "a dangling expense reference was committed")
except sqlite3.IntegrityError:
report(True, "schema 0002 keeps foreign keys enforced (a dangling reference fails the migration)")
db.close()
server_copy = os.path.join(ROOT, "server", "crates", "kvitt-server", "migrations", "0002_categories_v2.sql")
if os.path.exists(server_copy):
with open(server_copy, encoding="utf-8") as f:
same = f.read() == sql
report(same, "schema server migrations/0002_categories_v2.sql is byte-identical to the contract", "" if same else "differs")
# ----------------------------------------------------------------------------------------------------------------------
# Keyword table of the category suggestion (UX-3, CS-6)
# ----------------------------------------------------------------------------------------------------------------------
KEYWORDS_CSV = "category-keywords.csv"
KEYWORDS_HEADER = ["keyword", "match", "default_key", "source"]
def load_keywords():
path = os.path.join(CONTRACTS, KEYWORDS_CSV)
if not os.path.exists(path):
return None, None
with open(path, "rb") as f:
raw = f.read()
return raw, list(csv.reader(io.StringIO(raw.decode("utf-8"), newline="")))
def check_keywords():
raw, rows = load_keywords()
if raw is None:
report(False, "keywords %s exists" % KEYWORDS_CSV, "missing")
return []
problems = []
if raw.startswith(b"\xef\xbb\xbf") or b"\r" in raw or not raw.endswith(b"\n") or raw.endswith(b"\n\n"):
problems.append("must be UTF-8 without BOM, LF line ends, one final newline")
if not rows or rows[0] != KEYWORDS_HEADER:
problems.append("header must be %s" % ",".join(KEYWORDS_HEADER))
entries = []
for n, r in enumerate(rows[1:], start=2):
if len(r) != 4:
problems.append("line %d: %d columns" % (n, len(r)))
continue
kw, kind, key, source = r
if kind not in KEYWORD_MATCHES:
problems.append("line %d: match %r" % (n, kind))
if key not in DEFAULT_KEYS or key == "other":
problems.append("line %d: default_key %r (one of the 14 keys, never other)" % (n, key))
if source not in ("data", "curated"):
problems.append("line %d: source %r" % (n, source))
if not kw or normalize_description(kw) != kw:
problems.append("line %d: keyword %r is not normalised (CS-4 gives %r)" % (n, kw, normalize_description(kw)))
if (kind == "phrase") != (" " in kw):
problems.append("line %d: %r: a phrase has two or more tokens, a token or prefix exactly one" % (n, kw))
entries.append({"keyword": kw, "match": kind, "default_key": key, "source": source})
kws = [e["keyword"] for e in entries]
dup = sorted(set(k for k in kws if kws.count(k) > 1))
if dup:
problems.append("duplicate keywords %s" % dup)
order = [(e["default_key"], e["keyword"]) for e in entries]
if order != sorted(order):
bad = [b for a, b in zip(order, order[1:]) if a > b]
problems.append("not sorted by default_key, then keyword (code points); first out of order %r" % (bad[:1],))
unused = sorted(set(k for k in DEFAULT_KEYS if k != "other") - set(e["default_key"] for e in entries))
detail = "%d entries (%s), every default key but other covered%s" % (
len(entries), ", ".join("%d %s" % (sum(1 for e in entries if e["match"] == m), m) for m in KEYWORD_MATCHES),
"" if not unused else "; keys without entries: %s" % unused)
report(not problems, "keywords %s: header, match kinds, default keys, normalised, unique, sorted" % KEYWORDS_CSV,
"; ".join(problems[:6]) if problems else detail)
return entries
def check_vector_keywords(table):
"""Every keyword list in suggest_category.json is a subsequence of the table (same entries, table order)."""
path = os.path.join(CONTRACTS, "test-vectors", "suggest_category.json")
if not table or not os.path.exists(path):
return
try:
with open(path, encoding="utf-8") as f:
cases = json.load(f)["cases"]
except (ValueError, KeyError, TypeError):
return
index = dict((e["keyword"], (n, e["match"], e["default_key"])) for n, e in enumerate(table))
bad = []
for c in cases:
try:
pos = [index.get(k["keyword"]) for k in c["input"]["keywords"]]
ok = (all(p is not None and (p[1], p[2]) == (k["match"], k["default_key"]) for p, k in zip(pos, c["input"]["keywords"]))
and [p[0] for p in pos] == sorted(set(p[0] for p in pos)))
except (KeyError, TypeError):
ok = False
if not ok:
bad.append(c.get("name"))
report(not bad, "vectors suggest_category.json keyword lists are taken from %s in table order" % KEYWORDS_CSV,
"cases %s" % bad[:5] if bad else "%d cases" % len(cases))
# ----------------------------------------------------------------------------------------------------------------------
# OpenAPI (§7, §14.3) — text-level check
# ----------------------------------------------------------------------------------------------------------------------
@@ -1117,6 +1510,12 @@ def check_openapi():
m = re.search(r"^ RejectionReason:\n((?: .*\n|\s*\n)+)", text + "\n", re.M)
reasons = re.findall(r"^ - (\w+)$", m.group(1), re.M) if m else []
report(reasons == REASON[1], "openapi RejectionReason enum is the exhaustive list of §14.4.6", "got %s" % reasons if reasons != REASON[1] else "")
leaked = [t for t in ("ColorToken", "IconToken") if re.search(r"^ %s:" % t, text, re.M)]
icon_plain = re.search(r"^ icon:\n type: string\n", text, re.M) is not None
color_plain = re.search(r"^ color:\n type: string\n", text, re.M) is not None
report(not leaked and icon_plain and color_plain,
"openapi icon and color stay plain strings (§9.7 tokens, incl. key, shield, account_balance, slate, amber, are checked in code)",
"token enums %s, icon plain %s, color plain %s" % (leaked, icon_plain, color_plain) if (leaked or not icon_plain or not color_plain) else "")
schemas = set(re.findall(r"^ ([A-Z]\w*):$", text, re.M))
need = {"User", "Device", "Invite", "Share", "Account", "Category", "Split", "TemplateSplit", "Expense", "Allocation",
"Settlement", "RecurringTemplate", "AuditEntry", "Error"}
@@ -1146,8 +1545,11 @@ def main():
else:
check_json_parses()
counts = check_vectors()
table = check_keywords()
check_vector_keywords(table)
check_examples()
check_schema()
check_migration_0002()
check_openapi()
if counts:
print("vector cases: " + ", ".join("%s %d" % (k, v) for k, v in sorted(counts.items())))