Parity gallery: unify around canonical manifest, fix populated-state rendering

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>
This commit is contained in:
Trey T
2026-04-20 18:10:32 -05:00
parent 316b1f709d
commit 9fa58352c0
298 changed files with 2496 additions and 1343 deletions

View File

@@ -8,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.core.content.FileProvider
import java.io.File
@@ -58,6 +59,15 @@ actual fun rememberImagePicker(
actual fun rememberCameraPicker(
onImageCaptured: (ImageData) -> Unit
): () -> Unit {
// Compose previews and Roborazzi snapshot tests run without a
// `FileProvider` resolvable cache path — `getUriForFile` throws
// `Failed to find configured root...` because the test cache dir
// isn't registered in the manifest's `file_paths.xml`. The launch
// callback is never invoked in a preview/snapshot anyway, so
// returning a no-op keeps the composition clean.
if (LocalInspectionMode.current) {
return {}
}
val context = LocalContext.current
// Create a temp file URI for the camera to save to

View File

@@ -0,0 +1,68 @@
package com.tt.honeyDue.screenshot
import com.tt.honeyDue.testing.GalleryScreens
import com.tt.honeyDue.testing.Platform
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Parity gate — asserts [gallerySurfaces] is exactly the set of screens
* declared in [GalleryScreens] with [Platform.ANDROID] in their platforms.
*
* If this fails, either:
* - A new screen was added to [gallerySurfaces] but missing from the
* canonical manifest — update [GalleryScreens.all].
* - A new screen was added to the manifest but not wired into
* [gallerySurfaces] — add the corresponding `GallerySurface(...)`
* entry.
* - A rename landed on only one side — reconcile.
*
* This keeps Android and iOS from silently drifting apart in coverage.
* The iOS equivalent (`GalleryManifestParityTest.swift`) enforces the
* same invariant on the Swift test file.
*/
class GalleryManifestParityTest {
@Test
fun android_surfaces_match_manifest_exactly() {
val actual = gallerySurfaces.map { it.name }.toSet()
val expected = GalleryScreens.forAndroid.keys
val missing = expected - actual
val extra = actual - expected
if (missing.isNotEmpty() || extra.isNotEmpty()) {
val message = buildString {
appendLine("Android GallerySurfaces drifted from canonical manifest.")
if (missing.isNotEmpty()) {
appendLine()
appendLine("Screens in manifest but missing from GallerySurfaces.kt:")
missing.sorted().forEach { appendLine(" - $it") }
}
if (extra.isNotEmpty()) {
appendLine()
appendLine("Screens in GallerySurfaces.kt but missing from manifest:")
extra.sorted().forEach { appendLine(" - $it") }
}
appendLine()
appendLine("Reconcile by editing com.tt.honeyDue.testing.GalleryScreens and/or")
appendLine("com.tt.honeyDue.screenshot.GallerySurfaces so both agree.")
}
kotlin.test.fail(message)
}
assertEquals(expected, actual)
}
@Test
fun no_duplicate_surface_names() {
val duplicates = gallerySurfaces.map { it.name }
.groupingBy { it }
.eachCount()
.filterValues { it > 1 }
assertTrue(
duplicates.isEmpty(),
"Duplicate surface names in GallerySurfaces.kt: $duplicates",
)
}
}

View File

@@ -2,6 +2,7 @@
package com.tt.honeyDue.screenshot
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.tt.honeyDue.testing.Fixtures
import com.tt.honeyDue.ui.screens.AddDocumentScreen
import com.tt.honeyDue.ui.screens.AddResidenceScreen
@@ -25,11 +26,10 @@ import com.tt.honeyDue.ui.screens.RegisterScreen
import com.tt.honeyDue.ui.screens.ResetPasswordScreen
import com.tt.honeyDue.ui.screens.ResidenceDetailScreen
import com.tt.honeyDue.ui.screens.ResidencesScreen
import com.tt.honeyDue.ui.screens.TasksScreen
import com.tt.honeyDue.ui.screens.VerifyEmailScreen
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
import com.tt.honeyDue.ui.screens.dev.AnimationTestingScreen
import com.tt.honeyDue.ui.screens.onboarding.OnboardingCreateAccountContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingFirstTaskContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingHomeProfileContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingJoinResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingLocationContent
@@ -40,34 +40,44 @@ import com.tt.honeyDue.ui.screens.onboarding.OnboardingVerifyEmailContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingWelcomeContent
import com.tt.honeyDue.ui.screens.residence.JoinResidenceScreen
import com.tt.honeyDue.ui.screens.subscription.FeatureComparisonScreen
import com.tt.honeyDue.ui.screens.task.AddTaskWithResidenceScreen
import com.tt.honeyDue.ui.screens.task.TaskSuggestionsScreen
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
import com.tt.honeyDue.viewmodel.ContractorViewModel
import com.tt.honeyDue.viewmodel.DocumentViewModel
import com.tt.honeyDue.viewmodel.OnboardingViewModel
import com.tt.honeyDue.viewmodel.PasswordResetViewModel
/**
* Declarative manifest of every primary screen in the app that the parity
* gallery captures. Each entry renders the production composable directly
* the screen reads its data from [com.tt.honeyDue.data.LocalDataManager],
* which the capture driver overrides with a [com.tt.honeyDue.testing.FixtureDataManager]
* (empty or populated) per variant.
* Declarative manifest of every Android gallery surface. Must stay in sync
* with the canonical [com.tt.honeyDue.testing.GalleryScreens] manifest
* [GalleryManifestParityTest] fails CI if the two drift.
*
* Scope: the screens users land on. We deliberately skip:
* - dialogs that live inside a host screen (already captured on the host),
* - animation sub-views / decorative components in AnimationTesting/,
* - widget views (Android Glance / iOS WidgetKit — separate surface),
* - shared helper composables listed under `category: shared` in
* docs/ios-parity/screens.json (loaders, error rows, thumbnails — they
* - shared helper composables (loaders, error rows, thumbnails — they
* only appear as part of a parent screen).
*
* Screens that require a construction-time ViewModel (`OnboardingViewModel`,
* `PasswordResetViewModel`) instantiate it inline here. The production code
* paths start the viewmodel's own `launch { APILayer.xxx() }` on first
* composition — those calls fail fast in the hermetic Robolectric
* environment, but the composition itself renders the surface from the
* injected [com.tt.honeyDue.data.LocalDataManager] before any network
* result arrives, which is exactly what we want to compare against iOS.
* Detail-VM pattern (contractor_detail, document_detail, edit_document):
* the VM is created with the fixture id already pre-selected, so
* `stateIn(SharingStarted.Eagerly, initialValue = dataManager.x[id])`
* emits `Success(entity)` on first composition. Without this pre-select,
* the screens' own `LaunchedEffect(id) { vm.loadX(id) }` dispatches the id
* assignment to a coroutine that runs *after* Roborazzi captures the
* frame — so both empty and populated captures would render the `Idle`
* state and be byte-identical.
*
* Screens that require a construction-time ViewModel
* ([OnboardingViewModel], [PasswordResetViewModel]) instantiate it inline
* here. The production code paths start the viewmodel's own
* `launch { APILayer.xxx() }` on first composition — those calls fail fast
* in the hermetic Robolectric environment, but the composition itself
* renders the surface from the injected
* [com.tt.honeyDue.data.LocalDataManager] before any network result
* arrives, which is exactly what we want to compare against iOS.
*/
data class GallerySurface(
/** Snake-case identifier; used as the golden file-name prefix. */
@@ -177,6 +187,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onJoined = {},
)
},
GallerySurface("onboarding_first_task") {
OnboardingFirstTaskContent(
viewModel = OnboardingViewModel(),
onTasksAdded = {},
)
},
GallerySurface("onboarding_subscription") {
OnboardingSubscriptionContent(
onSubscribe = {},
@@ -184,7 +200,7 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
// ---------- Home / main navigation ----------
// ---------- Home (Android-only dashboard) ----------
GallerySurface("home") {
HomeScreen(
onNavigateToResidences = {},
@@ -240,12 +256,16 @@ val gallerySurfaces: List<GallerySurface> = listOf(
},
// ---------- Tasks ----------
GallerySurface("tasks") {
TasksScreen(onNavigateBack = {})
},
GallerySurface("all_tasks") {
AllTasksScreen(onNavigateToEditTask = {})
},
GallerySurface("add_task_with_residence") {
AddTaskWithResidenceScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onCreated = {},
)
},
GallerySurface("edit_task") {
EditTaskScreen(
task = Fixtures.tasks.first(),
@@ -285,9 +305,20 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("contractor_detail") {
val id = Fixtures.contractors.first().id
// Pass `initialSelectedContractorId` at VM construction so the
// synchronous `stateIn` initial-value closure observes both the
// id AND the fixture-seeded `dataManager.contractorDetail[id]`,
// emitting `Success(contractor)` on first composition. Without
// this the screen's own `LaunchedEffect(id) { vm.loadContractorDetail(id) }`
// dispatches the id assignment to a coroutine that runs after
// the frame is captured, leaving both empty and populated
// captures byte-identical on the `Idle` branch.
val vm = remember { ContractorViewModel(initialSelectedContractorId = id) }
ContractorDetailScreen(
contractorId = Fixtures.contractors.first().id,
contractorId = id,
onNavigateBack = {},
viewModel = vm,
)
},
@@ -299,10 +330,13 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("document_detail") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
DocumentDetailScreen(
documentId = Fixtures.documents.first().id ?: 0,
documentId = id,
onNavigateBack = {},
onNavigateToEdit = {},
onNavigateToEdit = { _ -> },
documentViewModel = vm,
)
},
GallerySurface("add_document") {
@@ -313,9 +347,12 @@ val gallerySurfaces: List<GallerySurface> = listOf(
)
},
GallerySurface("edit_document") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
EditDocumentScreen(
documentId = Fixtures.documents.first().id ?: 0,
documentId = id,
onNavigateBack = {},
documentViewModel = vm,
)
},
@@ -326,14 +363,11 @@ val gallerySurfaces: List<GallerySurface> = listOf(
onLogout = {},
)
},
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {})
},
GallerySurface("animation_testing") {
AnimationTestingScreen(onNavigateBack = {})
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {})

View File

@@ -6,54 +6,60 @@ 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 (P2).
* Parity-gallery Roborazzi snapshot tests.
*
* For every entry in [gallerySurfaces] we capture four variants:
* empty × light, empty × dark, populated × light, populated × dark
* Variant matrix (driven by [GalleryCategory] in the canonical
* [GalleryScreens] manifest):
*
* 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.
* 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)
*
* 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).
* DataFree surfaces — capture 2 variants:
* surface_light.png (empty fixture, lookups seeded, light)
* surface_dark.png (empty fixture, lookups seeded, dark)
*
* One parameterized test per surface gives granular CI failures — the
* 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; 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).
* the same directory where goldens are committed means record and
* verify round-trip through one canonical location.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@@ -64,24 +70,15 @@ class ScreenshotTests(
/**
* 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.
* 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` 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!".
* `AndroidContextProvider` is `internal`, but its static slot is
* writable through the generated `Companion.setANDROID_CONTEXT`
* accessor.
*/
@Before
fun bootstrapComposeResources() {
@@ -95,36 +92,44 @@ class ScreenshotTests(
@Test
fun captureAllVariants() {
Variant.all().forEach { variant ->
val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
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()
// Seed the global DataManager singleton from the fixture. Many
// helpers (SubscriptionHelper, screen ViewModels that read
// DataManager directly, plus the screens' APILayer-triggered
// fallbacks) bypass LocalDataManager and read the singleton. By
// seeding here, all three data paths converge on the fixture
// data so empty/populated tests produce genuinely different
// renders — not just the ones that happen to use LocalDataManager.
val dm = com.tt.honeyDue.data.DataManager
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)
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,
) {
CompositionLocalProvider(LocalDataManager provides fixture) {
// `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()
}
@@ -136,6 +141,45 @@ class ScreenshotTests(
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}")
@@ -145,23 +189,48 @@ class ScreenshotTests(
}
/**
* 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).
* 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 state: String,
val mode: String,
val fileSuffix: 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() },
/**
* 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) },
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View File

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Some files were not shown because too many files have changed in this diff Show More