test(android): Paparazzi preview screenshots with demo data; stateless screen composables

This commit is contained in:
OpenClaw Dev 2026-10-25 04:34:17 +01:00
parent 7b92e259ad
commit 1e32997504
20 changed files with 141 additions and 17 deletions

View file

@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.paparazzi) // screenshot tests only (JVM, test source set)
}
android {

View file

@ -15,6 +15,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import dev.byteclaw.client.ui.ByteClawTheme
import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.ChatViewModel
import dev.byteclaw.client.ui.SessionsScreen
@ -25,20 +26,27 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme(colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) {
ByteClawTheme(dark = isSystemInDarkTheme()) {
Surface {
val vm: ChatViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val nav = rememberNavController()
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
composable("chat") {
ChatScreen(vm, state, onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") })
ChatScreen(
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") },
)
}
composable("sessions") {
SessionsScreen(vm, state, onBack = { nav.popBackStack() }, onOpen = { nav.popBackStack() })
SessionsScreen(
state, onRefresh = { vm.refreshSessions() },
onOpen = { id -> vm.openSession(id); nav.popBackStack() },
onDelete = { id -> vm.deleteSession(id) }, onBack = { nav.popBackStack() },
)
}
composable("settings") {
SettingsScreen(vm, state, onBack = {
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
})
}

View file

@ -30,8 +30,16 @@ import dev.byteclaw.client.data.ServerSettings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSettings: () -> Unit) {
var input by remember { mutableStateOf("") }
fun ChatScreen(
state: UiState,
onSend: (String) -> Unit,
onNewChat: () -> Unit,
onDismissError: () -> Unit,
onSessions: () -> Unit,
onSettings: () -> Unit,
initialInput: String = "",
) {
var input by remember { mutableStateOf(initialInput) }
val listState = rememberLazyListState()
LaunchedEffect(state.lines.size, state.lines.lastOrNull()?.text?.length) {
if (state.lines.isNotEmpty()) listState.animateScrollToItem(state.lines.lastIndex)
@ -42,7 +50,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
title = { Text("ByteClaw") },
navigationIcon = { IconButton(onClick = onSessions) { Icon(Icons.AutoMirrored.Filled.List, "Sessions") } },
actions = {
IconButton(onClick = vm::newChat) { Icon(Icons.Filled.Add, "Neuer Chat") }
IconButton(onClick = onNewChat) { Icon(Icons.Filled.Add, "Neuer Chat") }
IconButton(onClick = onSettings) { Icon(Icons.Filled.Settings, "Einstellungen") }
},
)
@ -56,7 +64,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
}
state.error?.let {
Card(
Modifier.padding(horizontal = 12.dp, vertical = 4.dp).fillMaxWidth().clickable(onClick = vm::dismissError),
Modifier.padding(horizontal = 12.dp, vertical = 4.dp).fillMaxWidth().clickable(onClick = onDismissError),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) { Text(it, Modifier.padding(12.dp)) }
}
@ -71,7 +79,7 @@ fun ChatScreen(vm: ChatViewModel, state: UiState, onSessions: () -> Unit, onSett
enabled = state.settings.isConfigured,
)
IconButton(
onClick = { vm.send(input); input = "" },
onClick = { onSend(input); input = "" },
enabled = !state.busy && input.isNotBlank() && state.settings.isConfigured,
) { Icon(Icons.AutoMirrored.Filled.Send, "Senden") }
}
@ -105,8 +113,14 @@ private fun Bubble(line: ChatLine) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen: () -> Unit) {
LaunchedEffect(Unit) { vm.refreshSessions() }
fun SessionsScreen(
state: UiState,
onRefresh: () -> Unit,
onOpen: (String) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit,
) {
LaunchedEffect(Unit) { onRefresh() }
Scaffold(topBar = {
TopAppBar(title = { Text("Sessions") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
@ -119,8 +133,8 @@ fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen
ListItem(
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1) },
supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
trailingContent = { IconButton(onClick = { vm.deleteSession(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
modifier = Modifier.clickable { vm.openSession(s.id); onOpen() },
trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
modifier = Modifier.clickable { onOpen(s.id) },
)
HorizontalDivider()
}
@ -130,10 +144,16 @@ fun SessionsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit, onOpen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit) {
fun SettingsScreen(
state: UiState,
onSave: (ServerSettings) -> Unit,
onTest: (ServerSettings, (String) -> Unit) -> Unit,
onBack: () -> Unit,
initialStatus: String? = null,
) {
var url by remember { mutableStateOf(state.settings.serverUrl) }
var token by remember { mutableStateOf(state.settings.token) }
var status by remember { mutableStateOf<String?>(null) }
var status by remember { mutableStateOf(initialStatus) }
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
Scaffold(topBar = {
TopAppBar(title = { Text("Einstellungen") },
@ -151,10 +171,10 @@ fun SettingsScreen(vm: ChatViewModel, state: UiState, onBack: () -> Unit) {
style = MaterialTheme.typography.bodySmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
vm.saveSettings(ServerSettings(url, token)); onBack()
onSave(ServerSettings(url, token)); onBack()
}) { Text("Speichern") }
OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = {
status = "Teste…"; vm.testConnection(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it }
}) { Text("Verbindung testen") }
}
status?.let { Text(it) }

View file

@ -0,0 +1,11 @@
package dev.byteclaw.client.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
@Composable
fun ByteClawTheme(dark: Boolean, content: @Composable () -> Unit) {
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme(), content = content)
}

View file

@ -0,0 +1,77 @@
package dev.byteclaw.client.preview
import app.cash.paparazzi.DeviceConfig
import app.cash.paparazzi.Paparazzi
import com.android.resources.NightMode
import dev.byteclaw.client.data.ServerSettings
import dev.byteclaw.client.data.Session
import dev.byteclaw.client.ui.ByteClawTheme
import dev.byteclaw.client.ui.ChatLine
import dev.byteclaw.client.ui.ChatScreen
import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen
import dev.byteclaw.client.ui.UiState
import androidx.compose.runtime.Composable
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
/** Renders the main screens with DEMO data (no server, no emulator). Record: ./gradlew recordPaparazziDebug */
@RunWith(Parameterized::class)
class PreviewScreenshots(private val dark: Boolean) {
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{index}")
fun modes() = listOf(arrayOf<Any>(false), arrayOf<Any>(true))
}
@get:Rule
val paparazzi = Paparazzi(
deviceConfig = DeviceConfig.PIXEL_6.copy(nightMode = if (dark) NightMode.NIGHT else NightMode.NOTNIGHT),
maxPercentDifference = 0.1,
)
private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real")
private fun snap(name: String, content: @Composable () -> Unit) =
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) }
@Test fun chat() = snap("chat") {
ChatScreen(
state = UiState(
settings = settings, sessionId = "demo", busy = true,
lines = listOf(
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
ChatLine("tool", "{\"timezone\":\"Europe/Berlin\"}", "time_now"),
ChatLine("tool", "{\"url\":\"https://go.dev\"}", "web_fetch"),
ChatLine("assistant", "In Berlin ist es gerade 04:32 Uhr (Sonntag). Auf go.dev steht: „Build simple, secure, scalable systems with Go“ – die Seite verlinkt"),
),
),
onSend = {}, onNewChat = {}, onDismissError = {}, onSessions = {}, onSettings = {},
initialInput = "Fass das bitte kurz zusammen",
)
}
@Test fun sessions() = snap("sessions") {
SessionsScreen(
state = UiState(
settings = settings,
sessions = listOf(
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
Session("2", "[DEMO] Einkaufsliste für das Wochenende", "2026-10-24T18:05:00Z"),
Session("3", "[DEMO] Zusammenfassung README.md", "2026-10-23T09:41:00Z"),
Session("4", "[DEMO] Ideen für den Android-Client", "2026-10-21T20:15:00Z"),
),
),
onRefresh = {}, onOpen = {}, onDelete = {}, onBack = {},
)
}
@Test fun settings() = snap("settings") {
SettingsScreen(
state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {},
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB