diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt new file mode 100644 index 0000000..80ab3dc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -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") + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt new file mode 100644 index 0000000..dc005c7 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/approvals/ApprovalCoordinatorTest.kt @@ -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, + ), + ) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt new file mode 100644 index 0000000..92926f6 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -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 = mutableState + val mutableSignals = MutableSharedFlow(extraBufferCapacity = 16) + override val signals: SharedFlow = mutableSignals + val requests = mutableMapOf>() + var responseResult: Result = Result.success(Unit) + val responses = mutableListOf>() + + override suspend fun initialize(appDataDir: String): Result { + 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(UnsupportedOperationException()) + override suspend fun shareFileDescriptor(fd: Int, displayName: String, transferName: String, senderName: String) = Result.failure(UnsupportedOperationException()) + override suspend fun shareSecurityScopedFileUrl(fileUrl: String, displayName: String, transferName: String, senderName: String) = Result.failure(UnsupportedOperationException()) + override suspend fun inspectTicket(ticket: String) = Result.failure(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 { + 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 = mutablePermission + val published = mutableListOf() + val cancelled = mutableListOf() + var cancelAllCount = 0 + var openSettingsCount = 0 + var openSettingsResult: Result = Result.success(Unit) + override suspend fun refreshPermission() = permission.value + override suspend fun requestPermission() = permission.value + override suspend fun openSettings(): Result = openSettingsResult.also { openSettingsCount += 1 } + override suspend fun publish(notification: LocalNotification): Result = 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) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UiMessageControllerTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UiMessageControllerTest.kt new file mode 100644 index 0000000..34f55f3 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UiMessageControllerTest.kt @@ -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 }) + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt index 72376bc..0dfb119 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/state/AppUiModelsTest.kt @@ -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, ) } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt index 255885f..87fa8d3 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -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( diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt new file mode 100644 index 0000000..1ffcab3 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -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, + ) +}