Dev: add simple automated UI test setup (#1027)

## Description
This PR is mostly a proof-of-concept for setting up automated UI
testing. The tests use Roboelectric so they can run without an emulator.
There is also a sample instrumented test for running on emulators or
devices for future tests.

It adds two basic automated UI tests for adding a server. These are sort
integration tests because they use the actual implementations for the
view model and services like `ServerRepository` and `JellyfinServerDao`.

Also, moves the bulk of `MainActivity`'s compose code into a function
for future tests.

### Related issues
N/A

### Testing
Local testing

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-03 13:46:46 -05:00 committed by GitHub
parent 8935067c5a
commit 0004701296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 990 additions and 163 deletions

View file

@ -44,7 +44,7 @@ android {
targetSdk = 36
versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
}
signingConfigs {
@ -140,6 +140,12 @@ android {
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
protobuf {
@ -254,6 +260,7 @@ dependencies {
implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx)
implementation(libs.androidx.media3.effect)
implementation(libs.androidx.runner)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler)
@ -292,4 +299,9 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.androidx.ui.test.manifest)
}

View file

@ -0,0 +1,75 @@
package com.github.damontecres.wholphin.test
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class InstrumentedBasicUiTests {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<TestActivity>()
lateinit var screensaverService: ScreensaverService
@Before
fun setup() {
screensaverService = mockk(relaxed = true)
every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
}
@OptIn(ExperimentalTestApi::class)
@Test
fun myTest() {
// Start the app
composeTestRule.setContent {
WholphinTheme {
MainContent(
backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = mockk(relaxed = true),
appPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
screensaverService = screensaverService,
requestedDestination = Destination.Home(),
modifier = Modifier,
)
}
}
composeTestRule.onNodeWithText("Add Server").assertExists()
composeTestRule.onNodeWithTag("add_server").performKeyInput {
pressKey(Key.DirectionDown) // TODO
}
composeTestRule.onNodeWithTag("add_server").performClickEnter()
composeTestRule.onNodeWithText("Discovered Servers").assertExists()
}
}
@OptIn(ExperimentalTestApi::class)
fun SemanticsNodeInteraction.performClickEnter() =
performKeyInput {
pressKey(Key.DirectionCenter)
}

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.test
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TestActivity : ComponentActivity()

View file

@ -0,0 +1,14 @@
package com.github.damontecres.wholphin.test
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class WholphinTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
name: String?,
context: Context?,
): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View file

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<!-- Required for Android 11+ to query voice recognition availability -->
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Wholphin"
android:name=".WholphinApplication"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".test.TestActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- <service-->
<!-- android:name=".WholphinDreamService"-->
<!-- android:exported="true"-->
<!-- android:label="@string/app_name"-->
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.service.dreams.DreamService" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- </intent-filter>-->
<!-- </service>-->
</application>
</manifest>

View file

@ -8,12 +8,9 @@ import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -21,28 +18,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
@ -62,17 +47,12 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
@ -80,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -230,129 +211,19 @@ class MainActivity : AppCompatActivity() {
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
Surface(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
shape = RectangleShape,
) {
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
val backStack = setupNavigationManager.backStack
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as SetupDestination
NavEntry(key) {
when (key) {
SetupDestination.Loading -> {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
SetupDestination.ServerList -> {
SwitchServerContent(Modifier.fillMaxSize())
}
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
Modifier.fillMaxSize(),
)
}
is SetupDestination.AppContent -> {
LaunchedEffect(Unit) {
backdropService.clearBackdrop()
}
val current = key.current
ProvideLocalClock {
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
LaunchedEffect(Unit) {
try {
updateChecker.maybeShowUpdateToast(
appPreferences.updateUrl,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Exception during update check",
)
}
}
}
val appPreferences by userPreferencesDataStore.data.collectAsState(
appPreferences,
)
val preferences =
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!preferences.appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
val requestedDestination =
remember(intent) {
intent?.let(::extractDestination)
}
ApplicationContent(
user = current.user,
server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
}
},
)
val screenSaverState by screensaverService.state.collectAsState()
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
AnimatedVisibility(
screenSaverState.show,
Modifier.fillMaxSize(),
) {
AppScreensaver(appPreferences, Modifier.fillMaxSize())
}
val requestedDestination =
remember(intent) {
intent?.let(::extractDestination) ?: Destination.Home()
}
}
MainContent(
backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
requestedDestination = requestedDestination,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@ -400,6 +271,22 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
Timber.d("onStart")
lifecycleScope.launchDefault {
val appPreferences = userPreferencesDataStore.data.first()
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
try {
updateChecker.maybeShowUpdateToast(
appPreferences.updateUrl,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Exception during update check",
)
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {

View file

@ -0,0 +1,149 @@
package com.github.damontecres.wholphin
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@Composable
fun MainContent(
backStack: MutableList<SetupDestination>,
navigationManager: NavigationManager,
appPreferences: AppPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
requestedDestination: Destination,
modifier: Modifier = Modifier,
) {
Surface(
modifier =
modifier
.background(MaterialTheme.colorScheme.background),
shape = RectangleShape,
) {
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as SetupDestination
NavEntry(key) {
when (key) {
SetupDestination.Loading -> {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
SetupDestination.ServerList -> {
SwitchServerContent(Modifier.fillMaxSize())
}
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
Modifier.fillMaxSize(),
)
}
is SetupDestination.AppContent -> {
LaunchedEffect(Unit) {
backdropService.clearBackdrop()
}
val current = key.current
ProvideLocalClock {
val preferences =
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
ApplicationContent(
user = current.user,
server = current.server,
startDestination = requestedDestination,
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
}
},
)
val screenSaverState by screensaverService.state.collectAsState()
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
AnimatedVisibility(
screenSaverState.show,
Modifier.fillMaxSize(),
) {
AppScreensaver(appPreferences, Modifier.fillMaxSize())
}
}
}
}

View file

@ -9,10 +9,12 @@ import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@ -41,6 +43,7 @@ class ServerRepository
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current
@ -57,7 +60,7 @@ class ServerRepository
* The current user is removed
*/
suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.addOrUpdateServer(server)
}
apiClient.update(baseUrl = server.url, accessToken = null)
@ -71,7 +74,7 @@ class ServerRepository
server: JellyfinServer,
user: JellyfinUser,
): CurrentUser? =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server")
}
@ -126,7 +129,7 @@ class ServerRepository
return null
}
val serverAndUsers =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
@ -149,7 +152,7 @@ class ServerRepository
}
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server }
}
@ -163,7 +166,7 @@ class ServerRepository
suspend fun changeUser(
serverUrl: String,
authenticationResult: AuthenticationResult,
) = withContext(Dispatchers.IO) {
) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val authedUser = authenticationResult.user
@ -213,7 +216,7 @@ class ServerRepository
}
apiClient.update(accessToken = null)
}
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.deleteUser(user.serverId, user.id)
}
}
@ -233,7 +236,7 @@ class ServerRepository
}
apiClient.update(baseUrl = null, accessToken = null)
}
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.deleteServer(server.id)
}
}
@ -252,7 +255,7 @@ class ServerRepository
suspend fun setUserPin(
user: JellyfinUser,
pin: String?,
) = withContext(Dispatchers.IO) {
) = withContext(ioDispatcher) {
val newUser = user.copy(pin = pin)
val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
@ -265,7 +268,7 @@ class ServerRepository
}
suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
val userId = currentUser.value?.id
if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization")

View file

@ -17,7 +17,7 @@ import javax.inject.Singleton
class SetupNavigationManager
@Inject
constructor() {
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading)
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
/**
* Go to the specified [SetupDestination]

View file

@ -18,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -49,6 +50,14 @@ annotation class IoCoroutineScope
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@ -62,12 +71,6 @@ object AppModule {
version = BuildConfig.VERSION_NAME,
)
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
@StandardOkHttpClient
@Provides
@Singleton
@ -177,15 +180,29 @@ object AppModule {
}
}
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun ioCoroutineScope(
@IoDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun defaultCoroutineScope(
@DefaultDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
@ -199,3 +216,13 @@ object AppModule {
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
}
@Module
@InstallIn(SingletonComponent::class)
object DeviceModule {
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
}

View file

@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -382,7 +383,10 @@ fun AddServerCard(
Surface(
onClick = onClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
modifier =
Modifier
.size(cardSize)
.testTag("add_server"),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -352,6 +353,7 @@ fun SwitchServerContent(
),
modifier =
Modifier
.testTag("server_url_text")
.focusRequester(textBoxFocusRequester)
.fillMaxWidth(),
)

View file

@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@ -42,6 +45,8 @@ class SwitchServerViewModel
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: SetupNavigationManager,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util
import android.content.Context
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.google.auto.service.AutoService
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
@ -49,7 +50,7 @@ class CrashReportSender : ReportSender {
createJellyfin {
this.context = context
clientInfo = AppModule.clientInfo(context)
deviceInfo = AppModule.deviceInfo(context)
deviceInfo = DeviceModule.deviceInfo(context)
apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory
minimumServerVersion = Jellyfin.minimumVersion

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.test
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TestActivity : ComponentActivity()

View file

@ -0,0 +1,238 @@
package com.github.damontecres.wholphin.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.test.TestActivity
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
import org.jellyfin.sdk.api.operations.QuickConnectApi
import org.jellyfin.sdk.discovery.DiscoveryService
import org.jellyfin.sdk.discovery.RecommendedServerInfo
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.UUID
import org.jellyfin.sdk.model.api.PublicSystemInfo
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject
@HiltAndroidTest
@Config(application = HiltTestApplication::class, sdk = [34])
@RunWith(RobolectricTestRunner::class)
class BasicUiTests {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<TestActivity>()
@Inject
lateinit var jellyfin: Jellyfin
@Inject
lateinit var api: ApiClient
@Inject
lateinit var setupNavigationManager: SetupNavigationManager
lateinit var screensaverService: ScreensaverService
lateinit var switchServerViewModel: SwitchServerViewModel
val discovery: DiscoveryService = mockk()
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
Dispatchers.setMain(TestModule.testDispatcher)
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns TestModule.testDispatcher
every { Dispatchers.Default } returns TestModule.testDispatcher
hiltRule.inject()
screensaverService = mockk(relaxed = true)
every { screensaverService.state } returns
MutableStateFlow(
ScreensaverState(
false,
false,
false,
false,
),
)
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
every { jellyfin.discovery } returns discovery
}
@OptIn(ExperimentalCoroutinesApi::class)
@After
fun tearDown() {
Dispatchers.resetMain()
}
/**
* Tests successfully entering and submitting a server URL
*/
@OptIn(ExperimentalTestApi::class)
@Test
fun test_enter_server_url_success() {
coEvery { discovery.getRecommendedServers("localhost") } returns
listOf(
RecommendedServerInfo(
address = "localhost",
responseTime = 50,
score = RecommendedServerInfoScore.GREAT,
issues = emptyList(),
systemInfo =
Result.success(
PublicSystemInfo(
id = UUID.randomUUID().toString(),
startupWizardCompleted = true,
),
),
),
)
val quickConnectApi = mockk<QuickConnectApi>()
every { api.quickConnectApi } returns quickConnectApi
coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true)
composeTestRule.setContent {
WholphinTheme {
switchServerViewModel = hiltViewModel()
SwitchServerContent(
modifier = Modifier.fillMaxSize(),
viewModel = switchServerViewModel,
)
}
}
TestModule.testDispatcher.scheduler.advanceUntilIdle()
composeTestRule.onNodeWithText("Add Server").assertIsDisplayed()
composeTestRule.onNodeWithTag("add_server").performKeyInput {
pressKey(Key.DirectionDown) // TODO fix focus
}
composeTestRule
.onNodeWithTag("add_server")
.assertIsFocused()
.performClickEnter()
composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed()
composeTestRule.onNodeWithText("Enter server address").performClickEnter()
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle()
switchServerViewModel.addServerState.value.let {
if (it is LoadingState.Error) throw it.exception ?: Exception(it.message)
}
// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") }
Assert.assertEquals(1, setupNavigationManager.backStack.size)
Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList)
}
/**
* Tests entering and submitting a server URL that returns an error
*/
@OptIn(ExperimentalTestApi::class)
@Test
fun test_enter_server_url_error() {
coEvery { discovery.getRecommendedServers("localhost") } returns
listOf(
RecommendedServerInfo(
address = "localhost",
responseTime = 50,
score = RecommendedServerInfoScore.GREAT,
issues = emptyList(),
systemInfo =
Result.success(
PublicSystemInfo(
id = null, // Invalid
startupWizardCompleted = false,
),
),
),
)
val quickConnectApi = mockk<QuickConnectApi>()
every { api.quickConnectApi } returns quickConnectApi
coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true)
composeTestRule.setContent {
WholphinTheme {
switchServerViewModel = hiltViewModel()
SwitchServerContent(
modifier = Modifier.fillMaxSize(),
viewModel = switchServerViewModel,
)
}
}
TestModule.testDispatcher.scheduler.advanceUntilIdle()
composeTestRule.onNodeWithText("Add Server").assertIsDisplayed()
composeTestRule.onNodeWithTag("add_server").performKeyInput {
pressKey(Key.DirectionDown) // TODO fix focus
}
composeTestRule
.onNodeWithTag("add_server")
.assertIsFocused()
.performClickEnter()
composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed()
composeTestRule.onNodeWithText("Enter server address").performClickEnter()
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
TestModule.testDispatcher.scheduler.advanceUntilIdle()
Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error)
composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed()
}
}

View file

@ -0,0 +1,282 @@
package com.github.damontecres.wholphin.ui
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import androidx.room.Room
import androidx.work.WorkManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.AppDatabase
import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.JellyfinServerDao
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
import com.github.damontecres.wholphin.data.Migrations
import com.github.damontecres.wholphin.data.PlaybackEffectDao
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerPreferencesDao
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.test.StandardTestDispatcher
import okhttp3.OkHttpClient
import org.jellyfin.sdk.Jellyfin
import org.jellyfin.sdk.JellyfinOptions
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import timber.log.Timber
import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DeviceModule::class, AppModule::class],
)
object TestModule {
val testDispatcher = StandardTestDispatcher()
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = DeviceInfo("test_device_id", "test_device")
@Provides
@Singleton
fun clientInfo(
@ApplicationContext context: Context,
): ClientInfo =
ClientInfo(
name = context.getString(R.string.app_name),
version = BuildConfig.VERSION_NAME,
)
@StandardOkHttpClient
@Provides
@Singleton
fun okHttpClient() =
OkHttpClient
.Builder()
.apply {
// TODO user agent, timeouts, logging, etc
}.build()
@AuthOkHttpClient
@Provides
@Singleton
fun authOkHttpClient(
serverRepository: ServerRepository,
@StandardOkHttpClient okHttpClient: OkHttpClient,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
) = okHttpClient
.newBuilder()
.addInterceptor {
val request = it.request()
val newRequest =
serverRepository.currentUser.value?.accessToken?.let { token ->
request
.newBuilder()
.addHeader(
"Authorization",
AuthorizationHeaderBuilder.buildHeader(
clientName = clientInfo.name,
clientVersion = clientInfo.version,
deviceId = deviceInfo.id,
deviceName = deviceInfo.name,
accessToken = token,
),
).build()
}
it.proceed(newRequest ?: request)
}.build()
@Provides
@Singleton
fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides
@Singleton
fun jellyfin(
okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
): Jellyfin {
val jellyfin: Jellyfin = mockk()
every { jellyfin.clientInfo } returns clientInfo
every { jellyfin.deviceInfo } returns deviceInfo
every { jellyfin.options } returns
JellyfinOptions(
context,
clientInfo,
deviceInfo,
okHttpFactory,
okHttpFactory,
Jellyfin.minimumVersion,
)
every { jellyfin.createApi(any()) } returns apiClient(jellyfin)
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin)
return jellyfin
}
@Provides
@Singleton
fun apiClient(jellyfin: Jellyfin): ApiClient {
val api: ApiClient = mockk()
every { api.clientInfo } returns jellyfin.clientInfo!!
every { api.deviceInfo } returns jellyfin.deviceInfo!!
every { api.update(any(), any(), any(), any()) } returns Unit
return api
}
/**
* Implementation of [RememberTabManager] which remembers by server, user, & item
*/
@Provides
@Singleton
fun rememberTabManager(
serverRepository: ServerRepository,
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
): RememberTabManager = mockk()
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = testDispatcher
@Provides
@Singleton
@IoCoroutineScope
fun ioCoroutineScope(
@IoDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = testDispatcher
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(
@DefaultDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
fun workManager(
@ApplicationContext context: Context,
): WorkManager = mockk()
@Provides
@Singleton
fun seerrApi(
@StandardOkHttpClient okHttpClient: OkHttpClient,
): SeerrApi = mockk()
}
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DatabaseModule::class],
)
object TestDatabaseModule {
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun database(
@ApplicationContext context: Context,
): AppDatabase =
Room
.inMemoryDatabaseBuilder(
context,
AppDatabase::class.java,
).addMigrations(Migrations.Migrate2to3)
.allowMainThreadQueries()
.setQueryCallback({ sqlQuery, args ->
Timber.v("sqlQuery=$sqlQuery, args=$args")
}, Dispatchers.IO.asExecutor())
.build()
@Provides
@Singleton
fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao()
@Provides
@Singleton
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
@Provides
@Singleton
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
@Provides
@Singleton
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
@Provides
@Singleton
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
@Provides
@Singleton
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
@Provides
@Singleton
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
@Provides
@Singleton
fun userPreferencesDataStore(
@ApplicationContext context: Context,
userPreferencesSerializer: AppPreferencesSerializer,
): DataStore<AppPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer,
produceFile = { context.dataStoreFile("app_preferences.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(
produceNewData = { AppPreferences.getDefaultInstance() },
),
)
}
}

View file

@ -0,0 +1,16 @@
package com.github.damontecres.wholphin.ui
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import org.jellyfin.sdk.api.client.Response
@OptIn(ExperimentalTestApi::class)
fun SemanticsNodeInteraction.performClickEnter() =
performKeyInput {
pressKey(Key.DirectionCenter)
}
fun <T> successResponse(content: T) = Response(content, 200, emptyMap())

View file

@ -43,6 +43,7 @@ paletteKtx = "1.0.0"
kotlinxCoroutinesTest = "1.10.2"
coreTesting = "2.2.0"
openapi-generator = "7.20.0"
runner = "1.7.0"
[libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
@ -72,6 +73,7 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
@ -79,6 +81,7 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
@ -129,6 +132,7 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }