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