Files
honeyDueKMP/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/ScreenshotTests.kt
Trey T 6cc5295db8 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>
2026-04-18 23:45:12 -05:00

144 lines
6.3 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
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 org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Parity-gallery Roborazzi snapshot tests (P2).
*
* For every entry in [gallerySurfaces] we capture four variants:
* empty × light, empty × dark, populated × light, populated × dark
*
* 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.
*
* 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).
*
* 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(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h800dp-mdpi")
class ScreenshotTests(
private val surface: GallerySurface,
) {
/**
* 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 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()
}
}
}
}
}
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun surfaces(): List<Array<Any>> =
gallerySurfaces.map { arrayOf<Any>(it) }
}
}
/**
* 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() },
)
}
}