Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
50fa39c
test(catalog): define channel control mutation contract
MrFr3di Aug 31, 2026
f9f590b
feat(catalog): expose channel control mutations
MrFr3di Aug 31, 2026
dde1c64
feat(catalog): scaffold channel control repository methods
MrFr3di Aug 31, 2026
2469bf9
test(catalog): specify channel control mutation behavior
MrFr3di Aug 31, 2026
e251df9
test(catalog): keep channel control fixtures on active revision API
MrFr3di Aug 31, 2026
c01e2f0
feat(catalog): persist channel control overlays
MrFr3di Aug 31, 2026
cc2dae9
feat(catalog): wire channel control mutations to room
MrFr3di Aug 31, 2026
6dbd918
test(tv): update channel preferences fake
MrFr3di Aug 31, 2026
8ca1037
test(catalog): define channel management projection contract
MrFr3di Aug 31, 2026
612befb
test(catalog): require active variant in management row
MrFr3di Aug 31, 2026
e0d7578
feat(catalog): define channel management projection types
MrFr3di Aug 31, 2026
8866213
test(database): define channel management projection behavior
MrFr3di Aug 31, 2026
708f24f
feat(database): add channel management paging
MrFr3di Aug 31, 2026
26b9045
test(tv): update channel browse fake for management API
MrFr3di Aug 31, 2026
5b30305
test(channels): update browse fake for management API
MrFr3di Aug 31, 2026
7803f3f
test(database): harden channel management projection
MrFr3di Aug 31, 2026
1f0453b
test(tv): support channel management projection in accessibility fixture
MrFr3di Aug 31, 2026
960dff5
test(tv): support channel management projection in home fixtures
MrFr3di Aug 31, 2026
995921d
test(database): verify management paging overlay invalidation
MrFr3di Aug 31, 2026
9680c6f
test(channels): define manage channels view model contract
MrFr3di Aug 31, 2026
e95e7d6
test(channels): pin manage channel filters
MrFr3di Aug 31, 2026
40d734b
feat(channels): add manage channels view model
MrFr3di Aug 31, 2026
47acace
feat(channels): add manage channels TV surface
MrFr3di Aug 31, 2026
f8a702b
feat(channels): expose manage channels entry
MrFr3di Aug 31, 2026
e130743
feat(tv): add manage channels destination
MrFr3di Aug 31, 2026
bda528d
feat(tv): wire manage channels navigation
MrFr3di Aug 31, 2026
04121ae
fix(channels): compile manage channels entry
MrFr3di Aug 31, 2026
8c7fb38
test(channels): cover manage mutation dismissal contract
MrFr3di Aug 31, 2026
33df0fc
fix(channels): restore focus after manage mutations
MrFr3di Aug 31, 2026
9cc9b3e
test(channels): define quick action contract
MrFr3di Aug 31, 2026
077304e
feat(channels): add quick action controller
MrFr3di Aug 31, 2026
c2c14d1
feat(channels): wire long-OK quick actions
MrFr3di Aug 31, 2026
7d427da
test(channels): cover long-OK quick action journey
MrFr3di Aug 31, 2026
09eb348
feat(channels): enable quick actions from channels entry
MrFr3di Aug 31, 2026
1e99064
feat(navigation): provide channel preferences to quick actions
MrFr3di Aug 31, 2026
d458d7c
test(channels): use effective quick action assertions
MrFr3di Aug 31, 2026
285e760
test(channels): fix compose test assertion imports
MrFr3di Aug 31, 2026
aa84529
test(channels): synchronize hide quick action journey
MrFr3di Aug 31, 2026
5ef51d8
test(channels): await quick action dismissal
MrFr3di Aug 31, 2026
96df5d5
test(channel-control): add manage channels device acceptance
MrFr3di Aug 31, 2026
57f0200
test(channel-control): cover browse projection acceptance
MrFr3di Aug 31, 2026
235023c
test(channels): sync deterministic compose coroutine rule
MrFr3di Aug 31, 2026
fb0a599
test(channel-control): use deterministic compose rule in acceptance
MrFr3di Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ private object EmptyCatalogFixture : app.muxtv.catalog.ChannelBrowseRepository {
override fun pages(query: app.muxtv.catalog.ChannelBrowseQuery):
kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelBrowseItem>> =
kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty())

override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery):
kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelManagementItem>> =
kotlinx.coroutines.flow.flowOf(androidx.paging.PagingData.empty())
}

private fun SemanticsNodeInteraction.press(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
package app.muxtv

import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performSemanticsAction
import app.muxtv.catalog.ChannelFavoriteMutationResult
import app.muxtv.catalog.ChannelPreferenceMutationResult
import app.muxtv.catalog.ChannelPreferencesRepository
import app.muxtv.catalog.ChannelQuery
import app.muxtv.catalog.PlayableChannel
import app.muxtv.catalog.PlayableChannelSummary
import app.muxtv.catalog.PlaybackAccessMutationResult
import app.muxtv.catalog.PlaybackCatalog
import app.muxtv.catalog.PlaybackVariantResolution
import app.muxtv.catalog.RecentChannel
import app.muxtv.catalog.RecentChannelWriteResult
import app.muxtv.catalog.RecentChannelsQuery
import app.muxtv.catalog.RecentChannelsRepository
import app.muxtv.designsystem.MuxTvTheme
import app.muxtv.feature.channels.ChannelsRoute
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.junit.Rule
import org.junit.Test

class ChannelQuickActionsJourneyTest {
@get:Rule
val composeRule = createComposeRule()

@Test
fun normalOkStillOpensPlaybackWhenQuickActionsAreEnabled() {
val catalog = QuickActionPlaybackCatalog()
var openedChannelId: String? = null

composeRule.setContent {
MuxTvTheme {
channelsRoute(
catalog = catalog,
preferences = HidingPreferencesRepository(catalog),
onOpenChannel = { openedChannelId = it },
)
}
}
composeRule.waitForIdle()

composeRule.onNodeWithTag("channel-row-0")
.assertIsFocused()
.pressEnter()
composeRule.waitForIdle()

assertThat(openedChannelId).isEqualTo("channel-a")
composeRule.onNodeWithTag("channel-quick-actions").assertDoesNotExist()
}

@Test
fun longClickOpensQuickActionsWithoutStartingPlayback() {
val catalog = QuickActionPlaybackCatalog()
var openedChannelId: String? = null

composeRule.setContent {
MuxTvTheme {
channelsRoute(
catalog = catalog,
preferences = HidingPreferencesRepository(catalog),
onOpenChannel = { openedChannelId = it },
)
}
}
composeRule.waitForIdle()

composeRule.onNodeWithTag("channel-row-0")
.performSemanticsAction(SemanticsActions.OnLongClick)
composeRule.waitForIdle()

assertThat(openedChannelId).isNull()
composeRule.onNodeWithTag("channel-quick-actions").assertExists()
composeRule.onNodeWithText("В избранное").assertIsFocused()
}

@Test
fun hidingFocusedChannelReturnsFocusToNearestPreviousRow() {
val catalog = QuickActionPlaybackCatalog()
val preferences = HidingPreferencesRepository(catalog)

composeRule.setContent {
MuxTvTheme {
channelsRoute(
catalog = catalog,
preferences = preferences,
)
}
}
composeRule.waitForIdle()

composeRule.onNodeWithTag("channel-row-0").performKeyInput {
keyDown(Key.DirectionDown)
keyUp(Key.DirectionDown)
}
composeRule.onNodeWithTag("channel-row-1").assertIsFocused()
composeRule.onNodeWithTag("channel-row-1")
.performSemanticsAction(SemanticsActions.OnLongClick)
composeRule.waitForIdle()
composeRule.onNodeWithText("Скрыть").performClick()
composeRule.waitForIdle()

composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText(
"Долгое OK · быстрые действия",
substring = false,
).fetchSemanticsNodes().isEmpty()
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Второй", substring = false).fetchSemanticsNodes().isEmpty()
}
composeRule.waitForIdle()

composeRule.onNodeWithText("Первый", substring = false).assertIsFocused()
}

@androidx.compose.runtime.Composable
private fun channelsRoute(
catalog: QuickActionPlaybackCatalog,
preferences: ChannelPreferencesRepository,
onOpenChannel: (String) -> Unit = {},
) {
ChannelsRoute(
channelBrowseRepository = TestChannelBrowseRepository(
playbackCatalog = catalog,
recentChannelsRepository = EmptyRecentChannelsRepository,
epgGuideRepository = NoGuideEpgGuideRepository,
),
epgGuideRepository = NoGuideEpgGuideRepository,
playbackSessionStateSource = NoPlaybackSessionStateSource,
profileId = PROFILE_ID,
onOpenChannel = onOpenChannel,
channelPreferencesRepository = preferences,
)
}

private companion object {
const val PROFILE_ID = "profile-main"
}
}

private class HidingPreferencesRepository(
private val catalog: QuickActionPlaybackCatalog,
) : ChannelPreferencesRepository {
override suspend fun setFavorite(
profileId: String,
channelId: String,
isFavorite: Boolean,
): ChannelFavoriteMutationResult = ChannelFavoriteMutationResult.Applied

override suspend fun setHidden(
profileId: String,
channelId: String,
isHidden: Boolean,
): ChannelPreferenceMutationResult {
if (isHidden) catalog.remove(channelId)
return ChannelPreferenceMutationResult.Applied
}

override suspend fun setCustomName(
profileId: String,
channelId: String,
customName: String?,
): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied

override suspend fun setChannelNumber(
profileId: String,
channelId: String,
channelNumber: Int?,
): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied

override suspend fun resetCustomization(
profileId: String,
channelId: String,
): ChannelPreferenceMutationResult = ChannelPreferenceMutationResult.Applied
}

private class QuickActionPlaybackCatalog : PlaybackCatalog {
private val channels = MutableStateFlow(
listOf(
quickActionChannel("channel-a", "Первый"),
quickActionChannel("channel-b", "Второй"),
quickActionChannel("channel-c", "Третий"),
),
)

fun remove(channelId: String) {
channels.value = channels.value.filterNot { it.channelId == channelId }
}

override fun observeChannels(query: ChannelQuery): Flow<List<PlayableChannelSummary>> =
channels.map { rows ->
rows.filter { row -> !query.favoritesOnly || row.isFavorite }
.take(query.limit)
}

override suspend fun getChannel(
profileId: String,
channelId: String,
): PlayableChannel? = null

override suspend fun resolveVariant(
profileId: String,
channelId: String,
preferredVariantId: String?,
): PlaybackVariantResolution? = null

override suspend fun approveInsecurePlayback(
profileId: String,
channelId: String,
variantId: String,
): PlaybackAccessMutationResult = PlaybackAccessMutationResult.NotFound

override suspend fun revokeInsecurePlayback(
profileId: String,
channelId: String,
variantId: String,
): PlaybackAccessMutationResult = PlaybackAccessMutationResult.NotFound
}

private object EmptyRecentChannelsRepository : RecentChannelsRepository {
override fun observeRecent(query: RecentChannelsQuery): Flow<List<RecentChannel>> = flowOf(emptyList())

override suspend fun recordSuccessfulPlayback(
profileId: String,
channelId: String,
successfulAtEpochMillis: Long,
): RecentChannelWriteResult = RecentChannelWriteResult.Applied
}

private fun quickActionChannel(
id: String,
name: String,
): PlayableChannelSummary = PlayableChannelSummary(
channelId = id,
displayName = name,
logoUrl = null,
groupTitle = "Тест",
channelNumber = null,
isFavorite = false,
variantCount = 1,
)

private fun androidx.compose.ui.test.SemanticsNodeInteraction.pressEnter() = performKeyInput {
keyDown(Key.Enter)
keyUp(Key.Enter)
}
8 changes: 8 additions & 0 deletions app/tv/src/androidTest/kotlin/app/muxtv/HomeJourneyTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ class HomeJourneyTest {
val EmptyBrowseRepository = object : ChannelBrowseRepository {
override fun pages(query: ChannelBrowseQuery): Flow<androidx.paging.PagingData<ChannelBrowseItem>> =
flowOf(androidx.paging.PagingData.empty())

override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery):
Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelManagementItem>> =
flowOf(androidx.paging.PagingData.empty())
}

val FavoritesBrowseRepository = object : ChannelBrowseRepository {
Expand All @@ -257,6 +261,10 @@ class HomeJourneyTest {
),
),
)

override fun managementPages(query: app.muxtv.catalog.ChannelManagementQuery):
Flow<androidx.paging.PagingData<app.muxtv.catalog.ChannelManagementItem>> =
flowOf(androidx.paging.PagingData.empty())
}
}
}
Expand Down
Loading
Loading