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
+2 -15
View File
@@ -1,23 +1,10 @@
// :feature:settle — Settle up and settlement detail (S-9, S-10; M7d). Convention: android/README.md.
// Depends on :core:model and :core:design (added by the feature plugin); :core:fakes only for debug and tests.
// :feature:settle — Settle up and settlement detail (S-9, S-10). Convention: android/README.md; :core:model,
// :core:design, :core:fakes (debug and tests), :core:design's test fixtures and the screenshot tests come from kvitt.android.feature.
plugins {
id("kvitt.android.feature")
alias(libs.plugins.roborazzi)
}
roborazzi {
outputDir.set(file("src/test/screenshots"))
}
dependencies {
// BackHandler: system back with unsaved changes asks "Slänga ändringarna?" (design-spec S-9).
implementation(libs.androidx.activity.compose)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.junit.rule)
}
@@ -1,17 +1,12 @@
package se.hagstedt.kvitt.feature.settle
import androidx.lifecycle.SavedStateHandle
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.Rule
import org.junit.Test
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.fakes.FakeRepositories
@@ -19,15 +14,13 @@ import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.settle.SettleScenarios
import se.hagstedt.kvitt.model.SettlementId
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
/** S-9's state holder: defaults, re-proposals, enablement, warnings, error mapping and saving (AC-11.3 to AC-11.6). */
@OptIn(ExperimentalCoroutinesApi::class)
class SettleViewModelTest {
@Before
fun main() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun reset() = Dispatchers.resetMain()
@get:Rule
val main = MainDispatcherRule()
private fun vm(scenario: FakeScenario, args: Map<String, String> = emptyMap()): Pair<SettleViewModel, FakeRepositories> {
val repos = FakeRepositories(scenario)
@@ -1,33 +1,27 @@
package se.hagstedt.kvitt.feature.settle
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
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.settle.SettleScenarios
import se.hagstedt.kvitt.model.AuditAction
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
/** S-10's state holder: the detail, the missing and deleted states and deleting (AC-11.6, AC-12.4, AC-12.5). */
@OptIn(ExperimentalCoroutinesApi::class)
class SettlementDetailViewModelTest {
@Before
fun main() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun reset() = Dispatchers.resetMain()
@get:Rule
val main = MainDispatcherRule()
private fun vm(id: String, repos: FakeRepositories = FakeRepositories(SettleScenarios.detail)) =
SettlementDetailViewModel(SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to id)), repos.settlements, repos.accounts, repos.home, repos.connectivity, repos.sync)
@@ -31,7 +31,7 @@ private sealed interface Case {
private data class Settle(
val scenario: FakeScenario,
val args: Map<String, String> = emptyMap(),
override val height: Dp = ScreenSnapshots.PHONE_HEIGHT,
override val height: Dp = SettleSnapshots.PHONE_HEIGHT,
override val largeFont: Boolean = false,
val steps: SettleViewModel.() -> Unit = {},
) : Case
@@ -39,7 +39,7 @@ private data class Settle(
private data class Detail(
val scenario: FakeScenario,
val id: String,
override val height: Dp = ScreenSnapshots.PHONE_HEIGHT,
override val height: Dp = SettleSnapshots.PHONE_HEIGHT,
override val largeFont: Boolean = false,
val steps: SettlementDetailViewModel.() -> Unit = {},
) : Case
@@ -98,9 +98,9 @@ class SettleScreenshotTest(private val name: String) {
compose.waitForIdle()
vm.apply(case.steps)
compose.waitForIdle()
ScreenSnapshots.snap(compose, name, case.height, case.largeFont) {
SettleSnapshots.snap(compose, name, case.height, case.largeFont) {
val state by vm.state.collectAsState()
SettleContent(state, vm, overlayHost = ScreenSnapshots.inlineOverlays)
SettleContent(state, vm, overlayHost = SettleSnapshots.inlineOverlays)
}
}
is Detail -> {
@@ -109,9 +109,9 @@ class SettleScreenshotTest(private val name: String) {
SavedStateHandle(mapOf(SettleArgs.SETTLEMENT_ID to case.id)), repos.settlements, repos.accounts, repos.home, repos.connectivity, repos.sync,
)
vm.apply(case.steps)
ScreenSnapshots.snap(compose, name, case.height, case.largeFont) {
SettleSnapshots.snap(compose, name, case.height, case.largeFont) {
val state by vm.state.collectAsState()
SettlementDetailContent(state, vm, onBack = {}, onEdit = {}, overlayHost = ScreenSnapshots.inlineOverlays)
SettlementDetailContent(state, vm, onBack = {}, onEdit = {}, overlayHost = SettleSnapshots.inlineOverlays)
}
}
}
@@ -1,7 +1,5 @@
package se.hagstedt.kvitt.feature.settle.screenshot
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -12,50 +10,26 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.Alignment
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 androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
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 se.hagstedt.kvitt.feature.settle.OverlayHost
import se.hagstedt.kvitt.feature.settle.OverlayKind
import java.util.Locale
import kotlin.time.Instant
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
/**
* Captures a whole screen in three variants (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` with [largeFont] (200 % font scale, Swedish, light, like the spec's 200 % frames). Pattern
* of `:core:design`'s `Snapshots` (M6): content set once, configuration switched between captures. The screen is the
* Galaxy S26's 411 dp wide and [height] high; the clock is fixed at 17 October 2026 14:02 in Stockholm.
* S-9's and S-10's goldens through the test fixtures' [ScreenSnapshots] (`:core:design`): the screen is the Galaxy S26's 411 dp wide and
* `height` high on `surface`, captured from the compose root, so dialogs and sheets draw inside it ([inlineOverlays]).
*/
object ScreenSnapshots {
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
object SettleSnapshots {
/** Normal phone height in dp; long-content states pass a taller one so every row is in the capture. */
val PHONE_HEIGHT: Dp = 891.dp
private const val SCRIM_ALPHA = 0.38f
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 ""
}
/** Draws dialogs and sheets inside the screen over a 38 % scrim (design-spec §3.11), so the root capture shows them. */
val inlineOverlays: OverlayHost = { kind, _, content ->
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = SCRIM_ALPHA))) {
@@ -71,37 +45,8 @@ object ScreenSnapshots {
}
fun snap(compose: ComposeContentTestRule, name: String, height: Dp = PHONE_HEIGHT, largeFont: Boolean = false, content: @Composable BoxScope.() -> 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) {
Box(Modifier.size(411.dp, height).background(MaterialTheme.colorScheme.surface), content = content)
}
}
}
variants.forEach { variant ->
current = variant
compose.waitForIdle()
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
ScreenSnapshots.snap(compose, name, largeFont) {
Box(Modifier.size(411.dp, height).background(MaterialTheme.colorScheme.surface), content = content)
}
}
}