fix: show error dialog on deserialization failure instead of silent fallback (Closes #79)

When loading a saved character fails, the app now shows an error dialog
instead of silently replacing user data with EXAMPLE_CHARACTER. The raw
JSON is backed up and displayed for manual recovery.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-bot
2026-04-04 20:04:16 +00:00
parent c94341df62
commit 29bbe8d95b
7 changed files with 364 additions and 0 deletions
@@ -35,6 +35,7 @@ import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.UiConstants
import org.shahondin1624.lib.components.charactermodel.CharacterSheetPage
import org.shahondin1624.lib.components.charactermodel.DiceRollHistoryDialog
import org.shahondin1624.lib.components.charactermodel.LoadErrorDialog
import org.shahondin1624.lib.components.charactermodel.SaveStatusIndicator
import org.shahondin1624.lib.components.creation.CharacterCreationWizard
import org.shahondin1624.lib.functions.DiceRollHistory
@@ -49,6 +50,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.LoadStatus
import org.shahondin1624.viewmodel.SaveStatus
@OptIn(ExperimentalMaterial3Api::class)
@@ -278,8 +280,19 @@ private fun MainScaffold(
val currentRoute = currentRoute(navController)
val character by characterViewModel.character.collectAsState()
val saveStatus by characterViewModel.saveStatus.collectAsState()
val loadStatus by characterViewModel.loadStatus.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
// Show load error dialog when deserialization fails
if (loadStatus is LoadStatus.Error) {
val error = loadStatus as LoadStatus.Error
LoadErrorDialog(
errorMessage = error.message,
rawJson = error.rawJson,
onStartFresh = { characterViewModel.acknowledgeLoadError() }
)
}
// Show snackbar when a save error occurs
LaunchedEffect(saveStatus) {
if (saveStatus is SaveStatus.Error) {
@@ -186,6 +186,13 @@ object TestTags {
const val HEALING_DISMISS_BUTTON = "healing_dismiss_button"
fun healButton(track: String): String = "heal_button_${track.lowercase()}"
// --- Load error dialog ---
const val LOAD_ERROR_DIALOG = "load_error_dialog"
const val LOAD_ERROR_MESSAGE = "load_error_message"
const val LOAD_ERROR_START_FRESH = "load_error_start_fresh"
const val LOAD_ERROR_SHOW_RAW = "load_error_show_raw"
const val LOAD_ERROR_RAW_JSON = "load_error_raw_json"
// --- Recovery state ---
const val RECOVERY_STATE_BADGE = "recovery_state_badge"
const val DEATH_WARNING = "death_warning"
@@ -0,0 +1,121 @@
package org.shahondin1624.lib.components.charactermodel
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.shahondin1624.lib.components.TestTags
/**
* Dialog shown when the saved character data could not be deserialized.
* Gives the user two options:
* 1. Start Fresh — accept EXAMPLE_CHARACTER as the current character
* 2. Show Raw Data — display the raw JSON for manual recovery / export
*
* @param errorMessage A description of what went wrong during deserialization.
* @param rawJson The raw JSON string that failed to deserialize, preserved for recovery.
* @param onStartFresh Called when the user chooses to start fresh with a default character.
* @param onDismiss Called when the dialog is dismissed (same as start fresh, since we must proceed).
*/
@Composable
fun LoadErrorDialog(
errorMessage: String,
rawJson: String,
onStartFresh: () -> Unit,
onDismiss: () -> Unit = onStartFresh
) {
var showRawJson by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.testTag(TestTags.LOAD_ERROR_DIALOG),
title = {
Text("Failed to Load Character")
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Your saved character data could not be loaded. " +
"This may be due to corrupted data or an incompatible format change.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Error: $errorMessage",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.testTag(TestTags.LOAD_ERROR_MESSAGE)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Your original data has been backed up and is shown below " +
"so you can copy it for manual recovery.",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
if (!showRawJson) {
OutlinedButton(
onClick = { showRawJson = true },
modifier = Modifier.testTag(TestTags.LOAD_ERROR_SHOW_RAW)
) {
Text("Show Raw Data")
}
} else {
Text(
text = "Raw JSON:",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.height(4.dp))
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
) {
Text(
text = rawJson.ifEmpty { "(empty)" },
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
.horizontalScroll(rememberScrollState())
.testTag(TestTags.LOAD_ERROR_RAW_JSON)
)
}
}
}
},
confirmButton = {
TextButton(
onClick = onStartFresh,
modifier = Modifier.testTag(TestTags.LOAD_ERROR_START_FRESH)
) {
Text("Start Fresh")
}
}
)
}
@@ -26,11 +26,16 @@ class CharacterViewModel : ViewModel() {
private val settings = Settings()
private val _loadStatus = MutableStateFlow<LoadStatus>(LoadStatus.Success)
private val _character = MutableStateFlow(loadCharacter())
/** Observable character state. */
val character: StateFlow<ShadowrunCharacter> = _character.asStateFlow()
/** Observable load status — UI should check for [LoadStatus.Error] to show recovery dialog. */
val loadStatus: StateFlow<LoadStatus> = _loadStatus.asStateFlow()
private val _saveStatus = MutableStateFlow<SaveStatus>(SaveStatus.Idle)
/** Observable save status for UI display. */
@@ -75,8 +80,37 @@ class CharacterViewModel : ViewModel() {
saveCharacter(_character.value)
}
/**
* Acknowledge a load error and clear the error state.
* Called after the user has made a choice in the error dialog (e.g., "Start Fresh").
*/
fun acknowledgeLoadError() {
_loadStatus.value = LoadStatus.Success
}
/**
* Attempt to deserialize a raw JSON string as a character.
* Used for manual recovery when the user edits or re-pastes corrupted data.
*
* @return the deserialized character, or null if it still fails
*/
fun tryRecoverFromJson(json: String): ShadowrunCharacter? {
return try {
val character = DataLoader.deserialize(json)
Napier.i(tag = TAG) { "Recovery successful: ${character.characterData.name}" }
_character.value = character
_loadStatus.value = LoadStatus.Success
character
} catch (e: Exception) {
Napier.e(tag = TAG, throwable = e) { "Recovery attempt failed" }
null
}
}
/**
* Load character from local storage, falling back to EXAMPLE_CHARACTER.
* On deserialization failure, the raw JSON is preserved in a backup key
* and the error is reported via [_loadStatus].
*/
private fun loadCharacter(): ShadowrunCharacter {
return try {
@@ -84,15 +118,27 @@ class CharacterViewModel : ViewModel() {
if (json != null) {
val character = DataLoader.deserialize(json)
Napier.i(tag = TAG) { "Character loaded: ${character.characterData.name}" }
_loadStatus.value = LoadStatus.Success
character
} else {
Napier.i(tag = TAG) { "No saved character found, using example character" }
_loadStatus.value = LoadStatus.NoSavedData
EXAMPLE_CHARACTER
}
} catch (e: Exception) {
val rawJson = settings.getStringOrNull(STORAGE_KEY) ?: ""
Napier.e(tag = TAG, throwable = e) {
"Failed to deserialize saved character, falling back to example character"
}
// Preserve the raw data so it is not lost
if (rawJson.isNotEmpty()) {
settings.putString(BACKUP_KEY, rawJson)
Napier.i(tag = TAG) { "Raw character data backed up under key '$BACKUP_KEY'" }
}
_loadStatus.value = LoadStatus.Error(
message = e.message ?: "Unknown deserialization error",
rawJson = rawJson
)
EXAMPLE_CHARACTER
}
}
@@ -143,6 +189,8 @@ class CharacterViewModel : ViewModel() {
companion object {
private const val TAG = "CharacterViewModel"
private const val STORAGE_KEY = "shadowrun_character"
/** Backup key for preserving raw JSON when deserialization fails. */
private const val BACKUP_KEY = "shadowrun_character_backup"
/** How long to display the "Saved" status before reverting to Idle. */
private const val SAVED_DISPLAY_DURATION_MS = 2000L
}
@@ -0,0 +1,20 @@
package org.shahondin1624.viewmodel
/**
* Represents the result of loading a character from local storage.
* Exposed by [CharacterViewModel] so the UI can display load errors
* and offer recovery options.
*/
sealed class LoadStatus {
/** Character loaded successfully from storage. */
data object Success : LoadStatus()
/** No saved data found — using default character. */
data object NoSavedData : LoadStatus()
/** Deserialization failed. Raw JSON is preserved for recovery. */
data class Error(
val message: String,
val rawJson: String
) : LoadStatus()
}
@@ -8,9 +8,11 @@ import org.shahondin1624.model.characterdata.CharacterData
import org.shahondin1624.model.characterdata.Metatype
import org.shahondin1624.model.charactermodel.ShadowrunCharacter
import org.shahondin1624.model.createNewCharacter
import org.shahondin1624.viewmodel.LoadStatus
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -205,6 +207,49 @@ class CharacterViewModelTest {
assertEquals(50, restored.characterData.totalKarma)
}
// ---- LoadStatus model ----
@Test
fun loadStatusErrorPreservesRawJson() {
val rawJson = """{"characterData": {"name": "Corrupted"}}"""
val status = LoadStatus.Error(
message = "Missing required field",
rawJson = rawJson
)
assertIs<LoadStatus.Error>(status)
assertEquals("Missing required field", status.message)
assertEquals(rawJson, status.rawJson)
}
@Test
fun loadStatusSuccessAndNoSavedDataAreSingletons() {
val s1 = LoadStatus.Success
val s2 = LoadStatus.Success
assertEquals(s1, s2)
val n1 = LoadStatus.NoSavedData
val n2 = LoadStatus.NoSavedData
assertEquals(n1, n2)
}
@Test
fun deserializeMalformedJsonPreservesDataForRecovery() {
// Simulate what the ViewModel does: if deserialization fails,
// the raw JSON should be capturable for backup
val corruptedJson = """{"characterData": "not_an_object"}"""
var capturedRawJson: String? = null
try {
DataLoader.deserialize(corruptedJson)
} catch (_: Exception) {
// In the ViewModel, this is where we'd preserve the raw JSON
capturedRawJson = corruptedJson
}
assertNotNull(capturedRawJson, "Raw JSON should be preserved on failure")
assertEquals(corruptedJson, capturedRawJson)
}
@Test
fun saveAndLoadNotesAndLifestyles() {
val modified = EXAMPLE_CHARACTER.copy(
@@ -0,0 +1,110 @@
package org.shahondin1624
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.runComposeUiTest
import org.shahondin1624.lib.components.TestTags
import org.shahondin1624.lib.components.charactermodel.LoadErrorDialog
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Tests for the LoadErrorDialog composable.
*/
@OptIn(ExperimentalTestApi::class)
class LoadErrorDialogTest {
@Test
fun dialogDisplaysErrorMessage() = runComposeUiTest {
setContent {
LoadErrorDialog(
errorMessage = "Unexpected JSON token",
rawJson = """{"broken": true}""",
onStartFresh = {}
)
}
onNodeWithTag(TestTags.LOAD_ERROR_DIALOG).assertIsDisplayed()
onNodeWithTag(TestTags.LOAD_ERROR_MESSAGE).assertIsDisplayed()
onNodeWithTag(TestTags.LOAD_ERROR_MESSAGE)
.assertTextContains("Unexpected JSON token", substring = true)
}
@Test
fun startFreshButtonCallsCallback() = runComposeUiTest {
var startFreshCalled = false
setContent {
LoadErrorDialog(
errorMessage = "Test error",
rawJson = """{"test": 1}""",
onStartFresh = { startFreshCalled = true }
)
}
onNodeWithTag(TestTags.LOAD_ERROR_START_FRESH).assertIsDisplayed()
onNodeWithTag(TestTags.LOAD_ERROR_START_FRESH).performClick()
assertTrue(startFreshCalled, "Start Fresh callback should have been called")
}
@Test
fun showRawDataButtonRevealsJson() = runComposeUiTest {
val rawJson = """{"characterData": {"name": "Lost Runner"}}"""
setContent {
LoadErrorDialog(
errorMessage = "Parse error",
rawJson = rawJson,
onStartFresh = {}
)
}
// Raw JSON should not be visible initially
onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON).assertDoesNotExist()
// Click "Show Raw Data"
onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).assertIsDisplayed()
onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).performClick()
// Now raw JSON should be visible
onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON).assertIsDisplayed()
onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON)
.assertTextContains("Lost Runner", substring = true)
}
@Test
fun showRawDataButtonHidesAfterClick() = runComposeUiTest {
setContent {
LoadErrorDialog(
errorMessage = "Error",
rawJson = "{}",
onStartFresh = {}
)
}
onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).assertIsDisplayed()
onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).performClick()
// After clicking, the button should be replaced by the raw JSON display
onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).assertDoesNotExist()
onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON).assertIsDisplayed()
}
@Test
fun emptyRawJsonShowsPlaceholder() = runComposeUiTest {
setContent {
LoadErrorDialog(
errorMessage = "Error",
rawJson = "",
onStartFresh = {}
)
}
onNodeWithTag(TestTags.LOAD_ERROR_SHOW_RAW).performClick()
onNodeWithTag(TestTags.LOAD_ERROR_RAW_JSON)
.assertTextContains("(empty)", substring = true)
}
}