M12c: category contract v2 on Android (14 defaults, suggestCategory)

- :core:model: DefaultCategoryKey in AC-7.2 order (14 keys: housing, insurance, children, fees added),
  ColorToken + SLATE, AMBER, CategoryIcons.ALL + key, shield, account_balance (33),
  ExpenseRepository.suggestCategory(accountId, description); ContractTest updated.
- :domain: parseCategoryKeywords (pure, shared by :data and :core:fakes) with a parser test; the
  suggestCategory records and rule unchanged, suggest_category vectors pass.
- :data: PlatformUnicodeText (java.text.Normalizer, Character), CategoryKeywords parsed once from the
  Java resource that copyCategoryKeywords copies from contracts/category-keywords.csv at build time
  (missing file fails the build), ExpenseRepositoryImpl.suggestCategory over the Room snapshot and the
  DataStore last-used category; tests: byte identity and the 14.8 rules of the bundled table, the
  UnicodeText binding, and Room/Robolectric cases for history, first token, ties, keywords, fallback,
  archived categories, deleted expenses, both users and accounts, null account.
- :core:fakes: the 14 defaults with the schema v2 ids, fake suggestCategory through :domain with its own
  build-time copy of the table; tests.
- Room schema unchanged (version 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:21:53 +02:00
parent d39eb2c79b
commit b09f3220a1
18 changed files with 608 additions and 40 deletions
@@ -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 }) }
}
}