feat(worldclock): the tab, the picker, and the face that answers at a glance
A hero face for home over a list of cities, each carrying its time, its offset and how many days apart it is from you. The dial morphs between a circle and a sun with the hour *there*: the one showpiece M0 reserved, spent on information rather than decoration — a glance says "it is the middle of the night for them" with no sentence needed. The picker searches the device's own zones by word prefix. The list reorders by drag **and** by move-up/move-down actions, because a drag-only reorder is unreachable by a screen reader; each row reads as one sentence rather than three texts in child order. Home is set by hand or left following the device. At the twenty-fourth city the refusal is written on the screen, not only in the content description.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package de.jeanlucmakiola.clockula.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockRepository
|
||||
import de.jeanlucmakiola.clockula.domain.WorldClock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* §5.11 — 4 cases. What only a device can answer about the World clock tab:
|
||||
* that the `Canvas` face really composes and its semantics really reach
|
||||
* TalkBack, that the picker's real `Dialog` and real IME really add a city, and
|
||||
* that the two accessibility actions D13 exists for really reorder and remove a
|
||||
* row without a drag.
|
||||
*
|
||||
* Compiled in the gate; **not run here** — no device is attached (§5.11).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class WorldClockInstrumentedTest {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Seams {
|
||||
fun worldClocks(): WorldClockRepository
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createEmptyComposeRule()
|
||||
|
||||
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val seams: Seams =
|
||||
EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java)
|
||||
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scenario?.close()
|
||||
runBlocking { stored().forEach { seams.worldClocks().remove(it.id) } }
|
||||
}
|
||||
|
||||
private fun string(id: Int): String = context.getString(id)
|
||||
|
||||
private fun stored(): List<WorldClock> = runBlocking { seams.worldClocks().worldClocks().first() }
|
||||
|
||||
private fun given(vararg zoneIds: String) {
|
||||
runBlocking { zoneIds.forEach { seams.worldClocks().add(it) } }
|
||||
}
|
||||
|
||||
private fun openWorldClockTab() {
|
||||
ActivityScenario.launch<MainActivity>(
|
||||
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
).also { scenario = it }
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.tab_world_clock)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
/** TalkBack's own route into a row: the custom action, by its label. */
|
||||
private fun SemanticsNodeInteraction.performAccessibilityAction(label: String) {
|
||||
val action = fetchSemanticsNode().config[SemanticsActions.CustomActions]
|
||||
.first { it.label == label }
|
||||
composeTestRule.runOnUiThread { action.action() }
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
/** §5.11 #1 */
|
||||
@Test
|
||||
fun theHeroFaceComposesAndNamesTheHomeCityToTalkBack() {
|
||||
openWorldClockTab()
|
||||
|
||||
val home = java.time.ZoneId.systemDefault().id.substringAfterLast('/')
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(home, substring = true).assertExists()
|
||||
}
|
||||
|
||||
/** §5.11 #2 */
|
||||
@Test
|
||||
fun addingACityThroughThePickerPutsItOnTheTab() {
|
||||
openWorldClockTab()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.world_clock_add)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.zone_picker_search)).performTextInput("Tokyo")
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Tokyo").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Tokyo").assertExists()
|
||||
assertThat(stored().map { it.zoneId }).contains("Asia/Tokyo")
|
||||
}
|
||||
|
||||
/** §5.11 #3 */
|
||||
@Test
|
||||
fun theRemoveAccessibilityActionTakesTheCityOffTheTab() {
|
||||
given("Asia/Tokyo")
|
||||
openWorldClockTab()
|
||||
|
||||
composeTestRule.onNodeWithText("Tokyo")
|
||||
.performAccessibilityAction(string(R.string.world_clock_row_remove))
|
||||
composeTestRule.onNodeWithText(string(R.string.action_remove)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(stored()).isEmpty()
|
||||
}
|
||||
|
||||
/** §5.11 #4 */
|
||||
@Test
|
||||
fun theMoveUpAccessibilityActionReordersWithoutADrag() {
|
||||
given("Europe/Berlin", "Asia/Tokyo")
|
||||
openWorldClockTab()
|
||||
|
||||
composeTestRule.onNodeWithText("Tokyo")
|
||||
.performAccessibilityAction(string(R.string.world_clock_row_move_up))
|
||||
|
||||
assertThat(stored().map { it.zoneId }).containsExactly("Asia/Tokyo", "Europe/Berlin").inOrder()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user