From 5413ea1b578fec3fc0bf017516e040e3a7beca83 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:06:05 -0700 Subject: [PATCH] feat(settings): add message preferences with a validated numeric row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the four message-behaviour preferences from PATCH /api/user/update on the Settings screen, each saved as its own partial PATCH, applied optimistically and rolled back when the server refuses it. Grouping mirrors the web, which the help centre spells out: the character limit sits under Profile ("adjust it in Settings, then Profile (not Message Settings)"), the page size under View preferences, and only the two composer toggles under Message settings. The Message settings description points at the other two so either route finds them. Adds SettingsNumberRow to the settings kit for the numeric preferences: a digits-only field flanked by stepper buttons that only reports a whole number inside its range, so an out-of-range or malformed entry never becomes a request. Bounds live in SettingsBounds — messagesPerPage 10..30 as documented, and a deliberately wide 10..10000 for the undocumented character limit, with a rejected save restoring the previous value. Consumption of these preferences by the composer and feed is deliberately left to the :feature:messages lane (#24, #19); #18 already reads defaultPubliclyVisible. Closes #32 --- .../feature/profile/ui/SettingsScreenTest.kt | 184 +++++++++++ .../feature/profile/domain/SettingsBounds.kt | 64 ++++ .../profile/ui/settings/SettingsComponents.kt | 152 +++++++++ .../profile/ui/settings/SettingsScreen.kt | 114 +++++++ .../profile/ui/settings/SettingsViewModel.kt | 72 ++++ .../data/DefaultSettingsRepositoryTest.kt | 92 +++++- .../profile/ui/FakeSettingsRepository.kt | 5 + .../feature/profile/ui/PatchBodyKeys.kt | 22 ++ .../ui/SettingsMessagePreferencesTest.kt | 308 ++++++++++++++++++ .../profile/ui/SettingsNumberInputTest.kt | 45 +++ 10 files changed, 1054 insertions(+), 4 deletions(-) create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/PatchBodyKeys.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsMessagePreferencesTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsNumberInputTest.kt 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() + } +}