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>
144 lines
6.3 KiB
Kotlin
144 lines
6.3 KiB
Kotlin
@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() },
|
||
)
|
||
}
|
||
}
|
||
|