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

Class Class, % Method, % Branch, % Line, % Instruction, %
DebugLogScreenKt 100% (1/1) 71.4% (5/7) 42.9% (12/28) 90.3% (65/72) 84.7% (691/816)


 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.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.material3.SnackbarHostState
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
 import androidx.compose.runtime.remember
 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 com.georgeci.moneysurfer.domain.logging.DebugLogBuffer
 import com.georgeci.moneysurfer.domain.logging.DebugLogEntry
 import com.georgeci.moneysurfer.feature.settings.components.SettingsSubScreenScaffold
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsGroup
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsRow
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import moneysurfer.feature.settings.generated.resources.Res
 import moneysurfer.feature.settings.generated.resources.settings_debug_log_clear
 import moneysurfer.feature.settings.generated.resources.settings_debug_log_empty
 import moneysurfer.feature.settings.generated.resources.settings_debug_log_footnote
 import moneysurfer.feature.settings.generated.resources.settings_debug_log_title
 import org.jetbrains.compose.resources.stringResource
 
 /** Stable selectors for the QA log panel — see docs/testing/testing-strategy.md. */
 object DebugLogTestTags {
     const val Root = "debugLog:root"
     const val ClearRow = "debugLog:clear"
     const val Empty = "debugLog:empty"
 }
 
 /**
  * The last Warn/Error lines Kermit produced in this process, straight off [DebugLogBuffer].
  *
  * No ViewModel: the buffer is already a process-wide `StateFlow` with nothing to fetch, map or
  * persist, and clearing it is a direct call. Threading it through Koin would buy a constructor
  * parameter and nothing else.
  */
 @Composable
 fun DebugLogScreen(onNavigateBack: () -> Unit) {
     val entries by DebugLogBuffer.entries.collectAsState()
 
     DebugLogContent(
         entries = entries,
         onBack = onNavigateBack,
         onClear = DebugLogBuffer::clear,
     )
 }
 
 /** Stateless content, public so `:composeApp`'s desktop UI tests can mount it — as with the other screens. */
 @Composable
 fun DebugLogContent(
     entries: List<DebugLogEntry>,
     onBack: () -> Unit,
     onClear: () -> Unit,
 ) {
     val snackbarHostState = remember { SnackbarHostState() }
 
     SettingsSubScreenScaffold(
         title = stringResource(Res.string.settings_debug_log_title),
         snackbarHostState = snackbarHostState,
         onBack = onBack,
         showProgressOverlay = false,
     ) { padding ->
         if (entries.isEmpty()) {
             Text(
                 text = stringResource(Res.string.settings_debug_log_empty),
                 style = AppTheme.typography.bodyMedium,
                 color = AppTheme.materialColors.onSurfaceVariant,
                 modifier = Modifier
                     .padding(horizontal = 16.dp, vertical = AppTheme.spacing.default)
                     .testTag(DebugLogTestTags.Empty),
             )
             return@SettingsSubScreenScaffold
         }
 
         // Above the list, not under it: a full buffer is 100 entries deep with stack traces, and a
         // Clear button at the bottom of that is a button nobody reaches.
         SurferSettingsGroup {
             SurferSettingsRow(
                 icon = SurferIcons.Delete,
                 title = stringResource(Res.string.settings_debug_log_clear),
                 danger = true,
                 onClick = onClear,
                 modifier = Modifier.testTag(DebugLogTestTags.ClearRow),
             )
         }
 
         SurferSettingsGroup(
             modifier = Modifier.testTag(DebugLogTestTags.Root),
             footnote = stringResource(Res.string.settings_debug_log_footnote),
         ) {
             // A plain column rather than a LazyColumn: the scaffold already scrolls vertically, and
             // nesting a lazy list in it measures with infinite height. The buffer is capped at 100
             // entries, so composing them all is affordable — as long as they are keyed. New lines
             // are prepended, so an unkeyed loop shifts every row by one position and recomposes the
             // whole list on each of them, which at 100 stack traces is enough to stall the screen
             // during exactly the error burst it exists to show.
             Column(verticalArrangement = Arrangement.spacedBy(AppTheme.spacing.default)) {
                 entries.forEach { entry ->
                     key(entry.id) { DebugLogEntryRow(entry) }
                 }
             }
         }
 
         Spacer(Modifier.height(padding.calculateBottomPadding() + 24.dp))
     }
 }
 
 /**
  * Severity and tag on one line, then the message, then the stack trace when the line carried a
  * throwable — which is the part worth reading on a device with no console attached, so it scrolls
  * sideways rather than wrapping into unreadable ribbons.
  */
 @Composable
 private fun DebugLogEntryRow(entry: DebugLogEntry) {
     Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
         Row(
             modifier = Modifier.fillMaxWidth(),
             horizontalArrangement = Arrangement.SpaceBetween,
         ) {
             Text(
                 text = entry.severity.name,
                 style = AppTheme.typography.labelMedium,
                 color = AppTheme.materialColors.error,
             )
             Text(
                 text = entry.tag.ifBlank { "NoTag" },
                 style = AppTheme.typography.labelMedium,
                 color = AppTheme.materialColors.onSurfaceVariant,
             )
         }
 
         Text(
             text = entry.message,
             style = AppTheme.typography.bodySmall,
             color = AppTheme.materialColors.onSurface,
         )
 
         entry.throwable?.let { throwable ->
             // Remembered: serialising a deep coroutine stack is not something to redo on every
             // recomposition of a screen that recomposes whenever anything logs.
             val stackTrace = remember(throwable) { throwable.stackTraceToString() }
 
             Row(modifier = Modifier.horizontalScroll(rememberScrollState())) {
                 Text(
                     text = stackTrace,
                     style = AppTheme.typography.bodySmall,
                     color = AppTheme.materialColors.onSurfaceVariant,
                     fontFamily = FontFamily.Monospace,
                 )
             }
         }
     }
 }