Suite6 + P8: Comprehensive task tests + Roborazzi scaffolding
Suite6_ComprehensiveTaskTests ports iOS tests not covered by Suite5/10 (priority/frequency picker variants, custom intervals, completion history, edge cases). Roborazzi screenshot-regression scaffolding in place but gated with @Ignore until pipeline is wired — first `recordRoborazziDebug` run needs manual golden-image review. See docs/screenshot-tests.md for enablement steps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,500 @@
|
||||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
package com.tt.honeyDue.screenshot
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.RoborazziRule
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.tt.honeyDue.ui.theme.AppThemes
|
||||
import com.tt.honeyDue.ui.theme.HoneyDueTheme
|
||||
import com.tt.honeyDue.ui.theme.ThemeColors
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Roborazzi-driven screenshot regression tests (P8 scaffolding).
|
||||
*
|
||||
* Runs entirely on the Robolectric unit-test classpath — no emulator
|
||||
* required. The goal is to catch accidental UI drift (colour, spacing,
|
||||
* typography) on PRs by diffing generated PNGs against a committed
|
||||
* golden set.
|
||||
*
|
||||
* Matrix: 6 surfaces × 3 themes (Default / Ocean / Midnight) × 2 modes
|
||||
* (light / dark) = 36 images. This is a conservative baseline; the full
|
||||
* 11-theme matrix would produce 132+ images and is deferred.
|
||||
*
|
||||
* Workflow:
|
||||
* - Initial record: `./gradlew :composeApp:recordRoborazziDebug`
|
||||
* - Verify in CI: `./gradlew :composeApp:verifyRoborazziDebug`
|
||||
* - View diffs: `./gradlew :composeApp:compareRoborazziDebug`
|
||||
*
|
||||
* We intentionally build *theme showcase* surfaces locally rather than
|
||||
* invoking the full production screens (LoginScreen, TasksScreen, etc.)
|
||||
* because those screens depend on DataManager/network state that can't
|
||||
* be safely initialized from a Robolectric test. The showcases render
|
||||
* the same material3 primitives the screens are composed from, so a
|
||||
* colour/typography regression in Theme.kt will still be caught.
|
||||
*/
|
||||
// TEMPORARILY DISABLED: Roborazzi runtime pipeline needs additional setup
|
||||
// before screenshot tests can run green in CI. Enable via `@Ignore` removal
|
||||
// once `recordRoborazziDebug` successfully generates the initial golden
|
||||
// image set and CI is configured to run `verifyRoborazziDebug`.
|
||||
@org.junit.Ignore("Roborazzi pipeline pending — see docs/screenshot-tests.md")
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h800dp-mdpi")
|
||||
class ScreenshotTests {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val roborazziRule = RoborazziRule(
|
||||
composeRule = composeRule,
|
||||
captureRoot = composeRule.onRoot(),
|
||||
options = RoborazziRule.Options(
|
||||
outputDirectoryPath = "build/outputs/roborazzi",
|
||||
),
|
||||
)
|
||||
|
||||
// ---------- Login screen showcase ----------
|
||||
|
||||
@Test
|
||||
fun loginScreen_default_light() = runScreen("login_default_light", AppThemes.Default, darkTheme = false) {
|
||||
LoginShowcase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginScreen_default_dark() = runScreen("login_default_dark", AppThemes.Default, darkTheme = true) {
|
||||
LoginShowcase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginScreen_ocean_light() = runScreen("login_ocean_light", AppThemes.Ocean, darkTheme = false) {
|
||||
LoginShowcase()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginScreen_ocean_dark() = runScreen("login_ocean_dark", AppThemes.Ocean, darkTheme = true) {
|
||||
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,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
HoneyDueTheme(darkTheme = darkTheme, themeColors = theme) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
composeRule.onRoot().captureRoboImage("build/outputs/roborazzi/$name.png")
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Theme-agnostic showcase composables ============
|
||||
//
|
||||
// Each mirrors the *surface* (not the full data pipeline) of its named
|
||||
// production screen. This keeps Roborazzi tests hermetic — no Ktor
|
||||
// client, no DataManager, no ViewModel — while still exercising every
|
||||
// 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() {
|
||||
Scaffold(topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Tasks") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}) { 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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user