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:
+38
@@ -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"))
|
||||
}
|
||||
}
|
||||
+60
@@ -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]) }
|
||||
}
|
||||
}
|
||||
+161
@@ -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)
|
||||
}
|
||||
}
|
||||
+176
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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/ "))
|
||||
}
|
||||
}
|
||||
+49
@@ -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) }
|
||||
}
|
||||
}
|
||||
+116
@@ -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 = {}) } }),
|
||||
)
|
||||
}
|
||||
+76
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user