M7f: feature settings, S-13 to S-15 and S-19 to S-22
Hub, profile (photo picker), categories (drag reorder, editor with D-059 delete reasons), recurring expenses (list and S-7-style editor with schedule, AC-9.8 default start), devices, invites and about, against the :core:model interfaces. Scenarios and scripted online failures in fakes/settings. 84 states screenshot-tested in sv/en x light/dark (336 goldens); state-holder unit tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
package se.hagstedt.kvitt.fakes.settings
|
||||
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.CategoryRepository
|
||||
import se.hagstedt.kvitt.model.ColorToken
|
||||
import se.hagstedt.kvitt.model.Device
|
||||
import se.hagstedt.kvitt.model.DeviceId
|
||||
import se.hagstedt.kvitt.model.DeviceRepository
|
||||
import se.hagstedt.kvitt.model.Invite
|
||||
import se.hagstedt.kvitt.model.InviteId
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.InviteRepository
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.ProfileRepository
|
||||
import se.hagstedt.kvitt.model.RecurringRepository
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.TemplateId
|
||||
import se.hagstedt.kvitt.model.UpdateRepository
|
||||
import se.hagstedt.kvitt.model.UpdateState
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** The code of the design spec's S-21 frame, so a created invite reads like a real one (§8.3 alphabet). */
|
||||
const val SAMPLE_CODE = "7K3MQX9D2HPA"
|
||||
|
||||
/**
|
||||
* What the next online calls of the scripted repositories do instead of the fake's answer: fail with [fail] (for
|
||||
* example `Failed("network", null)` or `Failed("image_invalid", null)`), or never return while [hang] is set (an upload
|
||||
* or a list request in progress). Null and false: the fake answers.
|
||||
*/
|
||||
class OnlineScript {
|
||||
var fail: OnlineResult.Failed? = null
|
||||
var hang: Boolean = false
|
||||
|
||||
suspend fun <T> run(call: suspend () -> OnlineResult<T>): OnlineResult<T> {
|
||||
if (hang) awaitCancellation()
|
||||
return fail ?: call()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fakes of [FakeRepositories] with the online calls of the settings screens routed through one [script]: the
|
||||
* error, loading and in-progress states of S-14, S-15, S-19, S-20, S-21 and S-22 (M7f). Reads pass straight through.
|
||||
*/
|
||||
class ScriptedSettingsRepositories(scenario: FakeScenario) {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val script = OnlineScript()
|
||||
|
||||
val profile: ProfileRepository = object : ProfileRepository by fakes.profile {
|
||||
override suspend fun setDisplayName(name: String) = script.run { fakes.profile.setDisplayName(name) }
|
||||
override suspend fun setPicture(bytes: ByteArray, mimeType: String) = script.run { fakes.profile.setPicture(bytes, mimeType) }
|
||||
override suspend fun removePicture() = script.run { fakes.profile.removePicture() }
|
||||
}
|
||||
|
||||
val categories: CategoryRepository = object : CategoryRepository by fakes.categories {
|
||||
override suspend fun create(name: String, icon: String, color: ColorToken) = script.run { fakes.categories.create(name, icon, color) }
|
||||
override suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken) = script.run { fakes.categories.update(id, name, icon, color) }
|
||||
override suspend fun reorder(idsInOrder: List<CategoryId>) = script.run { fakes.categories.reorder(idsInOrder) }
|
||||
override suspend fun setArchived(id: CategoryId, archived: Boolean) = script.run { fakes.categories.setArchived(id, archived) }
|
||||
override suspend fun delete(id: CategoryId) = script.run { fakes.categories.delete(id) }
|
||||
override fun deletable(id: CategoryId): Flow<Boolean> = fakes.categories.deletable(id)
|
||||
}
|
||||
|
||||
val recurring: RecurringRepository = object : RecurringRepository by fakes.recurring {
|
||||
override suspend fun create(t: RecurringTemplate) = script.run { fakes.recurring.create(t) }
|
||||
override suspend fun update(t: RecurringTemplate) = script.run { fakes.recurring.update(t) }
|
||||
override suspend fun delete(id: TemplateId) = script.run { fakes.recurring.delete(id) }
|
||||
}
|
||||
|
||||
val devices: DeviceRepository = object : DeviceRepository {
|
||||
override suspend fun list(): OnlineResult<List<Device>> = script.run { fakes.devices.list() }
|
||||
override suspend fun revoke(id: DeviceId): OnlineResult<Unit> = script.run { fakes.devices.revoke(id) }
|
||||
}
|
||||
|
||||
val invites: InviteRepository = object : InviteRepository {
|
||||
override suspend fun list(): OnlineResult<List<Invite>> = script.run { fakes.invites.list() }
|
||||
override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite> = script.run {
|
||||
when (val created = fakes.invites.create(kind, targetUser)) {
|
||||
is OnlineResult.Ok -> OnlineResult.Ok(created.value.copy(code = SAMPLE_CODE, link = created.value.link?.substringBeforeLast('/') + "/" + SAMPLE_CODE))
|
||||
else -> created
|
||||
}
|
||||
}
|
||||
override suspend fun cancel(id: InviteId): OnlineResult<Unit> = script.run { fakes.invites.cancel(id) }
|
||||
}
|
||||
|
||||
/** `check` of the fake leaves the state unchanged; [afterCheck] is the state a check finds (S-22 result states). */
|
||||
var afterCheck: UpdateState? = null
|
||||
|
||||
val update: UpdateRepository = object : UpdateRepository {
|
||||
override val state = fakes.update.state
|
||||
|
||||
override suspend fun check() {
|
||||
if (script.hang) awaitCancellation()
|
||||
afterCheck?.let { found -> fakes.store.edit { it.copy(update = found) } }
|
||||
}
|
||||
|
||||
override suspend fun install() = fakes.update.install()
|
||||
}
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package se.hagstedt.kvitt.fakes.settings
|
||||
|
||||
import se.hagstedt.kvitt.fakes.FakeExpense
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.model.AboutState
|
||||
import se.hagstedt.kvitt.model.Account
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
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.Device
|
||||
import se.hagstedt.kvitt.model.DeviceId
|
||||
import se.hagstedt.kvitt.model.ExpenseDraft
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.model.Invite
|
||||
import se.hagstedt.kvitt.model.InviteId
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.Role
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import se.hagstedt.kvitt.model.Share
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.model.TemplateId
|
||||
import se.hagstedt.kvitt.model.UpdateInfo
|
||||
import se.hagstedt.kvitt.model.UpdateState
|
||||
import se.hagstedt.kvitt.model.User
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
|
||||
/**
|
||||
* Scenarios of `:feature:settings` (M7f; S-13 to S-15, S-19 to S-22), as in the design spec's frames: Jacob (admin) and
|
||||
* Anna, the accounts Vardag, Huset and Japan 2027, the 14 defaults plus the archived custom "Pant", four recurring
|
||||
* templates, both phones and one pending device invite. The phone's clock is 17 October 2026 14:02 Stockholm.
|
||||
*/
|
||||
object SettingsScenarios {
|
||||
val jacob: User = FakeScenarios.owner
|
||||
val anna: User = FakeScenarios.partner
|
||||
|
||||
val jacobPhone = DeviceId("01920000-0000-7000-8000-0000000000d1")
|
||||
val annaPhone = DeviceId("01920000-0000-7000-8000-0000000000d2")
|
||||
private val oldPhone = DeviceId("01920000-0000-7000-8000-0000000000d3")
|
||||
|
||||
val vardag = Account(AccountId("01920000-0000-7000-8000-0000000000b1"), "Vardag", mapOf(jacob.id to 5_000, anna.id to 5_000), "2026-01-03T10:00:00.000Z", archived = false)
|
||||
val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", mapOf(jacob.id to 6_000, anna.id to 4_000), "2026-01-04T10:00:00.000Z", archived = false)
|
||||
val japan = Account(AccountId("01920000-0000-7000-8000-0000000000b3"), "Japan 2027", mapOf(jacob.id to 5_000, anna.id to 5_000), "2026-05-01T10:00:00.000Z", archived = false)
|
||||
|
||||
private fun default(key: DefaultCategoryKey): Category = FakeScenarios.defaultCategories.first { it.defaultKey == key }
|
||||
|
||||
val groceries = default(DefaultCategoryKey.GROCERIES)
|
||||
val housing = default(DefaultCategoryKey.HOUSING)
|
||||
val utilities = default(DefaultCategoryKey.UTILITIES)
|
||||
val entertainment = default(DefaultCategoryKey.ENTERTAINMENT)
|
||||
|
||||
/** A custom, archived category still used by an expense (S-15 editor "custom archived", AC-7.6 in use). */
|
||||
val pant = Category(CategoryId("01920000-0000-7000-8000-0000000000c1"), null, "Pant", "water_drop", ColorToken.TEAL, 110, archived = true)
|
||||
|
||||
/** A custom, active category nothing uses: deletable. */
|
||||
val hund = Category(CategoryId("01920000-0000-7000-8000-0000000000c2"), null, "Hunden", "pets", ColorToken.BROWN, 120, archived = false)
|
||||
|
||||
val categories: List<Category> = FakeScenarios.defaultCategories + hund + pant
|
||||
|
||||
/** The default category Groceries renamed "Matvaror" (S-15 editor, AC-7.4). */
|
||||
val renamedCategories: List<Category> = categories.map { if (it.id == groceries.id) it.copy(customName = "Matvaror") else it }
|
||||
|
||||
private fun draft(account: Account, category: Category, ore: Long, description: String, payer: User, mode: SplitMode = SplitMode.DEFAULT, ratio: Map<UserId, Int>? = null, exact: Map<UserId, Long>? = null, fullOn: UserId? = null) =
|
||||
ExpenseDraft(null, account.id, category.id, ore, description, "", "2026-10-17", payer.id, mode, ratio, exact, fullOn)
|
||||
|
||||
val hyra = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e1"), draft(huset, housing, 1_240_000, "Hyra", anna), 25, "2026-09", null, paused = false, nextOccurrence = "2026-10-25")
|
||||
val bredband = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e2"), draft(huset, utilities, 39_900, "Bahnhof bredband", jacob, SplitMode.RATIO, ratio = mapOf(jacob.id to 5_000, anna.id to 5_000)), 28, "2026-01", null, paused = false, nextOccurrence = "2026-10-28")
|
||||
val netflix = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e3"), draft(vardag, entertainment, 14_900, "Netflix", jacob, SplitMode.EXACT, exact = mapOf(jacob.id to 7_900, anna.id to 7_000)), 1, "2026-03", "2027-02", paused = false, nextOccurrence = "2026-11-01")
|
||||
val spotify = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e4"), draft(vardag, entertainment, 23_900, "Spotify Family", anna, SplitMode.FULL, fullOn = anna.id), 12, "2025-11", null, paused = true, nextOccurrence = null)
|
||||
|
||||
private val pantExpense = FakeExpense(
|
||||
id = ExpenseId("01920000-0000-7000-8000-0000000000f1"),
|
||||
accountId = vardag.id,
|
||||
categoryId = pant.id,
|
||||
amountOre = 4_000,
|
||||
description = "Pant Willys",
|
||||
notes = "",
|
||||
date = "2026-08-02",
|
||||
payer = jacob.id,
|
||||
mode = SplitMode.DEFAULT,
|
||||
shares = listOf(Share(jacob.id, 2_000, 5_000), Share(anna.id, 2_000, 5_000)),
|
||||
createdAt = "2026-08-02T10:00:00.000Z",
|
||||
)
|
||||
|
||||
private val pendingExpense = pantExpense.copy(
|
||||
id = ExpenseId("01920000-0000-7000-8000-0000000000f2"),
|
||||
categoryId = groceries.id,
|
||||
amountOre = 124_760,
|
||||
description = "ICA Maxi Lindhagen",
|
||||
date = "2026-10-17",
|
||||
createdAt = "2026-10-17T11:30:00.000Z",
|
||||
shares = listOf(Share(jacob.id, 62_380, 5_000), Share(anna.id, 62_380, 5_000)),
|
||||
pending = true,
|
||||
)
|
||||
|
||||
private val devices = listOf(
|
||||
Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, current = true, revoked = false),
|
||||
Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, current = false, revoked = false),
|
||||
Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, current = false, revoked = true),
|
||||
)
|
||||
|
||||
private val pendingDeviceInvite = Invite(InviteId("01920000-0000-7000-8000-0000000000aa"), InviteKind.DEVICE, anna.id, null, null, "2026-10-16T18:30:00.000Z", "2026-10-19T18:30:00.000Z")
|
||||
|
||||
val update = UpdateInfo(12, "1.2", 8_400_000)
|
||||
|
||||
/** S-13 loaded (admin), S-15, S-19, S-20, S-21 after the partner joined, S-22: everything synced 3 minutes ago. */
|
||||
val loaded: FakeScenario = FakeScenarios.empty.copy(
|
||||
session = SessionState.Paired(jacob, anna, jacobPhone),
|
||||
accounts = listOf(vardag, huset, japan),
|
||||
categories = categories,
|
||||
expenses = listOf(pantExpense),
|
||||
templates = listOf(hyra, bredband, netflix, spotify),
|
||||
devices = devices,
|
||||
invites = listOf(pendingDeviceInvite),
|
||||
lastSuccessAt = "2026-10-17T11:59:00.000Z",
|
||||
about = AboutState("1.1", 11, "https://icehack.asuscomm.com:8443", "1.1.0"),
|
||||
today = "2026-10-17",
|
||||
now = "2026-10-17T12:02:00.000Z",
|
||||
defaultAccountId = huset.id,
|
||||
lastCategoryFor = mapOf(huset.id to housing.id),
|
||||
)
|
||||
|
||||
/** Anna's phone: she is the member, Jacob the admin (S-13 member, S-20 member). */
|
||||
val member: FakeScenario = loaded.copy(
|
||||
me = anna,
|
||||
partner = jacob,
|
||||
deviceId = annaPhone,
|
||||
session = SessionState.Paired(anna, jacob, annaPhone),
|
||||
devices = devices.map { it.copy(current = it.id == annaPhone) },
|
||||
invites = emptyList(),
|
||||
)
|
||||
|
||||
/** Before the partner joins: one user, a pending partner invite, no templates (AC-5.1, AC-2.1). */
|
||||
val partnerNotJoined: FakeScenario = loaded.copy(
|
||||
partner = null,
|
||||
session = SessionState.Paired(jacob, null, jacobPhone),
|
||||
templates = emptyList(),
|
||||
expenses = emptyList(),
|
||||
devices = devices.filter { it.user.id == jacob.id },
|
||||
invites = listOf(Invite(InviteId("01920000-0000-7000-8000-0000000000ab"), InviteKind.PARTNER, null, null, null, "2026-10-16T18:30:00.000Z", "2026-10-19T18:30:00.000Z")),
|
||||
)
|
||||
|
||||
/** Offline with two changes waiting to sync (§9.9). */
|
||||
val offline: FakeScenario = loaded.copy(online = false, expenses = listOf(pantExpense, pendingExpense, pendingExpense.copy(id = ExpenseId("01920000-0000-7000-8000-0000000000f3"), createdAt = "2026-10-17T11:40:00.000Z"))).withServerVersion(null)
|
||||
|
||||
/** Jacob with a profile picture (S-14). */
|
||||
val withPicture: FakeScenario = loaded.copy(me = jacob.copy(pictureVersion = 3))
|
||||
|
||||
/** No templates (S-19 empty), no pending invites (S-21 empty). */
|
||||
val empty: FakeScenario = loaded.copy(templates = emptyList(), invites = emptyList())
|
||||
|
||||
/** An update is waiting (S-13 "Uppdatering finns", S-22 banner). */
|
||||
val updateAvailable: FakeScenario = loaded.copy(update = UpdateState.Available(update))
|
||||
|
||||
/** The update is downloading (S-22). */
|
||||
val updateDownloading: FakeScenario = loaded.copy(update = UpdateState.Downloading(update, 0.64f))
|
||||
|
||||
private val longMe = jacob.copy(displayName = "Jacob Hagstedt-Lindqvist")
|
||||
private val longPartner = anna.copy(displayName = "Anna-Karin Lindqvist-Östergren")
|
||||
private val longAccount = huset.copy(name = "Sommarstugan på Gotland med brygga")
|
||||
|
||||
/** Long Swedish names and 7-digit amounts (SPEC §13.3 long content). */
|
||||
val long: FakeScenario = loaded.copy(
|
||||
me = longMe,
|
||||
partner = longPartner,
|
||||
session = SessionState.Paired(longMe, longPartner, jacobPhone),
|
||||
accounts = listOf(vardag, longAccount, japan),
|
||||
categories = categories.map {
|
||||
when (it.id) {
|
||||
groceries.id -> it.copy(customName = "Matvaror, hushållsartiklar och förbrukning")
|
||||
hund.id -> it.copy(customName = "Hundens veterinärbesök och försäkring")
|
||||
else -> it
|
||||
}
|
||||
},
|
||||
templates = listOf(
|
||||
hyra.copy(draft = hyra.draft.copy(accountId = longAccount.id, amountOre = 123_456_789, description = "Månadshyra för sommarstugan inklusive el, vatten och sophämtning"), dayOfMonth = 31),
|
||||
bredband,
|
||||
spotify.copy(draft = spotify.draft.copy(description = "Spotify Family och Storytel Familj, delat abonnemang")),
|
||||
),
|
||||
devices = devices.map {
|
||||
when (it.user.id) {
|
||||
jacob.id -> it.copy(user = longMe, name = if (it.current) "Samsung Galaxy S26 Ultra Enterprise Edition" else it.name)
|
||||
else -> it.copy(user = longPartner)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun FakeScenario.withServerVersion(version: String?): FakeScenario = copy(about = about.copy(serverVersion = version))
|
||||
|
||||
/** Every scenario of this file by name, for previews and tests. */
|
||||
val all: Map<String, FakeScenario> = mapOf(
|
||||
"loaded" to loaded,
|
||||
"member" to member,
|
||||
"partnerNotJoined" to partnerNotJoined,
|
||||
"offline" to offline,
|
||||
"withPicture" to withPicture,
|
||||
"empty" to empty,
|
||||
"updateAvailable" to updateAvailable,
|
||||
"updateDownloading" to updateDownloading,
|
||||
"long" to long,
|
||||
)
|
||||
|
||||
/** Roles stay as the household has them. */
|
||||
init {
|
||||
check(jacob.role == Role.ADMIN && anna.role == Role.MEMBER)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user