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:
2026-10-18 22:54:03 +02:00
parent e4e6e5d792
commit 8229ed60ea
55 changed files with 472 additions and 664 deletions
@@ -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) }
}
@@ -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)
@@ -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)
@@ -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()
}
@@ -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()
@@ -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 {