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:
Trey T
2026-04-18 17:39:39 -05:00
parent 0015a5810f
commit 40d2607da8
5 changed files with 1022 additions and 1 deletions

View File

@@ -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") }
}
}
}
}