Skip to content

test: cobertura unitaria (18 -> 81) y smoke test de UI en CI - #1

Merged
Joshumpa merged 4 commits into
mainfrom
test/unit-tests-and-ui-smoke
Aug 23, 2026
Merged

test: cobertura unitaria (18 -> 81) y smoke test de UI en CI#1
Joshumpa merged 4 commits into
mainfrom
test/unit-tests-and-ui-smoke

Conversation

@danielnuld

@danielnuld danielnuld commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Sube la cobertura de tests de 18 a 81 y añade un smoke test de UI que corre en CI.

Tests unitarios

La lógica que valía la pena probar estaba private dentro de clases que arrastran Android
(AndroidViewModel, Context), lo que obligaba a meter Robolectric para ejercitar un par
de funciones que no dependen de nada. Se extraen a funciones top-level internal en su
propio módulo. Ningún call site cambia y no se añade ninguna dependencia.

Suite Tests Qué fija
OtaVersionTest (:ota) 14 Compare numérico y no lexicográfico, padding de componentes, la v del tag, sufijos -rc1/+42, y que un tag_name basura ("", latest, nightly) nunca dispare una instalación
WmoConditionTest (:sdk) 32 Los 17 códigos WMO documentados, los 13 huecos del rango, y códigos imposibles que deben caer en CLOUDY en vez de dejar el home en blanco
ReconnectBackoffTest (:sdk) 17 La tabla completa de back-off (500 ms → 30 s), el techo, y que el delay nunca sea ≤ 0

:ota no tenía wiring de tests; se le añade el mismo que ya usa :sdk.

Un bug real en el back-off

attempt es Long y se narrowaba a Int antes de recortar:

minOf(500L shl attempt.toInt().coerceAtMost(6), 30_000L)

Con un attempt grande, toInt() desborda a negativo, coerceAtMost(6) deja pasar el
negativo, y shl en Kotlin solo lee los 6 bits bajos del contador — así que shl -1 es
shl 63, el resultado desborda y minOf elige el valor negativo. Un delay negativo
convierte la reconexión al MCU en un busy loop sobre el UART.

Hacen falta ~2^31 reintentos para llegar ahí, o sea milenios a 30 s por intento: es
inalcanzable en la práctica. Se corrige igual porque cuesta cero, recortando como Long
antes de bajar a Int.

Smoke test de UI

tools/smoke.sh conduce la app sobre adb y asierta contra el árbol de accesibilidad que
Compose ya expone — uiautomator dump ve cada nodo Text con sus bounds. Sin
dependencias nuevas y sin tocar el código de la app.

  • Localiza por texto y calcula el centro de los bounds, en vez de coordenadas fijas que se
    rompen al mover el layout.
  • Espera con polling hasta 15 s antes de rendirse, como los locators de Playwright.
  • Al fallar imprime todo el texto visible en ese momento y guarda un screenshot en
    build/smoke/.

15 checks: arranque, home, Ajustes, selector de temas, relanzado y logcat sin crashes.

En CI corre en un job aparte con reactivecircus/android-emulator-runner, sobre API 29 a
768x1024 — la misma API y el mismo layout que el head unit real. Reutiliza el APK que ya
sube el job build, y publica los screenshots como artifact pase o falle.

Encontró un bug de navegación

En la primera corrida falló al volver de Apariencia. No existe ni un BackHandler en
todo el proyecto
, así que el botón BACK del sistema nunca toca la variable screen de
HelmLauncher y termina la Activity desde cualquier pantalla: desde Ajustes no vuelve al
Home, se sale de la app.

Esa aserción queda invertida a propósito: espera el comportamiento roto, pasa mientras
el bug siga ahí, e imprime un NOTE en cuanto alguien meta el BackHandler. Un test rojo
permanente es un test que todo el mundo aprende a ignorar; el arreglo en sí va en otro PR.

Dos fallos de infraestructura en el camino

El job del emulador tardó tres corridas en quedar verde, y ninguna de las dos causas era
la app:

  1. pipefail + grep -q. grep -q sale en cuanto encuentra la primera coincidencia y
    manda SIGPIPE al adb que sigue escribiendo; con set -o pipefail el estado de la
    tubería pasa a ser ese 141, así que la aserción fallaba justo cuando el texto sí
    estaba
    — de ahí lo desconcertante de que el volcado del propio fallo lo mostrara.
    Localmente nunca se reprodujo porque el dump es pequeño y adb termina de escribir
    antes de que grep cierre. Verificado aislado:
    yes x | head -200000 | grep -qF xPIPESTATUS = 141 141 0.
    Sustituido por case sobre la captura en los tres puntos afectados.

  2. sleep fijo tras am start. El arranque en frío en el emulador de CI tarda mucho
    más que en uno local ya caliente. Ahora espera a mResumedActivity.

Lo que no cubre

Los previews vacíos en el selector de temas no los caza este script: el nodo existe y es
"visible", simplemente no dibuja nada. Eso solo lo ve un test de screenshot (Paparazzi o
Roborazzi), y con la Fase 3 de UX en curso las imágenes golden cambiarían en cada ajuste.
Mejor cuando el diseño se asiente.

Daniel Noé Núñez López added 4 commits August 21, 2026 21:45
La lógica pura estaba atrapada como private dentro de clases que arrastran
Android (AndroidViewModel, Context), lo que obligaba a Robolectric para
probar un par de funciones sin dependencias. Se extraen a funciones
top-level internal en su propio módulo; ningún call site cambia.

- isNewer: de OtaViewModel a OtaInfo.kt
- wmoToCondition: de OpenMeteoWeatherDataSource a WeatherCondition.kt
- reconnectDelayMs: de TwUtilMcuDataSource (estaba inline en el retryWhen)

De paso corrige el back-off: attempt es Long y se narrowaba a Int antes de
recortar, así que Long.MAX_VALUE.toInt() daba -1 y shl solo lee los 6 bits
bajos del contador, produciendo un delay negativo que convertía la
reconexión en un busy loop sobre el UART. Ahora se recorta como Long antes
de bajar a Int.

63 tests nuevos: 18 -> 81.
Conduce la app sobre adb y asierta contra el árbol de accesibilidad que
Compose ya expone (uiautomator ve cada nodo Text con sus bounds), así que
no hace falta tocar el código de la app ni añadir dependencias.

Localiza por texto y saca el centro de los bounds en vez de usar
coordenadas fijas, espera con polling hasta 15 s antes de rendirse, y al
fallar imprime todo el texto visible más un screenshot en build/smoke/.

La aserción del BACK va invertida a propósito: no existe ningún
BackHandler en el proyecto, así que el botón del sistema cierra la
Activity desde cualquier pantalla en vez de volver a la anterior. El
script pasa mientras el bug siga ahí y avisa el día que se arregle, en
lugar de quedarse en rojo permanente y que todos aprendan a ignorarlo.

En CI corre sobre API 29 a 768x1024, el mismo layout que el head unit.
El arranque en frío en el emulador de CI tarda bastante más que en uno
local ya caliente, así que el sleep 3 tras am start convertía la primera
aserción en un fallo intermitente: el volcado del propio fallo mostraba
km/h presente 5 s después de que wait_for_text se rindiera.

Ahora espera a mResumedActivity hasta 60 s, y sube el timeout de las
aserciones a 30 s — cada poll cuesta un ui_dump completo (~2 s en CI),
así que 15 s eran apenas unos pocos intentos.
grep -q sale en cuanto encuentra la primera coincidencia, lo que manda
SIGPIPE al adb que sigue escribiendo. Con set -o pipefail el estado de la
tubería pasa a ser ese 141, así que la aserción fallaba justo cuando el
texto SÍ estaba presente — de ahí que el volcado del propio fallo lo
mostrara.

Localmente no se reproducía porque el dump es pequeño y adb termina de
escribir antes de que grep cierre la tubería; en CI el volcado tarda más
y el SIGPIPE sí ocurre. Verificado aparte:

  yes 'x' | head -200000 | grep -qF x   ->  PIPESTATUS = 141 141 0

Se sustituye por case sobre la captura, en wait_for_text/sees_now y en la
espera de mResumedActivity de launch().
@Joshumpa Joshumpa self-assigned this Aug 23, 2026
@Joshumpa
Joshumpa merged commit d4f390d into main Aug 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants