Coverage Summary for Class: DebugConfigScreenKt (com.georgeci.moneysurfer.feature.settings.debug)

Class Method, % Branch, % Line, % Instruction, %
DebugConfigScreenKt 72% (18/25) 47.4% (36/76) 84.5% (142/168) 74.6% (1626/2179)
DebugConfigScreenKt$DebugConfigScreen$1$1 0% (0/1) 0% (0/2) 0% (0/3) 0% (0/33)
DebugConfigScreenKt$DebugConfigScreen$2$1 0% (0/1) 0% (0/2) 0% (0/3) 0% (0/33)
Total 66.7% (18/27) 45% (36/80) 81.6% (142/174) 72.4% (1626/2245)


 package com.georgeci.moneysurfer.feature.settings.debug
 
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.material3.SnackbarDuration
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 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.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.domain.config.ConfigDebugRow
 import com.georgeci.moneysurfer.domain.config.ConfigDebugRowKind
 import com.georgeci.moneysurfer.feature.settings.components.SettingsSubScreenScaffold
 import com.georgeci.moneysurfer.uikit.components.base.SurferFilterChipRow
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsChevron
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsGroup
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsRow
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsSwitch
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import com.georgeci.moneysurfer.utils.HandleSideEffect
 import moneysurfer.feature.settings.generated.resources.Res
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_degraded_format
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_footnote
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_host_owned
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_invalid_format
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_layer_absent
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_layer_undecodable_format
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_reset_all
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_section_keys
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_title
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_unavailable
 import moneysurfer.feature.settings.generated.resources.settings_debug_config_winner_format
 import moneysurfer.feature.settings.generated.resources.settings_debug_log_supporting
 import moneysurfer.feature.settings.generated.resources.settings_debug_log_title
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_done_format
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_failed
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_footnote
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_no_workspace
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_running
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_section
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_supporting
 import moneysurfer.feature.settings.generated.resources.settings_debug_prefill_title
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 
 /** Stable selectors for the QA configuration panel — see docs/testing/testing-strategy.md. */
 object DebugConfigTestTags {
     const val Root = "debugConfig:root"
     const val LogsRow = "debugConfig:logs"
     const val ResetAllRow = "debugConfig:resetAll"
     const val DegradedBanner = "debugConfig:degradedBanner"
     const val Unavailable = "debugConfig:unavailable"
 
     const val PrefillRow = "debugConfig:prefill"
 
     fun row(name: String): String = "debugConfig:row:$name"
 
     /** The switch on a `Bool` row. */
     fun toggle(name: String): String = "debugConfig:toggle:$name"
 
     /**
      * One chip of a `Choice` row. Tagged per option because the chip's only other handle is its
      * label, which here is the raw encoded value — see the note on `SurferFilterChipRow`.
      */
     fun choice(name: String, choice: String): String = "debugConfig:choice:$name:$choice"
 }
 
 @Composable
 fun DebugConfigScreen(
     onNavigateBack: () -> Unit,
     onNavigateToLogs: () -> Unit,
     viewModel: DebugConfigViewModel = koinViewModel(),
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
     val snackbarHostState = remember { SnackbarHostState() }
     var pendingInvalid by remember { mutableStateOf<DebugConfigEffect.NotifyInvalidValue?>(null) }
     var pendingPrefill by remember { mutableStateOf<PrefillOutcome?>(null) }
 
     // Resolved during composition so the positional placeholders go through Compose's own format
     // path and survive translator reordering; the effect below only feeds the snackbar.
     val invalidText: String? = pendingInvalid?.let {
         stringResource(Res.string.settings_debug_config_invalid_format, it.raw, it.name)
     }
     LaunchedEffect(invalidText) {
         val text = invalidText ?: return@LaunchedEffect
         snackbarHostState.showSnackbar(message = text, duration = SnackbarDuration.Short)
         pendingInvalid = null
     }
 
     val prefillText: String? = pendingPrefill?.let { prefillMessage(it) }
     LaunchedEffect(prefillText) {
         val text = prefillText ?: return@LaunchedEffect
         snackbarHostState.showSnackbar(message = text, duration = SnackbarDuration.Long)
         pendingPrefill = null
     }
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             DebugConfigEffect.NavigateBack -> onNavigateBack()
             DebugConfigEffect.NavigateToLogs -> onNavigateToLogs()
             is DebugConfigEffect.NotifyInvalidValue -> pendingInvalid = effect
             is DebugConfigEffect.NotifyPrefillResult -> pendingPrefill = effect.outcome
         }
     }
 
     DebugConfigContent(
         state = state,
         snackbarHostState = snackbarHostState,
         onEvent = viewModel::onEvent,
     )
 }
 
 /** Stateless content, public so `:composeApp`'s desktop UI tests can mount it — as with the other screens. */
 @Composable
 fun DebugConfigContent(
     state: DebugConfigState,
     snackbarHostState: SnackbarHostState,
     onEvent: (DebugConfigEvent) -> Unit,
 ) {
     SettingsSubScreenScaffold(
         title = stringResource(Res.string.settings_debug_config_title),
         snackbarHostState = snackbarHostState,
         onBack = { onEvent(DebugConfigEvent.OnBackClick) },
         showProgressOverlay = false,
     ) { padding ->
         if (!state.isAvailable) {
             // Release builds bind `DebugConfigSource.Empty`, which accepts writes and drops them.
             // A restored back stack can still land here, and editable rows that silently no-op are
             // worse than none.
             Text(
                 text = stringResource(Res.string.settings_debug_config_unavailable),
                 style = AppTheme.typography.bodyMedium,
                 color = AppTheme.materialColors.onSurfaceVariant,
                 modifier = Modifier
                     .padding(horizontal = 16.dp, vertical = AppTheme.spacing.default)
                     .testTag(DebugConfigTestTags.Unavailable),
             )
             return@SettingsSubScreenScaffold
         }
 
         if (state.degradedLayers.isNotEmpty()) {
             // A layer whose store failed to load resolves as absent, so every row below it shows a
             // fallback. Saying so beats letting the panel present that fallback as the truth.
             Text(
                 text = stringResource(
                     Res.string.settings_debug_config_degraded_format,
                     state.degradedLayers.joinToString(),
                 ),
                 style = AppTheme.typography.bodyMedium,
                 color = AppTheme.materialColors.error,
                 modifier = Modifier
                     .padding(horizontal = 16.dp, vertical = AppTheme.spacing.small)
                     .testTag(DebugConfigTestTags.DegradedBanner),
             )
         }
 
         SurferSettingsGroup(
             modifier = Modifier.testTag(DebugConfigTestTags.Root),
             title = stringResource(Res.string.settings_debug_config_section_keys),
             footnote = stringResource(Res.string.settings_debug_config_footnote),
         ) {
             state.rows.forEach { row -> ConfigKeyRow(row = row, onEvent = onEvent) }
         }
 
         SurferSettingsGroup(
             title = stringResource(Res.string.settings_debug_prefill_section),
             footnote = stringResource(Res.string.settings_debug_prefill_footnote),
         ) {
             SurferSettingsRow(
                 icon = SurferIcons.Sparkle,
                 title = stringResource(Res.string.settings_debug_prefill_title),
                 supportingText = if (state.inFlight) {
                     stringResource(Res.string.settings_debug_prefill_running)
                 } else {
                     stringResource(Res.string.settings_debug_prefill_supporting)
                 },
                 // A run writes hundreds of rows. Dropping the click handler while it is going is
                 // what makes the row look busy — there is no disabled state on a settings row.
                 onClick = if (state.inFlight) null else ({ onEvent(DebugConfigEvent.OnPrefillClick) }),
                 modifier = Modifier.testTag(DebugConfigTestTags.PrefillRow),
             )
         }
 
         SurferSettingsGroup {
             // The other half of the debug panel: what the app logged, for hosts where reading
             // logcat or the desktop console is not an option.
             SurferSettingsRow(
                 icon = SurferIcons.ListView,
                 title = stringResource(Res.string.settings_debug_log_title),
                 supportingText = stringResource(Res.string.settings_debug_log_supporting),
                 onClick = { onEvent(DebugConfigEvent.OnLogsClick) },
                 trailing = { SurferSettingsChevron() },
                 modifier = Modifier.testTag(DebugConfigTestTags.LogsRow),
             )
         }
 
         SurferSettingsGroup {
             SurferSettingsRow(
                 icon = SurferIcons.Delete,
                 title = stringResource(Res.string.settings_debug_config_reset_all),
                 danger = true,
                 onClick = { onEvent(DebugConfigEvent.OnResetAllClick) },
                 modifier = Modifier.testTag(DebugConfigTestTags.ResetAllRow),
             )
         }
 
         Spacer(Modifier.height(padding.calculateBottomPadding() + 24.dp))
     }
 }
 
 /**
  * The snackbar line for a finished prefill run.
  *
  * Resolved during composition, like the invalid-value message above, so the indexed placeholders go
  * through Compose's own format path rather than being assembled inside the effect. Public for the
  * same reason [DebugConfigContent] is: the counts are positional, and only a rendered assertion
  * catches them being read off the wrong report field.
  */
 @Composable
 fun prefillMessage(outcome: PrefillOutcome): String = when (outcome) {
     is PrefillOutcome.Done -> stringResource(
         Res.string.settings_debug_prefill_done_format,
         outcome.report.transactions,
         outcome.report.accounts,
         outcome.report.budgets,
         outcome.report.goals,
     )
     PrefillOutcome.NoWorkspace -> stringResource(Res.string.settings_debug_prefill_no_workspace)
     PrefillOutcome.Failed -> stringResource(Res.string.settings_debug_prefill_failed)
 }
 
 /**
  * One key. The control comes from the row's value kind — a boolean gets a switch, a closed set gets
  * chips — so nobody has to type `PRESET:Plum` by hand. Tapping an overridden row clears its
  * override.
  */
 @Composable
 private fun ConfigKeyRow(
     row: ConfigDebugRow,
     onEvent: (DebugConfigEvent) -> Unit,
 ) {
     val clearOverride: (() -> Unit)? =
         if (row.overridden) ({ onEvent(DebugConfigEvent.OnClearOverride(row.name)) }) else null
 
     when (val kind = row.kind) {
         ConfigDebugRowKind.Bool -> SurferSettingsRow(
             icon = SurferIcons.Code,
             title = row.name,
             supporting = { ResolutionSummary(row) },
             trailing = {
                 SurferSettingsSwitch(
                     checked = row.effectiveValue.toBoolean(),
                     onCheckedChange = { checked ->
                         onEvent(DebugConfigEvent.OnOverride(row.name, checked.toString()))
                     },
                     modifier = Modifier.testTag(DebugConfigTestTags.toggle(row.name)),
                 )
             },
             onClick = clearOverride,
             multiline = true,
             modifier = Modifier.testTag(DebugConfigTestTags.row(row.name)),
         )
 
         is ConfigDebugRowKind.Choice -> SurferSettingsRow(
             icon = SurferIcons.Code,
             title = row.name,
             supporting = {
                 Column {
                     ResolutionSummary(row)
                     Spacer(Modifier.height(AppTheme.spacing.small))
                     // A closed set can be longer than the screen (the palette's presets), so the
                     // chip row scrolls rather than clipping the tail options out of reach.
                     Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
                         SurferFilterChipRow(
                             options = kind.choices,
                             selected = row.effectiveValue,
                             label = { it },
                             onSelect = { choice -> onEvent(DebugConfigEvent.OnOverride(row.name, choice)) },
                             optionTestTag = { choice -> DebugConfigTestTags.choice(row.name, choice) },
                         )
                     }
                 }
             },
             onClick = clearOverride,
             multiline = true,
             modifier = Modifier.testTag(DebugConfigTestTags.row(row.name)),
         )
 
         // Free-form values (the encoded dashboard layout) are inspect-only: a text field here is a
         // routine way to store something no codec can read back.
         ConfigDebugRowKind.FreeText -> SurferSettingsRow(
             icon = SurferIcons.Code,
             title = row.name,
             supporting = { ResolutionSummary(row) },
             onClick = clearOverride,
             multiline = true,
             modifier = Modifier.testTag(DebugConfigTestTags.row(row.name)),
         )
     }
 }
 
 /**
  * Effective value, the layer that won, and every layer's cell — absent and undecodable read
  * differently on purpose, because "stored but rejected by the codec" is the failure the per-layer
  * view exists to expose.
  */
 @Composable
 private fun ResolutionSummary(row: ConfigDebugRow) {
     Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
         Text(
             text = row.effectiveValue + " · " +
                 stringResource(Res.string.settings_debug_config_winner_format, row.winner),
             style = AppTheme.typography.bodyMedium,
             color = AppTheme.materialColors.onSurfaceVariant,
         )
         if (row.hostOwned) {
             // Overriding one of these makes the app believe it is a different build while its DI
             // graph stays as compiled — worth saying out loud next to an ordinary feature flag.
             Text(
                 text = stringResource(Res.string.settings_debug_config_host_owned),
                 style = AppTheme.typography.labelSmall,
                 color = AppTheme.materialColors.error,
             )
         }
         row.layers.forEach { cell ->
             val rendered = when {
                 cell.undecodable ->
                     stringResource(Res.string.settings_debug_config_layer_undecodable_format, cell.value.orEmpty())
                 cell.value == null -> stringResource(Res.string.settings_debug_config_layer_absent)
                 else -> cell.value
             }
             Text(
                 text = "${cell.layer}: $rendered",
                 style = AppTheme.typography.labelSmall,
                 color = AppTheme.materialColors.onSurfaceVariant,
                 modifier = Modifier.padding(start = 2.dp),
             )
         }
     }
 }