diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 556cce6..e6eeea6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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) @@ -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' diff --git a/android/app/src/test/kotlin/com/evanjt/traintime/LinePillTest.kt b/android/app/src/test/kotlin/com/evanjt/traintime/LinePillTest.kt index ad9b340..914e897 100644 --- a/android/app/src/test/kotlin/com/evanjt/traintime/LinePillTest.kt +++ b/android/app/src/test/kotlin/com/evanjt/traintime/LinePillTest.kt @@ -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)) } } @@ -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)) + } } diff --git a/android/app/src/test/kotlin/com/evanjt/traintime/data/model/DepartureTest.kt b/android/app/src/test/kotlin/com/evanjt/traintime/data/model/DepartureTest.kt new file mode 100644 index 0000000..7a8a6eb --- /dev/null +++ b/android/app/src/test/kotlin/com/evanjt/traintime/data/model/DepartureTest.kt @@ -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) + } +} diff --git a/android/app/src/test/kotlin/com/evanjt/traintime/data/model/SerializationTest.kt b/android/app/src/test/kotlin/com/evanjt/traintime/data/model/SerializationTest.kt new file mode 100644 index 0000000..d28c52a --- /dev/null +++ b/android/app/src/test/kotlin/com/evanjt/traintime/data/model/SerializationTest.kt @@ -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>(json.encodeToString(list))) + } + + @Test + fun `favourite round-trips`() { + val list = listOf(Favourite("8500074", "Sion", "IR95", "Genève")) + assertEquals(list, json.decodeFromString>(json.encodeToString(list))) + } +} diff --git a/android/app/src/test/kotlin/com/evanjt/traintime/domain/GeoUtilsTest.kt b/android/app/src/test/kotlin/com/evanjt/traintime/domain/GeoUtilsTest.kt index 8b736e6..332e875 100644 --- a/android/app/src/test/kotlin/com/evanjt/traintime/domain/GeoUtilsTest.kt +++ b/android/app/src/test/kotlin/com/evanjt/traintime/domain/GeoUtilsTest.kt @@ -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) + } } diff --git a/android/app/src/test/kotlin/com/evanjt/traintime/ui/station/DepartureRowSnapshotTest.kt b/android/app/src/test/kotlin/com/evanjt/traintime/ui/station/DepartureRowSnapshotTest.kt index ad15174..f27f706 100644 --- a/android/app/src/test/kotlin/com/evanjt/traintime/ui/station/DepartureRowSnapshotTest.kt +++ b/android/app/src/test/kotlin/com/evanjt/traintime/ui/station/DepartureRowSnapshotTest.kt @@ -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 @@ -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]) @@ -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) } diff --git a/android/app/src/test/kotlin/com/evanjt/traintime/ui/tracking/ComponentSnapshotTest.kt b/android/app/src/test/kotlin/com/evanjt/traintime/ui/tracking/ComponentSnapshotTest.kt new file mode 100644 index 0000000..56e57bc --- /dev/null +++ b/android/app/src/test/kotlin/com/evanjt/traintime/ui/tracking/ComponentSnapshotTest.kt @@ -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 {} } } +} diff --git a/android/app/src/test/kotlin/com/evanjt/traintime/widget/WidgetLogicTest.kt b/android/app/src/test/kotlin/com/evanjt/traintime/widget/WidgetLogicTest.kt new file mode 100644 index 0000000..3977932 --- /dev/null +++ b/android/app/src/test/kotlin/com/evanjt/traintime/widget/WidgetLogicTest.kt @@ -0,0 +1,72 @@ +package com.evanjt.traintime.widget + +import com.evanjt.traintime.data.model.TransportMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WidgetLogicTest { + private val now = 1_718_000_000L + + private fun dep(line: String, dest: String, inSeconds: Long) = WidgetDeparture( + destination = dest, + departureTimestamp = now + inSeconds, + delay = 0, + platform = "1", + platformChanged = false, + lineNumber = line, + ) + + private fun station(id: String, vararg deps: WidgetDeparture) = + WidgetStation(id = id, name = id, departures = deps.toList()) + + @Test + fun `minutes text covers gone, now and minutes`() { + assertEquals("gone", dep("IC8", "Brig", -90).minutesText(now)) + assertEquals("now", dep("IC8", "Brig", 30).minutesText(now)) + assertEquals("10'", dep("IC8", "Brig", 600).minutesText(now)) + } + + @Test + fun `favourites block takes one not-gone departure per key, time-ordered`() { + val deps = listOf( + dep("IR90", "Genève", 300), + dep("IC8", "Brig", -60), // gone, skipped + dep("IC8", "Brig", 120), // first live match for IC8|Brig + dep("IC8", "Brig", 600), // duplicate key, skipped + ) + val block = WidgetFavourites.block(deps, setOf("IC8|Brig", "IR90|Genève"), now) + assertEquals(listOf("IC8", "IR90"), block.map { it.lineNumber }) + } + + @Test + fun `favourites block is empty without keys`() { + assertTrue(WidgetFavourites.block(listOf(dep("IC8", "Brig", 120)), emptySet(), now).isEmpty()) + } + + @Test + fun `available modes lists only non-empty groups`() { + val r = WidgetFetchResult(train = listOf(station("a")), tram = listOf(station("b"))) + assertEquals(listOf(TransportMode.TRAIN, TransportMode.TRAM), r.availableModes) + } + + @Test + fun `current station clamps an out-of-range index`() { + val r = WidgetFetchResult(train = listOf(station("a"), station("b")), selectedStationIndex = 5) + assertEquals("b", r.currentStation?.id) + } + + @Test + fun `current station is null when the selected mode is empty`() { + assertNull(WidgetFetchResult(bus = listOf(station("a"))).currentStation) + } + + @Test + fun `is refreshing only within the 15s window`() { + assertTrue(WidgetState(refreshStartedAt = now).isRefreshing(now + 5)) + assertFalse(WidgetState(refreshStartedAt = now).isRefreshing(now + 20)) + assertFalse(WidgetState(refreshStartedAt = 0).isRefreshing(now)) + } +} diff --git a/android/app/src/test/screenshots/departure_rows.png b/android/app/src/test/screenshots/departure_rows.png deleted file mode 100644 index aeb3e9d..0000000 Binary files a/android/app/src/test/screenshots/departure_rows.png and /dev/null differ diff --git a/android/app/src/test/screenshots/departure_rows_dark.png b/android/app/src/test/screenshots/departure_rows_dark.png new file mode 100644 index 0000000..3ac6926 Binary files /dev/null and b/android/app/src/test/screenshots/departure_rows_dark.png differ diff --git a/android/app/src/test/screenshots/departure_rows_light.png b/android/app/src/test/screenshots/departure_rows_light.png new file mode 100644 index 0000000..8fd46a2 Binary files /dev/null and b/android/app/src/test/screenshots/departure_rows_light.png differ diff --git a/android/app/src/test/screenshots/paused_dark.png b/android/app/src/test/screenshots/paused_dark.png new file mode 100644 index 0000000..bf573dd Binary files /dev/null and b/android/app/src/test/screenshots/paused_dark.png differ diff --git a/android/app/src/test/screenshots/paused_light.png b/android/app/src/test/screenshots/paused_light.png new file mode 100644 index 0000000..e2e46aa Binary files /dev/null and b/android/app/src/test/screenshots/paused_light.png differ diff --git a/android/app/src/test/screenshots/tracking_dark.png b/android/app/src/test/screenshots/tracking_dark.png new file mode 100644 index 0000000..ccccd49 Binary files /dev/null and b/android/app/src/test/screenshots/tracking_dark.png differ diff --git a/android/app/src/test/screenshots/tracking_light.png b/android/app/src/test/screenshots/tracking_light.png new file mode 100644 index 0000000..503707f Binary files /dev/null and b/android/app/src/test/screenshots/tracking_light.png differ diff --git a/apple/TrainTimePhoneTests/FavouritesTests.swift b/apple/TrainTimePhoneTests/FavouritesTests.swift new file mode 100644 index 0000000..6317964 --- /dev/null +++ b/apple/TrainTimePhoneTests/FavouritesTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import TrainTimePhone + +final class FavouritesTests: XCTestCase { + private func dep(_ line: String, _ dest: String, _ ts: Int) -> Departure { + Departure( + destination: dest, minutesUntil: 5, departureTimestamp: ts, delay: 0, + platform: "1", platformChanged: false, lineNumber: line, category: "IC", + trainNumber: nil, operatorRef: nil, + ) + } + + func testMergingInsertsMissingFavouritesInTimeOrder() { + let merged = FavouritesStore().merging( + favourites: [dep("IC8", "Brig", 1000)], + into: [dep("S3", "Villeneuve", 1500)], + ) + XCTAssertEqual(merged.map(\.lineNumber), ["IC8", "S3"]) + } + + func testMergingKeepsRegularWhenFavouriteAlreadyPresent() { + let regular = [dep("IC8", "Brig", 1000), dep("S3", "Villeneuve", 1500)] + let merged = FavouritesStore().merging(favourites: [dep("IC8", "Brig", 1000)], into: regular) + XCTAssertEqual(merged.count, 2) + } +} diff --git a/apple/TrainTimePhoneTests/GeoTests.swift b/apple/TrainTimePhoneTests/GeoTests.swift new file mode 100644 index 0000000..2f912a3 --- /dev/null +++ b/apple/TrainTimePhoneTests/GeoTests.swift @@ -0,0 +1,27 @@ +import CoreLocation +import XCTest +@testable import TrainTimePhone + +final class GeoTests: XCTestCase { + func testDistanceBetweenRealStationsIsDeterministicAndSymmetric() { + // Place de la Planta -> Gare de Sion, ~376 m by the flat-earth model. + let planta = CLLocationCoordinate2D(latitude: 46.2306, longitude: 7.3576) + let sion = CLLocationCoordinate2D(latitude: 46.2275, longitude: 7.3596) + XCTAssertEqual(GeoUtils.haversineDistance(from: planta, to: sion), 376.0, accuracy: 1.0) + XCTAssertEqual( + GeoUtils.haversineDistance(from: sion, to: planta), + GeoUtils.haversineDistance(from: planta, to: sion), + accuracy: 0.001, + ) + } + + func testWalkInfoFormatting() { + XCTAssertEqual(GeoUtils.formatWalkInfo(distanceMeters: 200), "2 min walk - 200m") + XCTAssertEqual(GeoUtils.formatWalkInfo(distanceMeters: 50), "<1 min walk - 50m") + XCTAssertEqual(GeoUtils.formatWalkInfo(distanceMeters: 100, walkTimeSeconds: 300), "5 min walk - 100m") + } + + func testWalkMinutesDeriveFromDistance() { + XCTAssertEqual(GeoUtils.walkMinutes(distanceMeters: 376), 376.0 / 83.0, accuracy: 0.001) + } +} diff --git a/apple/TrainTimePhoneTests/MyStationsTests.swift b/apple/TrainTimePhoneTests/MyStationsTests.swift index d2071de..1c68565 100644 --- a/apple/TrainTimePhoneTests/MyStationsTests.swift +++ b/apple/TrainTimePhoneTests/MyStationsTests.swift @@ -30,6 +30,13 @@ final class MyStationsTests: XCTestCase { ) } + func testReorderAllPinnedKeepsOrder() { + XCTAssertEqual( + MyStationsStore.reorder(nearby, pinnedIds: ["near", "big", "far"]).compactMap { $0.id }, + ["near", "big", "far"], + ) + } + func testPinnedStationCodableRoundTrip() throws { let pin = PinnedStation(id: "8500074", name: "Sion", lat: 46.23, lon: 7.36) let data = try JSONEncoder().encode([pin]) diff --git a/apple/TrainTimePhoneTests/ParsingTests.swift b/apple/TrainTimePhoneTests/ParsingTests.swift new file mode 100644 index 0000000..1d172be --- /dev/null +++ b/apple/TrainTimePhoneTests/ParsingTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import TrainTimePhone + +// Parses the same API JSON shapes as the Android MockWebServer tests, via the +// static from(json:) parsers — no network. +final class ParsingTests: XCTestCase { + func testStationParsesWithEmbeddedDepartures() { + let json: [String: Any] = [ + "id": "8501120", "name": "Lausanne", "lat": 46.516, "lon": 6.629, "dist": 250.0, + "departures": [ + ["to": "Brig", "category": "IC", "number": "IC8", "departure": 1_718_000_600, "delay": 2, "platform": "3"], + ], + ] + let station = Station.from(json: json, mode: .train)! + XCTAssertEqual(station.id, "8501120") + XCTAssertEqual(station.name, "Lausanne") + XCTAssertEqual(station.dist!, 250.0, accuracy: 0.001) + XCTAssertEqual(station.mode, .train) + let dep = station.embeddedDepartures!.first! + XCTAssertEqual(dep.destination, "Brig") + XCTAssertEqual(dep.lineNumber, "IC8") + XCTAssertEqual(dep.departureTimestamp, 1_718_000_600) + XCTAssertEqual(dep.delay, 2) + } + + func testStationWithoutIdIsNil() { + XCTAssertNil(Station.from(json: ["name": "x"], mode: .train)) + } + + func testDepartureParsesFieldsAndPlatformChange() { + let dep = Departure.from(json: [ + "to": "Genève", "number": "IR90", "departure": 1_718_000_720, "platform": "1", "platformChanged": true, + ]) + XCTAssertEqual(dep.destination, "Genève") + XCTAssertEqual(dep.lineNumber, "IR90") + XCTAssertEqual(dep.departureTimestamp, 1_718_000_720) + XCTAssertTrue(dep.platformChanged) + XCTAssertEqual(dep.delay, 0) + } + + func testFormationParsesWagons() { + let json: [String: Any] = [ + "track": "3", "sectors": ["A", "B"], + "wagons": [ + ["position": 1, "number": 101, "class": 2, "sector": "A", "features": ["wheelchair"]], + ["position": 2, "number": 102, "class": 1, "sector": "B"], + ], + ] + let f = Formation.from(json: json)! + XCTAssertEqual(f.track, "3") + XCTAssertEqual(f.sectors, ["A", "B"]) + XCTAssertEqual(f.wagons.count, 2) + XCTAssertEqual(f.wagons[0].wagonClass, 2) + XCTAssertEqual(f.wagons[0].features, ["wheelchair"]) + XCTAssertEqual(f.wagons[1].wagonClass, 1) + } + + func testFormationWithoutWagonsIsNil() { + XCTAssertNil(Formation.from(json: ["track": "3"])) + } +} diff --git a/apple/TrainTimePhoneTests/PhoneComponentSnapshotTests.swift b/apple/TrainTimePhoneTests/PhoneComponentSnapshotTests.swift new file mode 100644 index 0000000..15da423 --- /dev/null +++ b/apple/TrainTimePhoneTests/PhoneComponentSnapshotTests.swift @@ -0,0 +1,78 @@ +import SnapshotTesting +import SwiftUI +import UIKit +import XCTest +@testable import TrainTimePhone + +// Component-level visual regression (light + dark), mirroring Android's DepartureRowSnapshotTest + +// ComponentSnapshotTest. AppColors / system colours resolve through UITraitCollection, so light and +// dark are forced via the trait collection, not just the SwiftUI environment. +// Goldens are recorded on the build host and committed under __Snapshots__/; CI then verifies. + +final class PhoneComponentSnapshotTests: XCTestCase { + private let nowTs = 1_718_000_000 + + private func dep(_ line: String, _ dest: String, min: Int, delay: Int = 0, gone: Bool = false) -> Departure { + Departure( + destination: dest, minutesUntil: gone ? -1 : min, + departureTimestamp: nowTs + min * 60, delay: delay, + platform: "3", platformChanged: false, lineNumber: line, + category: "IC", trainNumber: nil, operatorRef: nil + ) + } + + private func assertLightDark(_ view: AnyView, _ name: String, + file: StaticString = #file, testName: String = #function, line: UInt = #line) { + for (suffix, style) in [("light", UIUserInterfaceStyle.light), ("dark", .dark)] { + assertSnapshot( + of: view, + as: .image(precision: 0.99, perceptualPrecision: 0.98, + layout: .sizeThatFits, traits: .init(userInterfaceStyle: style)), + named: "\(name)_\(suffix)", file: file, testName: testName, line: line + ) + } + } + + func testDepartureRows() { + let rows = VStack(spacing: 0) { + PhoneDepartureRowView(departure: dep("IR15", "Luzern", min: 6), mode: .train) + PhoneDepartureRowView(departure: dep("IC8", "Romanshorn", min: 8, delay: 1), mode: .train) + PhoneDepartureRowView(departure: dep("S7", "Worb Dorf", min: 6), isFavourite: true, mode: .train) + PhoneDepartureRowView(departure: dep("12", "Sion", min: 4), mode: .bus) + PhoneDepartureRowView(departure: dep("IR95", "Genève", min: -1, gone: true), mode: .train) + } + .padding(.horizontal, 12) + .frame(width: 360) + .background(Color(uiColor: .systemBackground)) + assertLightDark(AnyView(rows), "phone_departure_rows") + } + + func testTrackingBar() { + let bars = VStack(spacing: 14) { + TrackingBarView(schedBuf: 4, effectBuf: 5, hasGPS: true) // ahead + TrackingBarView(schedBuf: -2, effectBuf: -1, hasGPS: true) // behind + TrackingBarView(schedBuf: 0, effectBuf: 0, hasGPS: true) // on time + TrackingBarView(schedBuf: 0, effectBuf: 0, hasGPS: false) // no GPS + } + .padding(16) + .frame(width: 320) + .background(Color(uiColor: .systemBackground)) + assertLightDark(AnyView(bars), "phone_tracking") + } + + func testFormation() { + let formation = Formation( + track: "3", sectors: ["A", "B", "C"], + wagons: [ + FormationWagon(position: 1, number: 1, wagonClass: 1, sector: "A", features: ["business"], closed: false), + FormationWagon(position: 2, number: 2, wagonClass: 2, sector: "B", features: ["wheelchair", "restaurant"], closed: false), + FormationWagon(position: 3, number: 3, wagonClass: 2, sector: "C", features: [], closed: false), + ] + ) + let view = FormationDiagramView(formation: formation) + .padding(16) + .frame(width: 360) + .background(Color(uiColor: .systemBackground)) + assertLightDark(AnyView(view), "phone_formation") + } +} diff --git a/apple/TrainTimePhoneTests/WidgetLogicTests.swift b/apple/TrainTimePhoneTests/WidgetLogicTests.swift new file mode 100644 index 0000000..4eb8814 --- /dev/null +++ b/apple/TrainTimePhoneTests/WidgetLogicTests.swift @@ -0,0 +1,80 @@ +import XCTest +@testable import TrainTimePhone + +// Widget logic compiled into the phone target (WidgetEntry.swift). Mirrors Android's WidgetLogicTest. +// Every time-dependent path takes an injected `now`, never Date(), so results are deterministic. + +final class WidgetLogicTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_718_000_000) + private var nowTs: Int { Int(now.timeIntervalSince1970) } + + private func dep(_ line: String, _ dest: String, in offset: Int, delay: Int = 0) -> WidgetDeparture { + WidgetDeparture( + destination: dest, departureTimestamp: nowTs + offset, delay: delay, + platform: "3", platformChanged: false, lineNumber: line + ) + } + + private func station(_ id: String, _ name: String, _ deps: [WidgetDeparture] = []) -> WidgetStation { + WidgetStation(id: id, name: name, departures: deps) + } + + private func fav(_ line: String, _ dest: String) -> Favourite { + Favourite(stationId: "8501120", stationName: "Lausanne", lineNumber: line, destination: dest) + } + + private func result( + train: [WidgetStation] = [], bus: [WidgetStation] = [], + tram: [WidgetStation] = [], special: [WidgetStation] = [], + mode: TransportMode = .train, stationIndex: Int = 0 + ) -> WidgetFetchResult { + WidgetFetchResult( + train: train, bus: bus, tram: tram, special: special, + selectedModeRaw: mode.rawValue, selectedStationIndex: stationIndex, + fetchTime: now.timeIntervalSince1970 + ) + } + + func testMinutesTextCoversGoneNowAndMinutes() { + XCTAssertEqual(dep("IC8", "Brig", in: -90).minutesText(at: now), "gone") + XCTAssertEqual(dep("IC8", "Brig", in: 30).minutesText(at: now), "now") + XCTAssertEqual(dep("IC8", "Brig", in: 600).minutesText(at: now), "10'") + } + + func testIsGoneOncePastByAFullMinute() { + // isGone is minute-granular (integer division): a sub-minute-old departure still reads "now". + XCTAssertFalse(dep("IC8", "Brig", in: -30).isGone(at: now)) + XCTAssertTrue(dep("IC8", "Brig", in: -90).isGone(at: now)) + XCTAssertFalse(dep("IC8", "Brig", in: 60).isGone(at: now)) + } + + func testFavouritesBlockTakesOneNotGonePerKeyTimeOrdered() { + let deps = [ + dep("IR90", "Genève", in: 300), + dep("IC8", "Brig", in: -60), // gone, skipped + dep("IC8", "Brig", in: 120), // first live IC8 → kept + dep("IC8", "Brig", in: 600), // duplicate key, skipped + ] + let block = WidgetFavourites.block(in: deps, favourites: [fav("IC8", "Brig"), fav("IR90", "Genève")], at: now) + XCTAssertEqual(block.map(\.lineNumber), ["IC8", "IR90"]) // 120s sorts before 300s + } + + func testFavouritesBlockEmptyWithoutFavourites() { + XCTAssertTrue(WidgetFavourites.block(in: [dep("IC8", "Brig", in: 120)], favourites: [], at: now).isEmpty) + } + + func testAvailableModesListsOnlyNonEmptyGroups() { + let s = station("1", "A", [dep("IC8", "Brig", in: 120)]) + XCTAssertEqual(result(train: [s], tram: [s]).availableModes, [.train, .tram]) + } + + func testCurrentStationClampsOutOfRangeIndex() { + let r = result(train: [station("1", "A"), station("2", "B")], stationIndex: 5) + XCTAssertEqual(r.currentStation?.name, "B") + } + + func testCurrentStationNilWhenSelectedModeEmpty() { + // Selected mode is train but only the bus group has stations. + XCTAssertNil(result(bus: [station("1", "A")], mode: .train).currentStation) + } +} diff --git a/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testDepartureRows.phone_departure_rows_dark.png b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testDepartureRows.phone_departure_rows_dark.png new file mode 100644 index 0000000..5652c3f Binary files /dev/null and b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testDepartureRows.phone_departure_rows_dark.png differ diff --git a/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testDepartureRows.phone_departure_rows_light.png b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testDepartureRows.phone_departure_rows_light.png new file mode 100644 index 0000000..4e52721 Binary files /dev/null and b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testDepartureRows.phone_departure_rows_light.png differ diff --git a/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testFormation.phone_formation_dark.png b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testFormation.phone_formation_dark.png new file mode 100644 index 0000000..d445524 Binary files /dev/null and b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testFormation.phone_formation_dark.png differ diff --git a/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testFormation.phone_formation_light.png b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testFormation.phone_formation_light.png new file mode 100644 index 0000000..e2efbcd Binary files /dev/null and b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testFormation.phone_formation_light.png differ diff --git a/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testTrackingBar.phone_tracking_dark.png b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testTrackingBar.phone_tracking_dark.png new file mode 100644 index 0000000..d62421e Binary files /dev/null and b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testTrackingBar.phone_tracking_dark.png differ diff --git a/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testTrackingBar.phone_tracking_light.png b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testTrackingBar.phone_tracking_light.png new file mode 100644 index 0000000..fcc5d22 Binary files /dev/null and b/apple/TrainTimePhoneTests/__Snapshots__/PhoneComponentSnapshotTests/testTrackingBar.phone_tracking_light.png differ diff --git a/apple/TrainTimeWatch.xcodeproj/project.pbxproj b/apple/TrainTimeWatch.xcodeproj/project.pbxproj index 5a4e3f1..baf74c9 100644 --- a/apple/TrainTimeWatch.xcodeproj/project.pbxproj +++ b/apple/TrainTimeWatch.xcodeproj/project.pbxproj @@ -9,28 +9,54 @@ /* Begin PBXBuildFile section */ 042568384739493FAD7A09FD /* TrainAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045F4F1EAE4BCD90A7E433 /* TrainAPIService.swift */; }; 117B8C9D0E1F2A3B4C5D6E7F /* FocusedDeparture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 228C9D0E1F2A3B4C5D6E7F80 /* FocusedDeparture.swift */; }; + 1AD2F11980807139DEA59684 /* WidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = W0F100000000000000000002 /* WidgetEntry.swift */; }; + 1B060950AE09D88A3CF1B26A /* SwitchStationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = W0F100000000000000000008 /* SwitchStationIntent.swift */; }; 2165730DFBA03FEFCC0DE59F /* MyStationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02625399D810FA6B5C14FD14 /* MyStationsTests.swift */; }; 222BE6576F81920314253647 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 333CF7681920314253647580 /* MapView.swift */; }; + 289F2B4A5120384E8AFF0E6F /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126C5CC247C946DFB19AA5E7 /* Constants.swift */; }; + 2F8AA1586DEA526CA078D40E /* WidgetAccessoryViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = W0F100000000000000000009 /* WidgetAccessoryViews.swift */; }; 2FD884A23B2F4EAA9E96D717 /* TrainTimeWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9FC67E77924B2C8818045F /* TrainTimeWatchApp.swift */; }; 30228CE572A9425D93AAC33D /* Departure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C41E3FB709401C9161EB46 /* Departure.swift */; }; 339D0E1F2A3B4C5D6E7F8091 /* TrainTimeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44AE1F2A3B4C5D6E7F809102 /* TrainTimeViewModel.swift */; }; + 386411414728D4B73F556D9F /* TransportMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* TransportMode.swift */; }; + 3D21DFD006126D43CBC84E33 /* FavouritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0F00000000000000000002 /* FavouritesStore.swift */; }; 40B38B5AFDDD4E87B015AAB6 /* Station.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0776EECB8D2496AA4A29C74 /* Station.swift */; }; 444DG8920314253647586B92 /* StationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 555EH0A31425364758697CA3 /* StationPickerView.swift */; }; + 490335BD08DD9D60D7F4D9AA /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = W0F100000000000000000003 /* WidgetViews.swift */; }; + 501EF45BF70303C8F50A9A64 /* GeoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE4DE7715F97367EA34B9B9 /* GeoTests.swift */; }; 5258B5558D984B74B36EE8E4 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C443CDA163B84305AC19B8D3 /* LocationService.swift */; }; 55BF2A3B4C5D6E7F80910213 /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C03B4C5D6E7F8091021324 /* HapticService.swift */; }; + 6968BDDAB3674EC5BD06879E /* Favourite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0F00000000000000000001 /* Favourite.swift */; }; + 6ABF896064164BA2E196DCBB /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CD4893706A946E245A1D536 /* Foundation.framework */; }; + 6C1AB31CD092CDF147AAF318 /* FavouritesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E82B7243363FF8FDBAC85C /* FavouritesTests.swift */; }; + 73830162210ABA67F402627C /* WidgetLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998A749F2DFE8813AE120DE /* WidgetLogicTests.swift */; }; + 73F1033328A1C13AABCC2F52 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F678901234567890 /* Secrets.swift */; }; + 77824F9C05F61B09389063F0 /* TrainAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57045F4F1EAE4BCD90A7E433 /* TrainAPIService.swift */; }; 77D14C5D6E7F809102132435 /* GPSIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E25D6E7F80910213243546 /* GPSIndicatorView.swift */; }; + 7E21A2FD80980B8821C505ED /* SwitchModeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = W0F100000000000000000007 /* SwitchModeIntent.swift */; }; 8D78A06DBFD449E5BD8820F5 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 126C5CC247C946DFB19AA5E7 /* Constants.swift */; }; + 903B468600AD31D4C6DC3A2C /* GeoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* GeoUtils.swift */; }; + 96B3EAB37914AD6F54BF1F63 /* Departure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95C41E3FB709401C9161EB46 /* Departure.swift */; }; + 995A5980CFC9C3936C368DC3 /* WidgetEntrySnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545200192D9C010E25E54E72 /* WidgetEntrySnapshotTests.swift */; }; 999C01A2B3C4D5E6F7081920 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888B01A2B3C4D5E6F7081920 /* SettingsView.swift */; }; 99F36E7F8091021324354657 /* ModeIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA47F80910213243546576F /* ModeIndicatorView.swift */; }; 9F8BB8912BE0432E9DEFDF61 /* RoutingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EFDB27D479A403E916113BF /* RoutingService.swift */; }; A1B2C3D4E5F6789012345678 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F678901234567890 /* Secrets.swift */; }; AA1B2C3D4E5F6A7B8C9D0E1F /* GeoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB2C3D4E5F6A7B8C9D0E1F2A /* GeoUtils.swift */; }; AABB01A2B3C4D5E6F7081920 /* TrainTimeViewModel+Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDD01A2B3C4D5E6F7081920 /* TrainTimeViewModel+Navigation.swift */; }; + B42D488117A395EB3FA48A4C /* GPSQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6A7B8C9D0E1F2A3B4C5D6E /* GPSQuality.swift */; }; + B9CBE43DEA01B41E0CDA088F /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 5D45CBFFBA9E760ED0557D71 /* SnapshotTesting */; }; BBB580910213243546576F81 /* TrackingBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC6910213243546576F8192 /* TrackingBarView.swift */; }; C157F67C4DAA4D5F9A099407 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ECAA0CAB08B43EE9F8318B8 /* ContentView.swift */; }; + C29DB44BD26BE3B3E5C92570 /* WidgetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 225E12DB99F8E0FF6C52DDE3 /* WidgetStorageTests.swift */; }; + C71D5AD83F433527CADD0101 /* PhoneComponentSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EED5FAB58AD7A36A727159EA /* PhoneComponentSnapshotTests.swift */; }; CC3D4E5F6A7B8C9D0E1F2A3B /* TransportMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4E5F6A7B8C9D0E1F2A3B4C /* TransportMode.swift */; }; + CD29A0F93FFC675AEE286506 /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = BC53D2410C89B093018DCE7E /* SnapshotTesting */; }; CDFF29E54E2D79903BFD97C4 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CD4893706A946E245A1D536 /* Foundation.framework */; }; + DC0DF392F3916F887E8A0FA3 /* FocusedDeparture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 228C9D0E1F2A3B4C5D6E7F80 /* FocusedDeparture.swift */; }; + DD85640342070AD0AB33CF75 /* ParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A9C142D9AF015FE486CF /* ParsingTests.swift */; }; DDD7A213243546576F819203 /* DirectionArrowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEE8B3243546576F81920314 /* DirectionArrowView.swift */; }; + E2A8F234BE630819E1031F1F /* RefreshIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = W0F100000000000000000004 /* RefreshIntent.swift */; }; EE5F6A7B8C9D0E1F2A3B4C5D /* GPSQuality.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6A7B8C9D0E1F2A3B4C5D6E /* GPSQuality.swift */; }; EE8C7576B9CF469E803E4790 /* StationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE45B7B46148455F9A3E37F4 /* StationView.swift */; }; EEFF01A2B3C4D5E6F7081920 /* TrainTimeViewModel+Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1122334455667788990011AA /* TrainTimeViewModel+Tracking.swift */; }; @@ -40,6 +66,8 @@ F0A100000000000000000004 /* FormationDiagramView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F100000000000000000002 /* FormationDiagramView.swift */; }; F0A100000000000000000005 /* Formation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F100000000000000000001 /* Formation.swift */; }; F0DBC95E77AF41B1B71A8425 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07FBBC2455BC4C3696800498 /* Assets.xcassets */; }; + F42F6B6AA79557220D0AA424 /* Formation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0F100000000000000000001 /* Formation.swift */; }; + F5261D412B808F5400CF1210 /* Station.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0776EECB8D2496AA4A29C74 /* Station.swift */; }; F6FB9DC453C04B64BEF75371 /* DepartureRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583F0998708C4748991F75B5 /* DepartureRowView.swift */; }; FA0A00000000000000000001 /* Favourite.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0F00000000000000000001 /* Favourite.swift */; }; FA0A00000000000000000002 /* FavouritesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0F00000000000000000002 /* FavouritesStore.swift */; }; @@ -106,6 +134,13 @@ remoteGlobalIDString = P0T100000000000000000001; remoteInfo = TrainTimePhone; }; + BDC044855A2F646F7E384455 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1D64EB725CE243E1A4B6CB59 /* Project object */; + proxyType = 1; + remoteGlobalIDString = P0T100000000000000000001; + remoteInfo = TrainTimePhone; + }; P0C100000000000000000001 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 1D64EB725CE243E1A4B6CB59 /* Project object */; @@ -154,22 +189,28 @@ 111AD546576F819203142536 /* FocusedTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedTrackingView.swift; sourceTree = ""; }; 1122334455667788990011AA /* TrainTimeViewModel+Tracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrainTimeViewModel+Tracking.swift"; sourceTree = ""; }; 126C5CC247C946DFB19AA5E7 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 17E82B7243363FF8FDBAC85C /* FavouritesTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FavouritesTests.swift; sourceTree = ""; }; 1ECAA0CAB08B43EE9F8318B8 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 225E12DB99F8E0FF6C52DDE3 /* WidgetStorageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetStorageTests.swift; sourceTree = ""; }; 228C9D0E1F2A3B4C5D6E7F80 /* FocusedDeparture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusedDeparture.swift; sourceTree = ""; }; 240B53D6B2B59672FF00E4EF /* TrainTimePhoneTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TrainTimePhoneTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 2D9FC67E77924B2C8818045F /* TrainTimeWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainTimeWatchApp.swift; sourceTree = ""; }; 333CF7681920314253647580 /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; 44AE1F2A3B4C5D6E7F809102 /* TrainTimeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainTimeViewModel.swift; sourceTree = ""; }; + 545200192D9C010E25E54E72 /* WidgetEntrySnapshotTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetEntrySnapshotTests.swift; sourceTree = ""; }; 555EH0A31425364758697CA3 /* StationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationPickerView.swift; sourceTree = ""; }; 57045F4F1EAE4BCD90A7E433 /* TrainAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrainAPIService.swift; sourceTree = ""; }; 583F0998708C4748991F75B5 /* DepartureRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DepartureRowView.swift; sourceTree = ""; }; 5B51C424F9264A23AD022694 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5EFDB27D479A403E916113BF /* RoutingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingService.swift; sourceTree = ""; }; + 6390A9C142D9AF015FE486CF /* ParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ParsingTests.swift; sourceTree = ""; }; 66C03B4C5D6E7F8091021324 /* HapticService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticService.swift; sourceTree = ""; }; 888B01A2B3C4D5E6F7081920 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 88E25D6E7F80910213243546 /* GPSIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPSIndicatorView.swift; sourceTree = ""; }; 95C41E3FB709401C9161EB46 /* Departure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Departure.swift; sourceTree = ""; }; + 9998A749F2DFE8813AE120DE /* WidgetLogicTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetLogicTests.swift; sourceTree = ""; }; AAA47F80910213243546576F /* ModeIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeIndicatorView.swift; sourceTree = ""; }; + AEE4DE7715F97367EA34B9B9 /* GeoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GeoTests.swift; sourceTree = ""; }; B0776EECB8D2496AA4A29C74 /* Station.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Station.swift; sourceTree = ""; }; B2C3D4E5F678901234567890 /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; BB2C3D4E5F6A7B8C9D0E1F2A /* GeoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoUtils.swift; sourceTree = ""; }; @@ -179,7 +220,9 @@ CCC6910213243546576F8192 /* TrackingBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingBarView.swift; sourceTree = ""; }; CCDD01A2B3C4D5E6F7081920 /* TrainTimeViewModel+Navigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrainTimeViewModel+Navigation.swift"; sourceTree = ""; }; DD4E5F6A7B8C9D0E1F2A3B4C /* TransportMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportMode.swift; sourceTree = ""; }; + EED5FAB58AD7A36A727159EA /* PhoneComponentSnapshotTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PhoneComponentSnapshotTests.swift; sourceTree = ""; }; EEE8B3243546576F81920314 /* DirectionArrowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectionArrowView.swift; sourceTree = ""; }; + EFB8D592491119C7EE87E88B /* TrainTimeWidgetTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TrainTimeWidgetTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F0F100000000000000000001 /* Formation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Formation.swift; sourceTree = ""; }; F0F100000000000000000002 /* FormationDiagramView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormationDiagramView.swift; sourceTree = ""; }; F0F100000000000000000003 /* WatchFormationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchFormationView.swift; sourceTree = ""; }; @@ -218,6 +261,15 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 1F22507020B6D9384892B7EE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6ABF896064164BA2E196DCBB /* Foundation.framework in Frameworks */, + B9CBE43DEA01B41E0CDA088F /* SnapshotTesting in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6DB23342FAD74444B1CB1018 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -230,6 +282,7 @@ buildActionMask = 2147483647; files = ( CDFF29E54E2D79903BFD97C4 /* Foundation.framework in Frameworks */, + CD29A0F93FFC675AEE286506 /* SnapshotTesting in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -314,6 +367,16 @@ name = Frameworks; sourceTree = ""; }; + 3F77B6681CB2DCB3C467C5A3 /* TrainTimeWidgetTests */ = { + isa = PBXGroup; + children = ( + 225E12DB99F8E0FF6C52DDE3 /* WidgetStorageTests.swift */, + 545200192D9C010E25E54E72 /* WidgetEntrySnapshotTests.swift */, + ); + name = TrainTimeWidgetTests; + path = TrainTimeWidgetTests; + sourceTree = ""; + }; 444F8A9B1C2D3E4F5A6B7C8D /* Utilities */ = { isa = PBXGroup; children = ( @@ -339,6 +402,7 @@ P0P100000000000000000001 /* TrainTimePhone.app */, W0P100000000000000000001 /* TrainTimeWidgetExtension.appex */, 240B53D6B2B59672FF00E4EF /* TrainTimePhoneTests.xctest */, + EFB8D592491119C7EE87E88B /* TrainTimeWidgetTests.xctest */, ); name = Products; sourceTree = ""; @@ -361,6 +425,11 @@ isa = PBXGroup; children = ( 02625399D810FA6B5C14FD14 /* MyStationsTests.swift */, + AEE4DE7715F97367EA34B9B9 /* GeoTests.swift */, + 6390A9C142D9AF015FE486CF /* ParsingTests.swift */, + 17E82B7243363FF8FDBAC85C /* FavouritesTests.swift */, + 9998A749F2DFE8813AE120DE /* WidgetLogicTests.swift */, + EED5FAB58AD7A36A727159EA /* PhoneComponentSnapshotTests.swift */, ); name = TrainTimePhoneTests; path = TrainTimePhoneTests; @@ -375,6 +444,7 @@ 620CC025859F476183B6BC85 /* Products */, 2CE0F6908B48538357EEFBB0 /* Frameworks */, 89676CEEB3393D983B30621B /* TrainTimePhoneTests */, + 3F77B6681CB2DCB3C467C5A3 /* TrainTimeWidgetTests */, ); sourceTree = ""; }; @@ -447,6 +517,27 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 227299A02C621B0849FCB58C /* TrainTimeWidgetTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AF5E935BC18277C0347FD5FE /* Build configuration list for PBXNativeTarget "TrainTimeWidgetTests" */; + buildPhases = ( + A6B3ABE84F455E730548F834 /* Sources */, + 1F22507020B6D9384892B7EE /* Frameworks */, + 057D5D86B94FAA6779D94C7B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 6B892EEA7CB8863A29E9248B /* PBXTargetDependency */, + ); + name = TrainTimeWidgetTests; + packageProductDependencies = ( + 5D45CBFFBA9E760ED0557D71 /* SnapshotTesting */, + ); + productName = TrainTimeWidgetTests; + productReference = EFB8D592491119C7EE87E88B /* TrainTimeWidgetTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 6E9FC3E3B0BB4BA189C3C6A1 /* TrainTimeWatch */ = { isa = PBXNativeTarget; buildConfigurationList = 6DD01059674C40C698319380 /* Build configuration list for PBXNativeTarget "TrainTimeWatch" */; @@ -478,6 +569,9 @@ AE4F38B51DCEBBCDAB4BF4FE /* PBXTargetDependency */, ); name = TrainTimePhoneTests; + packageProductDependencies = ( + BC53D2410C89B093018DCE7E /* SnapshotTesting */, + ); productName = TrainTimePhoneTests; productReference = 240B53D6B2B59672FF00E4EF /* TrainTimePhoneTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -547,6 +641,9 @@ Base, ); mainGroup = 8B1A4211B1894783A138DD95; + packageReferences = ( + 93100560CBD94E73F751AC73 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */, + ); productRefGroup = 620CC025859F476183B6BC85 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -555,11 +652,19 @@ P0T100000000000000000001 /* TrainTimePhone */, W0T100000000000000000001 /* TrainTimeWidgetExtension */, 7E502B67F51774F5CD7CE6C8 /* TrainTimePhoneTests */, + 227299A02C621B0849FCB58C /* TrainTimeWidgetTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 057D5D86B94FAA6779D94C7B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 101B8ADDDACA464A97911953 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -632,11 +737,43 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A6B3ABE84F455E730548F834 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1AD2F11980807139DEA59684 /* WidgetEntry.swift in Sources */, + 490335BD08DD9D60D7F4D9AA /* WidgetViews.swift in Sources */, + E2A8F234BE630819E1031F1F /* RefreshIntent.swift in Sources */, + 7E21A2FD80980B8821C505ED /* SwitchModeIntent.swift in Sources */, + 1B060950AE09D88A3CF1B26A /* SwitchStationIntent.swift in Sources */, + 2F8AA1586DEA526CA078D40E /* WidgetAccessoryViews.swift in Sources */, + F5261D412B808F5400CF1210 /* Station.swift in Sources */, + 96B3EAB37914AD6F54BF1F63 /* Departure.swift in Sources */, + 386411414728D4B73F556D9F /* TransportMode.swift in Sources */, + DC0DF392F3916F887E8A0FA3 /* FocusedDeparture.swift in Sources */, + B42D488117A395EB3FA48A4C /* GPSQuality.swift in Sources */, + 77824F9C05F61B09389063F0 /* TrainAPIService.swift in Sources */, + 6968BDDAB3674EC5BD06879E /* Favourite.swift in Sources */, + 3D21DFD006126D43CBC84E33 /* FavouritesStore.swift in Sources */, + 903B468600AD31D4C6DC3A2C /* GeoUtils.swift in Sources */, + 289F2B4A5120384E8AFF0E6F /* Constants.swift in Sources */, + 73F1033328A1C13AABCC2F52 /* Secrets.swift in Sources */, + F42F6B6AA79557220D0AA424 /* Formation.swift in Sources */, + C29DB44BD26BE3B3E5C92570 /* WidgetStorageTests.swift in Sources */, + 995A5980CFC9C3936C368DC3 /* WidgetEntrySnapshotTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; F6AE57485F8B72C34865BEAB /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 2165730DFBA03FEFCC0DE59F /* MyStationsTests.swift in Sources */, + 501EF45BF70303C8F50A9A64 /* GeoTests.swift in Sources */, + DD85640342070AD0AB33CF75 /* ParsingTests.swift in Sources */, + 6C1AB31CD092CDF147AAF318 /* FavouritesTests.swift in Sources */, + 73830162210ABA67F402627C /* WidgetLogicTests.swift in Sources */, + C71D5AD83F433527CADD0101 /* PhoneComponentSnapshotTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -708,6 +845,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 6B892EEA7CB8863A29E9248B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = TrainTimePhone; + target = P0T100000000000000000001 /* TrainTimePhone */; + targetProxy = BDC044855A2F646F7E384455 /* PBXContainerItemProxy */; + }; AE4F38B51DCEBBCDAB4BF4FE /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = TrainTimePhone; @@ -817,6 +960,41 @@ }; name = Release; }; + 307AC011437A482CAD9A4B4E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.evanjt.traintime.widgettests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TrainTimePhone.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TrainTimePhone"; + }; + name = Debug; + }; + 755159C90C02B68AD6BB6FA4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = com.evanjt.traintime.widgettests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TrainTimePhone.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/TrainTimePhone"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 8EAD21F9FCA8994C774D5AEA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1014,6 +1192,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AF5E935BC18277C0347FD5FE /* Build configuration list for PBXNativeTarget "TrainTimeWidgetTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 755159C90C02B68AD6BB6FA4 /* Release */, + 307AC011437A482CAD9A4B4E /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; P0L100000000000000000001 /* Build configuration list for PBXNativeTarget "TrainTimePhone" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1033,6 +1220,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 93100560CBD94E73F751AC73 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-snapshot-testing"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.18.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5D45CBFFBA9E760ED0557D71 /* SnapshotTesting */ = { + isa = XCSwiftPackageProductDependency; + package = 93100560CBD94E73F751AC73 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = SnapshotTesting; + }; + BC53D2410C89B093018DCE7E /* SnapshotTesting */ = { + isa = XCSwiftPackageProductDependency; + package = 93100560CBD94E73F751AC73 /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; + productName = SnapshotTesting; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 1D64EB725CE243E1A4B6CB59 /* Project object */; } diff --git a/apple/TrainTimeWatch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/TrainTimeWatch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..a07abce --- /dev/null +++ b/apple/TrainTimeWatch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "09b08963887ce6eac26952e8acb25256a8ec102f2b5528ae68a97fa296723712", + "pins" : [ + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "b9b59eb58c946236d6f16305c576ad194c36444e", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", + "version" : "1.19.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "cb281f343fd953280336dcbd3822cdf47c182f5b", + "version" : "1.10.0" + } + } + ], + "version" : 3 +} diff --git a/apple/TrainTimeWatch.xcodeproj/xcshareddata/xcschemes/TrainTimePhone.xcscheme b/apple/TrainTimeWatch.xcodeproj/xcshareddata/xcschemes/TrainTimePhone.xcscheme index 2d9f58d..9841711 100644 --- a/apple/TrainTimeWatch.xcodeproj/xcshareddata/xcschemes/TrainTimePhone.xcscheme +++ b/apple/TrainTimeWatch.xcodeproj/xcshareddata/xcschemes/TrainTimePhone.xcscheme @@ -38,6 +38,16 @@ ReferencedContainer = "container:TrainTimeWatch.xcodeproj"> + + + + WidgetDeparture { + WidgetDeparture(destination: dest, departureTimestamp: nowTs + offset, delay: delay, + platform: "3", platformChanged: false, lineNumber: line) + } + + private func result() -> WidgetFetchResult { + let bern = WidgetStation(id: "8507000", name: "Bern", departures: [ + wdep("IC8", "Brig", in: 540), + wdep("IR16", "Zürich HB", in: 300), + wdep("IC6", "Basel SBB", in: 720, delay: 2), + wdep("RE1", "Luzern", in: 900), + wdep("S1", "Thun", in: 1200), + ]) + return WidgetFetchResult(train: [bern], bus: [], tram: [], special: [], + selectedModeRaw: TransportMode.train.rawValue, + selectedStationIndex: 0, fetchTime: now.timeIntervalSince1970) + } + + private func entry(dormant: Bool) -> DepartureEntry { + let favs = [Favourite(stationId: "8507000", stationName: "Bern", lineNumber: "IC8", destination: "Brig")] + return DepartureEntry.make(date: now, result: result(), favourites: favs, + isDormant: dormant, hideFavouritesBlock: false) + } + + private func widget(_ family: WidgetFamily, dormant: Bool = false) -> AnyView { + // widgetFamily is a read-only environment key; WidgetPreviewContext is the supported way + // to render a specific family. + AnyView(WidgetEntryView(entry: entry(dormant: dormant)).previewContext(WidgetPreviewContext(family: family))) + } + + private func assertLightDark(_ view: AnyView, _ name: String, _ width: CGFloat, _ height: CGFloat, + file: StaticString = #file, testName: String = #function, line: UInt = #line) { + for (suffix, style) in [("light", UIUserInterfaceStyle.light), ("dark", .dark)] { + assertSnapshot( + of: view, + as: .image(precision: 0.99, perceptualPrecision: 0.97, + layout: .fixed(width: width, height: height), + traits: .init(userInterfaceStyle: style)), + named: "\(name)_\(suffix)", file: file, testName: testName, line: line + ) + } + } + + func testSmallActive() { assertLightDark(widget(.systemSmall), "widget_small_active", 158, 158) } + func testMediumActive() { assertLightDark(widget(.systemMedium), "widget_medium_active", 338, 158) } + func testLargeActive() { assertLightDark(widget(.systemLarge), "widget_large_active", 338, 354) } + func testMediumDormant() { assertLightDark(widget(.systemMedium, dormant: true), "widget_medium_dormant", 338, 158) } + func testAccessoryRectangular() { assertLightDark(widget(.accessoryRectangular), "widget_accessory_rect", 160, 72) } +} diff --git a/apple/TrainTimeWidgetTests/WidgetStorageTests.swift b/apple/TrainTimeWidgetTests/WidgetStorageTests.swift new file mode 100644 index 0000000..a67e602 --- /dev/null +++ b/apple/TrainTimeWidgetTests/WidgetStorageTests.swift @@ -0,0 +1,37 @@ +import XCTest + +// Widget-extension-only storage transforms. This target compiles the widget's own sources, so the +// types are referenced directly (no import). Covers the pure selection/station rewrites; the +// SharedDefaults-backed flags (stoppedAt/hideFavouritesBlock) are app-group state, left out here. + +final class WidgetStorageTests: XCTestCase { + private func station(_ id: String, _ name: String) -> WidgetStation { + WidgetStation(id: id, name: name, departures: []) + } + + private func result() -> WidgetFetchResult { + WidgetFetchResult( + train: [station("1", "Bern"), station("2", "Thun")], + bus: [station("3", "Köniz")], tram: [], special: [], + selectedModeRaw: TransportMode.train.rawValue, selectedStationIndex: 0, + fetchTime: 1_718_000_000 + ) + } + + func testUpdateSelectionChangesIndicesPreservesData() { + let r = WidgetStorage.updateSelection(result(), modeRaw: TransportMode.bus.rawValue, stationIndex: 1) + XCTAssertEqual(r.selectedModeRaw, TransportMode.bus.rawValue) + XCTAssertEqual(r.selectedStationIndex, 1) + XCTAssertEqual(r.train.map(\.name), ["Bern", "Thun"]) // data preserved + XCTAssertEqual(r.bus.map(\.name), ["Köniz"]) + XCTAssertEqual(r.fetchTime, result().fetchTime) // fetchTime untouched + } + + func testUpdateStationReplacesOnlyTarget() { + let replacement = WidgetStation(id: "9", name: "Biel", departures: []) + let r = WidgetStorage.updateStation(result(), mode: .train, index: 0, station: replacement) + XCTAssertEqual(r.train.map(\.name), ["Biel", "Thun"]) // index 0 replaced + XCTAssertEqual(r.bus.map(\.name), ["Köniz"]) // other modes untouched + XCTAssertEqual(r.selectedModeRaw, result().selectedModeRaw) + } +} diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testAccessoryRectangular.widget_accessory_rect_dark.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testAccessoryRectangular.widget_accessory_rect_dark.png new file mode 100644 index 0000000..84e86ec Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testAccessoryRectangular.widget_accessory_rect_dark.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testAccessoryRectangular.widget_accessory_rect_light.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testAccessoryRectangular.widget_accessory_rect_light.png new file mode 100644 index 0000000..e177a13 Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testAccessoryRectangular.widget_accessory_rect_light.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testLargeActive.widget_large_active_dark.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testLargeActive.widget_large_active_dark.png new file mode 100644 index 0000000..06de1fb Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testLargeActive.widget_large_active_dark.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testLargeActive.widget_large_active_light.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testLargeActive.widget_large_active_light.png new file mode 100644 index 0000000..08609f7 Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testLargeActive.widget_large_active_light.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumActive.widget_medium_active_dark.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumActive.widget_medium_active_dark.png new file mode 100644 index 0000000..d898bf4 Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumActive.widget_medium_active_dark.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumActive.widget_medium_active_light.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumActive.widget_medium_active_light.png new file mode 100644 index 0000000..62bcbe4 Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumActive.widget_medium_active_light.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumDormant.widget_medium_dormant_dark.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumDormant.widget_medium_dormant_dark.png new file mode 100644 index 0000000..72e4957 Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumDormant.widget_medium_dormant_dark.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumDormant.widget_medium_dormant_light.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumDormant.widget_medium_dormant_light.png new file mode 100644 index 0000000..afb5296 Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testMediumDormant.widget_medium_dormant_light.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testSmallActive.widget_small_active_dark.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testSmallActive.widget_small_active_dark.png new file mode 100644 index 0000000..eaa53ee Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testSmallActive.widget_small_active_dark.png differ diff --git a/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testSmallActive.widget_small_active_light.png b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testSmallActive.widget_small_active_light.png new file mode 100644 index 0000000..344c32b Binary files /dev/null and b/apple/TrainTimeWidgetTests/__Snapshots__/WidgetEntrySnapshotTests/testSmallActive.widget_small_active_light.png differ diff --git a/garmin/TrainTime/source/LogicTest.mc b/garmin/TrainTime/source/LogicTest.mc new file mode 100644 index 0000000..62e2825 --- /dev/null +++ b/garmin/TrainTime/source/LogicTest.mc @@ -0,0 +1,55 @@ +using Toybox.Test; +using Toybox.Application.Storage; + +// Geo, parsing and favourites logic. Built only with `monkeyc -t`. + +(:test) +function testDistanceBetweenRealStations(logger) { + // Place de la Planta -> Gare de Sion, ~376 m by the flat-earth model. + var d = GeoMath.calculateDistance(46.2306, 7.3576, 46.2275, 7.3596); + return d >= 375 && d <= 377; +} + +(:test) +function testDistanceIsZeroAtSamePoint(logger) { + return GeoMath.calculateDistance(46.23, 7.36, 46.23, 7.36) == 0; +} + +(:test) +function testBearingPointsNorth(logger) { + var b = GeoMath.calculateBearing(46.0, 6.0, 47.0, 6.0); + return b > -0.01 && b < 0.01; +} + +(:test) +function testParseStationGroup(logger) { + var data = { + "train" => [ + { "id" => "8501120", "name" => "Lausanne", "lat" => 46.516, "lon" => 6.629, "dist" => 250 }, + ], + }; + var stations = ApiHandler.parseStationGroup(data, "train"); + if (stations.size() != 1) { return false; } + var s = stations[0]; + return s["id"].equals("8501120") && s["label"].equals("Lausanne") && s["dist"] == 250; +} + +(:test) +function testParseDepartureArray(logger) { + var deps = [ + { "to" => "Brig", "category" => "IC", "number" => "IC8", "departure" => 1718000600, "delay" => 2, "platform" => "3" }, + ]; + var parsed = ApiHandler.parseDepartureArray(deps); + if (parsed.size() != 1) { return false; } + var d = parsed[0]; + return d["dest"].equals("Brig") && d["line"].equals("IC8") && d["depTs"] == 1718000600 && d["delay"] == 2; +} + +(:test) +function testBuildFavouritesParam(logger) { + Storage.deleteValue("favourites"); + FavouritesManager.addFavourite("8501120", "IC8", "Brig", "Lausanne"); + var param = FavouritesManager.buildFavouritesParam("8501120"); + Storage.deleteValue("favourites"); + return param != null && param.equals("IC8:Brig"); +}