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