This commit was merged in pull request #119.
This commit is contained in:
@@ -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) },
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>(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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user