Files
honeyDueKMP/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/screenshot/GallerySurfaces.kt
T
Trey T 9fa58352c0 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>
2026-04-20 18:10:32 -05:00

384 lines
13 KiB
Kotlin

@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
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
import com.tt.honeyDue.ui.screens.AllTasksScreen
import com.tt.honeyDue.ui.screens.BiometricLockScreen
import com.tt.honeyDue.ui.screens.CompleteTaskScreen
import com.tt.honeyDue.ui.screens.ContractorDetailScreen
import com.tt.honeyDue.ui.screens.ContractorsScreen
import com.tt.honeyDue.ui.screens.DocumentDetailScreen
import com.tt.honeyDue.ui.screens.DocumentsScreen
import com.tt.honeyDue.ui.screens.EditDocumentScreen
import com.tt.honeyDue.ui.screens.EditResidenceScreen
import com.tt.honeyDue.ui.screens.EditTaskScreen
import com.tt.honeyDue.ui.screens.ForgotPasswordScreen
import com.tt.honeyDue.ui.screens.HomeScreen
import com.tt.honeyDue.ui.screens.LoginScreen
import com.tt.honeyDue.ui.screens.ManageUsersScreen
import com.tt.honeyDue.ui.screens.NotificationPreferencesScreen
import com.tt.honeyDue.ui.screens.ProfileScreen
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.VerifyEmailScreen
import com.tt.honeyDue.ui.screens.VerifyResetCodeScreen
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
import com.tt.honeyDue.ui.screens.onboarding.OnboardingNameResidenceContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingSubscriptionContent
import com.tt.honeyDue.ui.screens.onboarding.OnboardingValuePropsContent
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 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 (loaders, error rows, thumbnails — they
* only appear as part of a parent screen).
*
* 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. */
val name: String,
val content: @Composable () -> Unit,
) {
/**
* ParameterizedRobolectricTestRunner uses `toString()` in the test
* display name when the `{0}` pattern is set. The default data-class
* toString includes the composable lambda hash — not useful. Override
* so test reports show `ScreenshotTests[login]` instead of
* `ScreenshotTests[GallerySurface(name=login, content=...@abc123)]`.
*/
override fun toString(): String = name
}
val gallerySurfaces: List<GallerySurface> = listOf(
// ---------- Auth ----------
GallerySurface("login") {
LoginScreen(
onLoginSuccess = {},
onNavigateToRegister = {},
onNavigateToForgotPassword = {},
)
},
GallerySurface("register") {
RegisterScreen(
onRegisterSuccess = {},
onNavigateBack = {},
)
},
GallerySurface("forgot_password") {
ForgotPasswordScreen(
onNavigateBack = {},
onNavigateToVerify = {},
onNavigateToReset = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("verify_reset_code") {
VerifyResetCodeScreen(
onNavigateBack = {},
onNavigateToReset = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("reset_password") {
ResetPasswordScreen(
onPasswordResetSuccess = {},
onNavigateBack = {},
viewModel = PasswordResetViewModel(),
)
},
GallerySurface("verify_email") {
VerifyEmailScreen(
onVerifySuccess = {},
onLogout = {},
)
},
// ---------- Onboarding ----------
GallerySurface("onboarding_welcome") {
OnboardingWelcomeContent(
onStartFresh = {},
onJoinExisting = {},
onLogin = {},
)
},
GallerySurface("onboarding_value_props") {
OnboardingValuePropsContent(onContinue = {})
},
GallerySurface("onboarding_create_account") {
OnboardingCreateAccountContent(
viewModel = OnboardingViewModel(),
onAccountCreated = {},
)
},
GallerySurface("onboarding_verify_email") {
OnboardingVerifyEmailContent(
viewModel = OnboardingViewModel(),
onVerified = {},
)
},
GallerySurface("onboarding_location") {
OnboardingLocationContent(
viewModel = OnboardingViewModel(),
onLocationDetected = {},
onSkip = {},
)
},
GallerySurface("onboarding_name_residence") {
OnboardingNameResidenceContent(
viewModel = OnboardingViewModel(),
onContinue = {},
)
},
GallerySurface("onboarding_home_profile") {
OnboardingHomeProfileContent(
viewModel = OnboardingViewModel(),
onContinue = {},
onSkip = {},
)
},
GallerySurface("onboarding_join_residence") {
OnboardingJoinResidenceContent(
viewModel = OnboardingViewModel(),
onJoined = {},
)
},
GallerySurface("onboarding_first_task") {
OnboardingFirstTaskContent(
viewModel = OnboardingViewModel(),
onTasksAdded = {},
)
},
GallerySurface("onboarding_subscription") {
OnboardingSubscriptionContent(
onSubscribe = {},
onSkip = {},
)
},
// ---------- Home (Android-only dashboard) ----------
GallerySurface("home") {
HomeScreen(
onNavigateToResidences = {},
onNavigateToTasks = {},
onLogout = {},
)
},
// ---------- Residences ----------
GallerySurface("residences") {
ResidencesScreen(
onResidenceClick = {},
onAddResidence = {},
onJoinResidence = {},
onLogout = {},
)
},
GallerySurface("residence_detail") {
ResidenceDetailScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onNavigateToEditResidence = {},
onNavigateToEditTask = {},
)
},
GallerySurface("add_residence") {
AddResidenceScreen(
onNavigateBack = {},
onResidenceCreated = {},
)
},
GallerySurface("edit_residence") {
EditResidenceScreen(
residence = Fixtures.primaryHome,
onNavigateBack = {},
onResidenceUpdated = {},
)
},
GallerySurface("join_residence") {
JoinResidenceScreen(
onNavigateBack = {},
onJoined = {},
)
},
GallerySurface("manage_users") {
ManageUsersScreen(
residenceId = Fixtures.primaryHome.id,
residenceName = Fixtures.primaryHome.name,
isPrimaryOwner = true,
residenceOwnerId = Fixtures.primaryHome.ownerId,
onNavigateBack = {},
)
},
// ---------- Tasks ----------
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(),
onNavigateBack = {},
onTaskUpdated = {},
)
},
GallerySurface("complete_task") {
val task = Fixtures.tasks.first()
CompleteTaskScreen(
taskId = task.id,
taskTitle = task.title,
residenceName = Fixtures.primaryHome.name,
onNavigateBack = {},
onComplete = { _, _ -> },
)
},
GallerySurface("task_suggestions") {
TaskSuggestionsScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onSuggestionAccepted = {},
)
},
GallerySurface("task_templates_browser") {
TaskTemplatesBrowserScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
)
},
// ---------- Contractors ----------
GallerySurface("contractors") {
ContractorsScreen(
onNavigateBack = {},
onNavigateToContractorDetail = {},
)
},
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 = id,
onNavigateBack = {},
viewModel = vm,
)
},
// ---------- Documents ----------
GallerySurface("documents") {
DocumentsScreen(
onNavigateBack = {},
residenceId = Fixtures.primaryHome.id,
)
},
GallerySurface("document_detail") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
DocumentDetailScreen(
documentId = id,
onNavigateBack = {},
onNavigateToEdit = { _ -> },
documentViewModel = vm,
)
},
GallerySurface("add_document") {
AddDocumentScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onDocumentCreated = {},
)
},
GallerySurface("edit_document") {
val id = Fixtures.documents.first().id ?: 0
val vm = remember { DocumentViewModel(initialSelectedDocumentId = id) }
EditDocumentScreen(
documentId = id,
onNavigateBack = {},
documentViewModel = vm,
)
},
// ---------- Profile / settings ----------
GallerySurface("profile") {
ProfileScreen(
onNavigateBack = {},
onLogout = {},
)
},
GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {})
},
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {})
},
// ---------- Subscription ----------
GallerySurface("feature_comparison") {
FeatureComparisonScreen(
onNavigateBack = {},
onNavigateToUpgrade = {},
)
},
)