From 8bb804c6831c9807aec8cb89f336d5f4f427b8d6 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 7 Aug 2025 17:02:59 +0200 Subject: [PATCH] Add the ` LocationsUIState ` class for managing location selection logic. --- .../services/WeatherAppViewModel.kt | 123 +++++++++++ .../services/LocationsUIStateTest.kt | 208 ++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt index 44e32bb..1d2eb2d 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/services/WeatherAppViewModel.kt @@ -74,6 +74,129 @@ sealed class WeatherForecastUIState { val isLoading: Boolean get() = this is Loading } +/** + * UI state object for locations and selection + */ +class LocationsUIState private constructor( + val locations: List, + val selectedIndex: Int +) { + + private constructor(locations: List) : this(locations, if (locations.isEmpty()) -1 else 0) + + init { + if (locations.isEmpty()) { + require(selectedIndex == -1) { + "For an empty list, selected index has to be -1." + } + } else { + require(selectedIndex in locations.indices) { + "Selected index has to be in range from 0 to ${locations.lastIndex}." + } + } + } + + /** + * Get the currently selected location + */ + val selectedLocation: Location? + get() = locations.getOrNull(selectedIndex) + + /** + * Convert to UI representation with selection state + */ + fun toSelectableLocations(): List { + return locations.mapIndexed { index, location -> + SelectableLocation(location, index == selectedIndex) + } + } + + /** + * Create new state with a location added + */ + fun withLocationAdded(locationToAdd: Location): LocationsUIState { + val existingIndex = locations.indexOf(locationToAdd) + return if (existingIndex >= 0) { + // Location exists, just select it + LocationsUIState(locations = locations, selectedIndex = existingIndex) + } else { + // Add a new location and select it + val newLocations = locations + locationToAdd + LocationsUIState( + locations = newLocations, + selectedIndex = newLocations.lastIndex + ) + } + } + + /** + * Create a new state with a location removed + */ + fun withLocationDeleted(locationToRemove: Location): LocationsUIState { + val indexToDelete = locations.indexOf(locationToRemove) + if (indexToDelete < 0) return this // Location not found + + val newLocations = locations - locationToRemove + + val newSelectedIndex = when { + newLocations.isEmpty() -> -1 + indexToDelete <= selectedIndex -> (selectedIndex - 1).coerceIn(0, newLocations.lastIndex) + else -> selectedIndex // Deleted item after selected, no change + } + + return LocationsUIState( + locations = newLocations, + selectedIndex = newSelectedIndex + ) + } + + /** + * Create new state with different selection + */ + fun withItemAtIndexSelected(newIndex: Int): LocationsUIState { + if (newIndex == selectedIndex) return this + + return LocationsUIState(locations = locations, selectedIndex = newIndex) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as LocationsUIState + + if (selectedIndex != other.selectedIndex) return false + if (locations != other.locations) return false + + return true + } + + override fun hashCode(): Int { + var result = selectedIndex + result = 31 * result + locations.hashCode() + return result + } + + companion object { + /** + * Initializes a `LocationsUIState` object with the given list of locations. + * The initial selection index is set to `-1` if the list is empty or `0` if it contains locations. + * + * @param locations The list of locations to initialize the state with. + * @return The initialized `LocationsUIState` containing the provided locations and selection state. + */ + fun initial(locations: List): LocationsUIState = LocationsUIState(locations = locations) + + /** + * Creates an empty instance of `LocationsUIState` with no locations and a default selection state. + * + * @return A `LocationsUIState` initialized with an empty list of locations. + */ + fun empty(): LocationsUIState = initial(emptyList()) + } +} + + /** * A ViewModel responsible for managing the user's locations and corresponding weather data. * diff --git a/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt new file mode 100644 index 0000000..c3b5a3e --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/services/LocationsUIStateTest.kt @@ -0,0 +1,208 @@ +package org.jetbrains.plugins.template.weatherApp.services + +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.junit.Assert.* +import org.junit.Test + +internal class LocationsUIStateTest { + + @Test + fun `test initialization with empty locations`() { + val state = LocationsUIState.initial(emptyList()) + + assertTrue(state.locations.isEmpty()) + assertEquals(-1, state.selectedIndex) + assertNull(state.selectedLocation) + } + + @Test + fun `test initialization with locations and valid selection`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France") + ) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) + + assertEquals(2, state.locations.size) + assertEquals(1, state.selectedIndex) + assertEquals(locations[1], state.selectedLocation) + } + + @Test + fun `test selecting item out of range throws exception`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France") + ) + + assertThrows(IllegalArgumentException::class.java) { + LocationsUIState.initial(locations).withItemAtIndexSelected(5) + } + } + + @Test + fun `test toSelectableLocations with selection`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France"), + Location("Rome", "Italy") + ) + val state = LocationsUIState + .initial(locations) + .withItemAtIndexSelected(1) + + val selectableLocations = state.toSelectableLocations() + + assertEquals(3, selectableLocations.size) + assertFalse(selectableLocations[0].isSelected) + assertTrue(selectableLocations[1].isSelected) + assertFalse(selectableLocations[2].isSelected) + + assertEquals("Berlin, Germany", selectableLocations[0].location.label) + assertEquals("Paris, France", selectableLocations[1].location.label) + assertEquals("Rome, Italy", selectableLocations[2].location.label) + } + + @Test + fun `test withLocationAdded adds location when location doesn't exist`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France") + ) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) + + val newLocation = Location("Rome", "Italy") + val newState = state.withLocationAdded(newLocation) + + assertEquals(3, newState.locations.size) + assertEquals(2, newState.selectedIndex) // New location should be selected + assertEquals(newLocation, newState.selectedLocation) + } + + @Test + fun `test withLocationAdded selects existing location when location already exists in list`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val locations = listOf(berlin, paris) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) + + // Add Berlin again (already exists) + val newState = state.withLocationAdded(berlin) + + assertEquals(2, newState.locations.size) // No new location added + assertEquals(0, newState.selectedIndex) // Berlin is selected + assertEquals(berlin, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved removes location when location exists`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val rome = Location("Rome", "Italy") + val locations = listOf(berlin, paris, rome) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected + + // Remove Paris (the selected location) + val newState = state.withLocationDeleted(paris) + + assertEquals(2, newState.locations.size) + assertEquals(0, newState.selectedIndex) // Selection should move to Berlin + assertEquals(berlin, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved does nothing when location to remove doesn't exist in list`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val locations = listOf(berlin, paris) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected + + // Try to remove a location that doesn't exist + val newState = state.withLocationDeleted(Location("Rome", "Italy")) + + // State should remain unchanged + assertEquals(2, newState.locations.size) + assertEquals(1, newState.selectedIndex) + assertEquals(paris, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved when location to remove is currently selected location`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val locations = listOf(berlin, paris) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected + + // Remove Paris (the selected and last location) + val newState = state.withLocationDeleted(paris) + + assertEquals(1, newState.locations.size) + assertEquals(0, newState.selectedIndex) // Selection should move to Berlin + assertEquals(berlin, newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved when removing the only location`() { + val berlin = Location("Berlin", "Germany") + val locations = listOf(berlin) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) // Berlin selected + + // Remove Berlin (the only location) + val newState = state.withLocationDeleted(berlin) + + assertTrue(newState.locations.isEmpty()) + assertEquals(-1, newState.selectedIndex) // No selection + assertNull(newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved moves selection when location to remove is between first location and selected location`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val rome = Location("Rome", "Italy") + val madrid = Location("Madrid", "Spain") + val locations = listOf(berlin, paris, rome, madrid) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(2) // Rome selected (index 3) + + // Remove Paris (index 1) which is between 0 and selectedIndex (3) + val newState = state.withLocationDeleted(paris) + + assertEquals(3, newState.locations.size) + assertEquals(1, newState.selectedIndex) // Selection should be decremented by 1 + assertEquals(rome, newState.selectedLocation) // Still Rome, but at index 2 now + } + + @Test + fun `test withItemAtIndexSelected with valid index`() { + val locations = listOf( + Location("Berlin", "Germany"), + Location("Paris", "France"), + Location("Rome", "Italy") + ) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(0) // Berlin selected + + // Select Rome + val newState = state.withItemAtIndexSelected(2) + + assertEquals(3, newState.locations.size) + assertEquals(2, newState.selectedIndex) + assertEquals(locations[2], newState.selectedLocation) + } + + @Test + fun `test withLocationRemoved when removed location is after the selected location in list`() { + val berlin = Location("Berlin", "Germany") + val paris = Location("Paris", "France") + val rome = Location("Rome", "Italy") + val madrid = Location("Madrid", "Spain") + val locations = listOf(berlin, paris, rome, madrid) + val state = LocationsUIState.initial(locations).withItemAtIndexSelected(1) // Paris selected (index 1) + + // Remove Madrid (index 3) which is greater than selectedIndex (1) + val newState = state.withLocationDeleted(madrid) + + assertEquals(3, newState.locations.size) + assertEquals(1, newState.selectedIndex) // Selection should remain unchanged + assertEquals(paris, newState.selectedLocation) // Still Paris at index 1 + } +} \ No newline at end of file