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