summaryrefslogtreecommitdiff
path: root/android-app/app/src/test/kotlin/com
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/test/kotlin/com')
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/api/WeatherApiServiceTest.kt88
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/model/ForecastItemTest.kt57
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/model/WindArrowTest.kt49
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/repository/WeatherRepositoryTest.kt101
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/ui/MainViewModelTest.kt105
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()
+ }
+ }
+}