diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt index f8607f0..d540bcc 100644 --- a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt @@ -1,13 +1,21 @@ package com.interlinedlist.android.feature.profile.ui +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.profile.domain.UserSettings @@ -15,6 +23,9 @@ import com.interlinedlist.android.feature.profile.domain.ViewingPreference import com.interlinedlist.android.feature.profile.ui.settings.SettingsScreen import com.interlinedlist.android.feature.profile.ui.settings.SettingsTestTags import com.interlinedlist.android.feature.profile.ui.settings.SettingsUiState +import com.interlinedlist.android.feature.profile.ui.settings.settingsDecrementTag +import com.interlinedlist.android.feature.profile.ui.settings.settingsIncrementTag +import com.interlinedlist.android.feature.profile.ui.settings.settingsNumberErrorTag import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -29,6 +40,10 @@ class SettingsScreenTest { state: SettingsUiState, onSelectViewingPreference: (ViewingPreference) -> Unit = {}, onToggleShowPreviews: (Boolean) -> Unit = {}, + onSetMessagesPerPage: (Int) -> Unit = {}, + onSetMaxMessageLength: (Int) -> Unit = {}, + onToggleDefaultPubliclyVisible: (Boolean) -> Unit = {}, + onToggleShowAdvancedPostSettings: (Boolean) -> Unit = {}, onRetry: () -> Unit = {}, onDismissError: () -> Unit = {}, ) { @@ -40,6 +55,10 @@ class SettingsScreenTest { onRetry = onRetry, onSelectViewingPreference = onSelectViewingPreference, onToggleShowPreviews = onToggleShowPreviews, + onSetMessagesPerPage = onSetMessagesPerPage, + onSetMaxMessageLength = onSetMaxMessageLength, + onToggleDefaultPubliclyVisible = onToggleDefaultPubliclyVisible, + onToggleShowAdvancedPostSettings = onToggleShowAdvancedPostSettings, onDismissError = onDismissError, ) } @@ -127,4 +146,169 @@ class SettingsScreenTest { composeRule.onNodeWithTag(SettingsTestTags.PROGRESS).assertIsDisplayed() } + + // --- Message preferences (issue #32) ------------------------------------- + + @Test + fun messageSettings_showsTheStoredBooleanPreferences() { + setContent( + SettingsUiState( + settings = UserSettings( + defaultPubliclyVisible = false, + showAdvancedPostSettings = true, + ), + ), + ) + + composeRule.onNodeWithTag(SettingsTestTags.MESSAGE_SETTINGS).assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.DEFAULT_PUBLICLY_VISIBLE).assertIsOff() + composeRule.onNodeWithTag(SettingsTestTags.SHOW_ADVANCED_POST_SETTINGS).assertIsOn() + } + + @Test + fun togglingDefaultVisibility_reportsTheNewValue() { + var toggled: Boolean? = null + setContent( + state = SettingsUiState(settings = UserSettings(defaultPubliclyVisible = true)), + onToggleDefaultPubliclyVisible = { toggled = it }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.DEFAULT_PUBLICLY_VISIBLE).performClick() + + assert(toggled == false) + } + + @Test + fun togglingAdvancedPostSettings_reportsTheNewValue() { + var toggled: Boolean? = null + setContent( + state = SettingsUiState(settings = UserSettings(showAdvancedPostSettings = false)), + onToggleShowAdvancedPostSettings = { toggled = it }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.SHOW_ADVANCED_POST_SETTINGS).performClick() + + assert(toggled == true) + } + + @Test + fun characterLimit_showsTheStoredValueUnderProfile() { + setContent(SettingsUiState(settings = UserSettings(maxMessageLength = 666))) + + composeRule.onNodeWithTag(SettingsTestTags.PROFILE).assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.MAX_MESSAGE_LENGTH).assertTextEquals("666") + } + + @Test + fun characterLimit_fallsBackToTheServerDefaultWhenUnset() { + setContent(SettingsUiState(settings = UserSettings(maxMessageLength = null))) + + composeRule.onNodeWithTag(SettingsTestTags.MAX_MESSAGE_LENGTH).assertTextEquals("666") + } + + @Test + fun steppingTheCharacterLimit_reportsTheSteppedValue() { + var saved: Int? = null + setContent( + state = SettingsUiState(settings = UserSettings(maxMessageLength = 666)), + onSetMaxMessageLength = { saved = it }, + ) + + composeRule.onNodeWithTag(settingsIncrementTag(SettingsTestTags.MAX_MESSAGE_LENGTH)) + .performClick() + + assert(saved == 676) { "expected a 10-character step, got $saved" } + } + + @Test + fun messagesPerPage_stepsWithinTheDocumentedRange() { + var saved: Int? = null + setContent( + state = SettingsUiState(settings = UserSettings(messagesPerPage = 20)), + onSetMessagesPerPage = { saved = it }, + ) + + composeRule.onNodeWithTag(settingsDecrementTag(SettingsTestTags.MESSAGES_PER_PAGE)) + .performClick() + + assert(saved == 19) + } + + @Test + fun messagesPerPage_cannotStepBelowTheMinimum() { + var saved: Int? = null + setContent( + state = SettingsUiState(settings = UserSettings(messagesPerPage = 10)), + onSetMessagesPerPage = { saved = it }, + ) + + composeRule.onNodeWithTag(settingsDecrementTag(SettingsTestTags.MESSAGES_PER_PAGE)) + .assertIsNotEnabled() + + assert(saved == null) + } + + @Test + fun messagesPerPage_outOfRangeEntryIsRejectedWithoutReportingAValue() { + var saved: Int? = null + setContent( + state = SettingsUiState(settings = UserSettings(messagesPerPage = 20)), + onSetMessagesPerPage = { saved = it }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.MESSAGES_PER_PAGE).performTextClearance() + composeRule.onNodeWithTag(SettingsTestTags.MESSAGES_PER_PAGE).performTextInput("99") + composeRule.onNodeWithTag(SettingsTestTags.MESSAGES_PER_PAGE).performImeAction() + + composeRule.onNodeWithTag(settingsNumberErrorTag(SettingsTestTags.MESSAGES_PER_PAGE)) + .assertIsDisplayed() + assert(saved == null) { "an out-of-range entry must not be saved, got $saved" } + } + + @Test + fun messagesPerPage_inRangeEntryIsReported() { + var saved: Int? = null + setContent( + state = SettingsUiState(settings = UserSettings(messagesPerPage = 20)), + onSetMessagesPerPage = { saved = it }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.MESSAGES_PER_PAGE).performTextClearance() + composeRule.onNodeWithTag(SettingsTestTags.MESSAGES_PER_PAGE).performTextInput("25") + composeRule.onNodeWithTag(SettingsTestTags.MESSAGES_PER_PAGE).performImeAction() + + assert(saved == 25) + } + + @Test + fun characterLimit_rolledBackSaveRestoresTheFieldToTheStoredValue() { + // The screen is recomposed with the previous value after a rejected save; + // the field must follow rather than keep showing a value that never saved. + var settings by mutableStateOf(UserSettings(maxMessageLength = 666)) + composeRule.setContent { + InterlinedListTheme { + SettingsScreen( + state = SettingsUiState(settings = settings, errorMessage = null), + onBack = {}, + onRetry = {}, + onSelectViewingPreference = {}, + onToggleShowPreviews = {}, + onSetMessagesPerPage = {}, + // Optimistic apply, then the server refuses and it rolls back. + onSetMaxMessageLength = { settings = settings.copy(maxMessageLength = it) }, + onToggleDefaultPubliclyVisible = {}, + onToggleShowAdvancedPostSettings = {}, + onDismissError = {}, + ) + } + } + + composeRule.onNodeWithTag(settingsIncrementTag(SettingsTestTags.MAX_MESSAGE_LENGTH)) + .performClick() + composeRule.onNodeWithTag(SettingsTestTags.MAX_MESSAGE_LENGTH).assertTextEquals("676") + + settings = settings.copy(maxMessageLength = 666) + + composeRule.onNodeWithTag(SettingsTestTags.MAX_MESSAGE_LENGTH).assertTextEquals("666") + } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt new file mode 100644 index 0000000..6a3be57 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt @@ -0,0 +1,64 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * Bounds and server defaults for the numeric message preferences, plus the fallbacks + * the Settings UI shows when `GET /api/user` omits a preference. + * + * The ranges are enforced client-side so a stepper or a typed value can never PATCH + * nonsense (0, a negative page size, a nine-digit character limit). The server has + * the final say — it may refuse a value we consider valid — so callers must still + * handle a rejected save by restoring the previous value. + */ +object SettingsBounds { + + /** + * How many messages the feed loads at a time. The help centre states the + * supported range outright: "Messages per page: How many messages to load at + * once (10 to 30)". + */ + val MESSAGES_PER_PAGE: IntRange = 10..30 + + /** + * The account's message character limit. Neither the help centre nor the OpenAPI + * spec publishes a range for this one (only the 666 default), so the bounds are + * deliberately wide: low enough to allow a deliberately terse limit, high enough + * to clear any plausible server cap, and tight enough to reject nonsense. A value + * in this range that the server still refuses surfaces as a failed save. + */ + val MAX_MESSAGE_LENGTH: IntRange = 10..10_000 + + /** Stepper increment for [MAX_MESSAGE_LENGTH]; the field is there for big jumps. */ + const val MAX_MESSAGE_LENGTH_STEP: Int = 10 + + /** The character limit a fresh account gets (help centre: "default 666"). */ + const val DEFAULT_MAX_MESSAGE_LENGTH: Int = 666 + + /** The page size a fresh account gets (observed live on a real account). */ + const val DEFAULT_MESSAGES_PER_PAGE: Int = 20 + + /** New messages start public unless the account says otherwise. */ + const val DEFAULT_PUBLICLY_VISIBLE: Boolean = true + + /** The composer's gear options stay hidden unless the account opts in. */ + const val DEFAULT_SHOW_ADVANCED_POST_SETTINGS: Boolean = false +} + +/** + * The character limit to show, falling back to the server default when the account + * has no stored value. The preference fields are nullable because public profiles + * omit them; the Settings UI still has to render a concrete number. + */ +val UserSettings.maxMessageLengthOrDefault: Int + get() = maxMessageLength ?: SettingsBounds.DEFAULT_MAX_MESSAGE_LENGTH + +/** The feed page size to show, falling back to the server default. */ +val UserSettings.messagesPerPageOrDefault: Int + get() = messagesPerPage ?: SettingsBounds.DEFAULT_MESSAGES_PER_PAGE + +/** The composer's starting visibility, falling back to the server default. */ +val UserSettings.defaultPubliclyVisibleOrDefault: Boolean + get() = defaultPubliclyVisible ?: SettingsBounds.DEFAULT_PUBLICLY_VISIBLE + +/** Whether the composer's advanced options show, falling back to the server default. */ +val UserSettings.showAdvancedPostSettingsOrDefault: Boolean + get() = showAdvancedPostSettings ?: SettingsBounds.DEFAULT_SHOW_ADVANCED_POST_SETTINGS diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsComponents.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsComponents.kt index e3f76e2..0d2f742 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsComponents.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsComponents.kt @@ -9,15 +9,31 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp /** @@ -123,3 +139,139 @@ fun SettingsSwitchRow( ) } } + +/** Test tag of the decrement button of the [SettingsNumberRow] tagged [tag]. */ +fun settingsDecrementTag(tag: String): String = "${tag}Decrement" + +/** Test tag of the increment button of the [SettingsNumberRow] tagged [tag]. */ +fun settingsIncrementTag(tag: String): String = "${tag}Increment" + +/** Test tag of the validation message of the [SettingsNumberRow] tagged [tag]. */ +fun settingsNumberErrorTag(tag: String): String = "${tag}Error" + +/** + * Parses [text] as a whole number inside [range], or null when it is neither. + * + * This is the guard that keeps a typed value from reaching the API: the row only + * reports a change when this returns non-null, so an empty, malformed or + * out-of-range entry is never saved. + */ +internal fun parseBoundedInt(text: String, range: IntRange): Int? = + text.trim().toIntOrNull()?.takeIf { it in range } + +/** + * A whole-number setting: a validated field flanked by minus/plus buttons. + * + * The field accepts digits only and reports a change just once the entry is a whole + * number inside [range] — on Done, or on one of the stepper buttons — so the caller + * can never be handed a value it would have to reject. Leaving the field with an + * invalid entry restores the stored value rather than leaving a number on screen + * that was never saved, and because the field is keyed on [value] it also resyncs + * when the caller rolls a failed save back. + * + * @param value the currently stored value. + * @param range the values a user may enter. + * @param step how much the minus/plus buttons move; the field handles bigger jumps. + */ +@Composable +fun SettingsNumberRow( + label: String, + description: String, + value: Int, + range: IntRange, + onValueChange: (Int) -> Unit, + tag: String, + modifier: Modifier = Modifier, + step: Int = 1, +) { + // Keyed on `value` so a rollback, or any change from elsewhere, re-seeds the field. + var text by remember(value) { mutableStateOf(value.toString()) } + var wasFocused by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + val parsed = parseBoundedInt(text, range) + val isInvalid = parsed == null + val stepBase = parsed ?: value + + fun commit(candidate: Int) { + val clamped = candidate.coerceIn(range) + text = clamped.toString() + if (clamped != value) onValueChange(clamped) + } + + Column( + modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = { commit(stepBase - step) }, + enabled = stepBase > range.first, + modifier = Modifier.testTag(settingsDecrementTag(tag)), + ) { + Icon(Icons.Filled.Remove, contentDescription = "Decrease $label") + } + OutlinedTextField( + value = text, + onValueChange = { entry -> text = entry.filter(Char::isDigit).take(MAX_DIGITS) }, + singleLine = true, + isError = isInvalid, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + // An invalid entry keeps focus and its message so it can be fixed. + parsed?.let { + commit(it) + focusManager.clearFocus() + } + }, + ), + modifier = Modifier + .width(120.dp) + .testTag(tag) + .onFocusChanged { focusState -> + if (focusState.isFocused) { + wasFocused = true + } else if (wasFocused) { + wasFocused = false + // Leaving the field saves a valid entry and discards anything else. + if (parsed != null) commit(parsed) else text = value.toString() + } + }, + ) + IconButton( + onClick = { commit(stepBase + step) }, + enabled = stepBase < range.last, + modifier = Modifier.testTag(settingsIncrementTag(tag)), + ) { + Icon(Icons.Filled.Add, contentDescription = "Increase $label") + } + } + if (isInvalid) { + Text( + text = "Enter a whole number from ${range.first} to ${range.last}.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(settingsNumberErrorTag(tag)), + ) + } + } +} + +/** Widest entry the field accepts — keeps a pasted number from overflowing an Int. */ +private const val MAX_DIGITS = 6 diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt index 40738f3..e8059bd 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt @@ -34,8 +34,13 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.SettingsBounds import com.interlinedlist.android.feature.profile.domain.UserSettings import com.interlinedlist.android.feature.profile.domain.ViewingPreference +import com.interlinedlist.android.feature.profile.domain.defaultPubliclyVisibleOrDefault +import com.interlinedlist.android.feature.profile.domain.maxMessageLengthOrDefault +import com.interlinedlist.android.feature.profile.domain.messagesPerPageOrDefault +import com.interlinedlist.android.feature.profile.domain.showAdvancedPostSettingsOrDefault /** Stable test tags for the Settings screen. */ object SettingsTestTags { @@ -44,8 +49,14 @@ object SettingsTestTags { const val ERROR = "settingsError" const val DISMISS_ERROR = "settingsDismissError" const val RETRY = "settingsRetry" + const val PROFILE = "settingsGroupProfile" const val VIEW_PREFERENCES = "settingsGroupViewPreferences" + const val MESSAGE_SETTINGS = "settingsGroupMessageSettings" const val SHOW_PREVIEWS = "settingsShowPreviews" + const val MAX_MESSAGE_LENGTH = "settingsMaxMessageLength" + const val MESSAGES_PER_PAGE = "settingsMessagesPerPage" + const val DEFAULT_PUBLICLY_VISIBLE = "settingsDefaultPubliclyVisible" + const val SHOW_ADVANCED_POST_SETTINGS = "settingsShowAdvancedPostSettings" /** Tag for one feed-filter option, keyed on its wire value. */ fun viewingPreference(option: ViewingPreference): String = @@ -88,6 +99,10 @@ fun SettingsRoute( onRetry = viewModel::refresh, onSelectViewingPreference = viewModel::setViewingPreference, onToggleShowPreviews = viewModel::setShowPreviews, + onSetMessagesPerPage = viewModel::setMessagesPerPage, + onSetMaxMessageLength = viewModel::setMaxMessageLength, + onToggleDefaultPubliclyVisible = viewModel::setDefaultPubliclyVisible, + onToggleShowAdvancedPostSettings = viewModel::setShowAdvancedPostSettings, onDismissError = viewModel::dismissError, modifier = modifier, ) @@ -102,6 +117,10 @@ fun SettingsScreen( onRetry: () -> Unit, onSelectViewingPreference: (ViewingPreference) -> Unit, onToggleShowPreviews: (Boolean) -> Unit, + onSetMessagesPerPage: (Int) -> Unit, + onSetMaxMessageLength: (Int) -> Unit, + onToggleDefaultPubliclyVisible: (Boolean) -> Unit, + onToggleShowAdvancedPostSettings: (Boolean) -> Unit, onDismissError: () -> Unit, modifier: Modifier = Modifier, ) { @@ -129,10 +148,20 @@ fun SettingsScreen( if (state.errorMessage != null) { SaveErrorBanner(message = state.errorMessage, onDismiss = onDismissError) } + ProfileGroup( + settings = settings, + onSetMaxMessageLength = onSetMaxMessageLength, + ) ViewPreferencesGroup( settings = settings, onSelectViewingPreference = onSelectViewingPreference, onToggleShowPreviews = onToggleShowPreviews, + onSetMessagesPerPage = onSetMessagesPerPage, + ) + MessageSettingsGroup( + settings = settings, + onToggleDefaultPubliclyVisible = onToggleDefaultPubliclyVisible, + onToggleShowAdvancedPostSettings = onToggleShowAdvancedPostSettings, ) Spacer(Modifier.height(24.dp)) } @@ -175,6 +204,7 @@ private fun ViewPreferencesGroup( settings: UserSettings, onSelectViewingPreference: (ViewingPreference) -> Unit, onToggleShowPreviews: (Boolean) -> Unit, + onSetMessagesPerPage: (Int) -> Unit, ) { SettingsGroup( title = "View preferences", @@ -198,6 +228,82 @@ private fun ViewPreferencesGroup( onCheckedChange = onToggleShowPreviews, tag = SettingsTestTags.SHOW_PREVIEWS, ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + // The web keeps the page size here rather than under Message settings. + SettingsNumberRow( + label = "Messages per page", + description = "How many messages to load at once (10 to 30).", + value = settings.messagesPerPageOrDefault, + range = SettingsBounds.MESSAGES_PER_PAGE, + onValueChange = onSetMessagesPerPage, + tag = SettingsTestTags.MESSAGES_PER_PAGE, + ) + } +} + +/** + * "Profile": the account-wide message character limit. + * + * The web deliberately files the limit here and not under Message settings — its own + * help centre tells users to "adjust it in Settings, then Profile (not Message + * Settings)" — so this mirrors that. Message settings points at it for anyone who + * looks there first. + */ +@Composable +private fun ProfileGroup( + settings: UserSettings, + onSetMaxMessageLength: (Int) -> Unit, +) { + SettingsGroup( + title = "Profile", + description = "Your display name, bio and avatar are edited from Edit profile.", + modifier = Modifier.testTag(SettingsTestTags.PROFILE), + ) { + SettingsNumberRow( + label = "Message character limit", + description = "The longest message you can post (default 666 characters).", + value = settings.maxMessageLengthOrDefault, + range = SettingsBounds.MAX_MESSAGE_LENGTH, + onValueChange = onSetMaxMessageLength, + tag = SettingsTestTags.MAX_MESSAGE_LENGTH, + step = SettingsBounds.MAX_MESSAGE_LENGTH_STEP, + ) + } +} + +/** + * "Message settings": how the composer starts out. The character limit and the feed + * page size belong to the two groups above, matching the web, so the description + * says where they went. + */ +@Composable +private fun MessageSettingsGroup( + settings: UserSettings, + onToggleDefaultPubliclyVisible: (Boolean) -> Unit, + onToggleShowAdvancedPostSettings: (Boolean) -> Unit, +) { + SettingsGroup( + title = "Message settings", + description = "How new messages start out. As on the web, the character limit is " + + "under Profile and the page size under View preferences.", + modifier = Modifier.testTag(SettingsTestTags.MESSAGE_SETTINGS), + ) { + SettingsSwitchRow( + label = "Default message visibility", + description = "New messages start public. Turn this off to start them private.", + checked = settings.defaultPubliclyVisibleOrDefault, + onCheckedChange = onToggleDefaultPubliclyVisible, + tag = SettingsTestTags.DEFAULT_PUBLICLY_VISIBLE, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + SettingsSwitchRow( + label = "Advanced post settings", + description = "Show the gear icon next to the message input for images, " + + "video, and cross-posting.", + checked = settings.showAdvancedPostSettingsOrDefault, + onCheckedChange = onToggleShowAdvancedPostSettings, + tag = SettingsTestTags.SHOW_ADVANCED_POST_SETTINGS, + ) } } @@ -229,14 +335,22 @@ private fun SettingsScreenPreview() { SettingsScreen( state = SettingsUiState( settings = UserSettings( + maxMessageLength = 666, + defaultPubliclyVisible = true, + messagesPerPage = 20, viewingPreference = ViewingPreference.FOLLOWING, showPreviews = true, + showAdvancedPostSettings = false, ), ), onBack = {}, onRetry = {}, onSelectViewingPreference = {}, onToggleShowPreviews = {}, + onSetMessagesPerPage = {}, + onSetMaxMessageLength = {}, + onToggleDefaultPubliclyVisible = {}, + onToggleShowAdvancedPostSettings = {}, onDismissError = {}, ) } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt index e808d6f..85e7950 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt @@ -4,9 +4,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.profile.data.SettingsRepository +import com.interlinedlist.android.feature.profile.domain.SettingsBounds import com.interlinedlist.android.feature.profile.domain.UserSettings import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate import com.interlinedlist.android.feature.profile.domain.ViewingPreference +import com.interlinedlist.android.feature.profile.domain.defaultPubliclyVisibleOrDefault +import com.interlinedlist.android.feature.profile.domain.maxMessageLengthOrDefault +import com.interlinedlist.android.feature.profile.domain.messagesPerPageOrDefault +import com.interlinedlist.android.feature.profile.domain.showAdvancedPostSettingsOrDefault import com.interlinedlist.android.feature.profile.ui.common.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -90,8 +95,75 @@ class SettingsViewModel @Inject constructor( ) } + /** Chooses whether new messages start public or private. */ + fun setDefaultPubliclyVisible(publiclyVisible: Boolean) { + val current = _uiState.value.settings ?: return + if (current.defaultPubliclyVisibleOrDefault == publiclyVisible) return + save( + optimistic = current.copy(defaultPubliclyVisible = publiclyVisible), + previous = current, + update = UserSettingsUpdate(defaultPubliclyVisible = publiclyVisible), + ) + } + + /** Shows or hides the composer's advanced (gear) options. */ + fun setShowAdvancedPostSettings(enabled: Boolean) { + val current = _uiState.value.settings ?: return + if (current.showAdvancedPostSettingsOrDefault == enabled) return + save( + optimistic = current.copy(showAdvancedPostSettings = enabled), + previous = current, + update = UserSettingsUpdate(showAdvancedPostSettings = enabled), + ) + } + + /** + * Sets the account's message character limit. Values outside + * [SettingsBounds.MAX_MESSAGE_LENGTH] are refused here, so a bad number never + * reaches the API; a value we accept may still be refused by the server, in + * which case [save] rolls it back. + */ + fun setMaxMessageLength(characters: Int) { + val current = _uiState.value.settings ?: return + if (!withinRange(characters, SettingsBounds.MAX_MESSAGE_LENGTH, "Message character limit")) return + if (current.maxMessageLengthOrDefault == characters) return + save( + optimistic = current.copy(maxMessageLength = characters), + previous = current, + update = UserSettingsUpdate(maxMessageLength = characters), + ) + } + + /** + * Sets how many messages the feed loads at a time. The help centre documents the + * supported range as 10 to 30 ([SettingsBounds.MESSAGES_PER_PAGE]); anything else + * is refused without a request. + */ + fun setMessagesPerPage(messages: Int) { + val current = _uiState.value.settings ?: return + if (!withinRange(messages, SettingsBounds.MESSAGES_PER_PAGE, "Messages per page")) return + if (current.messagesPerPageOrDefault == messages) return + save( + optimistic = current.copy(messagesPerPage = messages), + previous = current, + update = UserSettingsUpdate(messagesPerPage = messages), + ) + } + fun dismissError() = _uiState.update { it.copy(errorMessage = null) } + /** + * True when [value] is inside [range]; otherwise reports it as an error naming + * the bounds and returns false, leaving the stored value alone. + */ + private fun withinRange(value: Int, range: IntRange, label: String): Boolean { + if (value in range) return true + _uiState.update { + it.copy(errorMessage = "$label must be between ${range.first} and ${range.last}.") + } + return false + } + private fun save( optimistic: UserSettings, previous: UserSettings, diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt index 67e4ba5..96bddb3 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonPrimitive import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -59,6 +61,10 @@ class DefaultSettingsRepositoryTest { private fun enqueueUser( viewingPreference: String = "all_messages", showPreviews: Boolean = true, + maxMessageLength: Int = 666, + defaultPubliclyVisible: Boolean = true, + messagesPerPage: Int = 20, + showAdvancedPostSettings: Boolean = false, ) = server.enqueue( MockResponse().setResponseCode(200).setBody( """ @@ -68,12 +74,12 @@ class DefaultSettingsRepositoryTest { "username": "adron", "displayName": "Adron Hall", "theme": "dark", - "maxMessageLength": 666, - "defaultPubliclyVisible": true, - "messagesPerPage": 20, + "maxMessageLength": $maxMessageLength, + "defaultPubliclyVisible": $defaultPubliclyVisible, + "messagesPerPage": $messagesPerPage, "viewingPreference": "$viewingPreference", "showPreviews": $showPreviews, - "showAdvancedPostSettings": false, + "showAdvancedPostSettings": $showAdvancedPostSettings, "latitude": 45.52, "longitude": -122.68, "isPrivateAccount": false, @@ -216,4 +222,82 @@ class DefaultSettingsRepositoryTest { assertThat(settings.showPreviews).isTrue() assertThat(settings.notificationTrayLimit).isNull() } + + // --- Message preferences (issue #32) ------------------------------------- + // Each of the four saves alone, and the two numeric ones go out as JSON + // numbers rather than the strings the auto-generated spec claims. + + @Test + fun `defaultPubliclyVisible PATCHes alone as a JSON boolean`() = runTest(testDispatcher) { + enqueueUser(defaultPubliclyVisible = false) + + val result = repository.update(UserSettingsUpdate(defaultPubliclyVisible = false)) + + val body = server.takeJsonBody() + assertThat(body.keys).containsExactly("defaultPubliclyVisible") + val sent = body.getValue("defaultPubliclyVisible").jsonPrimitive + assertThat(sent.isString).isFalse() + assertThat(sent.booleanOrNull).isFalse() + assertThat((result as ApiResult.Success).data.defaultPubliclyVisible).isFalse() + assertThat(repository.observeSettings().first()?.defaultPubliclyVisible).isFalse() + } + + @Test + fun `showAdvancedPostSettings PATCHes alone as a JSON boolean`() = runTest(testDispatcher) { + enqueueUser(showAdvancedPostSettings = true) + + val result = repository.update(UserSettingsUpdate(showAdvancedPostSettings = true)) + + val body = server.takeJsonBody() + assertThat(body.keys).containsExactly("showAdvancedPostSettings") + val sent = body.getValue("showAdvancedPostSettings").jsonPrimitive + assertThat(sent.isString).isFalse() + assertThat(sent.booleanOrNull).isTrue() + assertThat((result as ApiResult.Success).data.showAdvancedPostSettings).isTrue() + } + + @Test + fun `maxMessageLength PATCHes alone as a JSON number`() = runTest(testDispatcher) { + enqueueUser(maxMessageLength = 1000) + + val result = repository.update(UserSettingsUpdate(maxMessageLength = 1000)) + + val body = server.takeJsonBody() + assertThat(body.keys).containsExactly("maxMessageLength") + val sent = body.getValue("maxMessageLength").jsonPrimitive + assertThat(sent.isString).isFalse() + assertThat(sent.intOrNull).isEqualTo(1000) + assertThat((result as ApiResult.Success).data.maxMessageLength).isEqualTo(1000) + assertThat(repository.observeSettings().first()?.maxMessageLength).isEqualTo(1000) + } + + @Test + fun `messagesPerPage PATCHes alone as a JSON number`() = runTest(testDispatcher) { + enqueueUser(messagesPerPage = 30) + + val result = repository.update(UserSettingsUpdate(messagesPerPage = 30)) + + val body = server.takeJsonBody() + assertThat(body.keys).containsExactly("messagesPerPage") + val sent = body.getValue("messagesPerPage").jsonPrimitive + assertThat(sent.isString).isFalse() + assertThat(sent.intOrNull).isEqualTo(30) + assertThat((result as ApiResult.Success).data.messagesPerPage).isEqualTo(30) + } + + @Test + fun `a rejected numeric save leaves the cached value untouched`() = runTest(testDispatcher) { + enqueueUser(maxMessageLength = 666) + repository.refresh() + server.takeRequest() + + server.enqueue( + MockResponse().setResponseCode(400) + .setBody("""{ "error": "maxMessageLength out of range", "code": "bad_request" }"""), + ) + val result = repository.update(UserSettingsUpdate(maxMessageLength = 9_999)) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat(repository.observeSettings().first()?.maxMessageLength).isEqualTo(666) + } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt index fedc7c2..2adaa93 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt @@ -44,6 +44,11 @@ class FakeSettingsRepository : SettingsRepository { return current.copy( viewingPreference = update.viewingPreference ?: current.viewingPreference, showPreviews = update.showPreviews ?: current.showPreviews, + maxMessageLength = update.maxMessageLength ?: current.maxMessageLength, + defaultPubliclyVisible = update.defaultPubliclyVisible ?: current.defaultPubliclyVisible, + messagesPerPage = update.messagesPerPage ?: current.messagesPerPage, + showAdvancedPostSettings = + update.showAdvancedPostSettings ?: current.showAdvancedPostSettings, ) } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/PatchBodyKeys.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/PatchBodyKeys.kt new file mode 100644 index 0000000..327ac1c --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/PatchBodyKeys.kt @@ -0,0 +1,22 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.interlinedlist.android.feature.profile.data.mapper.toRequest +import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement + +/** + * The same JSON configuration the network module uses for request bodies: null + * fields are dropped rather than serialised as `null`. + */ +private val patchJson = Json { explicitNulls = false } + +/** + * The keys this update would actually put on the `PATCH /api/user/update` body, + * obtained by running it through the real mapper and serializer. Lets a ViewModel + * test assert "this preference PATCHed alone" against the wire shape rather than + * against a hand-maintained list of fields. + */ +fun UserSettingsUpdate.touchedFieldNames(): Set = + (patchJson.encodeToJsonElement(toRequest()) as JsonObject).keys diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsMessagePreferencesTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsMessagePreferencesTest.kt new file mode 100644 index 0000000..be56d39 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsMessagePreferencesTest.kt @@ -0,0 +1,308 @@ +package com.interlinedlist.android.feature.profile.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.domain.SettingsBounds +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.ui.settings.SettingsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The Message settings group (issue #32): default visibility, the character limit, + * the feed page size and the advanced-post-settings toggle. + * + * Each preference must go out as its own partial PATCH, apply optimistically and roll + * back when the server refuses; the two numeric ones must additionally refuse + * out-of-range input locally, without spending a request on it. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsMessagePreferencesTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeSettingsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeSettingsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + /** A view model already loaded with [settings]. */ + private fun loadedViewModel(settings: UserSettings): SettingsViewModel { + repo.refreshResult = ApiResult.Success(settings) + return SettingsViewModel(repo) + } + + private fun rejectSaves() { + repo.updateResult = { ApiResult.Failure(AppError.Server("boom")) } + } + + private val serverErrorMessage = "InterlinedList is having trouble right now. Try again shortly." + + // --- defaultPubliclyVisible ---------------------------------------------- + + @Test + fun `turning off default visibility saves only that field`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(defaultPubliclyVisible = true)) + + vm.uiState.test { + advanceUntilIdle() + vm.setDefaultPubliclyVisible(false) + + // Applied optimistically, before the request comes back. + assertThat(vm.uiState.value.settings?.defaultPubliclyVisible).isFalse() + assertThat(vm.uiState.value.isSaving).isTrue() + advanceUntilIdle() + + val sent = repo.updates.single() + assertThat(sent.defaultPubliclyVisible).isFalse() + assertThat(sent.touchedFieldNames()).containsExactly("defaultPubliclyVisible") + val state = expectMostRecentItem() + assertThat(state.settings?.defaultPubliclyVisible).isFalse() + assertThat(state.isSaving).isFalse() + assertThat(state.errorMessage).isNull() + } + } + + @Test + fun `a failed default visibility save rolls back and surfaces the error`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(defaultPubliclyVisible = true)) + rejectSaves() + + vm.uiState.test { + advanceUntilIdle() + vm.setDefaultPubliclyVisible(false) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.settings?.defaultPubliclyVisible).isTrue() + assertThat(state.isSaving).isFalse() + assertThat(state.errorMessage).isEqualTo(serverErrorMessage) + } + } + + @Test + fun `setting default visibility to the value already stored does not call the API`() = + runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(defaultPubliclyVisible = true)) + advanceUntilIdle() + + vm.setDefaultPubliclyVisible(true) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + assertThat(vm.uiState.value.settings?.defaultPubliclyVisible).isTrue() + } + + // --- showAdvancedPostSettings -------------------------------------------- + + @Test + fun `turning on advanced post settings saves only that field`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(showAdvancedPostSettings = false)) + + vm.uiState.test { + advanceUntilIdle() + vm.setShowAdvancedPostSettings(true) + + assertThat(vm.uiState.value.settings?.showAdvancedPostSettings).isTrue() + advanceUntilIdle() + + val sent = repo.updates.single() + assertThat(sent.showAdvancedPostSettings).isTrue() + assertThat(sent.touchedFieldNames()).containsExactly("showAdvancedPostSettings") + assertThat(expectMostRecentItem().settings?.showAdvancedPostSettings).isTrue() + } + } + + @Test + fun `a failed advanced post settings save rolls back and surfaces the error`() = + runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(showAdvancedPostSettings = false)) + rejectSaves() + + vm.uiState.test { + advanceUntilIdle() + vm.setShowAdvancedPostSettings(true) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.settings?.showAdvancedPostSettings).isFalse() + assertThat(state.errorMessage).isEqualTo(serverErrorMessage) + } + } + + // --- maxMessageLength ---------------------------------------------------- + + @Test + fun `setting the character limit saves only that field`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(maxMessageLength = 666)) + + vm.uiState.test { + advanceUntilIdle() + vm.setMaxMessageLength(1_000) + + assertThat(vm.uiState.value.settings?.maxMessageLength).isEqualTo(1_000) + advanceUntilIdle() + + val sent = repo.updates.single() + assertThat(sent.maxMessageLength).isEqualTo(1_000) + assertThat(sent.touchedFieldNames()).containsExactly("maxMessageLength") + assertThat(expectMostRecentItem().settings?.maxMessageLength).isEqualTo(1_000) + } + } + + @Test + fun `a character limit below the minimum is refused without a request`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(maxMessageLength = 666)) + + vm.uiState.test { + advanceUntilIdle() + vm.setMaxMessageLength(SettingsBounds.MAX_MESSAGE_LENGTH.first - 1) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + val state = expectMostRecentItem() + assertThat(state.settings?.maxMessageLength).isEqualTo(666) + assertThat(state.isSaving).isFalse() + assertThat(state.errorMessage).isEqualTo( + "Message character limit must be between " + + "${SettingsBounds.MAX_MESSAGE_LENGTH.first} and " + + "${SettingsBounds.MAX_MESSAGE_LENGTH.last}.", + ) + } + } + + @Test + fun `a character limit above the maximum is refused without a request`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(maxMessageLength = 666)) + + vm.uiState.test { + advanceUntilIdle() + vm.setMaxMessageLength(SettingsBounds.MAX_MESSAGE_LENGTH.last + 1) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + assertThat(expectMostRecentItem().settings?.maxMessageLength).isEqualTo(666) + } + } + + @Test + fun `a server rejection restores the previous character limit`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(maxMessageLength = 666)) + // In range for us, refused by the server (its own cap is not published). + // A 400 normalises to AppError.Unknown carrying the server's own wording. + repo.updateResult = { ApiResult.Failure(AppError.Unknown("maxMessageLength out of range")) } + + vm.uiState.test { + advanceUntilIdle() + vm.setMaxMessageLength(10_000) + assertThat(vm.uiState.value.settings?.maxMessageLength).isEqualTo(10_000) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(repo.updates).hasSize(1) + assertThat(state.settings?.maxMessageLength).isEqualTo(666) + assertThat(state.errorMessage).isEqualTo("maxMessageLength out of range") + } + } + + @Test + fun `re-entering the current character limit does not call the API`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(maxMessageLength = 666)) + advanceUntilIdle() + + vm.setMaxMessageLength(666) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + } + + @Test + fun `entering the server default when the account has no stored limit does not call the API`() = + runTest(dispatcher) { + // The row shows 666 for a null stored value, so "setting" 666 changes nothing. + val vm = loadedViewModel(UserSettings(maxMessageLength = null)) + advanceUntilIdle() + + vm.setMaxMessageLength(SettingsBounds.DEFAULT_MAX_MESSAGE_LENGTH) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + } + + // --- messagesPerPage ----------------------------------------------------- + + @Test + fun `setting messages per page saves only that field`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(messagesPerPage = 20)) + + vm.uiState.test { + advanceUntilIdle() + vm.setMessagesPerPage(30) + + assertThat(vm.uiState.value.settings?.messagesPerPage).isEqualTo(30) + advanceUntilIdle() + + val sent = repo.updates.single() + assertThat(sent.messagesPerPage).isEqualTo(30) + assertThat(sent.touchedFieldNames()).containsExactly("messagesPerPage") + assertThat(expectMostRecentItem().settings?.messagesPerPage).isEqualTo(30) + } + } + + @Test + fun `messages per page outside the documented ten to thirty is refused without a request`() = + runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(messagesPerPage = 20)) + + vm.uiState.test { + advanceUntilIdle() + vm.setMessagesPerPage(31) + vm.setMessagesPerPage(9) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + val state = expectMostRecentItem() + assertThat(state.settings?.messagesPerPage).isEqualTo(20) + assertThat(state.errorMessage).isEqualTo("Messages per page must be between 10 and 30.") + } + } + + @Test + fun `a failed messages per page save rolls back and surfaces the error`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(messagesPerPage = 20)) + rejectSaves() + + vm.uiState.test { + advanceUntilIdle() + vm.setMessagesPerPage(10) + assertThat(vm.uiState.value.settings?.messagesPerPage).isEqualTo(10) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.settings?.messagesPerPage).isEqualTo(20) + assertThat(state.errorMessage).isEqualTo(serverErrorMessage) + } + } + + @Test + fun `the documented bounds match the help centre`() { + assertThat(SettingsBounds.MESSAGES_PER_PAGE).isEqualTo(10..30) + assertThat(SettingsBounds.DEFAULT_MAX_MESSAGE_LENGTH).isEqualTo(666) + assertThat(SettingsBounds.DEFAULT_MESSAGES_PER_PAGE).isEqualTo(20) + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsNumberInputTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsNumberInputTest.kt new file mode 100644 index 0000000..75b5bc8 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsNumberInputTest.kt @@ -0,0 +1,45 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.profile.ui.settings.parseBoundedInt +import org.junit.Test + +/** + * The validation behind `SettingsNumberRow`'s field. It is what stops a typed value + * from reaching the ViewModel — and therefore the API — unless it is a whole number + * inside the row's range. + */ +class SettingsNumberInputTest { + + private val range = 10..30 + + @Test + fun `accepts a whole number inside the range`() { + assertThat(parseBoundedInt("10", range)).isEqualTo(10) + assertThat(parseBoundedInt("20", range)).isEqualTo(20) + assertThat(parseBoundedInt("30", range)).isEqualTo(30) + assertThat(parseBoundedInt(" 25 ", range)).isEqualTo(25) + } + + @Test + fun `rejects values outside the range`() { + assertThat(parseBoundedInt("9", range)).isNull() + assertThat(parseBoundedInt("31", range)).isNull() + assertThat(parseBoundedInt("0", range)).isNull() + } + + @Test + fun `rejects anything that is not a whole number`() { + assertThat(parseBoundedInt("", range)).isNull() + assertThat(parseBoundedInt(" ", range)).isNull() + assertThat(parseBoundedInt("abc", range)).isNull() + assertThat(parseBoundedInt("2.5", range)).isNull() + assertThat(parseBoundedInt("-20", range)).isNull() + assertThat(parseBoundedInt("20abc", range)).isNull() + } + + @Test + fun `rejects a number too large to be an Int instead of overflowing`() { + assertThat(parseBoundedInt("99999999999999", range)).isNull() + } +}