test: screenshot tests from a real Open-Meteo response

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 13:52:42 +02:00
co-authored by Claude Opus 5.5
parent c1c76252f1
commit c446798403
3 changed files with 107 additions and 0 deletions
@@ -0,0 +1,105 @@
package app.wetter
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.FormatListBulleted
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import app.wetter.data.Forecast
import app.wetter.data.OpenMeteo
import app.wetter.data.Quarter
import app.wetter.data.Settings
import app.wetter.data.ThemeMode
import app.wetter.ui.screens.DayContent
import app.wetter.ui.screens.ForecastPage
import app.wetter.ui.screens.SettingsScreen
import app.wetter.ui.theme.WeatherTheme
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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
import java.time.LocalDateTime
/** Renders the screens on the JVM into replica/clone-screens/ for replica-diff. Data: a real Open-Meteo response. */
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "de-rDE-w412dp-h915dp-xxhdpi", application = android.app.Application::class)
class ScreenshotTest {
@get:Rule val compose = createComposeRule()
private fun fixture(): Forecast {
val f = javaClass.classLoader!!.getResource("berlin_forecast.json")!!.readText()
val a = javaClass.classLoader!!.getResource("berlin_air.json")!!.readText()
val parsed = OpenMeteo().parse(f, a, System.currentTimeMillis())
// pin "now" to the response's own current time so the shots never drift
val now = LocalDateTime.parse(Regex("\"current\":\\{\"time\":\"([^\"]+)\"").find(f)!!.groupValues[1])
return parsed.copy(clock = now)
}
private fun Forecast.with(code: Int, isDay: Boolean, rainSoon: Boolean = false): Forecast {
val minutely = if (!rainSoon) minutely else List(12) { i ->
Quarter(now.withMinute(0).plusMinutes(15L * i), if (i in 2..7) listOf(0.2, 0.5, 0.9, 1.4, 0.8, 0.3)[i - 2] else 0.0)
}
return copy(current = current.copy(code = code, isDay = isDay), minutely = minutely)
}
private fun shot(name: String, theme: ThemeMode, content: @Composable () -> Unit) {
compose.setContent { WeatherTheme(theme) { content() } }
compose.onRoot().captureRoboImage("../../replica/clone-screens/$name.png")
}
@Composable
private fun Home(f: Forecast, title: String = "Berlin", preview: Boolean = false) = ForecastPage(
title = title, isCurrent = !preview, forecast = f, settings = Settings(), animate = false,
refreshing = false, failed = false, onRefresh = {},
navigation = { IconButton(onClick = {}) { Icon(Icons.Rounded.FormatListBulleted, null) } },
actions = {
if (preview) FilledTonalButton(onClick = {}) { Text("Speichern") }
else IconButton(onClick = {}) { Icon(Icons.Rounded.Settings, null) }
},
)
@Test fun parserReadsRealResponse() {
val f = fixture()
assertEquals(10, f.daily.size)
assertTrue(f.hourly.size >= 240)
assertTrue(f.upcomingHours(25).isNotEmpty())
assertTrue(f.air?.aqi != null)
}
@Test fun s01_real_light() = shot("S01-real-light", ThemeMode.LIGHT) { Home(fixture()) }
@Test fun s01_clear_day() = shot("S01-clear-day", ThemeMode.LIGHT) { Home(fixture().with(0, true)) }
@Test fun s01_clear_night_dark() = shot("S01-clear-night-dark", ThemeMode.DARK) { Home(fixture().with(0, false)) }
@Test fun s01_rain_nowcast() = shot("S01-rain-nowcast", ThemeMode.LIGHT) { Home(fixture().with(63, true, rainSoon = true)) }
@Test fun s01_snow() = shot("S01-snow", ThemeMode.LIGHT) { Home(fixture().with(73, true)) }
@Test fun s01_storm_dark() = shot("S01-storm-dark", ThemeMode.DARK) { Home(fixture().with(95, false)) }
@Config(qualifiers = "de-rDE-w412dp-h2600dp-xxhdpi")
@Test fun s01_full_light() = shot("S01-full-light", ThemeMode.LIGHT) { Home(fixture().with(2, true)) }
@Config(qualifiers = "de-rDE-w412dp-h2600dp-xxhdpi")
@Test fun s01_full_dark() = shot("S01-full-dark", ThemeMode.DARK) { Home(fixture().with(0, false)) }
@Config(qualifiers = "de-rDE-w412dp-h1800dp-xxhdpi")
@Test fun s04_day_dark() = shot("S04-day-dark", ThemeMode.DARK) {
val f = fixture()
DayContent(f, "Berlin", f.daily[1].date, Settings(), onBack = {})
}
@Test fun s15_preview() = shot("S15-preview", ThemeMode.LIGHT) { Home(fixture().with(1, true), "Zwickau", preview = true) }
@Test fun s08_settings() = shot("S08-settings", ThemeMode.LIGHT) { SettingsScreen(Settings(), onBack = {}) }
@Config(qualifiers = "de-rDE-w840dp-h900dp-xxhdpi")
@Test fun s01_tablet() = shot("S01-tablet", ThemeMode.LIGHT) { Box { Home(fixture().with(2, true)) } }
}
@@ -0,0 +1 @@
{"latitude":52.5,"longitude":13.400002,"generationtime_ms":0.6562471389770508,"utc_offset_seconds":7200,"timezone":"Europe/Berlin","timezone_abbreviation":"GMT+2","elevation":38.0,"current_units":{"time":"iso8601","interval":"seconds","european_aqi":"EAQI","pm2_5":"μg/m³","pm10":"μg/m³","alder_pollen":"grains/m³","birch_pollen":"grains/m³","grass_pollen":"grains/m³","mugwort_pollen":"grains/m³","olive_pollen":"grains/m³","ragweed_pollen":"grains/m³"},"current":{"time":"2026-10-17T13:00","interval":3600,"european_aqi":20,"pm2_5":5.0,"pm10":5.1,"alder_pollen":0.0,"birch_pollen":0.0,"grass_pollen":0.0,"mugwort_pollen":0.0,"olive_pollen":0.0,"ragweed_pollen":0.0}}
File diff suppressed because one or more lines are too long