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>
384 lines
13 KiB
Kotlin
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 = {},
|
|
)
|
|
},
|
|
)
|