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:
2026-09-22 13:05:18 +02:00
parent 5d11921c19
commit 26f1effb4f
16 changed files with 2394 additions and 9 deletions
@@ -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()
}
}