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
@@ -41,6 +41,7 @@ import se.hagstedt.kvitt.data.db.KvittDatabase
import se.hagstedt.kvitt.data.repo.AboutRepositoryImpl
import se.hagstedt.kvitt.data.repo.AccountRepositoryImpl
import se.hagstedt.kvitt.data.repo.AnalyticsRepositoryImpl
import se.hagstedt.kvitt.data.repo.CategoryKeywords
import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl
import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
@@ -229,8 +230,18 @@ object DataModule {
AccountRepositoryImpl(local, api, gate, db, clock)
@Provides @Singleton
fun expenses(local: LocalData, db: KvittDatabase, prefs: PhonePrefs, clock: Clock, ids: IdGenerator, triggers: SyncTriggers): ExpenseRepositoryImpl =
ExpenseRepositoryImpl(local, db, prefs, clock, ids, triggers)
fun categoryKeywords(): CategoryKeywords = CategoryKeywords()
@Provides @Singleton
fun expenses(
local: LocalData,
db: KvittDatabase,
prefs: PhonePrefs,
clock: Clock,
ids: IdGenerator,
triggers: SyncTriggers,
keywords: CategoryKeywords,
): ExpenseRepositoryImpl = ExpenseRepositoryImpl(local, db, prefs, clock, ids, triggers, keywords)
@Provides @Singleton
fun settlements(local: LocalData, db: KvittDatabase, clock: Clock, ids: IdGenerator, triggers: SyncTriggers): SettlementRepositoryImpl =
@@ -0,0 +1,65 @@
package se.hagstedt.kvitt.data.repo
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.domain.ActiveCategory
import se.hagstedt.kvitt.domain.CategoryKeyword
import se.hagstedt.kvitt.domain.SuggestionHistoryEntry
import se.hagstedt.kvitt.domain.UnicodeText
import se.hagstedt.kvitt.domain.parseCategoryKeywords
import se.hagstedt.kvitt.domain.suggestCategory
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import java.text.Normalizer
/** The platform's Unicode services for CS-4 (NFC, general categories). */
object PlatformUnicodeText : 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()
}
/**
* The keyword table of CS-6: the Java resource [RESOURCE], copied from contracts/category-keywords.csv at build time
* by `:data:copyCategoryKeywords`, parsed once on first use.
*/
class CategoryKeywords(private val source: () -> String = ::bundledTable) {
val entries: List<CategoryKeyword> by lazy { parseCategoryKeywords(source()) }
companion object {
const val RESOURCE = "/se/hagstedt/kvitt/data/category-keywords.csv"
/** The bundled table as text (UTF-8). */
fun bundledTable(): String = checkNotNull(CategoryKeywords::class.java.getResourceAsStream(RESOURCE)) {
"category keyword table $RESOURCE is not on the class path"
}.use { it.readBytes().decodeToString() }
}
}
/**
* UX-3 over one snapshot: history is every non-deleted expense on the phone (all accounts, both users, pending ones
* included), active categories are the ones neither archived nor deleted, and the fallback is the AC-8.6 default for
* [accountId] ([lastCategoryFor] if still active, else `other`; `other` when no account is selected).
*/
internal fun Snapshot.suggestCategory(
accountId: AccountId?,
description: String,
lastCategoryFor: Map<String, String>,
keywords: List<CategoryKeyword>,
): CategoryId? {
val active = categoryRows.filter { it.archivedAt == null && it.deletedAt == null }
val activeIds = active.map { it.id }.toSet()
val last = accountId?.let { lastCategoryFor[it.value] }?.takeIf { it in activeIds }
val fallback = (last ?: active.firstOrNull { it.defaultKey == OTHER_KEY }?.id)?.let { CategoryId(it) }
return suggestCategory(
description = description,
history = liveExpenses.map { SuggestionHistoryEntry(it.row.description, CategoryId(it.row.categoryId), LocalDate.parse(it.row.expenseDate), it.row.createdAt) },
keywords = keywords,
activeCategories = active.map { ActiveCategory(CategoryId(it.id), it.defaultKey) },
fallback = fallback,
unicode = PlatformUnicodeText,
)
}
private const val OTHER_KEY = "other"
@@ -21,6 +21,8 @@ import se.hagstedt.kvitt.data.util.nowTimestamp
import se.hagstedt.kvitt.data.util.today
import se.hagstedt.kvitt.domain.BP_TOTAL
import se.hagstedt.kvitt.domain.computeSplit
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDetailState
import se.hagstedt.kvitt.model.ExpenseDraft
@@ -44,6 +46,7 @@ class ExpenseRepositoryImpl(
private val clock: Clock,
private val ids: IdGenerator,
private val writes: LocalWriteListener,
private val keywords: CategoryKeywords,
) : ExpenseRepository {
private val timelines = Timelines(db, local)
@@ -73,8 +76,8 @@ class ExpenseRepositoryImpl(
val e = local.snapshot.first().expense(id.value)?.takeIf { !it.deleted } ?: return null
return ExpenseDraft(
id = e.id,
accountId = se.hagstedt.kvitt.model.AccountId(e.row.accountId),
categoryId = se.hagstedt.kvitt.model.CategoryId(e.row.categoryId),
accountId = AccountId(e.row.accountId),
categoryId = CategoryId(e.row.categoryId),
amountOre = e.row.amountOre,
description = e.row.description,
notes = e.row.notes,
@@ -181,6 +184,10 @@ class ExpenseRepositoryImpl(
)
}.distinctUntilChanged()
/** UX-3 (CS-5): a local computation over the Room snapshot and the bundled keyword table; no network (CS-3). */
override suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId? =
local.snapshot.first().suggestCategory(accountId, description, prefs.editorDefaults.first().lastCategoryFor, keywords.entries)
companion object {
const val REQUIRED = "required"
@@ -5,6 +5,7 @@ import se.hagstedt.kvitt.data.api.ExpensePushEntity
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.MutationDto
import se.hagstedt.kvitt.data.api.SplitDto
import se.hagstedt.kvitt.data.repo.CategoryKeywords
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
import se.hagstedt.kvitt.data.repo.SettlementRepositoryImpl
import se.hagstedt.kvitt.model.AccountId
@@ -20,7 +21,10 @@ fun <T> SaveResult<T>.savedId(): T = when (this) {
is SaveResult.Invalid -> throw AssertionError("save was invalid: $errors")
}
fun Harness.expenseRepo() = ExpenseRepositoryImpl(local, db, prefs, clock, ids, writes)
fun Harness.expenseRepo() = ExpenseRepositoryImpl(local, db, prefs, clock, ids, writes, bundledKeywords)
/** The bundled table, parsed once for the whole test JVM like the app's singleton. */
private val bundledKeywords = CategoryKeywords()
fun Harness.settlementRepo() = SettlementRepositoryImpl(local, db, clock, ids, writes)
@@ -0,0 +1,73 @@
package se.hagstedt.kvitt.data.repo
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.domain.CategoryKeyword
import se.hagstedt.kvitt.domain.KeywordMatch
import se.hagstedt.kvitt.domain.normalizeDescription
import se.hagstedt.kvitt.model.DefaultCategoryKey
import java.io.File
/** The bundled keyword table (SPEC §14.8) and the platform UnicodeText binding (§14.5.6). */
class CategoryKeywordsTest {
private val contract = File(checkNotNull(System.getProperty("kvitt.contracts.keywords")) { "kvitt.contracts.keywords not set" })
private fun bundledBytes(): ByteArray = checkNotNull(CategoryKeywords::class.java.getResourceAsStream(CategoryKeywords.RESOURCE)) {
"${CategoryKeywords.RESOURCE} is not on the class path"
}.use { it.readBytes() }
@Test
fun bundledTableIsByteIdenticalToTheContractFile() {
assertArrayEquals(contract.readBytes(), bundledBytes())
assertEquals(148, CategoryKeywords().entries.size)
}
@Test
fun bundledTableFollowsSpec14_8() {
val bytes = bundledBytes()
val text = bytes.decodeToString()
assertFalse("no BOM", text.startsWith(""))
assertFalse("\\n line ends", text.contains('\r'))
assertFalse("no quoting", text.contains('"'))
val entries = CategoryKeywords { text }.entries
val keys = DefaultCategoryKey.entries.map { it.name.lowercase() }.toSet() - "other"
assertEquals("unique keywords", entries.size, entries.map { it.keyword }.toSet().size)
entries.forEach {
assertEquals("normalised: ${it.keyword}", it.keyword, normalizeDescription(it.keyword, PlatformUnicodeText))
assertEquals("spaces only in phrases: ${it.keyword}", it.match == KeywordMatch.PHRASE, ' ' in it.keyword)
assertTrue(it.defaultKey, it.defaultKey in keys)
}
val codePointOrder = Comparator<String> { a, b -> compareCodePoints(a, b) }
val sorted = entries.sortedWith(compareBy<CategoryKeyword, String>(codePointOrder) { it.defaultKey }.thenBy(codePointOrder) { it.keyword })
assertEquals("sorted by default_key, then keyword, in code-point order", sorted, entries)
text.lines().drop(1).filter { it.isNotEmpty() }.forEach { assertTrue(it, it.substringAfterLast(',') in setOf("data", "curated")) }
}
@Test
fun platformUnicodeText_nfc_letters_nd_digits_supplementary() {
val u = PlatformUnicodeText
assertEquals("å", u.nfc("å"))
assertTrue(u.isLetter('ö'.code) && u.isLetter('É'.code) && u.isLetter('ß'.code))
assertTrue("Lo", u.isLetter(0x4E2D))
assertTrue("Lm", u.isLetter(0x02B0))
assertFalse(u.isLetter('&'.code) || u.isLetter('5'.code) || u.isLetter(0x0301))
assertTrue(u.isDigit('7'.code) && u.isDigit(0x0663))
assertFalse("No is not Nd", u.isDigit(0x00B2))
assertFalse("Nl is not Nd", u.isDigit(0x2165))
assertTrue("supplementary letter", u.isLetter(0x1D400))
assertTrue("supplementary Nd", u.isDigit(0x1D7CE))
assertFalse("emoji", u.isLetter(0x1F600))
// CS-4 end to end: NFC, `:` and the right single quote deleted, an emoji and a No digit become spaces, a supplementary letter stays.
assertEquals("caf\u00e9 willys mcdonalds \uD835\uDC00 & 2", normalizeDescription("Cafe\u0301 Willy:s McDonald\u2019s \uD83D\uDE00 \uD835\uDC00 & \u00B22", u))
}
}
private fun compareCodePoints(a: String, b: String): Int {
val x = a.codePoints().toArray()
val y = b.codePoints().toArray()
for (i in 0 until minOf(x.size, y.size)) if (x[i] != y[i]) return x[i].compareTo(y[i])
return x.size.compareTo(y.size)
}
@@ -0,0 +1,144 @@
package se.hagstedt.kvitt.data.repo
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.data.FakeServer
import se.hagstedt.kvitt.data.Harness
import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
import se.hagstedt.kvitt.data.Harness.Companion.ME
import se.hagstedt.kvitt.data.Harness.Companion.OTHER
import se.hagstedt.kvitt.data.Harness.Companion.PARTNER
import se.hagstedt.kvitt.data.api.ExpenseDto
import se.hagstedt.kvitt.data.api.SplitDto
import se.hagstedt.kvitt.data.draft
import se.hagstedt.kvitt.data.expenseRepo
import se.hagstedt.kvitt.data.runSync
import se.hagstedt.kvitt.data.savedId
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
/** UX-3 (CS-5) in `:data`: history from Room over every account and both users, the bundled keywords, the AC-8.6 default. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class CategorySuggestionTest {
private lateinit var h: Harness
private var serial = 0
@Before
fun setUp() {
h = Harness().ready()
h.server.addCategory(RESTAURANTS, "restaurants", null)
h.server.addCategory(HOUSING, "housing", null)
h.server.addCategory(CUSTOM, null, "Hundar")
h.server.addAccount(OTHER_ACCOUNT, "Hemma", mapOf(ME to 5000, PARTNER to 5000))
runSync {
h.engine.sync()
h.local.snapshot.first { it.categoryRows.size == 5 && it.accounts.size == 2 }
}
}
@After
fun tearDown() = h.close()
private suspend fun suggest(description: String, account: String? = ACCOUNT) = h.expenseRepo().suggestCategory(account?.let { AccountId(it) }, description)?.value
/** An expense the partner saved in [account], arriving through sync. */
private fun partnerExpense(description: String, category: String, account: String = OTHER_ACCOUNT, date: String = "2026-10-10", deletedAt: String? = null) {
val id = "0192b000-0000-7000-8000-" + (++serial).toString(16).padStart(12, '0')
h.server.storeExpense(
ExpenseDto(
id, account, category, 10_000, description, "", date, PARTNER, "default",
listOf(SplitDto(ME, 5000, 5000), SplitDto(PARTNER, 5000, 5000)), null, null, PARTNER, PARTNER, FakeServer.T0, FakeServer.T0, deletedAt, 0,
),
)
}
private suspend fun save(description: String, category: String, account: String = ACCOUNT) {
h.expenseRepo().save(draft(description, account = account).copy(categoryId = CategoryId(category))).savedId()
}
private suspend fun archive(category: String) {
h.server.categories[category] = h.server.categories.getValue(category).copy(archivedAt = "2026-10-17T00:00:00.000Z", serverVersion = h.server.next())
h.engine.sync()
h.local.snapshot.first { s -> s.categoryRows.any { it.id == category && it.archivedAt != null } }
}
private suspend fun syncUntil(live: Int) {
h.engine.sync()
h.local.snapshot.first { it.liveExpenses.size == live }
}
@Test
fun history_countsEveryAccountAndBothUsers_mostOftenWins_deletedLeftOut() = runSync {
partnerExpense("Willys", RESTAURANTS)
partnerExpense("Willy:s", RESTAURANTS, account = ACCOUNT)
repeat(3) { partnerExpense("Willys", HOUSING, deletedAt = "2026-10-11T00:00:00.000Z") }
syncUntil(2)
save("WILLYS", GROCERIES)
// Exact history: restaurants 2 (both accounts, the partner's) beats groceries 1 (mine); deleted housing rows do not count.
assertEquals(RESTAURANTS, suggest(" willys "))
// First token: "willys hemköp" has no exact match, its first token does.
assertEquals(RESTAURANTS, suggest("Willys Hemköp", account = OTHER_ACCOUNT))
// A user-created category is suggested from history like any other.
save("Hundfoder", CUSTOM)
assertEquals(CUSTOM, suggest("hundfoder"))
}
@Test
fun history_tie_goesToTheMostRecentlyUsed() = runSync {
partnerExpense("Max", HOUSING, date = "2026-10-01")
partnerExpense("Max", RESTAURANTS, date = "2026-10-12")
syncUntil(2)
assertEquals(RESTAURANTS, suggest("max"))
}
@Test
fun keywords_applyWithoutHistory_andNeedTheKeysCategory() = runSync {
assertEquals("ica: token of the bundled table", GROCERIES, suggest("ICA Maxi Lindhagen"))
assertEquals("hyra: housing, one of the UX-8 keys", HOUSING, suggest("Hyra november"))
// netflix maps to entertainment, which this household does not have: the AC-8.6 default applies.
assertEquals(OTHER, suggest("Netflix"))
}
@Test
fun fallback_isTheCategoryLastUsedInTheAccount_elseOther() = runSync {
assertEquals(OTHER, suggest("qqq"))
assertEquals("an empty description gives the default", OTHER, suggest(" "))
save("Grejer", HOUSING)
assertEquals(HOUSING, suggest("qqq"))
assertEquals(HOUSING, suggest(""))
assertEquals("another account has its own default", OTHER, suggest("qqq", account = OTHER_ACCOUNT))
assertEquals("no account selected", OTHER, suggest("qqq", account = null))
archive(HOUSING)
assertEquals("an archived last category falls back to other", OTHER, suggest("qqq"))
}
@Test
fun archivedCategories_areSkipped_inHistoryAndKeywords() = runSync {
partnerExpense("Willys", RESTAURANTS)
partnerExpense("Willys", RESTAURANTS)
partnerExpense("Willys", HOUSING)
syncUntil(3)
assertEquals(RESTAURANTS, suggest("Willys"))
archive(RESTAURANTS)
assertEquals("history counts only active categories", HOUSING, suggest("Willys"))
archive(HOUSING)
assertEquals("no active history category: the keyword table", GROCERIES, suggest("Willys"))
archive(GROCERIES)
assertEquals("the keyword's category archived: the default", OTHER, suggest("Willys"))
}
private companion object {
const val RESTAURANTS = "01920000-0000-7000-8000-000000000002"
const val HOUSING = "01920000-0000-7000-8000-000000000011"
const val CUSTOM = "0192c000-0000-7000-8000-000000000001"
const val OTHER_ACCOUNT = "0192a003-0000-7000-8000-000000000002"
}
}