M7b: feature home and account (S-4, S-5, S-6, S-16, S-17)
:feature:home: Home with balance hero, sync pill, accounts, archived section, empty and first-sync states, update banner and "Tillåt installation" dialog; sync status with pending rows, rejections of every §14.4.6 reason and the clock-skew banner. :feature:account: account detail with header, ledger by month, overflow menu, archive/unarchive/delete and their errors; account editor with name, percent fields (two decimals), slider, presets, preview, name_taken/validation errors and offline state. Fake scenarios in fakes/home and fakes/account; full-screen Roborazzi goldens for every state in sv/en × light/dark (200 % for long states); ViewModel 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:
+86
@@ -0,0 +1,86 @@
|
||||
package se.hagstedt.kvitt.feature.account
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
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
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
|
||||
/** One state of a screen: its golden name, whether a 200 % capture is taken, and its content. */
|
||||
class ScreenState(val name: String, val largeFont: Boolean = false, val content: @Composable () -> Unit)
|
||||
|
||||
private const val LONG_NAME = "Gemensamma utgifter för sommarstugan i Österåker och båten i Vaxholm"
|
||||
|
||||
/** Every state of S-5 and S-6 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
|
||||
val accountStates: List<ScreenState> = listOf(
|
||||
detail("S-5-account", AccountScenarios.loaded, AccountScenarios.vardag),
|
||||
detail("S-5-account-negative", AccountScenarios.loaded, AccountScenarios.huset),
|
||||
detail("S-5-account-archived", AccountScenarios.loaded, AccountScenarios.gotland),
|
||||
detail("S-5-account-menu", AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true) },
|
||||
detail("S-5-account-menu-deletable", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.setMenuOpen(true) },
|
||||
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
|
||||
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
|
||||
detail("S-5-account-offline", AccountScenarios.offline, AccountScenarios.vardag) { it.setMenuOpen(true) },
|
||||
detail("S-5-account-error", AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() },
|
||||
detail("S-5-account-long", AccountScenarios.long, AccountScenarios.longAccount, largeFont = true),
|
||||
editor("S-6-account-editor-create", AccountScenarios.editor),
|
||||
editor("S-6-account-editor", AccountScenarios.editor, largeFont = true) { it.onName("Sommarstugan"); it.onViewerPercent(60) },
|
||||
editor("S-6-account-editor-edit", AccountScenarios.editor, id = AccountScenarios.huset),
|
||||
editor("S-6-account-editor-decimals", AccountScenarios.editor) { it.onName("Sommarstugan"); it.onPercent(PercentField.Viewer, "62,75") },
|
||||
editor("S-6-account-editor-error", AccountScenarios.editor) { it.onName("Vardag"); it.save() },
|
||||
editor("S-6-account-editor-validation", AccountScenarios.editor) { it.onName(LONG_NAME) },
|
||||
editor("S-6-account-editor-network", AccountScenarios.editor, failWith = NETWORK) { it.onName("Sommarstugan"); it.save() },
|
||||
editor("S-6-account-editor-offline", AccountScenarios.editorOffline) { it.onName("Sommarstugan") },
|
||||
saving(),
|
||||
)
|
||||
|
||||
private fun saving(): ScreenState {
|
||||
val ui = editorState(AccountScenarios.editor) { it.onName("Sommarstugan"); it.onViewerPercent(60) }.copy(saving = true)
|
||||
return ScreenState("S-6-account-editor-saving") { AccountEditorContent(ui, AccountEditorActions()) }
|
||||
}
|
||||
|
||||
private fun detail(name: String, scenario: FakeScenario, id: AccountId, failWith: String? = null, largeFont: Boolean = false, act: (AccountDetailViewModel) -> Unit = {}): ScreenState {
|
||||
val ui = detailState(scenario, id, failWith, act)
|
||||
return ScreenState(name, largeFont) { AccountDetailContent(ui, AccountDetailActions()) }
|
||||
}
|
||||
|
||||
private fun editor(name: String, scenario: FakeScenario, id: AccountId? = null, failWith: String? = null, largeFont: Boolean = false, act: (AccountEditorViewModel) -> Unit = {}): ScreenState {
|
||||
val ui = editorState(scenario, id, failWith, act = act)
|
||||
return ScreenState(name, largeFont) { AccountEditorContent(ui, AccountEditorActions()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Goldens of every account detail and account editor state. `check` verifies them; re-record an intended change with
|
||||
* `tools/gradle-serial.sh :feature:account:recordRoborazziDebug` and open every changed PNG.
|
||||
*/
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
|
||||
class AccountScreenshotTest(private val state: String) {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun screen() {
|
||||
val screen = accountStates.first { it.name == state }
|
||||
ScreenSnapshots.snap(compose, screen.name, screen.largeFont, screen.content)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun states(): List<Array<Any>> = listOf(
|
||||
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
|
||||
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
|
||||
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
|
||||
"S-6-account-editor-error", "S-6-account-editor-validation", "S-6-account-editor-network", "S-6-account-editor-offline",
|
||||
"S-6-account-editor-saving",
|
||||
).map { arrayOf<Any>(it) }
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package se.hagstedt.kvitt.feature.account
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.AccountRepository
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
|
||||
/** The fake accounts with every online call failing with [code] (a REST error, §9.9); reads are the fakes'. */
|
||||
class FailingAccounts(private val delegate: AccountRepository, private val code: String, private val field: String? = null) : AccountRepository by delegate {
|
||||
override suspend fun create(name: String, defaultShares: Map<UserId, Int>): OnlineResult<AccountId> = OnlineResult.Failed(code, field)
|
||||
override suspend fun update(id: AccountId, name: String, defaultShares: Map<UserId, Int>): OnlineResult<Unit> = OnlineResult.Failed(code, field)
|
||||
override suspend fun setArchived(id: AccountId, archived: Boolean): OnlineResult<Unit> = OnlineResult.Failed(code, field)
|
||||
override suspend fun delete(id: AccountId): OnlineResult<Unit> = OnlineResult.Failed(code, field)
|
||||
}
|
||||
|
||||
fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) emptyMap() else mapOf(AccountDetailViewModel.ARG_ACCOUNT_ID to id.value))
|
||||
|
||||
/** S-5 for [id] in [scenario] after [act]; [failWith] makes the online calls fail with that code. */
|
||||
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
|
||||
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity)
|
||||
act(vm)
|
||||
vm.ui.value
|
||||
}
|
||||
|
||||
/** S-6 in [scenario], editing [id] or new, after [act]. */
|
||||
fun editorState(
|
||||
scenario: FakeScenario,
|
||||
id: AccountId? = null,
|
||||
failWith: String? = null,
|
||||
field: String? = null,
|
||||
act: (AccountEditorViewModel) -> Unit = {},
|
||||
): AccountEditorUi = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith, field)
|
||||
val vm = AccountEditorViewModel(args(id), accounts, fakes.session, fakes.connectivity)
|
||||
act(vm)
|
||||
vm.ui.value
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package se.hagstedt.kvitt.feature.account
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
|
||||
class AccountDetailViewModelTest {
|
||||
private fun ready(ui: AccountDetailUi) = ui as AccountDetailUi.Ready
|
||||
|
||||
@Test
|
||||
fun loadedAccountShowsBalanceMonthsAndFab() {
|
||||
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag))
|
||||
assertEquals(118_450L, ui.detail.balance.netOre)
|
||||
assertEquals(listOf("2026-10", "2026-09"), ui.detail.months.map { it.period })
|
||||
assertEquals(0, ui.colorIndex)
|
||||
assertEquals(50f, ui.viewerPercent)
|
||||
assertTrue(ui.showFab)
|
||||
assertFalse(ui.canDelete)
|
||||
assertTrue(ui.canEdit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archivedAccountHidesTheFab() {
|
||||
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland))
|
||||
assertFalse(ui.showFab)
|
||||
assertEquals(3, ui.colorIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun viewerPercentComesFromBasisPoints() {
|
||||
assertEquals(60f, ready(detailState(AccountScenarios.loaded, AccountScenarios.huset)).viewerPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyAccountMayBeDeletedOnlyOnline() {
|
||||
assertTrue(ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id)).canDelete)
|
||||
val offline = AccountScenarios.empty.copy(online = false)
|
||||
val ui = ready(detailState(offline, AccountScenarios.sommarstugan.id))
|
||||
assertFalse(ui.canDelete)
|
||||
assertFalse(ui.canEdit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveNavigatesBackWithTheName() {
|
||||
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true); it.archive() })
|
||||
assertEquals(AccountEvent.Archived("Vardag"), ui.event)
|
||||
assertFalse(ui.menuOpen)
|
||||
assertTrue(ui.detail.account.archived)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unarchiveStaysWithASnackbar() {
|
||||
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.gotland) { it.unarchive() })
|
||||
assertEquals(AccountMessage.Restored("Gotland 2026"), ui.message)
|
||||
assertFalse(ui.detail.account.archived)
|
||||
assertNull(ui.event)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkFailureOffersRetryAndChangesNothing() {
|
||||
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive() })
|
||||
assertEquals(AccountMessage.CouldNotReach(AccountAction.Archive), ui.message)
|
||||
assertFalse(ui.detail.account.archived)
|
||||
assertFalse(ui.busy)
|
||||
val shown = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = NETWORK) { it.archive(); it.messageShown() })
|
||||
assertNull(shown.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineActionSaysNeedsConnection() {
|
||||
val ui = ready(detailState(AccountScenarios.offline, AccountScenarios.vardag) { it.archive() })
|
||||
assertEquals(AccountMessage.NeedsConnection, ui.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAsksThenDeletes() {
|
||||
val asked = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() })
|
||||
assertTrue(asked.deleteDialog)
|
||||
val dismissed = ready(detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.dismissDelete() })
|
||||
assertFalse(dismissed.deleteDialog)
|
||||
val deleted = detailState(AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete(); it.confirmDelete() }
|
||||
assertEquals(AccountDetailUi.Gone(AccountEvent.Deleted), deleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteOfANonEmptyAccountIsRefused() {
|
||||
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag) { it.confirmDelete() })
|
||||
assertEquals(AccountMessage.DeleteBlocked, ui.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherErrorsKeepTheirCode() {
|
||||
val ui = ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag, failWith = "forbidden") { it.archive() })
|
||||
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownAccountIsGone() {
|
||||
assertEquals(AccountDetailUi.Gone(AccountEvent.Gone), detailState(AccountScenarios.loaded, AccountId("missing")))
|
||||
}
|
||||
}
|
||||
|
||||
class AccountEditorViewModelTest {
|
||||
@Test
|
||||
fun newAccountStartsAtFiftyFiftyAndNeedsAName() {
|
||||
val ui = editorState(AccountScenarios.editor)
|
||||
assertTrue(ui.loaded)
|
||||
assertNull(ui.editing)
|
||||
assertEquals(5_000, ui.viewerBp)
|
||||
assertEquals(FakeScenarios.owner, ui.me)
|
||||
assertFalse(ui.canSave)
|
||||
assertTrue(editorState(AccountScenarios.editor) { it.onName("Sommarstugan") }.canSave)
|
||||
assertFalse(editorState(AccountScenarios.editor) { it.onName(" ") }.canSave)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editStartsFromTheAccount() {
|
||||
val ui = editorState(AccountScenarios.editor, id = AccountScenarios.huset)
|
||||
assertEquals("Huset", ui.name)
|
||||
assertEquals(6_000, ui.viewerBp)
|
||||
assertFalse(ui.dirty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun percentsAcceptTwoDecimalsAndTheOtherFollows() {
|
||||
assertEquals(6_275, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75") }.viewerBp)
|
||||
assertEquals(10_000 - 3_350, editorState(AccountScenarios.editor) { it.onPercent(PercentField.Partner, "33.5") }.viewerBp)
|
||||
// A third decimal is not accepted: the text stays as it was.
|
||||
val third = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "62,75"); it.onPercent(PercentField.Viewer, "62,755") }
|
||||
assertEquals("62,75", third.typedText)
|
||||
// Over 100 is not a valid ratio: Spara is off.
|
||||
val over = editorState(AccountScenarios.editor) { it.onName("X"); it.onPercent(PercentField.Viewer, "101") }
|
||||
assertNull(over.viewerBp)
|
||||
assertFalse(over.canSave)
|
||||
assertNull(editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "") }.viewerBp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePercent() {
|
||||
assertEquals(6_000, parsePercentBp("60"))
|
||||
assertEquals(6_050, parsePercentBp("60,5"))
|
||||
assertEquals(5, parsePercentBp("0.05"))
|
||||
assertEquals(10_000, parsePercentBp("100"))
|
||||
assertNull(parsePercentBp("100,01"))
|
||||
assertNull(parsePercentBp(","))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sliderAndPresetsSetWholePercents() {
|
||||
val ui = editorState(AccountScenarios.editor) { it.onPercent(PercentField.Viewer, "12,5"); it.onViewerPercent(40) }
|
||||
assertEquals(4_000, ui.viewerBp)
|
||||
assertNull(ui.typed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createSavesBasisPointsAndReportsTheNewId() {
|
||||
val fakes = FakeRepositories(AccountScenarios.editor)
|
||||
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
|
||||
withMain { vm.onName("Sommarstugan"); vm.onPercent(PercentField.Viewer, "62,5"); vm.save() }
|
||||
val saved = vm.ui.value.saved
|
||||
val created = fakes.store.current.accounts.single { it.name == "Sommarstugan" }
|
||||
assertEquals(created.id, saved)
|
||||
assertEquals(mapOf(FakeScenarios.owner.id to 6_250, FakeScenarios.partner.id to 3_750), created.defaultShares)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nameTakenIsShownUnderTheName() {
|
||||
val ui = editorState(AccountScenarios.editor) { it.onName(" vardag "); it.save() }
|
||||
assertEquals(NameError.Taken("vardag"), ui.nameError)
|
||||
assertFalse(ui.saving)
|
||||
assertTrue(ui.canSave)
|
||||
assertNull(editorState(AccountScenarios.editor) { it.onName("Vardag"); it.save(); it.onName("Vardag 2") }.nameError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tooLongNameIsCaughtBeforeSaving() {
|
||||
val ui = editorState(AccountScenarios.editor) { it.onName("a".repeat(MAX_NAME + 1)) }
|
||||
assertEquals(NameError.TooLong, ui.nameError)
|
||||
assertFalse(ui.canSave)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverValidationMapsToTheField() {
|
||||
assertEquals(NameError.Invalid, editorState(AccountScenarios.editor, failWith = "validation_failed", field = "name") { it.onName("X"); it.save() }.nameError)
|
||||
assertTrue(editorState(AccountScenarios.editor, failWith = "validation_failed", field = "default_shares") { it.onName("X"); it.save() }.splitError)
|
||||
assertEquals(EditorMessage.CouldNotReach, editorState(AccountScenarios.editor, failWith = NETWORK) { it.onName("X"); it.save() }.message)
|
||||
assertEquals(EditorMessage.Failed("household_incomplete"), editorState(AccountScenarios.editor, failWith = "household_incomplete") { it.onName("X"); it.save() }.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun offlineCannotSave() {
|
||||
val ui = editorState(AccountScenarios.editorOffline) { it.onName("Sommarstugan") }
|
||||
assertFalse(ui.online)
|
||||
assertFalse(ui.enabled)
|
||||
assertFalse(ui.canSave)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closingWithChangesAsksFirst() {
|
||||
val fakes = FakeRepositories(AccountScenarios.editor)
|
||||
val vm = withMain { AccountEditorViewModel(args(null), fakes.accounts, fakes.session, fakes.connectivity) }
|
||||
assertTrue(withMain { vm.requestClose() })
|
||||
withMain { vm.onName("Sommarstugan") }
|
||||
assertFalse(withMain { vm.requestClose() })
|
||||
assertTrue(vm.ui.value.discardDialog)
|
||||
withMain { vm.keepEditing() }
|
||||
assertFalse(vm.ui.value.discardDialog)
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package se.hagstedt.kvitt.feature.account
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
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.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.unit.Density
|
||||
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
|
||||
import com.github.takahirom.roborazzi.captureScreenRoboImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
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
|
||||
|
||||
/**
|
||||
* Full-screen goldens of this module's screens, as M6's component snapshots but for whole screens (dialogs and menus
|
||||
* included): `src/test/screenshots/<state>_<sv|en>_<light|dark>.png`, plus `<state>_sv_light_200.png` at 200 % font
|
||||
* scale for the long states. The screen is the Galaxy S26 (411 × 891 dp), at xhdpi to keep the goldens small. The
|
||||
* clock is fixed at 17 October 2026 14:02 in Stockholm; the frame clock is advanced by hand so animations and
|
||||
* snackbars render the same every run.
|
||||
*/
|
||||
object ScreenSnapshots {
|
||||
const val QUALIFIERS = "w411dp-h891dp-xhdpi"
|
||||
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
|
||||
private const val SETTLE_MILLIS = 1_000L
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalRoborazziApi::class)
|
||||
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.mainClock.autoAdvance = false
|
||||
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, content = content)
|
||||
}
|
||||
}
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
|
||||
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> withMain(block: () -> T): T {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user