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,38 @@
package se.hagstedt.kvitt.feature.onboarding
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** "Klistra in" (design-spec S-1) and the code display and normalisation of §8.3. */
class InviteLinkTest {
@Test
fun aBareCodeInAnySpellingIsNormalised() {
assertEquals(PastedInvite("7K3MQX9D2HPA", null), InviteLink.parse("7K3M-QX9D-2HPA"))
assertEquals(PastedInvite("7K3MQX9D2HPA", null), InviteLink.parse(" 7k3m qx9d 2hpa\n"))
// O becomes 0, I and L become 1 (§8.3).
assertEquals(PastedInvite("0110ABCDEFGH", null), InviteLink.parse("OIlo-abcd-efgh"))
}
@Test
fun codesOfTheWrongLengthOrWithForeignCharactersAreRefused() {
assertNull(InviteLink.parse("7K3M-QX9D-2HP"))
assertNull(InviteLink.parse("7K3M-QX9D-2HPAX"))
assertNull(InviteLink.parse("7K3M-QX9D-2HPU"))
assertNull(InviteLink.parse("hello"))
}
@Test
fun theAppLinkGivesCodeAndServer() {
val parsed = InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA")
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), parsed)
assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA"))
}
@Test
fun theLandingLinkGivesCodeAndServer() {
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA"))
assertEquals(PastedInvite("7K3MQX9D2HPA", "http://192.168.1.210:8080"), InviteLink.parse("http://192.168.1.210:8080/i/7k3mqx9d2hpa"))
assertNull(InviteLink.parse("https://icehack.asuscomm.com:8443/x/7K3MQX9D2HPA"))
}
}
@@ -0,0 +1,60 @@
package se.hagstedt.kvitt.feature.onboarding
import org.junit.Assert.assertEquals
import org.junit.Test
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/** The module's strings are exactly the `onboarding_*` rows of docs/ux/strings.csv, in English and Swedish (D-017). */
class OnboardingStringsTest {
private data class Row(val key: String, val en: String, val sv: String, val notes: String)
private fun csv(): List<Row> {
val text = File("../../../docs/ux/strings.csv").readText()
val rows = mutableListOf<List<String>>()
var field = StringBuilder()
var record = mutableListOf<String>()
var quoted = false
var i = 0
while (i < text.length) {
val c = text[i]
when {
quoted && c == '"' && i + 1 < text.length && text[i + 1] == '"' -> { field.append('"'); i++ }
c == '"' -> quoted = !quoted
!quoted && c == ',' -> { record.add(field.toString()); field = StringBuilder() }
!quoted && c == '\n' -> { record.add(field.toString()); rows.add(record); record = mutableListOf(); field = StringBuilder() }
c == '\r' -> Unit
else -> field.append(c)
}
i++
}
if (field.isNotEmpty() || record.isNotEmpty()) { record.add(field.toString()); rows.add(record) }
return rows.drop(1).filter { it.size >= 4 && it[0].startsWith("onboarding_") }.map { Row(it[0], it[1], it[2], it[3]) }
}
private fun resources(folder: String): Map<String, String> {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File("src/main/res/$folder/strings.xml"))
val strings = doc.getElementsByTagName("string")
return (0 until strings.length).associate { i ->
val e = strings.item(i) as Element
e.getAttribute("name") to e.textContent.replace("\\'", "'").replace("\\\"", "\"").replace("\\\\", "\\")
}
}
@Test
fun englishMatchesTheCsv() {
val rows = csv()
val en = resources("values")
assertEquals(rows.map { it.key }.toSet(), en.keys)
rows.forEach { assertEquals(it.key, it.en, en[it.key]) }
}
@Test
fun swedishMatchesTheCsv() {
val rows = csv().filter { "translatable=false" !in it.notes }
val sv = resources("values-sv")
assertEquals(rows.map { it.key }.toSet(), sv.keys)
rows.forEach { assertEquals(it.key, it.sv, sv[it.key]) }
}
}
@@ -0,0 +1,161 @@
package se.hagstedt.kvitt.feature.onboarding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
import se.hagstedt.kvitt.fakes.onboarding.ScriptedInviteRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedProfileRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionState
/** S-2, S-3 and S-18 state holders over the onboarding fake scenarios. */
@OptIn(ExperimentalCoroutinesApi::class)
class OtherViewModelsTest {
@Before
fun main() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun reset() = Dispatchers.resetMain()
private fun profile(scenario: FakeScenario, picture: CallOutcome = CallOutcome.Succeed, name: CallOutcome = CallOutcome.Succeed) =
FakeRepositories(scenario).let { f ->
val repo = ScriptedProfileRepository(f.store, picture, name)
repo to ProfileStepViewModel(f.session, repo, f.connectivity)
}
@Test
fun theProfileStepPrefillsTheNameAndNamesTheOwner() {
val (_, vm) = profile(OnboardingScenarios.partnerJustPaired)
val s = vm.state.value
assertEquals("Anna", s.name)
assertEquals("Jacob", s.owner?.displayName)
assertTrue(s.canContinue)
assertFalse(s.hasPicture)
}
@Test
fun anUploadDisablesContinueUntilItEndsAndFailuresGiveTheirReason() {
val (_, hanging) = profile(OnboardingScenarios.partnerJustPaired, picture = CallOutcome.Hang)
hanging.onPicturePicked(ByteArray(4), "image/jpeg")
assertTrue(hanging.state.value.uploading)
assertFalse(hanging.state.value.canContinue)
val (_, ok) = profile(OnboardingScenarios.partnerJustPaired)
ok.onPicturePicked(ByteArray(4), "image/jpeg")
assertTrue(ok.state.value.hasPicture)
assertNull(ok.state.value.message)
mapOf(
"payload_too_large" to ProfileStepMessage.PictureTooLarge,
"unsupported_media_type" to ProfileStepMessage.PictureUnsupported,
"image_invalid" to ProfileStepMessage.PictureInvalid,
"network" to ProfileStepMessage.CouldNotReachServer,
"internal" to ProfileStepMessage.PictureFailed,
).forEach { (code, expected) ->
val (_, vm) = profile(OnboardingScenarios.partnerJustPaired, picture = CallOutcome.Fail(code))
vm.onPicturePicked(ByteArray(4), "image/jpeg")
assertEquals(code, expected, vm.state.value.message)
assertFalse(vm.state.value.uploading)
assertTrue(vm.state.value.canContinue)
assertFalse(vm.state.value.hasPicture)
}
}
@Test
fun offlineThePictureCannotChange() {
val (_, vm) = profile(OnboardingScenarios.partnerOffline)
assertFalse(vm.state.value.canChangePicture)
vm.onPicturePicked(ByteArray(4), "image/jpeg")
assertFalse(vm.state.value.uploading)
assertTrue(vm.state.value.canContinue) // an unchanged name needs no call
}
@Test
fun continueSavesAChangedNameAndStaysOnFailure() {
val (repo, vm) = profile(OnboardingScenarios.partnerJustPaired)
vm.onContinue()
assertTrue(vm.state.value.done)
assertTrue(repo.namesSet.isEmpty())
val (changedRepo, changed) = profile(OnboardingScenarios.partnerJustPaired)
changed.onNameChange(" Anna-Karin ")
changed.onContinue()
assertEquals(listOf("Anna-Karin"), changedRepo.namesSet)
assertTrue(changed.state.value.done)
val (_, failing) = profile(OnboardingScenarios.partnerJustPaired, name = CallOutcome.Fail("network"))
failing.onNameChange("Anna-Karin")
failing.onContinue()
assertFalse(failing.state.value.done)
assertEquals(ProfileStepMessage.CouldNotReachServer, failing.state.value.message)
val (_, blank) = profile(OnboardingScenarios.partnerJustPaired)
blank.onNameChange(" ")
assertFalse(blank.state.value.canContinue)
}
@Test
fun pictureMessagesMapOnlineResults() {
assertNull(pictureMessage(OnlineResult.Ok(Unit)))
assertEquals(ProfileStepMessage.CouldNotReachServer, pictureMessage(OnlineResult.Offline))
}
@Test
fun swedishGenitiveAddsNoSecondS() {
assertEquals("Jacob", genitiveArgument("Jacob", KvittLanguage.SWEDISH))
assertEquals("Jona", genitiveArgument("Jonas", KvittLanguage.SWEDISH))
assertEquals("Jonas", genitiveArgument("Jonas", KvittLanguage.ENGLISH))
}
private fun waiting(scenario: FakeScenario, list: CallOutcome = CallOutcome.Succeed) = FakeRepositories(scenario).let { f ->
WaitingViewModel(f.session, ScriptedInviteRepository(f.store, list), f.connectivity) to f
}
@Test
fun waitingShowsThePendingInviteExpiryOnlyOnline() {
assertEquals("2026-10-19T19:40:00.000Z", waiting(OnboardingScenarios.waitingWithInvite).first.state.value.inviteExpiresAt)
assertNull(waiting(OnboardingScenarios.waitingWithoutInvite).first.state.value.inviteExpiresAt)
val offline = waiting(OnboardingScenarios.waitingOffline).first.state.value
assertNull(offline.inviteExpiresAt)
assertFalse(offline.online)
assertNull(offline.loadError)
}
@Test
fun aFailedInviteListIsAnErrorAndComingOnlineLoadsAgain() {
val (vm, _) = waiting(OnboardingScenarios.waitingWithInvite, CallOutcome.Fail("network"))
assertEquals("network", vm.state.value.loadError)
assertNull(vm.state.value.inviteExpiresAt)
val (later, fakes) = waiting(OnboardingScenarios.waitingOffline)
fakes.store.edit { it.copy(online = true) }
assertEquals("2026-10-19T19:40:00.000Z", later.state.value.inviteExpiresAt)
}
@Test
fun pairAgainAcknowledgesTheRevocationFirst() {
val f = FakeRepositories(OnboardingScenarios.revoked)
val session = ScriptedSessionRepository(f.store)
val vm = RemovedViewModel(session)
assertFalse(vm.acknowledged.value)
vm.onPairAgain()
assertEquals(1, session.acknowledged)
assertEquals(SessionState.Unpaired, f.store.current.session)
assertTrue(vm.acknowledged.value)
}
}
@@ -0,0 +1,176 @@
package se.hagstedt.kvitt.feature.onboarding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
import se.hagstedt.kvitt.fakes.onboarding.PairCall
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
@OptIn(ExperimentalCoroutinesApi::class)
class PairingViewModelTest {
@Before
fun main() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun reset() = Dispatchers.resetMain()
private fun vm(scenario: FakeScenario = OnboardingScenarios.unpaired, outcome: CallOutcome = CallOutcome.Succeed, allowLanHttp: Boolean = false) =
ScriptedSessionRepository(FakeRepositories(scenario).store, outcome).let { it to PairingViewModel(it, allowLanHttp) }
@Test
fun theCodeIsNormalisedWhileTyping() {
val (_, vm) = vm()
vm.onCodeChange("7k3m-qx9d-2hpa-extra")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
vm.onCodeChange("oil")
assertEquals("011", vm.state.value.code)
}
@Test
fun pairIsEnabledWithTwelveCharactersANameAndAWellFormedAddress() {
val (_, vm) = vm()
assertEquals(RELEASE_SERVER_URL, vm.state.value.serverUrl)
vm.onCodeChange("7K3MQX9D2HP")
vm.onNameChange("Anna")
assertFalse(vm.state.value.canPair)
vm.onCodeChange("7K3MQX9D2HPA")
assertTrue(vm.state.value.canPair)
vm.onNameChange(" ")
assertFalse(vm.state.value.canPair)
vm.onNameChange("A".repeat(41))
assertTrue(vm.state.value.nameTooLong)
assertFalse(vm.state.value.canPair)
vm.onNameChange("Anna")
vm.onServerChange("not an address")
assertFalse(vm.state.value.canPair)
}
@Test
fun aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName() {
val (session, vm) = vm(OnboardingScenarios.unpairedPartner)
vm.prefill(RELEASE_SERVER_URL + "/", "7k3m-qx9d-2hpa")
vm.onNameChange(" Anna ")
vm.pair("Galaxy S26+")
assertEquals(listOf(PairCall(RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Anna", "Galaxy S26+")), session.pairCalls)
assertEquals(PairingDone.ProfileStep, vm.state.value.done)
}
@Test
fun theAdminGoesHomeAndTheAgainVariantSendsNoName() {
val (_, admin) = vm(OnboardingScenarios.unpaired)
admin.prefill(null, "7K3MQX9D2HPA")
admin.onNameChange("Jacob")
admin.pair("Galaxy S26")
assertEquals(PairingDone.Home, admin.state.value.done)
val (session, again) = vm(OnboardingScenarios.unrecognised)
assertEquals(PairingMode.Again, again.state.value.mode)
again.onCodeChange("7K3MQX9D2HPA")
assertTrue(again.state.value.canPair)
again.pair("Galaxy S26")
assertNull(session.pairCalls.single().displayName)
assertEquals(PairingDone.Home, again.state.value.done)
}
@Test
fun aLinkWithAnotherServerOpensTheServerRowOnce() {
val (_, vm) = vm()
vm.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
assertTrue(vm.state.value.serverExpanded)
vm.prefill(RELEASE_SERVER_URL, "AAAAAAAAAAAA")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
val (_, plain) = vm()
plain.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA")
assertFalse(plain.state.value.serverExpanded)
}
@Test
fun errorsMapToTheirFields() {
mapOf(
"invite_invalid" to PairingError.InviteInvalid,
"rate_limited" to PairingError.RateLimited,
"network" to PairingError.Network,
"protocol_unsupported" to PairingError.AppTooOld,
"internal" to PairingError.Server,
"unavailable" to PairingError.Server,
"validation_failed" to PairingError.Validation,
).forEach { (code, expected) ->
val (_, vm) = vm(outcome = CallOutcome.Fail(code))
vm.prefill(null, "7K3MQX9D2HPA")
vm.onNameChange("Anna")
vm.pair("Galaxy S26")
assertEquals(code, expected, vm.state.value.error)
assertFalse(vm.state.value.pairing)
assertTrue(vm.state.value.canPair) // the button is enabled again
}
}
@Test
fun offlinePairingGivesTheNetworkErrorUnderTheExpandedServer() {
val (_, vm) = vm(OnboardingScenarios.unpairedOffline)
vm.prefill(null, "7K3MQX9D2HPA")
vm.onNameChange("Anna")
vm.pair("Galaxy S26")
assertEquals(PairingError.Network, vm.state.value.error)
assertTrue(vm.state.value.serverExpanded)
}
@Test
fun releaseRefusesHttpBeforeCallingTheServerAndDebugAllowsTheLan() {
val (session, release) = vm()
release.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
release.onNameChange("Anna")
release.pair("Galaxy S26")
assertEquals(PairingError.HttpsRequired, release.state.value.error)
assertTrue(session.pairCalls.isEmpty())
val (debugSession, debug) = vm(allowLanHttp = true)
debug.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
debug.onNameChange("Anna")
debug.pair("Galaxy S26")
assertNull(debug.state.value.error)
assertEquals("http://192.168.1.210:8080", debugSession.pairCalls.single().serverUrl)
}
@Test
fun whilePairingTheButtonIsBusyAndEditingClearsTheFieldError() {
val (_, hanging) = vm(outcome = CallOutcome.Hang)
hanging.prefill(null, "7K3MQX9D2HPA")
hanging.onNameChange("Anna")
hanging.pair("Galaxy S26")
assertTrue(hanging.state.value.pairing)
assertFalse(hanging.state.value.canPair)
val (_, failed) = vm(outcome = CallOutcome.Fail("invite_invalid"))
failed.prefill(null, "7K3MQX9D2HPA")
failed.onNameChange("Anna")
failed.pair("Galaxy S26")
failed.onCodeChange("7K3MQX9D2HPB")
assertNull(failed.state.value.error)
}
@Test
fun pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters() {
val (_, vm) = vm()
vm.onPaste("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
assertNull(vm.state.value.error)
vm.onPaste("hello")
assertEquals(PairingError.CodeLength, vm.state.value.error)
vm.onPaste(null)
assertEquals(PairingError.CodeLength, vm.state.value.error)
}
}
@@ -0,0 +1,48 @@
package se.hagstedt.kvitt.feature.onboarding
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** AC-2.8: release accepts only https://; debug also http:// for localhost, the emulator host and private LANs. */
class ServerAddressTest {
@Test
fun httpsIsAlwaysAccepted() {
assertNull(ServerAddress.check(RELEASE_SERVER_URL, allowLanHttp = false))
assertNull(ServerAddress.check("https://example.org/", allowLanHttp = false))
}
@Test
fun releaseRefusesEveryHttpAddress() {
listOf("http://192.168.1.210:8080", "http://localhost:8080", "http://10.0.2.2:8080", "http://example.org").forEach {
assertEquals(it, ServerAddressProblem.HttpsRequired, ServerAddress.check(it, allowLanHttp = false))
}
}
@Test
fun debugAcceptsHttpOnlyForLocalAndPrivateAddresses() {
listOf(
"http://localhost:8080", "http://10.0.2.2:8080", "http://10.1.2.3", "http://172.16.0.1",
"http://172.31.255.255:8080", "http://192.168.1.210:8080",
).forEach { assertNull(it, ServerAddress.check(it, allowLanHttp = true)) }
listOf("http://172.15.0.1", "http://172.32.0.1", "http://192.169.0.1", "http://example.org", "http://8.8.8.8").forEach {
assertEquals(it, ServerAddressProblem.HttpsRequired, ServerAddress.check(it, allowLanHttp = true))
}
}
@Test
fun malformedAddressesAreInvalid() {
listOf("", "icehack.asuscomm.com", "ftp://example.org", "https://", "https://exa mple.org", "kvitt://invite").forEach {
assertEquals(it, ServerAddressProblem.Invalid, ServerAddress.check(it, allowLanHttp = true))
assertFalse(it, ServerAddress.isWellFormed(it))
}
assertTrue(ServerAddress.isWellFormed("http://example.org"))
}
@Test
fun normaliseTrimsSpacesAndTrailingSlash() {
assertEquals(RELEASE_SERVER_URL, ServerAddress.normalise(" $RELEASE_SERVER_URL/ "))
}
}
@@ -0,0 +1,49 @@
package se.hagstedt.kvitt.feature.onboarding.screenshot
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* One golden per screen state × light/dark × Swedish/English (PLAN-M7a §5, §7), plus 200 % for the states marked so.
* `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:onboarding:recordRoborazziDebug` and open every changed PNG.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi")
class OnboardingScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Before
fun mainDispatcher() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun resetMainDispatcher() = Dispatchers.resetMain()
@Test
fun screen() {
val state = OnboardingStates.all.first { it.name == name }
val content = state.render()
ScreenSnapshots.snap(compose, state.name, state.largeFont, content)
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun states(): List<Array<Any>> = OnboardingStates.all.map { arrayOf<Any>(it.name) }
}
}
@@ -0,0 +1,116 @@
package se.hagstedt.kvitt.feature.onboarding.screenshot
import androidx.compose.runtime.Composable
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
import se.hagstedt.kvitt.fakes.onboarding.ScriptedInviteRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedProfileRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
import se.hagstedt.kvitt.feature.onboarding.PairingActions
import se.hagstedt.kvitt.feature.onboarding.PairingContent
import se.hagstedt.kvitt.feature.onboarding.PairingViewModel
import se.hagstedt.kvitt.feature.onboarding.ProfileStepActions
import se.hagstedt.kvitt.feature.onboarding.ProfileStepContent
import se.hagstedt.kvitt.feature.onboarding.ProfileStepViewModel
import se.hagstedt.kvitt.feature.onboarding.RELEASE_SERVER_URL
import se.hagstedt.kvitt.feature.onboarding.RemovedContent
import se.hagstedt.kvitt.feature.onboarding.WaitingActions
import se.hagstedt.kvitt.feature.onboarding.WaitingContent
import se.hagstedt.kvitt.feature.onboarding.WaitingViewModel
/** One screen state: the golden's base name, whether a 200 % capture is added, and how to render it. */
class ScreenState(val name: String, val largeFont: Boolean = false, val render: () -> @Composable () -> Unit)
private const val CODE = "7K3MQX9D2HPA"
private const val LAN_SERVER = "http://192.168.1.210:8080"
/**
* Every state of S-1, S-2, S-3 and S-18 (design-spec §4 and PLAN-M7a §5). Each is produced by the screen's real
* ViewModel over a `:core:fakes` scenario (with a scripted outcome where a scenario cannot express it); the captured
* UI state is then rendered by the stateless screen. `Dispatchers.Main` is a test dispatcher (see the test class).
*/
object OnboardingStates {
private fun pairing(
scenario: FakeScenario,
outcome: CallOutcome = CallOutcome.Succeed,
allowLanHttp: Boolean = false,
act: PairingViewModel.() -> Unit,
): () -> @Composable () -> Unit = {
val store = FakeRepositories(scenario).store
val vm = PairingViewModel(ScriptedSessionRepository(store, outcome), allowLanHttp)
vm.act()
val state = vm.state.value
{ PairingContent(state, PairingActions()) }
}
private fun profile(
scenario: FakeScenario,
pictureOutcome: CallOutcome = CallOutcome.Succeed,
act: ProfileStepViewModel.() -> Unit = {},
): () -> @Composable () -> Unit = {
val fakes = FakeRepositories(scenario)
val vm = ProfileStepViewModel(fakes.session, ScriptedProfileRepository(fakes.store, pictureOutcome), fakes.connectivity)
vm.act()
val state = vm.state.value
{ ProfileStepContent(state, ProfileStepActions()) }
}
private fun waiting(scenario: FakeScenario, listOutcome: CallOutcome = CallOutcome.Succeed): () -> @Composable () -> Unit = {
val fakes = FakeRepositories(scenario)
val vm = WaitingViewModel(fakes.session, ScriptedInviteRepository(fakes.store, listOutcome), fakes.connectivity)
val state = vm.state.value
{ WaitingContent(state, WaitingActions()) }
}
private fun PairingViewModel.fillFromLink(server: String = RELEASE_SERVER_URL) {
prefill(server, CODE)
onNameChange("Anna")
}
private val picture = ByteArray(16)
val all: List<ScreenState> = listOf(
// S-1 Pairing.
ScreenState("s1_pairing_empty", render = pairing(OnboardingScenarios.unpaired) {}),
ScreenState("s1_pairing", largeFont = true, render = pairing(OnboardingScenarios.unpaired) { fillFromLink() }),
ScreenState("s1_pairing_link_lan", render = pairing(OnboardingScenarios.unpaired, allowLanHttp = true) { fillFromLink(LAN_SERVER) }),
ScreenState("s1_pairing_loading", render = pairing(OnboardingScenarios.unpaired, CallOutcome.Hang) { fillFromLink(); pair("Galaxy S26") }),
ScreenState(
"s1_pairing_error",
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("invite_invalid")) { fillFromLink(); pair("Galaxy S26") },
),
ScreenState(
"s1_pairing_error_network",
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("network")) { fillFromLink(); pair("Galaxy S26") },
),
ScreenState(
"s1_pairing_error_rate_limited",
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("rate_limited")) { fillFromLink(); pair("Galaxy S26") },
),
ScreenState("s1_pairing_error_https", render = pairing(OnboardingScenarios.unpaired) { fillFromLink(LAN_SERVER); pair("Galaxy S26") }),
ScreenState("s1_pairing_offline", render = pairing(OnboardingScenarios.unpairedOffline) { fillFromLink(); pair("Galaxy S26") }),
ScreenState("s1_pairing_again", render = pairing(OnboardingScenarios.unrecognised) {}),
// S-2 Partner profile step.
ScreenState("s2_profile_step", render = profile(OnboardingScenarios.partnerJustPaired)),
ScreenState("s2_profile_step_picture", render = profile(OnboardingScenarios.partnerWithPicture)),
ScreenState(
"s2_profile_step_loading",
render = profile(OnboardingScenarios.partnerJustPaired, CallOutcome.Hang) { onPicturePicked(picture, "image/jpeg") },
),
ScreenState(
"s2_profile_step_error",
render = profile(OnboardingScenarios.partnerJustPaired, CallOutcome.Fail("payload_too_large")) { onPicturePicked(picture, "image/jpeg") },
),
ScreenState("s2_profile_step_offline", render = profile(OnboardingScenarios.partnerOffline)),
ScreenState("s2_profile_step_long", largeFont = true, render = profile(OnboardingScenarios.partnerLongName)),
// S-3 Waiting for partner.
ScreenState("s3_waiting", render = waiting(OnboardingScenarios.waitingWithInvite)),
ScreenState("s3_waiting_no_invite", render = waiting(OnboardingScenarios.waitingWithoutInvite)),
ScreenState("s3_waiting_offline", render = waiting(OnboardingScenarios.waitingOffline)),
ScreenState("s3_waiting_error", render = waiting(OnboardingScenarios.waitingWithInvite, CallOutcome.Fail("network"))),
// S-18 Device removed.
ScreenState("s18_removed", render = { { RemovedContent(onPairAgain = {}) } }),
)
}
@@ -0,0 +1,76 @@
package se.hagstedt.kvitt.feature.onboarding.screenshot
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Captures a whole screen once per theme and language: `src/test/screenshots/<name>_<sv|en>_<light|dark>.png`, plus
* `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish, light), as `:core:design`'s Snapshots
* do for components. The phone is the Galaxy S26 (411 dp wide, SPEC §13.1); the clock is fixed at 17 October 2026
* 14:02 in Stockholm so every render is reproducible.
*/
object ScreenSnapshots {
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark) {
Box(Modifier.fillMaxSize()) { content() }
}
}
}
variants.forEach { variant ->
current = variant
compose.waitForIdle()
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}