ref: Rename package ch.parano.myice to ch.parano.myicek

Complete the MyIceK rename by updating the Kotlin package from ch.parano.myice to ch.parano.myicek across all source sets (androidMain, commonMain, desktopMain, iosMain, commonTest), the build.gradle.kts (namespace, applicationId, mainClass, desktop packageName) and the iOS Info.plist bundle URL name. The myice:// OAuth scheme is kept unchanged as it depends on the backend.

Also add pull-to-refresh to ScheduleScreen using Material 3 PullToRefreshBox, replacing the manual loading overlay.
This commit is contained in:
2026-08-06 15:20:47 +02:00
parent a2d5a57aec
commit 418a8978f2
52 changed files with 137 additions and 142 deletions
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// MyIce Kotlin Multiplatform — schedule/convocation viewer
// Copyright (C) 2026 parano.ch
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ch.parano.myicek.cache
import ch.parano.myicek.models.Event
import com.russhwolf.settings.MapSettings
import com.russhwolf.settings.Settings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ScheduleCacheTest {
private fun createCache(): Pair<ScheduleCache, Settings> {
val settings = MapSettings()
return ScheduleCache(settings) to settings
}
private fun sampleEvent(id: String, eventType: String = "Jeu") = Event(
idEvent = id,
agegroup = "U13 (Elite)",
name = "U13 Elite - HC Ajoie",
title = "U13 (Elite)\nGame\nArena",
opponent = "HC Ajoie",
place = "Arena",
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
color = "#e4222e",
eventType = eventType,
)
@Test
fun testSaveAndLoadEvents() {
val (cache, _) = createCache()
val events = listOf(sampleEvent("1"), sampleEvent("2"))
cache.saveEvents("default", events)
val loaded = cache.loadEvents("default")
assertNotNull(loaded)
assertEquals(2, loaded.size)
assertEquals("1", loaded[0].idEvent)
assertEquals("2", loaded[1].idEvent)
}
@Test
fun testLoadEventsReturnsNullWhenEmpty() {
val (cache, _) = createCache()
assertNull(cache.loadEvents("default"))
}
@Test
fun testGetLastUpdated() {
val (cache, _) = createCache()
assertNull(cache.getLastUpdated("default"))
cache.saveEvents("default", listOf(sampleEvent("1")))
val timestamp = cache.getLastUpdated("default")
assertNotNull(timestamp)
assertTrue(timestamp > 0)
}
@Test
fun testSaveAndLoadFilters() {
val (cache, _) = createCache()
cache.saveFilters("default", agegroup = "U13 (Elite)", subgroup = "U13 Elite")
val (agegroup, subgroup) = cache.loadFilters("default")
assertEquals("U13 (Elite)", agegroup)
assertEquals("U13 Elite", subgroup)
}
@Test
fun testSaveFiltersWithNulls() {
val (cache, _) = createCache()
cache.saveFilters("default", agegroup = "U13", subgroup = "Elite")
cache.saveFilters("default", agegroup = null, subgroup = null)
val (agegroup, subgroup) = cache.loadFilters("default")
assertNull(agegroup)
assertNull(subgroup)
}
@Test
fun testClearCache() {
val (cache, _) = createCache()
cache.saveEvents("default", listOf(sampleEvent("1")))
cache.saveFilters("default", "U13", "Elite")
cache.clearCache("default")
assertNull(cache.loadEvents("default"))
val (agegroup, subgroup) = cache.loadFilters("default")
assertNull(agegroup)
assertNull(subgroup)
}
@Test
fun testClearAllCache() {
val (cache, _) = createCache()
cache.saveEvents("default", listOf(sampleEvent("1")))
cache.saveEvents("leonard", listOf(sampleEvent("2")))
cache.clearAllCache()
assertNull(cache.loadEvents("default"))
assertNull(cache.loadEvents("leonard"))
}
@Test
fun testTypeFilterPersistence() {
val (cache, _) = createCache()
cache.saveTypeFilter("default", "GAMES")
assertEquals("GAMES", cache.loadTypeFilter("default"))
}
@Test
fun testTypeFilterNullWhenNotSet() {
val (cache, _) = createCache()
assertNull(cache.loadTypeFilter("default"))
}
}
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// MyIce Kotlin Multiplatform — schedule/convocation viewer
// Copyright (C) 2026 parano.ch
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ch.parano.myicek.filter
import ch.parano.myicek.models.Event
import kotlin.test.Test
import kotlin.test.assertEquals
class EventFilterTest {
private fun event(
id: String = "1",
agegroup: String = "U13 (Elite)",
name: String = "U13 Elite - HC Ajoie",
eventType: String = "Jeu",
) = Event(
idEvent = id,
agegroup = agegroup,
name = name,
title = "Title",
opponent = "Opp",
place = "Place",
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
color = "#e4222e",
eventType = eventType,
)
@Test
fun testExtractSubgroupWithSeparator() {
assertEquals("U13 Elite", extractSubgroup("U13 Elite - HC Ajoie"))
}
@Test
fun testExtractSubgroupMultipleSeparators() {
assertEquals("U13 Elite - Group A", extractSubgroup("U13 Elite - Group A - HC Ajoie"))
}
@Test
fun testExtractSubgroupNoSeparator() {
assertEquals("U13 Elite", extractSubgroup("U13 Elite"))
}
@Test
fun testExtractSubgroupEmptyString() {
assertEquals("", extractSubgroup(""))
}
@Test
fun testFilterGamesOnly() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
event(id = "3", eventType = "Jeu"),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.GAMES)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterPracticesOnly() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
event(id = "3", eventType = ""),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.PRACTICES)
assertEquals(2, result.size)
assertEquals("2", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterAll() {
val events = listOf(
event(id = "1", eventType = "Jeu"),
event(id = "2", eventType = ""),
)
val result = filterEvents(events, agegroup = null, subgroup = null, typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
}
@Test
fun testFilterByAgegroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)"),
event(id = "2", agegroup = "U15"),
event(id = "3", agegroup = "U13 (Elite)"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testFilterBySubgroup() {
val events = listOf(
event(id = "1", name = "U13 Elite - HC Ajoie"),
event(id = "2", name = "U13 Top - HC Geneva"),
event(id = "3", name = "U13 Elite - HC Lausanne"),
)
val result = filterEvents(events, agegroup = null, subgroup = "U13 Elite", typeFilter = TypeFilter.ALL)
assertEquals(2, result.size)
assertEquals("1", result[0].idEvent)
assertEquals("3", result[1].idEvent)
}
@Test
fun testFilterCombinedAgegroupAndSubgroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)", name = "U13 Elite - HC Ajoie"),
event(id = "2", agegroup = "U13 (Elite)", name = "U13 Top - HC Geneva"),
event(id = "3", agegroup = "U15", name = "U13 Elite - HC Lausanne"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = "U13 Elite", typeFilter = TypeFilter.ALL)
assertEquals(1, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testFilterGamesAndAgegroup() {
val events = listOf(
event(id = "1", agegroup = "U13 (Elite)", eventType = "Jeu"),
event(id = "2", agegroup = "U13 (Elite)", eventType = ""),
event(id = "3", agegroup = "U15", eventType = "Jeu"),
)
val result = filterEvents(events, agegroup = "U13 (Elite)", subgroup = null, typeFilter = TypeFilter.GAMES)
assertEquals(1, result.size)
assertEquals("1", result[0].idEvent)
}
@Test
fun testDistinctAgegroups() {
val events = listOf(
event(agegroup = "U15"),
event(agegroup = "U13 (Elite)"),
event(agegroup = "U15"),
)
val result = events.distinctAgegroups()
assertEquals(2, result.size)
assertEquals("U13 (Elite)", result[0])
assertEquals("U15", result[1])
}
@Test
fun testDistinctSubgroups() {
val events = listOf(
event(name = "U13 Elite - HC Ajoie"),
event(name = "U13 Top - HC Geneva"),
event(name = "U13 Elite - HC Lausanne"),
)
val result = events.distinctSubgroups()
assertEquals(2, result.size)
assertEquals("U13 Elite", result[0])
assertEquals("U13 Top", result[1])
}
}
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// MyIce Kotlin Multiplatform — schedule/convocation viewer
// Copyright (C) 2026 parano.ch
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ch.parano.myicek.models
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ModelSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun testAccountDeserialization() {
val input = """{"name":"default","label":"Default"}"""
val account = json.decodeFromString<Account>(input)
assertEquals("default", account.name)
assertEquals("Default", account.label)
}
@Test
fun testEventDeserialization() {
val input = """
{
"id_event": 761040,
"agegroup": "U13 (Elite)",
"name": "U13 Elite - HC Ajoie",
"title": "U13 (Elite)\nGame\nRaiffeisen Arena",
"opponent": "HC Ajoie",
"place": "Raiffeisen Arena, Porrentruy",
"start": "2024-11-10T14:00:00",
"end": "2024-11-10T16:15:00",
"color": "#e4222e",
"event": "Jeu"
}
""".trimIndent()
val event = json.decodeFromString<Event>(input)
assertEquals("761040", event.idEvent)
assertEquals("U13 (Elite)", event.agegroup)
assertEquals("U13 Elite - HC Ajoie", event.name)
assertEquals("HC Ajoie", event.opponent)
assertEquals("#e4222e", event.color)
assertEquals("Jeu", event.eventType)
}
@Test
fun testEventDeserializationWithNullFields() {
val input = """
{
"id_event": "12345",
"agegroup": "U15",
"name": "",
"title": "Practice",
"opponent": "",
"place": "Les Vernets",
"start": "2024-11-10T14:00:00",
"end": "2024-11-10T15:30:00"
}
""".trimIndent()
val event = json.decodeFromString<Event>(input)
assertEquals("12345", event.idEvent)
assertEquals("", event.name)
assertEquals("", event.opponent)
assertNull(event.color)
assertEquals("", event.eventType)
}
@Test
fun testPlayerWithIntNumber() {
val input = """
{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}
""".trimIndent()
val player = json.decodeFromString<Player>(input)
assertEquals("Gardien", player.position)
assertEquals("1", player.number)
assertEquals("Jean", player.fname)
}
@Test
fun testPlayerWithStringNumber() {
val input = """
{"position":"Attaquant","number":"27","fname":"Pierre","lname":"Martin","dob":"2012-05-20"}
""".trimIndent()
val player = json.decodeFromString<Player>(input)
assertEquals("27", player.number)
}
@Test
fun testPlayerWithNullNumber() {
val input = """
{"position":null,"number":null,"fname":"Paul","lname":"Durand","dob":"2012-01-10"}
""".trimIndent()
val player = json.decodeFromString<Player>(input)
assertNull(player.position)
assertNull(player.number)
}
@Test
fun testStaffDeserialization() {
val input = """
{"role":"Coach","fname":"Jean","lname":"Dupont"}
""".trimIndent()
val staff = json.decodeFromString<Staff>(input)
assertEquals("Coach", staff.role)
assertEquals("Jean", staff.fname)
assertEquals("Dupont", staff.lname)
}
@Test
fun testConvocationDeserialization() {
val input = """
{
"available": [
{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}
],
"staff": [
{"role":"Coach","fname":"Marc","lname":"Blanc"}
]
}
""".trimIndent()
val convocation = json.decodeFromString<Convocation>(input)
assertEquals(1, convocation.available.size)
assertEquals("1", convocation.available[0].number)
assertEquals(1, convocation.staff.size)
assertEquals("Coach", convocation.staff[0].role)
}
@Test
fun testConvocationWithEmptyArrays() {
val input = """{"available":[],"staff":[]}"""
val convocation = json.decodeFromString<Convocation>(input)
assertEquals(0, convocation.available.size)
assertEquals(0, convocation.staff.size)
}
@Test
fun testEventDetailDeserialization() {
val input = """
{
"title": "U13 (Elite) - Saison HC Ajoie",
"type": "games",
"place": "Raiffeisen Arena, Porrentruy",
"time_start": "2024-11-10T14:00:00",
"time_end": "2024-11-10T16:15:00",
"is_away": "1",
"convocation": {
"available": [],
"staff": []
}
}
""".trimIndent()
val detail = json.decodeFromString<EventDetail>(input)
assertEquals("U13 (Elite) - Saison HC Ajoie", detail.title)
assertEquals("games", detail.type)
assertEquals("2024-11-10T14:00:00", detail.timeStart)
assertEquals(0, detail.convocation.available.size)
}
@Test
fun testUserInfoDeserialization() {
val input = """{"email":"rene@luria.ch","sub":"abc123"}"""
val userInfo = json.decodeFromString<UserInfo>(input)
assertEquals("rene@luria.ch", userInfo.email)
}
@Test
fun testEventRoundTripSerialization() {
val event = Event(
idEvent = "761040",
agegroup = "U13 (Elite)",
name = "U13 Elite - HC Ajoie",
title = "U13 (Elite)\nGame\nArena",
opponent = "HC Ajoie",
place = "Arena",
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
color = "#e4222e",
eventType = "Jeu",
)
val jsonString = json.encodeToString(Event.serializer(), event)
val decoded = json.decodeFromString<Event>(jsonString)
assertEquals(event, decoded)
}
}
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// MyIce Kotlin Multiplatform — schedule/convocation viewer
// Copyright (C) 2026 parano.ch
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ch.parano.myicek.network
import ch.parano.myicek.models.EventDetail
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ApiServiceTest {
private val json = Json { ignoreUnknownKeys = true }
private fun mockClient(respondJson: String, requestUrl: String, expectedAuth: String? = "Bearer test-token"): HttpClient {
val engine = MockEngine { request ->
val actualUrl = if (request.url.encodedQuery.isEmpty()) {
request.url.encodedPath
} else {
request.url.encodedPath + "?" + request.url.encodedQuery
}
assertEquals(requestUrl, actualUrl)
if (expectedAuth != null) {
assertEquals(expectedAuth, request.headers["Authorization"])
}
respond(
content = respondJson,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
return HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
}
@Test
fun testGetUserInfo() = runTest {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""{"email":"rene@luria.ch","sub":"abc"}""",
"/userinfo",
)
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("rene@luria.ch", result.email)
}
@Test
fun testGetAccounts() = runTest {
AuthTokenHolder.token = "test-token"
val client = mockClient(
"""[{"name":"default","label":"Default"},{"name":"leonard","label":"Leonard"}]""",
"/accounts",
)
val api = ApiService(client)
val result = api.getAccounts()
assertEquals(2, result.size)
assertEquals("default", result[0].name)
assertEquals("Leonard", result[1].label)
}
@Test
fun testGetSchedule() = runTest {
AuthTokenHolder.token = "test-token"
val responseJson = """
[{"id_event":"761040","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"U13 (Elite)\nGame\nArena","opponent":"HC Ajoie","place":"Arena","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}]
""".trimIndent()
val client = mockClient(responseJson, "/schedule?account=default")
val api = ApiService(client)
val result = api.getSchedule("default")
assertEquals(1, result.size)
assertEquals("761040", result[0].idEvent)
assertEquals("Jeu", result[0].eventType)
}
@Test
fun testGetGameDetail() = runTest {
AuthTokenHolder.token = "test-token"
val responseJson = """
{"title":"U13 - Game","type":"games","place":"Arena","time_start":"2024-11-10T14:00:00","time_end":"2024-11-10T16:15:00","convocation":{"available":[{"position":"Gardien","number":1,"fname":"Jean","lname":"Dupont","dob":"2012-03-15"}],"staff":[{"role":"Coach","fname":"Marc","lname":"Blanc"}]}}
""".trimIndent()
val client = mockClient(responseJson, "/game/761040?account=default")
val api = ApiService(client)
val result: EventDetail = api.getGameDetail("761040", "default")
assertEquals("U13 - Game", result.title)
assertEquals("games", result.type)
assertEquals(1, result.convocation.available.size)
assertEquals("1", result.convocation.available[0].number)
assertEquals(1, result.convocation.staff.size)
}
@Test
fun testNoAuthTokenSendsNoAuthHeader() = runTest {
AuthTokenHolder.token = null
val engine = MockEngine { request ->
assertNull(request.headers["Authorization"])
respond(
content = """{"email":""}""",
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
val api = ApiService(client)
val result = api.getUserInfo()
assertEquals("", result.email)
}
}
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// MyIce Kotlin Multiplatform — schedule/convocation viewer
// Copyright (C) 2026 parano.ch
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ch.parano.myicek.util
import kotlin.test.Test
import kotlin.test.assertEquals
class EventDateTimeFormatterTest {
@Test
fun formatsSameDayEventAsDateFollowedByTimeRange() {
val formatted = formatEventDateTime(
start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00",
)
assertEquals("Dimanche 10 novembre 2024 de 14h à 16h15", formatted)
}
@Test
fun formatsSameDayEventWithSpaceSeparator() {
val formatted = formatEventDateTime(
start = "2026-08-09 10:00:00",
end = "2026-08-09 12:00:00",
)
assertEquals("Dimanche 9 août 2026 de 10h à 12h", formatted)
}
@Test
fun formatsCrossingMidnightEventWithBothDates() {
val formatted = formatEventDateTime(
start = "2024-11-10T20:00:00",
end = "2024-11-11T01:30:00",
)
assertEquals("Dimanche 10 novembre 2024 de 20h à 1h30 (Lundi 11 novembre 2024)", formatted)
}
@Test
fun returnsRawStartWhenStartUnparseable() {
val formatted = formatEventDateTime(start = "s", end = "2024-11-10T16:15:00")
assertEquals("s - 2024-11-10T16:15:00", formatted)
}
@Test
fun returnsRawEndWhenEndUnparseable() {
val formatted = formatEventDateTime(start = "2024-11-10T14:00:00", end = "e")
assertEquals("2024-11-10T14:00:00 - e", formatted)
}
@Test
fun returnsRawWhenBothUnparseable() {
val formatted = formatEventDateTime(start = "s", end = "e")
assertEquals("s - e", formatted)
}
@Test
fun formatsEventDateWithDayOfWeek() {
val formatted = formatEventDate("2026-08-09T10:00:00")
assertEquals("Dimanche 9 août 2026", formatted)
}
}
@@ -0,0 +1,229 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// MyIce Kotlin Multiplatform — schedule/convocation viewer
// Copyright (C) 2026 parano.ch
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ch.parano.myicek.viewmodel
import ch.parano.myicek.cache.ScheduleCache
import ch.parano.myicek.filter.TypeFilter
import ch.parano.myicek.models.Account
import ch.parano.myicek.models.Event
import ch.parano.myicek.network.ApiService
import com.russhwolf.settings.MapSettings
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockEngineConfig
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class ScheduleViewModelTest {
@BeforeTest
fun setUpDispatcher() {
Dispatchers.setMain(UnconfinedTestDispatcher())
}
@AfterTest
fun tearDownDispatcher() {
Dispatchers.resetMain()
}
private val json = Json { ignoreUnknownKeys = true }
private fun mockApi(accountsJson: String, scheduleJson: String): ApiService {
val config = MockEngineConfig().apply {
addHandler { request ->
val path = request.url.encodedPath
val response = when {
path == "/accounts" -> accountsJson
path == "/schedule" -> scheduleJson
else -> "[]"
}
respond(
content = response,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
dispatcher = Dispatchers.Unconfined
}
val client = HttpClient(MockEngine(config)) {
install(ContentNegotiation) { json(json) }
}
return ApiService(client)
}
private fun sampleEvent(
id: String = "1",
agegroup: String = "U13 (Elite)",
name: String = "U13 Elite - HC Ajoie",
eventType: String = "Jeu",
) = Event(
idEvent = id, agegroup = agegroup, name = name, title = "Title",
opponent = "Opp", place = "Place", start = "2024-11-10T14:00:00",
end = "2024-11-10T16:15:00", color = "#e4222e", eventType = eventType,
)
@Test
fun testLoadAccounts() = runTest {
val api = mockApi(
"""[{"name":"default","label":"Default"}]""",
"[]",
)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
assertEquals(1, vm.state.value.accounts.size)
assertEquals("default", vm.state.value.selectedAccount)
}
@Test
fun testRefreshScheduleStoresEvents() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13 (Elite)","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"2024-11-10T14:00:00","end":"2024-11-10T16:15:00","color":"#e4222e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(1, vm.state.value.events.size)
assertEquals("1", vm.state.value.events[0].idEvent)
}
@Test
fun testFilteredEventsDefaultGamesOnly() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(2, vm.state.value.events.size)
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("1", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testSetTypeFilterAll() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 - B","title":"T","opponent":"","place":"P","start":"s","end":"e"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setTypeFilter(TypeFilter.ALL)
assertEquals(2, vm.state.value.filteredEvents.size)
}
@Test
fun testSetAgegroup() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 - A","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U15","name":"U15 - B","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setAgegroup("U15")
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("2", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testSetSubgroup() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U13","name":"U13 Elite - HC Ajoie","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"U13 Top - HC Geneva","title":"T","opponent":"O","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
vm.setSubgroup("U13 Elite")
assertEquals(1, vm.state.value.filteredEvents.size)
assertEquals("1", vm.state.value.filteredEvents[0].idEvent)
}
@Test
fun testCacheLoadOnLoadCachedSchedule() = runTest {
val api = mockApi("""[{"name":"default","label":"Default"}]""", "[]")
val cache = ScheduleCache(MapSettings())
cache.saveEvents("default", listOf(sampleEvent("1")))
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.loadCachedSchedule()
assertEquals(1, vm.state.value.events.size)
assertFalse(vm.state.value.isLoading)
}
@Test
fun testAgegroupsComputed() = runTest {
val scheduleJson = """
[{"id_event":"1","agegroup":"U15","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"},
{"id_event":"2","agegroup":"U13","name":"N","title":"T","opponent":"","place":"P","start":"s","end":"e","event":"Jeu"}]
""".trimIndent()
val api = mockApi("""[{"name":"default","label":"Default"}]""", scheduleJson)
val cache = ScheduleCache(MapSettings())
val vm = ScheduleViewModel(api, cache)
vm.loadAccounts()
vm.refreshSchedule()
assertEquals(listOf("U13", "U15"), vm.state.value.agegroups)
}
}