@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.compose.ui.platform.LocalInspectionMode 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.testing.GalleryCategory import com.tt.honeyDue.testing.GalleryScreens 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.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode /** * Parity-gallery Roborazzi snapshot tests. * * Variant matrix (driven by [GalleryCategory] in the canonical * [GalleryScreens] manifest): * * DataCarrying surfaces — capture 4 variants: * surface_empty_light.png (empty fixture, no lookups, light) * surface_empty_dark.png (empty fixture, no lookups, dark) * surface_populated_light.png (populated fixture, light) * surface_populated_dark.png (populated fixture, dark) * * DataFree surfaces — capture 2 variants: * surface_light.png (empty fixture, lookups seeded, light) * surface_dark.png (empty fixture, lookups seeded, dark) * * The `empty` fixture for DataCarrying variants passes * `seedLookups = false` so form dropdowns render their empty state * (yielding a visible populated-vs-empty diff for forms that read * lookups from `DataManager`). The `empty` fixture for DataFree * variants passes `seedLookups = true` because those screens expect * realistic production lookups even when the user has no entities yet. * * DataFree surfaces omit the populated variant entirely — the screens * render no entity data, so `populated` would be byte-identical to * `empty` and add zero signal. * * Granular CI failures: one parameterized test per surface means 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. */ @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`. Under * Robolectric unit tests the `ContentProvider` that normally * populates it never runs, so every `stringResource(...)` call throws * "Android context is not initialized." * * Install the context eagerly via reflection before each test. * `AndroidContextProvider` is `internal`, but its static slot is * writable through the generated `Companion.setANDROID_CONTEXT` * accessor. */ @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() { val screen = GalleryScreens.forAndroid[surface.name] ?: error( "Surface '${surface.name}' is in GallerySurfaces.kt but not in " + "GalleryScreens.all (canonical manifest). " + "GalleryManifestParityTest should have caught this.", ) val variants = when (screen.category) { GalleryCategory.DataCarrying -> Variant.dataCarrying GalleryCategory.DataFree -> Variant.dataFree } variants.forEach { variant -> val fileName = "${surface.name}${variant.fileSuffix}.png" val fixture = variant.dataManager() seedSingleton(fixture) // Flush the main-thread Looper so any `stateIn(... Eagerly)` // collectors on VMs reused across captures have processed the // DataManager update before we snapshot. Without this, VMs // might see the previous variant's data because coroutine // emissions race the capture call. shadowOf(android.os.Looper.getMainLooper()).idle() captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") { HoneyDueTheme( themeColors = AppThemes.Default, darkTheme = variant.darkTheme, ) { // `LocalInspectionMode = true` signals to production // composables that they're rendering in a hermetic // preview/test environment. Camera pickers, gated push // registrations, and animation callbacks use this flag // to short-circuit calls that require real Android // subsystems (e.g. `FileProvider` paths that aren't // resolvable under Robolectric's test data dir). CompositionLocalProvider( LocalDataManager provides fixture, LocalInspectionMode provides true, ) { Box(Modifier.fillMaxSize()) { surface.content() } } } } } // Reset after suite so other tests don't inherit state. com.tt.honeyDue.data.DataManager.setSubscription(null) } /** * Mirror every StateFlow on `fixture` onto the `DataManager` singleton * so code paths that bypass `LocalDataManager` (screens that call * `DataManager.x` directly, VMs whose default-arg resolves to the * singleton, `SubscriptionHelper` free-tier gate) see the same data. * * Critical: clear the singleton first so the previous variant's * writes don't leak into this variant's `empty` render. */ private fun seedSingleton(fixture: IDataManager) { val dm = com.tt.honeyDue.data.DataManager dm.clear() dm.setSubscription(fixture.subscription.value) dm.setCurrentUser(fixture.currentUser.value) fixture.myResidences.value?.let { dm.setMyResidences(it) } dm.setResidences(fixture.residences.value) fixture.totalSummary.value?.let { dm.setTotalSummary(it) } fixture.allTasks.value?.let { dm.setAllTasks(it) } dm.setDocuments(fixture.documents.value) dm.setContractors(fixture.contractors.value) dm.setFeatureBenefits(fixture.featureBenefits.value) dm.setUpgradeTriggers(fixture.upgradeTriggers.value) dm.setTaskCategories(fixture.taskCategories.value) dm.setTaskPriorities(fixture.taskPriorities.value) dm.setTaskFrequencies(fixture.taskFrequencies.value) fixture.contractorsByResidence.value.forEach { (rid, list) -> dm.setContractorsForResidence(rid, list) } fixture.contractorDetail.value.values.forEach { dm.setContractorDetail(it) } fixture.documentDetail.value.values.forEach { dm.setDocumentDetail(it) } fixture.taskCompletions.value.forEach { (taskId, completions) -> dm.setTaskCompletions(taskId, completions) } fixture.tasksByResidence.value.forEach { (rid, cols) -> dm.setTasksForResidence(rid, cols) } fixture.notificationPreferences.value?.let { dm.setNotificationPreferences(it) } } companion object { @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") fun surfaces(): List> = gallerySurfaces.map { arrayOf(it) } } } /** * One render-variant captured per surface. The `dataManager` factory is * invoked lazily so each capture gets a pristine fixture (avoiding * cross-test StateFlow mutation). * * @property fileSuffix Appended to the surface name to form the PNG * filename. Includes a leading `_`. Examples: `_empty_light`, * `_populated_dark`, `_light`, `_dark`. */ private data class Variant( val fileSuffix: String, val darkTheme: Boolean, val dataManager: () -> IDataManager, ) { companion object { /** * DataCarrying surfaces: 4 variants. `empty` captures pass * `seedLookups = false` so form dropdowns render empty in the * empty-variant PNGs — letting screens that read lookups produce * a visible diff against the populated variant. */ val dataCarrying: List = listOf( Variant("_empty_light", darkTheme = false) { FixtureDataManager.empty(seedLookups = false) }, Variant("_empty_dark", darkTheme = true) { FixtureDataManager.empty(seedLookups = false) }, Variant("_populated_light", darkTheme = false) { FixtureDataManager.populated() }, Variant("_populated_dark", darkTheme = true) { FixtureDataManager.populated() }, ) /** * DataFree surfaces: 2 variants (light/dark only). Lookups are * seeded because forms expect them to be present in production * (a user with zero entities still sees the priority picker). * The populated variant is deliberately omitted — DataFree * surfaces render no entity data, so `populated` would be * byte-identical to `empty`. */ val dataFree: List = listOf( Variant("_light", darkTheme = false) { FixtureDataManager.empty(seedLookups = true) }, Variant("_dark", darkTheme = true) { FixtureDataManager.empty(seedLookups = true) }, ) } }