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:
@@ -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)
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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())))
|
||||
|
||||
Reference in New Issue
Block a user