diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cd5486..4835280 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -645,24 +645,28 @@ jobs: # with them. Each app's list of what its release ships is held to what it # really resolves, and a notice copied from one release of a library is # held to that release, so neither can drift out of date unnoticed. The - # watch's rules for its tile and complication, and the car's reading of - # what is typed into it, are tested here too. + # car's screens, every template it is handed and what it hands on, and its + # reading of what is typed into it, are tested here too; the watch's tile + # and complication run with its screens below. - name: Every library each release ships is in its notices - run: ./gradlew --no-daemon :app:checkNotices :wear:checkNotices :automotive:checkNotices :notices:testDebugUnitTest :wear:testDebugUnitTest :car:testDebugUnitTest + run: ./gradlew --no-daemon :app:checkNotices :wear:checkNotices :automotive:checkNotices :notices:testDebugUnitTest :car:testDebugUnitTest # Every screen drawn here, by Android's own graphics stack under - # Robolectric, and compared with the image kept in the repository: light - # and dark, a small phone to a tablet, the largest text, a longer language - # and right to left. A screen that moved fails, and the old, the new and - # the difference between them are kept below. The app's other unit tests - # run in the same pass. + # Robolectric, and compared with the image kept in the repository: the + # phone in light and dark, a small phone to a tablet, the largest text, a + # longer language and right to left; and the watch on a round face, its + # tile as the watch's own renderer draws it. A screen that moved fails, + # and the old, the new and the difference between them are kept below. + # Each app's other unit tests run in the same pass. - name: Every screen matches its reference image - run: ./gradlew --no-daemon :app:verifyRoborazziDebug + run: ./gradlew --no-daemon :app:verifyRoborazziDebug :wear:verifyRoborazziDebug - name: Keep the screens that no longer match if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: screens-that-moved - path: android/app/build/outputs/roborazzi + path: | + android/app/build/outputs/roborazzi + android/wear/build/outputs/roborazzi if-no-files-found: ignore # Lint's errors fail the build in every module; its warnings do not. The # errors it has found so far were screens reading the device's language, diff --git a/android/TESTING.md b/android/TESTING.md index 921cc0a..b900147 100644 --- a/android/TESTING.md +++ b/android/TESTING.md @@ -16,7 +16,7 @@ world, is set out at the end as a guide to work through before each release. | Design | Every screen against a reference image: light and dark, a small phone, a phone, a foldable and a tablet, the largest text, a longer language and right to left | `app/src/test/kotlin/com/gridpointcode/screens`, images in `app/src/test/screenshots` | CI, every PR | | Robustness | StrictMode and leak detection around every device test, a seeded stress run of random input, the process ended and the screen put back | `NoStrictModeViolations`, LeakCanary, `RandomInputTest`, `app/src/test/.../ProcessDeathTest.kt` | CI, every PR | | Performance | The baseline profile the release carries, and benchmarks of start-up and frames with it and without it | `benchmark`, `app/src/release/generated/baselineProfiles` | CI checks the profile reaches the release; benchmarks by hand | -| The watch and the car | Their screens, the tile, the complication and the car's templates | next | | +| The watch and the car | The car's templates and what they hand on; the watch's tile and complication, their timelines and how they draw; the watch's screens on a round face | `car/src/test`, `wear/src/test`, images in `wear/src/test/screenshots` | CI, every PR | ## Running them @@ -159,6 +159,33 @@ folder when the check fails. waiting to be compiled. Whether the profile helps a reader is for a real phone to say, a low-end one most of all; see below. +### How the watch and the car are checked + +- **The car's templates.** A car draws an app's screens itself, from templates, + and refuses one that breaks its rules. `CarScreensTest` hands each screen to + the Car App Library's own test car and reads what comes back, row by row and + action by action: a saved place's code, how it is said, how far and which way + with the directions to the door; where the car is, with how close and no way + to navigate to it; the list before a fix, newest first; a car with no phone; + search, a code offered as it is typed and a short form waiting for a fix; and + a car older than level 7. Navigate is pressed, and what it hands the driver's + navigation app is checked: a geo URI of the place, never the code. +- **The watch's tile and complication.** Each is asked as a watch asks, over a + last fix planted in their store: the place for its hour, then the offer to + find one, both in one timeline whose first entry ends when the hour does. An + entry left without an end ends at zero, and the watch drew nothing; the tests + hold that. The tile is drawn by the Tiles renderer the watch itself uses, on a + round face, at a time held still, and kept as an image. +- **The watch's screens.** Here, asking for location, finding, and with a place; + the saved places with their arrows; walking to one. Drawn on a round face + with the time along the top held at ten past ten, and verified with the + phone's images by `:wear:verifyRoborazziDebug`. +- **One process, many tests.** Robolectric keeps the process from one test to + the next. Android's default factory for view models is kept for the process + too, so it handed every later watch test the first test's application, and + its files; the watch's screen tests make their view model with a factory of + their own. + ### How the device tests are written - **As a reader, not as the code.** `Reader` drives the app by what the screen diff --git a/android/car/build.gradle.kts b/android/car/build.gradle.kts index ba81c4e..a6edd09 100644 --- a/android/car/build.gradle.kts +++ b/android/car/build.gradle.kts @@ -18,6 +18,13 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + testOptions { + // The templates are built from the car module's own strings and icons. + unitTests.isIncludeAndroidResources = true + // Robolectric's Android 16 reaches into the JDK's own file descriptors. + unitTests.all { it.jvmArgs("--add-opens=java.base/jdk.internal.access=ALL-UNNAMED") } + } + sourceSets { getByName("main") { // The palette as the resources design/build-tokens.mjs writes, day and @@ -37,4 +44,7 @@ dependencies { api(libs.kotlinx.coroutines.core) testImplementation(libs.kotlin.test.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.car.app.testing) } diff --git a/android/car/src/test/kotlin/com/gridpointcode/car/CarScreensTest.kt b/android/car/src/test/kotlin/com/gridpointcode/car/CarScreensTest.kt new file mode 100644 index 0000000..2b69ecf --- /dev/null +++ b/android/car/src/test/kotlin/com/gridpointcode/car/CarScreensTest.kt @@ -0,0 +1,173 @@ +package com.gridpointcode.car + +import android.content.Context +import android.os.Looper +import androidx.car.app.CarContext +import androidx.car.app.HandshakeInfo +import androidx.car.app.versioning.CarAppApiLevels +import androidx.car.app.OnDoneCallback +import androidx.car.app.model.Action +import androidx.car.app.model.Distance +import androidx.car.app.model.DistanceSpan +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row +import androidx.car.app.model.SearchTemplate +import androidx.car.app.testing.TestCarContext +import androidx.car.app.testing.TestScreenManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.gridpointcode.core.Point +import com.gridpointcode.core.SavedPlace +import com.gridpointcode.core.Source +import com.gridpointcode.core.aloud +import com.gridpointcode.core.formatted +import com.gridpointcode.core.metresBetween +import com.gridpointcode.core.selectionOf +import com.gridpointcode.core.spellingFor +import java.util.Locale +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf + +/** + * The car's screens as the car is handed them: templates, checked row by row + * and action by action on the Car App Library's own test car. A car draws a + * template in its own style and refuses one that breaks its rules, so what is + * checked is what each screen says and does, and what it hands to other apps. + */ +@RunWith(AndroidJUnit4::class) +class CarScreensTest { + + private val car = carAt(CarAppApiLevels.LEVEL_7) + + /** A test car that has shaken hands at [level], as a real one does before it asks for a template. */ + private fun carAt(level: Int) = TestCarContext.createCarContext(ApplicationProvider.getApplicationContext()).apply { + updateHandshakeInfo(HandshakeInfo("com.gridpointcode.test.host", level)) + } + private val speaker by lazy { CarSpeaker(car) } + private val spelling = spellingFor(Locale.ENGLISH) + + private val market = SavedPlace("G3RJM8X3L1", "Market, north door", "North door, beside the bakery stall", savedAt = 1) + private val kensington = SavedPlace("G3RJL5FRCR", "Kensington Market", "", savedAt = 2) + + /** The car at Union Station, to within four metres. */ + private val union = CarFix(Point(43.64546, -79.38063), 4) + + private fun places( + vararg saved: SavedPlace, + savedHere: Boolean = true, + notices: ((Context) -> String)? = null, + ) = CarPlaces(MutableStateFlow(saved.toList()), { spelling }, savedHere, notices) + + private fun Row.lines(): List = listOf(title.toString()) + texts.map { it.toString() } + + private fun click(action: Action) = sent { action.onClickDelegate!!.sendClick(it) } + + private fun click(row: Row) = sent { row.onClickDelegate!!.sendClick(it) } + + /** Sends a host's call into the app and lets the main thread run it, as the car's would. */ + private fun sent(call: (OnDoneCallback) -> Unit) { + call(object : OnDoneCallback {}) + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun aSavedPlaceSaysItsCodeHowFarWhichWayAndHowToFindTheDoor() { + val template = PlaceScreen(car, Shown.saved(market), union, places(market), speaker).onGetTemplate() as PaneTemplate + val rows = template.pane.rows + assertEquals(listOf(formatted(market.code), aloud(market.code, spelling)), rows[0].lines()) + // The car writes the distance itself, from a span, in the driver's units. + val far = rows[1].title!!.spans.single().carSpan as DistanceSpan + val metres = metresBetween(union.point, selectionOf(market.code, Source.SAVED).point) + assertTrue("the market is under a kilometre from the station: $metres", metres < 1000) + assertEquals(Distance.UNIT_METERS, far.distance.displayUnit) + assertEquals(Math.round(metres).toDouble(), far.distance.displayDistance, 0.0) + assertEquals("north-east · North door, beside the bakery stall", rows[1].texts.single().toString()) + assertEquals(listOf("Navigate", "Read aloud"), template.pane.actions.map { it.title.toString() }) + assertEquals("Market, north door", template.header?.title?.toString() ?: template.title.toString()) + } + + @Test + fun navigateHandsThePointToTheDriversOwnNavigationApp() { + val template = PlaceScreen(car, Shown.saved(market), union, places(market), speaker).onGetTemplate() as PaneTemplate + click(template.pane.actions.first { it.title.toString() == "Navigate" }) + val sent = car.startCarAppIntents.single() + val point = selectionOf(market.code, Source.SAVED).point + assertEquals(CarContext.ACTION_NAVIGATE, sent.action) + assertEquals(String.format(Locale.ROOT, "geo:%.6f,%.6f", point.latitude, point.longitude), sent.dataString) + } + + @Test + fun whereTheCarIsSaysHowCloseAndOffersNoWayToNavigateThere() { + val template = PlaceScreen(car, Shown.here(union), union, places(), speaker).onGetTemplate() as PaneTemplate + assertEquals(formatted(union.code), template.pane.rows[0].title.toString()) + assertEquals("±4 m", template.pane.rows[1].title.toString()) + assertEquals(listOf("Read aloud"), template.pane.actions.map { it.title.toString() }) + assertEquals("Where am I", template.header?.title?.toString() ?: template.title.toString()) + } + + @Test + fun beforeAFixTheSavedPlacesAreAListNewestFirstUnderWhereTheCarIs() { + // As the shelf keeps them: the newest first. + val template = PlacesScreen(car, places(kensington, market), speaker).onGetTemplate() as ListTemplate + val rows = template.singleList!!.items.map { it as Row } + assertEquals("Where am I", rows[0].title.toString()) + assertEquals("Allow location to find where the car is.", rows[0].texts.single().toString()) + assertEquals(listOf("Kensington Market", "Market, north door"), rows.drop(1).map { it.title.toString() }) + assertEquals(formatted(market.code), rows[2].texts.last().toString()) + } + + @Test + fun anOlderCarGetsTheTitleTheWayItKnows() { + val older = carAt(CarAppApiLevels.LEVEL_6) + val template = PlaceScreen(older, Shown.saved(market), union, places(market), CarSpeaker(older)).onGetTemplate() as PaneTemplate + // Built the way a car before level 7 takes it, with the title and the + // header action set on their own, and without asking for a header. + @Suppress("DEPRECATION") + assertEquals("Market, north door", template.title.toString()) + } + + @Test + fun aSavedPlaceOpensItsOwnScreen() { + val template = PlacesScreen(car, places(market), speaker).onGetTemplate() as ListTemplate + click(template.singleList!!.items[1] as Row) + val pushed = car.getCarService(TestScreenManager::class.java).screensPushed.single() + val opened = (pushed as PlaceScreen).onGetTemplate() as PaneTemplate + assertEquals(formatted(market.code), opened.pane.rows[0].title.toString()) + } + + @Test + fun aCarWithNoPhoneSaysWhereTheSavedPlacesAre() { + val template = PlacesScreen(car, places(savedHere = false, notices = { "The notices." }), speaker).onGetTemplate() as ListTemplate + val rows = template.singleList!!.items.map { it as Row } + assertEquals("Saved places come from the phone app, with Android Auto.", rows.last().title.toString()) + val actions = template.actionStrip!!.actions + assertEquals(2, actions.size) + assertEquals("Notices", actions.last().title.toString()) + } + + @Test + fun aCodeTypedIsOfferedAsItIsTypedAndOpensWhenSent() { + val screen = SearchScreen(car, union, places(), speaker) + val empty = screen.onGetTemplate() as SearchTemplate + assertEquals("The place appears here as it is typed.", empty.itemList!!.noItemsMessage.toString()) + sent { empty.searchCallbackDelegate.sendSearchTextChanged("g3rjm 8x3l1", it) } + val offered = (screen.onGetTemplate() as SearchTemplate).itemList!!.items.single() as Row + assertEquals(listOf(formatted(market.code), "Open"), offered.lines()) + sent { empty.searchCallbackDelegate.sendSearchSubmitted("g3rjm 8x3l1", it) } + assertTrue(car.getCarService(TestScreenManager::class.java).screensPushed.single() is PlaceScreen) + } + + @Test + fun aShortFormWaitsForAFixRatherThanNamingADoorElsewhere() { + val screen = SearchScreen(car, null, places(), speaker) + val template = screen.onGetTemplate() as SearchTemplate + sent { template.searchCallbackDelegate.sendSearchTextChanged("-8X3L1", it) } + val row = (screen.onGetTemplate() as SearchTemplate).itemList!!.items.single() as Row + assertEquals("A short form is read against where the car is, which is not known yet.", row.title.toString()) + } +} diff --git a/android/car/src/test/resources/robolectric.properties b/android/car/src/test/resources/robolectric.properties new file mode 100644 index 0000000..5de1f94 --- /dev/null +++ b/android/car/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# Android 16, the release the device tests also run on. +sdk=36 diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a6ac26f..c6d7cdb 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -118,6 +118,12 @@ leakcanary-android-instrumentation = { module = "com.squareup.leakcanary:leakcan androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "profileinstaller" } androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "benchmark" } +# The car's templates and the watch's tile, checked on the computer: the Car App +# Library's test car, and the tile service's test client and renderer. +car-app-testing = { module = "androidx.car.app:app-testing", version.ref = "carApp" } +wear-tiles-testing = { module = "androidx.wear.tiles:tiles-testing", version.ref = "wearTiles" } +wear-tiles-renderer = { module = "androidx.wear.tiles:tiles-renderer", version.ref = "wearTiles" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/android/wear/build.gradle.kts b/android/wear/build.gradle.kts index 42c5c10..ed384d1 100644 --- a/android/wear/build.gradle.kts +++ b/android/wear/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.roborazzi) } // The same four values the phone app is signed with for upload, and the same @@ -42,6 +43,14 @@ android { compose = true } + testOptions { + // The watch's screens, tile and complication, drawn and read on the + // computer, need its strings, fonts and drawables. + unitTests.isIncludeAndroidResources = true + // Robolectric's Android 16 reaches into the JDK's own file descriptors. + unitTests.all { it.jvmArgs("--add-opens=java.base/jdk.internal.access=ALL-UNNAMED") } + } + signingConfigs { if (upload.values.all { it != null }) { create("upload") { @@ -102,7 +111,27 @@ dependencies { implementation(libs.androidx.concurrent.futures) testImplementation(libs.kotlin.test.junit) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.compose.ui.test.junit4) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) + testImplementation(libs.wear.tiles.testing) + // The renderer the watch draws tiles with, for the tile's reference images. + // In the debug build rather than the tests alone, because a library's own + // resources are made only for a build it is part of; the release never has it. + debugImplementation(libs.wear.tiles.renderer) + debugImplementation(libs.compose.ui.test.manifest) } // Holds the watch's notices to what its release really ships. apply(from = rootProject.file("gradle/notices.gradle.kts")) + +// The watch's reference images, beside the phone's: its screens on a round +// face and its tile as the watch draws it. ./gradlew :wear:recordRoborazziDebug +// draws them again; :wear:verifyRoborazziDebug, which CI runs, compares. +roborazzi { + outputDir.set(file("src/test/screenshots")) +} diff --git a/android/wear/src/main/kotlin/com/gridpointcode/wear/CodeTile.kt b/android/wear/src/main/kotlin/com/gridpointcode/wear/CodeTile.kt index 3d52fcf..d1c30ac 100644 --- a/android/wear/src/main/kotlin/com/gridpointcode/wear/CodeTile.kt +++ b/android/wear/src/main/kotlin/com/gridpointcode/wear/CodeTile.kt @@ -40,12 +40,16 @@ import java.util.Date * entry of its timeline. Read aloud opens the app, which reads the next place * it finds: where the wrist is now, not the code on the tile. */ -class CodeTile : TileService() { +class CodeTile( + // The time, for a test to hold still. The system makes the tile with none + // given, through the constructor with no arguments Kotlin writes for it. + private val clock: () -> Long = System::currentTimeMillis, +) : TileService() { override fun onTileRequest(request: RequestBuilders.TileRequest): ListenableFuture { val device = request.deviceConfiguration val last = LastFixStore(this).read() - val now = System.currentTimeMillis() + val now = clock() val timeline = TimelineBuilders.Timeline.Builder() if (last != null && last.shown(now)) { timeline.addTimelineEntry(entry(found(this, device, last), end = last.shownUntil)) diff --git a/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchModel.kt b/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchModel.kt index 5827941..ed15e21 100644 --- a/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchModel.kt +++ b/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchModel.kt @@ -92,7 +92,8 @@ class WatchModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch { shelf.save(SavedPlace(here.code, label = "", note = "", savedAt = System.currentTimeMillis())) } } - private fun arrived(fix: WatchFix) { + /** A fix from the location listener, or from a test standing in for it. */ + internal fun arrived(fix: WatchFix) { found.value = fix readAfter?.let { asked -> if (fix.at >= asked) { diff --git a/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchScreens.kt b/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchScreens.kt index f34d2c5..d2c2aaf 100644 --- a/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchScreens.kt +++ b/android/wear/src/main/kotlin/com/gridpointcode/wear/WatchScreens.kt @@ -40,6 +40,7 @@ import androidx.wear.compose.material3.ListHeader import androidx.wear.compose.material3.MaterialTheme import androidx.wear.compose.material3.ScreenScaffold import androidx.wear.compose.material3.Text +import androidx.wear.compose.material3.TimeText import androidx.wear.compose.navigation.SwipeDismissableNavHost import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavController @@ -111,10 +112,19 @@ private const val SAVED_ROUTE = "saved" private const val WALK_ROUTE = "walk" private const val NOTICES_ROUTE = "notices" +/** + * The watch app's screens. [timeText] is the time along the top, the watch's + * own unless a test holds it still. + */ @Composable -fun WatchApp(model: WatchModel, speaker: WatchSpeaker, onAllow: () -> Unit) { +fun WatchApp( + model: WatchModel, + speaker: WatchSpeaker, + onAllow: () -> Unit, + timeText: @Composable () -> Unit = { TimeText() }, +) { MaterialTheme(colorScheme = WatchColours) { - AppScaffold { + AppScaffold(timeText = timeText) { val navigation = rememberSwipeDismissableNavController() SwipeDismissableNavHost(navController = navigation, startDestination = HERE_ROUTE) { composable(HERE_ROUTE) { diff --git a/android/wear/src/test/kotlin/com/gridpointcode/wear/CodeComplicationTest.kt b/android/wear/src/test/kotlin/com/gridpointcode/wear/CodeComplicationTest.kt new file mode 100644 index 0000000..6e3798c --- /dev/null +++ b/android/wear/src/test/kotlin/com/gridpointcode/wear/CodeComplicationTest.kt @@ -0,0 +1,82 @@ +package com.gridpointcode.wear + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.wear.watchface.complications.data.ComplicationData +import androidx.wear.watchface.complications.data.ComplicationType +import androidx.wear.watchface.complications.data.ShortTextComplicationData +import androidx.wear.watchface.complications.datasource.ComplicationRequest +import java.time.Instant +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric + +/** + * The complication, as the watch face asks for it: the short form of the last + * place the app found, for an hour, and after that, and before any, an offer to + * find one. The face is never asked to update to get from one to the other: the + * timeline carries both, the code's entry ending when its hour does. + */ +@RunWith(AndroidJUnit4::class) +class CodeComplicationTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + private val complication = Robolectric.buildService(CodeComplication::class.java).create().get() + private val now = System.currentTimeMillis() + + private fun asked(type: ComplicationType = ComplicationType.SHORT_TEXT) = + runBlocking { complication.onComplicationRequest(ComplicationRequest(1, type, false)) } + + private fun ComplicationData.words(at: Long = now): String = + (this as ShortTextComplicationData).text.getTextAt(app.resources, Instant.ofEpochMilli(at)).toString() + + @Test + fun aRecentFixShowsItsShortFormUntilItsHourIsUp() { + val found = LastFix("G3RJM8X3L1", 4, now - 10 * 60 * 1000L) + LastFixStore(app).write(found) + val timeline = asked()!! + val entry = timeline.timelineEntries!!.single() + assertEquals("-8X3L1", entry.complicationData.words()) + // Ended when its hour is, never left open: an open end ended at zero, + // and the face found nothing to show. + assertEquals(found.at, entry.validity.start.toEpochMilli()) + assertEquals(found.shownUntil, entry.validity.end.toEpochMilli()) + assertEquals("Find", timeline.defaultComplicationData.words()) + } + + @Test + fun aScreenReaderHearsTheShortFormSaid() { + LastFixStore(app).write(LastFix("G3RJM8X3L1", 4, now)) + val data = asked()!!.timelineEntries!!.single().complicationData as ShortTextComplicationData + assertEquals("Short form -8X3L1", data.contentDescription!!.getTextAt(app.resources, Instant.ofEpochMilli(now)).toString()) + } + + @Test + fun withNoFixItOffersToFindOne() { + val timeline = asked()!! + assertTrue(timeline.timelineEntries.isNullOrEmpty()) + assertEquals("Find", timeline.defaultComplicationData.words()) + } + + @Test + fun aFixOlderThanAnHourIsNotShown() { + LastFixStore(app).write(LastFix("G3RJM8X3L1", 4, now - 2 * LastFix.SHOWN_FOR_MS)) + assertTrue(asked()!!.timelineEntries.isNullOrEmpty()) + } + + @Test + fun onlyShortTextIsOffered() { + assertNull(asked(ComplicationType.LONG_TEXT)) + assertNull(complication.getPreviewData(ComplicationType.RANGED_VALUE)) + } + + @Test + fun theFacesPickerShowsTheSpecificationsExample() { + assertEquals("-98NM9", complication.getPreviewData(ComplicationType.SHORT_TEXT)!!.words()) + } +} diff --git a/android/wear/src/test/kotlin/com/gridpointcode/wear/CodeTileTest.kt b/android/wear/src/test/kotlin/com/gridpointcode/wear/CodeTileTest.kt new file mode 100644 index 0000000..be5b215 --- /dev/null +++ b/android/wear/src/test/kotlin/com/gridpointcode/wear/CodeTileTest.kt @@ -0,0 +1,165 @@ +package com.gridpointcode.wear + +import android.app.Application +import android.graphics.Color +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.wear.protolayout.DeviceParametersBuilders +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.tiles.RequestBuilders +import androidx.wear.protolayout.TimelineBuilders +import androidx.wear.tiles.renderer.TileRenderer +import androidx.wear.tiles.testing.TestTileClient +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.MoreExecutors +import java.time.Duration +import java.util.TimeZone +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The tile, as a watch asks for it and draws it: the last place found, for an + * hour, then the offer to open the app, both in one timeline so the watch need + * not ask again to move from one to the other. Drawn by the Tiles renderer the + * watch itself uses, on a round face, and kept as reference images. + */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = ROUND_WATCH) +class CodeTileTest { + + @get:Rule + val roborazzi = RoborazziRule(options = RoborazziRule.Options(roborazziOptions = RoborazziOptions())) + + private val app: Application = ApplicationProvider.getApplicationContext() + + /** 13:41 UTC on 2 October 2026, held still so the time the tile shows is the same in every run. */ + private val now = 1_790_948_460_000L + + // The test client gives the tile its context and creates it, as the watch does. + private val client by lazy { TestTileClient(CodeTile { now }, MoreExecutors.directExecutor()) } + + private val device = DeviceParametersBuilders.DeviceParameters.Builder() + .setScreenWidthDp(227) + .setScreenHeightDp(227) + .setScreenDensity(2f) + // Left out, it is nought, and every size that follows the reader's text + // setting came out infinite: the code and the button's word drew as nothing. + .setFontScale(1f) + .setScreenShape(DeviceParametersBuilders.SCREEN_SHAPE_ROUND) + .setDevicePlatform(DeviceParametersBuilders.DEVICE_PLATFORM_WEAR_OS) + .build() + + private var zone: TimeZone? = null + + @Before + fun holdTheZone() { + zone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @After + fun giveTheZoneBack() { + TimeZone.setDefault(zone) + } + + /** + * The answer, once the main thread has run the work that makes it. Under + * Robolectric the test is the main thread, so waiting on it would wait on + * itself. + */ + private fun ListenableFuture.answered(): T { + repeat(ANSWER_ROUNDS) { + if (isDone) return get() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(10)) + } + return get() + } + + private fun entries(): List = + client.requestTile(RequestBuilders.TileRequest.Builder().setDeviceConfiguration(device).build()).answered() + .tileTimeline!!.timelineEntries + + /** An entry drawn as the watch draws it, on a round face the size of one. */ + private fun drawn(entry: TimelineBuilders.TimelineEntry): ViewGroup { + val resources = client.requestTileResourcesAsync(RequestBuilders.ResourcesRequest.Builder().setVersion("1").build()).answered() + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + // A watch's screen is black where the tile draws nothing. + val face = FrameLayout(activity).apply { setBackgroundColor(Color.BLACK) } + activity.setContentView(face, ViewGroup.LayoutParams(FACE_PX, FACE_PX)) + val renderer = TileRenderer(activity, MoreExecutors.directExecutor()) {} + renderer.inflateAsync(entry.layout!!, resources ?: ResourceBuilders.Resources.Builder().setVersion("1").build(), face).answered() + shadowOf(Looper.getMainLooper()).idle() + return face + } + + private fun View.words(): List = when (this) { + is TextView -> listOf(text.toString()) + is ViewGroup -> (0 until childCount).flatMap { getChildAt(it).words() } + else -> emptyList() + } + + @Test + fun aRecentFixIsShownForItsHourThenTheOfferToOpenTheApp() { + val found = LastFix("G3RJM8X3L1", 4, now - 10 * 60 * 1000L) + LastFixStore(app).write(found) + val (place, after) = entries() + // The place until its hour is up; the offer from then on, to the end of + // time: an entry left without an end ends at zero, and the watch drew + // the tile empty. + assertEquals(found.shownUntil, place.validity!!.endMillis) + assertEquals(found.shownUntil, after.validity!!.startMillis) + assertEquals(Long.MAX_VALUE, after.validity!!.endMillis) + val words = drawn(place).words() + assertTrue("the code in two halves: $words", "#G3RJM" in words && "8X3L1" in words) + assertTrue("$words", "Open the app to find where you are." in drawn(after).words()) + } + + @Test + fun withNoFixTheTileOffersToOpenTheApp() { + val only = entries().single() + assertNull(only.validity) + assertTrue("Open the app to find where you are." in drawn(only).words()) + } + + @Test + fun theTileWithAPlace() { + LastFixStore(app).write(LastFix("G3RJM8X3L1", 4, now - 10 * 60 * 1000L)) + drawn(entries().first()).captureRoboImage() + } + + @Test + fun theTileWithNoPlace() { + drawn(entries().single()).captureRoboImage() + } + + private companion object { + /** A round face 454 pixels across. */ + const val FACE_PX = 454 + + /** Ten seconds of the main thread's time, in steps. */ + const val ANSWER_ROUNDS = 1_000 + } +} + +/** A round watch, as the Pixel Watch is. */ +const val ROUND_WATCH = "w227dp-h227dp-small-notlong-round-watch-xhdpi-keyshidden-nonav" diff --git a/android/wear/src/test/kotlin/com/gridpointcode/wear/WatchScreensTest.kt b/android/wear/src/test/kotlin/com/gridpointcode/wear/WatchScreensTest.kt new file mode 100644 index 0000000..4944cf7 --- /dev/null +++ b/android/wear/src/test/kotlin/com/gridpointcode/wear/WatchScreensTest.kt @@ -0,0 +1,122 @@ +package com.gridpointcode.wear + +import android.Manifest +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.ViewModelProvider +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.wear.compose.material3.TimeSource +import androidx.wear.compose.material3.TimeText +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziRule +import com.github.takahirom.roborazzi.captureScreenRoboImage +import com.gridpointcode.core.Point +import com.gridpointcode.core.SavedPlace +import com.gridpointcode.core.encodeSaved +import java.io.File +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The watch's screens on a round face, for the design's reference images: the + * code where the wrist is, the saved places with their arrows, and walking to + * one. The time along the top is held at ten past ten, and the saved places are + * the ones the watch keeps in its file when the phone is out of reach. + */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(qualifiers = ROUND_WATCH) +class WatchScreensTest { + + @get:Rule + val rule = createEmptyComposeRule() + + @get:Rule + val roborazzi = RoborazziRule(options = RoborazziRule.Options(roborazziOptions = RoborazziOptions())) + + private val app: Application = ApplicationProvider.getApplicationContext() + + private val market = SavedPlace("G3RJM8X3L1", "Market, north door", "North door, beside the bakery stall", savedAt = 3) + private val tower = SavedPlace("G3RJM0M67J", "Tower base", "", savedAt = 2) + private val kensington = SavedPlace("G3RJL5FRCR", "Kensington Market", "", savedAt = 1) + + /** At Union Station, to within four metres. */ + private val union = WatchFix(Point(43.64546, -79.38063), 4, at = 1_790_948_460_000L) + + /** Ten past ten, the hour a watch is shown at. */ + private val stillTime: @Composable () -> Unit = { + TimeText(timeSource = object : TimeSource { + @Composable + override fun currentTime(): String = "10:09" + }) + } + + /** + * Opens the watch app with location [permitted] or not, the [saved] places + * in its file, and [fix] where the watch is, if found. + */ + private fun open(permitted: Boolean, saved: List = emptyList(), fix: WatchFix? = null) { + if (permitted) shadowOf(app).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + File(app.filesDir, WatchShelf.CACHE).writeText(encodeSaved(saved)) + ActivityScenario.launch(ComponentActivity::class.java).onActivity { activity -> + // Made by a factory of this test's own: the default one is kept for + // the whole process, which Robolectric keeps from test to test, and + // it handed every later test the first test's application and files. + val model = ViewModelProvider(activity, ViewModelProvider.AndroidViewModelFactory(app))[WatchModel::class.java] + activity.setContent { WatchApp(model, WatchSpeaker(activity), onAllow = {}, timeText = stillTime) } + fix?.let(model::arrived) + } + rule.waitForIdle() + } + + @OptIn(ExperimentalRoborazziApi::class) + private fun record() { + rule.waitForIdle() + captureScreenRoboImage() + } + + @Test + fun hereAskingForLocation() { + open(permitted = false) + record() + } + + @Test + fun hereFinding() { + open(permitted = true) + record() + } + + @Test + fun hereWithAPlace() { + open(permitted = true, fix = union) + record() + } + + @Test + fun savedPlacesNearestFirst() { + open(permitted = true, saved = listOf(market, tower, kensington), fix = union) + rule.onNodeWithText(app.getString(R.string.saved_open)).performClick() + record() + } + + @Test + fun walkingToASavedPlace() { + open(permitted = true, saved = listOf(market, tower, kensington), fix = union) + rule.onNodeWithText(app.getString(R.string.saved_open)).performClick() + rule.onNodeWithText("Market, north door").performClick() + record() + } +} diff --git a/android/wear/src/test/resources/robolectric.properties b/android/wear/src/test/resources/robolectric.properties new file mode 100644 index 0000000..5de1f94 --- /dev/null +++ b/android/wear/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# Android 16, the release the device tests also run on. +sdk=36 diff --git a/android/wear/src/test/screenshots/com.gridpointcode.wear.CodeTileTest.theTileWithAPlace.png b/android/wear/src/test/screenshots/com.gridpointcode.wear.CodeTileTest.theTileWithAPlace.png new file mode 100644 index 0000000..5bba0d3 Binary files /dev/null and b/android/wear/src/test/screenshots/com.gridpointcode.wear.CodeTileTest.theTileWithAPlace.png differ diff --git a/android/wear/src/test/screenshots/com.gridpointcode.wear.CodeTileTest.theTileWithNoPlace.png b/android/wear/src/test/screenshots/com.gridpointcode.wear.CodeTileTest.theTileWithNoPlace.png new file mode 100644 index 0000000..7aea79a Binary files /dev/null and b/android/wear/src/test/screenshots/com.gridpointcode.wear.CodeTileTest.theTileWithNoPlace.png differ diff --git a/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereAskingForLocation.png b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereAskingForLocation.png new file mode 100644 index 0000000..e14ed7c Binary files /dev/null and b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereAskingForLocation.png differ diff --git a/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereFinding.png b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereFinding.png new file mode 100644 index 0000000..7cff452 Binary files /dev/null and b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereFinding.png differ diff --git a/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereWithAPlace.png b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereWithAPlace.png new file mode 100644 index 0000000..a525d27 Binary files /dev/null and b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.hereWithAPlace.png differ diff --git a/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.savedPlacesNearestFirst.png b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.savedPlacesNearestFirst.png new file mode 100644 index 0000000..f95b2fc Binary files /dev/null and b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.savedPlacesNearestFirst.png differ diff --git a/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.walkingToASavedPlace.png b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.walkingToASavedPlace.png new file mode 100644 index 0000000..622b1d1 Binary files /dev/null and b/android/wear/src/test/screenshots/com.gridpointcode.wear.WatchScreensTest.walkingToASavedPlace.png differ