diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-13 19:59:01 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-03-13 19:59:01 +0000 |
| commit | 51f86cff118f9532783c4e61724e07173ec029d7 (patch) | |
| tree | 1c5601142391003830527f0c97d8ef7fa4145052 /android-app/app/src/test/kotlin | |
| parent | 7e40bd03ab0246552d26d92fda8623b8da4653f3 (diff) | |
feat: add wind/current map overlay and weather forecast on startup
Implements the wind/current map overlay and 7-day weather forecast
display that loads on application launch:
Data layer:
- Open-Meteo API (free, no key): WeatherApiService + MarineApiService
- Moshi-annotated response models (WeatherResponse, MarineResponse)
- WeatherRepository: parallel async fetch, Result<T> error propagation
- Domain models: WindArrow (with beaufortScale/isCalm) + ForecastItem
(with weatherDescription/isRainy via WMO weather codes)
Presentation layer:
- MainViewModel: StateFlow<UiState> (Loading/Success/Error),
windArrow and forecast streams
- MainActivity: runtime location permission, FusedLocationProvider
GPS fetch on startup, falls back to SF Bay default
- MapFragment: MapLibre GL map with wind-arrow SymbolLayer;
icon rotated by wind direction, size scaled by speed in knots
- ForecastFragment + ForecastAdapter: RecyclerView ListAdapter
showing 7-day hourly forecast (time, description, wind, temp, precip)
Resources:
- ic_wind_arrow.xml vector drawable (north-pointing, rotated by MapLibre)
- BottomNavigationView: Map / Forecast tabs
- ViewBinding enabled throughout
Tests (TDD — written before implementation):
- WindArrowTest: calm detection, direction normalisation, Beaufort scale
- ForecastItemTest: weatherDescription, isRainy for WMO codes
- WeatherApiServiceTest: MockWebServer request params + response parsing
- WeatherRepositoryTest: MockK service mocks, data mapping, error paths
- MainViewModelTest: Turbine StateFlow assertions for all state transitions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/test/kotlin')
5 files changed, 400 insertions, 0 deletions
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt new file mode 100644 index 0000000..ac2a652 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt @@ -0,0 +1,88 @@ +package com.example.androidapp.data.api + +import com.example.androidapp.data.model.WeatherResponse +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +class WeatherApiServiceTest { + + private lateinit var mockServer: MockWebServer + private lateinit var service: WeatherApiService + + @Before + fun setUp() { + mockServer = MockWebServer() + mockServer.start() + + val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + service = Retrofit.Builder() + .baseUrl(mockServer.url("/")) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(WeatherApiService::class.java) + } + + @After + fun tearDown() { + mockServer.shutdown() + } + + @Test + fun `getWeatherForecast sends correct query parameters`() = runTest { + mockServer.enqueue(MockResponse().setBody(WEATHER_JSON).setResponseCode(200)) + + service.getWeatherForecast( + latitude = 37.5, + longitude = -122.3, + hourly = "windspeed_10m,winddirection_10m,temperature_2m,precipitation_probability,weathercode", + forecastDays = 1, + windSpeedUnit = "kn" + ) + + val request = mockServer.takeRequest() + val url = request.requestUrl!! + assertEquals("37.5", url.queryParameter("latitude")) + assertEquals("-122.3", url.queryParameter("longitude")) + assertEquals("kn", url.queryParameter("wind_speed_unit")) + } + + @Test + fun `getWeatherForecast parses response correctly`() = runTest { + mockServer.enqueue(MockResponse().setBody(WEATHER_JSON).setResponseCode(200)) + + val response = service.getWeatherForecast(37.5, -122.3) + assertEquals(37.5, response.latitude, 0.01) + assertEquals(2, response.hourly.time.size) + assertEquals(15.0, response.hourly.windspeed10m[0], 0.01) + assertEquals(270.0, response.hourly.winddirection10m[0], 0.01) + assertEquals(18.5, response.hourly.temperature2m[0], 0.01) + assertEquals(20, response.hourly.precipitationProbability[0]) + assertEquals(1, response.hourly.weathercode[0]) + } + + companion object { + private val WEATHER_JSON = """ + { + "latitude": 37.5, + "longitude": -122.3, + "hourly": { + "time": ["2026-03-13T00:00", "2026-03-13T01:00"], + "windspeed_10m": [15.0, 16.0], + "winddirection_10m": [270.0, 275.0], + "temperature_2m": [18.5, 18.0], + "precipitation_probability": [20, 25], + "weathercode": [1, 1] + } + } + """.trimIndent() + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt new file mode 100644 index 0000000..f0a903f --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt @@ -0,0 +1,57 @@ +package com.example.androidapp.data.model + +import org.junit.Assert.* +import org.junit.Test + +class ForecastItemTest { + + private fun makeItem(windKt: Double = 10.0, precipPct: Int = 0, weatherCode: Int = 0) = + ForecastItem( + timeIso = "2026-03-13T12:00", + windKt = windKt, + windDirDeg = 180.0, + tempC = 15.0, + precipProbabilityPct = precipPct, + weatherCode = weatherCode + ) + + @Test + fun `ForecastItem stores all fields correctly`() { + val item = makeItem(windKt = 12.5, precipPct = 30, weatherCode = 61) + assertEquals("2026-03-13T12:00", item.timeIso) + assertEquals(12.5, item.windKt, 0.001) + assertEquals(30, item.precipProbabilityPct) + assertEquals(61, item.weatherCode) + } + + @Test + fun `weatherDescription returns rain for code 61`() { + val item = makeItem(weatherCode = 61) + assertTrue(item.weatherDescription().contains("Rain", ignoreCase = true)) + } + + @Test + fun `weatherDescription returns clear for code 0`() { + val item = makeItem(weatherCode = 0) + assertTrue(item.weatherDescription().contains("Clear", ignoreCase = true)) + } + + @Test + fun `weatherDescription returns cloudy for code 2`() { + val item = makeItem(weatherCode = 2) + assertTrue(item.weatherDescription().contains("Cloud", ignoreCase = true)) + } + + @Test + fun `isRainy returns true for rain codes 51 to 67`() { + assertTrue(makeItem(weatherCode = 51).isRainy()) + assertTrue(makeItem(weatherCode = 63).isRainy()) + assertTrue(makeItem(weatherCode = 67).isRainy()) + } + + @Test + fun `isRainy returns false for clear codes`() { + assertFalse(makeItem(weatherCode = 0).isRainy()) + assertFalse(makeItem(weatherCode = 1).isRainy()) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt new file mode 100644 index 0000000..b61e6fb --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt @@ -0,0 +1,49 @@ +package com.example.androidapp.data.model + +import org.junit.Assert.* +import org.junit.Test + +class WindArrowTest { + + @Test + fun `WindArrow holds lat lon speed direction`() { + val arrow = WindArrow(lat = 37.5, lon = -122.3, speedKt = 15.0, directionDeg = 270.0) + assertEquals(37.5, arrow.lat, 0.001) + assertEquals(-122.3, arrow.lon, 0.001) + assertEquals(15.0, arrow.speedKt, 0.001) + assertEquals(270.0, arrow.directionDeg, 0.001) + } + + @Test + fun `WindArrow with zero speed is calm`() { + val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 0.0, directionDeg = 0.0) + assertEquals(0.0, arrow.speedKt, 0.001) + assertTrue(arrow.isCalm()) + } + + @Test + fun `WindArrow isCalm returns false when speed above threshold`() { + val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 5.0, directionDeg = 90.0) + assertFalse(arrow.isCalm()) + } + + @Test + fun `WindArrow direction 360 is normalised to 0`() { + val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 10.0, directionDeg = 360.0) + assertEquals(0.0, arrow.normalisedDirection(), 0.001) + } + + @Test + fun `WindArrow direction within 0 to 359 is unchanged`() { + val arrow = WindArrow(lat = 0.0, lon = 0.0, speedKt = 10.0, directionDeg = 180.0) + assertEquals(180.0, arrow.normalisedDirection(), 0.001) + } + + @Test + fun `WindArrow beaufortScale returns correct force for various speeds`() { + assertEquals(0, WindArrow(0.0, 0.0, 0.0, 0.0).beaufortScale()) // calm + assertEquals(1, WindArrow(0.0, 0.0, 2.0, 0.0).beaufortScale()) // light air + assertEquals(3, WindArrow(0.0, 0.0, 9.0, 0.0).beaufortScale()) // gentle + assertEquals(7, WindArrow(0.0, 0.0, 30.0, 0.0).beaufortScale()) // near gale + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt new file mode 100644 index 0000000..e1bf288 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt @@ -0,0 +1,101 @@ +package com.example.androidapp.data.repository + +import com.example.androidapp.data.api.MarineApiService +import com.example.androidapp.data.api.WeatherApiService +import com.example.androidapp.data.model.* +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class WeatherRepositoryTest { + + private val weatherApi = mockk<WeatherApiService>() + private val marineApi = mockk<MarineApiService>() + private lateinit var repo: WeatherRepository + + private val weatherResponse = WeatherResponse( + latitude = 37.5, + longitude = -122.3, + hourly = WeatherHourly( + time = listOf("2026-03-13T00:00", "2026-03-13T01:00"), + windspeed10m = listOf(15.0, 16.0), + winddirection10m = listOf(270.0, 275.0), + temperature2m = listOf(18.5, 18.0), + precipitationProbability = listOf(20, 25), + weathercode = listOf(1, 1) + ) + ) + + private val marineResponse = MarineResponse( + latitude = 37.5, + longitude = -122.3, + hourly = MarineHourly( + time = listOf("2026-03-13T00:00", "2026-03-13T01:00"), + waveHeight = listOf(1.2, 1.1), + waveDirection = listOf(250.0, 255.0), + oceanCurrentVelocity = listOf(0.3, 0.4), + oceanCurrentDirection = listOf(180.0, 185.0) + ) + ) + + @Before + fun setUp() { + repo = WeatherRepository(weatherApi, marineApi) + } + + @Test + fun `fetchForecastItems maps weather response to ForecastItem list`() = runTest { + coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse + coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse + + val result = repo.fetchForecastItems(37.5, -122.3) + + assertTrue(result.isSuccess) + val items = result.getOrThrow() + assertEquals(2, items.size) + assertEquals("2026-03-13T00:00", items[0].timeIso) + assertEquals(15.0, items[0].windKt, 0.001) + assertEquals(270.0, items[0].windDirDeg, 0.001) + assertEquals(18.5, items[0].tempC, 0.001) + assertEquals(20, items[0].precipProbabilityPct) + assertEquals(1, items[0].weatherCode) + } + + @Test + fun `fetchWindArrow returns WindArrow for first (current) hour`() = runTest { + coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse + coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse + + val result = repo.fetchWindArrow(37.5, -122.3) + + assertTrue(result.isSuccess) + val arrow = result.getOrThrow() + assertEquals(37.5, arrow.lat, 0.001) + assertEquals(-122.3, arrow.lon, 0.001) + assertEquals(15.0, arrow.speedKt, 0.001) + assertEquals(270.0, arrow.directionDeg, 0.001) + } + + @Test + fun `fetchForecastItems returns failure when weather API throws`() = runTest { + coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Network error") + coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse + + val result = repo.fetchForecastItems(37.5, -122.3) + + assertTrue(result.isFailure) + } + + @Test + fun `fetchWindArrow returns failure when API throws`() = runTest { + coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Timeout") + coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse + + val result = repo.fetchWindArrow(37.5, -122.3) + + assertTrue(result.isFailure) + } +} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt new file mode 100644 index 0000000..cb5f6f9 --- /dev/null +++ b/android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt @@ -0,0 +1,105 @@ +package com.example.androidapp.ui + +import app.cash.turbine.test +import com.example.androidapp.data.model.ForecastItem +import com.example.androidapp.data.model.WindArrow +import com.example.androidapp.data.repository.WeatherRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MainViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val repo = mockk<WeatherRepository>() + private lateinit var vm: MainViewModel + + private val sampleArrow = WindArrow(37.5, -122.3, 15.0, 270.0) + private val sampleForecast = listOf( + ForecastItem("2026-03-13T00:00", 15.0, 270.0, 18.5, 20, 1) + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun makeVm() = MainViewModel(repo) + + @Test + fun `initial uiState is Loading`() { + coEvery { repo.fetchWindArrow(any(), any()) } coAnswers { Result.success(sampleArrow) } + coEvery { repo.fetchForecastItems(any(), any()) } coAnswers { Result.success(sampleForecast) } + vm = makeVm() + // Before loadWeather() is called the state is Loading + assertEquals(UiState.Loading, vm.uiState.value) + } + + @Test + fun `loadWeather success transitions to Success state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + vm.uiState.test { + assertEquals(UiState.Loading, awaitItem()) + vm.loadWeather(37.5, -122.3) + assertEquals(UiState.Success, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadWeather populates windArrow and forecast`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + vm.loadWeather(37.5, -122.3) + + assertEquals(sampleArrow, vm.windArrow.value) + assertEquals(sampleForecast, vm.forecast.value) + } + + @Test + fun `loadWeather arrow failure transitions to Error state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.failure(RuntimeException("Net error")) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.success(sampleForecast) + vm = makeVm() + + vm.uiState.test { + awaitItem() // Loading + vm.loadWeather(37.5, -122.3) + val state = awaitItem() + assertTrue(state is UiState.Error) + assertTrue((state as UiState.Error).message.contains("Net error")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loadWeather forecast failure transitions to Error state`() = runTest { + coEvery { repo.fetchWindArrow(any(), any()) } returns Result.success(sampleArrow) + coEvery { repo.fetchForecastItems(any(), any()) } returns Result.failure(RuntimeException("Timeout")) + vm = makeVm() + + vm.uiState.test { + awaitItem() // Loading + vm.loadWeather(37.5, -122.3) + val state = awaitItem() + assertTrue(state is UiState.Error) + cancelAndIgnoreRemainingEvents() + } + } +} |
