Add the LocationsUIState class for managing location selection logic.

This commit is contained in:
Nebojsa Vuksic 2025-08-07 17:02:59 +02:00
parent d62e8e9594
commit 8bb804c683
2 changed files with 331 additions and 0 deletions

View File

@ -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<Location>,
val selectedIndex: Int
) {
private constructor(locations: List<Location>) : 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<SelectableLocation> {
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<Location>): 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.
*

View File

@ -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
}
}