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