From b09f3220a1dc0b57c15b9efa0bf038038a583213 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 08:21:53 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/core/fakes/build.gradle.kts | 37 +++++ .../kvitt/fakes/FakeCategorySuggestion.kt | 48 ++++++ .../kvitt/fakes/FakeExpenseRepository.kt | 5 + .../se/hagstedt/kvitt/fakes/FakeScenarios.kt | 42 ++--- .../hagstedt/kvitt/fakes/EmptyScenarioTest.kt | 4 +- .../kvitt/fakes/FakeCategorySuggestionTest.kt | 63 ++++++++ .../kotlin/se/hagstedt/kvitt/model/Enums.kt | 12 +- .../se/hagstedt/kvitt/model/Expenses.kt | 6 + .../se/hagstedt/kvitt/model/ContractTest.kt | 22 ++- android/data/build.gradle.kts | 39 +++++ .../se/hagstedt/kvitt/data/di/DataModule.kt | 15 +- .../kvitt/data/repo/CategorySuggestions.kt | 65 ++++++++ .../kvitt/data/repo/ExpenseRepositoryImpl.kt | 11 +- .../se/hagstedt/kvitt/data/TestSupport.kt | 6 +- .../kvitt/data/repo/CategoryKeywordsTest.kt | 73 +++++++++ .../kvitt/data/repo/CategorySuggestionTest.kt | 144 ++++++++++++++++++ .../kvitt/domain/CategorySuggestion.kt | 27 +++- .../kvitt/domain/CategoryKeywordsTest.kt | 29 ++++ 18 files changed, 608 insertions(+), 40 deletions(-) create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestion.kt create mode 100644 android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestionTest.kt create mode 100644 android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestions.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategoryKeywordsTest.kt create mode 100644 android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestionTest.kt create mode 100644 android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/CategoryKeywordsTest.kt diff --git a/android/core/fakes/build.gradle.kts b/android/core/fakes/build.gradle.kts index e86fef0..b960518 100644 --- a/android/core/fakes/build.gradle.kts +++ b/android/core/fakes/build.gradle.kts @@ -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 + + @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") { + 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) } +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestion.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestion.kt new file mode 100644 index 0000000..01bcb51 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestion.kt @@ -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 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, + ) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt index 911e39f..5207636 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeExpenseRepository.kt @@ -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 }) } diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt index bd65c76..d627928 100644 --- a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt @@ -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 = 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) diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt index b2c097e..8d6a01c 100644 --- a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/EmptyScenarioTest.kt @@ -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()) diff --git a/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestionTest.kt b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestionTest.kt new file mode 100644 index 0000000..f2c9984 --- /dev/null +++ b/android/core/fakes/src/test/kotlin/se/hagstedt/kvitt/fakes/FakeCategorySuggestionTest.kt @@ -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")) + } + +} diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt index f5a55e3..79a1d83 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt @@ -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 = 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", ) } diff --git a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt index 64e4f01..908af79 100644 --- a/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt +++ b/android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Expenses.kt @@ -22,4 +22,10 @@ interface ExpenseRepository { suspend fun save(draft: ExpenseDraft): SaveResult suspend fun delete(id: ExpenseId) fun detail(id: ExpenseId): Flow + + /** + * 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? } diff --git a/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt index 99e3398..ff70db9 100644 --- a/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt +++ b/android/core/model/src/test/kotlin/se/hagstedt/kvitt/model/ContractTest.kt @@ -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 }) } } } diff --git a/android/data/build.gradle.kts b/android/data/build.gradle.kts index 5fd9254..69829ca 100644 --- a/android/data/build.gradle.kts +++ b/android/data/build.gradle.kts @@ -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().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 + + @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") { + 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) } } diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt index 6965b96..ddbd13f 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/di/DataModule.kt @@ -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 = diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestions.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestions.kt new file mode 100644 index 0000000..0ed212b --- /dev/null +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestions.kt @@ -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 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, + keywords: List, +): 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" diff --git a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt index 1b4df43..0c3ba7f 100644 --- a/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt +++ b/android/data/src/main/kotlin/se/hagstedt/kvitt/data/repo/ExpenseRepositoryImpl.kt @@ -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" diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt index 86d5a28..e0e5c42 100644 --- a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/TestSupport.kt @@ -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 SaveResult.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) diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategoryKeywordsTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategoryKeywordsTest.kt new file mode 100644 index 0000000..2d99afd --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategoryKeywordsTest.kt @@ -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 { a, b -> compareCodePoints(a, b) } + val sorted = entries.sortedWith(compareBy(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) +} diff --git a/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestionTest.kt b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestionTest.kt new file mode 100644 index 0000000..e681e93 --- /dev/null +++ b/android/data/src/test/kotlin/se/hagstedt/kvitt/data/repo/CategorySuggestionTest.kt @@ -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" + } +} diff --git a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt index c528289..588466c 100644 --- a/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt +++ b/android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt @@ -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 { + 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 = compareBy({ it.expenseDate }, { it.createdAt }) diff --git a/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/CategoryKeywordsTest.kt b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/CategoryKeywordsTest.kt new file mode 100644 index 0000000..0c28fd4 --- /dev/null +++ b/android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/CategoryKeywordsTest.kt @@ -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") } + } +}