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:
+2
-1
@@ -7,6 +7,7 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.design.format.AmountKey
|
||||
import se.hagstedt.kvitt.fakes.settings.SAMPLE_CODE
|
||||
import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories
|
||||
import se.hagstedt.kvitt.fakes.settings.SettingsScenarios
|
||||
@@ -25,9 +26,9 @@ import se.hagstedt.kvitt.feature.settings.profile.ProfileViewModel
|
||||
import se.hagstedt.kvitt.feature.settings.recurring.EditorFocus
|
||||
import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel
|
||||
import se.hagstedt.kvitt.feature.settings.recurring.StartError
|
||||
import se.hagstedt.kvitt.design.format.AmountKey
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
|
||||
/** The settings ViewModels against the fakes: input handling, enablement rules and error mapping (PLAN-M7f §5). */
|
||||
|
||||
+4
-3
@@ -29,8 +29,9 @@ import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
import se.hagstedt.kvitt.design.component.editorOptionTag
|
||||
import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories
|
||||
import se.hagstedt.kvitt.fakes.settings.SettingsScenarios
|
||||
import se.hagstedt.kvitt.feature.settings.screenshot.ScreenSnapshots
|
||||
import se.hagstedt.kvitt.feature.settings.screenshot.SettingsSnapshots
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.design.testing.TestClock
|
||||
|
||||
/**
|
||||
* The three option rows of S-19 (Konto, Betalt av, Delning; D-107, extending D-102) keep their places and sizes when
|
||||
@@ -50,7 +51,7 @@ class RecurringOptionRowsLayoutTest {
|
||||
val f = r.fakes
|
||||
val handle = SavedStateHandle(mapOf(RecurringEditorViewModel.ARG_TEMPLATE_ID to SettingsScenarios.hyra.id.value))
|
||||
val vm = RecurringEditorViewModel(handle, f.expenses, f.home, r.recurring, f.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
requireNotNull(vm.state.value)
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ class RecurringOptionRowsLayoutTest {
|
||||
LocalConfiguration provides context.resources.configuration,
|
||||
LocalResources provides context.resources,
|
||||
LocalDensity provides Density(context),
|
||||
LocalKvittClock provides ScreenSnapshots.clock,
|
||||
LocalKvittClock provides TestClock,
|
||||
) {
|
||||
KvittTheme { RecurringEditorContent(state, RecurringEditorActions()) }
|
||||
}
|
||||
|
||||
+5
-5
@@ -39,10 +39,10 @@ class CategoriesScreenshotTest(private val state: String) {
|
||||
private fun list(name: String, scenario: FakeScenario, scrollToEnd: Boolean = false, drive: (ScriptedSettingsRepositories, CategoryListViewModel) -> Unit = { _, _ -> }) = Shot("list_$name") { compose ->
|
||||
val r = ScriptedSettingsRepositories(scenario)
|
||||
val vm = CategoryListViewModel(r.categories, r.fakes.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
drive(r, vm)
|
||||
val prepare: (androidx.compose.ui.test.junit4.ComposeContentTestRule) -> Unit = { c -> if (scrollToEnd) c.onNodeWithText("Pant").performScrollTo() }
|
||||
ScreenSnapshots.snap(compose, "s15_categories_list_$name", prepare = prepare) {
|
||||
SettingsSnapshots.snap(compose, "s15_categories_list_$name", prepare = prepare) {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
CategoryListContent(s, CategoryListActions())
|
||||
}
|
||||
@@ -52,9 +52,9 @@ class CategoriesScreenshotTest(private val state: String) {
|
||||
val r = ScriptedSettingsRepositories(scenario)
|
||||
val handle = SavedStateHandle(category?.let { mapOf(CategoryEditorViewModel.ARG_CATEGORY_ID to it.id.value) } ?: emptyMap())
|
||||
val vm = CategoryEditorViewModel(handle, r.categories, r.fakes.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
drive(r, vm)
|
||||
ScreenSnapshots.snap(compose, "s15_categories_editor_$name") {
|
||||
SettingsSnapshots.snap(compose, "s15_categories_editor_$name") {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
CategoryEditorContent(s, CategoryEditorActions())
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class CategoriesScreenshotTest(private val state: String) {
|
||||
editor("error_network", SettingsScenarios.loaded, SettingsScenarios.hund) { r, vm ->
|
||||
r.script.fail = OnlineResult.Failed("network", null)
|
||||
vm.onColor(se.hagstedt.kvitt.model.ColorToken.ORANGE)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
vm.save()
|
||||
},
|
||||
editor("needs_connection", SettingsScenarios.offline, SettingsScenarios.hund),
|
||||
|
||||
+6
-6
@@ -41,9 +41,9 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) {
|
||||
val r = ScriptedSettingsRepositories(scenario)
|
||||
before(r)
|
||||
val vm = DevicesViewModel(r.devices, r.fakes.session, r.profile, r.fakes.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
drive(r, vm)
|
||||
ScreenSnapshots.snap(compose, "s20_devices_$name") {
|
||||
SettingsSnapshots.snap(compose, "s20_devices_$name") {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
DevicesContent(s, DevicesActions())
|
||||
}
|
||||
@@ -54,9 +54,9 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) {
|
||||
private fun invites(name: String, scenario: FakeScenario, drive: (ScriptedSettingsRepositories, InvitesViewModel) -> Unit = { _, _ -> }) = Shot("s21_invites_$name") { compose ->
|
||||
val r = ScriptedSettingsRepositories(scenario)
|
||||
val vm = InvitesViewModel(r.invites, r.fakes.session, r.profile, r.fakes.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
drive(r, vm)
|
||||
ScreenSnapshots.snap(compose, "s21_invites_$name") {
|
||||
SettingsSnapshots.snap(compose, "s21_invites_$name") {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
InvitesContent(s, InvitesActions())
|
||||
}
|
||||
@@ -65,9 +65,9 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) {
|
||||
private fun about(name: String, scenario: FakeScenario, drive: (ScriptedSettingsRepositories, AboutViewModel) -> Unit = { _, _ -> }) = Shot("s22_about_$name") { compose ->
|
||||
val r = ScriptedSettingsRepositories(scenario)
|
||||
val vm = AboutViewModel(r.fakes.about, r.update, r.fakes.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
drive(r, vm)
|
||||
ScreenSnapshots.snap(compose, "s22_about_$name") {
|
||||
SettingsSnapshots.snap(compose, "s22_about_$name") {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
AboutContent(s, AboutActions(), updateCheckAvailable = true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ class HubScreenshotTest(private val state: String) {
|
||||
|
||||
private fun shot(name: String, scenario: FakeScenario) = Shot(name) { compose ->
|
||||
val vm = viewModel(ScriptedSettingsRepositories(scenario))
|
||||
ScreenSnapshots.snap(compose, "s13_hub_$name") {
|
||||
SettingsSnapshots.snap(compose, "s13_hub_$name") {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
SettingsHubContent(s, SettingsHubActions())
|
||||
}
|
||||
|
||||
+2
-2
@@ -34,9 +34,9 @@ class ProfileScreenshotTest(private val state: String) {
|
||||
private fun shot(name: String, scenario: FakeScenario, picture: Boolean = false, drive: (ScriptedSettingsRepositories, ProfileViewModel) -> Unit = { _, _ -> }) = Shot(name) { compose ->
|
||||
val r = ScriptedSettingsRepositories(scenario)
|
||||
val vm = ProfileViewModel(r.profile, r.fakes.session, r.fakes.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
drive(r, vm)
|
||||
ScreenSnapshots.snap(compose, "s14_profile_$name", picture) {
|
||||
SettingsSnapshots.snap(compose, "s14_profile_$name", picture) {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
ProfileContent(s, ProfileActions())
|
||||
}
|
||||
|
||||
+8
-8
@@ -43,7 +43,7 @@ class RecurringScreenshotTest(private val state: String) {
|
||||
val r = ScriptedSettingsRepositories(scenario)
|
||||
val f = r.fakes
|
||||
val vm = RecurringListViewModel(r.recurring, f.accounts, r.categories, f.session, r.profile, f.connectivity)
|
||||
ScreenSnapshots.snap(compose, "s19_recurring_list_$name") {
|
||||
SettingsSnapshots.snap(compose, "s19_recurring_list_$name") {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
RecurringListContent(s, RecurringListActions())
|
||||
}
|
||||
@@ -60,10 +60,10 @@ class RecurringScreenshotTest(private val state: String) {
|
||||
val f = r.fakes
|
||||
val handle = SavedStateHandle(template?.let { mapOf(RecurringEditorViewModel.ARG_TEMPLATE_ID to it.id.value) } ?: emptyMap())
|
||||
val vm = RecurringEditorViewModel(handle, f.expenses, f.home, r.recurring, f.connectivity)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
drive(r, vm)
|
||||
ScreenSnapshots.idle()
|
||||
ScreenSnapshots.snap(compose, "s19_recurring_editor_$name", prepare = { c -> if (scrollTo != null) c.onNodeWithText(scrollTo, substring = true).performScrollTo() }) {
|
||||
SettingsSnapshots.idle()
|
||||
SettingsSnapshots.snap(compose, "s19_recurring_editor_$name", prepare = { c -> if (scrollTo != null) c.onNodeWithText(scrollTo, substring = true).performScrollTo() }) {
|
||||
val s by vm.state.collectAsStateWithLifecycle()
|
||||
RecurringEditorContent(s, RecurringEditorActions())
|
||||
}
|
||||
@@ -113,24 +113,24 @@ class RecurringScreenshotTest(private val state: String) {
|
||||
editor("error_validation", SettingsScenarios.loaded, null, scrollTo = "06:00") { r, vm ->
|
||||
newHyra(r, vm)
|
||||
r.script.fail = OnlineResult.Failed("validation_failed", "start_period")
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
vm.save()
|
||||
},
|
||||
editor("error_network", SettingsScenarios.loaded, SettingsScenarios.hyra) { r, vm ->
|
||||
r.script.fail = OnlineResult.Failed("network", null)
|
||||
vm.onPaused(true)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
vm.save()
|
||||
},
|
||||
editor("required", SettingsScenarios.loaded, null) { _, vm ->
|
||||
vm.focus(EditorFocus.None)
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
vm.save()
|
||||
},
|
||||
editor("delete_confirm", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm -> vm.askDelete() },
|
||||
editor("discard", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm ->
|
||||
vm.onDescription("Hyra och el")
|
||||
ScreenSnapshots.idle()
|
||||
SettingsSnapshots.idle()
|
||||
vm.close()
|
||||
},
|
||||
editor("needs_connection", SettingsScenarios.offline, SettingsScenarios.hyra),
|
||||
|
||||
+22
-70
@@ -1,98 +1,50 @@
|
||||
package se.hagstedt.kvitt.feature.settings.screenshot
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.LinearGradient
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Shader
|
||||
import android.os.Looper
|
||||
import android.os.LocaleList
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
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.datetime.TimeZone
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.component.AvatarPictures
|
||||
import se.hagstedt.kvitt.design.component.KvittClock
|
||||
import se.hagstedt.kvitt.design.component.LocalAvatarPictures
|
||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
|
||||
|
||||
/**
|
||||
* Whole-screen goldens of `:feature:settings` (M7f), following `:core:design`'s pattern: three captures (Swedish light and
|
||||
* dark, English light; D-098), `src/test/screenshots/<name>_<sv_light|sv_dark|en_light>.png`. The screen (Robolectric `w411dp-h914dp`, the Galaxy
|
||||
* S26) is captured with its dialogs. Content is set once; language and theme switch through a configuration context.
|
||||
* The clock is 17 October 2026 14:02 Stockholm, as in the fakes' scenarios.
|
||||
* `:feature:settings`'s goldens through the test fixtures' [ScreenSnapshots] (`:core:design`) (Robolectric `w411dp-h914dp`, the Galaxy
|
||||
* S26): the whole screen with its dialogs once the ViewModel's coroutines are idle, on `surface`. [snap]'s `picture`
|
||||
* draws a stand-in profile picture for users with a `pictureVersion`.
|
||||
*/
|
||||
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 suffix: String get() = "${language}_${if (dark) "dark" else "light"}"
|
||||
}
|
||||
|
||||
object SettingsSnapshots {
|
||||
/** Runs pending main-thread work (ViewModel coroutines) before a capture. */
|
||||
fun idle() = shadowOf(Looper.getMainLooper()).idle()
|
||||
fun idle() = ScreenSnapshots.idleMainLooper()
|
||||
|
||||
@OptIn(ExperimentalRoborazziApi::class)
|
||||
fun snap(compose: ComposeContentTestRule, name: String, picture: Boolean = false, prepare: (ComposeContentTestRule) -> Unit = {}, content: @Composable () -> Unit) {
|
||||
val variants = listOf(Variant("sv", dark = false), Variant("sv", dark = true), Variant("en", dark = false))
|
||||
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")))
|
||||
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)
|
||||
}
|
||||
val pictures = remember { if (picture) AvatarPictures { user -> if (user.pictureVersion != null) portrait else null } else AvatarPictures { null } }
|
||||
CompositionLocalProvider(
|
||||
LocalContext provides context,
|
||||
LocalConfiguration provides context.resources.configuration,
|
||||
LocalResources provides context.resources,
|
||||
LocalDensity provides Density(context),
|
||||
LocalKvittClock provides clock,
|
||||
LocalAvatarPictures provides pictures,
|
||||
// Coil loads synchronously in inspection mode, so a picture is drawn before the capture.
|
||||
LocalInspectionMode provides picture,
|
||||
) {
|
||||
KvittTheme(darkTheme = variant.dark) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
idle()
|
||||
compose.waitForIdle()
|
||||
prepare(compose)
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
idle()
|
||||
compose.waitForIdle()
|
||||
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
ScreenSnapshots.snap(
|
||||
compose,
|
||||
name,
|
||||
capture = ScreenSnapshots.Capture.ScreenWhenIdle,
|
||||
prepare = prepare,
|
||||
providers = { inner ->
|
||||
val pictures = remember { if (picture) AvatarPictures { user -> if (user.pictureVersion != null) portrait else null } else AvatarPictures { null } }
|
||||
CompositionLocalProvider(
|
||||
LocalAvatarPictures provides pictures,
|
||||
// Coil loads synchronously in inspection mode, so a picture is drawn before the capture.
|
||||
LocalInspectionMode provides picture,
|
||||
content = inner,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ package se.hagstedt.kvitt.feature.settings.screenshot
|
||||
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
|
||||
/** One screenshot state: [run] builds the ViewModel from the fakes, drives it and calls [ScreenSnapshots.snap]. */
|
||||
/** One screenshot state: [run] builds the ViewModel from the fakes, drives it and calls [SettingsSnapshots.snap]. */
|
||||
class Shot(val name: String, val run: (ComposeContentTestRule) -> Unit)
|
||||
|
||||
/** The parameter list of a screen's test: one entry per state. */
|
||||
|
||||
Reference in New Issue
Block a user