test(ui): cover modular features and feedback flows

This commit is contained in:
2026-07-10 18:19:33 +02:00
parent a15be5787d
commit 3160a5ed9c
7 changed files with 609 additions and 45 deletions

View File

@@ -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")
}
}

View File

@@ -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,
),
)
}

View File

@@ -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)
}

View File

@@ -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 })
}
}

View File

@@ -1,15 +1,14 @@
package com.vnidrop.app.ui.state
import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.feature.receive.ReceiveState
import com.vnidrop.app.feature.send.SendState
import com.vnidrop.app.core.Transfer
import com.vnidrop.app.ui.theme.ThemeMode
import com.vnidrop.app.ui.theme.resolveDarkTheme
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import uniffi.vnidrop.StoredTransfer
class AppUiModelsTest {
@Test
@@ -34,18 +33,6 @@ class AppUiModelsTest {
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
fun byteFormattingKeepsTransferCardsReadable() {
assertEquals("58 B", formatBytes(58UL))
@@ -54,37 +41,28 @@ class AppUiModelsTest {
@Test
fun sendStateExposesShareEligibility() {
val ready = SendUiState(selectedSource = "/tmp/payload.txt")
val ready = SendState(selectedSource = "/tmp/payload.txt")
assertTrue(ready.canCreateShare(isCoreInitialized = true))
assertFalse(ready.canCreateShare(isCoreInitialized = false))
assertFalse(SendUiState().canCreateShare(isCoreInitialized = true))
assertFalse(ready.copy(isSharing = true).canCreateShare(isCoreInitialized = true))
assertTrue(ready.canCreateShare(coreInitialized = true))
assertFalse(ready.canCreateShare(coreInitialized = false))
assertFalse(SendState().canCreateShare(coreInitialized = true))
assertFalse(ready.copy(isSharing = true).canCreateShare(coreInitialized = true))
}
@Test
fun receiveStateExposesInspectAndReceiveEligibility() {
val ready = ReceiveUiState(ticket = "ticket", outputDirectory = "/tmp/out")
assertTrue(ready.canInspect(isCoreInitialized = true))
assertTrue(ready.canReceive(isCoreInitialized = true))
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",
val ready = ReceiveState(
ticket = "ticket",
outputDirectory = "/tmp/out",
folderAccessStatus = com.vnidrop.app.core.FolderAccessStatus.Writable,
)
assertTrue(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Writable).canReceiveIntoFolder)
assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.PermissionRequired).canReceiveIntoFolder)
assertFalse(PreferencesUiState(receiveFolder = folder, folderAccessStatus = FolderAccessStatus.Unavailable).canReceiveIntoFolder)
assertTrue(ready.canInspect(coreInitialized = true))
assertTrue(ready.canReceive(coreInitialized = true))
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
@@ -97,19 +75,16 @@ class AppUiModelsTest {
assertFalse(storedTransfer(status = "cancelled").isActiveTransfer())
}
private fun storedTransfer(status: String): StoredTransfer =
StoredTransfer(
private fun storedTransfer(status: String): Transfer =
Transfer(
localId = "local-1",
transferId = 1UL,
peerId = null,
direction = "send",
status = status,
transferName = "Demo",
contentHash = null,
ticket = null,
fileCount = 1UL,
totalSize = 128UL,
createdAt = 1L,
updatedAt = 1L,
)
}

View File

@@ -30,6 +30,15 @@ class AppPreferencesRepositoryTest {
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 {
val directory = Files.createTempDirectory("vnidrop-preferences-test").toString()
return AppPreferencesRepository(

View File

@@ -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,
)
}