diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..741d3a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + branches: + - dev + paths-ignore: + - '**.md' + - 'assets/**' + pull_request: + branches: + - dev + - beta + - main + paths-ignore: + - '**.md' + - 'assets/**' + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CI: true + ANDROID_PLATFORM: platforms;android-37.0 + ANDROID_BUILD_TOOLS: build-tools;36.0.0 + +jobs: + verify: + # Hosted runners: pull requests from forks run here, never on a machine of the project. + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Set up Java + uses: actions/setup-java@v6.0.1 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6.4.0 + + - name: Install Android SDK packages + run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" > /dev/null + + - name: Test, lint and build + run: | + ./gradlew --stacktrace \ + testDebugUnitTest \ + :app:compileDebugAndroidTestKotlin \ + lintDebug \ + :app:assembleRelease + + - name: Upload reports + if: ${{ failure() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: reports + path: | + **/build/reports/ + **/build/test-results/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8462976 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,161 @@ +name: Release + +# A push to beta publishes a prerelease, a push to main a stable release. The version comes from app/build.gradle.kts +# and the text from release-notes/v.md, which must be in the pushed commit. +on: + push: + branches: + - beta + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +env: + CI: true + APPLICATION_ID: video.typetype.tv + ANDROID_PLATFORM: platforms;android-37.0 + ANDROID_BUILD_TOOLS: build-tools;36.0.0 + BUILD_TOOLS_VERSION: 36.0.0 + +jobs: + verify: + uses: ./.github/workflows/ci.yml + + publish: + needs: verify + if: ${{ github.ref_name == 'beta' || github.ref_name == 'main' }} + runs-on: ubuntu-latest + timeout-minutes: 45 + # The signing key lives in this environment, which only the beta and main branches can use. + environment: release + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Set up Java + uses: actions/setup-java@v6.0.1 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6.4.0 + + - name: Install Android SDK packages + run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" > /dev/null + + - name: Check the version + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(sed -nE 's/^val appVersion = "([^"]+)"$/\1/p' app/build.gradle.kts)" + case "$GITHUB_REF_NAME" in + beta) pattern='^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]?$'; channel=beta ;; + main) pattern='^[0-9]+\.[0-9]+\.[0-9]+$'; channel=stable ;; + esac + if [[ ! "$version" =~ $pattern ]]; then + echo "::error::Version '$version' does not fit the $GITHUB_REF_NAME branch" + exit 1 + fi + tag="v$version" + if [ ! -f "release-notes/$tag.md" ]; then + echo "::error::release-notes/$tag.md is missing" + exit 1 + fi + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" > /dev/null 2>&1; then + echo "::error::Tag $tag already exists: raise the version before publishing" + exit 1 + fi + { + echo "RELEASE_VERSION=$version" + echo "RELEASE_TAG=$tag" + echo "RELEASE_CHANNEL=$channel" + } >> "$GITHUB_ENV" + + - name: Build + run: ./gradlew --stacktrace :app:assembleRelease + + - name: Sign + env: + KEYSTORE_BASE64: ${{ secrets.TV_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.TV_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.TV_KEY_ALIAS }} + run: | + set -euo pipefail + test -n "$KEYSTORE_BASE64" && test -n "$KEYSTORE_PASSWORD" && test -n "$KEY_ALIAS" + tools="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION" + keystore="$RUNNER_TEMP/release.jks" + trap 'rm -f "$keystore"' EXIT + printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore" + mkdir -p artifacts + "$tools/zipalign" -f -P 16 4 app/build/outputs/apk/release/app-release-unsigned.apk "$RUNNER_TEMP/aligned.apk" + "$tools/apksigner" sign --ks "$keystore" --ks-key-alias "$KEY_ALIAS" --v4-signing-enabled false \ + --ks-pass env:KEYSTORE_PASSWORD --key-pass env:KEYSTORE_PASSWORD \ + --out "artifacts/TypeType-TV-$RELEASE_TAG.apk" "$RUNNER_TEMP/aligned.apk" + + - name: Check the APK + run: | + set -euo pipefail + tools="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION" + apk="artifacts/TypeType-TV-$RELEASE_TAG.apk" + "$tools/zipalign" -c -P 16 4 "$apk" + "$tools/apksigner" verify --print-certs "$apk" | grep -E 'Signer #1 certificate (DN|SHA-256)' + badging="$("$tools/aapt2" dump badging "$apk" | head -n 1)" + echo "$badging" + package="$(sed -nE "s/^package: name='([^']+)'.*/\1/p" <<< "$badging")" + code="$(sed -nE "s/.*versionCode='([0-9]+)'.*/\1/p" <<< "$badging")" + name="$(sed -nE "s/.*versionName='([^']+)'.*/\1/p" <<< "$badging")" + test "$package" = "$APPLICATION_ID" + test "$name" = "$RELEASE_VERSION" + echo "RELEASE_CODE=$code" >> "$GITHUB_ENV" + + - name: Write checksum and update manifest + run: | + set -euo pipefail + cd artifacts + apk="TypeType-TV-$RELEASE_TAG.apk" + sha256sum "$apk" > "$apk.sha256" + jq -n \ + --arg channel "$RELEASE_CHANNEL" \ + --arg packageName "$APPLICATION_ID" \ + --arg versionName "$RELEASE_VERSION" \ + --argjson versionCode "$RELEASE_CODE" \ + --arg apkAsset "$apk" \ + --arg sha256 "$(cut -d ' ' -f 1 "$apk.sha256")" \ + '{schemaVersion: 1, channel: $channel, packageName: $packageName, versionName: $versionName, + versionCode: $versionCode, apkAsset: $apkAsset, sha256: $sha256}' > typetype-update.json + cat typetype-update.json + + - name: Keep the files with the run + uses: actions/upload-artifact@v7.0.1 + with: + name: typetype-tv-${{ env.RELEASE_TAG }} + path: artifacts/ + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ "$RELEASE_CHANNEL" = beta ]; then + flags=(--prerelease --latest=false) + else + flags=(--latest) + fi + gh release create "$RELEASE_TAG" artifacts/* \ + --target "$GITHUB_SHA" \ + --title "TypeType TV $RELEASE_VERSION" \ + --notes-file "release-notes/$RELEASE_TAG.md" \ + "${flags[@]}" diff --git a/README.md b/README.md index 091ff88..ed74679 100644 --- a/README.md +++ b/README.md @@ -31,20 +31,23 @@ synchronization. It supports YouTube, Bilibili, and NicoNico. | Home | Video details | Player | | --- | --- | --- | -| ![TypeType TV home screen](assets/screenshots/tv-home.png) | ![TypeType TV video details](assets/screenshots/tv-detail.png) | ![TypeType TV player](assets/screenshots/tv-player.png) | +| ![TypeType TV home with a muted preview](assets/screenshots/tv-home.png) | ![TypeType TV video details](assets/screenshots/tv-detail.png) | ![TypeType TV player](assets/screenshots/tv-player.png) | -| Comments | Search | Settings | +| Live chat | More videos | Search | | --- | --- | --- | -| ![TypeType TV comments panel](assets/screenshots/tv-comments.png) | ![TypeType TV search with the on-screen keyboard](assets/screenshots/tv-search.png) | ![TypeType TV playback settings](assets/screenshots/tv-settings.png) | +| ![TypeType TV live stream with its chat](assets/screenshots/tv-live-chat.png) | ![TypeType TV related videos under the player](assets/screenshots/tv-related.png) | ![TypeType TV search with the on-screen keyboard](assets/screenshots/tv-search.png) | ## Features -- Browse the home feed, search videos and channels, and open channels and playlists. +- Browse the home feed, search videos, playlists and channels, and open channels and playlists. +- Rest on a video for two seconds to see a muted preview behind the home screen and the video details. - Resume videos and find your history, favorites, and Watch Later in the library. +- Play up to 4K with AVC, VP9, AV1 or HEVC, chosen from the decoders of your device; HDR on HDR screens. - Choose the quality, audio language, captions and their style, and the playback speed. - Use chapters, SponsorBlock, the description, comments and their replies, and the Up next queue. -- Show scrolling comments on Bilibili and NicoNico videos. -- Watch live streams, or keep only the audio. +- Press Down twice in the player to browse more videos without leaving playback. +- Watch YouTube live streams with their chat beside the picture, the live audience, and the quality of your choice. +- Show scrolling comments on Bilibili and NicoNico videos, or keep only the audio. - Subscribe to channels and add videos to your playlists from the player. ## Build @@ -52,12 +55,14 @@ synchronization. It supports YouTube, Bilibili, and NicoNico. Requirements: JDK 17 and the Android SDK (`ANDROID_HOME` set). ```sh -./gradlew :app:testDebugUnitTest :app:assembleDebug +./gradlew testDebugUnitTest :app:assembleDebug ``` -The project has two modules: +The project is split into modules: -- `app`: the TV interface, built with Compose for remote-control focus, and the client for the TypeType Server API. +- `app`: the TV application, its navigation and startup. +- `core:*`: shared resources, the TypeType Server client, playback, the design system, and test fakes. +- `feature:*`: one module per screen family (home, search, library, settings, collections, details, player, sign-in). - `player`: the Media3 source for TypeType SABR playback. ## Acknowledgements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea8cbbe..510b4d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,22 @@ plugins { alias(libs.plugins.kotlin.serialization) } +// The release workflow reads this line: "x.y.z-beta.n" is published from the beta branch, "x.y.z" from main. +val appVersion = "0.1.1-beta.1" + +// The version code follows from the name, so each release installs over the one before, and a beta sits under the +// stable release that ends it: 0.3.0-beta.2 is 30002 and 0.3.0 is 30099. +fun versionCodeOf(name: String): Int { + val match = requireNotNull(Regex("""(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?""").matchEntire(name)) { + "Version $name is neither x.y.z nor x.y.z-beta.n" + } + val (major, minor, patch, beta) = match.destructured + require(minor.toInt() < 100 && patch.toInt() < 100) { "Minor and patch of $name stay under 100" } + val step = if (beta.isEmpty()) 99 else beta.toInt() + require(beta.isEmpty() || step in 1..98) { "The beta number of $name is 1 to 98" } + return major.toInt() * 1_000_000 + minor.toInt() * 10_000 + patch.toInt() * 100 + step +} + android { namespace = "video.typetype.tv" compileSdk = 37 @@ -12,8 +28,8 @@ android { applicationId = "video.typetype.tv" minSdk = 23 targetSdk = 37 - versionCode = 1 - versionName = "0.2.0" + versionCode = versionCodeOf(appVersion) + versionName = appVersion testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -41,10 +57,6 @@ android { unitTests.isReturnDefaultValues = true } - sourceSets { - getByName("test").kotlin.srcDir("src/sharedTest/java") - getByName("androidTest").kotlin.srcDir("src/sharedTest/java") - } } dependencies { @@ -57,7 +69,18 @@ dependencies { implementation(libs.androidx.concurrent.futures) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(project(":player")) + implementation(project(":core:resources")) + implementation(project(":core:data")) + implementation(project(":core:playback")) + implementation(project(":core:ui")) + implementation(project(":feature:auth")) + implementation(project(":feature:home")) + implementation(project(":feature:search")) + implementation(project(":feature:library")) + implementation(project(":feature:settings")) + implementation(project(":feature:collections")) + implementation(project(":feature:detail")) + implementation(project(":feature:player")) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.datasource.okhttp) @@ -77,6 +100,7 @@ dependencies { testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.okhttp.tls) + androidTestImplementation(project(":core:testing")) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.compose.ui.test.accessibility) diff --git a/app/src/androidTest/java/video/typetype/tv/ui/ChannelPanelTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/ChannelPanelTest.kt new file mode 100644 index 0000000..d1fa65b --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/ChannelPanelTest.kt @@ -0,0 +1,73 @@ +package video.typetype.tv.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import video.typetype.tv.data.Channel +import video.typetype.tv.data.MockContentRepository +import video.typetype.tv.resources.R +import video.typetype.tv.ui.player.panel.ChannelCardActions +import video.typetype.tv.ui.player.panel.ChannelPanel + +@OptIn(ExperimentalTestApi::class) +class ChannelPanelTest { + @get:Rule + val rule = createAndroidComposeRule() + + private val channel = Channel("c", "Blender", "", "1.3M", url = "https://c.example") + private val log = mutableListOf() + + private fun show(subscribed: Boolean = false) = rule.setContent { + ChannelPanel( + channel = channel, + repository = MockContentRepository(latencyMillis = 0), + actions = ChannelCardActions( + subscribed = subscribed, + canSubscribe = true, + onSubscribe = { log += "subscribe:$it" }, + onOpenChannel = { log += "open" }, + ), + onClose = { log += "close" }, + ) + } + + @Test + fun theCardIsAccessibleAndShowsWhoTheChannelIs() { + rule.enableAccessibilityChecks() + show() + rule.waitForIdle() + rule.onRoot().tryPerformAccessibilityChecks() + rule.onNodeWithText("Blender").assertExists() + rule.onNodeWithText(rule.activity.getString(R.string.detail_subscribers, "1.3M")).assertExists() + } + + @Test + fun openChannelHasTheFocusAndOpensWithOk() { + show() + rule.waitForIdle() + rule.onNodeWithText(rule.activity.getString(R.string.player_open_channel)).run { + assertIsFocused() + performKeyInput { pressKey(Key.DirectionCenter) } + } + assertEquals(listOf("open"), log) + } + + @Test + fun subscribeFollowsTheChannelAndSaysWhenItIsFollowed() { + show(subscribed = true) + rule.onNodeWithText(rule.activity.getString(R.string.detail_subscribed)).performClick() + assertEquals(listOf("subscribe:false"), log) + } +} diff --git a/app/src/androidTest/java/video/typetype/tv/ui/CollectionScreensAccessibilityTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/CollectionScreensAccessibilityTest.kt index dc21fd8..3aa9bd2 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/CollectionScreensAccessibilityTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/CollectionScreensAccessibilityTest.kt @@ -52,7 +52,7 @@ class CollectionScreensAccessibilityTest { @Test fun channelNamesEveryControl() = assertEveryControlIsNamed("channel") { - ChannelScreen(MockCatalog.all.first().channel.url, repository, onOpen = {}, onOpenPodcast = {}) + ChannelScreen(MockCatalog.all.first().channel.url, repository, onOpen = {}, onOpenPodcast = {}, onOpenPlaylist = {}) } @Test diff --git a/app/src/androidTest/java/video/typetype/tv/ui/LibraryFocusTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/LibraryFocusTest.kt new file mode 100644 index 0000000..59aeb93 --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/LibraryFocusTest.kt @@ -0,0 +1,52 @@ +package video.typetype.tv.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.isFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import video.typetype.tv.data.MockContentRepository +import video.typetype.tv.ui.library.LibraryScreen +import video.typetype.tv.ui.library.LibrarySession + +// Coming back from the videos of a library section lands on that section's tab, not on the nearest one. +class LibraryFocusTest { + @get:Rule + val rule = createComposeRule() + + private val tabs = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab) + + private fun focusedId(): Int = rule.onNode(isFocused()).fetchSemanticsNode().id + + private fun press(key: Key) { + rule.onRoot().performKeyInput { pressKey(key) } + rule.waitForIdle() + } + + @Test + fun leftFromTheVideosReturnsToTheOpenSection() { + rule.setContent { LibraryScreen(MockContentRepository(latencyMillis = 0), LibrarySession(), onOpen = {}) } + rule.waitUntil(5_000) { rule.onAllNodes(tabs).fetchSemanticsNodes().size >= 3 } + val tab = rule.onAllNodes(tabs)[1] + tab.requestFocus() + rule.waitForIdle() + val tabId = tab.fetchSemanticsNode().id + rule.waitUntil(5_000) { + press(Key.DirectionRight) + focusedId() != tabId + } + press(Key.DirectionDown) + assertNotEquals(tabId, focusedId()) + press(Key.DirectionLeft) + assertEquals(tabId, focusedId()) + } +} diff --git a/app/src/androidTest/java/video/typetype/tv/ui/OptionWheelTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/OptionWheelTest.kt new file mode 100644 index 0000000..9046fc5 --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/OptionWheelTest.kt @@ -0,0 +1,102 @@ +package video.typetype.tv.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.unit.dp +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import video.typetype.tv.ui.components.OptionWheel +import video.typetype.tv.ui.components.WheelOption +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Tune + +class OptionWheelTest { + @get:Rule + val rule = createComposeRule() + + private val picks = mutableListOf() + + private fun show(start: Int) { + rule.setContent { + var selected by remember { mutableIntStateOf(start) } + Column { + OptionWheel( + options = listOf("Channels", "Videos", "Playlists").map { WheelOption(it, Icons.Rounded.Tune) }, + selected = selected, + onSelect = { + picks += it + selected = it + }, + description = "Type", + ) + Box(Modifier.size(40.dp).semantics { contentDescription = "Below" }.focusable()) + } + } + rule.onNodeWithContentDescription("Type").requestFocus() + } + + private fun press(key: Key) { + rule.onNodeWithContentDescription("Type").performKeyInput { pressKey(key) } + rule.waitForIdle() + } + + @Test + fun okConfirmsTheValueTheWheelWasTurnedTo() { + show(start = 1) + press(Key.DirectionDown) + press(Key.DirectionUp) + press(Key.DirectionUp) + assertEquals(emptyList(), picks) + press(Key.DirectionCenter) + assertEquals(listOf(0), picks) + } + + @Test + fun okOnTheChosenValueChangesNothing() { + show(start = 1) + press(Key.DirectionCenter) + assertEquals(emptyList(), picks) + } + + @Test + fun theEndsLeaveTheKeyToTheScreen() { + show(start = 0) + press(Key.DirectionUp) + assertEquals(emptyList(), picks) + } + + @Test + fun okMovesOnToWhatIsBelow() { + show(start = 1) + press(Key.DirectionDown) + press(Key.DirectionCenter) + assertEquals(listOf(2), picks) + rule.onNodeWithContentDescription("Below").assertIsFocused() + } + + @Test + fun leavingTheWheelKeepsTheValueItWasTurnedTo() { + show(start = 1) + press(Key.DirectionDown) + rule.onNodeWithContentDescription("Below").requestFocus() + rule.waitForIdle() + assertEquals(listOf(2), picks) + } +} diff --git a/app/src/androidTest/java/video/typetype/tv/ui/PlaybackControllerTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/PlaybackControllerTest.kt index 07b0684..63fde82 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/PlaybackControllerTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/PlaybackControllerTest.kt @@ -17,7 +17,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import video.typetype.tv.R +import video.typetype.tv.resources.R import video.typetype.tv.data.MockContentRepository import video.typetype.tv.data.net.AlwaysOnline import video.typetype.tv.data.net.ApiException diff --git a/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlDescriptionsTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlDescriptionsTest.kt index 6b729df..19b7ddd 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlDescriptionsTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlDescriptionsTest.kt @@ -28,12 +28,12 @@ class PlayerControlDescriptionsTest { rule.setContent { PlayerControls( video, remember { PlayerUiState() }, - PlayerPreferences(true, true, "English", true, "720p", "1.5x", commentsAvailable = true, commentsOn = true, audioLabel = "French"), - PlayerControlActions({}, {}, {}, {}, {}, {}, {}), + PlayerPreferences(true, true, "English", true, "720p", commentsAvailable = true, commentsOn = true, audioLabel = "French"), + PlayerControlActions({}, {}, {}, {}, {}, {}), remember { FocusRequester() }, ) } - listOf("Video quality, 720p", "Playback speed, 1.5x", "Audio language, French", "Scrolling comments on").forEach { + listOf("Video quality, 720p", "Audio language, French", "Scrolling comments on").forEach { rule.onNode(hasContentDescription(it)).assertIsDisplayed() } } diff --git a/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlsOptionsTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlsOptionsTest.kt index 0c00387..b699167 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlsOptionsTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/PlayerControlsOptionsTest.kt @@ -2,12 +2,18 @@ package video.typetype.tv.ui import androidx.compose.runtime.remember import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals import androidx.compose.ui.test.onNodeWithText import androidx.test.platform.app.InstrumentationRegistry +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.getBoundsInRoot +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import video.typetype.tv.R +import video.typetype.tv.resources.R import video.typetype.tv.data.Channel import video.typetype.tv.data.Video import video.typetype.tv.ui.player.PlayerControlActions @@ -25,7 +31,7 @@ class PlayerControlsOptionsTest { "https://v.example/1", "A title", Channel("c", "Channel", "", "1M"), durationSeconds = 120, views = "1 view", age = "today", genre = "", description = "", ) - private val actions = PlayerControlActions({}, {}, {}, {}, {}, {}, {}) + private val actions = PlayerControlActions({}, {}, {}, {}, {}, {}) private fun show(preferences: PlayerPreferences) = rule.setContent { val state = remember { PlayerUiState() } @@ -35,17 +41,57 @@ class PlayerControlsOptionsTest { @Test fun hidesCaptionAndQualityButtonsWhenTheyCannotWork() { - show(PlayerPreferences(false, false, "", false, "Auto", "1x")) + show(PlayerPreferences(false, false, "", false, "Auto")) rule.onNodeWithText(context.getString(R.string.player_captions)).assertDoesNotExist() rule.onNodeWithText("Auto").assertDoesNotExist() - rule.onNodeWithText("1x").assertIsDisplayed() + rule.onNodeWithText(context.getString(R.string.menu_comments)).assertDoesNotExist() } @Test fun showsTheSelectedCaptionLanguageAndQuality() { - show(PlayerPreferences(true, true, "English", true, "720p", "1.25x")) + show(PlayerPreferences(true, true, "English", true, "720p")) rule.onNodeWithText("English").assertIsDisplayed() rule.onNodeWithText("720p").assertIsDisplayed() - rule.onNodeWithText("1.25x").assertIsDisplayed() + } + + @Test + fun theCommentsTheChannelAndTheDescriptionAreOnePressAway() { + var opened = "" + rule.setContent { + PlayerControls( + video.copy(channel = video.channel.copy(url = "https://c.example")), + remember { PlayerUiState() }, + PlayerPreferences(false, false, "", false, "Auto", commentsPanelAvailable = true), + PlayerControlActions({}, {}, {}, {}, {}, {}, onOpenComments = { opened = "comments" }, onShowChannel = { opened = "channel" }, onOpenDescription = { opened = "description" }), + remember { FocusRequester() }, + ) + } + rule.onNodeWithText(context.getString(R.string.menu_comments)).performClick() + assertEquals("comments", opened) + rule.onNode(hasContentDescription(context.getString(R.string.player_open_channel_description, "Channel"))).performClick() + assertEquals("channel", opened) + rule.onNode(hasContentDescription(context.getString(R.string.menu_description))).performClick() + assertEquals("description", opened) + } + + @Test + fun manyChoicesWithALongAudioNameStillFitOnTheScreen() { + rule.setContent { + PlayerControls( + video.copy(channel = video.channel.copy(url = "https://c.example")), + remember { PlayerUiState() }, + PlayerPreferences( + true, true, "English (auto-generated)", true, "1080p", + audioLabel = "French (FR) original", commentsPanelAvailable = true, + ), + actions, + remember { FocusRequester() }, + ) + } + val screen = rule.onRoot().getBoundsInRoot().right + val more = rule.onNode(hasContentDescription(context.getString(R.string.player_more_description))).getBoundsInRoot() + assertTrue(more.right <= screen) + rule.onNodeWithText("French (FR) original").assertDoesNotExist() + rule.onNodeWithText(context.getString(R.string.menu_comments)).assertIsDisplayed() } } diff --git a/app/src/androidTest/java/video/typetype/tv/ui/PlayerPanelsTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/PlayerPanelsTest.kt index cff912a..8db22a4 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/PlayerPanelsTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/PlayerPanelsTest.kt @@ -6,13 +6,15 @@ import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.isFocused import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput @@ -40,7 +42,7 @@ import video.typetype.tv.ui.player.panel.QueuePanel @OptIn(ExperimentalTestApi::class) class PlayerPanelsTest { @get:Rule - val rule = createComposeRule() + val rule = createAndroidComposeRule() private val chapters = listOf(Chapter("Intro", 0), Chapter("Part one", 60), Chapter("Part two", 300)) @@ -70,12 +72,13 @@ class PlayerPanelsTest { private val menuModel = PlayerMenuModel( hasChapters = true, queueSize = 3, captionsAvailable = true, activity = VideoActivity(favorite = true), channelName = "Channel", canSubscribe = true, audioOnly = false, statsOn = true, + speedLabel = "1.5x", ) private fun menuActions(log: MutableList = mutableListOf()) = PlayerMenuActions( onChapters = { log += "chapters" }, onDescription = { log += "description" }, onComments = { log += "comments" }, onQueue = { log += "queue" }, onFavorite = { log += "favorite" }, onWatchLater = { log += "watch-later" }, onSubscribe = { log += "subscribe" }, - onPlaylists = { log += "playlists" }, onAudioOnly = { log += "audio-only" }, + onPlaylists = { log += "playlists" }, onSpeed = { log += "speed" }, onAudioOnly = { log += "audio-only" }, onCaptionStyle = { log += "caption-style" }, onStats = { log += "stats" }, ) @@ -93,6 +96,15 @@ class PlayerPanelsTest { assertEquals(listOf("chapters"), log) } + @Test + fun theMenuOpensOnTheRowItWasLeftOnAndRemembersTheNextOne() { + val chosen = mutableListOf() + rule.setContent { PlayerMenuPanel(menuModel, menuActions(), onClose = {}, initialEntry = "comments", onEntry = { chosen += it }) } + rule.waitForIdle() + rule.onAllNodes(isFocused()).onFirst().assert(hasContentDescription("Comments")).performKeyInput { pressKey(Key.DirectionCenter) } + assertEquals(listOf("comments"), chosen) + } + @Test fun theMenuReportsTheStateOfEachToggle() { rule.setContent { PlayerMenuPanel(menuModel, menuActions(), onClose = {}) } @@ -101,6 +113,8 @@ class PlayerPanelsTest { rule.onNode(hasContentDescription("Subscribe to Channel")).assertExists() rule.onNode(hasScrollAction()).performScrollToNode(hasContentDescription("Playback statistics, On")) rule.onNode(hasContentDescription("Playback statistics, On")).assertExists() + rule.onNode(hasScrollAction()).performScrollToNode(hasContentDescription("Playback speed, 1.5x")) + rule.onNode(hasContentDescription("Playback speed, 1.5x")).assertExists() } @Test @@ -159,7 +173,7 @@ class PlayerPanelsTest { var closed = 0 rule.setContent { InfoPanel(video, onClose = { closed++ }) } rule.waitForIdle() - rule.onAllNodes(isFocused()).onFirst().performKeyInput { pressKey(Key.Back) } + rule.runOnUiThread { rule.activity.onBackPressedDispatcher.onBackPressed() } rule.waitForIdle() assertEquals(1, closed) } diff --git a/app/src/androidTest/java/video/typetype/tv/ui/PlayerRemoteKeysTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/PlayerRemoteKeysTest.kt new file mode 100644 index 0000000..eb91a67 --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/PlayerRemoteKeysTest.kt @@ -0,0 +1,51 @@ +package video.typetype.tv.ui + +import android.view.KeyEvent as AndroidKeyEvent +import androidx.compose.ui.input.key.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import video.typetype.tv.ui.player.PlayerPanel +import video.typetype.tv.ui.player.RemoteKeyActions +import video.typetype.tv.ui.player.handleRemoteKey + +class PlayerRemoteKeysTest { + private val calls = mutableListOf() + private val actions = RemoteKeyActions( + play = { calls += "play" }, + pause = { calls += "pause" }, + next = { calls += "next" }, + startOver = { calls += "start" }, + toggleCaptions = { calls += "captions" }, + openPanel = { calls += it.name }, + seekToFraction = { calls += "seek $it" }, + ) + + private fun press(code: Int, panelOpen: Boolean = false) = + handleRemoteKey(KeyEvent(AndroidKeyEvent(AndroidKeyEvent.ACTION_DOWN, code)), panelOpen, actions) + + @Test + fun dedicatedKeysReachTheirTargetInOnePress() { + assertTrue(press(AndroidKeyEvent.KEYCODE_CAPTIONS)) + assertTrue(press(AndroidKeyEvent.KEYCODE_INFO)) + assertTrue(press(AndroidKeyEvent.KEYCODE_MENU)) + assertTrue(press(AndroidKeyEvent.KEYCODE_MEDIA_NEXT)) + assertTrue(press(AndroidKeyEvent.KEYCODE_MEDIA_PREVIOUS)) + assertEquals(listOf("captions", PlayerPanel.Info.name, PlayerPanel.Menu.name, "next", "start"), calls) + } + + @Test + fun digitsJumpToTenthsOfTheVideo() { + assertTrue(press(AndroidKeyEvent.KEYCODE_5)) + assertTrue(press(AndroidKeyEvent.KEYCODE_0)) + assertEquals(listOf("seek 0.5", "seek 0.0"), calls) + } + + @Test + fun arrowsAndOpenPanelsAreLeftAlone() { + assertFalse(press(AndroidKeyEvent.KEYCODE_DPAD_DOWN)) + assertFalse(press(AndroidKeyEvent.KEYCODE_CAPTIONS, panelOpen = true)) + assertTrue(calls.isEmpty()) + } +} diff --git a/app/src/androidTest/java/video/typetype/tv/ui/PlayerSessionTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/PlayerSessionTest.kt index ca66647..978e64e 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/PlayerSessionTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/PlayerSessionTest.kt @@ -69,7 +69,8 @@ class PlayerSessionTest { press(AndroidKeyEvent.KEYCODE_DPAD_RIGHT) press(AndroidKeyEvent.KEYCODE_DPAD_RIGHT) press(AndroidKeyEvent.KEYCODE_DPAD_LEFT) - assertEquals(105_000L, session.scrub.target) + // Each press of one scrub moves a little further: +5 s, +5.25 s, then -5.51 s. + assertEquals(104_738L, session.scrub.target) } @Test @@ -80,26 +81,42 @@ class PlayerSessionTest { assertFalse(session.scrub.active) onUi { session.controlsVisible = false } press(AndroidKeyEvent.KEYCODE_DPAD_LEFT) - assertTrue(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_DOWN)) - assertTrue(session.scrub.active) - assertTrue(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_UP)) + assertTrue(session.handlesBack(upNextVisible = false)) + onUi { session.back(upNextVisible = false) } assertFalse(session.scrub.active) } @Test fun backHidesVisibleControlsBeforeLeaving() { - assertTrue(session.controlsVisible) - assertTrue(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_DOWN)) - assertTrue(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_UP)) + assertTrue(session.handlesBack(upNextVisible = false)) + onUi { session.back(upNextVisible = false) } assertFalse(session.controlsVisible) - assertFalse(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_DOWN)) + assertFalse(session.handlesBack(upNextVisible = false)) + } + + @Test + fun backClosesTheRelatedRowBeforeTheControls() { + onUi { session.relatedOpen = true } + onUi { session.back(upNextVisible = false) } + assertFalse(session.relatedOpen) + assertTrue(session.controlsVisible) + } + + @Test + fun downFromPlaybackLandsOnTheButtons() { + onUi { session.controlsVisible = false } + assertTrue(press(AndroidKeyEvent.KEYCODE_DPAD_DOWN)) + assertTrue(session.controlsVisible) + assertTrue(session.controlsFromBelow) + onUi { session.controlsVisible = false } + press(AndroidKeyEvent.KEYCODE_DPAD_UP) + assertFalse(session.controlsFromBelow) } @Test fun backWithNothingToCancelIsLeftToTheScreen() { onUi { session.controlsVisible = false } - assertFalse(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_DOWN)) - assertFalse(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_UP)) + assertFalse(session.handlesBack(upNextVisible = false)) } @Test @@ -107,7 +124,8 @@ class PlayerSessionTest { onUi { session.controlsVisible = false } assertTrue(press(AndroidKeyEvent.KEYCODE_DPAD_CENTER, visible = true)) assertEquals(1, playedNext) - assertTrue(press(AndroidKeyEvent.KEYCODE_BACK, AndroidKeyEvent.ACTION_UP, visible = true)) + assertTrue(session.handlesBack(upNextVisible = true)) + onUi { session.back(upNextVisible = true) } assertTrue(session.upNext.dismissed) } @@ -137,4 +155,34 @@ class PlayerSessionTest { assertFalse(press(AndroidKeyEvent.KEYCODE_DPAD_RIGHT)) assertFalse(session.scrub.active) } + + @Test + fun aScrubHoldsThePictureThenPlaysFromTheChosenPoint() { + onUi { + player.play() + session.controlsVisible = false + } + press(AndroidKeyEvent.KEYCODE_DPAD_RIGHT) + onUi { assertFalse(player.playWhenReady) } + press(AndroidKeyEvent.KEYCODE_DPAD_CENTER) + onUi { assertTrue(player.playWhenReady) } + } + + @Test + fun aCancelledScrubPlaysAgainAndAPausedVideoStaysPaused() { + onUi { + player.play() + session.controlsVisible = false + } + press(AndroidKeyEvent.KEYCODE_DPAD_LEFT) + onUi { session.back(upNextVisible = false) } + onUi { assertTrue(player.playWhenReady) } + onUi { + player.pause() + session.controlsVisible = false + } + press(AndroidKeyEvent.KEYCODE_DPAD_LEFT) + press(AndroidKeyEvent.KEYCODE_DPAD_CENTER) + onUi { assertFalse(player.playWhenReady) } + } } diff --git a/app/src/androidTest/java/video/typetype/tv/ui/RelatedShelfTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/RelatedShelfTest.kt new file mode 100644 index 0000000..fc24b2b --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/RelatedShelfTest.kt @@ -0,0 +1,40 @@ +package video.typetype.tv.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.isFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import video.typetype.tv.data.Chapter +import video.typetype.tv.ui.player.RelatedShelf + +class RelatedShelfTest { + @get:Rule + val rule = createComposeRule() + + private val chapters = listOf(Chapter("Intro", 0), Chapter("Part one", 60), Chapter("Part two", 300)) + + @Test + fun theChaptersOpenOnTheOneBeingWatchedAndOkJumpsToTheChosenOne() { + val chosen = mutableListOf() + rule.setContent { + RelatedShelf( + videos = emptyList(), + chapters = chapters, + currentChapter = 1, + onChapter = { chosen += it.title }, + onOpen = {}, + onClose = {}, + ) + } + rule.waitForIdle() + rule.onAllNodes(isFocused()).onFirst().performKeyInput { pressKey(Key.DirectionRight) } + rule.waitForIdle() + rule.onAllNodes(isFocused()).onFirst().performKeyInput { pressKey(Key.DirectionCenter) } + assertEquals(listOf("Part two"), chosen) + } +} diff --git a/app/src/androidTest/java/video/typetype/tv/ui/ScreenAccessibilityTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/ScreenAccessibilityTest.kt index e6e5011..c0f145b 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/ScreenAccessibilityTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/ScreenAccessibilityTest.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.test.tryPerformAccessibilityChecks import org.junit.Before import org.junit.Rule import org.junit.Test -import video.typetype.tv.R +import video.typetype.tv.resources.R import video.typetype.tv.data.Channel import video.typetype.tv.ui.search.SearchScope import video.typetype.tv.ui.search.PlaylistResults @@ -18,18 +18,13 @@ import video.typetype.tv.data.ContentSource import video.typetype.tv.data.ChannelSummary import video.typetype.tv.ui.detail.PlaylistPickerState import video.typetype.tv.ui.detail.PlaylistPicker -import video.typetype.tv.ui.detail.CommentsState import video.typetype.tv.ui.auth.CertificateRequest import video.typetype.tv.ui.auth.CertificatePrompt import video.typetype.tv.data.net.tls.CertificateInfo -import video.typetype.tv.data.MockContentRepository import video.typetype.tv.data.InMemoryVideoActivity -import video.typetype.tv.data.ContentRepository -import video.typetype.tv.data.CommentsPage -import video.typetype.tv.data.Comment import video.typetype.tv.data.SettingItem import video.typetype.tv.data.Video -import video.typetype.tv.ui.auth.UiMessage +import video.typetype.tv.ui.util.UiMessage import video.typetype.tv.ui.components.ScreenFailure import video.typetype.tv.ui.player.PlayerControlActions import video.typetype.tv.ui.player.PlayerControls @@ -65,8 +60,8 @@ class ScreenAccessibilityTest { val requester = remember { FocusRequester() } PlayerControls( video, state, - PlayerPreferences(true, true, "English", true, "720p", "1x", commentsAvailable = true, commentsOn = true, audioLabel = "English"), - PlayerControlActions({}, {}, {}, {}, {}, {}, {}), + PlayerPreferences(true, true, "English", true, "720p", commentsAvailable = true, commentsOn = true, audioLabel = "English"), + PlayerControlActions({}, {}, {}, {}, {}, {}), requester, ) } diff --git a/app/src/androidTest/java/video/typetype/tv/ui/ScreenFailureTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/ScreenFailureTest.kt index 0e560da..dda8d9b 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/ScreenFailureTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/ScreenFailureTest.kt @@ -8,8 +8,8 @@ import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test -import video.typetype.tv.R -import video.typetype.tv.ui.auth.UiMessage +import video.typetype.tv.resources.R +import video.typetype.tv.ui.util.UiMessage import video.typetype.tv.ui.components.ScreenFailure class ScreenFailureTest { diff --git a/app/src/androidTest/java/video/typetype/tv/ui/SeekBarKeysTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/SeekBarKeysTest.kt index 90b64fd..c8e0264 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/SeekBarKeysTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/SeekBarKeysTest.kt @@ -11,7 +11,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import video.typetype.tv.R +import video.typetype.tv.resources.R import video.typetype.tv.ui.player.SeekBar class SeekBarKeysTest { @@ -28,7 +28,7 @@ class SeekBarKeysTest { positionMillis = { 30_000 }, bufferedMillis = { 60_000 }, durationMillis = 600_000, - onScrubBy = { seeks += it }, + onScrub = { direction, _ -> seeks += direction.toLong() }, onTogglePlay = { toggles++ }, ) } diff --git a/app/src/androidTest/java/video/typetype/tv/ui/SettingRowsTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/SettingRowsTest.kt index 6e99b8a..7a8d631 100644 --- a/app/src/androidTest/java/video/typetype/tv/ui/SettingRowsTest.kt +++ b/app/src/androidTest/java/video/typetype/tv/ui/SettingRowsTest.kt @@ -9,8 +9,9 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import video.typetype.tv.R +import video.typetype.tv.resources.R import video.typetype.tv.data.SettingItem +import video.typetype.tv.ui.settings.ChoiceRequest import video.typetype.tv.ui.settings.SettingChange import video.typetype.tv.ui.settings.SettingRow import video.typetype.tv.ui.settings.SettingsSession @@ -43,15 +44,15 @@ class SettingRowsTest { } @Test - fun choiceCyclesThroughTheWireValues() { + fun aChoiceOpensItsPickerInsteadOfChangingAlone() { val session = SettingsSession() val changes = mutableListOf() - show(choice, session, changes) + val requests = mutableListOf() + rule.setContent { SettingRow(choice, session, onAction = {}, onChange = { changes += it }, onChoose = { requests += it }) } rule.onNodeWithText(context.getString(R.string.settings_default_quality)).performClick() rule.waitForIdle() - assertEquals("1080p", changes.single().value) - assertEquals(1, session.choiceIndex(choice)) - changes.single().revert() + assertEquals(choice, requests.single().item) + assertTrue(changes.isEmpty()) assertEquals(0, session.choiceIndex(choice)) } } diff --git a/app/src/androidTest/java/video/typetype/tv/ui/SettingsFocusWalkTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/SettingsFocusWalkTest.kt new file mode 100644 index 0000000..7d292de --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/SettingsFocusWalkTest.kt @@ -0,0 +1,77 @@ +package video.typetype.tv.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.isFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import video.typetype.tv.data.MockContentRepository +import video.typetype.tv.data.MockSettings +import video.typetype.tv.ui.settings.SettingsScreen +import video.typetype.tv.ui.settings.SettingsSession + +// Every row of every settings group can be reached with Down and back with Up, information rows included, and +// Left from any row returns to that group's tab: the focus never jumps over a row or to another group. +class SettingsFocusWalkTest { + @get:Rule + val rule = createComposeRule() + + // A picker opens in its own window; the focused node of the topmost window is the one that receives keys. + private fun focused() = rule.onAllNodes(isFocused()).let { it[it.fetchSemanticsNodes().size - 1] } + + private fun focusedId(): Int = focused().fetchSemanticsNode().id + + private fun press(key: Key) { + focused().performKeyInput { pressKey(key) } + rule.waitForIdle() + } + + @Test + fun upAndDownVisitEveryRowOfEveryGroup() { + rule.setContent { SettingsScreen(MockContentRepository(latencyMillis = 0), SettingsSession(), onAction = {}) } + val tabs = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab) + rule.waitUntil(5_000) { rule.onAllNodes(tabs).fetchSemanticsNodes().size == MockSettings.groups.size } + MockSettings.groups.forEachIndexed { index, group -> + rule.onAllNodes(tabs)[index].requestFocus() + rule.waitForIdle() + press(Key.DirectionRight) + repeat(group.items.size) { press(Key.DirectionUp) } + val visited = mutableListOf(focusedId()) + repeat(group.items.size) { + press(Key.DirectionDown) + if (focusedId() != visited.last()) visited += focusedId() + } + assertEquals("rows reached going down in ${group.id}", group.items.size, visited.size) + repeat(group.items.size - 1) { press(Key.DirectionUp) } + assertEquals("first row reached going back up in ${group.id}", visited.first(), focusedId()) + // From the last row, Left goes back to this group's tab, not to the nearest one. + repeat(group.items.size - 1) { press(Key.DirectionDown) } + press(Key.DirectionLeft) + assertEquals("Left returns to the tab of ${group.id}", rule.onAllNodes(tabs)[index].fetchSemanticsNode().id, focusedId()) + } + } + + @Test + fun pickingAnOptionGivesTheFocusBackToItsRow() { + rule.setContent { SettingsScreen(MockContentRepository(latencyMillis = 0), SettingsSession(), onAction = {}) } + val tabs = SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab) + rule.waitUntil(5_000) { rule.onAllNodes(tabs).fetchSemanticsNodes().size == MockSettings.groups.size } + val playback = MockSettings.groups.indexOfFirst { it.id == "playback" } + rule.onAllNodes(tabs)[playback].requestFocus() + rule.waitForIdle() + press(Key.DirectionRight) + repeat(MockSettings.groups[playback].items.size) { press(Key.DirectionUp) } + val row = focusedId() + press(Key.DirectionCenter) + press(Key.DirectionDown) + press(Key.DirectionCenter) + assertEquals("the focus is back on the setting that was changed", row, focusedId()) + } +} diff --git a/app/src/androidTest/java/video/typetype/tv/ui/VideoCardMenuTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/VideoCardMenuTest.kt new file mode 100644 index 0000000..3f93523 --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/VideoCardMenuTest.kt @@ -0,0 +1,165 @@ +package video.typetype.tv.ui + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import video.typetype.tv.data.MockCatalog +import video.typetype.tv.data.Video +import video.typetype.tv.resources.R +import video.typetype.tv.ui.components.LocalVideoMenu +import video.typetype.tv.ui.components.PlaylistContext +import video.typetype.tv.ui.components.VideoCard +import video.typetype.tv.ui.components.VideoMenu +import video.typetype.tv.data.MockContentRepository +import video.typetype.tv.ui.detail.VideoMenuActions +import video.typetype.tv.ui.detail.VideoMenuPanel + +class VideoCardMenuTest { + @get:Rule + val rule = createComposeRule() + + private val video = MockCatalog.all.first() + private var opened = 0 + private var menus = mutableListOf