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