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