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
@@ -0,0 +1,48 @@
package se.hagstedt.kvitt.fakes
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 se.hagstedt.kvitt.model.DefaultCategoryKey
import java.text.Normalizer
private const val KEYWORDS_RESOURCE = "/se/hagstedt/kvitt/fakes/category-keywords.csv"
/** The keyword table, copied from contracts/category-keywords.csv by `:core:fakes:copyCategoryKeywords`, parsed once. */
internal val bundledKeywords: List<CategoryKeyword> by lazy {
parseCategoryKeywords(
checkNotNull(FakeStore::class.java.getResourceAsStream(KEYWORDS_RESOURCE)) { "$KEYWORDS_RESOURCE is not on the class path" }
.use { it.readBytes().decodeToString() },
)
}
private 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()
}
/**
* UX-3 (CS-5) over the scenario, with the same rule as `:data`: history is every non-deleted expense, active
* categories are the non-archived ones (the scenario holds no deleted categories), the fallback is the AC-8.6 default.
*/
internal fun FakeScenario.suggestCategory(accountId: AccountId?, description: String): CategoryId? {
val active = categories.filter { !it.archived }
val last = accountId?.let { lastCategoryFor[it] }?.takeIf { id -> active.any { it.id == id } }
return suggestCategory(
description = description,
history = expenses.filter { !it.deleted }.map { SuggestionHistoryEntry(it.description, it.categoryId, LocalDate.parse(it.date), it.createdAt) },
keywords = bundledKeywords,
activeCategories = active.map { ActiveCategory(it.id, it.defaultKey?.name?.lowercase()) },
fallback = last ?: active.firstOrNull { it.defaultKey == DefaultCategoryKey.OTHER }?.id,
unicode = JvmUnicodeText,
)
}
@@ -4,6 +4,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
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
@@ -114,6 +116,9 @@ class FakeExpenseRepository(private val store: FakeStore) : ExpenseRepository {
return SaveResult.Saved(id)
}
override suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId? =
store.current.suggestCategory(accountId, description)
override suspend fun delete(id: ExpenseId) = store.edit { s ->
s.copy(expenses = s.expenses.map { if (it.id == id) it.copy(deleted = true, pending = true) else it })
}
@@ -19,33 +19,37 @@ object FakeScenarios {
val partner = User(UserId("01920000-0000-7000-8000-0000000000a2"), Role.MEMBER, "Anna", null)
private val ownerDevice = DeviceId("01920000-0000-7000-8000-0000000000d1")
/** The ten seeded default categories (SPEC AC-7.2, ids of §14.1). */
/** The 14 seeded default categories (SPEC AC-7.2 with UX-8, ids of §14.1 and schema v2), in sort order. */
val defaultCategories: List<Category> = listOf(
Triple(DefaultCategoryKey.GROCERIES, "shopping_cart", ColorToken.GREEN),
Triple(DefaultCategoryKey.RESTAURANTS, "restaurant", ColorToken.ORANGE),
Triple(DefaultCategoryKey.HOME, "home", ColorToken.BROWN),
Triple(DefaultCategoryKey.UTILITIES, "bolt", ColorToken.YELLOW),
Triple(DefaultCategoryKey.TRANSPORT, "directions_car", ColorToken.BLUE),
Triple(DefaultCategoryKey.TRAVEL, "flight", ColorToken.TEAL),
Triple(DefaultCategoryKey.ENTERTAINMENT, "movie", ColorToken.PURPLE),
Triple(DefaultCategoryKey.HEALTH, "medical_services", ColorToken.RED),
Triple(DefaultCategoryKey.SHOPPING, "shopping_bag", ColorToken.PINK),
Triple(DefaultCategoryKey.OTHER, "category", ColorToken.GREY),
).mapIndexed { index, (key, icon, color) ->
DefaultSeed(0x01, DefaultCategoryKey.GROCERIES, "shopping_cart", ColorToken.GREEN, 10),
DefaultSeed(0x02, DefaultCategoryKey.RESTAURANTS, "restaurant", ColorToken.ORANGE, 20),
DefaultSeed(0x11, DefaultCategoryKey.HOUSING, "key", ColorToken.INDIGO, 25),
DefaultSeed(0x03, DefaultCategoryKey.HOME, "home", ColorToken.BROWN, 30),
DefaultSeed(0x04, DefaultCategoryKey.UTILITIES, "bolt", ColorToken.YELLOW, 40),
DefaultSeed(0x12, DefaultCategoryKey.INSURANCE, "shield", ColorToken.SLATE, 45),
DefaultSeed(0x05, DefaultCategoryKey.TRANSPORT, "directions_car", ColorToken.BLUE, 50),
DefaultSeed(0x06, DefaultCategoryKey.TRAVEL, "flight", ColorToken.TEAL, 60),
DefaultSeed(0x07, DefaultCategoryKey.ENTERTAINMENT, "movie", ColorToken.PURPLE, 70),
DefaultSeed(0x08, DefaultCategoryKey.HEALTH, "medical_services", ColorToken.RED, 80),
DefaultSeed(0x13, DefaultCategoryKey.CHILDREN, "child_care", ColorToken.LIME, 85),
DefaultSeed(0x09, DefaultCategoryKey.SHOPPING, "shopping_bag", ColorToken.PINK, 90),
DefaultSeed(0x14, DefaultCategoryKey.FEES, "account_balance", ColorToken.AMBER, 95),
DefaultSeed(0x0a, DefaultCategoryKey.OTHER, "category", ColorToken.GREY, 100),
).map { seed ->
Category(
id = CategoryId("01920000-0000-7000-8000-" + (index + 1).toString(16).padStart(12, '0')),
defaultKey = key,
id = CategoryId("01920000-0000-7000-8000-" + seed.idSuffix.toString(16).padStart(12, '0')),
defaultKey = seed.key,
customName = null,
icon = icon,
color = color,
sortOrder = (index + 1) * 10,
icon = seed.icon,
color = seed.color,
sortOrder = seed.sortOrder,
archived = false,
)
}
/**
* The empty scenario: the household is complete (owner and partner, no pictures), online, synced, no accounts, the
* ten default categories only, no expenses, settlements, templates, invites or rejections. Every required field
* 14 default categories only, no expenses, settlements, templates, invites or rejections. Every required field
* of every `*State` is satisfied.
*/
val empty = FakeScenario(
@@ -75,3 +79,5 @@ object FakeScenarios {
lastCategoryFor = emptyMap(),
)
}
private class DefaultSeed(val idSuffix: Int, val key: DefaultCategoryKey, val icon: String, val color: ColorToken, val sortOrder: Int)
@@ -46,7 +46,7 @@ class EmptyScenarioTest {
assertNull(fakes.settlements.detail(SettlementId("none")).first())
val options = fakes.expenses.editorOptions().first()
assertNull(options.defaultAccountId)
assertEquals(10, options.categories.size)
assertEquals(14, options.categories.size)
val settle = fakes.settlements.start(null, null)
assertNull(settle.amountOre)
assertTrue(settle.lines.isEmpty())
@@ -63,7 +63,7 @@ class EmptyScenarioTest {
@Test
fun settingsRepositoriesAnswer() = runTest {
assertEquals(10, fakes.categories.all().first().size)
assertEquals(14, fakes.categories.all().first().size)
assertTrue(fakes.recurring.all().first().isEmpty())
assertEquals(1, (fakes.devices.list() as OnlineResult.Ok).value.size)
assertTrue((fakes.invites.list() as OnlineResult.Ok).value.isEmpty())
@@ -0,0 +1,63 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryIcons
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SaveResult
import se.hagstedt.kvitt.model.SplitMode
/** The 14 seeded defaults (AC-7.2, UX-8) and the fake suggestCategory (UX-3) through :domain. */
class FakeCategorySuggestionTest {
private val account = Account(AccountId("01920000-0000-7000-8000-0000000000c1"), "Vardag", mapOf(FakeScenarios.owner.id to 5000, FakeScenarios.partner.id to 5000), "2026-10-01T00:00:00.000Z", archived = false)
private val fakes = FakeRepositories(FakeScenarios.empty.copy(accounts = listOf(account)))
private fun id(key: DefaultCategoryKey) = FakeScenarios.defaultCategories.single { it.defaultKey == key }.id
private suspend fun suggest(description: String, accountId: AccountId? = account.id) = fakes.expenses.suggestCategory(accountId, description)
private suspend fun save(description: String, key: DefaultCategoryKey) {
val draft = ExpenseDraft(null, account.id, id(key), 10_000, description, "", "2026-10-16", FakeScenarios.owner.id, SplitMode.DEFAULT, null, null, null)
assertTrue(fakes.expenses.save(draft) is SaveResult.Saved)
}
@Test
fun defaultsAreTheFourteenOfAc7_2_withTheSchemaV2Ids() {
val defaults = FakeScenarios.defaultCategories
assertEquals(DefaultCategoryKey.entries, defaults.map { it.defaultKey })
assertEquals(listOf(10, 20, 25, 30, 40, 45, 50, 60, 70, 80, 85, 90, 95, 100), defaults.map { it.sortOrder })
assertEquals("01920000-0000-7000-8000-000000000011", id(DefaultCategoryKey.HOUSING).value)
assertEquals("01920000-0000-7000-8000-000000000014", id(DefaultCategoryKey.FEES).value)
assertEquals("01920000-0000-7000-8000-00000000000a", id(DefaultCategoryKey.OTHER).value)
assertTrue(defaults.all { it.icon in CategoryIcons.ALL })
assertEquals(14, defaults.map { it.id }.toSet().size)
}
@Test
fun keywordsThenHistoryThenTheDefault() = runTest {
assertEquals(id(DefaultCategoryKey.OTHER), suggest(""))
assertEquals(id(DefaultCategoryKey.GROCERIES), suggest("ICA Kvantum"))
assertEquals(id(DefaultCategoryKey.HOUSING), suggest("Hyra"))
assertEquals(id(DefaultCategoryKey.OTHER), suggest("qqq"))
save("ICA Kvantum", DefaultCategoryKey.HOME)
assertEquals("history beats the keyword table", id(DefaultCategoryKey.HOME), suggest("ica kvantum"))
assertEquals("AC-8.6: last used in the account", id(DefaultCategoryKey.HOME), suggest("qqq"))
assertEquals(id(DefaultCategoryKey.OTHER), suggest("qqq", accountId = null))
}
@Test
fun archivedCategoriesAreSkipped() = runTest {
save("Willys", DefaultCategoryKey.HOME)
assertEquals(OnlineResult.Ok(Unit), fakes.categories.setArchived(id(DefaultCategoryKey.HOME), true))
assertEquals(id(DefaultCategoryKey.GROCERIES), suggest("Willys"))
assertEquals(OnlineResult.Ok(Unit), fakes.categories.setArchived(id(DefaultCategoryKey.GROCERIES), true))
assertEquals(id(DefaultCategoryKey.OTHER), suggest("Willys"))
}
}