diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index 0470ba1..d7eca57 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -108,3 +108,10 @@ Antes de distribuir una APK productiva: ### Alcance real Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final. + +### Segunda pasada funcional + +- Coordenadas normalizadas al contrato API (6 decimales, precisión 3). +- Yacimiento seleccionable para Hallazgos y como padre explícito de nuevas Instalaciones. Se elimina el fallback que podía presentar un tipo Yacimiento como alta de Instalación. +- Formulario Datos técnicos sobre el elemento seleccionado: carga y guarda las definiciones/valores por familia mediante los endpoints existentes del Dashboard; valida obligatorios, números, Sí/No, fechas y opciones. Es un paso separado del alta estructural y GPS/foto. +- Se agregan siete pruebas para coordenadas y valores técnicos. diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index 0e18317..2ed20e4 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.korexlabs.dhinspeccion.data.FieldCoordinates import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest import com.korexlabs.dhinspeccion.data.DhRepository @@ -271,9 +272,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { commonName = commonName?.trim()?.takeIf { it.isNotBlank() }, description = description?.trim()?.takeIf { it.isNotBlank() }, attributes = attributes, - deviceLatitude = latitude, - deviceLongitude = longitude, - deviceAccuracyM = accuracyM, + deviceLatitude = FieldCoordinates.latitude(latitude), + deviceLongitude = FieldCoordinates.longitude(longitude), + deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy), deviceCapturedAt = Instant.now().toString(), ) selectedFieldAsset = repository.createFieldAsset(visitId, request) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt index 03861ee..1c95deb 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/DhMobile.kt @@ -527,13 +527,13 @@ class DhRepository(context: Context) { visitId = visitId, assetId = assetId, file = part, - latitude = latitude.toString().toRequestBody(text), - longitude = longitude.toString().toRequestBody(text), - accuracy = accuracyM?.toString()?.toRequestBody(text), + latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), + longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), + accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text), - exifLatitude = latitude.toString().toRequestBody(text), - exifLongitude = longitude.toString().toRequestBody(text), + exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), + exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), exifCapturedAt = capturedAt.toRequestBody(text), ) } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldCoordinates.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldCoordinates.kt new file mode 100644 index 0000000..3f1a388 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldCoordinates.kt @@ -0,0 +1,14 @@ +package com.korexlabs.dhinspeccion.data + +import java.math.BigDecimal +import java.math.RoundingMode + +internal object FieldCoordinates { + private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double { + require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." } + return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble() + } + fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0) + fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0) + fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0) +} diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt index 1c33037..f90b88f 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt @@ -224,9 +224,9 @@ class FieldFindingsRepository(context: Context) { title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), capturedAt = capturedAt.toRequestBody(text), - latitude = latitude.toString().toRequestBody(text), - longitude = longitude.toString().toRequestBody(text), - accuracyM = accuracyM?.toString()?.toRequestBody(text), + latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text), + longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text), + accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text), deviceLabel = "DH Android".toRequestBody(text), ) } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt index e109716..a7d2d78 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -426,9 +426,9 @@ class MobileActsRepository(context: Context) { val consent = "true".toRequestBody(text) val signedAt = Instant.now().toString().toRequestBody(text) val device = "DH Android".toRequestBody(text) - val lat = latitude?.toString()?.toRequestBody(text) - val lon = longitude?.toString()?.toRequestBody(text) - val accuracy = accuracyM?.toString()?.toRequestBody(text) + val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text) + val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text) + val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text) if (company) { api.signCompany( "Bearer ${session.accessToken}", actId, file, consent, signedAt, diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/TechnicalValues.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/TechnicalValues.kt new file mode 100644 index 0000000..eaaa77d --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/TechnicalValues.kt @@ -0,0 +1,64 @@ +package com.korexlabs.dhinspeccion.data + +import android.content.Context +import com.korexlabs.dhinspeccion.BuildConfig +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.* +import java.time.LocalDate +import java.time.OffsetDateTime + +internal data class TechnicalDefinition( + val id: String, val code: String, val name: String, val dataType: String, + val isRequired: Boolean = false, val isActive: Boolean = true, + val unit: String? = null, val options: List? = null, +) +internal data class TechnicalValues( + val assetId: String, val family: FieldInventoryFamily, + val definitions: List, val values: Map, +) +internal data class TechnicalValuesRequest(val values: Map) +private interface TechnicalApi { + @GET("assets/{id}/technical-values") + suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues + @PUT("assets/{id}/technical-values") + suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues + @POST("auth/mobile/refresh") + suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse +} +internal class TechnicalRepository(context: Context) { + private val store = SecureSessionStore(context.applicationContext) + private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL) + .addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build())) + .build().create(TechnicalApi::class.java) + suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) } + suspend fun save(id: String, values: Map) = authorized { + api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values)) + } + private suspend fun authorized(block: suspend (StoredSession) -> T): T { + val previous = store.load() ?: error("Sesión no iniciada") + try { return block(previous) } + catch (error: HttpException) { if (error.code() != 401) throw error } + val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) + return block(current) + } +} + +/** Keeps IDs and typed JSON values identical to the Dashboard contract. */ +internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? { + val value = text.trim() + require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." } + if (value.isBlank()) return null + fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.") + return when (definition.dataType) { + "NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid() + "BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() } + "DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() } + "DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() } + "SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid() + else -> value.takeIf { it.length <= 4000 } ?: invalid() + } +} diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt index 7cb02f1..0e56467 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt @@ -329,13 +329,13 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) model.loadFieldTypes(null) } - fun startInstallation() { + fun startInstallation(parent: FieldInventoryItem? = null) { model.clearSelectedFieldAsset() modeName = ModernInventoryMode.CREATE_INSTALLATION.name - parentId = null - parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección" + parentId = parent?.id + parentLabel = parent?.name ?: visit.scopeAsset?.name ?: "Yacimiento de la inspección" resetForm() - model.loadFieldTypes(null) + model.loadFieldTypes(parent?.id) } fun startSubinstallation() { @@ -349,6 +349,10 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } fun chooseParent(item: FieldInventoryItem) { + if (modernItemTypeCode(item) == "yacimiento") { + startInstallation(item) + return + } keyboard?.hide() focusManager.clearFocus() parentId = item.id @@ -385,8 +389,8 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) ModernInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion") else -> false } - } ?: model.fieldTypes.firstOrNull() - if (model.fieldTypes.none { it.id == selectedTypeId }) { + } + if (selectedTypeId != preferred?.id) { selectedTypeId = preferred?.id selectedFamilyId = null familySearch = "" @@ -435,7 +439,7 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) fun createWithLocation() { val type = selectedType ?: return - val effectiveParent = if (mode == ModernInventoryMode.CREATE_SUBINSTALLATION) parentId else null + val effectiveParent = parentId scope.launch { runCatching { currentModernGeo(context) } .onSuccess { geo -> @@ -544,6 +548,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } if (selectedCapture != null) { + if (modernItemTypeCode(selectedCapture.asset) in setOf("instalacion", "subinstalacion")) { + TechnicalFieldsButton(selectedCapture.asset.id, enabled = !model.busy) + } ModernCaptureCard( item = selectedCapture.asset, gps = selectedCapture.capture.creationGpsCaptured, @@ -735,8 +742,9 @@ private fun ModernFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) } } - if (model.fieldTypes.isEmpty() && !model.busy) { - ModernLocalError("No hay un tipo habilitado para esta ubicación.") {} + if (selectedType == null && !model.busy) { + ModernLocalError("Elegí un Yacimiento en Inventario y tocá Agregar Instalación. No se puede crear una Instalación directamente dentro de un Área.") {} + OutlinedButton(onClick = { backToBrowse() }) { Text("Elegir Yacimiento") } } if (model.fieldTypes.size > 1) { @@ -886,7 +894,7 @@ private fun ModernInventoryBrowse( ) { val focusManager = LocalFocusManager.current val keyboard = LocalSoftwareKeyboardController.current - val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("instalacion", "subinstalacion") } + val rows = model.inventory.filter { modernItemTypeCode(it) in setOf("yacimiento", "instalacion", "subinstalacion") } Column(Modifier.fillMaxSize().padding(horizontal = 18.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { Button(onClick = onStartSubinstallation, modifier = Modifier.weight(1f)) { @@ -934,7 +942,7 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit, Text(item.name, fontWeight = FontWeight.Bold) Text(item.code, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - StatusPill(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación") + StatusPill(when (typeCode) { "subinstalacion" -> "Subinstalación"; "yacimiento" -> "Yacimiento"; else -> "Instalación" }) } item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) } Text( @@ -946,11 +954,11 @@ private fun ModernInventoryCard(item: FieldInventoryItem, onInspect: () -> Unit, Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) { Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Seleccionar para Hallazgo") } - if (typeCode == "instalacion") { + if (typeCode in setOf("yacimiento", "instalacion")) { OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Filled.Add, null) Spacer(Modifier.width(6.dp)) - Text("Agregar Subinstalación") + Text(if (typeCode == "yacimiento") "Agregar Instalación" else "Agregar Subinstalación") } } } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/TechnicalFieldsButton.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/TechnicalFieldsButton.kt new file mode 100644 index 0000000..4abb521 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/TechnicalFieldsButton.kt @@ -0,0 +1,99 @@ +package com.korexlabs.dhinspeccion.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.korexlabs.dhinspeccion.data.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +@Composable +internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) { + val context = LocalContext.current + val repository = remember { TechnicalRepository(context) } + val scope = rememberCoroutineScope() + var open by rememberSaveable(assetId) { mutableStateOf(false) } + var busy by remember(assetId) { mutableStateOf(false) } + var snapshot by remember(assetId) { mutableStateOf(null) } + var error by remember(assetId) { mutableStateOf(null) } + var saved by remember(assetId) { mutableStateOf(false) } + val values = remember(assetId) { mutableStateMapOf() } + + OutlinedButton(onClick = { open = true }, enabled = enabled, + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) { + Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos") + } + if (!open) return + LaunchedEffect(assetId, open) { + busy = true; error = null; snapshot = null + try { + val loaded = repository.get(assetId) + snapshot = loaded + values.clear() + loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() } + } catch (cancelled: CancellationException) { throw cancelled } + catch (failure: Exception) { error = DhRepository.humanError(failure) } + finally { busy = false } + } + AlertDialog( + onDismissRequest = { if (!busy) open = false }, + title = { Text(snapshot?.family?.name ?: "Datos técnicos") }, + text = { + Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.") + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + snapshot?.definitions?.filter { it.isActive }?.forEach { definition -> + val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "") + val current = values[definition.id].orEmpty() + if (definition.dataType in setOf("BOOLEAN", "SELECT")) { + Text(label) + val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty() + options.forEach { option -> + FilterChip(selected = current == option, enabled = !busy, + onClick = { values[definition.id] = option }, + label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "Sí" else "No" else option) }) + } + if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") } + } else { + OutlinedTextField(value = current, onValueChange = { values[definition.id] = it }, + label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(), + supportingText = { + when (definition.dataType) { + "DATE" -> Text("AAAA-MM-DD") + "DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00") + else -> Unit + } + }) + } + } + if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.") + } + }, + confirmButton = { + TextButton(enabled = !busy && snapshot != null, onClick = { + val current = snapshot ?: return@TextButton + busy = true; error = null + scope.launch { + try { + val payload = current.definitions.filter { it.isActive }.mapNotNull { definition -> + technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it } + }.toMap() + snapshot = repository.save(assetId, payload) + saved = true; open = false + } catch (cancelled: CancellationException) { throw cancelled } + catch (failure: Exception) { error = DhRepository.humanError(failure) } + finally { busy = false } + } + }) { Text("Guardar datos") } + }, + dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } }, + ) +} diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FieldCoordinatesTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FieldCoordinatesTest.kt new file mode 100644 index 0000000..247c06b --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FieldCoordinatesTest.kt @@ -0,0 +1,24 @@ +package com.korexlabs.dhinspeccion +import com.korexlabs.dhinspeccion.data.FieldCoordinates +import org.junit.Assert.* +import org.junit.Test +class FieldCoordinatesTest { + @Test fun realGpsIsRoundedToApiPrecision() { + assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0) + assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0) + assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0) + } + @Test fun boundsAndZeroRemainValid() { + assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0) + assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0) + assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0) + } + @Test fun invalidCoordinatesAreRejected() { + for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) { + try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") } + catch (_: IllegalArgumentException) { } + } + try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") } + catch (_: IllegalArgumentException) { } + } +} diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt index ef56ab8..59c24b5 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/FinalFieldFlowContractTest.kt @@ -38,7 +38,7 @@ class FinalFieldFlowContractTest { @Test fun structuralSelectionUsesTheServerTypeCode() { assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }")) - assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"instalacion\", \"subinstalacion\")")) + assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")")) } @Test diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/TechnicalValuesTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/TechnicalValuesTest.kt new file mode 100644 index 0000000..8375b60 --- /dev/null +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/TechnicalValuesTest.kt @@ -0,0 +1,28 @@ +package com.korexlabs.dhinspeccion +import com.korexlabs.dhinspeccion.data.* +import org.junit.Assert.* +import org.junit.Test +class TechnicalValuesTest { + private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B")) + @Test fun decimalCommaAndBooleanFalseKeepTheirTypes() { + assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5")) + assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false")) + assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0")) + } + @Test fun optionalBlankIsOmittedButRequiredBlankFails() { + assertNull(technicalInputValue(field("TEXT"), " ")) + try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") } + catch (_: IllegalArgumentException) { } + } + @Test fun invalidNumbersDatesAndOptionsFailBeforeSave() { + for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) { + try { technicalInputValue(field(type), value); fail("Invalid value accepted") } + catch (_: IllegalArgumentException) { } + } + } + @Test fun validDatesAndSelectRemainCanonical() { + assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14")) + assertEquals("A", technicalInputValue(field("SELECT"), "A")) + assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00")) + } +}