P2: Android parity gallery — real-screen captures (partial, 17/40 surfaces)

Replaces the synthetic theme-showcase ScreenshotTests with real screens
rendered against FixtureDataManager.empty() / .populated() via
LocalDataManager. GallerySurfaces.kt manifest declares 40 screens.

Landed: 68 goldens covering 17 surfaces (login, register, password-reset
chain, 10 onboarding screens, home, residences-list).

Missing: 23 detail/edit screens that need a specific fixture model passed
via GallerySurfaces.kt — tracked as follow-up in docs/parity-gallery.md.
Non-blocking: these render silently as blank and don't fail the suite.

Android total: 2.5 MB, avg 41 KB, max 113 KB — well under the 150 KB
per-file budget enforced by the CI size gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 23:45:12 -05:00
parent 3bac38449c
commit 6cc5295db8
110 changed files with 470 additions and 453 deletions

View File

@@ -223,11 +223,21 @@ compose.desktop {
} }
} }
// Roborazzi screenshot-regression plugin (P8). Pin the golden-image // Roborazzi screenshot-regression plugin (parity gallery, P2). Pin the
// output directory inside the test source set so goldens live in git // golden-image output directory inside the test source set so goldens live
// alongside the tests themselves. Anything under build/ is gitignored // in git alongside the tests themselves. Anything under build/ is
// and gets blown away by `gradle clean` — not where committed goldens // gitignored and gets blown away by `gradle clean` — not where committed
// belong. // goldens belong.
//
// NOTE on path mismatch: `captureRoboImage(filePath = ...)` in
// ScreenshotTests.kt takes a *relative path* resolved against the Gradle
// test task's working directory (`composeApp/`). We intentionally point
// that same path at `src/androidUnitTest/roborazzi/...` — and configure
// the plugin extension below to match — so record and verify read from
// and write to the exact same committed-golden location. Any other
// arrangement results in the "original file was not found" error because
// the plugin doesn't currently auto-copy between `build/outputs/roborazzi`
// and the extension outputDir for the KMM Android target.
roborazzi { roborazzi {
outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi")) outputDir.set(layout.projectDirectory.dir("src/androidUnitTest/roborazzi"))
} }

View File

@@ -0,0 +1,349 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot
import androidx.compose.runtime.Composable
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.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.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.TaskSuggestionsScreen
import com.tt.honeyDue.ui.screens.task.TaskTemplatesBrowserScreen
import com.tt.honeyDue.ui.screens.theme.ThemeSelectionScreen
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.
*
* 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
* 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.
*/
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_subscription") {
OnboardingSubscriptionContent(
onSubscribe = {},
onSkip = {},
)
},
// ---------- Home / main navigation ----------
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("tasks") {
TasksScreen(onNavigateBack = {})
},
GallerySurface("all_tasks") {
AllTasksScreen(onNavigateToEditTask = {})
},
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") {
ContractorDetailScreen(
contractorId = Fixtures.contractors.first().id,
onNavigateBack = {},
)
},
// ---------- Documents ----------
GallerySurface("documents") {
DocumentsScreen(
onNavigateBack = {},
residenceId = Fixtures.primaryHome.id,
)
},
GallerySurface("document_detail") {
DocumentDetailScreen(
documentId = Fixtures.documents.first().id ?: 0,
onNavigateBack = {},
onNavigateToEdit = {},
)
},
GallerySurface("add_document") {
AddDocumentScreen(
residenceId = Fixtures.primaryHome.id,
onNavigateBack = {},
onDocumentCreated = {},
)
},
GallerySurface("edit_document") {
EditDocumentScreen(
documentId = Fixtures.documents.first().id ?: 0,
onNavigateBack = {},
)
},
// ---------- Profile / settings ----------
GallerySurface("profile") {
ProfileScreen(
onNavigateBack = {},
onLogout = {},
)
},
GallerySurface("theme_selection") {
ThemeSelectionScreen(onNavigateBack = {})
},
GallerySurface("notification_preferences") {
NotificationPreferencesScreen(onNavigateBack = {})
},
GallerySurface("animation_testing") {
AnimationTestingScreen(onNavigateBack = {})
},
GallerySurface("biometric_lock") {
BiometricLockScreen(onUnlocked = {})
},
// ---------- Subscription ----------
GallerySurface("feature_comparison") {
FeatureComparisonScreen(
onNavigateBack = {},
onNavigateToUpgrade = {},
)
},
)

View File

@@ -1,485 +1,143 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.tt.honeyDue.screenshot package com.tt.honeyDue.screenshot
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Task
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.test.core.app.ApplicationProvider
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ExperimentalMaterial3Api
import com.github.takahirom.roborazzi.RoborazziOptions
import com.github.takahirom.roborazzi.captureRoboImage 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.ui.theme.AppThemes import com.tt.honeyDue.ui.theme.AppThemes
import com.tt.honeyDue.ui.theme.HoneyDueTheme import com.tt.honeyDue.ui.theme.HoneyDueTheme
import com.tt.honeyDue.ui.theme.ThemeColors import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
/** /**
* Roborazzi-driven screenshot regression tests (P8). * Parity-gallery Roborazzi snapshot tests (P2).
* *
* Runs entirely on the Robolectric unit-test classpath — no emulator * For every entry in [gallerySurfaces] we capture four variants:
* required. The goal is to catch accidental UI drift (colour, spacing, * empty × light, empty × dark, populated × light, populated × dark
* typography) on PRs by diffing generated PNGs against a committed
* golden set.
* *
* Matrix: 6 surfaces × 3 themes (Default / Ocean / Midnight) × 2 modes * Per surface that's 4 PNGs × ~40 surfaces ≈ 160 goldens. Paired with the
* (light / dark) = 36 images. This is a conservative baseline; the full * iOS swift-snapshot-testing gallery (P3) that captures the same set of
* 11-theme matrix would produce 132+ images and is deferred. * (screen, data, theme) tuples, any visual divergence between the two
* platforms surfaces here as a golden diff rather than silently shipping.
* *
* Implementation notes: * How this differs from the showcase tests that lived here before:
* - We use the top-level `captureRoboImage(path) { composable }` form * - Showcases rendered hand-crafted theme-agnostic surfaces; now we
* from roborazzi-compose. That helper registers * render the actual production composables (`LoginScreen(…)`, etc.)
* `RoborazziTransparentActivity` at runtime via Robolectric's shadow * through the fixture-backed [LocalDataManager].
* PackageManager, so we don't need `createComposeRule()` / * - Surfaces are declared in [GallerySurfaces.kt] instead of being
* `ActivityScenarioRule<ComponentActivity>` and therefore avoid the * inlined, so adding a new screen is a one-line change.
* "Unable to resolve activity for Intent MAIN/LAUNCHER cmp=.../ComponentActivity" * - Previously 6 surfaces × 3 themes × 2 modes; now the matrix is
* failure that bit the initial scaffolding (RoboMonitoringInstrumentation:102). * N surfaces × {empty, populated} × {light, dark} — themes beyond
* - Goldens land under `composeApp/build/outputs/roborazzi/`, which the * the default are intentionally out of scope (theme variation is
* Roborazzi Gradle plugin picks up for record / verify / compare. * covered by the dedicated theme_selection surface).
* *
* Workflow: * One parameterized test per surface gives granular CI failures — the
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug` * report shows `ScreenshotTests[login]`, `ScreenshotTests[tasks]`, etc.
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug` * rather than one monolithic failure when any surface drifts.
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug` *
* 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).
*/ */
@RunWith(RobolectricTestRunner::class) @RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h800dp-mdpi") @Config(qualifiers = "w360dp-h800dp-mdpi")
class ScreenshotTests { class ScreenshotTests(
private val surface: GallerySurface,
) {
// ---------- Login screen showcase ---------- /**
* Compose Multiplatform's `stringResource()` loads text via a
@Test * JVM-static context held by `AndroidContextProvider`. In a real APK
fun loginScreen_default_light() = runScreen("login_default_light", AppThemes.Default, darkTheme = false) { * that ContentProvider is registered in the manifest and populated at
LoginShowcase() * 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.
*
* 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!".
*/
@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 @Test
fun loginScreen_default_dark() = runScreen("login_default_dark", AppThemes.Default, darkTheme = true) { fun captureAllVariants() {
LoginShowcase() Variant.all().forEach { variant ->
} val fileName = "${surface.name}_${variant.state}_${variant.mode}.png"
captureRoboImage(filePath = "src/androidUnitTest/roborazzi/$fileName") {
@Test HoneyDueTheme(
fun loginScreen_ocean_light() = runScreen("login_ocean_light", AppThemes.Ocean, darkTheme = false) { themeColors = AppThemes.Default,
LoginShowcase() darkTheme = variant.darkTheme,
} ) {
CompositionLocalProvider(LocalDataManager provides variant.dataManager()) {
@Test Box(Modifier.fillMaxSize()) {
fun loginScreen_ocean_dark() = runScreen("login_ocean_dark", AppThemes.Ocean, darkTheme = true) { surface.content()
LoginShowcase() }
} }
}
@Test
fun loginScreen_midnight_light() = runScreen("login_midnight_light", AppThemes.Midnight, darkTheme = false) {
LoginShowcase()
}
@Test
fun loginScreen_midnight_dark() = runScreen("login_midnight_dark", AppThemes.Midnight, darkTheme = true) {
LoginShowcase()
}
// ---------- Tasks list showcase ----------
@Test
fun tasksScreen_default_light() = runScreen("tasks_default_light", AppThemes.Default, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_default_dark() = runScreen("tasks_default_dark", AppThemes.Default, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_light() = runScreen("tasks_ocean_light", AppThemes.Ocean, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_ocean_dark() = runScreen("tasks_ocean_dark", AppThemes.Ocean, darkTheme = true) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_light() = runScreen("tasks_midnight_light", AppThemes.Midnight, darkTheme = false) {
TasksShowcase()
}
@Test
fun tasksScreen_midnight_dark() = runScreen("tasks_midnight_dark", AppThemes.Midnight, darkTheme = true) {
TasksShowcase()
}
// ---------- Residences list showcase ----------
@Test
fun residencesScreen_default_light() = runScreen("residences_default_light", AppThemes.Default, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_default_dark() = runScreen("residences_default_dark", AppThemes.Default, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_light() = runScreen("residences_ocean_light", AppThemes.Ocean, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_ocean_dark() = runScreen("residences_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_light() = runScreen("residences_midnight_light", AppThemes.Midnight, darkTheme = false) {
ResidencesShowcase()
}
@Test
fun residencesScreen_midnight_dark() = runScreen("residences_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ResidencesShowcase()
}
// ---------- Profile/theme-selection / complete-task showcases ----------
@Test
fun profileScreen_default_light() = runScreen("profile_default_light", AppThemes.Default, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_default_dark() = runScreen("profile_default_dark", AppThemes.Default, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_light() = runScreen("profile_ocean_light", AppThemes.Ocean, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_ocean_dark() = runScreen("profile_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_light() = runScreen("profile_midnight_light", AppThemes.Midnight, darkTheme = false) {
ProfileShowcase()
}
@Test
fun profileScreen_midnight_dark() = runScreen("profile_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ProfileShowcase()
}
@Test
fun themeSelection_default_light() = runScreen("themes_default_light", AppThemes.Default, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_default_dark() = runScreen("themes_default_dark", AppThemes.Default, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_light() = runScreen("themes_ocean_light", AppThemes.Ocean, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_ocean_dark() = runScreen("themes_ocean_dark", AppThemes.Ocean, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_light() = runScreen("themes_midnight_light", AppThemes.Midnight, darkTheme = false) {
ThemePaletteShowcase()
}
@Test
fun themeSelection_midnight_dark() = runScreen("themes_midnight_dark", AppThemes.Midnight, darkTheme = true) {
ThemePaletteShowcase()
}
@Test
fun completeTask_default_light() = runScreen("complete_task_default_light", AppThemes.Default, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_default_dark() = runScreen("complete_task_default_dark", AppThemes.Default, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_light() = runScreen("complete_task_ocean_light", AppThemes.Ocean, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_ocean_dark() = runScreen("complete_task_ocean_dark", AppThemes.Ocean, darkTheme = true) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_light() = runScreen("complete_task_midnight_light", AppThemes.Midnight, darkTheme = false) {
CompleteTaskShowcase()
}
@Test
fun completeTask_midnight_dark() = runScreen("complete_task_midnight_dark", AppThemes.Midnight, darkTheme = true) {
CompleteTaskShowcase()
}
// ---------- Shared runner ----------
private fun runScreen(
name: String,
theme: ThemeColors,
darkTheme: Boolean,
content: @Composable () -> Unit,
) {
captureRoboImage(
filePath = "build/outputs/roborazzi/$name.png",
roborazziOptions = RoborazziOptions(),
) {
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
content()
} }
} }
} }
}
// ============ Theme-agnostic showcase composables ============ companion object {
// @JvmStatic
// Each mirrors the *surface* (not the full data pipeline) of its named @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
// production screen. This keeps Roborazzi tests hermetic — no Ktor fun surfaces(): List<Array<Any>> =
// client, no DataManager, no ViewModel — while still exercising every gallerySurfaces.map { arrayOf<Any>(it) }
// colour slot in the MaterialTheme that ships with the app.
@Composable
private fun LoginShowcase() {
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
"honeyDue",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Keep your home running",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(value = "testuser", onValueChange = {}, label = { Text("Username") })
OutlinedTextField(value = "•••••••••", onValueChange = {}, label = { Text("Password") })
Button(onClick = {}, modifier = Modifier.fillMaxSize(1f)) {
Text("Sign In")
}
TextButton(onClick = {}) { Text("Forgot password?") }
}
} }
} }
@Composable /**
private fun TasksShowcase() { * One of the four render-variants captured per surface. The
Scaffold(topBar = { * `dataManager` factory is invoked lazily so each capture gets its own
TopAppBar( * pristine fixture (avoiding cross-test StateFlow mutation).
title = { Text("Tasks") }, */
colors = TopAppBarDefaults.topAppBarColors( private data class Variant(
containerColor = MaterialTheme.colorScheme.surface, val state: String,
), val mode: 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() },
) )
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
listOf("Replace HVAC filter", "Test smoke alarms", "Clean gutters").forEach { title ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Task, null, tint = MaterialTheme.colorScheme.primary)
Text(title, style = MaterialTheme.typography.bodyLarge)
}
}
}
Button(onClick = {}, colors = ButtonDefaults.buttonColors()) {
Icon(Icons.Filled.Add, null)
Text("New task", modifier = Modifier.padding(start = 8.dp))
}
}
} }
} }
@Composable
private fun ResidencesShowcase() {
Scaffold(topBar = {
TopAppBar(title = { Text("Residences") })
}) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(12.dp),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(Icons.Filled.Home, null, tint = MaterialTheme.colorScheme.primary)
Text("Primary Home", style = MaterialTheme.typography.titleMedium)
}
Text(
"1234 Sunflower Lane",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedButton(onClick = {}) { Text("Add residence") }
}
}
}
@Composable
private fun ProfileShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Profile") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
"testuser",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"claude@treymail.com",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
listOf("Notifications", "Theme", "Help").forEach { label ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Text(label, modifier = Modifier.padding(16.dp))
}
}
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) { Text("Log out") }
}
}
}
@Composable
private fun ThemePaletteShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Theme") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(
"Primary" to MaterialTheme.colorScheme.primary,
"Secondary" to MaterialTheme.colorScheme.secondary,
"Tertiary" to MaterialTheme.colorScheme.tertiary,
"Surface" to MaterialTheme.colorScheme.surface,
"Error" to MaterialTheme.colorScheme.error,
).forEach { (label, color) ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Card(
colors = CardDefaults.cardColors(containerColor = color),
shape = RoundedCornerShape(8.dp),
) {
Column(Modifier.padding(24.dp)) { Text(" ", color = Color.Transparent) }
}
Text(label, color = MaterialTheme.colorScheme.onBackground)
}
}
}
}
}
@Composable
private fun CompleteTaskShowcase() {
Scaffold(topBar = { TopAppBar(title = { Text("Complete Task") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Test smoke alarms", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(value = "42.50", onValueChange = {}, label = { Text("Actual cost") })
OutlinedTextField(value = "All alarms passed.", onValueChange = {}, label = { Text("Notes") })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}) { Text("Cancel") }
Button(onClick = {}) { Text("Mark complete") }
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

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