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:
@@ -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)
|
||||
}
|
||||
|
||||
+4
-11
@@ -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)
|
||||
|
||||
+4
-10
@@ -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)
|
||||
|
||||
+6
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-61
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user