9fa58352c0
Single source of truth: `com.tt.honeyDue.testing.GalleryScreens` lists every user-reachable screen with its category (DataCarrying / DataFree) and per-platform reachability. Both platforms' test harnesses are CI-gated against it — `GalleryManifestParityTest` on each side fails if the surface list drifts from the manifest. Variant matrix by category: DataCarrying captures 4 PNGs (empty/populated × light/dark), DataFree captures 2 (light/dark only). Empty variants for DataCarrying use `FixtureDataManager.empty(seedLookups = false)` so form screens that only read DM lookups can diff against populated. Detail-screen rendering fixed on both platforms. Root cause: VM `stateIn(Eagerly, initialValue = …)` closures evaluated `_selectedX.value` before screen-side `LaunchedEffect` / `.onAppear` could set the id, leaving populated captures byte-identical to empty. Kotlin: `ContractorViewModel` + `DocumentViewModel` accept `initialSelectedX: Int? = null` so the id is set in the primary constructor before `stateIn` computes its seed. Swift: `ContractorViewModel`, `DocumentViewModelWrapper`, `ResidenceViewModel`, `OnboardingTasksViewModel` gained pre-seed init params. `ContractorDetailView`, `DocumentDetailView`, `ResidenceDetailView`, `OnboardingFirstTaskContent` gained test/preview init overloads that accept the pre-seeded VM. Corresponding view bodies prefer cached success state over loading/error — avoids a spinner flashing over already-visible content during background refreshes (production benefit too). Real production bug fixed along the way: `DataManager.clear()` was missing `_contractorDetail`, `_documentDetail`, `_contractorsByResidence`, `_taskCompletions`, `_notificationPreferences`. On logout these maps leaked across user sessions; in the gallery they leaked the previous surface's populated state into the next surface's empty capture. `ImagePicker.android.kt` guards `rememberCameraPicker` with `LocalInspectionMode` — `FileProvider.getUriForFile` can't resolve the Robolectric test-cache path, so `add_document` / `edit_document` previously failed the entire capture. Honest reclassifications: `complete_task`, `manage_users`, and `task_suggestions` moved to DataFree. Their first-paint visible state is driven by static props or APILayer calls, not by anything on `IDataManager` — populated would be byte-identical to empty without a significant production rewire. The manifest comments call this out. Manifest counts after all moves: 43 screens = 12 DataCarrying + 31 DataFree, 37 on both platforms + 3 Android-only (home, documents, biometric_lock) + 3 iOS-only (documents_warranties, add_task, profile_edit). Test results after full record: Android: 11/11 DataCarrying diff populated vs empty iOS: 12/12 DataCarrying diff populated vs empty Also in this change: - `scripts/build_parity_gallery.py` parses the Kotlin manifest directly, renders rows in product-flow order, shows explicit `[missing — <platform>]` placeholders for expected-but-absent captures and muted `not on <platform>` placeholders for platform-specific screens. Docs regenerated. - `scripts/cleanup_orphan_goldens.sh` safely removes PNGs from prior test configurations (theme-named, compare artifacts, legacy empty/populated pairs for what is now DataFree). Dry-run by default. - `docs/parity-gallery.md` rewritten: canonical-manifest workflow, adding-a-screen guide, variant matrix explained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
237 lines
11 KiB
Kotlin
237 lines
11 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.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<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() {
|
|
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<Array<Any>> =
|
|
gallerySurfaces.map { arrayOf<Any>(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<Variant> = 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<Variant> = listOf(
|
|
Variant("_light", darkTheme = false) { FixtureDataManager.empty(seedLookups = true) },
|
|
Variant("_dark", darkTheme = true) { FixtureDataManager.empty(seedLookups = true) },
|
|
)
|
|
}
|
|
}
|