@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() 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> = gallerySurfaces.map { arrayOf(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 = 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() }, ) } }