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:
+11
-67
@@ -1,7 +1,5 @@
|
||||
package se.hagstedt.kvitt.design.screenshot
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
@@ -11,84 +9,30 @@ import androidx.compose.foundation.layout.wrapContentHeight
|
||||
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.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 com.github.takahirom.roborazzi.captureRoboImage
|
||||
import kotlinx.datetime.TimeZone
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.catalogue.Samples
|
||||
import se.hagstedt.kvitt.design.component.KvittClock
|
||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
import se.hagstedt.kvitt.design.component.ProvideHousehold
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
|
||||
|
||||
/**
|
||||
* Captures three goldens (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, like the spec's 200 % frames).
|
||||
* The content is set once; language (configuration, resources), theme and font scale (the platform's non-linear
|
||||
* scaling, from a configuration context) switch between captures. Width: the Galaxy S26, 411 dp (SPEC §13.1). The
|
||||
* clock is fixed at 17 October 2026 14:02 in Stockholm and the viewer is Jacob, so every render is reproducible.
|
||||
* A component sheet's goldens through the test fixtures' [ScreenSnapshots] (`:core:design`) (D-098): the sheet is a column 411 dp wide (the
|
||||
* Galaxy S26, SPEC §13.1) with the gutter and section gaps, on `surface`, and the viewer is Jacob.
|
||||
*/
|
||||
object Snapshots {
|
||||
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, content: @Composable ColumnScope.() -> 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) {
|
||||
ProvideHousehold(Samples.jacob, Samples.anna) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column(
|
||||
modifier = Modifier.width(411.dp).wrapContentHeight().padding(KvittTheme.spacing.gutter),
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
ScreenSnapshots.snap(compose, name, largeFont) {
|
||||
ProvideHousehold(Samples.jacob, Samples.anna) {
|
||||
Surface(color = MaterialTheme.colorScheme.surface) {
|
||||
Column(
|
||||
modifier = Modifier.width(411.dp).wrapContentHeight().padding(KvittTheme.spacing.gutter),
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package se.hagstedt.kvitt.design.testing
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
/** Sets `Dispatchers.Main` to [dispatcher] (unconfined by default) for each test, so ViewModels run on the test thread. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
|
||||
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
|
||||
|
||||
override fun finished(description: Description) = Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
/** Runs [block] with `Dispatchers.Main` unconfined, so a ViewModel's state is ready when it returns. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> withMain(block: () -> T): T {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package se.hagstedt.kvitt.design.testing
|
||||
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.github.takahirom.roborazzi.captureScreenRoboImage
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The goldens of a screen state (SPEC §6.2 "Tests", D-098): `src/test/screenshots/<name>_<variant>.png` for every
|
||||
* [Variant.goldens]. The content is composed once; language, theme and font scale switch through [TestEnvironment]
|
||||
* between captures. Full screens render at 1x through [QUALIFIERS] (the Galaxy S26, 411 × 891 dp, SPEC §13.1).
|
||||
*/
|
||||
object ScreenSnapshots {
|
||||
/** Robolectric qualifiers of a full screen at 1x. */
|
||||
const val QUALIFIERS = "w411dp-h891dp-mdpi"
|
||||
|
||||
/** How a variant is settled and captured. */
|
||||
enum class Capture {
|
||||
/** The compose root after `waitForIdle`: the screen without dialog windows. */
|
||||
Root,
|
||||
|
||||
/**
|
||||
* The whole screen with its dialog windows, frame clock advanced by hand so animations and snackbars render
|
||||
* the same every run: 1 s, then one more frame and an idle main looper, so the capture shows this variant and
|
||||
* not the previous one (a dialog window draws in a later frame).
|
||||
*/
|
||||
Screen,
|
||||
|
||||
/**
|
||||
* The whole screen with its dialog windows after the main looper (ViewModel coroutines) and Compose are idle;
|
||||
* the `prepare` step (a scroll) runs once before the first capture.
|
||||
*/
|
||||
ScreenWhenIdle,
|
||||
}
|
||||
|
||||
private const val SETTLE_MILLIS = 1_000L
|
||||
|
||||
/** Runs pending main-thread work (ViewModel coroutines). */
|
||||
fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle()
|
||||
|
||||
/**
|
||||
* Captures [content] in [KvittTheme] for every variant. [recreate] composes the content anew per variant, so
|
||||
* remembered state (scroll, picker) starts fresh; [jvmLocale] also sets the JVM default locale per variant
|
||||
* (Material's date picker formats months and weekdays with it, as the app's locale on a phone); [providers] wraps
|
||||
* the theme, for composition locals a module's screens need (avatar pictures).
|
||||
*/
|
||||
@OptIn(ExperimentalRoborazziApi::class)
|
||||
fun snap(
|
||||
compose: ComposeContentTestRule,
|
||||
name: String,
|
||||
largeFont: Boolean = false,
|
||||
capture: Capture = Capture.Root,
|
||||
recreate: Boolean = false,
|
||||
jvmLocale: Boolean = false,
|
||||
prepare: (ComposeContentTestRule) -> Unit = {},
|
||||
providers: @Composable (content: @Composable () -> Unit) -> Unit = { it() },
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val variants = Variant.goldens(largeFont)
|
||||
var current by mutableStateOf(variants.first())
|
||||
if (capture == Capture.Screen) compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
val variant = current
|
||||
TestEnvironment(variant) {
|
||||
providers {
|
||||
KvittTheme(darkTheme = variant.dark) {
|
||||
if (recreate) key(variant) { content() } else content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (capture == Capture.ScreenWhenIdle) {
|
||||
idleMainLooper()
|
||||
compose.waitForIdle()
|
||||
prepare(compose)
|
||||
}
|
||||
val hostLocale = Locale.getDefault()
|
||||
try {
|
||||
variants.forEach { variant ->
|
||||
if (jvmLocale) Locale.setDefault(variant.locale)
|
||||
current = variant
|
||||
val path = "src/test/screenshots/${name}_${variant.suffix}.png"
|
||||
when (capture) {
|
||||
Capture.Root -> {
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().captureRoboImage(path)
|
||||
}
|
||||
Capture.Screen -> {
|
||||
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
|
||||
idleMainLooper()
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
idleMainLooper()
|
||||
captureScreenRoboImage(path)
|
||||
}
|
||||
Capture.ScreenWhenIdle -> {
|
||||
idleMainLooper()
|
||||
compose.waitForIdle()
|
||||
captureScreenRoboImage(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (jvmLocale) Locale.setDefault(hostLocale)
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package se.hagstedt.kvitt.design.testing
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
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.unit.Density
|
||||
import kotlinx.datetime.TimeZone
|
||||
import se.hagstedt.kvitt.design.component.KvittClock
|
||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** The tests' "now": 17 October 2026 14:02 in Stockholm, the fake scenarios' clock, so every render is reproducible. */
|
||||
val TestClock: KvittClock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
|
||||
|
||||
/**
|
||||
* One golden of a component sheet or screen state (D-098): Swedish light and dark and English light, plus Swedish
|
||||
* light at 200 % font scale for the long states. [suffix] is the file-name part `sv_light`, `sv_dark`, `en_light` or
|
||||
* `sv_light_200`.
|
||||
*/
|
||||
data class Variant(val language: String, val dark: Boolean, val fontScale: Float = 1f) {
|
||||
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
|
||||
|
||||
/** The app's locale for [language]: `sv-SE` or `en-GB`. */
|
||||
val locale: Locale get() = Locale.forLanguageTag(if (language == "sv") "sv-SE" else "en-GB")
|
||||
|
||||
companion object {
|
||||
/** The three goldens of every state, then the 200 % one when [largeFont]. */
|
||||
fun goldens(largeFont: Boolean): List<Variant> =
|
||||
listOf(Variant("sv", dark = false), Variant("sv", dark = true), Variant("en", dark = false)) +
|
||||
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders [content] as the phone would in [variant]: language (configuration and resources), night mode and font
|
||||
* scale (the platform's non-linear scaling, from a configuration context) and [clock]. The theme itself is the
|
||||
* caller's, so a test can pick `KvittTheme(darkTheme = variant.dark)`.
|
||||
*/
|
||||
@Composable
|
||||
fun TestEnvironment(variant: Variant, clock: KvittClock = TestClock, content: @Composable () -> Unit) {
|
||||
val base = LocalContext.current
|
||||
val baseConfiguration = LocalConfiguration.current
|
||||
val context = remember(variant) {
|
||||
val config = Configuration(baseConfiguration).apply {
|
||||
setLocales(LocaleList(variant.locale))
|
||||
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,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user