Skip to content

Commit d4f390d

Browse files
danielnuldDaniel Noé Núñez López
andauthored
test: cobertura unitaria (18 -> 81) y smoke test de UI en CI (#1)
* test: cubrir versionado OTA, mapeo WMO y back-off del MCU 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. * test: smoke test de UI por adb y job de CI 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. * fix(smoke): esperar a que la Activity esté resumed en vez de dormir fijo 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. * fix(smoke): no usar grep -q en tuberías bajo pipefail 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(). --------- Co-authored-by: Daniel Noé Núñez López <daniel.nunez@stjsonora.gob.mx>
1 parent 1b5d729 commit d4f390d

11 files changed

Lines changed: 432 additions & 31 deletions

File tree

.github/workflows/ci.yml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,46 @@ jobs:
4444
name: helm-debug
4545
path: app/build/outputs/apk/debug/app-debug.apk
4646
retention-days: 14
47+
48+
smoke:
49+
name: UI smoke test
50+
runs-on: ubuntu-latest
51+
needs: build
52+
53+
steps:
54+
- uses: actions/checkout@v4
55+
56+
- name: Download debug APK
57+
uses: actions/download-artifact@v4
58+
with:
59+
name: helm-debug
60+
path: app/build/outputs/apk/debug
61+
62+
# The emulator needs KVM, and the runner does not grant it by default.
63+
- name: Enable KVM
64+
run: |
65+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
66+
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
67+
sudo udevadm control --reload-rules
68+
sudo udevadm trigger --name-match=kvm
69+
70+
# API 29 + 768x1024 portrait mirrors the real head unit, so the smoke run
71+
# exercises the same layout the car does.
72+
- name: Run smoke test
73+
uses: reactivecircus/android-emulator-runner@v2
74+
with:
75+
api-level: 29
76+
target: google_apis
77+
arch: x86_64
78+
emulator-options: >-
79+
-no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
80+
-camera-back emulated -skin 768x1024
81+
script: bash tools/smoke.sh
82+
83+
- name: Upload smoke screenshots
84+
if: always()
85+
uses: actions/upload-artifact@v4
86+
with:
87+
name: smoke-screenshots
88+
path: build/smoke/
89+
retention-days: 14

ota/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ android {
1212
targetCompatibility = JavaVersion.VERSION_17
1313
}
1414
kotlinOptions { jvmTarget = "17" }
15+
testOptions {
16+
unitTests.all { it.useJUnitPlatform() }
17+
}
1518
}
1619

1720
dependencies {
1821
implementation(libs.androidx.core.ktx)
1922
implementation(libs.kotlinx.coroutines.android)
2023
implementation(libs.androidx.lifecycle.viewmodel.ktx)
24+
25+
testImplementation(libs.junit5.api)
26+
testImplementation(libs.junit5.params)
27+
testRuntimeOnly(libs.junit5.engine)
2128
}

ota/src/main/kotlin/dev/helm/ota/OtaInfo.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,23 @@ data class OtaInfo(
55
val apkUrl: String,
66
val changelog: String,
77
)
8+
9+
// Semantic-ish version compare. Tolerates a leading "v", ignores pre-release ("-rc1")
10+
// and build metadata ("+42") suffixes, and pads missing components with 0 so
11+
// "1.2" and "1.2.0" compare equal.
12+
internal fun isNewer(server: String, installed: String): Boolean {
13+
fun String.components(): List<Int> {
14+
val core = substringBefore('+').substringBefore('-').trimStart('v')
15+
val parts = core.split('.').mapNotNull { it.toIntOrNull() }
16+
return parts.ifEmpty { listOf(0) }
17+
}
18+
val s = server.components()
19+
val c = installed.components()
20+
for (i in 0 until maxOf(s.size, c.size)) {
21+
val sv = s.getOrElse(i) { 0 }
22+
val cv = c.getOrElse(i) { 0 }
23+
if (sv > cv) return true
24+
if (sv < cv) return false
25+
}
26+
return false
27+
}

ota/src/main/kotlin/dev/helm/ota/OtaViewModel.kt

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,4 @@ class OtaViewModel(application: Application) : AndroidViewModel(application) {
8585
fun reset() {
8686
_state.value = OtaState.Idle
8787
}
88-
89-
private fun isNewer(server: String, installed: String): Boolean {
90-
fun String.components(): List<Int> {
91-
val core = substringBefore('+').substringBefore('-').trimStart('v')
92-
val parts = core.split('.').mapNotNull { it.toIntOrNull() }
93-
return parts.ifEmpty { listOf(0) }
94-
}
95-
val s = server.components()
96-
val c = installed.components()
97-
val len = maxOf(s.size, c.size)
98-
for (i in 0 until len) {
99-
val sv = s.getOrElse(i) { 0 }
100-
val cv = c.getOrElse(i) { 0 }
101-
if (sv > cv) return true
102-
if (sv < cv) return false
103-
}
104-
return false
105-
}
10688
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package dev.helm.ota
2+
3+
import org.junit.jupiter.api.Assertions.assertFalse
4+
import org.junit.jupiter.api.Assertions.assertTrue
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.params.ParameterizedTest
7+
import org.junit.jupiter.params.provider.CsvSource
8+
9+
class OtaVersionTest {
10+
11+
@ParameterizedTest(name = "server {0} is newer than installed {1}")
12+
@CsvSource(
13+
"0.2.0, 0.1.0",
14+
"1.0.0, 0.9.9",
15+
"0.1.1, 0.1.0",
16+
"0.10.0, 0.9.0", // numeric compare, not lexicographic
17+
"1.1, 1.0.9", // missing component pads to 0, compared left to right
18+
)
19+
fun `newer server version triggers update`(server: String, installed: String) {
20+
assertTrue(isNewer(server, installed))
21+
}
22+
23+
@ParameterizedTest(name = "server {0} is NOT newer than installed {1}")
24+
@CsvSource(
25+
"0.1.0, 0.1.0",
26+
"0.1.0, 0.2.0",
27+
"0.9.0, 0.10.0",
28+
"1.0, 1.0.0", // equal once padded
29+
"0.1.0, 1.0.0", // downgrade must never be offered
30+
)
31+
fun `same or older server version does not trigger update`(server: String, installed: String) {
32+
assertFalse(isNewer(server, installed))
33+
}
34+
35+
@Test
36+
fun `leading v is stripped from the release tag`() {
37+
assertTrue(isNewer("v0.2.0", "0.1.0"))
38+
assertFalse(isNewer("v0.1.0", "0.1.0"))
39+
}
40+
41+
@Test
42+
fun `pre-release and build metadata suffixes are ignored`() {
43+
assertFalse(isNewer("0.1.0-rc1", "0.1.0"))
44+
assertFalse(isNewer("0.1.0+42", "0.1.0"))
45+
assertTrue(isNewer("0.2.0-rc1", "0.1.0"))
46+
}
47+
48+
@Test
49+
fun `unparseable versions never trigger an update`() {
50+
// A garbage tag_name must not push an APK onto the head unit.
51+
assertFalse(isNewer("", "0.1.0"))
52+
assertFalse(isNewer("latest", "0.1.0"))
53+
assertFalse(isNewer("nightly", "0.1.0"))
54+
}
55+
56+
@Test
57+
fun `unparseable installed version accepts any real release`() {
58+
// versionName came back empty from PackageManager — treated as 0.
59+
assertTrue(isNewer("0.1.0", ""))
60+
}
61+
}

sdk/src/main/kotlin/dev/helm/sdk/OpenMeteoWeatherDataSource.kt

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,4 @@ class OpenMeteoWeatherDataSource(
110110
}
111111
}
112112
}
113-
114-
private fun wmoToCondition(code: Int): WeatherCondition = when (code) {
115-
0 -> WeatherCondition.CLEAR
116-
in 1..3 -> WeatherCondition.CLOUDY
117-
45, 48 -> WeatherCondition.HAZE
118-
in 51..67 -> WeatherCondition.RAIN
119-
in 71..77 -> WeatherCondition.SNOW
120-
in 80..82 -> WeatherCondition.RAIN
121-
85, 86 -> WeatherCondition.SNOW
122-
in 95..99 -> WeatherCondition.THUNDERSTORM
123-
else -> WeatherCondition.CLOUDY
124-
}
125113
}

sdk/src/main/kotlin/dev/helm/sdk/TwUtilMcuDataSource.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,17 @@ internal class TwUtilMcuDataSource : McuDataSource {
1212

1313
override fun events(): Flow<McuEvent> = adapter.events()
1414
.retryWhen { _, attempt ->
15-
delay(minOf(500L shl attempt.toInt().coerceAtMost(6), 30_000L))
15+
delay(reconnectDelayMs(attempt))
1616
true
1717
}
1818

1919
override suspend fun send(code: Int, arg1: Int, arg2: Int, data: ByteArray): Result<Unit> =
2020
adapter.send(code, arg1, arg2, data)
2121
}
22+
23+
// Exponential back-off for MCU reconnection: 500 ms, 1 s, 2 s, 4 s, 8 s, 16 s, then 30 s
24+
// forever. Clamp the attempt as a Long *before* narrowing to Int: Long.MAX_VALUE.toInt()
25+
// is -1, and `shl` only reads the low 6 bits of the count, so narrowing first turns a very
26+
// long outage into a negative delay and busy-loops the UART.
27+
internal fun reconnectDelayMs(attempt: Long): Long =
28+
minOf(500L shl attempt.coerceIn(0L, 6L).toInt(), 30_000L)

sdk/src/main/kotlin/dev/helm/sdk/WeatherCondition.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,18 @@ enum class WeatherCondition(val label: String) {
88
SNOW("Snow"),
99
HAZE("Haze"),
1010
}
11+
12+
// WMO weather interpretation codes → Helm's 6 icon buckets.
13+
// https://open-meteo.com/en/docs — unknown codes fall back to CLOUDY so the
14+
// widget always renders something rather than blanking out.
15+
internal fun wmoToCondition(code: Int): WeatherCondition = when (code) {
16+
0 -> WeatherCondition.CLEAR
17+
in 1..3 -> WeatherCondition.CLOUDY
18+
45, 48 -> WeatherCondition.HAZE
19+
in 51..67 -> WeatherCondition.RAIN
20+
in 71..77 -> WeatherCondition.SNOW
21+
in 80..82 -> WeatherCondition.RAIN
22+
85, 86 -> WeatherCondition.SNOW
23+
in 95..99 -> WeatherCondition.THUNDERSTORM
24+
else -> WeatherCondition.CLOUDY
25+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package dev.helm.sdk
2+
3+
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Assertions.assertTrue
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.params.ParameterizedTest
7+
import org.junit.jupiter.params.provider.CsvSource
8+
import org.junit.jupiter.params.provider.ValueSource
9+
10+
class ReconnectBackoffTest {
11+
12+
@ParameterizedTest(name = "attempt {0} waits {1} ms")
13+
@CsvSource(
14+
"0, 500",
15+
"1, 1000",
16+
"2, 2000",
17+
"3, 4000",
18+
"4, 8000",
19+
"5, 16000",
20+
"6, 30000", // 32000 clamped to the 30 s ceiling
21+
)
22+
fun `back-off follows the documented schedule`(attempt: Long, expectedMs: Long) {
23+
assertEquals(expectedMs, reconnectDelayMs(attempt))
24+
}
25+
26+
@ParameterizedTest(name = "attempt {0} stays at the 30 s ceiling")
27+
@ValueSource(longs = [7, 8, 20, 63, 64, 1_000, Int.MAX_VALUE.toLong(), Long.MAX_VALUE])
28+
fun `long outages stay at the ceiling`(attempt: Long) {
29+
assertEquals(30_000L, reconnectDelayMs(attempt))
30+
}
31+
32+
@Test
33+
fun `delay is never zero or negative`() {
34+
// A non-positive delay would turn reconnection into a busy loop on the UART.
35+
listOf(0L, 6L, 7L, 64L, Long.MAX_VALUE).forEach {
36+
assertTrue(reconnectDelayMs(it) > 0, "attempt $it produced a non-positive delay")
37+
}
38+
}
39+
40+
@Test
41+
fun `back-off is monotonic up to the ceiling`() {
42+
val delays = (0L..10L).map { reconnectDelayMs(it) }
43+
delays.zipWithNext { a, b -> assertTrue(b >= a, "delay decreased: $a then $b") }
44+
assertEquals(30_000L, delays.last())
45+
}
46+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package dev.helm.sdk
2+
3+
import org.junit.jupiter.api.Assertions.assertEquals
4+
import org.junit.jupiter.api.Test
5+
import org.junit.jupiter.params.ParameterizedTest
6+
import org.junit.jupiter.params.provider.CsvSource
7+
import org.junit.jupiter.params.provider.ValueSource
8+
9+
class WmoConditionTest {
10+
11+
@ParameterizedTest(name = "WMO {0} maps to {1}")
12+
@CsvSource(
13+
"0, CLEAR",
14+
"1, CLOUDY", "2, CLOUDY", "3, CLOUDY",
15+
"45, HAZE", "48, HAZE",
16+
"51, RAIN", "61, RAIN", "67, RAIN",
17+
"71, SNOW", "77, SNOW",
18+
"80, RAIN", "82, RAIN",
19+
"85, SNOW", "86, SNOW",
20+
"95, THUNDERSTORM", "99, THUNDERSTORM",
21+
)
22+
fun `documented WMO codes map to the right icon`(code: Int, expected: WeatherCondition) {
23+
assertEquals(expected, wmoToCondition(code))
24+
}
25+
26+
@ParameterizedTest(name = "gap code {0} falls back to CLOUDY")
27+
@ValueSource(ints = [4, 44, 49, 50, 68, 70, 78, 79, 83, 84, 87, 94, 100])
28+
fun `codes in the gaps fall back to CLOUDY`(code: Int) {
29+
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(code))
30+
}
31+
32+
@Test
33+
fun `nonsense codes never crash the widget`() {
34+
// A malformed API response must degrade to an icon, not blank the home screen.
35+
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(-1))
36+
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(9999))
37+
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MAX_VALUE))
38+
assertEquals(WeatherCondition.CLOUDY, wmoToCondition(Int.MIN_VALUE))
39+
}
40+
41+
@Test
42+
fun `every condition has a non-empty label for the widget`() {
43+
WeatherCondition.entries.forEach { assertEquals(it.label, it.label.trim().ifEmpty { null }) }
44+
}
45+
}

0 commit comments

Comments
 (0)