M7e: feature analytics (S-11, S-12) with fake scenarios and screenshot tests

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 10:41:03 +02:00
parent 1ea752cd80
commit ea7d494ffa
18 changed files with 1738 additions and 15 deletions
@@ -0,0 +1,194 @@
package se.hagstedt.kvitt.fakes.analytics
import se.hagstedt.kvitt.fakes.FakeExpense
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AnalyticsFilter
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
/**
* Scenarios of S-11 (analytics) and S-12 (drill-down), M7e. All share FakeScenarios.empty's household and clock
* (today 2026-10-17); the expense data is plain and deterministic so every screenshot is reproducible.
*
* - [loaded]: May to October 2026, eleven default categories, four accounts (one archived), settlements none.
* - [offline]: [loaded] while offline, with pending local changes that analytics already include (AC-14.1).
* - [loading]: the first sync has not finished: nothing synced yet, no categories, no accounts, no expenses.
* - [long]: long Swedish category and account names, 7-digit totals, many groups.
*
* The filters of the named S-11 states are [defaultFilter], [weekFilter] and [emptyFilter]; [customPickerStart] and
* [customPickerEnd] are the custom range shown in the picker frame (a past month, so the picker never marks today).
*/
object AnalyticsScenarios {
private val me = FakeScenarios.owner.id
private val partner = FakeScenarios.partner.id
val vardag = account(0x01, "Vardag", "2025-01-10T09:00:00.000Z")
val huset = account(0x02, "Huset", "2025-03-02T09:00:00.000Z")
val japan = account(0x03, "Japan 2027", "2026-06-01T09:00:00.000Z")
val stugan = account(0x04, "Stugan", "2025-05-20T09:00:00.000Z", archived = true)
private fun category(key: DefaultCategoryKey): Category = FakeScenarios.defaultCategories.first { it.defaultKey == key }
val groceries = category(DefaultCategoryKey.GROCERIES)
val restaurants = category(DefaultCategoryKey.RESTAURANTS)
val housing = category(DefaultCategoryKey.HOUSING)
val home = category(DefaultCategoryKey.HOME)
val utilities = category(DefaultCategoryKey.UTILITIES)
val insurance = category(DefaultCategoryKey.INSURANCE)
val transport = category(DefaultCategoryKey.TRANSPORT)
val travel = category(DefaultCategoryKey.TRAVEL)
val entertainment = category(DefaultCategoryKey.ENTERTAINMENT)
val health = category(DefaultCategoryKey.HEALTH)
val shopping = category(DefaultCategoryKey.SHOPPING)
/** AC-14.2 to AC-14.5 defaults: last 6 months, month, household, category. */
val defaultFilter = AnalyticsFilter(RangePreset.LAST_6_MONTHS, null, null, Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
/** S-11-analytics-week: custom 24 Aug to 17 Oct, week, my share, by account. */
val weekFilter = AnalyticsFilter(RangePreset.CUSTOM, "2026-08-24", "2026-10-17", Granularity.WEEK, Measure.MY_SHARE, GroupBy.ACCOUNT)
/** S-11-analytics-empty: custom 1 to 30 November, a period without expenses. */
val emptyFilter = AnalyticsFilter(RangePreset.CUSTOM, "2026-11-01", "2026-11-30", Granularity.MONTH, Measure.HOUSEHOLD, GroupBy.CATEGORY)
const val customPickerStart = "2025-03-03"
const val customPickerEnd = "2025-03-23"
private class Line(val day: Int, val description: String, val category: Category, val account: Account, val ore: Long, val paidByMe: Boolean)
/** Recurring monthly spending; travel and the archived account vary by month. */
private fun month(month: Int): List<Line> {
val m = month.toLong()
val lines = mutableListOf(
Line(3, "Hyra", housing, huset, 1_033_300, paidByMe = month % 2 == 0),
Line(5, "Hemförsäkring", insurance, huset, 32_900, paidByMe = true),
Line(9, "Elräkning", utilities, huset, 98_000 + m * 4_150, paidByMe = false),
Line(4, "Willys Hornstull", groceries, vardag, 152_045 + m * 3_120, paidByMe = true),
Line(11, "ICA Maxi Lindhagen", groceries, vardag, 118_760 + m * 2_480, paidByMe = false),
Line(19, "Coop Liljeholmen", groceries, vardag, 136_510 + m * 1_970, paidByMe = true),
Line(25, "Hemköp Odenplan", groceries, vardag, 97_300 + m * 2_210, paidByMe = false),
Line(8, "Pizzeria Mamma Mia", restaurants, vardag, 48_400 + m * 1_230, paidByMe = false),
Line(22, "Café Pascal", restaurants, vardag, 21_600 + m * 980, paidByMe = true),
Line(14, "SL-kort", transport, vardag, 98_000, paidByMe = month % 2 == 1),
Line(13, "Biljetter Dramaten", entertainment, vardag, 42_000 + m * 2_300, paidByMe = true),
Line(17, "Apotek Hjärtat", health, vardag, 18_950 + m * 1_440, paidByMe = false),
Line(27, "Clas Ohlson", home, vardag, 38_990 + m * 3_330, paidByMe = true),
Line(21, "Åhléns City", shopping, vardag, 64_500 + m * 2_750, paidByMe = false),
)
when (month) {
5 -> lines += Line(16, "Färja till stugan", travel, stugan, 86_000, paidByMe = true)
6 -> lines += Line(20, "Midsommar i stugan", travel, stugan, 312_400, paidByMe = false)
7 -> lines += Line(6, "Tåg till Göteborg", travel, vardag, 412_800, paidByMe = true)
8 -> lines += Line(12, "Hotell i Visby", travel, vardag, 156_000, paidByMe = false)
9 -> {
lines += Line(15, "Flyg till Tokyo", travel, japan, 1_384_000, paidByMe = true)
lines += Line(23, "Ryokan i Kyoto", travel, japan, 412_600, paidByMe = false)
}
10 -> lines += Line(2, "Japan Rail Pass", travel, japan, 960_000, paidByMe = true)
}
return lines
}
/** October's groceries are the four rows of the S-12 frame (3 910,00 kr in total). */
private val october = listOf(
Line(16, "ICA Maxi Lindhagen", groceries, vardag, 124_760, paidByMe = false),
Line(12, "Willys Hornstull", groceries, vardag, 86_435, paidByMe = true),
Line(7, "ICA Nära Hornstull", groceries, vardag, 78_560, paidByMe = false),
Line(1, "Lidl Liljeholmen", groceries, vardag, 101_245, paidByMe = true),
)
private fun lines(): List<Pair<String, Line>> {
val earlier = (5..9).flatMap { m -> month(m).map { date(2026, m, it.day) to it } }
val oct = month(10).filter { it.category != groceries && it.day <= 17 }.map { date(2026, 10, it.day) to it } +
october.map { date(2026, 10, it.day) to it }
return earlier + oct
}
private val expenses: List<FakeExpense> = lines().mapIndexed { index, (date, line) -> expense(index + 1, date, line) }
val loaded: FakeScenario = FakeScenarios.empty.copy(
accounts = listOf(vardag, huset, japan, stugan),
expenses = expenses,
defaultAccountId = vardag.id,
)
/** Offline with two pending changes: an October expense edited offline and a new one, both counted (AC-14.1). */
val offline: FakeScenario = loaded.copy(
online = false,
expenses = expenses.map { if (it.description == "ICA Maxi Lindhagen" && it.date == "2026-10-16") it.copy(pending = true) else it } +
expense(900, "2026-10-17", Line(17, "Systembolaget", groceries, vardag, 46_900, paidByMe = true)).copy(pending = true),
)
/** First sync running: nothing on the phone yet (SPEC §13.3 "loading (first sync only)"). */
val loading: FakeScenario = FakeScenarios.empty.copy(
categories = emptyList(),
lastSuccessAt = null,
syncRunning = true,
)
private val longGroceries = Category(CategoryId(id(0xc1)), null, "Livsmedel och hushållsartiklar till sommarstugan", "shopping_cart", ColorToken.GREEN, 110, archived = false)
private val longRenovation = Category(CategoryId(id(0xc2)), null, "Renoveringsprojektet i källarförrådet", "build", ColorToken.AMBER, 120, archived = false)
private val longChildren = Category(CategoryId(id(0xc3)), null, "Barnens fritidsaktiviteter och utrustning", "child_care", ColorToken.LIME, 130, archived = true)
private val longAccount = account(0x05, "Sommarstugan i Österåker 2027", "2025-02-01T09:00:00.000Z")
private val longArchived = account(0x06, "Gemensamma sparkontot för bilen", "2025-04-01T09:00:00.000Z", archived = true)
/** Long Swedish names and 7-digit totals over every default category and three long custom ones. */
val long: FakeScenario = run {
val categories = FakeScenarios.defaultCategories + listOf(longGroceries, longRenovation, longChildren)
val accounts = listOf(vardag, longAccount, longArchived, huset)
val rows = (5..10).flatMap { m ->
categories.mapIndexed { i, category ->
val account = accounts[(i + m) % accounts.size]
val day = ((i * 3 + m) % 17) + 1
val ore = 4_812_345L + i * 1_377_000L + m * 213_500L
date(2026, m, day) to Line(day, "Inköp ${i + 1} för ${category.customName ?: category.defaultKey?.name?.lowercase()}", category, account, ore, paidByMe = (i + m) % 2 == 0)
}
}
FakeScenarios.empty.copy(
accounts = accounts,
categories = categories,
expenses = rows.mapIndexed { index, (date, line) -> expense(2000 + index, date, line) },
)
}
/** The long category of [long] used for its drill-down. */
val longCategory: Category get() = longGroceries
private fun expense(n: Int, date: String, line: Line): FakeExpense {
val payer = if (line.paidByMe) me else partner
val other = if (line.paidByMe) partner else me
val otherShare = line.ore / 2 // §9.1: the payer gets the extra öre
return FakeExpense(
id = ExpenseId("01920000-0000-7000-8000-e" + n.toString(16).padStart(11, '0')),
accountId = line.account.id,
categoryId = line.category.id,
amountOre = line.ore,
description = line.description,
notes = "",
date = date,
payer = payer,
mode = SplitMode.DEFAULT,
shares = listOf(Share(payer, line.ore - otherShare, 5000), Share(other, otherShare, 5000)),
createdAt = "${date}T12:00:00.000Z",
)
}
private fun account(n: Int, name: String, createdAt: String, archived: Boolean = false) =
Account(AccountId(id(0xa00 + n)), name, mapOf(me to 5000, partner to 5000), createdAt, archived)
private fun id(n: Int): String = "01920000-0000-7000-8000-" + n.toString(16).padStart(12, '0')
private fun date(year: Int, month: Int, day: Int): String = "$year-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}"
}