diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt index 448e854..732aedb 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/App.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,6 +49,7 @@ import org.shahondin1624.theme.WindowSizeClass import org.shahondin1624.viewmodel.CharacterCreationViewModel import org.shahondin1624.logging.AppLogger import org.shahondin1624.viewmodel.CharacterViewModel +import org.shahondin1624.viewmodel.SaveStatus @OptIn(ExperimentalMaterial3Api::class) @Preview @@ -276,6 +278,19 @@ private fun MainScaffold( val currentRoute = currentRoute(navController) val character by characterViewModel.character.collectAsState() val saveStatus by characterViewModel.saveStatus.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + // Show snackbar when a save error occurs + LaunchedEffect(saveStatus) { + if (saveStatus is SaveStatus.Error) { + val errorMessage = (saveStatus as SaveStatus.Error).message + snackbarHostState.showSnackbar( + message = "Save failed: $errorMessage", + actionLabel = "Dismiss", + duration = SnackbarDuration.Long + ) + } + } // Dice roll history (in-memory, not persisted) val diceRollHistory = remember { DiceRollHistory() } @@ -306,6 +321,12 @@ private fun MainScaffold( Scaffold( modifier = modifier.fillMaxSize(), + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR) + ) + }, topBar = { TopAppBar( title = { Text(topBarTitle) }, diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt index e4b9bb8..fd3916c 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/TestTags.kt @@ -135,6 +135,7 @@ object TestTags { const val SAVE_STATUS_SAVING = "save_status_saving" const val SAVE_STATUS_SAVED = "save_status_saved" const val SAVE_STATUS_ERROR = "save_status_error" + const val SAVE_ERROR_SNACKBAR = "save_error_snackbar" // --- Top app bar --- const val TOP_APP_BAR = "top_app_bar" diff --git a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SaveStatusIndicator.kt b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SaveStatusIndicator.kt index ddf7b74..2676b30 100644 --- a/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SaveStatusIndicator.kt +++ b/sharedUI/src/commonMain/kotlin/org/shahondin1624/lib/components/charactermodel/SaveStatusIndicator.kt @@ -94,7 +94,7 @@ fun SaveStatusIndicator( ) { Icon( imageVector = Icons.Default.ErrorOutline, - contentDescription = "Save failed", + contentDescription = "Save failed: ${currentStatus.message}", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error ) diff --git a/sharedUI/src/commonTest/kotlin/org/shahondin1624/SaveErrorNotificationTest.kt b/sharedUI/src/commonTest/kotlin/org/shahondin1624/SaveErrorNotificationTest.kt new file mode 100644 index 0000000..422e09c --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/org/shahondin1624/SaveErrorNotificationTest.kt @@ -0,0 +1,188 @@ +package org.shahondin1624 + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import org.shahondin1624.lib.components.TestTags +import org.shahondin1624.lib.components.charactermodel.SaveStatusIndicator +import org.shahondin1624.viewmodel.SaveStatus +import kotlin.test.Test + +/** + * Tests for save error notification behavior. + * Verifies that save failures produce user-visible error feedback + * (issue #77: Silent save failures). + */ +@OptIn(ExperimentalTestApi::class) +class SaveErrorNotificationTest { + + @Test + fun saveStatusIndicatorShowsErrorIcon() = runComposeUiTest { + setContent { + MaterialTheme { + SaveStatusIndicator(status = SaveStatus.Error("disk full")) + } + } + + onNodeWithTag(TestTags.SAVE_STATUS_ERROR).assertIsDisplayed() + onNodeWithText("Save failed").assertIsDisplayed() + } + + @Test + fun saveStatusIndicatorShowsNothingWhenIdle() = runComposeUiTest { + setContent { + MaterialTheme { + SaveStatusIndicator(status = SaveStatus.Idle) + } + } + + onNodeWithTag(TestTags.SAVE_STATUS_SAVING).assertDoesNotExist() + onNodeWithTag(TestTags.SAVE_STATUS_SAVED).assertDoesNotExist() + onNodeWithTag(TestTags.SAVE_STATUS_ERROR).assertDoesNotExist() + } + + @Test + fun saveStatusIndicatorShowsSavingSpinner() = runComposeUiTest { + setContent { + MaterialTheme { + SaveStatusIndicator(status = SaveStatus.Saving) + } + } + + onNodeWithTag(TestTags.SAVE_STATUS_SAVING).assertIsDisplayed() + onNodeWithText("Saving...").assertIsDisplayed() + } + + @Test + fun saveStatusIndicatorShowsSavedCheckmark() = runComposeUiTest { + setContent { + MaterialTheme { + SaveStatusIndicator(status = SaveStatus.Saved) + } + } + + onNodeWithTag(TestTags.SAVE_STATUS_SAVED).assertIsDisplayed() + onNodeWithText("Saved").assertIsDisplayed() + } + + @Test + fun snackbarAppearsOnSaveError() = runComposeUiTest { + setContent { + MaterialTheme { + val snackbarHostState = remember { SnackbarHostState() } + var saveStatus by remember { mutableStateOf(SaveStatus.Idle) } + + LaunchedEffect(saveStatus) { + if (saveStatus is SaveStatus.Error) { + val errorMessage = (saveStatus as SaveStatus.Error).message + snackbarHostState.showSnackbar( + message = "Save failed: $errorMessage", + actionLabel = "Dismiss", + duration = SnackbarDuration.Long + ) + } + } + + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR) + ) + } + ) { + // Content area is empty for this test + } + + // Trigger the error state + LaunchedEffect(Unit) { + saveStatus = SaveStatus.Error("serialization failed") + } + } + } + + waitForIdle() + onNodeWithText("Save failed: serialization failed").assertIsDisplayed() + } + + @Test + fun snackbarDoesNotAppearOnSuccessfulSave() = runComposeUiTest { + setContent { + MaterialTheme { + val snackbarHostState = remember { SnackbarHostState() } + val saveStatus = SaveStatus.Saved + + LaunchedEffect(saveStatus) { + if (saveStatus is SaveStatus.Error) { + val errorMessage = (saveStatus as SaveStatus.Error).message + snackbarHostState.showSnackbar( + message = "Save failed: $errorMessage", + actionLabel = "Dismiss", + duration = SnackbarDuration.Long + ) + } + } + + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR) + ) + } + ) { + // Content area is empty for this test + } + } + } + + waitForIdle() + onNodeWithText("Save failed", substring = true).assertDoesNotExist() + } + + @Test + fun snackbarIncludesErrorDetails() = runComposeUiTest { + setContent { + MaterialTheme { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + snackbarHostState.showSnackbar( + message = "Save failed: Storage permission denied", + actionLabel = "Dismiss", + duration = SnackbarDuration.Long + ) + } + + Scaffold( + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR) + ) + } + ) { + // Content area is empty for this test + } + } + } + + waitForIdle() + onNodeWithText("Save failed: Storage permission denied").assertIsDisplayed() + onNodeWithText("Dismiss").assertIsDisplayed() + } +}