mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
test(ui): cover modular features and feedback flows
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
package com.vnidrop.app.feature
|
||||||
|
|
||||||
|
import com.vnidrop.app.DeviceInfo
|
||||||
|
import com.vnidrop.app.PlatformEnvironment
|
||||||
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
|
import com.vnidrop.app.core.ReceiveFolderKind
|
||||||
|
import com.vnidrop.app.feature.app.AppViewModel
|
||||||
|
import com.vnidrop.app.feature.receive.ReceiveViewModel
|
||||||
|
import com.vnidrop.app.feature.send.SendViewModel
|
||||||
|
import com.vnidrop.app.feature.settings.SettingsViewModel
|
||||||
|
import com.vnidrop.app.notifications.NotificationPermission
|
||||||
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
|
import com.vnidrop.app.support.FakeCoreGateway
|
||||||
|
import com.vnidrop.app.support.FakeFileSystemService
|
||||||
|
import com.vnidrop.app.support.FakeNotificationService
|
||||||
|
import com.vnidrop.app.support.FakePreferencesRepository
|
||||||
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
|
import com.vnidrop.app.ui.navigation.AppDestination
|
||||||
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ViewModelsTest {
|
||||||
|
@AfterTest
|
||||||
|
fun resetDispatcher() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appViewModelInitializesCoreAndOwnsNavigation() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = FakeCoreGateway()
|
||||||
|
val viewModel = AppViewModel(environment(), core, preferences(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(core.state.value.isInitialized)
|
||||||
|
viewModel.selectDestination(AppDestination.Settings)
|
||||||
|
assertEquals(AppDestination.Settings, viewModel.state.value.destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsEnablesNotificationsOnlyAfterPermission() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val preferences = preferences()
|
||||||
|
val notifications = FakeNotificationService(NotificationPermission.Granted)
|
||||||
|
val viewModel = SettingsViewModel(
|
||||||
|
environment(),
|
||||||
|
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
|
||||||
|
FakeFileSystemService(folder),
|
||||||
|
preferences,
|
||||||
|
notifications,
|
||||||
|
UiMessageController(),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.setNotificationsEnabled(true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(preferences.mutablePreferences.value.notificationsEnabled)
|
||||||
|
viewModel.setNotificationsEnabled(false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertFalse(preferences.mutablePreferences.value.notificationsEnabled)
|
||||||
|
assertEquals(1, notifications.cancelAllCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val preferences = preferences()
|
||||||
|
val viewModel = SettingsViewModel(
|
||||||
|
environment(),
|
||||||
|
{ DeviceInfo("Device", null, "OS", null, null) },
|
||||||
|
FakeFileSystemService(folder),
|
||||||
|
preferences,
|
||||||
|
FakeNotificationService(NotificationPermission.Denied),
|
||||||
|
UiMessageController(),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.setNotificationsEnabled(true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertFalse(preferences.mutablePreferences.value.notificationsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsCompletesNotificationOptInAfterSystemSettingsGrant() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val preferences = preferences()
|
||||||
|
val notifications = FakeNotificationService(NotificationPermission.Denied)
|
||||||
|
val viewModel = SettingsViewModel(
|
||||||
|
environment(),
|
||||||
|
{ DeviceInfo("Device", null, "OS", null, null) },
|
||||||
|
FakeFileSystemService(folder),
|
||||||
|
preferences,
|
||||||
|
notifications,
|
||||||
|
UiMessageController(),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.openNotificationSettings()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, notifications.openSettingsCount)
|
||||||
|
|
||||||
|
notifications.mutablePermission.value = NotificationPermission.Granted
|
||||||
|
viewModel.refreshNotificationPermission()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(preferences.mutablePreferences.value.notificationsEnabled)
|
||||||
|
assertEquals(NotificationPermission.Granted, viewModel.state.value.notificationPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsReportsUnsupportedNotificationPlatforms() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val preferences = preferences()
|
||||||
|
val viewModel = SettingsViewModel(
|
||||||
|
environment(),
|
||||||
|
{ DeviceInfo("Device", null, "OS", null, null) },
|
||||||
|
FakeFileSystemService(folder),
|
||||||
|
preferences,
|
||||||
|
FakeNotificationService(NotificationPermission.Unsupported),
|
||||||
|
UiMessageController(),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.setNotificationsEnabled(true)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertFalse(preferences.mutablePreferences.value.notificationsEnabled)
|
||||||
|
assertEquals(NotificationPermission.Unsupported, viewModel.state.value.notificationPermission)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendViewModelOwnsSelectedFileState() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val viewModel = SendViewModel(FakeCoreGateway(), FakeFileSystemService(folder), preferences(), UiMessageController())
|
||||||
|
viewModel.onFilePicked(com.vnidrop.app.core.PickedShareFile("/tmp/photo.jpg", "photo.jpg"))
|
||||||
|
assertEquals("photo.jpg", viewModel.state.value.transferName)
|
||||||
|
assertTrue(viewModel.state.value.hasSelectedSource)
|
||||||
|
viewModel.clearSelectedSource()
|
||||||
|
assertFalse(viewModel.state.value.hasSelectedSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun receiveViewModelBuildsStateFromPreferences() = runTest {
|
||||||
|
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||||
|
val core = FakeCoreGateway().apply { mutableState.value = mutableState.value.copy(isInitialized = true) }
|
||||||
|
val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), UiMessageController())
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.setTicket("ticket")
|
||||||
|
assertTrue(viewModel.state.value.canReceive(coreInitialized = true))
|
||||||
|
assertEquals("Receiver", viewModel.state.value.receiverName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preferences() = FakePreferencesRepository(
|
||||||
|
AppPreferences("Receiver", folder, ThemeMode.System, notificationsEnabled = false),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun environment() = PlatformEnvironment("Test", "1.0", "/tmp/vnidrop")
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val folder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "Downloads")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.vnidrop.app.feature.approvals
|
||||||
|
|
||||||
|
import com.vnidrop.app.core.CoreSignal
|
||||||
|
import com.vnidrop.app.core.CoreState
|
||||||
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
|
import com.vnidrop.app.core.Transfer
|
||||||
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
|
import com.vnidrop.app.core.ReceiveFolderKind
|
||||||
|
import com.vnidrop.app.platform.AppVisibility
|
||||||
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
|
import com.vnidrop.app.support.FakeCoreGateway
|
||||||
|
import com.vnidrop.app.support.FakeNotificationService
|
||||||
|
import com.vnidrop.app.support.FakePreferencesRepository
|
||||||
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class ApprovalCoordinatorTest {
|
||||||
|
@Test
|
||||||
|
fun ordersRequestsAndPublishesEachNotificationOnce() = runTest {
|
||||||
|
val core = FakeCoreGateway()
|
||||||
|
core.requests[1UL] = listOf(request("new", 20), request("old", 10))
|
||||||
|
core.mutableState.value = CoreState(isInitialized = true, transfers = listOf(activeTransfer()))
|
||||||
|
val notifications = FakeNotificationService()
|
||||||
|
val visibility = AppVisibility(initiallyForeground = false)
|
||||||
|
val coordinator = ApprovalCoordinator(core, preferences(enabled = true), notifications, visibility, UiMessageController(), backgroundScope)
|
||||||
|
|
||||||
|
runCurrent()
|
||||||
|
core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(listOf("old", "new"), coordinator.state.value.pending.map(PendingApproval::id))
|
||||||
|
assertEquals(2, notifications.published.size)
|
||||||
|
|
||||||
|
core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(2, notifications.published.size)
|
||||||
|
|
||||||
|
visibility.setForeground(true)
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(notifications.cancelAllCount > 0)
|
||||||
|
|
||||||
|
core.requests[1UL] = emptyList()
|
||||||
|
core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL))
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(coordinator.state.value.pending.isEmpty())
|
||||||
|
assertEquals(2, notifications.cancelled.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedResponseKeepsRequestVisible() = runTest {
|
||||||
|
val core = FakeCoreGateway().apply {
|
||||||
|
requests[1UL] = listOf(request("request", 10))
|
||||||
|
mutableState.value = CoreState(isInitialized = true, transfers = listOf(activeTransfer()))
|
||||||
|
responseResult = Result.failure(IllegalStateException("database unavailable"))
|
||||||
|
}
|
||||||
|
val coordinator = ApprovalCoordinator(
|
||||||
|
core,
|
||||||
|
preferences(enabled = false),
|
||||||
|
FakeNotificationService(),
|
||||||
|
AppVisibility(),
|
||||||
|
UiMessageController(),
|
||||||
|
backgroundScope,
|
||||||
|
)
|
||||||
|
runCurrent()
|
||||||
|
core.mutableSignals.emit(CoreSignal.ApprovalChanged(1UL))
|
||||||
|
advanceUntilIdle()
|
||||||
|
coordinator.accept("request")
|
||||||
|
runCurrent()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(coordinator.state.value.pending.any { it.id == "request" })
|
||||||
|
assertTrue(coordinator.state.value.respondingIds.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(id: String, requestedAt: Long) = ReceiverRequestModel(
|
||||||
|
id = id,
|
||||||
|
transferId = 1UL,
|
||||||
|
remoteEndpointId = "endpoint",
|
||||||
|
transferName = "Photos",
|
||||||
|
receiverName = "Peer",
|
||||||
|
receiverDeviceName = "Phone",
|
||||||
|
appVersion = "1.0",
|
||||||
|
status = "requested",
|
||||||
|
reason = null,
|
||||||
|
requestedAt = requestedAt,
|
||||||
|
respondedAt = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun activeTransfer() = Transfer("local", 1UL, "send", "sharing", null, "Photos", 1UL, 1UL, null)
|
||||||
|
|
||||||
|
private fun preferences(enabled: Boolean) = FakePreferencesRepository(
|
||||||
|
AppPreferences(
|
||||||
|
username = "Sender",
|
||||||
|
receiveFolder = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/tmp", "tmp"),
|
||||||
|
themeMode = ThemeMode.System,
|
||||||
|
notificationsEnabled = enabled,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package com.vnidrop.app.support
|
||||||
|
|
||||||
|
import com.vnidrop.app.core.CoreGateway
|
||||||
|
import com.vnidrop.app.core.CoreSignal
|
||||||
|
import com.vnidrop.app.core.CoreState
|
||||||
|
import com.vnidrop.app.core.FileSystemService
|
||||||
|
import com.vnidrop.app.core.FolderAccessStatus
|
||||||
|
import com.vnidrop.app.core.PickedShareFile
|
||||||
|
import com.vnidrop.app.core.ReceiveFolder
|
||||||
|
import com.vnidrop.app.core.ReceiverRequestModel
|
||||||
|
import com.vnidrop.app.core.Share
|
||||||
|
import com.vnidrop.app.core.TicketInspectionModel
|
||||||
|
import com.vnidrop.app.notifications.LocalNotification
|
||||||
|
import com.vnidrop.app.notifications.LocalNotificationService
|
||||||
|
import com.vnidrop.app.notifications.NotificationPermission
|
||||||
|
import com.vnidrop.app.preferences.AppPreferences
|
||||||
|
import com.vnidrop.app.preferences.PreferencesRepository
|
||||||
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import uniffi.vnidrop.ReceiveOutputSink
|
||||||
|
|
||||||
|
class FakeCoreGateway : CoreGateway {
|
||||||
|
val mutableState = MutableStateFlow(CoreState())
|
||||||
|
override val state: StateFlow<CoreState> = mutableState
|
||||||
|
val mutableSignals = MutableSharedFlow<CoreSignal>(extraBufferCapacity = 16)
|
||||||
|
override val signals: SharedFlow<CoreSignal> = mutableSignals
|
||||||
|
val requests = mutableMapOf<ULong, List<ReceiverRequestModel>>()
|
||||||
|
var responseResult: Result<Unit> = Result.success(Unit)
|
||||||
|
val responses = mutableListOf<Triple<String, Boolean, String?>>()
|
||||||
|
|
||||||
|
override suspend fun initialize(appDataDir: String): Result<Unit> {
|
||||||
|
mutableState.value = mutableState.value.copy(isInitialized = true)
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
override fun shutdown() = Unit
|
||||||
|
override suspend fun sharePath(path: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
|
override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
|
override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure<Share>(UnsupportedOperationException())
|
||||||
|
override suspend fun inspectTicket(ticket: String) = Result.failure<TicketInspectionModel>(UnsupportedOperationException())
|
||||||
|
override suspend fun receive(ticket: String, outputDir: String, receiverName: String) = Result.success(Unit)
|
||||||
|
override suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String) = Result.success(Unit)
|
||||||
|
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) = Result.success(Unit)
|
||||||
|
override suspend fun cancel(transferId: ULong) = Result.success(Unit)
|
||||||
|
override suspend fun receiverRequests(transferId: ULong) = Result.success(requests[transferId].orEmpty())
|
||||||
|
override suspend fun respondReceiverRequest(requestId: String, accepted: Boolean, reason: String?): Result<Unit> {
|
||||||
|
responses += Triple(requestId, accepted, reason)
|
||||||
|
return responseResult
|
||||||
|
}
|
||||||
|
override suspend fun refresh() = Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakePreferencesRepository(
|
||||||
|
initial: AppPreferences,
|
||||||
|
) : PreferencesRepository {
|
||||||
|
val mutablePreferences = MutableStateFlow(initial)
|
||||||
|
override val preferences = mutablePreferences
|
||||||
|
override suspend fun setUsername(username: String) { mutablePreferences.value = mutablePreferences.value.copy(username = username) }
|
||||||
|
override suspend fun setReceiveFolder(folder: ReceiveFolder) { mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = folder) }
|
||||||
|
override suspend fun resetReceiveFolder() = Unit
|
||||||
|
override suspend fun setThemeMode(mode: ThemeMode) { mutablePreferences.value = mutablePreferences.value.copy(themeMode = mode) }
|
||||||
|
override suspend fun setNotificationsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(notificationsEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeNotificationService(
|
||||||
|
permission: NotificationPermission = NotificationPermission.Granted,
|
||||||
|
) : LocalNotificationService {
|
||||||
|
val mutablePermission = MutableStateFlow(permission)
|
||||||
|
override val permission: StateFlow<NotificationPermission> = mutablePermission
|
||||||
|
val published = mutableListOf<LocalNotification>()
|
||||||
|
val cancelled = mutableListOf<String>()
|
||||||
|
var cancelAllCount = 0
|
||||||
|
var openSettingsCount = 0
|
||||||
|
var openSettingsResult: Result<Unit> = Result.success(Unit)
|
||||||
|
override suspend fun refreshPermission() = permission.value
|
||||||
|
override suspend fun requestPermission() = permission.value
|
||||||
|
override suspend fun openSettings(): Result<Unit> = openSettingsResult.also { openSettingsCount += 1 }
|
||||||
|
override suspend fun publish(notification: LocalNotification): Result<Unit> = Result.success(Unit).also { published += notification }
|
||||||
|
override suspend fun cancel(id: String) { cancelled += id }
|
||||||
|
override suspend fun cancelAll() { cancelAllCount += 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeFileSystemService(
|
||||||
|
private val folder: ReceiveFolder,
|
||||||
|
) : FileSystemService {
|
||||||
|
override fun defaultReceiveFolder() = folder
|
||||||
|
override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable
|
||||||
|
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null
|
||||||
|
override suspend fun sharePickedFile(
|
||||||
|
repository: CoreGateway,
|
||||||
|
file: PickedShareFile,
|
||||||
|
transferName: String,
|
||||||
|
senderName: String,
|
||||||
|
) = repository.sharePath(file.value, transferName, senderName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.vnidrop.app.ui.feedback
|
||||||
|
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class UiMessageControllerTest {
|
||||||
|
@Test
|
||||||
|
fun messagesBufferAndPreserveFifoOrder() = runTest {
|
||||||
|
val controller = UiMessageController()
|
||||||
|
assertTrue(controller.tryShow(UiMessage(UiText.Dynamic("first"))))
|
||||||
|
assertTrue(controller.tryShow(UiMessage(UiText.Dynamic("second"))))
|
||||||
|
val collected = async { controller.messages.take(2).toList() }.await()
|
||||||
|
assertEquals(listOf("first", "second"), collected.map { (it.text as UiText.Dynamic).value })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
package com.vnidrop.app.ui.state
|
package com.vnidrop.app.ui.state
|
||||||
|
|
||||||
import com.vnidrop.app.core.FolderAccessStatus
|
import com.vnidrop.app.feature.receive.ReceiveState
|
||||||
import com.vnidrop.app.core.ReceiveFolder
|
import com.vnidrop.app.feature.send.SendState
|
||||||
import com.vnidrop.app.core.ReceiveFolderKind
|
import com.vnidrop.app.core.Transfer
|
||||||
import com.vnidrop.app.ui.theme.ThemeMode
|
import com.vnidrop.app.ui.theme.ThemeMode
|
||||||
import com.vnidrop.app.ui.theme.resolveDarkTheme
|
import com.vnidrop.app.ui.theme.resolveDarkTheme
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFalse
|
import kotlin.test.assertFalse
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
import uniffi.vnidrop.StoredTransfer
|
|
||||||
|
|
||||||
class AppUiModelsTest {
|
class AppUiModelsTest {
|
||||||
@Test
|
@Test
|
||||||
@@ -34,18 +33,6 @@ class AppUiModelsTest {
|
|||||||
assertTrue(resolveDarkTheme(ThemeMode.Dark, systemDark = false))
|
assertTrue(resolveDarkTheme(ThemeMode.Dark, systemDark = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun coreErrorsBecomeStableUserMessages() {
|
|
||||||
assertEquals(
|
|
||||||
"The ticket could not be read. Check that the full ticket was copied.",
|
|
||||||
friendlyCoreError("reason=failed to parse transfer ticket"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"The transfer is waiting for approval or was refused by the sender.",
|
|
||||||
friendlyCoreError("permission denied by sender"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun byteFormattingKeepsTransferCardsReadable() {
|
fun byteFormattingKeepsTransferCardsReadable() {
|
||||||
assertEquals("58 B", formatBytes(58UL))
|
assertEquals("58 B", formatBytes(58UL))
|
||||||
@@ -54,37 +41,28 @@ class AppUiModelsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sendStateExposesShareEligibility() {
|
fun sendStateExposesShareEligibility() {
|
||||||
val ready = SendUiState(selectedSource = "/tmp/payload.txt")
|
val ready = SendState(selectedSource = "/tmp/payload.txt")
|
||||||
|
|
||||||
assertTrue(ready.canCreateShare(isCoreInitialized = true))
|
assertTrue(ready.canCreateShare(coreInitialized = true))
|
||||||
assertFalse(ready.canCreateShare(isCoreInitialized = false))
|
assertFalse(ready.canCreateShare(coreInitialized = false))
|
||||||
assertFalse(SendUiState().canCreateShare(isCoreInitialized = true))
|
assertFalse(SendState().canCreateShare(coreInitialized = true))
|
||||||
assertFalse(ready.copy(isSharing = true).canCreateShare(isCoreInitialized = true))
|
assertFalse(ready.copy(isSharing = true).canCreateShare(coreInitialized = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun receiveStateExposesInspectAndReceiveEligibility() {
|
fun receiveStateExposesInspectAndReceiveEligibility() {
|
||||||
val ready = ReceiveUiState(ticket = "ticket", outputDirectory = "/tmp/out")
|
val ready = ReceiveState(
|
||||||
|
ticket = "ticket",
|
||||||
assertTrue(ready.canInspect(isCoreInitialized = true))
|
outputDirectory = "/tmp/out",
|
||||||
assertTrue(ready.canReceive(isCoreInitialized = true))
|
folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable,
|
||||||
assertFalse(ready.canInspect(isCoreInitialized = false))
|
|
||||||
assertFalse(ready.copy(ticket = "").canReceive(isCoreInitialized = true))
|
|
||||||
assertFalse(ready.copy(outputDirectory = "").canReceive(isCoreInitialized = true))
|
|
||||||
assertFalse(ready.copy(isReceiving = true).canReceive(isCoreInitialized = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun preferencesStateExposesReceiveFolderEligibility() {
|
|
||||||
val folder = ReceiveFolder(
|
|
||||||
kind = ReceiveFolderKind.FileSystemPath,
|
|
||||||
value = "/tmp/downloads",
|
|
||||||
displayName = "Downloads",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assertTrue(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Writable).canReceiveIntoFolder)
|
assertTrue(ready.canInspect(coreInitialized = true))
|
||||||
assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.PermissionRequired).canReceiveIntoFolder)
|
assertTrue(ready.canReceive(coreInitialized = true))
|
||||||
assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Unavailable).canReceiveIntoFolder)
|
assertFalse(ready.canInspect(coreInitialized = false))
|
||||||
|
assertFalse(ready.copy(ticket = "").canReceive(coreInitialized = true))
|
||||||
|
assertFalse(ready.copy(outputDirectory = "").canReceive(coreInitialized = true))
|
||||||
|
assertFalse(ready.copy(isReceiving = true).canReceive(coreInitialized = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -97,19 +75,16 @@ class AppUiModelsTest {
|
|||||||
assertFalse(storedTransfer(status = "cancelled").isActiveTransfer())
|
assertFalse(storedTransfer(status = "cancelled").isActiveTransfer())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun storedTransfer(status: String): StoredTransfer =
|
private fun storedTransfer(status: String): Transfer =
|
||||||
StoredTransfer(
|
Transfer(
|
||||||
localId = "local-1",
|
localId = "local-1",
|
||||||
transferId = 1UL,
|
transferId = 1UL,
|
||||||
peerId = null,
|
peerId = null,
|
||||||
direction = "send",
|
direction = "send",
|
||||||
status = status,
|
status = status,
|
||||||
transferName = "Demo",
|
transferName = "Demo",
|
||||||
contentHash = null,
|
|
||||||
ticket = null,
|
ticket = null,
|
||||||
fileCount = 1UL,
|
fileCount = 1UL,
|
||||||
totalSize = 128UL,
|
totalSize = 128UL,
|
||||||
createdAt = 1L,
|
|
||||||
updatedAt = 1L,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ class AppPreferencesRepositoryTest {
|
|||||||
assertEquals(ThemeMode.Dark, repository.preferences.first().themeMode)
|
assertEquals(ThemeMode.Dark, repository.preferences.first().themeMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notificationOptInIsDisabledByDefaultAndPersisted() = runBlocking {
|
||||||
|
val repository = repositoryForTest()
|
||||||
|
|
||||||
|
assertEquals(false, repository.preferences.first().notificationsEnabled)
|
||||||
|
repository.setNotificationsEnabled(true)
|
||||||
|
assertEquals(true, repository.preferences.first().notificationsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
private fun repositoryForTest(): AppPreferencesRepository {
|
private fun repositoryForTest(): AppPreferencesRepository {
|
||||||
val directory = Files.createTempDirectory("vnidrop-preferences-test").toString()
|
val directory = Files.createTempDirectory("vnidrop-preferences-test").toString()
|
||||||
return AppPreferencesRepository(
|
return AppPreferencesRepository(
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.vnidrop.app.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.test.v2.runComposeUiTest
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import com.vnidrop.app.feature.approvals.ApprovalBannerHost
|
||||||
|
import com.vnidrop.app.feature.approvals.ApprovalState
|
||||||
|
import com.vnidrop.app.feature.approvals.PendingApproval
|
||||||
|
import com.vnidrop.app.feature.settings.SettingsScreen
|
||||||
|
import com.vnidrop.app.feature.settings.SettingsSection
|
||||||
|
import com.vnidrop.app.feature.settings.SettingsState
|
||||||
|
import com.vnidrop.app.notifications.NotificationPermission
|
||||||
|
import com.vnidrop.app.ui.feedback.UiMessage
|
||||||
|
import com.vnidrop.app.ui.feedback.UiMessageController
|
||||||
|
import com.vnidrop.app.ui.feedback.UiText
|
||||||
|
import com.vnidrop.app.ui.feedback.VniDropSnackbarHost
|
||||||
|
import com.vnidrop.app.ui.state.WindowClass
|
||||||
|
import com.vnidrop.app.ui.navigation.AppDestination
|
||||||
|
import com.vnidrop.app.ui.shell.AppShell
|
||||||
|
import com.vnidrop.app.ui.theme.VniDropTheme
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
class FoundationComposeTest {
|
||||||
|
@Test
|
||||||
|
fun approvalBannerInvokesAcceptAction() = runComposeUiTest {
|
||||||
|
var accepted: String? = null
|
||||||
|
setContent {
|
||||||
|
VniDropTheme(isDarkTheme = false) {
|
||||||
|
ApprovalBannerHost(
|
||||||
|
state = ApprovalState(pending = listOf(approval())),
|
||||||
|
onAccept = { accepted = it },
|
||||||
|
onRefuse = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodeWithText("Approve").performClick()
|
||||||
|
runOnIdle { assertEquals("request", accepted) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun phoneSettingsNavigatesToNotificationSection() = runComposeUiTest {
|
||||||
|
val state = mutableStateOf(SettingsState())
|
||||||
|
setContent {
|
||||||
|
VniDropTheme(isDarkTheme = false) {
|
||||||
|
SettingsScreen(
|
||||||
|
state = state.value,
|
||||||
|
windowClass = WindowClass.Phone,
|
||||||
|
onSectionSelected = { state.value = state.value.copy(selectedSection = it) },
|
||||||
|
onUsernameChanged = {},
|
||||||
|
onThemeModeChanged = {},
|
||||||
|
onChooseFolder = {},
|
||||||
|
onResetFolder = {},
|
||||||
|
onNotificationsChanged = {},
|
||||||
|
onOpenNotificationSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodeWithText("Notifications").performClick()
|
||||||
|
onNodeWithText("Let VniDrop notify you about new connection requests while the app is running in the background.").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notificationSettingCanBeToggledFromItsRow() = runComposeUiTest {
|
||||||
|
var enabled = false
|
||||||
|
setContent {
|
||||||
|
VniDropTheme(isDarkTheme = false) {
|
||||||
|
SettingsScreen(
|
||||||
|
state = SettingsState(selectedSection = SettingsSection.Notifications),
|
||||||
|
windowClass = WindowClass.Phone,
|
||||||
|
onSectionSelected = {},
|
||||||
|
onUsernameChanged = {},
|
||||||
|
onThemeModeChanged = {},
|
||||||
|
onChooseFolder = {},
|
||||||
|
onResetFolder = {},
|
||||||
|
onNotificationsChanged = { enabled = it },
|
||||||
|
onOpenNotificationSettings = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodeWithText("Allow notifications").performClick()
|
||||||
|
runOnIdle { assertEquals(true, enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deniedNotificationSettingOffersSystemSettingsAction() = runComposeUiTest {
|
||||||
|
var opened = false
|
||||||
|
setContent {
|
||||||
|
VniDropTheme(isDarkTheme = false) {
|
||||||
|
SettingsScreen(
|
||||||
|
state = SettingsState(
|
||||||
|
selectedSection = SettingsSection.Notifications,
|
||||||
|
notificationPermission = NotificationPermission.Denied,
|
||||||
|
),
|
||||||
|
windowClass = WindowClass.Phone,
|
||||||
|
onSectionSelected = {},
|
||||||
|
onUsernameChanged = {},
|
||||||
|
onThemeModeChanged = {},
|
||||||
|
onChooseFolder = {},
|
||||||
|
onResetFolder = {},
|
||||||
|
onNotificationsChanged = {},
|
||||||
|
onOpenNotificationSettings = { opened = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodeWithText("Open Settings").performClick()
|
||||||
|
runOnIdle { assertTrue(opened) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun snackbarDisplaysBufferedMessage() = runComposeUiTest {
|
||||||
|
val controller = UiMessageController()
|
||||||
|
controller.tryShow(UiMessage(UiText.Dynamic("Saved successfully")))
|
||||||
|
setContent {
|
||||||
|
VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) }
|
||||||
|
}
|
||||||
|
onNodeWithText("Saved successfully").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun phoneSnackbarOverlayStopsAboveBottomNavigation() = runComposeUiTest {
|
||||||
|
setContent {
|
||||||
|
VniDropTheme(isDarkTheme = false) {
|
||||||
|
AppShell(
|
||||||
|
selectedDestination = AppDestination.Send,
|
||||||
|
windowClass = WindowClass.Phone,
|
||||||
|
onDestinationSelected = {},
|
||||||
|
overlay = {
|
||||||
|
Box(Modifier.align(Alignment.BottomCenter).size(20.dp).testTag("snackbar-overlay"))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val overlayBottom = onNodeWithTag("snackbar-overlay").getUnclippedBoundsInRoot().bottom
|
||||||
|
val navigationLabelTop = onNodeWithText("Send").getUnclippedBoundsInRoot().top
|
||||||
|
assertTrue(overlayBottom <= navigationLabelTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun snackbarActionAndCancellationAreForwarded() = runComposeUiTest {
|
||||||
|
val controller = UiMessageController()
|
||||||
|
var actionCount = 0
|
||||||
|
controller.tryShow(
|
||||||
|
UiMessage(
|
||||||
|
text = UiText.Dynamic("Undoable action"),
|
||||||
|
actionLabel = UiText.Dynamic("Undo"),
|
||||||
|
onAction = { actionCount += 1 },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
setContent { VniDropTheme(isDarkTheme = false) { VniDropSnackbarHost(controller) } }
|
||||||
|
onNodeWithText("Undo").performClick()
|
||||||
|
runOnIdle { assertEquals(1, actionCount) }
|
||||||
|
|
||||||
|
controller.tryShow(UiMessage(UiText.Dynamic("Dismiss me")))
|
||||||
|
onNodeWithText("Dismiss me").assertIsDisplayed()
|
||||||
|
controller.dismissCurrent()
|
||||||
|
onAllNodesWithText("Dismiss me").assertCountEquals(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun approval() = PendingApproval(
|
||||||
|
id = "request",
|
||||||
|
transferId = 1UL,
|
||||||
|
transferName = "Photos",
|
||||||
|
receiverName = "Alice",
|
||||||
|
receiverDeviceName = "Phone",
|
||||||
|
requestedAt = 1L,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user