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