Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,15 @@ jobs:
DEVICE_ID=$(xcrun simctl list devices available --json \
| jq -r '[.devices[][] | select(.name | startswith("iPhone")) | .udid] | first')
echo "Using simulator $DEVICE_ID"
# TrainTimePhoneTests holds the logic + phone component snapshots; TrainTimeWidgetTests
# holds the widget-only logic + WidgetKit entry-view snapshots. Snapshot goldens are
# recorded on a build host with this same dynamic device pick, so the runner matches.
xcodebuild test \
-project TrainTimeWatch.xcodeproj \
-scheme TrainTimePhone \
-destination "platform=iOS Simulator,id=$DEVICE_ID" \
-only-testing:TrainTimePhoneTests \
-only-testing:TrainTimeWidgetTests \
CODE_SIGNING_ALLOWED=NO

- name: Build unsigned (PR validation)
Expand Down Expand Up @@ -261,10 +265,45 @@ jobs:
echo ""
ls -la ../builds/

- name: Compile unit tests
- name: Run unit tests
if: steps.check_creds.outputs.has_creds == 'true'
working-directory: garmin/TrainTime
run: monkeyc -d fenix6pro -f monkey.jungle -o /tmp/test.prg -y ~/.Garmin/developer_key.der -t
shell: bash
# tester.sh forces strict type-check (-l 3) the project doesn't use, so we
# run the sim at the default level. A blind sleep races the sim boot on slow
# runners (the prior cause of empty output and a failed grep), so poll the
# sim's IPC port instead; ss/netstat aren't in the image, hence /dev/tcp.
# monkeydo always exits 1, so judge by the PASSED summary line.
run: |
trap 'kill $(jobs -p) 2>/dev/null || true' EXIT
export DISPLAY=:1
Xvfb :1 -screen 0 1280x1024x24 >/tmp/xvfb.log 2>&1 &
mkdir -p bin
monkeyc -f monkey.jungle -d fenix6pro -o bin/app.prg -y ~/.Garmin/developer_key.der -t

# matco bakes the CIQ devices under /root/.Garmin, but GitHub forces
# HOME=/github/home for container jobs, so the simulator looks there and
# finds none (monkeyc resolves devices via the SDK, hence builds still pass).
mkdir -p ~/.Garmin
rm -rf ~/.Garmin/ConnectIQ
ln -s /root/.Garmin/ConnectIQ ~/.Garmin/ConnectIQ

simulator >/tmp/sim.log 2>&1 &
ready=0
for i in $(seq 1 60); do
if (exec 3<>/dev/tcp/127.0.0.1/1234) 2>/dev/null; then ready=1; break; fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::Simulator never opened port 1234"; cat /tmp/sim.log; exit 1
fi
echo "Simulator ready after ${i}s"

timeout 120 monkeydo bin/app.prg fenix6pro -t | tee result.txt || true
if ! grep -Eq '^PASSED \(passed=[0-9]+, failed=0, errors=0\)' result.txt; then
echo "::error::Garmin unit tests did not pass"; cat /tmp/sim.log; exit 1
fi
echo "Garmin unit tests passed"

- name: Upload Garmin builds
if: steps.check_creds.outputs.has_creds == 'true'
Expand Down
10 changes: 8 additions & 2 deletions android/app/src/test/kotlin/com/evanjt/traintime/LinePillTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import org.junit.Test
class LinePillTest {
@Test
fun `long-distance prefixes map to the long-distance fill`() {
for (line in listOf("IC8", "IR15", "EC", "ICE", "RJX")) {
for (line in listOf("IC8", "IR15", "EC", "ICE", "RJX", "TGV", "EN", "NJ", "PE", "ICN")) {
assertEquals(LightPalette.lineLongDistance, LightPalette.linePill(line, TransportMode.TRAIN))
}
}

@Test
fun `regional prefixes map to the regional fill`() {
for (line in listOf("S3", "RE90", "R", "SN")) {
for (line in listOf("S3", "RE90", "R", "SN", "S20")) {
assertEquals(LightPalette.lineRegional, LightPalette.linePill(line, TransportMode.TRAIN))
}
}
Expand All @@ -25,4 +25,10 @@ class LinePillTest {
assertEquals(LightPalette.lineTram, LightPalette.linePill("3", TransportMode.TRAM))
assertEquals(LightPalette.lineRegional, LightPalette.linePill("5", TransportMode.TRAIN))
}

@Test
fun `prefix match is case-insensitive and empty falls back to mode`() {
assertEquals(LightPalette.lineLongDistance, LightPalette.linePill("ic8", TransportMode.TRAIN))
assertEquals(LightPalette.lineRegional, LightPalette.linePill("", TransportMode.TRAIN))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.evanjt.traintime.data.model

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class DepartureTest {
private fun dep(minutesUntil: Int) = Departure(
destination = "Brig",
minutesUntil = minutesUntil,
departureTimestamp = 1000L,
delay = 0,
platform = "1",
platformChanged = false,
lineNumber = "IC8",
category = "IC",
trainNumber = null,
operatorRef = null,
)

@Test
fun `minutes text covers gone, now and minutes`() {
assertEquals("gone", dep(-1).minutesText)
assertEquals("now", dep(0).minutesText)
assertEquals("5'", dep(5).minutesText)
}

@Test
fun `is gone only when negative`() {
assertTrue(dep(-1).isGone)
assertFalse(dep(0).isGone)
assertFalse(dep(3).isGone)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.evanjt.traintime.data.model

import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test

// Guards the persisted JSON shape: a field rename or type change would break
// stored favourites / pinned stations across an app update.
class SerializationTest {
private val json = Json { ignoreUnknownKeys = true }

@Test
fun `pinned station round-trips`() {
val list = listOf(
PinnedStation("8500074", "Sion", 46.23, 7.36),
PinnedStation("8501120", "Lausanne", null, null),
)
assertEquals(list, json.decodeFromString<List<PinnedStation>>(json.encodeToString(list)))
}

@Test
fun `favourite round-trips`() {
val list = listOf(Favourite("8500074", "Sion", "IR95", "Genève"))
assertEquals(list, json.decodeFromString<List<Favourite>>(json.encodeToString(list)))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,22 @@ class GeoUtilsTest {
assertEquals("<1 min walk - 50m", GeoUtils.formatWalkInfo(50.0))
assertEquals("5 min walk - 100m", GeoUtils.formatWalkInfo(100.0, walkTimeSeconds = 300.0))
}

@Test
fun `distance between real stations is deterministic and symmetric`() {
// Place de la Planta -> Gare de Sion, ~376 m by the flat-earth model.
val d = GeoUtils.haversineDistance(46.2306, 7.3576, 46.2275, 7.3596)
assertEquals(376.0, d, 1.0)
assertEquals(d, GeoUtils.haversineDistance(46.2275, 7.3596, 46.2306, 7.3576), 0.001)
}

@Test
fun `distance is zero at the same point`() {
assertEquals(0.0, GeoUtils.haversineDistance(46.2306, 7.3576, 46.2306, 7.3576), 0.001)
}

@Test
fun `walk minutes derive from distance over walk speed`() {
assertEquals(376.0 / 83.0, GeoUtils.walkMinutes(376.0), 0.001)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.evanjt.traintime.DarkPalette
import com.evanjt.traintime.LightPalette
import com.evanjt.traintime.LocalAppPalette
import com.evanjt.traintime.data.model.Departure
import com.evanjt.traintime.data.model.TransportMode
import com.github.takahirom.roborazzi.RoborazziOptions
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
Expand All @@ -21,10 +26,9 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode

// Screenshot regression for the departure row: line pills (long-distance red,
// regional blue, bus grey-blue), delay capsule, and the favourite gold row.
// Renders on the JVM via Robolectric — no emulator. Golden lives under
// src/test/screenshots; CI runs verifyRoborazziDebug.
// Visual regression for the departure rows: line pills (long-distance red,
// regional blue, bus grey-blue), delay capsule, favourite gold row, gone row,
// in light and dark. Renders on the JVM via Robolectric — no emulator.
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34])
Expand All @@ -45,22 +49,38 @@ class DepartureRowSnapshotTest {
operatorRef = null,
)

@Test
fun departureRows() {
@Composable
private fun rows() {
Column(Modifier.width(360.dp)) {
DepartureRow(dep("IR15", "Luzern", 6), isFavourite = false, mode = TransportMode.TRAIN)
DepartureRow(dep("IC8", "Romanshorn", 8, delay = 1), isFavourite = false, mode = TransportMode.TRAIN)
DepartureRow(dep("S7", "Worb Dorf", 6), isFavourite = true, mode = TransportMode.TRAIN)
DepartureRow(dep("12", "Sion", 4), isFavourite = false, mode = TransportMode.BUS)
DepartureRow(dep("IR95", "Genève", -1), isFavourite = false, mode = TransportMode.TRAIN)
}
}

private fun capture(name: String, dark: Boolean) {
composeRule.setContent {
CompositionLocalProvider(LocalAppPalette provides LightPalette) {
MaterialTheme {
Surface {
Column(Modifier.width(360.dp)) {
DepartureRow(dep("IR15", "Luzern", 6), isFavourite = false, mode = TransportMode.TRAIN)
DepartureRow(dep("IC8", "Romanshorn", 8, delay = 1), isFavourite = false, mode = TransportMode.TRAIN)
DepartureRow(dep("S7", "Worb Dorf", 6), isFavourite = true, mode = TransportMode.TRAIN)
DepartureRow(dep("12", "Sion", 4), isFavourite = false, mode = TransportMode.BUS)
}
}
CompositionLocalProvider(LocalAppPalette provides if (dark) DarkPalette else LightPalette) {
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) {
Surface { rows() }
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/departure_rows.png")
composeRule.onRoot().captureRoboImage("src/test/screenshots/$name.png", roborazziOptions = TOLERANT)
}

private companion object {
// Absorb minor anti-aliasing differences between local and CI rendering.
val TOLERANT = RoborazziOptions(
compareOptions = RoborazziOptions.CompareOptions(changeThreshold = 0.01f),
)
}

@Test
fun departureRowsLight() = capture("departure_rows_light", dark = false)

@Test
fun departureRowsDark() = capture("departure_rows_dark", dark = true)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.evanjt.traintime.ui.tracking

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.evanjt.traintime.DarkPalette
import com.evanjt.traintime.LightPalette
import com.evanjt.traintime.LocalAppPalette
import com.evanjt.traintime.data.model.Formation
import com.evanjt.traintime.data.model.FormationWagon
import com.evanjt.traintime.ui.station.InactiveScreen
import com.github.takahirom.roborazzi.RoborazziOptions
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode

// Visual regression for the tracking bar (ahead/behind/on-time/no-GPS), the
// formation diagram (1st/2nd class, sectors, features) and the Paused screen,
// in light and dark.
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34])
class ComponentSnapshotTest {
@get:Rule
val composeRule = createComposeRule()

private val formation = Formation(
track = "3",
sectors = listOf("A", "B", "C"),
wagons = listOf(
FormationWagon(1, 1, 1, "A", listOf("business"), false),
FormationWagon(2, 2, 2, "B", listOf("wheelchair", "restaurant"), false),
FormationWagon(3, 3, 2, "C", emptyList(), false),
),
)

@Composable
private fun tracking() {
Column(
Modifier.width(360.dp).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TrackingBar(schedBuf = 4.0, effectBuf = 5.0, hasGps = true)
TrackingBar(schedBuf = -2.0, effectBuf = -1.0, hasGps = true)
TrackingBar(schedBuf = 0.0, effectBuf = 0.0, hasGps = true)
TrackingBar(schedBuf = 0.0, effectBuf = 0.0, hasGps = false)
FormationDiagram(formation)
}
}

private fun capture(name: String, dark: Boolean, content: @Composable () -> Unit) {
composeRule.setContent {
CompositionLocalProvider(LocalAppPalette provides if (dark) DarkPalette else LightPalette) {
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) {
Surface { content() }
}
}
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/$name.png", roborazziOptions = TOLERANT)
}

private companion object {
// Absorb minor anti-aliasing differences between local and CI rendering.
val TOLERANT = RoborazziOptions(
compareOptions = RoborazziOptions.CompareOptions(changeThreshold = 0.01f),
)
}

@Test fun trackingLight() = capture("tracking_light", dark = false) { tracking() }

@Test fun trackingDark() = capture("tracking_dark", dark = true) { tracking() }

@Test fun pausedLight() = capture("paused_light", dark = false) { Box(Modifier.size(320.dp, 220.dp)) { InactiveScreen {} } }

@Test fun pausedDark() = capture("paused_dark", dark = true) { Box(Modifier.size(320.dp, 220.dp)) { InactiveScreen {} } }
}
Loading
Loading