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