Testing, Remove ObjectBox

This commit is contained in:
Dimitris
2026-02-28 13:10:48 +01:00
parent eb6d3e4ef7
commit a468529ca4
35 changed files with 1148 additions and 431 deletions

View File

@@ -1,14 +1,9 @@
import org.gradle.kotlin.dsl.annotationProcessor
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.2.21"
kotlin("kapt")
}
alias(libs.plugins.kotlin.kapt)
}
android {
namespace = "com.kouros.data"
@@ -37,9 +32,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
testOptions {
unitTests {
isReturnDefaultValues = true
}
}
}
@@ -60,20 +55,20 @@ dependencies {
implementation(libs.android.sdk.turf)
implementation(libs.androidx.compose.runtime)
// objectbox
implementation(libs.objectbox.kotlin)
implementation(libs.androidx.material3)
annotationProcessor(libs.objectbox.processor)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.maplibre.compose)
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation(libs.androidx.compose.material.icons.extended)
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
apply(plugin = "io.objectbox")
}

View File

@@ -2,9 +2,9 @@ package com.kouros.navigation.data
import androidx.compose.ui.graphics.Color
val NavigationColor = Color(0xFF0730B2)
val NavigationColor = Color(0xFF16BBB6)
val RouteColor = Color(0xFF5582D0)
val RouteColor = Color(0xFF7B06E1)
val SpeedColor = Color(0xFF262525)

View File

@@ -16,13 +16,9 @@
package com.kouros.navigation.data
import android.location.Location
import android.location.LocationManager
import android.net.Uri
import com.kouros.navigation.data.route.Lane
import com.kouros.navigation.utils.location
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import kotlinx.serialization.Serializable
data class Category(
@@ -30,9 +26,13 @@ data class Category(
val name: String,
)
@Entity
data class Places(
val places: List<Place>,
)
@Serializable
data class Place(
@Id
var id: Long = 0,
var name: String? = null,
var category: String? = null,
@@ -42,8 +42,7 @@ data class Place(
var city: String? = null,
var street: String? = null,
var distance: Float = 0F,
@Transient
var avatar: Uri? = null,
//var avatar: Uri? = null,
var lastDate: Long = 0
)

View File

@@ -53,6 +53,8 @@ abstract class NavigationRepository {
carOrientation: Float,
context: Context
): Double {
if (currentLocation.latitude == 0.0)
return 0.0
val osrm = OsrmRepository()
val route = osrm.getRoute(context, currentLocation, location, carOrientation, SearchFilter())
val gson = GsonBuilder().serializeNulls().create()

View File

@@ -18,4 +18,6 @@ data class NavigationState (
val currentRouteIndex: Int = 0,
val destination: Place = Place(),
val carConnection: Int = 0,
var routingEngine: Int = 0,
)

View File

@@ -1,22 +0,0 @@
package com.kouros.navigation.data
import android.content.Context
import com.kouros.navigation.data.MyObjectBox
import io.objectbox.BoxStore
/**
* Singleton to keep BoxStore reference.
*/
object ObjectBox {
lateinit var boxStore: BoxStore
private set
fun init(context: Context) {
try {
boxStore = MyObjectBox.builder().androidContext(context.applicationContext).build()
} catch (e: Exception) {
println(e.message)
}
}
}

View File

@@ -42,6 +42,11 @@ class DataStoreManager(private val context: Context) {
val LAST_ROUTE = stringPreferencesKey("LastRoute")
val TOMTOM_APIKEY = stringPreferencesKey("TomTomApiKey")
val RECENT_PLACES = stringPreferencesKey("RecentPlaces")
val FAVORITES = stringPreferencesKey("Favorites")
}
// Read values
@@ -89,6 +94,18 @@ class DataStoreManager(private val context: Context) {
?: ""
}
val recentPlacesFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.RECENT_PLACES]
?: ""
}
val favoritesFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.FAVORITES]
?: ""
}
// Save values
suspend fun setShow3D(enabled: Boolean) {
context.dataStore.edit { preferences ->
@@ -137,4 +154,16 @@ class DataStoreManager(private val context: Context) {
prefs[PreferencesKeys.TOMTOM_APIKEY] = apiKey
}
}
suspend fun setRecentPlaces(apiKey: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.RECENT_PLACES] = apiKey
}
}
suspend fun setFavorites(apiKey: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.FAVORITES] = apiKey
}
}
}

View File

@@ -33,6 +33,13 @@ class TomTomRepository : NavigationRepository() {
val routeJsonString = routeJson.bufferedReader().use { it.readText() }
return routeJsonString
}
var filter = ""
if (searchFilter.avoidMotorway) {
filter = "$filter&avoid=motorways"
}
if (searchFilter.avoidTollway) {
filter = "$filter&avoid=tollRoads"
}
val repository = getSettingsRepository(context)
val tomtomApiKey = runBlocking { repository.tomTomApiKeyFlow.first() }
val url =
@@ -42,7 +49,7 @@ class TomTomRepository : NavigationRepository() {
"&vehicleMaxSpeed=120&vehicleCommercial=false" +
"&instructionsType=text&language=en-GB&sectionType=lanes" +
"&routeRepresentation=encodedPolyline" +
"&vehicleEngineType=combustion&key=$tomtomApiKey"
"&vehicleEngineType=combustion$filter&key=$tomtomApiKey"
return fetchUrl(
url,
false

View File

@@ -1,30 +1,27 @@
package com.kouros.navigation.model
//import com.kouros.navigation.data.Preferences.boxStore
import android.content.Context
import android.location.Location
import androidx.car.app.CarContext
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.GsonBuilder
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.ObjectBox.boxStore
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.Place_
import com.kouros.navigation.data.Places
import com.kouros.navigation.data.SearchFilter
import com.kouros.navigation.data.nominatim.Search
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.overpass.Overpass
import com.kouros.navigation.utils.Levenshtein
import com.kouros.navigation.utils.NavigationUtils
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -111,20 +108,18 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
/**
* Loads the most recent place from ObjectBox and calculates its distance.
* Loads the most recent place from Preferences and calculates its distance.
* Posts the result to recentPlace LiveData if distance > 1km.
*/
fun loadRecentPlace(location: Location, carOrientation: Float, context: Context) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual(""))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val settingsRepository = getSettingsRepository(context)
val recentPlaces = settingsRepository.recentPlacesFlow.first()
val gson = GsonBuilder().serializeNulls().create()
val places = gson.fromJson(recentPlaces, Places::class.java)
val place = places.places.minByOrNull { it.lastDate.dec() }
if (place != null) {
val plLocation = location(place.longitude, place.latitude)
val distance = repository.getRouteDistance(
location,
@@ -145,33 +140,39 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
/**
* Loads all recent places from ObjectBox and calculates distances.
* Loads all recent places from Preferences and calculates distances.
* Posts the sorted list to places LiveData.
*/
fun loadRecentPlaces(context: Context, location: Location, carOrientation: Float) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.RECENT)))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val gson = GsonBuilder().serializeNulls().create()
val recentPlaces = gson.fromJson(rp, Places::class.java)
val pl = mutableListOf<Place>()
var id : Long = 0
if (rp.isNotEmpty()) {
for (place in recentPlaces.places) {
if (place.category.equals(Constants.RECENT)) {
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
place.id = id
id += 1
}
pl.add(place)
}
}
}
places.postValue(results)
places.postValue(pl)
} catch (e: Exception) {
e.printStackTrace()
}
@@ -179,32 +180,36 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
/**
* Loads favorite places from ObjectBox and calculates distances.
* Loads favorite places from Preferences and calculates distances.
* Posts the sorted list to favorites LiveData.
*/
fun loadFavorites(context: Context, location: Location, carOrientation: Float) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.FAVORITES)))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val plLocation = location(place.longitude, place.latitude)
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val gson = GsonBuilder().serializeNulls().create()
val recentPlaces = gson.fromJson(rp, Places::class.java)
val pl = mutableListOf<Place>()
if (rp.isNotEmpty()) {
for (place in recentPlaces.places) {
if (place.category.equals(Constants.FAVORITES)) {
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
}
pl.add(place)
}
}
}
favorites.postValue(results)
favorites.postValue(pl)
} catch (e: Exception) {
e.printStackTrace()
}
@@ -335,7 +340,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
Constants.CONTACTS,
street = addressLines[0],
city = addressLines[1],
avatar = address.avatar,
//avatar = address.avatar,
longitude = 0.0,
latitude = 0.0,
distance = 0F,
@@ -417,7 +422,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
val distAmenities = mutableListOf<Elements>()
amenities.forEach {
val plLocation =
location(longitude = it.lon!!, latitude = it.lat!!)
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
distAmenities.add(it)
@@ -470,18 +475,18 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
/**
* Saves a place as a favorite in ObjectBox.
* Saves a place as a favorite in Preferences.
*/
fun saveFavorite(place: Place) {
fun saveFavorite(context: Context, place: Place) {
place.category = Constants.FAVORITES
savePlace(place)
savePlace(context, place)
}
/**
* Saves a place to recent destinations in ObjectBox.
* Saves a place to recent destinations in Preferences.
* Skips fuel stations, charging stations, and pharmacies.
*/
fun saveRecent(place: Place) {
fun saveRecent(context: Context, place: Place) {
if (place.category == Constants.FUEL_STATION
|| place.category == Constants.CHARGING_STATION
|| place.category == Constants.PHARMACY
@@ -489,30 +494,36 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
return
}
place.category = Constants.RECENT
savePlace(place)
savePlace(context, place)
}
/**
* Saves a place to ObjectBox, removing existing duplicates first.
* Saves a place to Preferences, removing existing duplicates first.
* Updates the timestamp to current time.
*/
private fun savePlace(place: Place) {
private fun savePlace(context: Context, place: Place) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(
Place_.name.equal(place.name!!).and(Place_.category.equal(place.category!!))
)
.build()
val results = query.find()
query.close()
if (results.isNotEmpty()) {
placeBox.remove(results.first())
val places = mutableListOf<Place>()
val gson = GsonBuilder().serializeNulls().create()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
var id : Long = 0
if (rp.isNotEmpty()) {
val recentPlaces =
gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (curPlace in recentPlaces) {
if (curPlace.name != place.name || curPlace.category != place.category) {
curPlace.id = id
places.add(curPlace)
id += 1
}
}
}
val current = LocalDateTime.now(ZoneOffset.UTC)
place.lastDate = current.atZone(ZoneOffset.UTC).toEpochSecond()
placeBox.put(place)
places.add(place)
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
} catch (e: Exception) {
e.printStackTrace()
}
@@ -520,37 +531,40 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
/**
* Deletes a place from favorites in ObjectBox.
* Deletes a place from favorites in Preferences.
*/
fun deleteFavorite(place: Place) {
fun deleteFavorite(context: Context, place: Place) {
place.category = Constants.FAVORITES
deletePlace(place)
deletePlace(context, place)
}
/**
* Deletes a place from recent destinations in ObjectBox.
* Deletes a place from recent destinations in Preferences.
*/
fun deleteRecent(place: Place) {
fun deleteRecent(context: Context, place: Place) {
place.category = Constants.RECENT
deletePlace(place)
deletePlace(context, place)
}
/**
* Deletes a place from ObjectBox matching name and category.
* Deletes a place from Preferences matching name and category.
*/
fun deletePlace(place: Place) {
fun deletePlace(context: Context, place: Place) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(
Place_.name.equal(place.name!!).and(Place_.category.equal(place.category!!))
)
.build()
val results = query.find()
query.close()
if (results.isNotEmpty()) {
placeBox.remove(results.first())
val gson = GsonBuilder().serializeNulls().create()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val places = mutableListOf<Place>()
if (rp.isNotEmpty()) {
val recentPlaces =
gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (curPlace in recentPlaces) {
if (curPlace.name != place.name || curPlace.category != place.category) {
places.add(curPlace)
}
}
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
}
} catch (e: Exception) {
e.printStackTrace()
@@ -569,59 +583,23 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
return SearchFilter(avoidMotorway, avoidTollway)
}
/**
* Loads recent places with calculated distances for Compose state.
* @return SnapshotStateList of recent places with distances
*/
fun loadPlaces2(
context: Context,
location: Location,
carOrientation: Float
): SnapshotStateList<Place?> {
val results = listOf<Place>()
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.RECENT)))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val plLocation = location(place.longitude, place.latitude)
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
}
} catch (e: Exception) {
e.printStackTrace()
}
return results.toMutableStateList()
}
/**
* Loads recent places as Compose SnapshotStateList.
* @return SnapshotStateList of recent places
*/
fun loadRecentPlace(): SnapshotStateList<Place?> {
val results = listOf<Place>()
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.RECENT)))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
return results.toMutableStateList()
} catch (e: Exception) {
e.printStackTrace()
fun loadRecentPlace(context: Context): SnapshotStateList<Place?> {
val pl = mutableListOf<Place>()
val settingsRepository = getSettingsRepository(context)
val rp = runBlocking { settingsRepository.recentPlacesFlow.first()}
if (rp.isNotEmpty()) {
val gson = GsonBuilder().serializeNulls().create()
val recentPlaces = gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (place in recentPlaces) {
if (place.category == Constants.RECENT) {
pl.add(place)
}
}
}
return results.toMutableStateList()
return pl.toMutableStateList()
}
}

View File

@@ -1,25 +1,17 @@
package com.kouros.navigation.model
import android.content.Context
import android.location.Location
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_NATIVE
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_PROJECTION
import androidx.car.app.navigation.model.Maneuver
import com.kouros.navigation.data.Constants.NEXT_STEP_THRESHOLD
import com.kouros.navigation.data.NavigationState
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.StepData
import com.kouros.navigation.data.datastore.DataStoreManager
import com.kouros.navigation.data.route.Lane
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.repository.SettingsRepository
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlin.math.absoluteValue
open class RouteModel {
@@ -37,40 +29,40 @@ open class RouteModel {
val curLeg: Leg
get() = navState.route.routes[navState.currentRouteIndex].legs.first()
fun startNavigation(routeString: String, context: Context) {
val repository = getSettingsRepository(context)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
fun startNavigation(routeString: String) {
navState = navState.copy(
route = Route.Builder()
.routeEngine(routingEngine)
.routeEngine(navState.routingEngine)
.route(routeString)
.build()
)
if (hasLegs()) {
navState = navState.copy(navigating = true)
getSettingsViewModel(context).onLastRouteChanged(routeString)
}
}
private fun hasLegs(): Boolean {
fun hasLegs(): Boolean {
return navState.route.routes.isNotEmpty() && navState.route.routes[0].legs.isNotEmpty()
}
fun stopNavigation(context: Context) {
fun stopNavigation() {
navState = navState.copy(
route = Route.Builder().buildEmpty(),
navigating = false,
arrived = false,
maneuverType = Maneuver.TYPE_UNKNOWN
)
getSettingsViewModel(context).onLastRouteChanged("")
}
fun updateLocation(context: Context, curLocation: Location, viewModel: NavigationViewModel) {
fun updateLocation(curLocation: Location, viewModel: NavigationViewModel) {
if (curLocation.hasBearing()) {
navState = navState.copy(routeBearing = curLocation.bearing)
}
navState = navState.copy(currentLocation = curLocation)
routeCalculator.findStep(curLocation)
if (navState.carConnection == CONNECTION_TYPE_PROJECTION
|| navState.carConnection == CONNECTION_TYPE_NATIVE) {
|| navState.carConnection == CONNECTION_TYPE_NATIVE
) {
routeCalculator.updateSpeedLimit(curLocation, viewModel)
}
navState = navState.copy(lastLocation = navState.currentLocation)
@@ -80,15 +72,11 @@ open class RouteModel {
val distanceToNextStep = routeCalculator.leftStepDistance()
// Determine the maneuver type and corresponding icon
val currentStep = navState.route.nextStep(0)
val streetName = if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
currentStep.street
} else {
currentStep.maneuver.street
}
val curManeuverType = if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
Maneuver.TYPE_STRAIGHT
} else {
currentStep.maneuver.type
var streetName = currentStep.maneuver.street
var curManeuverType = currentStep.maneuver.type
if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
streetName = currentStep.street
curManeuverType = Maneuver.TYPE_STRAIGHT
}
val exitNumber = currentStep.maneuver.exit
val maneuverIcon = navState.iconMapper.maneuverIcon(curManeuverType)
@@ -108,13 +96,14 @@ open class RouteModel {
fun nextStep(): StepData {
val distanceToNextStep = routeCalculator.leftStepDistance()
val step = navState.route.nextStep(1)
val streetName = if (distanceToNextStep < NEXT_STEP_THRESHOLD) {
step.maneuver.street
} else {
step.street
val currentStep = navState.route.nextStep(0)
val nextStep = navState.route.nextStep(1)
var streetName = nextStep.street
var maneuverType = currentStep.maneuver.type
if (distanceToNextStep < NEXT_STEP_THRESHOLD) {
streetName = nextStep.maneuver.street
maneuverType = nextStep.maneuver.type
}
val maneuverType = step.maneuver.type
val maneuverIcon = navState.iconMapper.maneuverIcon(maneuverType)
// Construct and return the final StepData object
@@ -125,7 +114,7 @@ open class RouteModel {
icon = maneuverIcon,
arrivalTime = routeCalculator.arrivalTime(),
leftDistance = routeCalculator.travelLeftDistance(),
exitNumber = step.maneuver.exit
exitNumber = nextStep.maneuver.exit
)
}
@@ -138,7 +127,9 @@ open class RouteModel {
navState.lastLocation.distanceTo(location(it.location[0], it.location[1]))
val sectionBearing =
navState.lastLocation.bearingTo(location(it.location[0], it.location[1]))
if (distance < NEXT_STEP_THRESHOLD && (navState.routeBearing.absoluteValue - sectionBearing.absoluteValue).absoluteValue < 10) {
val bearingDeviation =
(navState.routeBearing.absoluteValue - sectionBearing.absoluteValue).absoluteValue
if (distance < NEXT_STEP_THRESHOLD && bearingDeviation < 10) {
lanes = it.lane
}
}

View File

@@ -29,6 +29,14 @@ class SettingsRepository(
val tomTomApiKeyFlow: Flow<String> =
dataStoreManager.tomTomApiKeyFlow
val recentPlacesFlow: Flow<String> =
dataStoreManager.recentPlacesFlow
val favoritesFlow: Flow<String> =
dataStoreManager.favoritesFlow
suspend fun setShow3D(enabled: Boolean) {
dataStoreManager.setShow3D(enabled)
}
@@ -61,4 +69,11 @@ class SettingsRepository(
dataStoreManager.setTomtomApiKey(apiKey)
}
suspend fun setRecentPlaces(places: String) {
dataStoreManager.setRecentPlaces(places)
}
suspend fun setFavorites(favorites: String) {
dataStoreManager.setFavorites(favorites)
}
}

View File

@@ -36,7 +36,6 @@ object GeoUtils {
fun decodePolyline(encoded: String, precision: Int = 6): List<List<Double>> {
val factor = 10.0.pow(precision)
var lat = 0
var lng = 0
val coordinates = mutableListOf<List<Double>>()

View File

@@ -0,0 +1,247 @@
package com.kouros.navigation.model
import android.location.Location
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Maneuver
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.data.route.Summary
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
class RouteCalculatorTest {
private lateinit var routeModel: RouteModel
private lateinit var routeCalculator: RouteCalculator
@Before
fun setup() {
routeModel = RouteModel()
routeCalculator = RouteCalculator(routeModel)
}
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
/**
* Creates a Step with [numWaypoints] evenly spaced waypoints.
* The Maneuver location is mocked to avoid Android stub issues.
*/
private fun createStep(
index: Int,
numWaypoints: Int = 2,
duration: Double = 60.0,
distance: Double = 200.0,
waypointIndex: Int = 0,
): Step {
val waypoints = (0 until numWaypoints).map { i ->
listOf(11.0 + index * 0.01 + i * 0.001, 48.0)
}
return Step(
index = index,
waypointIndex = waypointIndex,
maneuver = Maneuver(waypoints = waypoints, location = mock()),
duration = duration,
distance = distance,
)
}
private fun setupRoute(steps: List<Step>, currentStepIndex: Int = 0): Route {
val leg = Leg(steps = steps)
val routes = Routes(
legs = listOf(leg),
summary = Summary(),
routeGeoJson = "",
waypoints = emptyList(),
)
return Route(routeEngine = 1, routes = listOf(routes), currentStepIndex = currentStepIndex)
}
// ----------------------------------------------------------
// findStep
// ----------------------------------------------------------
@Test
fun `findStep updates currentStepIndex to step containing the nearest waypoint`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1)))
val mockLocation: Location = mock()
// step0/wp0: 500F, step0/wp1: 400F, step1/wp0: 300F, step1/wp1: 8F
whenever(mockLocation.distanceTo(any())).thenReturn(500F, 400F, 300F, 8F)
routeCalculator.findStep(mockLocation)
assertEquals(1, routeModel.navState.route.currentStepIndex)
}
@Test
fun `findStep updates waypointIndex to the nearest waypoint within the step`() {
val step0 = createStep(index = 0, numWaypoints = 3)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val mockLocation: Location = mock()
// wp0: 100F, wp1: 30F (nearest), wp2: 80F
whenever(mockLocation.distanceTo(any())).thenReturn(100F, 30F, 80F)
routeCalculator.findStep(mockLocation)
assertEquals(1, step0.waypointIndex)
}
@Test
fun `findStep skips all steps before currentStepIndex`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(
route = setupRoute(listOf(step0, step1), currentStepIndex = 1)
)
val mockLocation: Location = mock()
whenever(mockLocation.distanceTo(any())).thenReturn(200F, 50F)
routeCalculator.findStep(mockLocation)
// step0 is skipped, so distanceTo is only called for step1's 2 waypoints
verify(mockLocation, times(2)).distanceTo(any())
assertEquals(1, routeModel.navState.route.currentStepIndex)
}
@Test
fun `findStep breaks early once nearestDistance drops below NEAREST_LOCATION_DISTANCE`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
val step2 = createStep(index = 2, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(
route = setupRoute(listOf(step0, step1, step2))
)
val mockLocation: Location = mock()
// step0/wp0: 500F, step0/wp1: 5F — 5F < NEAREST_LOCATION_DISTANCE (10F) → break
whenever(mockLocation.distanceTo(any())).thenReturn(500F, 5F)
routeCalculator.findStep(mockLocation)
// step1 and step2 are never evaluated
verify(mockLocation, times(2)).distanceTo(any())
assertEquals(0, routeModel.navState.route.currentStepIndex)
}
// ----------------------------------------------------------
// travelLeftTime
// ----------------------------------------------------------
@Test
fun `travelLeftTime sums future step durations plus full current step duration at first waypoint`() {
// waypointIndex=0, waypoints=2 → percent = 100*(2-0)/2 = 100 → time = 60s
val step0 = createStep(index = 0, numWaypoints = 2, duration = 60.0, waypointIndex = 0)
val step1 = createStep(index = 1, numWaypoints = 2, duration = 120.0)
val step2 = createStep(index = 2, numWaypoints = 2, duration = 90.0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1, step2)))
val result = routeCalculator.travelLeftTime()
// future: 120 + 90 = 210; current: 60 → total: 270
assertEquals(270.0, result, 0.01)
}
@Test
fun `travelLeftTime uses proportional duration based on remaining waypoints in current step`() {
// waypointIndex=2, waypoints=4 → percent = 100*(4-2)/4 = 50 → time = 80*50/100 = 40s
val step0 = createStep(index = 0, numWaypoints = 4, duration = 80.0, waypointIndex = 2)
val step1 = createStep(index = 1, numWaypoints = 2, duration = 40.0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1)))
val result = routeCalculator.travelLeftTime()
// future: 40; current: 40 → total: 80
assertEquals(80.0, result, 0.01)
}
@Test
fun `travelLeftTime returns only future steps when at last step`() {
val step0 = createStep(index = 0, numWaypoints = 2, duration = 60.0, waypointIndex = 1)
routeModel.navState = routeModel.navState.copy(
route = setupRoute(listOf(step0), currentStepIndex = 0)
)
val result = routeCalculator.travelLeftTime()
// no future steps; waypointIndex=1, waypoints=2 → percent = 100*(2-1)/2 = 50 → 30s
assertEquals(30.0, result, 0.01)
}
// ----------------------------------------------------------
// leftStepDistance
// ----------------------------------------------------------
@Test
fun `leftStepDistance returns 0 when waypointIndex is at the last position`() {
// Loop range: waypointIndex..<waypoints.size-1 = 2..<2, which is empty
val step0 = createStep(index = 0, numWaypoints = 3, waypointIndex = 2)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val result = routeCalculator.leftStepDistance()
assertEquals(0.0, result, 0.01)
}
// ----------------------------------------------------------
// travelLeftDistance
// ----------------------------------------------------------
@Test
fun `travelLeftDistance sums distances of all future steps plus leftStepDistance`() {
// step0 at last waypoint → leftStepDistance = 0
val step0 = createStep(index = 0, numWaypoints = 2, distance = 100.0, waypointIndex = 1)
val step1 = createStep(index = 1, numWaypoints = 2, distance = 200.0)
val step2 = createStep(index = 2, numWaypoints = 2, distance = 150.0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1, step2)))
val result = routeCalculator.travelLeftDistance()
// 200 + 150 + 0 = 350
assertEquals(350.0, result, 0.01)
}
@Test
fun `travelLeftDistance returns 0 when on last step at last waypoint`() {
val step0 = createStep(index = 0, numWaypoints = 2, distance = 200.0, waypointIndex = 1)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val result = routeCalculator.travelLeftDistance()
assertEquals(0.0, result, 0.01)
}
// ----------------------------------------------------------
// arrivalTime
// ----------------------------------------------------------
@Test
fun `arrivalTime returns a timestamp roughly travelLeftTime seconds in the future`() {
// step0: 2 waypoints at wp0 → 100% of 3600s duration
val step0 = createStep(index = 0, numWaypoints = 2, duration = 3600.0, waypointIndex = 0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val before = System.currentTimeMillis()
val result = routeCalculator.arrivalTime()
assertTrue("Arrival time should be in the future", result > before)
val expectedArrival = before + 3_600_000L
assertTrue(
"Arrival time should be within 1s of expected",
result in expectedArrival - 1_000..expectedArrival + 1_000
)
}
}

View File

@@ -0,0 +1,109 @@
package com.kouros.navigation.model
import android.location.Location
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Maneuver
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.data.route.Summary
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.doNothing
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class RouteModelTest {
val route =
"{\"formatVersion\":\"0.0.12\",\"report\":{\"effectiveSettings\":[{\"key\":\"avoid\",\"value\":\"unpavedRoads\"},{\"key\":\"computeBestOrder\",\"value\":\"false\"},{\"key\":\"computeTollAmounts\",\"value\":\"none\"},{\"key\":\"computeTravelTimeFor\",\"value\":\"none\"},{\"key\":\"contentType\",\"value\":\"json\"},{\"key\":\"departAt\",\"value\":\"2026-02-26T11:12:31.436Z\"},{\"key\":\"guidanceVersion\",\"value\":\"1\"},{\"key\":\"includeTollPaymentTypes\",\"value\":\"none\"},{\"key\":\"instructionsType\",\"value\":\"text\"},{\"key\":\"language\",\"value\":\"en-GB\"},{\"key\":\"locations\",\"value\":\"48.18575,11.57937:48.18440,11.58298\"},{\"key\":\"maxAlternatives\",\"value\":\"0\"},{\"key\":\"maxPathAlternatives\",\"value\":\"0\"},{\"key\":\"routeRepresentation\",\"value\":\"encodedPolyline\"},{\"key\":\"routeType\",\"value\":\"eco\"},{\"key\":\"sectionType\",\"value\":\"traffic\"},{\"key\":\"sectionType\",\"value\":\"lanes\"},{\"key\":\"traffic\",\"value\":\"true\"},{\"key\":\"travelMode\",\"value\":\"car\"},{\"key\":\"vehicleAxleWeight\",\"value\":\"0\"},{\"key\":\"vehicleCommercial\",\"value\":\"false\"},{\"key\":\"vehicleEngineType\",\"value\":\"combustion\"},{\"key\":\"vehicleHeading\",\"value\":\"90\"},{\"key\":\"vehicleHeight\",\"value\":\"0.00\"},{\"key\":\"vehicleLength\",\"value\":\"0.00\"},{\"key\":\"vehicleMaxSpeed\",\"value\":\"120\"},{\"key\":\"vehicleNumberOfAxles\",\"value\":\"0\"},{\"key\":\"vehicleWeight\",\"value\":\"0\"},{\"key\":\"vehicleWidth\",\"value\":\"0.00\"}]},\"routes\":[{\"summary\":{\"lengthInMeters\":904,\"travelTimeInSeconds\":183,\"trafficDelayInSeconds\":0,\"trafficLengthInMeters\":0,\"departureTime\":\"2026-02-26T12:12:31+01:00\",\"arrivalTime\":\"2026-02-26T12:15:34+01:00\"},\"legs\":[{\"summary\":{\"lengthInMeters\":904,\"travelTimeInSeconds\":183,\"trafficDelayInSeconds\":0,\"trafficLengthInMeters\":0,\"departureTime\":\"2026-02-26T12:12:31+01:00\",\"arrivalTime\":\"2026-02-26T12:15:34+01:00\"},\"encodedPolyline\":\"sfbeH_rteAE|DM|K?P@BBDBBLBdFRBqABw@FkDD_BD_CDkD@uA?oB?{@C_C?YA}@IiEY?\",\"encodedPolylinePrecision\":5}],\"guidance\":{\"instructions\":[{\"routeOffsetInMeters\":0,\"travelTimeInSeconds\":0,\"point\":{\"latitude\":48.18554,\"longitude\":11.57936},\"pointIndex\":0,\"instructionType\":\"LOCATION_DEPARTURE\",\"street\":\"Vogelhartstraße\",\"countryCode\":\"DEU\",\"possibleCombineWithNext\":false,\"drivingSide\":\"RIGHT\",\"maneuver\":\"DEPART\",\"message\":\"Leave from Vogelhartstraße\"},{\"routeOffsetInMeters\":375,\"travelTimeInSeconds\":87,\"point\":{\"latitude\":48.18437,\"longitude\":11.57606},\"pointIndex\":8,\"instructionType\":\"TURN\",\"street\":\"Milbertshofener Straße\",\"countryCode\":\"DEU\",\"junctionType\":\"REGULAR\",\"turnAngleInDecimalDegrees\":-90,\"possibleCombineWithNext\":false,\"drivingSide\":\"RIGHT\",\"maneuver\":\"TURN_LEFT\",\"message\":\"Turn left onto Milbertshofener Straße\"},{\"routeOffsetInMeters\":890,\"travelTimeInSeconds\":168,\"point\":{\"latitude\":48.18427,\"longitude\":11.58297},\"pointIndex\":21,\"instructionType\":\"TURN\",\"street\":\"Bad-Soden-Straße\",\"countryCode\":\"DEU\",\"junctionType\":\"REGULAR\",\"turnAngleInDecimalDegrees\":-90,\"possibleCombineWithNext\":true,\"drivingSide\":\"RIGHT\",\"maneuver\":\"TURN_LEFT\",\"message\":\"Turn left onto Bad-Soden-Straße\",\"combinedMessage\":\"Turn left onto Bad-Soden-Straße then you have arrived at Bad-Soden-Straße\"},{\"routeOffsetInMeters\":904,\"travelTimeInSeconds\":183,\"point\":{\"latitude\":48.1844,\"longitude\":11.58297},\"pointIndex\":22,\"instructionType\":\"LOCATION_ARRIVAL\",\"street\":\"Bad-Soden-Straße\",\"countryCode\":\"DEU\",\"possibleCombineWithNext\":false,\"drivingSide\":\"RIGHT\",\"maneuver\":\"ARRIVE\",\"message\":\"You have arrived at Bad-Soden-Straße\"}],\"instructionGroups\":[{\"firstInstructionIndex\":0,\"lastInstructionIndex\":3,\"groupMessage\":\"Leave from Vogelhartstraße. Take the Milbertshofener Straße. Continue to your destination at Bad-Soden-Straße\",\"groupLengthInMeters\":904}]}}]}"
private lateinit var routeModel: RouteModel
@Before
fun setup() {
routeModel = RouteModel()
routeModel.navState = routeModel.navState.copy(routingEngine = RouteEngine.TOMTOM.ordinal)
routeModel.startNavigation(route)
}
private fun createStep(
index: Int,
numWaypoints: Int = 2,
duration: Double = 60.0,
distance: Double = 200.0,
waypointIndex: Int = 0,
): Step {
val waypoints = (0 until numWaypoints).map { i ->
listOf(11.0 + index * 0.01 + i * 0.001, 48.0)
}
return Step(
index = index,
waypointIndex = waypointIndex,
maneuver = Maneuver(waypoints = waypoints, location = mock()),
duration = duration,
distance = distance,
)
}
private fun setupRoute(steps: List<Step>, currentStepIndex: Int = 0): Route {
val leg = Leg(steps = steps)
val routes = Routes(
legs = listOf(leg),
summary = Summary(),
routeGeoJson = "",
waypoints = emptyList(),
)
return Route(routeEngine = 2, routes = listOf(routes), currentStepIndex = currentStepIndex)
}
@Test
fun `hasLegs returns true when route has legs`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1)))
val result = routeModel.hasLegs()
assert(result)
}
@Test
fun `startNavigation updates route and sets navigating to true `() {
assert(routeModel.navState.navigating)
}
@Test
fun `updateLocation updates currentLocation and lastLocation `() {
val routeCalculator: RouteCalculator = mock()
val mockLocation: Location = mock()
whenever(mockLocation.latitude).thenReturn(48.18554)
whenever(mockLocation.longitude).thenReturn(11.57936)
whenever(mockLocation.bearing).thenReturn(90F)
whenever(mockLocation.hasBearing()).thenReturn(true)
doNothing().`when`(routeCalculator).findStep(mockLocation)
routeModel.updateLocation(mockLocation, mock())
assert(routeModel.navState.currentLocation.latitude == 48.18554)
assert(routeModel.navState.currentLocation.longitude == 11.57936)
}
@Test
fun `currentStep returns StepData `() {
val stepData = routeModel.currentStep()
assert(stepData.leftStepDistance == 0.0)
assert(stepData.instruction == "Milbertshofener Straße")
}
@Test
fun `nextStep returns StepData `() {
routeModel.currentStep()
val stepData = routeModel.nextStep()
assert(stepData.leftStepDistance == 0.0)
assert(stepData.instruction == "Bad-Soden-Straße")
}
@Test
fun `stopNavigation updates route and sets navigating to false `() {
routeModel.stopNavigation()
assert(!routeModel.navState.navigating)
}
}

View File

@@ -0,0 +1,205 @@
package com.kouros.navigation.utils
import android.location.Location
import org.junit.Assert.*
import org.junit.Test
import org.maplibre.geojson.Point
import org.maplibre.turf.TurfMisc
import org.mockito.Mockito.mock
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class GeoUtilsTest {
private fun createLocation(lat: Double, lng: Double, bearing: Float? = null): Location {
val location = mock<Location>()
whenever(location.latitude).thenReturn(lat)
whenever(location.longitude).thenReturn(lng)
whenever(location.hasBearing()).thenReturn(bearing != null)
bearing?.let { whenever(location.bearing).thenReturn(it) }
return location
}
@Test
fun `snapLocation snaps to nearest point on line`() {
val location = createLocation(48.0, 11.0)
val stepCoordinates = listOf(
Point.fromLngLat(11.0, 48.0),
Point.fromLngLat(11.001, 48.0),
Point.fromLngLat(11.002, 48.0)
)
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(0.0, result.latitude, 0.001)
assertEquals(0.0, result.longitude, 0.001)
}
@Test
fun `snapLocation preserves bearing when available`() {
val location = createLocation(48.0, 11.0, bearing = 90f)
val stepCoordinates = listOf(
Point.fromLngLat(11.0, 48.0),
Point.fromLngLat(11.001, 48.0)
)
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(0f, result.bearing, 0.001f)
}
@Test
fun `snapLocation returns original location when coordinates has less than 2 points`() {
val location = createLocation(48.0, 11.0)
val stepCoordinates = listOf(Point.fromLngLat(11.0, 48.0))
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(0.0, result.latitude, 0.001)
assertEquals(0.0, result.longitude, 0.001)
}
@Test
fun `decodePolyline decodes simple polyline correctly`() {
val encoded =
"sfbeH_rteAE|DQEy@GQ?wDQFkEH{G?M?sA@kB?_FAeC?o@?[@_@\\Ab@CVAz@CJA@?dBGhAGjAExAGlBEvBKdCKTAfCKLAv@ELA|AGnAGt@ClCKjDQpBIf@BXDPDPBF@ZB?S@SAYAUKi@Go@Cc@BgBBs@Bg@JyAJiAXqBDWNs@TaA\\mAFa@Po@`BwF?YL]FSFSl@iB^kALc@L]Ro@f@yAf@{AFQNe@dAoCdBgEx@qBTi@BITe@L[L_@^}@HSXu@pB}El@eAb@e@f@[h@QZCRAL?j@HRFh@Vf@XLJhAn@lAv@TLtAz@r@`@bAn@pAv@f@X~@j@z@h@vBpA`@VHDFFJFf@X`CzApAh@f@L`@Fz@@f@AXEVEVEhA[h@Yn@e@PQFEJKRWV[PW`@w@t@}AHQN]~BiFP]`AoBh@aADGTa@t@aAt@{@PQJKJGFG@Cd@]XSxDmCf@a@n@o@TY\\g@LQHMJSLUP[f@iAPg@b@yAFODMNi@FS|@qCVaAHUHUn@wBHYh@eBpAkEjBiGfAeDj@yADMFQd@sAf@kAJUh@qAf@eAt@sAn@iALSN[p@kAVc@JOLSj@w@z@}@x@q@pAu@p@_@j@Sl@MLCRCb@E`@?^?L@`ABz@?N@~AFdADJ@rAH`DVpCVrAJd@BfHp@zGl@pAJ|ALnGp@jEh@fBJpAFF?P@N@ZCtC]r@GJCFCLCD?TEVEXGhAYzAg@NGv@]`@QJEvB_AXMVK\\Qb@Qn@QJCNAZC^ENA`@FnBb@xEtA^H^JnCl@z@r@`@Pr@TtBlA~C`Bn@\\xAl@PF`@LrAVlCh@bBLl@BlBJdG\\RDjCHn@?pB?xB?R@`@@GxAC^?ZInBIfCAvC?r@@dD@n@@b@@^D`C?TDxAFbBHdB@VHp@RjAJb@NNH`@VlBFf@PzARhBFd@@LRbBFh@\\nC@FNhAb@lEj@tDPpABTBRZlBTdBXjBn@xEBLDTRpAR~@l@jDj@Qv@IrEP"
val result = GeoUtils.decodePolyline(encoded, 5)
assertEquals(339, result.size)
assertEquals(11.58204, result[10][0], 0.001)
assertEquals(48.18686, result[10][1], 0.001)
assertEquals(11.59979, result[100][0], 0.001)
assertEquals(48.17076, result[100][1], 0.001)
}
@Test
fun `decodePolyline returns empty list for empty string`() {
val result = GeoUtils.decodePolyline("")
assertTrue(result.isEmpty())
}
@Test
fun `createLineStringCollection creates valid GeoJSON`() {
val coordinates = listOf(
listOf(11.0, 48.0),
listOf(11.1, 48.1),
listOf(11.2, 48.2)
)
val result = GeoUtils.createLineStringCollection(coordinates)
assertTrue(result.contains("LineString"))
assertTrue(result.contains("11.0"))
assertTrue(result.contains("48.0"))
assertTrue(result.contains("FeatureCollection"))
}
@Test
fun `createPointCollection creates valid GeoJSON with category`() {
val coordinates = listOf(
listOf(11.0, 48.0),
listOf(11.1, 48.1)
)
val category = "TestCategory"
val result = GeoUtils.createPointCollection(coordinates, category)
assertTrue(result.contains("Point"))
assertTrue(result.contains("TestCategory"))
assertTrue(result.contains("FeatureCollection"))
}
@Test
fun `createPointCollection handles empty coordinates`() {
val coordinates = emptyList<List<Double>>()
val result = GeoUtils.createPointCollection(coordinates, "Empty")
assertTrue(result.contains("FeatureCollection"))
}
@Test
fun `calculateSquareRadius returns correct bounding box`() {
val lat = 48.0
val lng = 11.0
val radius = 1.0 // 1 km
val result = GeoUtils.calculateSquareRadius(lat, lng, radius)
// Result should be in format: lngMin,latMin,lngMax,latMax
val parts = result.split(",")
assertEquals(4, parts.size)
val lngMin = parts[0].toDouble()
val latMin = parts[1].toDouble()
val lngMax = parts[2].toDouble()
val latMax = parts[3].toDouble()
assertTrue(latMin < lat)
assertTrue(latMax > lat)
assertTrue(lngMin < lng)
assertTrue(lngMax > lng)
assertTrue(latMax - latMin > 0)
assertTrue(lngMax - lngMin > 0)
}
@Test
fun `getBoundingBox returns correct format`() {
val lat = 48.0
val lon = 11.0
val radius = 1.0 // 1 km
val result = GeoUtils.getBoundingBox(lat, lon, radius)
// Result should be in format: minLat,minLon,maxLat,maxLon
val parts = result.split(",")
assertEquals(4, parts.size)
val minLat = parts[0].toDouble()
val minLon = parts[1].toDouble()
val maxLat = parts[2].toDouble()
val maxLon = parts[3].toDouble()
assertTrue(minLat < lat)
assertTrue(maxLat > lat)
assertTrue(minLon < lon)
assertTrue(maxLon > lon)
}
@Test
fun `calculateSquareRadius and getBoundingBox produce different formats`() {
val lat = 48.0
val lng = 11.0
val radius = 1.0
val squareRadius = GeoUtils.calculateSquareRadius(lat, lng, radius)
val boundingBox = GeoUtils.getBoundingBox(lat, lng, radius)
// Both should have 4 comma-separated values
assertEquals(4, squareRadius.split(",").size)
assertEquals(4, boundingBox.split(",").size)
// But the order should be different
assertNotEquals(squareRadius, boundingBox)
}
@Test
fun `calculateSquareRadius scales with radius`() {
val lat = 48.0
val lng = 11.0
val smallRadius = GeoUtils.calculateSquareRadius(lat, lng, 1.0)
val largeRadius = GeoUtils.calculateSquareRadius(lat, lng, 10.0)
val smallParts = smallRadius.split(",").map { it.toDouble() }
val largeParts = largeRadius.split(",").map { it.toDouble() }
// Larger radius should produce larger bounding box
assertTrue((largeParts[3] - largeParts[1]) > (smallParts[3] - smallParts[1]))
}
}