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.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -48,6 +49,7 @@ import org.shahondin1624.theme.WindowSizeClass
|
|||||||
import org.shahondin1624.viewmodel.CharacterCreationViewModel
|
import org.shahondin1624.viewmodel.CharacterCreationViewModel
|
||||||
import org.shahondin1624.logging.AppLogger
|
import org.shahondin1624.logging.AppLogger
|
||||||
import org.shahondin1624.viewmodel.CharacterViewModel
|
import org.shahondin1624.viewmodel.CharacterViewModel
|
||||||
|
import org.shahondin1624.viewmodel.SaveStatus
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Preview
|
@Preview
|
||||||
@@ -276,6 +278,19 @@ private fun MainScaffold(
|
|||||||
val currentRoute = currentRoute(navController)
|
val currentRoute = currentRoute(navController)
|
||||||
val character by characterViewModel.character.collectAsState()
|
val character by characterViewModel.character.collectAsState()
|
||||||
val saveStatus by characterViewModel.saveStatus.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)
|
// Dice roll history (in-memory, not persisted)
|
||||||
val diceRollHistory = remember { DiceRollHistory() }
|
val diceRollHistory = remember { DiceRollHistory() }
|
||||||
@@ -306,6 +321,12 @@ private fun MainScaffold(
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
|
snackbarHost = {
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbarHostState,
|
||||||
|
modifier = Modifier.testTag(TestTags.SAVE_ERROR_SNACKBAR)
|
||||||
|
)
|
||||||
|
},
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(topBarTitle) },
|
title = { Text(topBarTitle) },
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ object TestTags {
|
|||||||
const val SAVE_STATUS_SAVING = "save_status_saving"
|
const val SAVE_STATUS_SAVING = "save_status_saving"
|
||||||
const val SAVE_STATUS_SAVED = "save_status_saved"
|
const val SAVE_STATUS_SAVED = "save_status_saved"
|
||||||
const val SAVE_STATUS_ERROR = "save_status_error"
|
const val SAVE_STATUS_ERROR = "save_status_error"
|
||||||
|
const val SAVE_ERROR_SNACKBAR = "save_error_snackbar"
|
||||||
|
|
||||||
// --- Top app bar ---
|
// --- Top app bar ---
|
||||||
const val TOP_APP_BAR = "top_app_bar"
|
const val TOP_APP_BAR = "top_app_bar"
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ fun SaveStatusIndicator(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ErrorOutline,
|
imageVector = Icons.Default.ErrorOutline,
|
||||||
contentDescription = "Save failed",
|
contentDescription = "Save failed: ${currentStatus.message}",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.error
|
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