diff --git a/app/build.gradle b/app/build.gradle index 5cacb249..5dc93ef0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.0' id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' } @@ -92,8 +93,11 @@ dependencies { implementation 'org.luaj:luaj-jse:3.0.1' implementation 'com.github.amitshekhariitbhu.Fast-Android-Networking:android-networking:1.0.4' implementation 'androidx.security:security-crypto:1.1.0-alpha06' + implementation libs.bundles.ktor testImplementation 'junit:junit:4.13.2' + testImplementation libs.bundles.mockito + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' androidTestImplementation 'androidx.test.ext:junit:1.1.4' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0' } \ No newline at end of file diff --git a/app/src/androidTest/java/com/coderGtm/yantra/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/coderGtm/yantra/ExampleInstrumentedTest.kt deleted file mode 100644 index 3e3db571..00000000 --- a/app/src/androidTest/java/com/coderGtm/yantra/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.coderGtm.yantra - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.coderGtm.yantra", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/coderGtm/yantra/commands/weather/Command.kt b/app/src/main/java/com/coderGtm/yantra/commands/weather/Command.kt index 28034e53..300b60d1 100644 --- a/app/src/main/java/com/coderGtm/yantra/commands/weather/Command.kt +++ b/app/src/main/java/com/coderGtm/yantra/commands/weather/Command.kt @@ -1,11 +1,5 @@ package com.coderGtm.yantra.commands.weather -import android.content.pm.PackageManager -import android.graphics.Typeface -import androidx.appcompat.app.AppCompatDelegate -import com.android.volley.Request -import com.android.volley.toolbox.StringRequest -import com.android.volley.toolbox.Volley import com.coderGtm.yantra.R import com.coderGtm.yantra.blueprints.BaseCommand import com.coderGtm.yantra.models.CommandMetadata @@ -18,26 +12,18 @@ class Command(terminal: Terminal) : BaseCommand(terminal) { description = terminal.activity.getString(R.string.cmd_weather_help) ) + override fun execute(command: String) { - val args = command.split(" ") - if (args.size < 2) { - output(terminal.activity.getString(R.string.please_specify_a_location), terminal.theme.errorTextColor) - return + when (val parseResult = parseWeatherCommand(command, this.terminal.activity)) { + is ParseResult.MissingLocation -> handleMissingLocation(this) + is ParseResult.ValidationError -> handleValidationError( + parseResult.formatErrors, + parseResult.invalidFields, + this + ) + + is ParseResult.ListCommand -> showAvailableFields(this) + is ParseResult.Success -> fetchWeatherData(parseResult.args, this) } - val location = command.trim().removePrefix(args[0]).trim() - val langCode = AppCompatDelegate.getApplicationLocales().toLanguageTags() - output(terminal.activity.getString(R.string.fetching_weather_report_of, location), terminal.theme.resultTextColor, Typeface.ITALIC) - val apiKey = terminal.activity.packageManager.getApplicationInfo(terminal.activity.packageName, PackageManager.GET_META_DATA).metaData.getString("WEATHER_API_KEY") - val url = "https://api.weatherapi.com/v1/forecast.json?key=$apiKey&q=$location&lang=$langCode&aqi=yes" - val queue = Volley.newRequestQueue(terminal.activity) - val stringRequest = StringRequest( - Request.Method.GET, url, - { response -> - handleResponse(response, this@Command) - }, - { error -> - handleError(error, this@Command) - }) - queue.add(stringRequest) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/coderGtm/yantra/commands/weather/Helper.kt b/app/src/main/java/com/coderGtm/yantra/commands/weather/Helper.kt index a84f5813..0d855cde 100644 --- a/app/src/main/java/com/coderGtm/yantra/commands/weather/Helper.kt +++ b/app/src/main/java/com/coderGtm/yantra/commands/weather/Helper.kt @@ -1,82 +1,177 @@ package com.coderGtm.yantra.commands.weather +import android.content.pm.PackageManager import android.graphics.Typeface -import com.android.volley.NoConnectionError -import com.android.volley.VolleyError +import androidx.appcompat.app.AppCompatDelegate import com.coderGtm.yantra.R -import org.json.JSONObject -import kotlin.math.roundToInt +import com.coderGtm.yantra.blueprints.BaseCommand +import com.coderGtm.yantra.network.HttpClientProvider +import io.ktor.client.call.body +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.get +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.ConnectException +import java.net.UnknownHostException +import kotlin.coroutines.cancellation.CancellationException -fun handleResponse(response: String, command: Command) { - command.output("-------------------------") - val json = JSONObject(response) - try { - val weather_location = json.getJSONObject("location").getString("name") + ", " + json.getJSONObject("location").getString("country") - val current = json.getJSONObject("current") - val condition = current.getJSONObject("condition").getString("text") - val temp_c = current.getDouble("temp_c") - val temp_f = current.getDouble("temp_f") - val feelslike_c = current.getDouble("feelslike_c") - val feelslike_f = current.getDouble("feelslike_f") - val wind_kph = current.getDouble("wind_kph") - val wind_mph = current.getDouble("wind_mph") - val wind_dir = current.getString("wind_dir") - val humidity = current.getDouble("humidity") - val air_quality = current.getJSONObject("air_quality") - val air_quality_index = air_quality.getInt("us-epa-index") - val forecast = json.getJSONObject("forecast") - val forecastDay = forecast.getJSONArray("forecastday").getJSONObject(0) - val day = forecastDay.getJSONObject("day") - val maxtemp_c = day.getDouble("maxtemp_c") - val mintemp_c = day.getDouble("mintemp_c") - val maxtemp_f = day.getDouble("maxtemp_f") - val mintemp_f = day.getDouble("mintemp_f") - val will_it_rain = day.getInt("daily_will_it_rain") - val will_it_snow = day.getInt("daily_will_it_snow") - val precipitation_chance = day.getInt("daily_chance_of_rain") - val snow_chance = day.getInt("daily_chance_of_snow") - command.output(command.terminal.activity.getString(R.string.weather_report_of, weather_location), command.terminal.theme.successTextColor, Typeface.BOLD) - command.output("=> $condition") - command.output(command.terminal.activity.getString(R.string.weather_temperature_c_f, temp_c, temp_f)) - command.output(command.terminal.activity.getString(R.string.weather_feels_like_c_f, feelslike_c, feelslike_f)) - command.output(command.terminal.activity.getString(R.string.weather_min_c_f, mintemp_c, mintemp_f)) - command.output(command.terminal.activity.getString(R.string.weather_max_c_f, maxtemp_c, maxtemp_f)) - command.output(command.terminal.activity.getString(R.string.weather_humidity, humidity.roundToInt())) - command.output(command.terminal.activity.getString(R.string.weather_wind, wind_kph, wind_mph, wind_dir)) - command.output(command.terminal.activity.getString(R.string.weather_air_quality, getAqiText(air_quality_index))) - if (will_it_rain == 1) { - command.output(command.terminal.activity.getString(R.string.precipitation_chance, precipitation_chance)) - } - if (will_it_snow == 1) { - command.output(command.terminal.activity.getString(R.string.snow_chance, snow_chance)) +private var weatherJob: Job? = null + +/** + * Fetches weather data from the WeatherAPI for the specified location. + * + * @param args The [WeatherCommandArgs] containing the location for which to fetch weather data. + * @param command The [BaseCommand] instance. + */ +fun fetchWeatherData(args: WeatherCommandArgs, command: BaseCommand) { + val location = args.location + + val langCode = AppCompatDelegate.getApplicationLocales().toLanguageTags() + command.output( + command.terminal.activity.getString(R.string.fetching_weather_report_of, location), + command.terminal.theme.resultTextColor, + Typeface.ITALIC + ) + + val apiKey = command.terminal.activity.packageManager.getApplicationInfo( + command.terminal.activity.packageName, + PackageManager.GET_META_DATA + ).metaData.getString("WEATHER_API_KEY") + + val url = + "https://api.weatherapi.com/v1/forecast.json?key=$apiKey&q=$location&lang=$langCode&aqi=yes" + + weatherJob?.cancel() + weatherJob = CoroutineScope(Dispatchers.Main).launch { + try { + ensureActive() + val weather = withContext(Dispatchers.IO) { + HttpClientProvider.client.get(url).body() + } + handleResponse(weather, args, command) + } catch (e: Exception) { + if (e is CancellationException) return@launch + handleKtorError(e, command) } - } catch (e: Exception) { - command.output(command.terminal.activity.getString(R.string.an_error_occurred, e.message.toString())) } +} - command.output("-------------------------") +/** + * Handles the error response from the WeatherAPI. + */ +internal suspend fun handleKtorError(error: Exception, command: BaseCommand) { + when (error) { + is ClientRequestException -> { + val apiError = parseErrorResponse(error) + val stringRes = getWeatherApiErrorStringRes(apiError, error.response.status.value) + command.output( + command.terminal.activity.getString(stringRes), + command.terminal.theme.errorTextColor + ) + } + + is ConnectException, is UnknownHostException -> { + command.output( + command.terminal.activity.getString(R.string.no_internet_connection), + command.terminal.theme.errorTextColor + ) + } + + else -> { + command.output( + command.terminal.activity.getString(R.string.an_error_occurred_no_reason), + command.terminal.theme.errorTextColor + ) + } + } } -fun handleError(error: VolleyError, command: Command) { - if (error is NoConnectionError) { - command.output(command.terminal.activity.getString(R.string.no_internet_connection), command.terminal.theme.errorTextColor) +/** + * Convenience function to parse the error response from the WeatherAPI. + */ +internal suspend fun parseErrorResponse( + exception: ClientRequestException +): WeatherApiError? = withContext(Dispatchers.IO) { + try { + exception.response.body().error + } catch (_: Exception) { + null } - else if (error.networkResponse.statusCode == 400) { - command.output(command.terminal.activity.getString(R.string.location_not_found), command.terminal.theme.warningTextColor) +} + +/** + * Convenience function to get the appropriate error string resource based on the API error code. + */ +internal fun getWeatherApiErrorStringRes( + apiError: WeatherApiError?, + statusCode: Int +): Int = when (apiError?.code) { + 1002 -> R.string.weather_api_key_not_provided + 1003 -> R.string.weather_location_parameter_missing + 1005 -> R.string.weather_api_request_invalid + 1006 -> R.string.weather_location_not_found + 2006 -> R.string.weather_api_key_invalid + 2007 -> R.string.weather_quota_exceeded + 2008 -> R.string.weather_api_disabled + 2009 -> R.string.weather_api_access_restricted + 9000 -> R.string.weather_bulk_request_invalid + 9001 -> R.string.weather_bulk_too_many_locations + 9999 -> R.string.weather_internal_error + else -> getGenericErrorForStatus(statusCode) } - else { - command.output(command.terminal.activity.getString(R.string.an_error_occurred_no_reason),command.terminal.theme.errorTextColor) + +/** + * Convenience function to get the appropriate error string resource based on the HTTP status code. + */ +private fun getGenericErrorForStatus(statusCode: Int): Int { + return when (statusCode) { + 400 -> R.string.weather_location_not_found + 401 -> R.string.weather_api_key_invalid + 403 -> R.string.weather_quota_exceeded + else -> R.string.weather_unknown_error } } -fun getAqiText(index: Int): String { - return when (index) { - 1 -> "Good" - 2 -> "Moderate" - 3 -> "Unhealthy for sensitive group" - 4 -> "Unhealthy" - 5 -> "Very Unhealthy" - 6 -> "Hazardous" - else -> "Unknown" +/** + * Handles the successful response from the WeatherAPI. + */ +private fun handleResponse( + weather: WeatherResponse, + args: WeatherCommandArgs, + command: BaseCommand, +) { + command.output("-------------------------") + with(command.terminal.activity) { + try { + val location = "${weather.location.name}, ${weather.location.country}" + command.output( + getString(R.string.weather_report_of, location), + command.terminal.theme.successTextColor, + Typeface.BOLD + ) + + val fieldsToShow = if (args.showDefaultFields) { + DEFAULT_WEATHER_FIELDS + } else { + args.requestedFields + } + + fieldsToShow.forEach { fieldKey -> + WEATHER_FIELD_MAP[fieldKey]?.renderer?.invoke(weather, command) + } + } catch (e: Exception) { + command.output( + getString( + R.string.an_error_occurred, + e.message.toString() + ) + ) + } } + + command.output("-------------------------") } \ No newline at end of file diff --git a/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherField.kt b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherField.kt new file mode 100644 index 00000000..15cb4c06 --- /dev/null +++ b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherField.kt @@ -0,0 +1,423 @@ +package com.coderGtm.yantra.commands.weather + +import android.content.Context +import com.coderGtm.yantra.R +import com.coderGtm.yantra.blueprints.BaseCommand + +/** + * Weather field definitions for user-accessible weather data. + * + * Note that only 38 fields exposed below out of 100+ available from API) + * + * To add new fields: + * 1. Add WeatherField entry to appropriate category below + * 2. Add string resource to values/strings.xml (and translate to all languages) + * 3. Implement renderer function accessing WeatherResponse properties + */ + +data class WeatherField( + val key: String, + val nameRes: Int, + val isDefault: Boolean = false, + val renderer: (WeatherResponse, BaseCommand) -> Unit, +) { + fun getDescription(context: Context): String = + context.getString(nameRes) +} + +data class WeatherCategory( + val nameRes: Int, + val fields: List, +) { + fun displayFields(command: BaseCommand) { + command.output("${command.terminal.activity.getString(nameRes)}:") + fields.forEach { field -> + command.output(" -${field.key.padEnd(12)} ${field.getDescription(command.terminal.activity)}") + } + command.output("") + } +} + +val WEATHER_FIELD_CATEGORIES = listOf( + WeatherCategory( + R.string.weather_category_current, listOf( + WeatherField( + "temp", + R.string.weather_field_temp, + isDefault = true + ) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_temperature_c_f, + weather.current.tempC, + weather.current.tempF + ) + ) + }, + WeatherField( + "feels", + R.string.weather_field_feels, + isDefault = true + ) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_feels_like_c_f, + weather.current.feelslikeC, + weather.current.feelslikeF + ) + ) + }, + WeatherField("windchill", R.string.weather_field_windchill) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_windchill_c_f, + weather.current.windchillC, + weather.current.windchillF + ) + ) + }, + WeatherField("heatindex", R.string.weather_field_heatindex) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_heatindex_c_f, + weather.current.heatindexC, + weather.current.heatindexF + ) + ) + }, + WeatherField("dewpoint", R.string.weather_field_dewpoint) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_dewpoint_c_f, + weather.current.dewpointC, + weather.current.dewpointF + ) + ) + }, + WeatherField( + "humidity", + R.string.weather_field_humidity, + isDefault = true + ) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_humidity, + weather.current.humidity + ) + ) + }, + WeatherField( + "wind", + R.string.weather_field_wind, + isDefault = true + ) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_wind, + weather.current.windKph, + weather.current.windMph, + weather.current.windDir + ) + ) + }, + WeatherField("gust", R.string.weather_field_gust) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_gust, + weather.current.gustKph, + weather.current.gustMph + ) + ) + }, + WeatherField("pressure", R.string.weather_field_pressure) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_pressure, + weather.current.pressureMb + ) + ) + }, + WeatherField("uv", R.string.weather_field_uv) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_uv_index, + weather.current.uv + ) + ) + }, + WeatherField("visibility", R.string.weather_field_visibility) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_visibility, + weather.current.visKm + ) + ) + }, + WeatherField( + "condition", + R.string.weather_field_condition, + isDefault = true + ) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_condition, + weather.current.condition.text + ) + ) + }, + WeatherField("cloud", R.string.weather_field_cloud) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_cloud_cover, + weather.current.cloud + ) + ) + }, + WeatherField("precip", R.string.weather_field_precip) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_precipitation, + weather.current.precipMm + ) + ) + }, + WeatherField("solarrad", R.string.weather_field_solarrad) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_solar_radiation, + weather.current.shortRad, + weather.current.diffRad, + weather.current.dni, + weather.current.gti + ) + ) + } + ) + ), + WeatherCategory( + R.string.weather_category_forecast, listOf( + WeatherField("min", R.string.weather_field_min, isDefault = true) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_min_c_f, + weather.forecast.forecastday[0].day.mintempC, + weather.forecast.forecastday[0].day.mintempF + ) + ) + }, + WeatherField("max", R.string.weather_field_max, isDefault = true) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_max_c_f, + weather.forecast.forecastday[0].day.maxtempC, + weather.forecast.forecastday[0].day.maxtempF + ) + ) + }, + WeatherField("avgtemp", R.string.weather_field_avgtemp) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_avgtemp_c_f, + weather.forecast.forecastday[0].day.avgtempC, + weather.forecast.forecastday[0].day.avgtempF + ) + ) + }, + WeatherField("maxwind", R.string.weather_field_maxwind) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_maxwind, + weather.forecast.forecastday[0].day.maxwindKph, + weather.forecast.forecastday[0].day.maxwindMph + ) + ) + }, + WeatherField("totalprecip", R.string.weather_field_totalprecip) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_total_precipitation, + weather.forecast.forecastday[0].day.totalprecipMm + ) + ) + }, + WeatherField("totalsnow", R.string.weather_field_totalsnow) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_total_snow, + weather.forecast.forecastday[0].day.totalsnowCm + ) + ) + }, + WeatherField("avghumidity", R.string.weather_field_avghumidity) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_avg_humidity, + weather.forecast.forecastday[0].day.avghumidity + ) + ) + }, + WeatherField( + "rain", + R.string.weather_field_rain, + isDefault = true + ) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.precipitation_chance, + weather.forecast.forecastday[0].day.dailyChanceOfRain + ) + ) + }, + WeatherField( + "snow", + R.string.weather_field_snow, + isDefault = true + ) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.snow_chance, + weather.forecast.forecastday[0].day.dailyChanceOfSnow + ) + ) + } + ) + ), + WeatherCategory( + R.string.weather_category_air_quality, listOf( + WeatherField("aqi", R.string.weather_field_aqi, isDefault = true) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_air_quality, + getAqiText(weather.current.airQuality.usEpaIndex, command.terminal.activity) + ) + ) + }, + WeatherField("co", R.string.weather_field_co) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_co, + weather.current.airQuality.co + ) + ) + }, + WeatherField("no2", R.string.weather_field_no2) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_no2, + weather.current.airQuality.no2 + ) + ) + }, + WeatherField("o3", R.string.weather_field_o3) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_o3, + weather.current.airQuality.o3 + ) + ) + }, + WeatherField("so2", R.string.weather_field_so2) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_so2, + weather.current.airQuality.so2 + ) + ) + }, + WeatherField("pm25", R.string.weather_field_pm25) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_pm25, + weather.current.airQuality.pm25 + ) + ) + }, + WeatherField("pm10", R.string.weather_field_pm10) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_pm10, + weather.current.airQuality.pm10 + ) + ) + } + ) + ), + WeatherCategory( + R.string.weather_category_astronomy, listOf( + WeatherField("sunrise", R.string.weather_field_sunrise) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_sunrise, + weather.forecast.forecastday[0].astro.sunrise + ) + ) + }, + WeatherField("sunset", R.string.weather_field_sunset) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_sunset, + weather.forecast.forecastday[0].astro.sunset + ) + ) + }, + WeatherField("moonrise", R.string.weather_field_moonrise) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_moonrise, + weather.forecast.forecastday[0].astro.moonrise + ) + ) + }, + WeatherField("moonset", R.string.weather_field_moonset) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_moonset, + weather.forecast.forecastday[0].astro.moonset + ) + ) + }, + WeatherField("moonphase", R.string.weather_field_moonphase) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_moon_phase, + weather.forecast.forecastday[0].astro.moonPhase + ) + ) + }, + WeatherField("moonlight", R.string.weather_field_moonlight) { weather, command -> + command.output( + command.terminal.activity.getString( + R.string.weather_moon_illumination, + weather.forecast.forecastday[0].astro.moonIllumination + ) + ) + } + ) + ) +) + +val VALID_WEATHER_FIELDS = + WEATHER_FIELD_CATEGORIES.flatMap { it.fields.map { field -> field.key } }.toSet() + +val DEFAULT_WEATHER_FIELDS = WEATHER_FIELD_CATEGORIES + .flatMap { it.fields } + .filter { it.isDefault } + .map { it.key } + +val WEATHER_FIELD_MAP = WEATHER_FIELD_CATEGORIES + .flatMap { it.fields } + .associateBy { it.key } + +fun getAqiText(index: Int, context: Context): String { + return with(context) { + when (index) { + 1 -> getString(R.string.aqi_good) + 2 -> getString(R.string.aqi_moderate) + 3 -> getString(R.string.aqi_unhealthy_sensitive) + 4 -> getString(R.string.aqi_unhealthy) + 5 -> getString(R.string.aqi_very_unhealthy) + 6 -> getString(R.string.aqi_hazardous) + else -> getString(R.string.aqi_unknown) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherModels.kt b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherModels.kt new file mode 100644 index 00000000..0c523c44 --- /dev/null +++ b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherModels.kt @@ -0,0 +1,151 @@ +/** + * Complete data models for WeatherAPI.com response. + * + * Note that these models map the full API response, but only a subset of fields are currently exposed + * to users. See [WeatherField].kt for more info and instructions on adding new user-accessible + * fields. + * + * [https://github.com/Kotlin/kotlinx.serialization/issues/2844](More info) about why the + * opt-in annotation is needed. + */ +@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) + +package com.coderGtm.yantra.commands.weather + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WeatherResponse( + val location: Location, + val current: Current, + val forecast: Forecast +) + +@Serializable +data class Location( + val name: String, + val region: String, + val country: String, + val lat: Double, + val lon: Double, + @SerialName("tz_id") val tzId: String, + @SerialName("localtime_epoch") val localtimeEpoch: Long, + val localtime: String +) + +@Serializable +data class Current( + @SerialName("last_updated_epoch") val lastUpdatedEpoch: Long, + @SerialName("last_updated") val lastUpdated: String, + @SerialName("temp_c") val tempC: Double, + @SerialName("temp_f") val tempF: Double, + @SerialName("is_day") val isDay: Int, + val condition: Condition, + @SerialName("wind_mph") val windMph: Double, + @SerialName("wind_kph") val windKph: Double, + @SerialName("wind_degree") val windDegree: Int, + @SerialName("wind_dir") val windDir: String, + @SerialName("pressure_mb") val pressureMb: Double, + @SerialName("pressure_in") val pressureIn: Double, + @SerialName("precip_mm") val precipMm: Double, + @SerialName("precip_in") val precipIn: Double, + val humidity: Int, + val cloud: Int, + @SerialName("feelslike_c") val feelslikeC: Double, + @SerialName("feelslike_f") val feelslikeF: Double, + @SerialName("windchill_c") val windchillC: Double, + @SerialName("windchill_f") val windchillF: Double, + @SerialName("heatindex_c") val heatindexC: Double, + @SerialName("heatindex_f") val heatindexF: Double, + @SerialName("dewpoint_c") val dewpointC: Double, + @SerialName("dewpoint_f") val dewpointF: Double, + @SerialName("vis_km") val visKm: Double, + @SerialName("vis_miles") val visMiles: Double, + val uv: Double, + @SerialName("gust_mph") val gustMph: Double, + @SerialName("gust_kph") val gustKph: Double, + @SerialName("air_quality") val airQuality: AirQuality, + @SerialName("short_rad") val shortRad: Double, + @SerialName("diff_rad") val diffRad: Double, + val dni: Double, + val gti: Double +) + +@Serializable +data class Condition( + val text: String, + val icon: String, + val code: Int +) + +@Serializable +data class AirQuality( + val co: Double, + val no2: Double, + val o3: Double, + val so2: Double, + @SerialName("pm2_5") val pm25: Double, + val pm10: Double, + @SerialName("us-epa-index") val usEpaIndex: Int, + @SerialName("gb-defra-index") val gbDefraIndex: Int +) + +@Serializable +data class Forecast( + val forecastday: List +) + +@Serializable +data class TodayForecast( + val day: DayForecast, + val astro: Astro +) + +@Serializable +data class DayForecast( + @SerialName("maxtemp_c") val maxtempC: Double, + @SerialName("maxtemp_f") val maxtempF: Double, + @SerialName("mintemp_c") val mintempC: Double, + @SerialName("mintemp_f") val mintempF: Double, + @SerialName("avgtemp_c") val avgtempC: Double, + @SerialName("avgtemp_f") val avgtempF: Double, + @SerialName("maxwind_mph") val maxwindMph: Double, + @SerialName("maxwind_kph") val maxwindKph: Double, + @SerialName("totalprecip_mm") val totalprecipMm: Double, + @SerialName("totalprecip_in") val totalprecipIn: Double, + @SerialName("totalsnow_cm") val totalsnowCm: Double, + @SerialName("avgvis_km") val avgvisKm: Double, + @SerialName("avgvis_miles") val avgvisMiles: Double, + @SerialName("avghumidity") val avghumidity: Double, + @SerialName("daily_will_it_rain") val dailyWillItRain: Int, + @SerialName("daily_will_it_snow") val dailyWillItSnow: Int, + @SerialName("daily_chance_of_rain") val dailyChanceOfRain: Int, + @SerialName("daily_chance_of_snow") val dailyChanceOfSnow: Int, + val condition: Condition, + val uv: Double, + @SerialName("air_quality") val airQuality: AirQuality +) + +@Serializable +data class Astro( + val sunrise: String, + val sunset: String, + val moonrise: String, + val moonset: String, + @SerialName("moon_phase") val moonPhase: String, + @SerialName("moon_illumination") val moonIllumination: Int, + @SerialName("is_moon_up") val isMoonUp: Int, + @SerialName("is_sun_up") val isSunUp: Int +) + +@Serializable +data class WeatherErrorResponse( + val error: WeatherApiError +) + +@Serializable +data class WeatherApiError( + val code: Int, + val message: String +) \ No newline at end of file diff --git a/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherRenderer.kt b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherRenderer.kt new file mode 100644 index 00000000..a704588e --- /dev/null +++ b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherRenderer.kt @@ -0,0 +1,57 @@ +package com.coderGtm.yantra.commands.weather + +import android.graphics.Typeface +import com.coderGtm.yantra.R +import com.coderGtm.yantra.blueprints.BaseCommand + +fun showAvailableFields(command: BaseCommand) { + command.output( + command.terminal.activity.getString( + R.string.weather_available_fields, + VALID_WEATHER_FIELDS.size + ), command.terminal.theme.successTextColor, Typeface.BOLD + ) + command.output("") + + WEATHER_FIELD_CATEGORIES.forEach { it.displayFields(command) } + + command.output(command.terminal.activity.getString(R.string.examples)) + command.output(" weather london -temp -humidity") + command.output(" weather paris -uv -wind -condition") + command.output(" weather tokyo -sunrise -sunset -moonphase") + command.output(" weather denver -co -pm25 -aqi") +} + +fun handleMissingLocation(command: BaseCommand) { + command.output( + command.terminal.activity.getString(R.string.please_specify_a_location), + command.terminal.theme.errorTextColor + ) +} + +fun handleValidationError( + formatErrors: List, + invalidFields: List, + command: BaseCommand, +) { + command.output( + command.terminal.activity.getString(R.string.weather_invalid_command_format), + command.terminal.theme.errorTextColor, + Typeface.BOLD + ) + formatErrors.forEach { error -> + command.output("• $error", command.terminal.theme.errorTextColor) + } + invalidFields.forEach { field -> + command.output( + command.terminal.activity.getString( + R.string.weather_unknown_field_bullet, + field + ), command.terminal.theme.errorTextColor + ) + } + command.output( + command.terminal.activity.getString(R.string.weather_use_list_command), + command.terminal.theme.warningTextColor + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherValidation.kt b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherValidation.kt new file mode 100644 index 00000000..4f115318 --- /dev/null +++ b/app/src/main/java/com/coderGtm/yantra/commands/weather/WeatherValidation.kt @@ -0,0 +1,287 @@ +package com.coderGtm.yantra.commands.weather + +import android.content.Context +import com.coderGtm.yantra.R +import com.coderGtm.yantra.blueprints.BaseCommand + +sealed class ParseResult { + data class Success(val args: WeatherCommandArgs) : ParseResult() + object MissingLocation : ParseResult() + data class ValidationError(val formatErrors: List, val invalidFields: List) : + ParseResult() + + object ListCommand : ParseResult() +} + +data class LocationAndFields( + val location: String, + val fields: List, +) + +data class WeatherCommandArgs( + val location: String, + val requestedFields: List, + val showDefaultFields: Boolean, +) + +data class ValidationResult( + val validFields: List, + val invalidFields: List, + val formatErrors: List, +) { + fun isValid(): Boolean { + return formatErrors.isEmpty() && invalidFields.isEmpty() + } +} + + +/** + * This function takes a raw command string (e.g., "weather denver -temp -humidity") and attempts + * to extract the location and requested weather fields. + * + * @param command The raw weather command string. + * @return A [ParseResult] object representing the outcome of the parsing. + */ +fun parseWeatherCommand(command: String, context: Context): ParseResult { + val args = command + .trim() + .split("\\s+".toRegex()) + + if (args.size < 2) return ParseResult.MissingLocation + + if (args.size == 2 && args[1] == "list") return ParseResult.ListCommand + + val commandArgs = args.drop(1) + + // validate for field-like words in raw arguments before location extraction + val earlyValidation = validateRawArgsForFieldLikeWords(commandArgs, context) + if (earlyValidation.isNotEmpty()) { + return ParseResult.ValidationError(earlyValidation, emptyList()) + } + + // extract location first to determine what needs validation + val locationAndFields = extractLocationAndFields(commandArgs) + + // validate the extracted location for field-like words + val locationValidation = validateLocationForFieldLikeWords(locationAndFields.location, context) + if (locationValidation.isNotEmpty()) { + return ParseResult.ValidationError(locationValidation, emptyList()) + } + + val firstFieldIndex = commandArgs.indexOfFirst { it.startsWith("-") } + // validate arguments after location to catch misplaced non-hyphenated words + val argsAfterLocation = if (firstFieldIndex == -1) emptyList() else commandArgs.drop(firstFieldIndex) + val validationResult = validateWeatherFields(argsAfterLocation, context) + + if (!(validationResult.isValid())) { + return ParseResult.ValidationError( + validationResult.formatErrors, + validationResult.invalidFields + ) + } + + return when { + locationAndFields.location.isEmpty() -> ParseResult.MissingLocation + else -> ParseResult.Success( + WeatherCommandArgs( + locationAndFields.location, + locationAndFields.fields, + locationAndFields.fields.isEmpty() + ) + ) + } +} + +/** + * This function checks each argument in the provided list to ensure it conforms to the expected + * format for weather fields. It also verifies if the field name is a recognized weather field. + * + * @param args A list of strings, where each string is a valid or invalid weather field argument + * (e.g., "-temp", "--humidity", "-"). + * @param context The [Context] instance for accessing string resources. + * @return A [ValidationResult]. + */ +fun validateWeatherFields(args: List, context: Context): ValidationResult { + val validFields = mutableListOf() + val invalidFields = mutableListOf() + val formatErrors = mutableListOf() + + args.forEach { arg -> + when { + arg == "-" -> { + formatErrors.add( + context.getString(R.string.weather_error_single_hyphen) + ) + } + + arg.contains("--") -> { + val correctFormat = "-${arg.substringAfterLast("-")}" + formatErrors.add( + context.getString( + R.string.weather_error_multiple_hyphens, + arg, + correctFormat + ) + ) + } + + arg.startsWith("-") -> { + val field = arg.removePrefix("-") + if (field in VALID_WEATHER_FIELDS) { + validFields.add(field) + } else { + // check if this might be combined fields separated by hyphens + val combinedFieldsResult = checkForCombinedFields(field, context) + if (combinedFieldsResult.isNotEmpty()) { + formatErrors.addAll(combinedFieldsResult) + } else { + invalidFields.add(field) + } + } + } + + else -> { + formatErrors.add( + context.getString( + R.string.weather_error_misplaced_argument, + arg + ) + ) + } + } + } + return ValidationResult(validFields, invalidFields, formatErrors) +} + + +/** + * Extracts the location and requested weather fields from a list of command arguments. + * + * Note: This function assumes the arguments have already been validated by [validateWeatherFields]. + * It performs minimal validation and focuses on parsing/extraction. + * + * @param args The list of command arguments (pre-validated). + * @return A [LocationAndFields] containing the extracted location string and a list of requested + * weather field names (without the leading hyphen). If no fields are requested, the field list + * will be empty. + */ +internal fun extractLocationAndFields(args: List): LocationAndFields { + val firstParamIndex = args.indexOfFirst { it.startsWith("-") } + + val locationParts = if (firstParamIndex == -1) args else args.take(firstParamIndex) + val location = locationParts.joinToString(" ") + + val fields = if (firstParamIndex == -1) { + emptyList() + } else { + args.drop(firstParamIndex) + .filter { it.startsWith("-") } + .map { it.removePrefix("-") } + .filter { it.isNotEmpty() } + } + + return LocationAndFields(location, fields) +} + +/** + * Validates raw command arguments for field-like words before location extraction. + * This catches cases like "weather Paris temp -humidity" where "temp" appears to be a field. + * + * @param args The raw command arguments to validate + * @param context The [Context] instance for accessing string resources + * @return List of error messages, empty if arguments are valid + */ +fun validateRawArgsForFieldLikeWords(args: List, context: Context): List { + val errors = mutableListOf() + val firstFieldIndex = args.indexOfFirst { it.startsWith("-") } + + // check words before first field for field-like names (or all words if no fields) + val wordsBeforeFields = if (firstFieldIndex == -1) args else args.take(firstFieldIndex) + + // check words within the location part (excluding the very first word) for field-like patterns + wordsBeforeFields.drop(1).forEach { word -> + val normalizedWord = word.lowercase() + if (normalizedWord in VALID_WEATHER_FIELDS) { + if (firstFieldIndex == -1) { + // no hyphenated fields present, suggest adding hyphens + errors.add( + context.getString( + R.string.weather_error_field_in_location_no_hyphens, + word, + "-$word" + ) + ) + } else { + // hyphenated fields present, this word should be part of location + errors.add( + context.getString( + R.string.weather_error_field_in_location, + word, + "-$word" + ) + ) + } + } + } + + return errors +} + +/** + * Validates location string for field-like words that might indicate missing hyphens. + * + * @param location The extracted location string to validate + * @param context The [Context] instance for accessing string resources + * @return List of error messages, empty if location is valid + */ +fun validateLocationForFieldLikeWords(location: String, context: Context): List { + val locationWords = location.trim().split("\\s+".toRegex()) + val errors = mutableListOf() + + // check each word in location against known field names. todo: is this brittle? + locationWords.forEach { word -> + val normalizedWord = word.lowercase() + if (normalizedWord in VALID_WEATHER_FIELDS) { + errors.add( + context.getString( + R.string.weather_error_field_in_location, + word, + "-$word" + ) + ) + } + } + + return errors +} + +/** + * Checks if an invalid field might be multiple valid fields combined with hyphens. + * For example, "uv-temp" should be "-uv -temp". + * + * @param field The field name to check + * @param context The [Context] instance for accessing string resources + * @return List of error messages suggesting correct format, empty if not combined fields + */ +private fun checkForCombinedFields(field: String, context: Context): List { + val errors = mutableListOf() + + // split on hyphens and check if all parts are valid fields + val parts = field.split("-") + if (parts.size > 1) { + val allPartsValid = parts.all { it.lowercase() in VALID_WEATHER_FIELDS } + + if (allPartsValid) { + val suggestedFormat = parts.joinToString(" ") { "-$it" } + errors.add( + context.getString( + R.string.weather_error_combined_fields, + "-$field", + suggestedFormat + ) + ) + } + } + + return errors +} \ No newline at end of file diff --git a/app/src/main/java/com/coderGtm/yantra/network/HttpClientProvider.kt b/app/src/main/java/com/coderGtm/yantra/network/HttpClientProvider.kt new file mode 100644 index 00000000..aee22504 --- /dev/null +++ b/app/src/main/java/com/coderGtm/yantra/network/HttpClientProvider.kt @@ -0,0 +1,42 @@ +package com.coderGtm.yantra.network + +import android.util.Log +import com.coderGtm.yantra.BuildConfig +import io.ktor.client.HttpClient +import io.ktor.client.engine.android.Android +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +/** + * Provides a singleton HttpClient instance for the application. + * This ensures efficient resource usage and connection pool reuse across all network requests. + */ +object HttpClientProvider { + + val client: HttpClient by lazy { + HttpClient(Android) { + expectSuccess = true + + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + coerceInputValues = true + }) + } + + if (BuildConfig.DEBUG) { + install(Logging) { + logger = object : io.ktor.client.plugins.logging.Logger { + override fun log(message: String) { + Log.d("HTTP call", message) + } + } + level = LogLevel.ALL + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 98f91479..528399d6 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -522,8 +522,6 @@ Total de tokens utilizados en esta conversación: %1$s ¡El historial de mensajes de IA se ha restablecido! Sensación térmica: %1$.1f°C (%2$.1f°F)]]> - Probabilidad de precipitación: %1$d%%]]> - Probabilidad de nieve: %1$d%%]]> ¿Borrar la lista de inicio? ¿Estás seguro de que deseas borrar tu script de inicio? backup [-i] @@ -572,6 +570,87 @@ ¡No se encontró una nota con el nombre %1$s! \'%1$s\' eliminado del bloc de notas. Calidad del aire: %1$s]]> + Índice UV: %1$.1f]]> + Presión: %1$.1f mb]]> + Visibilidad: %1$.1f km]]> + Condición: %1$s]]> + Cobertura de nubes: %1$d%%]]> + Precipitación: %1$.1f mm]]> + Sensación térmica con viento: %1$.1f°C (%2$.1f°F)]]> + Índice de calor: %1$.1f°C (%2$.1f°F)]]> + Punto de rocío: %1$.1f°C (%2$.1f°F)]]> + Ráfaga de viento: %1$.1f kph (%2$.1f mph)]]> + Monóxido de carbono: %1$.1f μg/m³]]> + Dióxido de nitrógeno: %1$.1f μg/m³]]> + Ozono: %1$.1f μg/m³]]> + Dióxido de azufre: %1$.1f μg/m³]]> + PM2.5: %1$.1f μg/m³]]> + PM10: %1$.1f μg/m³]]> + Radiación solar: Corta=%1$.1f, Dif=%2$.1f, DNI=%3$.1f, GTI=%4$.1f]]> + Amanecer: %1$s]]> + Atardecer: %1$s]]> + Salida de luna: %1$s]]> + Puesta de luna: %1$s]]> + Fase lunar: %1$s]]> + Iluminación lunar: %1$d%%]]> + Temperatura promedio: %1$.1f°C (%2$.1f°F)]]> + Viento máximo: %1$.1f kph (%2$.1f mph)]]> + Precipitación total: %1$.1f mm]]> + Nieve total: %1$.1f cm]]> + Humedad promedio: %1$d%%]]> + Campo meteorológico desconocido: %1$s + %1$s: No disponible + Formato de comando inválido: + • Campo desconocido: \'%1$s\' + Campos meteorológicos disponibles (%1$d total): + \nUsa \'weather list\' para ver los campos disponibles. + Clima actual + Pronóstico + Calidad del aire + Astronomía + Temperatura (°C/°F) + Sensación térmica + Temperatura con viento + Índice de calor + Punto de rocío + Porcentaje de humedad + Velocidad y dirección del viento + Velocidad de ráfaga + Presión atmosférica + Índice UV + Distancia de visibilidad + Condición meteorológica + Porcentaje de nubes + Cantidad de precipitación + Radiación solar + Temperatura mínima + Temperatura máxima + Temperatura promedio + Velocidad máxima del viento + Precipitación total + Nevadas totales + Humedad promedio + Probabilidad de lluvia (si aplica) + Probabilidad de nieve (si aplica) + Índice de calidad del aire + Monóxido de carbono + Dióxido de nitrógeno + Ozono + Dióxido de azufre + Partículas PM2.5 + Partículas PM10 + Hora del amanecer + Hora del atardecer + Hora de salida de luna + Hora de puesta de luna + Fase lunar + Iluminación lunar % + Un solo guión \'-\' no es un formato de campo válido + \'%1$s\' usa guiones múltiples, usa formato de guión simple: %2$s + => Probabilidad de precipitación: %1$d%% + => Probabilidad de nieve: %1$d%% + Ejemplos: + Sensación térmica con viento: %1$s°C / %2$s°F "'%1$s' el comando recibe al menos un parametro. 0 parametros suministrados! " Reiniciar color del tema del texto de la terminal. Color del texto de la terminal establecido a #%1$s @@ -588,6 +667,20 @@ No hay editor de texto externo disponible El nombre del tema \'%1$s\' no está disponible. Por favor, elige otro nombre. Tema guardado como \'%1$s\'. Ahora puedes usarlo con el comando \'theme %2$s\'. + + + Error de configuración del servicio meteorológico + Por favor, proporciona una ubicación para consultar el clima + Formato de solicitud meteorológica inválido + Ubicación no encontrada. Por favor, verifica la ortografía e intenta de nuevo + Error de configuración del servicio meteorológico + Cuota del servicio meteorológico excedida. Por favor, intenta más tarde + Servicio meteorológico temporalmente no disponible + Acceso al servicio meteorológico restringido + Formato de solicitud meteorológica inválido + Demasiadas ubicaciones en la solicitud + Error interno del servicio meteorológico + Error desconocido del servicio meteorológico No hay temas para exportar. Guarda un tema primero para poder exportarlo. Elige un archivo de tema en formato JSON. No hay temas guardados para eliminar. @@ -602,4 +695,11 @@ Importado: %1$s Selecciona un tema para eliminar El tema %1$s se eliminó correctamente. + Buena + Moderada + Insalubre para grupos sensibles + Insalubre + Muy insalubre + Peligrosa + Desconocida diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 713b9d8b..9d2ca581 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -522,8 +522,8 @@ I token usati in totale per questa conversazione sono: %1$s La cronologia messaggi dell\'IA è stata eliminata! Temperatura percepita: %1$.1f°C (%2$.1f°F)]]> - Possibilità che piova: %1$d%%]]> - Possibilità che nevichi: %1$d%%]]> + => Possibilità che piova: %1$d%% + => Possibilità che nevichi: %1$d%% Cancellare la lista init? Sei sicuro di voler cancellare il tuo script init? backup [-i] @@ -572,6 +572,91 @@ Nessuna nota trovata con il nome %1$s! Eliminata \'%1$s\' dal blocco note. Qualità dell\'aria: %1$s]]> + Indice UV: %1$.1f]]> + Pressione: %1$.1f mb]]> + Visibilità: %1$.1f km]]> + Condizione: %1$s]]> + Copertura nuvolosa: %1$d%%]]> + Precipitazioni: %1$.1f mm]]> + Temperatura con vento: %1$.1f°C (%2$.1f°F)]]> + Indice di calore: %1$.1f°C (%2$.1f°F)]]> + Punto di rugiada: %1$.1f°C (%2$.1f°F)]]> + Raffica di vento: %1$.1f km/h (%2$.1f mph)]]> + Monossido di carbonio: %1$.1f μg/m³]]> + Biossido di azoto: %1$.1f μg/m³]]> + Ozono: %1$.1f μg/m³]]> + Biossido di zolfo: %1$.1f μg/m³]]> + PM2.5: %1$.1f μg/m³]]> + PM10: %1$.1f μg/m³]]> + Radiazione solare: Corta=%1$.1f, Diff=%2$.1f, DNI=%3$.1f, GTI=%4$.1f]]> + Alba: %1$s]]> + Tramonto: %1$s]]> + Sorgere della luna: %1$s]]> + Tramonto della luna: %1$s]]> + Fase lunare: %1$s]]> + Illuminazione lunare: %1$d%%]]> + Temperatura media: %1$.1f°C (%2$.1f°F)]]> + Vento massimo: %1$.1f km/h (%2$.1f mph)]]> + Precipitazioni totali: %1$.1f mm]]> + Neve totale: %1$.1f cm]]> + Umidità media: %1$d%%]]> + Campo meteo sconosciuto: %1$s + %1$s: Non disponibile + Formato comando non valido: + • Campo sconosciuto: \'%1$s\' + Campi meteo disponibili (%1$d totale): + \nUsa \'weather list\' per vedere i campi disponibili. + + + Meteo attuale + Previsioni + Qualità dell\'aria + Astronomia + + + Temperatura (°C/°F) + Temperatura percepita + Temperatura con vento + Indice di calore + Punto di rugiada + Percentuale di umidità + Velocità e direzione del vento + Raffica di vento + Pressione atmosferica + Indice UV + Distanza di visibilità + Condizione meteo + Percentuale di nuvolosità + Quantità di precipitazioni + Radiazione solare + Temperatura minima + Temperatura massima + Temperatura media + Velocità massima del vento + Precipitazioni totali + Nevicata totale + Umidità media + Possibilità di pioggia (se applicabile) + Possibilità di neve (se applicabile) + Indice di qualità dell\'aria + Monossido di carbonio + Biossido di azoto + Ozono + Biossido di zolfo + Particelle PM2.5 + Particelle PM10 + Ora dell\'alba + Ora del tramonto + Ora del sorgere della luna + Ora del tramonto della luna + Fase lunare + Illuminazione lunare % + Un singolo trattino \'-\' non è un formato di campo valido + \'%1$s\' usa trattini multipli, usa il formato con trattino singolo: %2$s + \'%1$s\' non è valido - gli argomenti devono venire prima dei campi meteo + \'%1$s\' sembra essere un campo meteo - intendevi \'%2$s\'? + \'%1$s\' sembra essere un campo meteo - prova \'%2$s\' invece + \'%1$s\' combina più campi - usa \'%2$s\' invece "'%1$s' il comando prende almeno 1 parametro. ne sono stati forniti 0! " Il colore del testo del terminale è stato reimpostato al tema. Il colore del testo del terminale è stato impostato a #%1$s @@ -582,7 +667,7 @@ Modello dell\'IA aggiornato! Non è possibile cambiare lo stato del Bluetooth in questa versione d Android a causa di restrizioni del sistema. Aggiornamento dello script fallito! - Seleziona un'opzione per modificare %1$s + Seleziona un\'opzione per modificare %1$s Launcher\'s editor Editor di testo esterno Nessun editor di testo disponibile @@ -602,4 +687,27 @@ %1$s importato Seleziona un tema da cancellare Tema %1$s rimosso con successo. + Esempi: + Temperatura con vento: %1$s°C / %2$s°F + Buona + Moderata + Malsana per gruppi sensibili + Malsana + Molto malsana + Pericolosa + Sconosciuta + + + Errore di configurazione del servizio meteorologico + Fornire una località per controllare il meteo + Formato di richiesta meteorologica non valido + Località non trovata. Controllare l\'ortografia e riprovare + Errore di configurazione del servizio meteorologico + Quota del servizio meteorologico superata. Riprovare più tardi + Servizio meteorologico temporaneamente non disponibile + Accesso al servizio meteorologico limitato + Formato di richiesta meteorologica non valido + Troppe località nella richiesta + Errore interno del servizio meteorologico + Errore sconosciuto del servizio meteorologico diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c8def914..904b4b98 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -522,8 +522,6 @@ Всего токенов было использовано при разговоре: %1$s История сообщений с AI очищена! Ощущается как: %1$.1f°C (%2$.1f°F)]]> - Вероятность осадков: %1$d%%]]> - Вероятность снега: %1$d%%]]> Очисть список Init? Вы уверены что вы хотите очистить ваш init скрипт? backup [-i] @@ -572,6 +570,87 @@ Заметка с именем %1$s не обнаженна! \'%1$s\' удалена из блокнота. Качество воздуха: %1$s]]> + УФ индекс: %1$.1f]]> + Давление: %1$.1f мб]]> + Видимость: %1$.1f км]]> + Состояние: %1$s]]> + Облачность: %1$d%%]]> + Осадки: %1$.1f мм]]> + Ощущается с ветром: %1$.1f°C (%2$.1f°F)]]> + Индекс жары: %1$.1f°C (%2$.1f°F)]]> + Точка росы: %1$.1f°C (%2$.1f°F)]]> + Порыв ветра: %1$.1f км/ч (%2$.1f миль/ч)]]> + Монооксид углерода: %1$.1f мкг/м³]]> + Диоксид азота: %1$.1f мкг/м³]]> + Озон: %1$.1f мкг/м³]]> + Диоксид серы: %1$.1f мкг/м³]]> + PM2.5: %1$.1f мкг/м³]]> + PM10: %1$.1f мкг/м³]]> + Солнечная радиация: Короткая=%1$.1f, Рассеянная=%2$.1f, DNI=%3$.1f, GTI=%4$.1f]]> + Восход: %1$s]]> + Закат: %1$s]]> + Восход луны: %1$s]]> + Заход луны: %1$s]]> + Фаза луны: %1$s]]> + Освещённость луны: %1$d%%]]> + Средняя температура: %1$.1f°C (%2$.1f°F)]]> + Максимальный ветер: %1$.1f км/ч (%2$.1f миль/ч)]]> + Общие осадки: %1$.1f мм]]> + Общий снег: %1$.1f см]]> + Средняя влажность: %1$d%%]]> + Неизвестное поле погоды: %1$s + %1$s: Недоступно + Неверный формат команды: + • Неизвестное поле: \'%1$s\' + Доступные поля погоды (%1$d всего): + \nИспользуйте \'weather list\' для просмотра доступных полей. + Текущая погода + Прогноз + Качество воздуха + Астрономия + Температура (°C/°F) + Ощущаемая температура + Температура с ветром + Индекс жары + Точка росы + Процент влажности + Скорость и направление ветра + Скорость порыва ветра + Атмосферное давление + УФ индекс + Дальность видимости + Погодные условия + Процент облачности + Количество осадков + Солнечная радиация + Минимальная температура + Максимальная температура + Средняя температура + Максимальная скорость ветра + Общие осадки + Общий снегопад + Средняя влажность + Вероятность дождя (если применимо) + Вероятность снега (если применимо) + Индекс качества воздуха + Монооксид углерода + Диоксид азота + Озон + Диоксид серы + Частицы PM2.5 + Частицы PM10 + Время восхода солнца + Время заката солнца + Время восхода луны + Время захода луны + Фаза луны + Освещённость луны % + Одиночный дефис \'-\' не является допустимым форматом поля + \'%1$s\' использует множественные дефисы, используйте формат с одним дефисом: %2$s + => Вероятность осадков: %1$d%% + => Вероятность снега: %1$d%% + Примеры: + Ощущается с ветром: %1$s°C / %2$s°F "Команда '%1$s' принимает как минимум 1 параметр. Было указано 0 параметров! " Цвет текста терминала сброшен до темы. Цвет текста треминала был изменён на #%1$s @@ -602,4 +681,27 @@ Импортировано: %1$s Выберите тему для удаления Тема %1$s успешно удалена. + + + Хороший + Умеренный + Нездоровый для чувствительных групп + Нездоровый + Очень нездоровый + Опасный + Неизвестный + + + Ошибка конфигурации службы погоды + Пожалуйста, укажите местоположение для проверки погоды + Неверный формат запроса погоды + Местоположение не найдено. Проверьте правописание и попробуйте снова + Ошибка конфигурации службы погоды + Превышена квота службы погоды. Попробуйте позже + Служба погоды временно недоступна + Доступ к службе погоды ограничен + Неверный формат запроса погоды + Слишком много местоположений в запросе + Внутренняя ошибка службы погоды + Неизвестная ошибка службы погоды diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index a6fb0ea6..f87d0885 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -522,8 +522,6 @@ Укупно коришћених токена у овом разговору: %1$s Историја порука са вештачком интелигенцијом је ресетована! Субјективни осећај: %1$.1f°C (%2$.1f°F)]]> - Вероватноћа падавина: %1$d%%]]> - Вероватноћа снега: %1$d%%]]> Очисти стартну листу? Да ли сте сигурни да желите да обришете свој стартни скрипт? backup [-i] @@ -572,7 +570,88 @@ Нема белешке са именом %1$s! Обрисана „%1$s” из бележнице. Квалитет ваздуха: %1$s]]> - Команда „%1$s” захтева бар један параметар. Није дат ниједан! + УВ индекс: %1$.1f]]> + Притисак: %1$.1f мб]]> + Видљивост: %1$.1f км]]> + Стање: %1$s]]> + Облачност: %1$d%%]]> + Падавине: %1$.1f мм]]> + Осећај са ветром: %1$.1f°C (%2$.1f°F)]]> + Индекс топлоте: %1$.1f°C (%2$.1f°F)]]> + Тачка росе: %1$.1f°C (%2$.1f°F)]]> + Налет ветра: %1$.1f км/ч (%2$.1f миља/ч)]]> + Угљен моноксид: %1$.1f μг/м³]]> + Азот диоксид: %1$.1f μг/м³]]> + Озон: %1$.1f μг/м³]]> + Сумпор диоксид: %1$.1f μг/м³]]> + PM2.5: %1$.1f μг/м³]]> + PM10: %1$.1f μг/м³]]> + Сунчево зрачење: Кратко=%1$.1f, Дифузно=%2$.1f, DNI=%3$.1f, GTI=%4$.1f]]> + Излазак сунца: %1$s]]> + Залазак сунца: %1$s]]> + Излазак месеца: %1$s]]> + Залазак месеца: %1$s]]> + Фаза месеца: %1$s]]> + Осветљеност месеца: %1$d%%]]> + Просечна температура: %1$.1f°C (%2$.1f°F)]]> + Максимални ветар: %1$.1f км/ч (%2$.1f миља/ч)]]> + Укупне падавине: %1$.1f мм]]> + Укупан снег: %1$.1f цм]]> + Просечна влажност: %1$d%%]]> + Непознато поље времена: %1$s + %1$s: Није доступно + Неисправан формат команде: + • Непознато поље: \'%1$s\' + Доступна поља времена (%1$d укупно): + \nКористите \'weather list\' да видите доступна поља. + Тренутно време + Прогноза + Квалитет ваздуха + Астрономија + Температура (°C/°F) + Осетна температура + Температура са ветром + Индекс топлоте + Тачка росе + Проценат влажности + Брзина и смер ветра + Брзина налета ветра + Атмосферски притисак + УВ индекс + Даљина видљивости + Временски услови + Проценат облачности + Количина падавина + Сунчево зрачење + Минимална температура + Максимална температура + Просечна температура + Максимална брзина ветра + Укупне падавине + Укупан снегопад + Просечна влажност + Вероватноћа кише (ако се односи) + Вероватноћа снега (ако се односи) + Индекс квалитета ваздуха + Угљен моноксид + Азот диоксид + Озон + Сумпор диоксид + Честице PM2.5 + Честице PM10 + Време изласка сунца + Време заласка сунца + Време изласка месеца + Време заласка месеца + Фаза месеца + Осветљеност месеца % + Једна цртица \'-\' није важећи формат поља + \'%1$s\' користи вишеструке цртице, користи формат са једном цртицом: %2$s + => Вероватноћа падавина: %1$d%% + => Вероватноћа снега: %1$d%% + Примери: + Осећај са ветром: %1$s°C / %2$s°F + Команда „%1$s" захтева бар један параметар. Није дат ниједан! Боја текста у терминалу је враћена на тему. Боја текста у терминалу постављена на #%1$s Нема додатих звучних ефеката! @@ -602,4 +681,27 @@ Увезено %1$s Изаберите тему за брисање Тема %1$s је успешно уклоњена. + + + Добар + Умерен + Нездрав за осетљиве групе + Нездрав + Веома нездрав + Опасан + Непознат + + + Грешка у конфигурацији временске службе + Молимо наведите локацију за проверу времена + Неисправан формат захтева за време + Локација није пронађена. Проверите правопис и покушајте поново + Грешка у конфигурацији временске службе + Прекорачена квота временске службе. Покушајте касније + Временска служба привремено недоступна + Приступ временској служби ограничен + Неисправан формат захтева за време + Превише локација у захтеву + Унутрашња грешка временске службе + Непозната грешка временске службе diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 235d1beb..a0db15d8 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -522,8 +522,6 @@ Всього використано токенів у цій розмові: %1$s Історія повідомлень AI була скинута! Відчувається як: %1$.1f°C (%2$.1f°F) ]]> - Ймовірність опадів: %1$d%% ]]> - Ймовірність снігу: %1$d%% ]]> Очистити список ініціалізації? Ви впевнені, що хочете очистити свій скрипт ініціалізації? backup [-i] @@ -572,6 +570,87 @@ Нотатку з назвою %1$s не знайдено! \'%1$s\' видалено з блокнота. Якість повітря: %1$s]]> + УФ індекс: %1$.1f]]> + Тиск: %1$.1f мб]]> + Видимість: %1$.1f км]]> + Стан: %1$s]]> + Хмарність: %1$d%%]]> + Опади: %1$.1f мм]]> + Відчувається з вітром: %1$.1f°C (%2$.1f°F)]]> + Індекс спеки: %1$.1f°C (%2$.1f°F)]]> + Точка роси: %1$.1f°C (%2$.1f°F)]]> + Поривистість вітру: %1$.1f км/год (%2$.1f миль/год)]]> + Монооксид вуглецю: %1$.1f мкг/м³]]> + Діоксид азоту: %1$.1f мкг/м³]]> + Озон: %1$.1f мкг/м³]]> + Діоксид сірки: %1$.1f мкг/м³]]> + PM2.5: %1$.1f мкг/м³]]> + PM10: %1$.1f мкг/м³]]> + Сонячна радіація: Коротка=%1$.1f, Дифузна=%2$.1f, DNI=%3$.1f, GTI=%4$.1f]]> + Схід сонця: %1$s]]> + Захід сонця: %1$s]]> + Схід місяця: %1$s]]> + Захід місяця: %1$s]]> + Фаза місяця: %1$s]]> + Освітленість місяця: %1$d%%]]> + Середня температура: %1$.1f°C (%2$.1f°F)]]> + Максимальний вітер: %1$.1f км/год (%2$.1f миль/год)]]> + Загальні опади: %1$.1f мм]]> + Загальний сніг: %1$.1f см]]> + Середня вологість: %1$d%%]]> + Невідоме поле погоди: %1$s + %1$s: Недоступне + Неправильний формат команди: + • Невідоме поле: \'%1$s\' + Доступні поля погоди (%1$d всього): + \nВикористовуйте \'weather list\' для перегляду доступних полів. + Поточна погода + Прогноз + Якість повітря + Астрономія + Температура (°C/°F) + Відчувається як + Температура з вітром + Індекс спеки + Точка роси + Відсоток вологості + Швидкість і напрямок вітру + Швидкість пориву вітру + Атмосферний тиск + УФ індекс + Дальність видимості + Погодні умови + Відсоток хмарності + Кількість опадів + Сонячна радіація + Мінімальна температура + Максимальна температура + Середня температура + Максимальна швидкість вітру + Загальні опади + Загальний снігопад + Середня вологість + Ймовірність дощу (якщо застосовується) + Ймовірність снігу (якщо застосовується) + Індекс якості повітря + Монооксид вуглецю + Діоксид азоту + Озон + Діоксид сірки + Частинки PM2.5 + Частинки PM10 + Час сходу сонця + Час заходу сонця + Час сходу місяця + Час заходу місяця + Фаза місяця + Освітленість місяця % + Одинарний дефіс \'-\' не є дійсним форматом поля + \'%1$s\' використовує множинні дефіси, використовуйте формат з одним дефісом: %2$s + => Ймовірність опадів: %1$d%% + => Ймовірність снігу: %1$d%% + Приклади: + Відчувається з вітром: %1$s°C / %2$s°F "Команда \'%1$s\' вимагає щонайменше 1 параметр. Надано 0!" Колір тексту термінала скинуто до теми. Колір тексту термінала змінено на #%1$s @@ -602,4 +681,27 @@ Імпортовано: %1$s Виберіть тему для видалення Тему %1$s успішно видалено. + + + Хороший + Помірний + Нездоровий для чутливих груп + Нездоровий + Дуже нездоровий + Небезпечний + Невідомий + + + Помилка конфігурації служби погоди + Будь ласка, вкажіть місцезнаходження для перевірки погоди + Неправильний формат запиту погоди + Місцезнаходження не знайдено. Перевірте правопис і спробуйте знову + Помилка конфігурації служби погоди + Перевищено квоту служби погоди. Спробуйте пізніше + Служба погоди тимчасово недоступна + Доступ до служби погоди обмежено + Неправильний формат запиту погоди + Занадто багато місцезнаходжень у запиті + Внутрішня помилка служби погоди + Невідома помилка служби погоди diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5246a12a..4922a2ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,8 +522,8 @@ Total Tokens used in this conversation: %1$s AI message history has been reset! Feels like: %1$.1f°C (%2$.1f°F)]]> - Precipitation chances: %1$d%%]]> - Snow chances: %1$d%%]]> + => Precipitation chance: %1$d%% + => Snow chance: %1$d%% Clear Init List? Are you sure you want to clear your init script? backup [-i] @@ -572,6 +572,105 @@ No note fond by the name %1$s! Deleted \'%1$s\' from notepad. Air Quality: %1$s]]> + UV Index: %1$.1f]]> + Pressure: %1$.1f mb]]> + Visibility: %1$.1f km]]> + Condition: %1$s]]> + Cloud Cover: %1$d%%]]> + Precipitation: %1$.1f mm]]> + Wind Chill: %1$.1f°C (%2$.1f°F)]]> + Heat Index: %1$.1f°C (%2$.1f°F)]]> + Dew Point: %1$.1f°C (%2$.1f°F)]]> + Wind Gust: %1$.1f kph (%2$.1f mph)]]> + Carbon Monoxide: %1$.1f μg/m³]]> + Nitrogen Dioxide: %1$.1f μg/m³]]> + Ozone: %1$.1f μg/m³]]> + Sulfur Dioxide: %1$.1f μg/m³]]> + PM2.5: %1$.1f μg/m³]]> + PM10: %1$.1f μg/m³]]> + Solar Radiation: Short=%1$.1f, Diff=%2$.1f, DNI=%3$.1f, GTI=%4$.1f]]> + Sunrise: %1$s]]> + Sunset: %1$s]]> + Moonrise: %1$s]]> + Moonset: %1$s]]> + Moon Phase: %1$s]]> + Moon Illumination: %1$d%%]]> + Average Temperature: %1$.1f°C (%2$.1f°F)]]> + Max Wind: %1$.1f kph (%2$.1f mph)]]> + Total Precipitation: %1$.1f mm]]> + Total Snow: %1$.1f cm]]> + Average Humidity: %1$d%%]]> + Unknown weather field: %1$s + %1$s: Not available + Invalid command format: + • Unknown field: \'%1$s\' + Available weather fields (%1$d total): + \nUse \'weather list\' to see available fields. + + + Current weather + Forecast + Air quality + Astronomy + + + Temperature (°C/°F) + Feels like temperature + Wind chill temperature + Heat index temperature + Dew point temperature + Humidity percentage + Wind speed and direction + Wind gust speed + Atmospheric pressure + UV index + Visibility distance + Weather condition + Cloud cover percentage + Precipitation amount + Solar radiation + Minimum temperature + Maximum temperature + Average temperature + Maximum wind speed + Total precipitation + Total snowfall + Average humidity + Rain chance (if applicable) + Snow chance (if applicable) + Air quality index + Carbon monoxide + Nitrogen dioxide + Ozone + Sulfur dioxide + PM2.5 particles + PM10 particles + Sunrise time + Sunset time + Moonrise time + Moonset time + Moon phase + Moon illumination % + Single hyphen \'-\' is not a valid field format + \'%1$s\' uses multiple hyphens, use single hyphen format: %2$s + \'%1$s\' is invalid - arguments must come before weather fields + \'%1$s\' appears to be a weather field - did you mean \'%2$s\'? + \'%1$s\' appears to be a weather field - try \'%2$s\' instead + \'%1$s\' combines multiple fields - use \'%2$s\' instead + + + Weather service configuration error + Please provide a location to check weather + Invalid weather request format + Location not found. Please check the spelling and try again + Weather service configuration error + Weather service quota exceeded. Please try again later + Weather service temporarily unavailable + Weather service access restricted + Invalid weather request format + Too many locations in request + Weather service internal error + Unknown weather service error "'%1$s' command takes at least 1 parameter. 0 provided! " Terminal text color reset to theme. Terminal text color set to #%1$s @@ -602,4 +701,13 @@ Imported %1$s Select theme to delete Removed %1$s theme successfully. + Examples: + Wind chill: %1$s°C / %2$s°F + Good + Moderate + Unhealthy for Sensitive Groups + Unhealthy + Very Unhealthy + Hazardous + Unknown \ No newline at end of file diff --git a/app/src/test/java/com/coderGtm/yantra/ExampleUnitTest.kt b/app/src/test/java/com/coderGtm/yantra/ExampleUnitTest.kt deleted file mode 100644 index a6afc73e..00000000 --- a/app/src/test/java/com/coderGtm/yantra/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.coderGtm.yantra - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/coderGtm/yantra/commands/weather/ExtractLocationAndFieldsTest.kt b/app/src/test/java/com/coderGtm/yantra/commands/weather/ExtractLocationAndFieldsTest.kt new file mode 100644 index 00000000..bb5b6596 --- /dev/null +++ b/app/src/test/java/com/coderGtm/yantra/commands/weather/ExtractLocationAndFieldsTest.kt @@ -0,0 +1,188 @@ +package com.coderGtm.yantra.commands.weather + +import org.junit.Test +import org.junit.Assert.* + +class ExtractLocationAndFieldsTest { + + @Test + fun `location only extracted correctly`() { + val args = listOf("london") + val result = extractLocationAndFields(args) + + assertEquals("london", result.location) + assertTrue(result.fields.isEmpty()) + } + + @Test + fun `location with fields extracted correctly`() { + val args = listOf("london", "-temp", "-humidity") + val result = extractLocationAndFields(args) + + assertEquals("london", result.location) + assertEquals(listOf("temp", "humidity"), result.fields) + } + + @Test + fun `multi word location extracted correctly`() { + val args = listOf("new", "york", "-temp") + val result = extractLocationAndFields(args) + + assertEquals("new york", result.location) + assertEquals(listOf("temp"), result.fields) + } + + @Test + fun `multi word location without fields`() { + val args = listOf("san", "francisco") + val result = extractLocationAndFields(args) + + assertEquals("san francisco", result.location) + assertTrue(result.fields.isEmpty()) + } + + @Test + fun `fields without hyphens ignored`() { + val args = listOf("tokyo", "temp", "-humidity", "wind", "-pressure") + val result = extractLocationAndFields(args) + + // non-hyphenated arguments before first "-" are considered part of location + assertEquals("tokyo temp", result.location) + assertEquals(listOf("humidity", "pressure"), result.fields) + } + + @Test + fun `empty args returns empty location`() { + val args = emptyList() + val result = extractLocationAndFields(args) + + assertEquals("", result.location) + assertTrue(result.fields.isEmpty()) + } + + @Test + fun `only fields with no location`() { + val args = listOf("-temp", "-humidity") + val result = extractLocationAndFields(args) + + assertEquals("", result.location) + assertEquals(listOf("temp", "humidity"), result.fields) + } + + @Test + fun `fields with empty names filtered out`() { + val args = listOf("london", "-temp", "-", "-humidity") + val result = extractLocationAndFields(args) + + assertEquals("london", result.location) + assertEquals(listOf("temp", "humidity"), result.fields) + } + + @Test + fun `complex location with multiple fields`() { + val args = listOf("los", "angeles", "california", "-temp", "-feels", "-wind", "-humidity") + val result = extractLocationAndFields(args) + + assertEquals("los angeles california", result.location) + assertEquals(listOf("temp", "feels", "wind", "humidity"), result.fields) + } + + @Test + fun `location with mixed field formats`() { + val args = listOf("chicago", "-temp", "sometext", "-humidity", "-wind") + val result = extractLocationAndFields(args) + + assertEquals("chicago", result.location) + assertEquals(listOf("temp", "humidity", "wind"), result.fields) + } + + @Test + fun `hyphenated location parsed correctly`() { + val args = listOf("winston-salem", "-temp", "-humidity") + val result = extractLocationAndFields(args) + assertEquals("winston-salem", result.location) + assertEquals(listOf("temp", "humidity"), result.fields) + } + + @Test + fun `unicode location names handled correctly`() { + val args = listOf("москва", "-temp") // Moscow in Russian + val result = extractLocationAndFields(args) + assertEquals("москва", result.location) + assertEquals(listOf("temp"), result.fields) + } + + @Test + fun `location with special characters`() { + val args = listOf("coeur", "d'alene", "-humidity") + val result = extractLocationAndFields(args) + assertEquals("coeur d'alene", result.location) + assertEquals(listOf("humidity"), result.fields) + } + + @Test + fun `very long location name`() { + val longLocation = listOf("lake", "chargoggagoggmanchauggagoggchaubunagungamaugg", "-temp") + val result = extractLocationAndFields(longLocation) + assertEquals("lake chargoggagoggmanchauggagoggchaubunagungamaugg", result.location) + assertEquals(listOf("temp"), result.fields) + } + + @Test + fun `maximum number of fields`() { + val manyFields = listOf("london") + VALID_WEATHER_FIELDS.map { "-$it" } + val result = extractLocationAndFields(manyFields) + assertEquals("london", result.location) + assertEquals(VALID_WEATHER_FIELDS.toList(), result.fields) + } + + @Test + fun `duplicate fields are preserved`() { + val args = listOf("london", "-temp", "-humidity", "-temp", "-wind") + val result = extractLocationAndFields(args) + assertEquals("london", result.location) + assertEquals(listOf("temp", "humidity", "temp", "wind"), result.fields) + } + + @Test + fun `fields with numbers in location name`() { + val args = listOf("highway", "101", "exit", "23", "-temp") + val result = extractLocationAndFields(args) + assertEquals("highway 101 exit 23", result.location) + assertEquals(listOf("temp"), result.fields) + } + + @Test + fun `location with punctuation`() { + val args = listOf("st.", "john's", "-wind", "-temp") + val result = extractLocationAndFields(args) + assertEquals("st. john's", result.location) + assertEquals(listOf("wind", "temp"), result.fields) + } + + @Test + fun `single character location parts`() { + val args = listOf("a", "b", "c", "-humidity") + val result = extractLocationAndFields(args) + assertEquals("a b c", result.location) + assertEquals(listOf("humidity"), result.fields) + } + + @Test + fun `malformed input with multiple consecutive hyphens`() { + val args = listOf("london", "---temp", "-humidity") + val result = extractLocationAndFields(args) + assertEquals("london", result.location) + // Function extracts what it can - removePrefix only removes first hyphen + assertEquals(listOf("--temp", "humidity"), result.fields) + } + + @Test + fun `fields at start followed by location`() { + val args = listOf("-temp", "-humidity", "london", "england") + val result = extractLocationAndFields(args) + assertEquals("", result.location) // No location before first field + assertEquals(listOf("temp", "humidity"), result.fields) + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherCommandParsingTest.kt b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherCommandParsingTest.kt new file mode 100644 index 00000000..cf606061 --- /dev/null +++ b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherCommandParsingTest.kt @@ -0,0 +1,173 @@ +package com.coderGtm.yantra.commands.weather + +import android.content.Context +import com.coderGtm.yantra.R +import org.junit.Test +import org.junit.Assert.* +import org.mockito.kotlin.* + +class WeatherCommandParsingTest { + + private fun createMockContext(): Context { + val mockContext = mock() + + // Mock string resources with generic returns + whenever(mockContext.getString(eq(R.string.weather_error_single_hyphen))) + .thenReturn("Single hyphen error") + whenever(mockContext.getString(eq(R.string.weather_error_field_in_location_no_hyphens), any(), any())) + .thenReturn("Field in location without hyphens error") + whenever(mockContext.getString(eq(R.string.weather_error_field_in_location), any(), any())) + .thenReturn("Field in location error") + whenever(mockContext.getString(eq(R.string.weather_error_multiple_hyphens), any(), any())) + .thenReturn("Multiple hyphens error") + whenever(mockContext.getString(eq(R.string.weather_error_misplaced_argument), any())) + .thenReturn("Misplaced argument error") + whenever(mockContext.getString(eq(R.string.weather_error_combined_fields), any(), any())) + .thenReturn("Combined fields error") + + return mockContext + } + + @Test + fun `parseWeatherCommand returns Success for valid location and fields`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather london -temp -humidity", mockContext) + + assertTrue("Should return Success", result is ParseResult.Success) + val success = result as ParseResult.Success + assertEquals("london", success.args.location) + assertEquals(listOf("temp", "humidity"), success.args.requestedFields) + assertFalse("Should not show default fields", success.args.showDefaultFields) + } + + @Test + fun `parseWeatherCommand returns Success for location only`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather new york", mockContext) + + assertTrue("Should return Success", result is ParseResult.Success) + val success = result as ParseResult.Success + assertEquals("new york", success.args.location) + assertTrue("Should be empty fields", success.args.requestedFields.isEmpty()) + assertTrue("Should show default fields when no fields specified", success.args.showDefaultFields) + } + + @Test + fun `parseWeatherCommand returns MissingLocation for too few arguments`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather", mockContext) + + assertTrue("Should return MissingLocation", result is ParseResult.MissingLocation) + } + + @Test + fun `parseWeatherCommand returns ListCommand for list argument`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather list", mockContext) + + assertTrue("Should return ListCommand", result is ParseResult.ListCommand) + } + + @Test + fun `parseWeatherCommand returns MissingLocation when only fields provided`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather -temp -humidity", mockContext) + + assertTrue("Should return MissingLocation", result is ParseResult.MissingLocation) + } + + @Test + fun `parseWeatherCommand returns ValidationError for field-like words in location`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather london temp -humidity", mockContext) + + assertTrue("Should return ValidationError", result is ParseResult.ValidationError) + val error = result as ParseResult.ValidationError + assertEquals(1, error.formatErrors.size) + assertTrue("Should contain field in location error", + error.formatErrors.any { it.contains("Field in location") }) + } + + @Test + fun `parseWeatherCommand returns ValidationError for invalid field format`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather london --temp", mockContext) + + assertTrue("Should return ValidationError", result is ParseResult.ValidationError) + val error = result as ParseResult.ValidationError + assertEquals(1, error.formatErrors.size) + assertTrue("Should contain multiple hyphens error", + error.formatErrors.any { it.contains("Multiple hyphens") }) + } + + @Test + fun `parseWeatherCommand returns ValidationError for single hyphen`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather london -", mockContext) + + assertTrue("Should return ValidationError", result is ParseResult.ValidationError) + val error = result as ParseResult.ValidationError + assertEquals(1, error.formatErrors.size) + assertTrue("Should contain single hyphen error", + error.formatErrors.any { it.contains("Single hyphen") }) + } + + @Test + fun `parseWeatherCommand returns ValidationError for invalid field names`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather london -invalidfield", mockContext) + + assertTrue("Should return ValidationError", result is ParseResult.ValidationError) + val error = result as ParseResult.ValidationError + assertEquals(1, error.invalidFields.size) + assertEquals("invalidfield", error.invalidFields[0]) + } + + @Test + fun `parseWeatherCommand handles mixed validation errors`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather london -temp --humidity -invalidfield", mockContext) + + assertTrue("Should return ValidationError", result is ParseResult.ValidationError) + val error = result as ParseResult.ValidationError + assertEquals(1, error.formatErrors.size) // --humidity format error + assertEquals(1, error.invalidFields.size) // invalidfield + assertTrue("Should contain multiple hyphens error", + error.formatErrors.any { it.contains("Multiple hyphens") }) + assertEquals("invalidfield", error.invalidFields[0]) + } + + @Test + fun `parseWeatherCommand handles complex multi-word locations`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather los angeles california -temp -wind", mockContext) + + assertTrue("Should return Success", result is ParseResult.Success) + val success = result as ParseResult.Success + assertEquals("los angeles california", success.args.location) + assertEquals(listOf("temp", "wind"), success.args.requestedFields) + } + + @Test + fun `parseWeatherCommand handles hyphenated location names`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather winston-salem -temp", mockContext) + + assertTrue("Should return Success", result is ParseResult.Success) + val success = result as ParseResult.Success + assertEquals("winston-salem", success.args.location) + assertEquals(listOf("temp"), success.args.requestedFields) + } + + @Test + fun `parseWeatherCommand early validation catches field-like words without hyphens`() { + val mockContext = createMockContext() + val result = parseWeatherCommand("weather london temp humidity", mockContext) + + assertTrue("Should return ValidationError", result is ParseResult.ValidationError) + val error = result as ParseResult.ValidationError + assertEquals(2, error.formatErrors.size) // Should catch both "temp" and "humidity" + assertTrue("Should suggest adding hyphens", + error.formatErrors.all { it.contains("without hyphens") }) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherFieldValidationTest.kt b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherFieldValidationTest.kt new file mode 100644 index 00000000..884e3008 --- /dev/null +++ b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherFieldValidationTest.kt @@ -0,0 +1,83 @@ +package com.coderGtm.yantra.commands.weather + +import org.junit.Test +import org.junit.Assert.* + +class WeatherFieldValidationTest { + + @Test + fun `all field keys are unique across categories`() { + val allFieldKeys = WEATHER_FIELD_CATEGORIES.flatMap { it.fields.map { field -> field.key } } + val uniqueKeys = allFieldKeys.toSet() + + assertEquals( + "All field keys should be unique", + allFieldKeys.size, + uniqueKeys.size + ) + } + + @Test + fun `VALID_WEATHER_FIELDS contains all expected fields`() { + val allFieldKeys = WEATHER_FIELD_CATEGORIES.flatMap { it.fields.map { field -> field.key } } + + assertEquals( + "VALID_WEATHER_FIELDS should contain all field keys", + allFieldKeys.toSet(), + VALID_WEATHER_FIELDS + ) + } + + @Test + fun `DEFAULT_WEATHER_FIELDS matches isDefault flag`() { + val expectedDefaults = WEATHER_FIELD_CATEGORIES + .flatMap { it.fields } + .filter { it.isDefault } + .map { it.key } + .toSet() + + assertEquals( + "DEFAULT_WEATHER_FIELDS should match fields with isDefault=true", + expectedDefaults, + DEFAULT_WEATHER_FIELDS.toSet() + ) + } + + @Test + fun `WEATHER_FIELD_MAP maps all fields correctly`() { + val allFields = WEATHER_FIELD_CATEGORIES.flatMap { it.fields } + + // Check all fields are in the map + allFields.forEach { field -> + assertTrue( + "Field '${field.key}' should be in WEATHER_FIELD_MAP", + WEATHER_FIELD_MAP.containsKey(field.key) + ) + assertEquals( + "Field object should match in WEATHER_FIELD_MAP", + field, + WEATHER_FIELD_MAP[field.key] + ) + } + + // Check map doesn't contain extra fields + assertEquals( + "WEATHER_FIELD_MAP should contain exactly the same fields", + allFields.size, + WEATHER_FIELD_MAP.size + ) + } + + @Test + fun `renderer functions are not null`() { + val allFields = WEATHER_FIELD_CATEGORIES.flatMap { it.fields } + + allFields.forEach { field -> + assertNotNull( + "Field '${field.key}' should have a non-null renderer", + field.renderer + ) + } + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherHelperTest.kt b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherHelperTest.kt new file mode 100644 index 00000000..358b31e6 --- /dev/null +++ b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherHelperTest.kt @@ -0,0 +1,114 @@ +package com.coderGtm.yantra.commands.weather + +import com.coderGtm.yantra.R +import org.junit.Test +import org.junit.Assert.* + +class WeatherHelperTest { + + @Test + fun `getWeatherApiErrorStringRes with known error code 1006 returns location not found`() { + val apiError = WeatherApiError(1006, "Location not found") + val statusCode = 400 + val result = getWeatherApiErrorStringRes(apiError, statusCode) + assertEquals(R.string.weather_location_not_found, result) + } + + @Test + fun `getWeatherApiErrorStringRes with known error code 2007 returns quota exceeded`() { + val apiError = WeatherApiError(2007, "Quota exceeded") + val statusCode = 403 + val result = getWeatherApiErrorStringRes(apiError, statusCode) + assertEquals(R.string.weather_quota_exceeded, result) + } + + @Test + fun `getWeatherApiErrorStringRes with all known error codes returns correct string resources`() { + val testCases = mapOf( + 1002 to R.string.weather_api_key_not_provided, + 1003 to R.string.weather_location_parameter_missing, + 1005 to R.string.weather_api_request_invalid, + 1006 to R.string.weather_location_not_found, + 2006 to R.string.weather_api_key_invalid, + 2007 to R.string.weather_quota_exceeded, + 2008 to R.string.weather_api_disabled, + 2009 to R.string.weather_api_access_restricted, + 9000 to R.string.weather_bulk_request_invalid, + 9001 to R.string.weather_bulk_too_many_locations, + 9999 to R.string.weather_internal_error + ) + + testCases.forEach { (errorCode, expectedStringRes) -> + val apiError = WeatherApiError(errorCode, "Test error") + val statusCode = 400 + val result = getWeatherApiErrorStringRes(apiError, statusCode) + assertEquals( + "Error code $errorCode should map to correct string resource", + expectedStringRes, + result + ) + } + } + + @Test + fun `getWeatherApiErrorStringRes with unknown error code falls back to status code mapping`() { + val testCases = mapOf( + 400 to R.string.weather_location_not_found, + 401 to R.string.weather_api_key_invalid, + 403 to R.string.weather_quota_exceeded, + 500 to R.string.weather_unknown_error // Unknown status code + ) + + testCases.forEach { (statusCode, expectedStringRes) -> + val apiError = WeatherApiError(9998, "Unknown error") + val result = getWeatherApiErrorStringRes(apiError, statusCode) + assertEquals( + "Status code $statusCode should map to correct string resource", + expectedStringRes, + result + ) + } + } + + @Test + fun `getWeatherApiErrorStringRes with null apiError falls back to status code mapping`() { + val statusCode = 401 + val result = getWeatherApiErrorStringRes(null, statusCode) + assertEquals(R.string.weather_api_key_invalid, result) + } + + @Test + fun `getWeatherApiErrorStringRes with null apiError and unknown status code returns unknown error`() { + val statusCode = 502 + val result = getWeatherApiErrorStringRes(null, statusCode) + assertEquals(R.string.weather_unknown_error, result) + } + + @Test + fun `getWeatherApiErrorStringRes with zero error code falls back to status code mapping`() { + val zeroCodeError = WeatherApiError(0, "Zero code") + val statusCode = 400 + val result = getWeatherApiErrorStringRes(zeroCodeError, statusCode) + assertEquals(R.string.weather_location_not_found, result) + } + + @Test + fun `getWeatherApiErrorStringRes with negative error code falls back to status code mapping`() { + val negativeCodeError = WeatherApiError(-1, "Negative code") + val statusCode = 401 + val result = getWeatherApiErrorStringRes(negativeCodeError, statusCode) + assertEquals(R.string.weather_api_key_invalid, result) + } + + @Test + fun `getWeatherApiErrorStringRes with large error code falls back to status code mapping`() { + val largeCodeError = WeatherApiError(99999, "Large code") + val statusCode = 403 + val result = getWeatherApiErrorStringRes(largeCodeError, statusCode) + assertEquals(R.string.weather_quota_exceeded, result) + } + + //-------------------------- WRITE OTHER TESTS BELOW THIS LINE -----------------------// + + +} \ No newline at end of file diff --git a/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherModelsTest.kt b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherModelsTest.kt new file mode 100644 index 00000000..c7e9fe43 --- /dev/null +++ b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherModelsTest.kt @@ -0,0 +1,51 @@ +package com.coderGtm.yantra.commands.weather + +import org.junit.Test +import org.junit.Assert.* + +class WeatherModelsTest { + + @Test + fun `ValidationResult isValid returns true for valid result`() { + val result = ValidationResult( + validFields = listOf("temp", "humidity"), + invalidFields = emptyList(), + formatErrors = emptyList() + ) + + assertTrue("Should be valid when no errors", result.isValid()) + } + + @Test + fun `ValidationResult isValid returns false when format errors exist`() { + val result = ValidationResult( + validFields = listOf("temp"), + invalidFields = emptyList(), + formatErrors = listOf("Invalid format") + ) + + assertFalse("Should be invalid when format errors exist", result.isValid()) + } + + @Test + fun `ValidationResult isValid returns false when invalid fields exist`() { + val result = ValidationResult( + validFields = listOf("temp"), + invalidFields = listOf("invalid"), + formatErrors = emptyList() + ) + + assertFalse("Should be invalid when invalid fields exist", result.isValid()) + } + + @Test + fun `ValidationResult isValid returns false when both errors exist`() { + val result = ValidationResult( + validFields = listOf("temp"), + invalidFields = listOf("invalid"), + formatErrors = listOf("Format error") + ) + + assertFalse("Should be invalid when both types of errors exist", result.isValid()) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherValidationHelperTest.kt b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherValidationHelperTest.kt new file mode 100644 index 00000000..7db67578 --- /dev/null +++ b/app/src/test/java/com/coderGtm/yantra/commands/weather/WeatherValidationHelperTest.kt @@ -0,0 +1,249 @@ +package com.coderGtm.yantra.commands.weather + +import android.content.Context +import com.coderGtm.yantra.R +import org.junit.Test +import org.junit.Assert.* +import org.mockito.kotlin.* + +class WeatherValidationHelperTest { + + private fun createMockContext(): Context { + val mockContext = mock() + + // Mock string resources with generic returns + whenever(mockContext.getString(eq(R.string.weather_error_single_hyphen))) + .thenReturn("Single hyphen error") + whenever(mockContext.getString(eq(R.string.weather_error_field_in_location_no_hyphens), any(), any())) + .thenReturn("Field in location without hyphens error") + whenever(mockContext.getString(eq(R.string.weather_error_field_in_location), any(), any())) + .thenReturn("Field in location error") + whenever(mockContext.getString(eq(R.string.weather_error_multiple_hyphens), any(), any())) + .thenReturn("Multiple hyphens error") + whenever(mockContext.getString(eq(R.string.weather_error_misplaced_argument), any())) + .thenReturn("Misplaced argument error") + whenever(mockContext.getString(eq(R.string.weather_error_combined_fields), any(), any())) + .thenReturn("Combined fields error") + + return mockContext + } + + // Tests for validateWeatherFields function + @Test + fun `validateWeatherFields correctly categorizes valid fields`() { + val mockContext = createMockContext() + val result = validateWeatherFields(listOf("-temp", "-humidity", "-wind"), mockContext) + + assertEquals(listOf("temp", "humidity", "wind"), result.validFields) + assertTrue("Should have no invalid fields", result.invalidFields.isEmpty()) + assertTrue("Should have no format errors", result.formatErrors.isEmpty()) + assertTrue("Should be valid", result.isValid()) + } + + @Test + fun `validateWeatherFields detects invalid field names`() { + val mockContext = createMockContext() + val result = validateWeatherFields(listOf("-temp", "-invalidfield", "-humidity"), mockContext) + + assertEquals(listOf("temp", "humidity"), result.validFields) + assertEquals(listOf("invalidfield"), result.invalidFields) + assertTrue("Should have no format errors", result.formatErrors.isEmpty()) + assertFalse("Should not be valid", result.isValid()) + } + + @Test + fun `validateWeatherFields detects single hyphen format error`() { + val mockContext = createMockContext() + val result = validateWeatherFields(listOf("-temp", "-", "-humidity"), mockContext) + + assertEquals(listOf("temp", "humidity"), result.validFields) + assertTrue("Should have no invalid fields", result.invalidFields.isEmpty()) + assertEquals(1, result.formatErrors.size) + assertTrue("Should contain single hyphen error", + result.formatErrors.any { it.contains("Single hyphen") }) + assertFalse("Should not be valid", result.isValid()) + } + + @Test + fun `validateWeatherFields detects multiple hyphens format error`() { + val mockContext = createMockContext() + val result = validateWeatherFields(listOf("-temp", "--humidity", "-wind"), mockContext) + + assertEquals(listOf("temp", "wind"), result.validFields) + assertTrue("Should have no invalid fields", result.invalidFields.isEmpty()) + assertEquals(1, result.formatErrors.size) + assertTrue("Should contain multiple hyphens error", + result.formatErrors.any { it.contains("Multiple hyphens") }) + assertFalse("Should not be valid", result.isValid()) + } + + @Test + fun `validateWeatherFields detects misplaced non-hyphenated arguments`() { + val mockContext = createMockContext() + val result = validateWeatherFields(listOf("-temp", "misplaced", "-humidity"), mockContext) + + assertEquals(listOf("temp", "humidity"), result.validFields) + assertTrue("Should have no invalid fields", result.invalidFields.isEmpty()) + assertEquals(1, result.formatErrors.size) + assertTrue("Should contain misplaced argument error", + result.formatErrors.any { it.contains("Misplaced argument") }) + assertFalse("Should not be valid", result.isValid()) + } + + @Test + fun `validateWeatherFields handles mixed validation errors`() { + val mockContext = createMockContext() + val result = validateWeatherFields( + listOf("-temp", "--badformat", "-", "misplaced", "-invalidfield"), + mockContext + ) + + assertEquals(listOf("temp"), result.validFields) + assertEquals(listOf("invalidfield"), result.invalidFields) + assertEquals(3, result.formatErrors.size) // --badformat, -, misplaced + assertFalse("Should not be valid", result.isValid()) + } + + @Test + fun `validateWeatherFields handles empty input`() { + val mockContext = createMockContext() + val result = validateWeatherFields(emptyList(), mockContext) + + assertTrue("Should have no valid fields", result.validFields.isEmpty()) + assertTrue("Should have no invalid fields", result.invalidFields.isEmpty()) + assertTrue("Should have no format errors", result.formatErrors.isEmpty()) + assertTrue("Should be valid", result.isValid()) + } + + // Tests for validateRawArgsForFieldLikeWords function + @Test + fun `validateRawArgsForFieldLikeWords detects field-like words in location`() { + val mockContext = createMockContext() + val result = validateRawArgsForFieldLikeWords( + listOf("london", "temp", "humidity", "-pressure"), + mockContext + ) + + assertEquals(2, result.size) // Should detect "temp" and "humidity" + assertTrue("Should contain field in location errors", + result.all { it.contains("Field in location") }) + } + + @Test + fun `validateRawArgsForFieldLikeWords suggests hyphens when no fields present`() { + val mockContext = createMockContext() + val result = validateRawArgsForFieldLikeWords( + listOf("london", "temp", "humidity"), + mockContext + ) + + assertEquals(2, result.size) // Should detect "temp" and "humidity" + assertTrue("Should suggest adding hyphens", + result.all { it.contains("without hyphens") }) + } + + @Test + fun `validateRawArgsForFieldLikeWords ignores first word as location`() { + val mockContext = createMockContext() + val result = validateRawArgsForFieldLikeWords( + listOf("temp", "humidity", "-pressure"), // "temp" is first word (location) + mockContext + ) + + assertEquals(1, result.size) // Should only detect "humidity", not "temp" + } + + @Test + fun `validateRawArgsForFieldLikeWords ignores non-field words`() { + val mockContext = createMockContext() + val result = validateRawArgsForFieldLikeWords( + listOf("new", "york", "city", "-temp"), + mockContext + ) + + assertEquals(0, result.size) // "york" and "city" are not weather fields + } + + @Test + fun `validateRawArgsForFieldLikeWords handles case insensitive detection`() { + val mockContext = createMockContext() + val result = validateRawArgsForFieldLikeWords( + listOf("london", "TEMP", "Humidity", "-pressure"), + mockContext + ) + + assertEquals(2, result.size) // Should detect "TEMP" and "Humidity" + } + + @Test + fun `validateRawArgsForFieldLikeWords handles no hyphenated fields`() { + val mockContext = createMockContext() + val result = validateRawArgsForFieldLikeWords( + listOf("london", "temp"), + mockContext + ) + + assertEquals(1, result.size) + assertTrue("Should suggest adding hyphens when no fields present", + result[0].contains("without hyphens")) + } + + @Test + fun `validateRawArgsForFieldLikeWords handles empty input`() { + val mockContext = createMockContext() + val result = validateRawArgsForFieldLikeWords(emptyList(), mockContext) + + assertEquals(0, result.size) + } + + // Tests for validateLocationForFieldLikeWords function + @Test + fun `validateLocationForFieldLikeWords detects field names in location string`() { + val mockContext = createMockContext() + val result = validateLocationForFieldLikeWords("london temp humidity", mockContext) + + assertEquals(2, result.size) // Should detect "temp" and "humidity" + assertTrue("Should contain field in location errors", + result.all { it.contains("Field in location") }) + } + + @Test + fun `validateLocationForFieldLikeWords handles case insensitive detection`() { + val mockContext = createMockContext() + val result = validateLocationForFieldLikeWords("london TEMP Humidity", mockContext) + + assertEquals(2, result.size) // Should detect "TEMP" and "Humidity" + } + + @Test + fun `validateLocationForFieldLikeWords ignores non-field words`() { + val mockContext = createMockContext() + val result = validateLocationForFieldLikeWords("new york city", mockContext) + + assertEquals(0, result.size) // None of these are weather fields + } + + @Test + fun `validateLocationForFieldLikeWords handles empty location`() { + val mockContext = createMockContext() + val result = validateLocationForFieldLikeWords("", mockContext) + + assertEquals(0, result.size) + } + + @Test + fun `validateLocationForFieldLikeWords handles single word location`() { + val mockContext = createMockContext() + val result = validateLocationForFieldLikeWords("temp", mockContext) + + assertEquals(1, result.size) // Should detect "temp" as field-like + } + + @Test + fun `validateLocationForFieldLikeWords handles whitespace normalization`() { + val mockContext = createMockContext() + val result = validateLocationForFieldLikeWords(" london temp humidity ", mockContext) + + assertEquals(2, result.size) // Should handle extra whitespace properly + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 00000000..7299988c --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,21 @@ +[versions] +ktor = "2.3.12" # ideally we'd use the latest version but this would require updating to kotlin 2+ +mockitoCore = "5.19.0" +mockitoInline = "5.2.0" +mockitoKotlin = "4.1.0" # later versions require using jvm 11 and a higher version of kotlin + +[libraries] +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } # transitively brings in kotlinx-serialization-json +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } + +[bundles] +ktor = ["ktor-client-android", "ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-client-logging"] +mockito = ["mockito-core", "mockito-inline", "mockito-kotlin"] + +[plugins] +