M12c: category contract v2 on Android (14 defaults, suggestCategory)

- :core:model: DefaultCategoryKey in AC-7.2 order (14 keys: housing, insurance, children, fees added),
  ColorToken + SLATE, AMBER, CategoryIcons.ALL + key, shield, account_balance (33),
  ExpenseRepository.suggestCategory(accountId, description); ContractTest updated.
- :domain: parseCategoryKeywords (pure, shared by :data and :core:fakes) with a parser test; the
  suggestCategory records and rule unchanged, suggest_category vectors pass.
- :data: PlatformUnicodeText (java.text.Normalizer, Character), CategoryKeywords parsed once from the
  Java resource that copyCategoryKeywords copies from contracts/category-keywords.csv at build time
  (missing file fails the build), ExpenseRepositoryImpl.suggestCategory over the Room snapshot and the
  DataStore last-used category; tests: byte identity and the 14.8 rules of the bundled table, the
  UnicodeText binding, and Room/Robolectric cases for history, first token, ties, keywords, fallback,
  archived categories, deleted expenses, both users and accounts, null account.
- :core:fakes: the 14 defaults with the schema v2 ids, fake suggestCategory through :domain with its own
  build-time copy of the table; tests.
- Room schema unchanged (version 1).

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:21:53 +02:00
parent d39eb2c79b
commit b09f3220a1
18 changed files with 608 additions and 40 deletions
@@ -6,9 +6,10 @@ 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.
// The input records live here and not in :core:model: the keyword table (contracts/category-keywords.csv) is data, not
// a UI contract, and default keys stay the wire strings of the table and the vectors (DefaultCategoryKey carries the
// same 14 keys since UX-8; a key it lacks simply never matches a category).
// :data and :core:fakes bundle the table (copied from contracts/ at build time) and parse it with parseCategoryKeywords.
/** One non-deleted expense on the phone (every account, either user). */
data class SuggestionHistoryEntry(
@@ -94,6 +95,26 @@ fun suggestCategory(
?: fallback
}
/**
* Parses the keyword table: header `keyword,match,default_key,source`, then one entry per line, in file order (CS-6
* breaks ties by table order). No quoting; the table has none. Throws [IllegalArgumentException] on a malformed table,
* which is a build defect (contracts-check validates the file).
*/
fun parseCategoryKeywords(csv: String): List<CategoryKeyword> {
val lines = csv.lines().filter { it.isNotEmpty() }
require(lines.firstOrNull() == KEYWORDS_HEADER) { "category keywords: header is not $KEYWORDS_HEADER" }
return lines.drop(1).mapIndexed { index, line ->
val fields = line.split(',')
require(fields.size == KEYWORDS_HEADER.split(',').size) { "category keywords: line ${index + 2} has ${fields.size} fields" }
val match = KeywordMatch.entries.firstOrNull { it.name.lowercase() == fields[1] }
requireNotNull(match) { "category keywords: line ${index + 2} has match ${fields[1]}" }
require(fields[0].isNotEmpty() && fields[2].isNotEmpty()) { "category keywords: line ${index + 2} is incomplete" }
CategoryKeyword(fields[0], match, fields[2])
}
}
private const val KEYWORDS_HEADER = "keyword,match,default_key,source"
private class Tally(val id: CategoryId, val count: Int, val latest: SuggestionHistoryEntry)
private val RECENCY: Comparator<SuggestionHistoryEntry> = compareBy({ it.expenseDate }, { it.createdAt })
@@ -0,0 +1,29 @@
package se.hagstedt.kvitt.domain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
/** The keyword table parser (contracts/category-keywords.csv, CS-6); the bundled file itself is tested in :data. */
class CategoryKeywordsTest {
@Test
fun entriesKeepFileOrder_crlfAndTrailingNewlineAccepted() {
val csv = "keyword,match,default_key,source\r\nbarn,prefix,children,data\r\ncity gross,phrase,groceries,curated\r\nica,token,groceries,data\n"
assertEquals(
listOf(
CategoryKeyword("barn", KeywordMatch.PREFIX, "children"),
CategoryKeyword("city gross", KeywordMatch.PHRASE, "groceries"),
CategoryKeyword("ica", KeywordMatch.TOKEN, "groceries"),
),
parseCategoryKeywords(csv),
)
}
@Test
fun malformedTablesAreRejected() {
assertThrows(IllegalArgumentException::class.java) { parseCategoryKeywords("keyword,match\nica,token") }
assertThrows(IllegalArgumentException::class.java) { parseCategoryKeywords("keyword,match,default_key,source\nica,fuzzy,groceries,data") }
assertThrows(IllegalArgumentException::class.java) { parseCategoryKeywords("keyword,match,default_key,source\nica,token,groceries") }
assertThrows(IllegalArgumentException::class.java) { parseCategoryKeywords("keyword,match,default_key,source\n,token,groceries,data") }
}
}