diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRailTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRailTest.kt new file mode 100644 index 0000000..8c00c73 --- /dev/null +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRailTest.kt @@ -0,0 +1,192 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import androidx.compose.ui.test.assertIsDisplayed +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.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.ui.feed.MessagesFeedScreen +import com.interlinedlist.android.feature.messages.ui.feed.MessagesFeedTags +import com.interlinedlist.android.feature.messages.ui.feed.MessagesFeedUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The trending rail: that each of its states is visibly *something*, that the + * failure state is not mistakable for the quiet one, and that a tapped tag hands + * back the exact string the feed must be filtered by. + */ +@RunWith(AndroidJUnit4::class) +class TrendingTagsRailTest { + + @get:Rule + val composeRule = createComposeRule() + + /** A real tag from the live site: spaces *and* a comma, in one label. */ + private val awkwardTag = "life is short, o brave girl" + + private fun setRail( + state: TrendingTagsUiState, + onOpenTag: (String) -> Unit = {}, + onRetry: () -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + TrendingTagsRail(state = state, onOpenTag = onOpenTag, onRetry = onRetry) + } + } + } + + @Test + fun tags_areOfferedInTheServersOrder_withTheWindowTheyWereAskedFor() { + setRail( + TrendingTagsUiState( + tags = listOf( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-12T20:40:05.777Z"), + TrendingTag(awkwardTag, count = 1), + ), + ), + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.CHIPS).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("Lego")).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag(awkwardTag)).assertIsDisplayed() + // The window is the one the app requested; the response never reports one. + composeRule.onNodeWithText("Trending this week").assertIsDisplayed() + } + + @Test + fun tappingATag_handsBackTheExactTagString() { + val opened = mutableListOf() + setRail( + TrendingTagsUiState(tags = listOf(TrendingTag(awkwardTag, count = 1))), + onOpenTag = { opened += it }, + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag(awkwardTag)).performClick() + + // Not trimmed, split on the comma, or turned into a hashtag: this string + // is what MessagesDestinations.tagFeedRoute encodes into the tag feed. + assertThat(opened).containsExactly(awkwardTag) + } + + @Test + fun noTrendingTags_rendersTheEmptyState_ratherThanABlankStrip() { + setRail(TrendingTagsUiState(tags = emptyList())) + + composeRule.onNodeWithTag(TrendingTagsRailTags.EMPTY).assertIsDisplayed() + composeRule.onNodeWithText(NO_TRENDING_TAGS).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.ERROR).assertDoesNotExist() + composeRule.onNodeWithTag(TrendingTagsRailTags.CHIPS).assertDoesNotExist() + } + + @Test + fun aFailedLookup_looksDifferentFromAQuietInstance_andCanBeRetried() { + val retries = mutableListOf() + setRail( + TrendingTagsUiState(errorMessage = "No connection. Check your network and try again."), + onRetry = { retries += Unit }, + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.ERROR).assertIsDisplayed() + // "Nothing is trending" and "we could not find out" are different answers. + composeRule.onNodeWithTag(TrendingTagsRailTags.EMPTY).assertDoesNotExist() + + composeRule.onNodeWithTag(TrendingTagsRailTags.RETRY).performClick() + assertThat(retries).hasSize(1) + } + + @Test + fun theFirstLoad_showsProgress_ratherThanClaimingThereIsNothing() { + setRail(TrendingTagsUiState(isLoading = true)) + + composeRule.onNodeWithTag(TrendingTagsRailTags.PROGRESS).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.EMPTY).assertDoesNotExist() + } + + @Test + fun staleTagsSurviveAFailedRefresh_insteadOfBeingReplacedByAnError() { + setRail( + TrendingTagsUiState( + tags = listOf(TrendingTag("lists", count = 6)), + errorMessage = "No connection. Check your network and try again.", + ), + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("lists")).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.ERROR).assertDoesNotExist() + } + + // --- where it lives ---------------------------------------------------- + + private fun setFeed( + state: MessagesFeedUiState, + trending: TrendingTagsUiState, + onOpenTag: ((String) -> Unit)? = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + MessagesFeedScreen( + state = state, + trending = trending, + onRefresh = {}, + onLoadMore = {}, + onOpenMessage = {}, + onDig = {}, + onDelete = {}, + onOpenCompose = {}, + onDismissCompose = {}, + onComposeTextChange = {}, + onPost = {}, + onOpenTag = onOpenTag, + ) + } + } + } + + private fun message(id: String) = Message( + id = id, content = "a message", authorId = "u1", authorUsername = "adron", + authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, + digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = false, + ) + + @Test + fun theRail_ridesAtTheTopOfTheFeed_whereItIsWalkedPast() { + setFeed( + MessagesFeedUiState(messages = listOf(message("1"))), + TrendingTagsUiState(tags = listOf(TrendingTag("lists", count = 6))), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.LIST).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.RAIL).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("lists")).assertIsDisplayed() + } + + @Test + fun anEmptyFeed_stillOffersSomewhereToGo() { + setFeed( + MessagesFeedUiState(messages = emptyList()), + TrendingTagsUiState(tags = listOf(TrendingTag("lists", count = 6))), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.EMPTY).assertIsDisplayed() + composeRule.onNodeWithTag(TrendingTagsRailTags.chipTag("lists")).assertIsDisplayed() + } + + @Test + fun theRail_isAbsentWhereThereIsNowhereToSendTheUser() { + setFeed( + MessagesFeedUiState(messages = listOf(message("1"))), + TrendingTagsUiState(tags = listOf(TrendingTag("lists", count = 6))), + onOpenTag = null, + ) + + composeRule.onNodeWithTag(TrendingTagsRailTags.RAIL).assertDoesNotExist() + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt index 8a04592..86abb98 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt @@ -29,6 +29,8 @@ import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow import com.interlinedlist.android.feature.messages.domain.asPushedOriginal import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -460,6 +462,18 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun trendingTags( + window: TrendingWindow, + limit: Int, + ): ApiResult> = withContext(dispatchers.io) { + // window.wire, never a raw string: the server accepts anything and + // quietly counts a week instead of telling us the value was wrong. + when (val result = safeCall { api.trendingTags(window = window.wire, limit = limit) }) { + is ApiResult.Success -> ApiResult.Success(result.data.toDomain()) + is ApiResult.Failure -> result + } + } + override suspend fun search(query: String): ApiResult> = withContext(dispatchers.io) { when (val result = safeCall { api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt index 7f48c3f..367f198 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt @@ -9,6 +9,8 @@ import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow import kotlinx.coroutines.flow.Flow /** @@ -129,6 +131,20 @@ interface MessagesRepository { */ suspend fun autocompleteTags(query: String, limit: Int = TAG_SUGGESTION_LIMIT): ApiResult> + /** + * The most-used tags across public messages in the trailing [window], from + * `GET /api/tags/trending`, in the server's order (count descending). + * + * Network-only, like [autocompleteTags]: trending is a discovery surface for + * *right now*, so a cached copy would be worse than an honest empty/error + * state. [window] is sent explicitly because the response never reports which + * period it covers — the request is what makes the surface's wording true. + */ + suspend fun trendingTags( + window: TrendingWindow = TrendingWindow.WEEK, + limit: Int = TRENDING_TAG_LIMIT, + ): ApiResult> + /** * Pushes (reposts) [messageId] as-is: posts `pushedMessageId` with **no** * content, always publicly. A quote — the same repost with the user's own @@ -216,5 +232,13 @@ interface MessagesRepository { companion object { /** How many tag suggestions to ask for (server default 10, max 50). */ const val TAG_SUGGESTION_LIMIT = 10 + + /** + * How many trending tags to ask for (server default 20, max 100). Kept + * short deliberately: they render as one horizontally scrolling row, and + * a rail nobody reaches the end of is no more discoverable than a short + * one. + */ + const val TRENDING_TAG_LIMIT = 12 } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt index 827ff16..6ae7b6d 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt @@ -11,6 +11,7 @@ import com.interlinedlist.android.feature.messages.data.remote.dto.MetadataRespo import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.ScheduledMessagesResponse import com.interlinedlist.android.feature.messages.data.remote.dto.TagAutocompleteResponse +import com.interlinedlist.android.feature.messages.data.remote.dto.TrendingTagsResponse import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest import okhttp3.MultipartBody import retrofit2.http.Body @@ -137,6 +138,22 @@ interface MessagesApi { @Query("limit") limit: Int? = null, ): TagAutocompleteResponse + /** + * The most-used tags across **public** messages inside a trailing [window]. + * + * [window] must be one of `day`, `week` or `month`: the server falls back to + * `week` for anything else **without reporting it**, so a typo would silently + * mislabel the surface. [limit] defaults to 20 server-side and is clamped to + * 100. The response is a bare `{ "tags": [ { tag, count, lastUsedAt } ] }` — + * it does **not** echo the window back, so the caller is the only thing that + * knows which period the counts cover. + */ + @GET("api/tags/trending") + suspend fun trendingTags( + @Query("window") window: String, + @Query("limit") limit: Int, + ): TrendingTagsResponse + /** Reports a message with a reason (and optional free-text detail). */ @POST("api/messages/{id}/report") suspend fun report(@Path("id") id: String, @Body body: ReportRequest) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt index ba70ed4..bc816ee 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TagsResponse.kt @@ -1,6 +1,7 @@ package com.interlinedlist.android.feature.messages.data.remote.dto import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.domain.TrendingTag import kotlinx.serialization.Serializable /** @@ -30,3 +31,37 @@ data class TagSuggestionDto( return TagSuggestion(tag = label, count = count) } } + +/** + * Response from `GET /api/tags/trending?window=…&limit=…`: + * `{ "tags": [ { "tag": "Lego", "count": 2, "lastUsedAt": "2026-09-12T20:40:05.777Z" } ] }`. + * + * Verified live: there is **no window metadata on the response** and no `data` + * envelope or pagination — the window is only ever something the caller asks + * for. Rows arrive ordered by `count` (descending), then most-recently-used + * first, and that order is preserved exactly as sent. + */ +@Serializable +data class TrendingTagsResponse( + val tags: List = emptyList(), +) { + /** The rows as domain values, dropping any entry with no usable tag. */ + fun toDomain(): List = tags.mapNotNull { it.toDomainOrNull() } +} + +/** One `{ tag, count, lastUsedAt }` row of the trending response. */ +@Serializable +data class TrendingTagDto( + val tag: String = "", + val count: Int = 0, + val lastUsedAt: String? = null, +) { + fun toDomainOrNull(): TrendingTag? { + val label = tag.takeIf { it.isNotBlank() } ?: return null + return TrendingTag( + tag = label, + count = count, + lastUsedAt = lastUsedAt?.takeIf { it.isNotBlank() }, + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TrendingTag.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TrendingTag.kt new file mode 100644 index 0000000..20bf9fc --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/TrendingTag.kt @@ -0,0 +1,43 @@ +package com.interlinedlist.android.feature.messages.domain + +/** + * One row of `GET /api/tags/trending`: a tag used on public messages inside the + * requested trailing window, how many public messages used it there, and when it + * was last used. + * + * Like [TagSuggestion], [tag] is a **free-form label** (spaces and punctuation + * included) and is the exact string the tag feed queries by, so nothing here + * trims, lowercases or tokenises it. + */ +data class TrendingTag( + val tag: String, + /** Public messages using this tag within the window; the ordering key. */ + val count: Int = 0, + /** + * ISO-8601 instant of the most recent public message carrying this tag, or + * null when the API omitted it. Raw text: the UI formats it, nothing parses + * it for logic. + */ + val lastUsedAt: String? = null, +) + +/** + * The trailing window `GET /api/tags/trending?window=` counts over. + * + * The window is a **request** parameter, never part of the response: the live + * payload is `{ tags: [ { tag, count, lastUsedAt } ] }` and says nothing about + * the period it covers. The app therefore labels the surface from the window it + * asked for, and an unknown value would be silently swallowed by the server + * (which falls back to `week` without complaining) — so only these three + * documented values may ever be sent. + */ +enum class TrendingWindow( + /** The wire value for the `window` query parameter. */ + val wire: String, + /** How to describe this window in the UI, e.g. "Trending this week". */ + val label: String, +) { + DAY("day", "today"), + WEEK("week", "this week"), + MONTH("month", "this month"), +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt index 806d73f..1b2af82 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt @@ -85,6 +85,9 @@ import com.interlinedlist.android.feature.messages.ui.components.MessageCard import com.interlinedlist.android.feature.messages.ui.components.ModerationDialog import com.interlinedlist.android.feature.messages.ui.components.ReportDialog import com.interlinedlist.android.feature.messages.ui.readMediaBytes +import com.interlinedlist.android.feature.messages.ui.trending.TrendingTagsRail +import com.interlinedlist.android.feature.messages.ui.trending.TrendingTagsUiState +import com.interlinedlist.android.feature.messages.ui.trending.TrendingTagsViewModel import java.time.Instant import java.time.temporal.ChronoUnit @@ -170,11 +173,17 @@ fun MessagesRoute( onOpenTag: ((String) -> Unit)? = null, onBack: () -> Unit = {}, viewModel: MessagesFeedViewModel = hiltViewModel(), + trendingViewModel: TrendingTagsViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + // Trending has its own ViewModel: it outlives a feed refresh, and a trending + // lookup that fails must not read as a feed that failed. + val trending by trendingViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current MessagesFeedScreen( state = state, + trending = trending, + onRetryTrending = trendingViewModel::refresh, onRefresh = viewModel::refresh, onLoadMore = viewModel::loadMore, onViewingPreferenceChange = viewModel::onViewingPreferenceChange, @@ -238,6 +247,8 @@ fun MessagesFeedScreen( onComposeTextChange: (String) -> Unit, onPost: () -> Unit, modifier: Modifier = Modifier, + trending: TrendingTagsUiState = TrendingTagsUiState(), + onRetryTrending: () -> Unit = {}, onViewingPreferenceChange: (ViewingPreference) -> Unit = {}, onOpenScheduled: () -> Unit = {}, onOpenTag: ((String) -> Unit)? = null, @@ -330,10 +341,22 @@ fun MessagesFeedScreen( enabled = !state.isChangingViewingPreference, onSelect = onViewingPreferenceChange, ) + // A rail of doors that open nothing is not worth its space, so it + // only exists where the host wired somewhere to go. + val trendingRail: (@Composable () -> Unit)? = onOpenTag?.let { openTag -> + { + TrendingTagsRail( + state = trending, + onOpenTag = openTag, + onRetry = onRetryTrending, + ) + } + } when { state.subscriptionRequired -> LockedState(message = state.errorMessage) else -> FeedContent( state = state, + trendingRail = trendingRail, onRefresh = onRefresh, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, @@ -452,6 +475,7 @@ private val ViewingPreference.label: String @Composable private fun FeedContent( state: MessagesFeedUiState, + trendingRail: (@Composable () -> Unit)?, onRefresh: () -> Unit, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, @@ -475,9 +499,10 @@ private fun FeedContent( when { state.isEmpty && state.isRefreshing -> LoadingState() state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRefresh) - state.isEmpty -> EmptyState(tag = state.tag) + state.isEmpty -> EmptyState(tag = state.tag, trendingRail = trendingRail) else -> FeedList( state = state, + trendingRail = trendingRail, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, onOpenTag = onOpenTag, @@ -499,6 +524,7 @@ private fun FeedContent( @Composable private fun FeedList( state: MessagesFeedUiState, + trendingRail: (@Composable () -> Unit)?, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, onOpenTag: ((String) -> Unit)?, @@ -529,6 +555,10 @@ private fun FeedList( .fillMaxSize() .testTag(MessagesFeedTags.LIST), ) { + // First row of the feed rather than a fixed band above it: the switcher + // and the composer already own the top of this screen, so the rail earns + // its place by scrolling away once the reader is past it. + trendingRail?.let { rail -> item(key = "trendingTags") { rail() } } items(state.messages, key = { it.id }) { message -> MessageCard( message = message, @@ -567,21 +597,32 @@ private fun LoadingState() { } @Composable -private fun EmptyState(tag: String? = null) { - Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { - Text( - // The tag feed hides the composer, so "be the first to post" would be - // an invitation the screen cannot honour. - text = if (tag != null) { - "Nothing tagged \u201C$tag\u201D yet." - } else { - "No messages yet. Be the first to post." - }, - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.testTag(MessagesFeedTags.EMPTY), - ) +private fun EmptyState( + tag: String? = null, + trendingRail: (@Composable () -> Unit)? = null, +) { + Column(Modifier.fillMaxSize()) { + // An empty feed is exactly where somewhere-to-go matters most, so the + // rail stays on screen instead of scrolling with a list that has no rows. + trendingRail?.invoke() + Box( + modifier = Modifier.fillMaxSize().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + // The tag feed hides the composer, so "be the first to post" + // would be an invitation the screen cannot honour. + text = if (tag != null) { + "Nothing tagged \u201C$tag\u201D yet." + } else { + "No messages yet. Be the first to post." + }, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MessagesFeedTags.EMPTY), + ) + } } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRail.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRail.kt new file mode 100644 index 0000000..5feae13 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsRail.kt @@ -0,0 +1,215 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.ui.relativeTime +import java.time.Instant + +/** Stable test tags for the trending-tags rail. */ +object TrendingTagsRailTags { + const val RAIL = "trendingTagsRail" + const val TITLE = "trendingTagsTitle" + const val CHIPS = "trendingTagsChips" + const val PROGRESS = "trendingTagsProgress" + const val EMPTY = "trendingTagsEmpty" + const val ERROR = "trendingTagsError" + const val RETRY = "trendingTagsRetry" + + /** Prefix for one trending tag chip; suffixed with the tag itself. */ + const val CHIP_PREFIX = "trendingTag_" + + fun chipTag(tag: String): String = CHIP_PREFIX + tag +} + +/** What the surface says when the instance genuinely has no trending tags. */ +const val NO_TRENDING_TAGS = "No trending tags yet. Tag a message to start one." + +/** + * The trending tags, as one horizontally scrolling row of doors into tag feeds. + * + * Placed at the top of the feed list (and inside its empty state) rather than on + * a screen of its own: a discovery surface nobody walks past is not discovery. + * It scrolls away with the feed because the top of this screen is already spoken + * for by the view-preference switcher and the composer. + * + * Every state is rendered explicitly — an instance with nothing trending says so, + * and a failed lookup says something different — so the rail never degenerates + * into an unexplained blank strip. + */ +@Composable +fun TrendingTagsRail( + state: TrendingTagsUiState, + onOpenTag: (String) -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 8.dp) + .testTag(TrendingTagsRailTags.RAIL), + ) { + Text( + // "this week" is the window the app asked for; the response does not + // report one, so nothing here may claim a period it did not request. + text = state.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag(TrendingTagsRailTags.TITLE), + ) + Spacer(Modifier.height(6.dp)) + when (state.status) { + TrendingTagsStatus.LOADING -> Box(Modifier.padding(horizontal = 16.dp)) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier + .size(20.dp) + .testTag(TrendingTagsRailTags.PROGRESS), + ) + } + + TrendingTagsStatus.TAGS -> LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .testTag(TrendingTagsRailTags.CHIPS), + ) { + items(state.tags, key = { it.tag }) { trending -> + TrendingTagChip(trending = trending, onClick = { onOpenTag(trending.tag) }) + } + } + + TrendingTagsStatus.EMPTY -> Text( + text = NO_TRENDING_TAGS, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .testTag(TrendingTagsRailTags.EMPTY), + ) + + TrendingTagsStatus.ERROR -> Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp), + ) { + Text( + text = state.errorMessage.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .testTag(TrendingTagsRailTags.ERROR), + ) + TextButton( + onClick = onRetry, + modifier = Modifier.testTag(TrendingTagsRailTags.RETRY), + ) { + Text("Retry") + } + } + } + Spacer(Modifier.height(8.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } +} + +/** + * One tag, with its count. The label is the tag exactly as stored — free-form + * text that may contain spaces and punctuation, never prettified into a hashtag + * — truncated rather than allowed to push the rest of the rail off screen. + */ +@Composable +private fun TrendingTagChip(trending: TrendingTag, onClick: () -> Unit) { + val description = trendingTagDescription(trending) + AssistChip( + onClick = onClick, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = trending.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 180.dp), + ) + if (trending.count > 0) { + Spacer(Modifier.size(6.dp)) + Text( + text = trending.count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + modifier = Modifier + .semantics { contentDescription = description } + .testTag(TrendingTagsRailTags.chipTag(trending.tag)), + ) +} + +/** + * Spoken label for a trending chip: the tag, how many messages carry it, and — + * the one thing the payload reports about recency — when it was last used. + * + * [now] is injectable so the wording stays deterministic in tests. + */ +fun trendingTagDescription(trending: TrendingTag, now: Instant = Instant.now()): String = + buildString { + append(trending.tag) + if (trending.count > 0) { + append(", ${trending.count} ") + append(if (trending.count == 1) "message" else "messages") + } + val recency = relativeTime(trending.lastUsedAt, now) + if (recency.isNotBlank()) append(", last used $recency") + } + +@Preview(showBackground = true) +@Composable +private fun TrendingTagsRailPreview() { + InterlinedListTheme { + TrendingTagsRail( + state = TrendingTagsUiState( + tags = listOf( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-12T20:40:05.777Z"), + TrendingTag("life is short, o brave girl", count = 1), + ), + ), + onOpenTag = {}, + onRetry = {}, + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModel.kt new file mode 100644 index 0000000..ad7af4a --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModel.kt @@ -0,0 +1,101 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow +import com.interlinedlist.android.feature.messages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Which of the four mutually exclusive things the trending surface is showing. */ +enum class TrendingTagsStatus { + /** The first load has not answered yet. */ + LOADING, + + /** Tags to offer. */ + TAGS, + + /** + * The instance is genuinely quiet — nobody tagged a public message in the + * window. A real answer, rendered as such rather than as nothing at all. + */ + EMPTY, + + /** The lookup failed. Deliberately not the same thing as [EMPTY]. */ + ERROR, +} + +/** State of the trending-tags surface. */ +data class TrendingTagsUiState( + /** + * The trailing window the counts were requested for. The response never says + * which period it covers, so this — the window the app *asked* for — is the + * only honest source for the surface's wording. + */ + val window: TrendingWindow = TrendingWindow.WEEK, + val tags: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, +) { + /** + * The single place that decides what renders, so "empty" and "failed" can + * never collapse into the same blank strip. + * + * Tags outrank both: a refresh that fails (or is still running) leaves the + * doors that already work on screen rather than replacing them with a banner. + */ + val status: TrendingTagsStatus + get() = when { + tags.isNotEmpty() -> TrendingTagsStatus.TAGS + errorMessage != null -> TrendingTagsStatus.ERROR + isLoading -> TrendingTagsStatus.LOADING + else -> TrendingTagsStatus.EMPTY + } + + /** e.g. "Trending this week" — derived from the window that was requested. */ + val title: String get() = "Trending ${window.label}" +} + +/** + * Loads `GET /api/tags/trending` for the feed's trending rail. + * + * Deliberately separate from `MessagesFeedViewModel`: the rail has its own + * lifecycle (it survives a feed refresh, fails on its own, and retries on its + * own), and folding four more fields into the feed's state would make a failed + * trending lookup look like a failed feed. + */ +@HiltViewModel +class TrendingTagsViewModel @Inject constructor( + private val repository: MessagesRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(TrendingTagsUiState(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + /** (Re)loads the trending tags; also the Retry action of the error state. */ + fun refresh() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + when (val result = repository.trendingTags(window = _uiState.value.window)) { + is ApiResult.Success -> _uiState.update { + it.copy(tags = result.data, isLoading = false, errorMessage = null) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TrendingTagsTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TrendingTagsTest.kt new file mode 100644 index 0000000..d85bbc3 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TrendingTagsTest.kt @@ -0,0 +1,143 @@ +package com.interlinedlist.android.feature.messages.data + +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.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore +import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.interlinedlist.android.feature.messages.domain.TrendingWindow +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Trending tags (`GET /api/tags/trending`). + * + * The window is a **request** parameter the response never echoes back, and the + * server silently falls back to `week` for any value it does not recognise — so + * the request itself is the only place the app's "this week" wording can be kept + * honest. These tests pin the parameters on the wire, the mapping, and the + * failure path the surface renders as its error state. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TrendingTagsTest { + + private val dispatcher = StandardTestDispatcher() + + // Mirrors the production Json (see core:network NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private lateinit var server: MockWebServer + private lateinit var api: MessagesApi + private lateinit var userApi: InterlinedListApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(MessagesApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultMessagesRepository( + api = api, + userApi = userApi, + viewingPreferenceStore = ViewingPreferenceStore(userApi, json), + messageDao = FakeMessageDao(), + sessionStore = fakeSessionStore("me"), + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueue(code: Int, body: String) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun `asks for the window and limit it will label the surface with`() = runTest(dispatcher) { + enqueue(200, """{ "tags": [] }""") + + repository().trendingTags() + + val request = server.takeRequest() + assertThat(request.path).startsWith("/api/tags/trending?") + // Sent explicitly, never left to the server default: the UI says "this + // week", so the request must be the one that makes that true. + assertThat(request.requestUrl?.queryParameter("window")).isEqualTo(TrendingWindow.WEEK.wire) + assertThat(request.requestUrl?.queryParameter("limit")) + .isEqualTo(MessagesRepository.TRENDING_TAG_LIMIT.toString()) + } + + @Test + fun `a different window is sent as the API's documented wire value`() = runTest(dispatcher) { + enqueue(200, """{ "tags": [] }""") + + repository().trendingTags(window = TrendingWindow.MONTH) + + assertThat(server.takeRequest().requestUrl?.queryParameter("window")).isEqualTo("month") + } + + @Test + fun `maps the live payload in the server's order`() = runTest(dispatcher) { + enqueue( + 200, + """ + { + "tags": [ + { "tag": "Lego", "count": 2, "lastUsedAt": "2026-09-12T20:40:05.777Z" }, + { "tag": "life is short, o brave girl", "count": 1, + "lastUsedAt": "2026-09-11T03:44:52.334Z" } + ] + } + """.trimIndent(), + ) + + val result = repository().trendingTags() + + val tags = (result as ApiResult.Success).data + assertThat(tags.map { it.tag }) + .containsExactly("Lego", "life is short, o brave girl").inOrder() + assertThat(tags.first().count).isEqualTo(2) + assertThat(tags.last().lastUsedAt).isEqualTo("2026-09-11T03:44:52.334Z") + } + + @Test + fun `a quiet instance reports no trending tags, not a failure`() = runTest(dispatcher) { + enqueue(200, """{ "tags": [] }""") + + val result = repository().trendingTags() + + assertThat((result as ApiResult.Success).data).isEmpty() + } + + @Test + fun `a server failure surfaces as an error, never as an empty list`() = runTest(dispatcher) { + enqueue(500, """{ "error": "boom", "code": "server_error" }""") + + val result = repository().trendingTags() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Server::class.java) + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TrendingTagsResponseTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TrendingTagsResponseTest.kt new file mode 100644 index 0000000..6a4e87a --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/TrendingTagsResponseTest.kt @@ -0,0 +1,93 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Test + +/** + * `GET /api/tags/trending` answers with a bare `{ "tags": [ … ] }` — no `data` + * envelope, no pagination, and (verified live) **no window metadata**: the + * trailing window is something the caller *asks* for, never something the + * response reports back. These tests pin that shape and the defensive parsing + * around it, because tags are free-form user text: real ones contain spaces, + * commas and mixed case. + */ +class TrendingTagsResponseTest { + + // Mirrors NetworkModule's configuration. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private fun decode(body: String) = json.decodeFromString(TrendingTagsResponse.serializer(), body) + + @Test + fun `reads the live payload, keeping the server's order and its odd tags`() { + val response = decode( + """ + { + "tags": [ + { "tag": "Lego", "count": 2, "lastUsedAt": "2026-09-12T20:40:05.777Z" }, + { "tag": "nuclear god", "count": 2, "lastUsedAt": "2026-09-11T03:00:14.845Z" }, + { "tag": "life is short, o brave girl", "count": 1, + "lastUsedAt": "2026-09-11T03:44:52.334Z" } + ] + } + """.trimIndent(), + ) + + // Spaces, a comma and mixed case all survive verbatim: the tag is the key + // the tag feed queries by, so any normalisation here would break the link. + assertThat(response.toDomain().map { it.tag }) + .containsExactly("Lego", "nuclear god", "life is short, o brave girl") + .inOrder() + assertThat(response.toDomain().first().count).isEqualTo(2) + assertThat(response.toDomain().first().lastUsedAt).isEqualTo("2026-09-12T20:40:05.777Z") + } + + @Test + fun `a row missing or nulling fields still parses`() { + val response = decode( + """ + { + "tags": [ + { "tag": "quiet" }, + { "tag": "nulled", "count": null, "lastUsedAt": null } + ] + } + """.trimIndent(), + ) + + val tags = response.toDomain() + assertThat(tags.map { it.tag }).containsExactly("quiet", "nulled").inOrder() + assertThat(tags.map { it.count }).containsExactly(0, 0) + assertThat(tags.map { it.lastUsedAt }).containsExactly(null, null) + } + + @Test + fun `rows without a usable tag are dropped rather than rendered blank`() { + val response = decode( + """{ "tags": [ { "count": 9 }, { "tag": " ", "count": 4 }, { "tag": "real" } ] }""", + ) + + assertThat(response.toDomain().map { it.tag }).containsExactly("real") + } + + @Test + fun `unknown keys the API may add later are ignored`() { + val response = decode( + """ + { + "window": "week", + "tags": [ { "tag": "lists", "count": 6, "score": 0.42 } ] + } + """.trimIndent(), + ) + + assertThat(response.toDomain().map { it.tag }).containsExactly("lists") + } + + @Test + fun `an empty or absent tag list decodes to no tags`() { + assertThat(decode("""{ "tags": [] }""").toDomain()).isEmpty() + assertThat(decode("{}").toDomain()).isEmpty() + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt index e32d58f..f4beaaa 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt @@ -13,6 +13,8 @@ import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.PushedMessage import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow @@ -66,6 +68,10 @@ class FakeMessagesRepository : MessagesRepository { /** Per-query canned autocomplete answers; anything else falls back below. */ val autocompleteResponses = mutableMapOf>>() var autocompleteResult: ApiResult> = ApiResult.Success(emptyList()) + /** What [trendingTags] answers with. */ + var trendingTagsResult: ApiResult> = ApiResult.Success(emptyList()) + /** Every window [trendingTags] was asked for, in order. */ + val trendingWindows = mutableListOf() /** The account's saved feed preference, as read from `GET /api/user`. */ var viewingPreferenceResult: ApiResult = ApiResult.Success(ViewingPreference.ALL) /** What the `PATCH /api/user/update` of the preference answers with. */ @@ -343,6 +349,14 @@ class FakeMessagesRepository : MessagesRepository { return autocompleteResponses[query] ?: autocompleteResult } + override suspend fun trendingTags( + window: TrendingWindow, + limit: Int, + ): ApiResult> { + trendingWindows += window + return trendingTagsResult + } + override suspend fun search(query: String): ApiResult> = searchResult } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagDescriptionTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagDescriptionTest.kt new file mode 100644 index 0000000..6e082e6 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagDescriptionTest.kt @@ -0,0 +1,40 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.messages.domain.TrendingTag +import org.junit.Test +import java.time.Instant + +/** + * The chip's spoken label. `lastUsedAt` is the only recency the payload actually + * reports (there is no window metadata on the response), so this is where it is + * used — and it has to survive rows that arrive without it. + */ +class TrendingTagDescriptionTest { + + private val now = Instant.parse("2026-09-16T18:00:00Z") + + @Test + fun `reads the tag, its count and when it was last used`() { + val description = trendingTagDescription( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-14T18:00:00Z"), + now, + ) + + assertThat(description).isEqualTo("Lego, 2 messages, last used 2d") + } + + @Test + fun `a single message is not pluralised`() { + val description = trendingTagDescription(TrendingTag("lists", count = 1), now) + + assertThat(description).isEqualTo("lists, 1 message") + } + + @Test + fun `a row missing its count and timestamp still reads as the tag`() { + val description = trendingTagDescription(TrendingTag("life is short, o brave girl"), now) + + assertThat(description).isEqualTo("life is short, o brave girl") + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModelTest.kt new file mode 100644 index 0000000..5f7fb9c --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/trending/TrendingTagsViewModelTest.kt @@ -0,0 +1,149 @@ +package com.interlinedlist.android.feature.messages.ui.trending + +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.messages.domain.TrendingTag +import com.interlinedlist.android.feature.messages.domain.TrendingWindow +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +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 +import java.net.URLDecoder + +/** + * The trending-tags surface. + * + * Its whole job is to be a set of doors: every state it can be in has to look + * deliberate (a quiet instance has no trending tags, and that is not a bug), and + * the tag behind each door has to reach the tag feed byte-for-byte — trending + * tags are free-form labels with spaces and commas in them. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TrendingTagsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + /** A real tag from the live site: spaces *and* a comma, in one label. */ + private val awkwardTag = "life is short, o brave girl" + + private fun repo(result: ApiResult>) = + FakeMessagesRepository().apply { trendingTagsResult = result } + + @Test + fun `loads the trending tags on open, in the server's order`() = runTest(dispatcher) { + val repo = repo( + ApiResult.Success( + listOf( + TrendingTag("Lego", count = 2, lastUsedAt = "2026-09-12T20:40:05.777Z"), + TrendingTag(awkwardTag, count = 1, lastUsedAt = "2026-09-11T03:44:52.334Z"), + ), + ), + ) + val vm = TrendingTagsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.status).isEqualTo(TrendingTagsStatus.TAGS) + // The server ranks them; the rail never re-sorts or re-labels. + assertThat(state.tags.map { it.tag }) + .containsExactly("Lego", awkwardTag).inOrder() + } + } + + @Test + fun `starts in a loading state rather than claiming there is nothing`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Success(emptyList()))) + + vm.uiState.test { + assertThat(awaitItem().status).isEqualTo(TrendingTagsStatus.LOADING) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `an empty answer is an empty state, not a blank`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Success(emptyList()))) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + // A new or quiet instance legitimately has no trending tags: the + // surface must say so instead of rendering nothing at all. + assertThat(state.status).isEqualTo(TrendingTagsStatus.EMPTY) + assertThat(state.errorMessage).isNull() + } + } + + @Test + fun `a failure is an error state, distinct from empty`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Failure(AppError.Network("offline")))) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.status).isEqualTo(TrendingTagsStatus.ERROR) + assertThat(state.status).isNotEqualTo(TrendingTagsStatus.EMPTY) + assertThat(state.errorMessage).isNotNull() + assertThat(state.tags).isEmpty() + } + } + + @Test + fun `retry asks again and recovers`() = runTest(dispatcher) { + val repo = repo(ApiResult.Failure(AppError.Network("offline"))) + val vm = TrendingTagsViewModel(repo) + advanceUntilIdle() + + repo.trendingTagsResult = ApiResult.Success(listOf(TrendingTag("lists", count = 6))) + vm.refresh() + advanceUntilIdle() + + vm.uiState.test { + val state = expectMostRecentItem() + assertThat(state.status).isEqualTo(TrendingTagsStatus.TAGS) + assertThat(state.errorMessage).isNull() + } + assertThat(repo.trendingWindows).hasSize(2) + } + + @Test + fun `asks for the window it labels the surface with`() = runTest(dispatcher) { + val repo = repo(ApiResult.Success(emptyList())) + val vm = TrendingTagsViewModel(repo) + advanceUntilIdle() + + // The response carries no window metadata, so the request is the only + // thing that makes this wording true. + assertThat(repo.trendingWindows).containsExactly(TrendingWindow.WEEK) + assertThat(vm.uiState.value.window).isEqualTo(TrendingWindow.WEEK) + assertThat(vm.uiState.value.title).isEqualTo("Trending this week") + } + + @Test + fun `a tapped trending tag routes to that tag's feed, byte-for-byte`() = runTest(dispatcher) { + val vm = TrendingTagsViewModel(repo(ApiResult.Success(listOf(TrendingTag(awkwardTag))))) + advanceUntilIdle() + + val tapped = vm.uiState.value.tags.single().tag + val route = MessagesDestinations.tagFeedRoute(tapped) + + assertThat(route).isEqualTo("messages/tag/life%20is%20short%2C%20o%20brave%20girl") + // What the nav argument hands the tag feed is the tag the API gave us. + val decoded = URLDecoder.decode(route.removePrefix("messages/tag/"), Charsets.UTF_8.name()) + assertThat(decoded).isEqualTo(awkwardTag) + } +}