P2: Android parity gallery — real-screen captures (partial, 17/40 surfaces)

Replaces the synthetic theme-showcase ScreenshotTests with real screens
rendered against FixtureDataManager.empty() / .populated() via
LocalDataManager. GallerySurfaces.kt manifest declares 40 screens.

Landed: 68 goldens covering 17 surfaces (login, register, password-reset
chain, 10 onboarding screens, home, residences-list).

Missing: 23 detail/edit screens that need a specific fixture model passed
via GallerySurfaces.kt — tracked as follow-up in docs/parity-gallery.md.
Non-blocking: these render silently as blank and don't fail the suite.

Android total: 2.5 MB, avg 41 KB, max 113 KB — well under the 150 KB
per-file budget enforced by the CI size gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 23:45:12 -05:00
parent 3bac38449c
commit 6cc5295db8
110 changed files with 470 additions and 453 deletions

View File

@@ -1,485 +1,143 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Task
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ExperimentalMaterial3Api
import com.github.takahirom.roborazzi.RoborazziOptions
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.tt.honeyDue.data.IDataManager
import com.tt.honeyDue.data.LocalDataManager
import com.tt.honeyDue.testing.FixtureDataManager
import com.tt.honeyDue.ui.theme.AppThemes
import com.tt.honeyDue.ui.theme.HoneyDueTheme
import com.tt.honeyDue.ui.theme.ThemeColors
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Roborazzi-driven screenshot regression tests (P8).
* Parity-gallery Roborazzi snapshot tests (P2).
*
* Runs entirely on the Robolectric unit-test classpath — no emulator
* required. The goal is to catch accidental UI drift (colour, spacing,
* typography) on PRs by diffing generated PNGs against a committed
* golden set.
* For every entry in [gallerySurfaces] we capture four variants:
* empty × light, empty × dark, populated × light, populated × dark
*
* Matrix: 6 surfaces × 3 themes (Default / Ocean / Midnight) × 2 modes
* (light / dark) = 36 images. This is a conservative baseline; the full
* 11-theme matrix would produce 132+ images and is deferred.
* Per surface that's 4 PNGs × ~40 surfaces ≈ 160 goldens. Paired with the
* iOS swift-snapshot-testing gallery (P3) that captures the same set of
* (screen, data, theme) tuples, any visual divergence between the two
* platforms surfaces here as a golden diff rather than silently shipping.
*
* Implementation notes:
* - We use the top-level `captureRoboImage(path) { composable }` form
* from roborazzi-compose. That helper registers
* `RoborazziTransparentActivity` at runtime via Robolectric's shadow
* PackageManager, so we don't need `createComposeRule()` /
* `ActivityScenarioRule<ComponentActivity>` and therefore avoid the
* "Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity"
* failure that bit the initial scaffolding (RoboMonitoringInstrumentation:102).
* - Goldens land under `composeApp/build/outputs/roborazzi/`, which the
* Roborazzi Gradle plugin picks up for record / verify / compare.
* How this differs from the showcase tests that lived here before:
* - Showcases rendered hand-crafted theme-agnostic surfaces; now we
* render the actual production composables (`LoginScreen(…)`, etc.)
* through the fixture-backed [LocalDataManager].
* - Surfaces are declared in [GallerySurfaces.kt] instead of being
* inlined, so adding a new screen is a one-line change.
* - Previously 6 surfaces × 3 themes × 2 modes; now the matrix is
* N surfaces × {empty, populated} × {light, dark} — themes beyond
* the default are intentionally out of scope (theme variation is
* covered by the dedicated theme_selection surface).
*
* Workflow:
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
* One parameterized test per surface gives granular CI failures — the
* report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
* rather than one monolithic failure when any surface drifts.
*
* Why the goldens land directly under `src/androidUnitTest/roborazzi/`:
* Roborazzi resolves `captureRoboImage(filePath = …)` relative to the
* Gradle test task's working directory (the module root). Writing to
* the same directory where goldens are committed means record and verify
* round-trip through one canonical location; we never have to copy
* between a transient `build/outputs/roborazzi/` and the committed
* fixture directory (which was the source of the pre-existing
* "original file was not found" failure).
*/
@RunWith(RobolectricTestRunner::class)
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h800dp-mdpi")
class ScreenshotTests {
class ScreenshotTests(
private val surface: GallerySurface,
) {
// ---------- Login screen showcase ----------
@Test
fun loginScreen_default_light() = runScreen("login_default_light", AppThemes.Default, darkTheme = false) {
LoginShowcase()
/**
* Compose Multiplatform's `stringResource()` loads text via a
* JVM-static context held by `AndroidContextProvider`. In a real APK
* that ContentProvider is registered in the manifest and populated at
* app start; under Robolectric unit tests it never runs, so every
* `stringResource(...)` call throws "Android context is not
* initialized."
*
* `PreviewContextConfigurationEffect()` is the documented fix — but
* it only fires inside `LocalInspectionMode = true`, and even then
* the first composition frame renders before the effect lands, so
* `stringResource()` calls race the context set.
*
* Install the context eagerly via reflection before each test.
* `AndroidContextProvider` is `internal` in Kotlin, so we can't
* touch its class directly — but its static slot is writable
* through the generated `Companion.setANDROID_CONTEXT` accessor.
* `@Before` runs inside the Robolectric sandbox (where
* `ApplicationProvider` is valid); `@BeforeClass` would run outside
* it and fail with "No instrumentation registered!".
*/
@Before
fun bootstrapComposeResources() {
val appContext = ApplicationProvider.getApplicationContext<android.content.Context>()
val providerClass = Class.forName("org.jetbrains.compose.resources.AndroidContextProvider")
val companionField = providerClass.getDeclaredField("Companion").apply { isAccessible = true }
val companion = companionField.get(null)
val setter = companion.javaClass.getMethod("setANDROID_CONTEXT", android.content.Context::class.java)
setter.invoke(companion, appContext)
}
@Test
fun loginScreen_default_dark() = runScreen("login_default_dark", AppThemes.Default, darkTheme = true) {
LoginShowcase()
}
@Test
fun loginScreen_ocean_light() = runScreen("login_ocean_light", AppThemes.Ocean, darkTheme = false) {
LoginShowcase()
}
@Test
fun loginScreen_ocean_dark() = runScreen("login_ocean_dark", AppThemes.Ocean, darkTheme = true) {
LoginShowcase()
}
@Test
fun loginScreen_midnight_light() = runScreen("login_midnight_light", AppThemes.Midnight, darkTheme = false) {
LoginShowcase()
}
@Test
fun loginScreen_midnight_dark() = runScreen("login_midnight_dark", AppThemes.Midnight, darkTheme = true) {
LoginShowcase()
}
// ---------- Tasks list showcase ----------
@Test
fun tasksScreen_default_light() = runScreen("tasks_default_light", AppThemes.Default, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_default_dark() = runScreen("tasks_default_dark", AppThemes.Default, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_light() = runScreen("tasks_ocean_light", AppThemes.Ocean, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_dark() = runScreen("tasks_ocean_dark", AppThemes.Ocean, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_light() = runScreen("tasks_midnight_light", AppThemes.Midnight, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_dark() = runScreen("tasks_midnight_dark", AppThemes.Midnight, darkTheme = true) {
TasksShowcase()
}
// ---------- Residences list showcase ----------
@Test
fun residencesScreen_default_light() = runScreen("residences_default_light", AppThemes.Default, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_default_dark() = runScreen("residences_default_dark", AppThemes.Default, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_light() = runScreen("residences_ocean_light", AppThemes.Ocean, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_dark() = runScreen("residences_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_light() = runScreen("residences_midnight_light", AppThemes.Midnight, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_dark() = runScreen("residences_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ResidencesShowcase()
}
// ---------- Profile/theme-selection / complete-task showcases ----------
@Test
fun profileScreen_default_light() = runScreen("profile_default_light", AppThemes.Default, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_default_dark() = runScreen("profile_default_dark", AppThemes.Default, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_light() = runScreen("profile_ocean_light", AppThemes.Ocean, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_dark() = runScreen("profile_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_light() = runScreen("profile_midnight_light", AppThemes.Midnight, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_dark() = runScreen("profile_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ProfileShowcase()
}
@Test
fun themeSelection_default_light() = runScreen("themes_default_light", AppThemes.Default, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_default_dark() = runScreen("themes_default_dark", AppThemes.Default, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_light() = runScreen("themes_ocean_light", AppThemes.Ocean, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_dark() = runScreen("themes_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_light() = runScreen("themes_midnight_light", AppThemes.Midnight, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_dark() = runScreen("themes_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun completeTask_default_light() = runScreen("complete_task_default_light", AppThemes.Default, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_default_dark() = runScreen("complete_task_default_dark", AppThemes.Default, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_light() = runScreen("complete_task_ocean_light", AppThemes.Ocean, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_dark() = runScreen("complete_task_ocean_dark", AppThemes.Ocean, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_light() = runScreen("complete_task_midnight_light", AppThemes.Midnight, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_dark() = runScreen("complete_task_midnight_dark", AppThemes.Midnight, darkTheme = true) {
CompleteTaskShowcase()
}
// ---------- Shared runner ----------
private fun runScreen(
name: String,
theme: ThemeColors,
darkTheme: Boolean,
content: @Composable () -> Unit,
) {
captureRoboImage(
filePath = "build/outputs/roborazzi/$name.png",
roborazziOptions = RoborazziOptions(),
) {
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
content()
fun captureAllVariants() {
Variant.all().forEach { variant ->
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
HoneyDueTheme(
themeColors = AppThemes.Default,
darkTheme = variant.darkTheme,
) {
CompositionLocalProvider(LocalDataManager provides variant.dataManager()) {
Box(Modifier.fillMaxSize()) {
surface.content()
}
}
}
}
}
}
}
// ============ Theme-agnostic showcase composables ============
//
// Each mirrors the *surface* (not the full data pipeline) of its named
// production screen. This keeps Roborazzi tests hermetic — no Ktor
// client, no DataManager, no ViewModel — while still exercising every
// colour slot in the MaterialTheme that ships with the app.
@Composable
private fun LoginShowcase() {
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"honeyDue",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Keep your home running",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(value = "testuser", onValueChange = {}, label = { Text("Username") })
OutlinedTextField(value = "•••••••••", onValueChange = {}, label = { Text("Password") })
Button(onClick = {}, modifier = Modifier.fillMaxSize(1f)) {
Text("Sign In")
}
TextButton(onClick = {}) { Text("Forgot password?") }
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun surfaces(): List<Array<Any>> =
gallerySurfaces.map { arrayOf<Any>(it) }
}
}
@Composable
private fun TasksShowcase() {
Scaffold(topBar = {
TopAppBar(
title = { Text("Tasks") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
/**
* One of the four render-variants captured per surface. The
* `dataManager` factory is invoked lazily so each capture gets its own
* pristine fixture (avoiding cross-test StateFlow mutation).
*/
private data class Variant(
val state: String,
val mode: String,
val darkTheme: Boolean,
val dataManager: () -> IDataManager,
) {
companion object {
fun all(): List<Variant> = listOf(
Variant("empty", "light", darkTheme = false) { FixtureDataManager.empty() },
Variant("empty", "dark", darkTheme = true) { FixtureDataManager.empty() },
Variant("populated", "light", darkTheme = false) { FixtureDataManager.populated() },
Variant("populated", "dark", darkTheme = true) { FixtureDataManager.populated() },
)
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
listOf("Replace HVAC filter", "Test smoke alarms", "Clean gutters").forEach { title ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Task, null, tint = MaterialTheme.colorScheme.primary)
Text(title, style = MaterialTheme.typography.bodyLarge)
}
}
}
Button(onClick = {}, colors = ButtonDefaults.buttonColors()) {
Icon(Icons.Filled.Add, null)
Text("New task", modifier = Modifier.padding(start = 8.dp))
}
}
}
}
@Composable
private fun ResidencesShowcase() {
Scaffold(topBar = {
TopAppBar(title = { Text("Residences") })
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.Filled.Home, null, tint = MaterialTheme.colorScheme.primary)
Text("Primary Home", style = MaterialTheme.typography.titleMedium)
}
Text(
"1234 Sunflower Lane",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedButton(onClick = {}) { Text("Add residence") }
}
}
}
@Composable
private fun ProfileShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Profile") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"testuser",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"claude@treymail.com",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
listOf("Notifications", "Theme", "Help").forEach { label ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Text(label, modifier = Modifier.padding(16.dp))
}
}
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) { Text("Log out") }
}
}
}
@Composable
private fun ThemePaletteShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Theme") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
"Primary" to MaterialTheme.colorScheme.primary,
"Secondary" to MaterialTheme.colorScheme.secondary,
"Tertiary" to MaterialTheme.colorScheme.tertiary,
"Surface" to MaterialTheme.colorScheme.surface,
"Error" to MaterialTheme.colorScheme.error,
).forEach { (label, color) ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Card(
colors = CardDefaults.cardColors(containerColor = color),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(24.dp)) { Text(" ", color = Color.Transparent) }
}
Text(label, color = MaterialTheme.colorScheme.onBackground)
}
}
}
}
}
@Composable
private fun CompleteTaskShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Complete Task") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Test smoke alarms", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(value = "42.50", onValueChange = {}, label = { Text("Actual cost") })
OutlinedTextField(value = "All alarms passed.", onValueChange = {}, label = { Text("Notes") })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}) { Text("Cancel") }
Button(onClick = {}) { Text("Mark complete") }
}
}
}
}