M7a: feature onboarding (S-1, S-2, S-3, S-18) with fakes, unit 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:45 +02:00
parent 64539324e8
commit 1ea752cd80
26 changed files with 2517 additions and 15 deletions
@@ -0,0 +1,80 @@
package se.hagstedt.kvitt.fakes.onboarding
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
/**
* Scenarios of `:feature:onboarding` (S-1, S-2, S-3, S-18; PLAN-M7a §5). Plain data on top of [FakeScenarios.empty];
* the outcomes of online calls that a scenario cannot express (a pairing refused with `invite_invalid`, an upload
* still running) come from [ScriptedSessionRepository], [ScriptedProfileRepository] and [ScriptedInviteRepository].
*/
object OnboardingScenarios {
private val owner = FakeScenarios.owner
private val partner = FakeScenarios.partner
private val partnerDevice = DeviceId("01920000-0000-7000-8000-0000000000d2")
/** S-1 on a phone that was never paired: no session, online. */
val unpaired: FakeScenario = FakeScenarios.empty.copy(session = SessionState.Unpaired)
/** S-1 on the phone of the partner-to-be: pairing returns Anna, a `member` (AC-2.10 leads on to S-2). */
val unpairedPartner: FakeScenario = unpaired.copy(me = partner, partner = owner, deviceId = partnerDevice, devices = emptyList())
/** S-1 offline: pairing never queues, the call ends in the network error (design-spec S-1). */
val unpairedOffline: FakeScenario = unpaired.copy(online = false)
/** S-1 "Para ihop telefonen igen": the server answered 401 `unauthorized`, local data kept (§14.4.8). */
val unrecognised: FakeScenario = FakeScenarios.empty.copy(session = SessionState.Unrecognised)
/** S-18: the server answered 401 `device_revoked` (AC-3.3). */
val revoked: FakeScenario = FakeScenarios.empty.copy(session = SessionState.Revoked)
/** S-2: Anna has just paired with Jacob's partner invite; she is the viewer, no picture yet (AC-2.10). */
val partnerJustPaired: FakeScenario = FakeScenarios.empty.copy(
me = partner,
partner = owner,
deviceId = partnerDevice,
session = SessionState.Paired(partner, owner, partnerDevice),
devices = emptyList(),
)
/** S-2 after a picture was uploaded (`picture_version` 1). */
val partnerWithPicture: FakeScenario = partnerJustPaired.withMe(partnerJustPaired.me.copy(pictureVersion = 1))
/** S-2 with a long Swedish name at the §9.6 limit's order of size. */
val partnerLongName: FakeScenario = partnerJustPaired.withMe(partnerJustPaired.me.copy(displayName = "Anna-Karin Lindqvist Östergren"))
/** S-2 offline: picture and name are online-only (§9.9). */
val partnerOffline: FakeScenario = partnerJustPaired.copy(online = false)
/** S-3: Jacob alone, a partner invite created on 16 October 21:40 that runs until Monday 19 October 21:40 (AC-2.3). */
val waitingWithInvite: FakeScenario = FakeScenarios.empty.copy(
partner = null,
session = SessionState.Paired(owner, null, FakeScenarios.empty.deviceId),
invites = listOf(
Invite(
id = InviteId("01920000-0000-7000-8000-0000000000f1"),
kind = InviteKind.PARTNER,
targetUser = null,
code = null,
link = null,
createdAt = "2026-10-16T19:40:00.000Z",
expiresAt = "2026-10-19T19:40:00.000Z",
),
),
)
/** S-3 without a pending invite. */
val waitingWithoutInvite: FakeScenario = waitingWithInvite.copy(invites = emptyList())
/** S-3 offline: the invite list is online-only, so no invite row (design-spec S-3). */
val waitingOffline: FakeScenario = waitingWithInvite.copy(online = false)
private fun FakeScenario.withMe(me: User): FakeScenario =
copy(me = me, session = SessionState.Paired(me, partner, deviceId))
}
@@ -0,0 +1,91 @@
package se.hagstedt.kvitt.fakes.onboarding
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import se.hagstedt.kvitt.fakes.FakeInviteRepository
import se.hagstedt.kvitt.fakes.FakeProfileRepository
import se.hagstedt.kvitt.fakes.FakeSessionRepository
import se.hagstedt.kvitt.fakes.FakeStore
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.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/**
* How a scripted online call ends. [Succeed] runs the shared fake (which still answers `Offline` when the scenario is
* offline); [Fail] answers `Failed(code, field)` when online; [Hang] never returns, so a screen stays in its progress
* state for a screenshot.
*/
sealed interface CallOutcome {
data object Succeed : CallOutcome
data class Fail(val code: String, val field: String? = null) : CallOutcome
data object Hang : CallOutcome
}
/** The arguments of one [SessionRepository.pair] call, recorded for tests. */
data class PairCall(val serverUrl: String, val code: String, val displayName: String?, val deviceName: String)
private suspend fun <T> FakeStore.scripted(outcome: CallOutcome, succeed: suspend () -> OnlineResult<T>): OnlineResult<T> = when (outcome) {
CallOutcome.Succeed -> succeed()
is CallOutcome.Fail -> if (current.online) OnlineResult.Failed(outcome.code, outcome.field) else OnlineResult.Offline
CallOutcome.Hang -> awaitCancellation()
}
/** [FakeSessionRepository] with a scripted [pair] outcome. */
class ScriptedSessionRepository(private val store: FakeStore, var pairOutcome: CallOutcome = CallOutcome.Succeed) : SessionRepository {
private val delegate = FakeSessionRepository(store)
val pairCalls = mutableListOf<PairCall>()
var acknowledged = 0
private set
override val session: Flow<SessionState> = delegate.session
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> {
pairCalls += PairCall(serverUrl, code, displayName, deviceName)
return store.scripted(pairOutcome) { delegate.pair(serverUrl, code, displayName, deviceName) }
}
override suspend fun acknowledgeRevoked() {
acknowledged += 1
delegate.acknowledgeRevoked()
}
}
/** [FakeProfileRepository] with scripted picture and name outcomes. */
class ScriptedProfileRepository(
private val store: FakeStore,
var pictureOutcome: CallOutcome = CallOutcome.Succeed,
var nameOutcome: CallOutcome = CallOutcome.Succeed,
) : ProfileRepository {
private val delegate = FakeProfileRepository(store)
val namesSet = mutableListOf<String>()
override fun household(): Flow<List<User>> = delegate.household()
override suspend fun setDisplayName(name: String): OnlineResult<Unit> {
namesSet += name
return store.scripted(nameOutcome) { delegate.setDisplayName(name) }
}
override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit> =
store.scripted(pictureOutcome) { delegate.setPicture(bytes, mimeType) }
override suspend fun removePicture(): OnlineResult<Unit> = store.scripted(pictureOutcome) { delegate.removePicture() }
}
/** [FakeInviteRepository] with a scripted [list] outcome (S-3's error state). */
class ScriptedInviteRepository(private val store: FakeStore, var listOutcome: CallOutcome = CallOutcome.Succeed) : InviteRepository {
private val delegate = FakeInviteRepository(store)
override suspend fun list(): OnlineResult<List<Invite>> = store.scripted(listOutcome) { delegate.list() }
override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite> = delegate.create(kind, targetUser)
override suspend fun cancel(id: InviteId): OnlineResult<Unit> = delegate.cancel(id)
}