Android overhaul: one screenshot harness in :core:design's test fixtures; kvitt.android.screenshots wires Roborazzi
Eight modules each had a copy of the same configuration-context harness (ScreenSnapshots/Snapshots); M12f had to fix the same capture bug in two of them. :core:design's test fixtures (src/testFixtures, package se.hagstedt.kvitt.design.testing) now hold Variant, TestClock, TestEnvironment, ScreenSnapshots with its three capture modes (Root; Screen, with the hand-advanced frame clock; ScreenWhenIdle), MainDispatcherRule and withMain. Test fixtures rather than a :core:testing module: :core:design's own tests can use them without a dependency on itself, no module is added to the §6.2 graph, and lint treats them as test sources. The architecture check rejects test fixtures outside test configurations. Modules keep only their own frame (analytics household, settle size and inline overlays, settings avatar pictures). Every golden is unchanged. kvitt.android.screenshots applies Roborazzi with outputDir src/test/screenshots and adds the screenshot test libraries; the feature plugin applies it and adds the test fixtures, so the seven feature build scripts lose their copied blocks. Removed: roborazzi-junit-rule and roborazzi-compose (never imported), the explicit lifecycle-viewmodel-compose (feature plugin, :app) and hilt-lifecycle-viewmodel-compose in :app (both still arrive through the features; release and debug runtime classpaths are identical), and the unused catalog entries androidx-hilt-navigation-compose and androidx-lifecycle-viewmodel-compose. @Before/@After setMain pairs in nine test classes use MainDispatcherRule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+6
-4
@@ -31,8 +31,10 @@ import se.hagstedt.kvitt.design.format.AmountKey
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
|
||||
import se.hagstedt.kvitt.feature.expense.screenshot.NoActions
|
||||
import se.hagstedt.kvitt.feature.expense.screenshot.ScreenSnapshots
|
||||
import se.hagstedt.kvitt.feature.expense.screenshot.buildState
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
|
||||
import se.hagstedt.kvitt.design.testing.TestClock
|
||||
|
||||
/**
|
||||
* The four option rows of S-7 (Konto, Datum, Betalt av, Delning; D-102) keep their places and sizes when their values
|
||||
@@ -42,12 +44,12 @@ import se.hagstedt.kvitt.model.SplitMode
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi")
|
||||
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
|
||||
class EditorOptionRowsLayoutTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private val short: ExpenseEditorState = ScreenSnapshots.build { idle ->
|
||||
private val short: ExpenseEditorState = buildState { idle ->
|
||||
val fakes = FakeRepositories(ExpenseScenarios.household)
|
||||
val vm = ExpenseEditorViewModel(SavedStateHandle(), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
|
||||
idle()
|
||||
@@ -79,7 +81,7 @@ class EditorOptionRowsLayoutTest {
|
||||
LocalConfiguration provides context.resources.configuration,
|
||||
LocalResources provides context.resources,
|
||||
LocalDensity provides Density(context),
|
||||
LocalKvittClock provides ScreenSnapshots.clock,
|
||||
LocalKvittClock provides TestClock,
|
||||
) {
|
||||
KvittTheme { ExpenseEditorContent(state, NoActions) }
|
||||
}
|
||||
|
||||
+5
-13
@@ -1,37 +1,29 @@
|
||||
package se.hagstedt.kvitt.feature.expense
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.Rule
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
|
||||
|
||||
/** S-8 state holder: loaded, pending timeline line, deleted, missing, delete flow. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ExpenseDetailViewModelTest {
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
@get:Rule
|
||||
val main = MainDispatcherRule()
|
||||
private val dispatcher = main.dispatcher
|
||||
|
||||
private fun TestScope.detail(id: ExpenseId, scenario: FakeScenario = ExpenseScenarios.household): Pair<ExpenseDetailViewModel, FakeRepositories> {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
|
||||
+5
-13
@@ -1,21 +1,16 @@
|
||||
package se.hagstedt.kvitt.feature.expense
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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.Rule
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.design.format.AmountInput
|
||||
import se.hagstedt.kvitt.design.format.AmountKey
|
||||
@@ -26,20 +21,17 @@ import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios
|
||||
import se.hagstedt.kvitt.model.FieldError
|
||||
import se.hagstedt.kvitt.model.Share
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
|
||||
|
||||
/** S-7 state holder: defaults, keypad input, split modes, the category suggestion, enablement and error mapping. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ExpenseEditorViewModelTest {
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
@get:Rule
|
||||
val main = MainDispatcherRule()
|
||||
private val dispatcher = main.dispatcher
|
||||
private val jacob = FakeScenarios.owner.id
|
||||
private val anna = FakeScenarios.partner.id
|
||||
|
||||
@Before
|
||||
fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
|
||||
@After
|
||||
fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
private fun TestScope.editor(scenario: FakeScenario = ExpenseScenarios.household, args: Map<String, Any?> = emptyMap()): Pair<ExpenseEditorViewModel, FakeRepositories> {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
|
||||
|
||||
+12
-89
@@ -1,21 +1,5 @@
|
||||
package se.hagstedt.kvitt.feature.expense.screenshot
|
||||
|
||||
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.test.onRoot
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
@@ -23,81 +7,20 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
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
|
||||
|
||||
/**
|
||||
* Captures three goldens of a whole screen (Swedish light and dark, English light; D-098): `src/test/screenshots/<name>_<sv_light|sv_dark|en_light>.png`,
|
||||
* plus `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish and light, as the spec's 200 %
|
||||
* frames). The pattern of `:core:design` (M6): content set once, language, theme and font scale switched through a
|
||||
* configuration context between captures.
|
||||
* The clock is fixed at 17 October 2026 14:02 in Stockholm, the fake scenarios' "now".
|
||||
* Runs [setup] with the view models' main dispatcher set to a test dispatcher and returns its result; [setup] calls
|
||||
* the given `idle` after actions that wait (the 150 ms suggestion debounce).
|
||||
*/
|
||||
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 = false, content: @Composable () -> Unit) {
|
||||
val variants = listOf(Variant("sv", dark = false, fontScale = 1f), Variant("sv", dark = true, fontScale = 1f), Variant("en", dark = false, fontScale = 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) { content() }
|
||||
}
|
||||
}
|
||||
// Material's date picker formats months and weekdays with the JVM default locale (the app's locale on a phone).
|
||||
val hostLocale = Locale.getDefault()
|
||||
try {
|
||||
variants.forEach { variant ->
|
||||
Locale.setDefault(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB"))
|
||||
current = variant
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
} finally {
|
||||
Locale.setDefault(hostLocale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [setup] with the view models' main dispatcher set to a test dispatcher and returns its result; [setup] calls
|
||||
* the given `idle` after actions that wait (the 150 ms suggestion debounce).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> build(setup: (idle: () -> Unit) -> T): T {
|
||||
val dispatcher = UnconfinedTestDispatcher()
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val out = mutableListOf<T>()
|
||||
try {
|
||||
runTest(dispatcher) { out += setup { advanceUntilIdle() } }
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
return out.single()
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> buildState(setup: (idle: () -> Unit) -> T): T {
|
||||
val dispatcher = UnconfinedTestDispatcher()
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val out = mutableListOf<T>()
|
||||
try {
|
||||
runTest(dispatcher) { out += setup { advanceUntilIdle() } }
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
return out.single()
|
||||
}
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ private fun editor(
|
||||
largeFont: Boolean = false,
|
||||
steps: ExpenseEditorViewModel.(idle: () -> Unit) -> Unit = {},
|
||||
) = Case(name, largeFont) {
|
||||
val state = ScreenSnapshots.build { idle ->
|
||||
val state = buildState { idle ->
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val args = if (edit == null) emptyMap() else mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to edit.value)
|
||||
val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity)
|
||||
@@ -98,7 +98,7 @@ private fun detail(
|
||||
largeFont: Boolean = false,
|
||||
steps: ExpenseDetailViewModel.() -> Unit = {},
|
||||
) = Case(name, largeFont) {
|
||||
val state = ScreenSnapshots.build { idle ->
|
||||
val state = buildState { idle ->
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync)
|
||||
idle()
|
||||
|
||||
+4
-3
@@ -7,6 +7,7 @@ import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
|
||||
|
||||
/**
|
||||
* S-7 and S-8 goldens: every state × light/dark × Swedish/English, plus 200 % for the states the design spec draws at
|
||||
@@ -15,7 +16,7 @@ import org.robolectric.annotation.GraphicsMode
|
||||
*/
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h891dp-mdpi")
|
||||
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
|
||||
class ExpenseScreenshotTest(private val name: String) {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
@@ -23,7 +24,7 @@ class ExpenseScreenshotTest(private val name: String) {
|
||||
@Test
|
||||
fun screen() {
|
||||
val case = (editorCases + detailCases).first { it.name == name }
|
||||
ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content())
|
||||
ScreenSnapshots.snap(compose, case.name, case.largeFont, jvmLocale = true, content = case.content())
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -44,7 +45,7 @@ class ExpenseTallScreenshotTest(private val name: String) {
|
||||
@Test
|
||||
fun screen() {
|
||||
val case = tallCases.first { it.name == name }
|
||||
ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content())
|
||||
ScreenSnapshots.snap(compose, case.name, case.largeFont, jvmLocale = true, content = case.content())
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
Reference in New Issue
Block a user