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
+37
View File
@@ -10,3 +10,40 @@ dependencies {
implementation(project(":domain"))
testImplementation(libs.kotlinx.coroutines.test)
}
// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6) is bundled as the Java resource
// se/hagstedt/kvitt/fakes/category-keywords.csv, copied from contracts/category-keywords.csv at build time (never
// hand-copied). A missing table fails the build: the task's input file must exist.
abstract class CopyCategoryKeywords : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val table: RegularFileProperty
@get:Input
abstract val resourcePath: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun copy() {
val source = table.get().asFile
if (!source.isFile) throw GradleException("missing ${source.path}")
val root = outputDir.get().asFile
root.deleteRecursively()
val target = root.resolve(resourcePath.get())
target.parentFile.mkdirs()
source.copyTo(target)
}
}
val categoryKeywordsTable = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv")
val copyCategoryKeywords = tasks.register<CopyCategoryKeywords>("copyCategoryKeywords") {
description = "Copies contracts/category-keywords.csv into the main Java resources."
table.set(categoryKeywordsTable)
resourcePath.set("se/hagstedt/kvitt/fakes/category-keywords.csv")
outputDir.set(layout.buildDirectory.dir("generated/category-keywords"))
}
androidComponents {
onVariants { variant -> variant.sources.resources?.addGeneratedSourceDirectory(copyCategoryKeywords, CopyCategoryKeywords::outputDir) }
}
@@ -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"))
}
}
@@ -14,9 +14,13 @@ enum class AuditAction { CREATE, UPDATE, DELETE }
enum class AuditSource { APP, RECURRING, IMPORT }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOME, UTILITIES, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, SHOPPING, OTHER }
/** The 14 default categories of AC-7.2 (UX-8), in their sort order. */
enum class DefaultCategoryKey {
GROCERIES, RESTAURANTS, HOUSING, HOME, UTILITIES, INSURANCE, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, CHILDREN, SHOPPING,
FEES, OTHER,
}
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME }
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME, SLATE, AMBER }
enum class RangePreset { THIS_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS, THIS_YEAR, ALL_TIME, CUSTOM }
@@ -28,12 +32,12 @@ enum class GroupBy { CATEGORY, ACCOUNT }
enum class SyncRejectionReason { STALE, DELETED, VALIDATION_FAILED, REFERENCE_NOT_FOUND, CLOCK_SKEW }
/** Category icon tokens: Material Symbols names, the exhaustive v1 set of SPEC §9.7, in that order. */
/** Category icon tokens: Material Symbols names, the exhaustive set of SPEC §9.7 in that order (33 with UX-8). */
object CategoryIcons {
val ALL: List<String> = listOf(
"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",
"celebration", "build", "chair", "subscriptions", "category", "key", "shield", "account_balance",
)
}
@@ -22,4 +22,10 @@ interface ExpenseRepository {
suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId>
suspend fun delete(id: ExpenseId)
fun detail(id: ExpenseId): Flow<ExpenseDetailState?>
/**
* UX-3 (category-suggestion.md CS-5): the category for a new expense with [description] in [accountId]: history,
* then the keyword table, then the AC-8.6 default. Null only when no active category exists to fall back to.
*/
suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId?
}
@@ -19,11 +19,14 @@ class ContractTest {
assertEquals(listOf("create", "update", "delete"), wire(AuditAction.entries.toTypedArray()))
assertEquals(listOf("app", "recurring", "import"), wire(AuditSource.entries.toTypedArray()))
assertEquals(
listOf("groceries", "restaurants", "home", "utilities", "transport", "travel", "entertainment", "health", "shopping", "other"),
listOf(
"groceries", "restaurants", "housing", "home", "utilities", "insurance", "transport", "travel", "entertainment",
"health", "children", "shopping", "fees", "other",
),
wire(DefaultCategoryKey.entries.toTypedArray()),
)
assertEquals(
listOf("green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime"),
listOf("green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", "indigo", "lime", "slate", "amber"),
wire(ColorToken.entries.toTypedArray()),
)
assertEquals(
@@ -59,13 +62,16 @@ class ContractTest {
}
@Test
fun categoryIconsAreTheThirtyTokensOfSpec9_7InOrder() {
assertEquals(30, CategoryIcons.ALL.size)
assertEquals(30, CategoryIcons.ALL.toSet().size)
fun categoryIconsAreTheThirtyThreeTokensOfSpec9_7InOrder() {
assertEquals(33, CategoryIcons.ALL.size)
assertEquals(33, CategoryIcons.ALL.toSet().size)
assertEquals("shopping_cart", CategoryIcons.ALL.first())
assertEquals("category", CategoryIcons.ALL.last())
// The ten default categories (AC-7.2) use icons from the set.
listOf("shopping_cart", "restaurant", "home", "bolt", "directions_car", "flight", "movie", "medical_services", "shopping_bag", "category")
assertEquals(listOf("category", "key", "shield", "account_balance"), CategoryIcons.ALL.takeLast(4))
// The 14 default categories (AC-7.2, UX-8) use icons from the set.
listOf(
"shopping_cart", "restaurant", "key", "home", "bolt", "shield", "directions_car", "flight", "movie", "medical_services",
"child_care", "shopping_bag", "account_balance", "category",
)
.forEach { assertEquals(it, CategoryIcons.ALL.single { icon -> icon == it }) }
}
}
+39
View File
@@ -40,9 +40,48 @@ dependencies {
// so a changed contract file re-runs the tests.
val contractsExamples = rootProject.layout.projectDirectory.dir("../contracts/examples")
val roomSchemas = layout.projectDirectory.dir("schemas")
val categoryKeywordsTable = rootProject.layout.projectDirectory.file("../contracts/category-keywords.csv")
tasks.withType<Test>().configureEach {
inputs.dir(contractsExamples).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("contractsExamples")
inputs.dir(roomSchemas).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("roomSchemas")
systemProperty("kvitt.contracts.examples", contractsExamples.asFile.absolutePath)
systemProperty("kvitt.room.schemas", roomSchemas.asFile.absolutePath)
inputs.file(categoryKeywordsTable).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("categoryKeywords")
systemProperty("kvitt.contracts.keywords", categoryKeywordsTable.asFile.absolutePath)
}
// The keyword table of the category suggestion (UX-3, SPEC §9.11 CS-6) is bundled as the Java resource
// se/hagstedt/kvitt/data/category-keywords.csv, copied from contracts/category-keywords.csv at build time (never
// hand-copied). A missing table fails the build: the task's input file must exist.
abstract class CopyCategoryKeywords : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val table: RegularFileProperty
@get:Input
abstract val resourcePath: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun copy() {
val source = table.get().asFile
if (!source.isFile) throw GradleException("missing ${source.path}")
val root = outputDir.get().asFile
root.deleteRecursively()
val target = root.resolve(resourcePath.get())
target.parentFile.mkdirs()
source.copyTo(target)
}
}
val copyCategoryKeywords = tasks.register<CopyCategoryKeywords>("copyCategoryKeywords") {
description = "Copies contracts/category-keywords.csv into the main Java resources."
table.set(categoryKeywordsTable)
resourcePath.set("se/hagstedt/kvitt/data/category-keywords.csv")
outputDir.set(layout.buildDirectory.dir("generated/category-keywords"))
}
androidComponents {
onVariants { variant -> variant.sources.resources?.addGeneratedSourceDirectory(copyCategoryKeywords, CopyCategoryKeywords::outputDir) }
}
@@ -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"
}
}
@@ -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") }
}
}