0.2.58 - Requests module

This commit is contained in:
ponzischeme89
2026-08-12 14:13:19 +12:00
parent 64f19aeef2
commit 613f203cf9
27 changed files with 3026 additions and 23 deletions
@@ -0,0 +1,138 @@
package com.ponzischeme89.memby.ui.requests
import com.ponzischeme89.memby.ui.userSwitcherActionCount
import com.ponzischeme89.memby.ui.UserSwitcherDirection
import com.ponzischeme89.memby.ui.userSwitcherNextIndex
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RequestPresentationTest {
@Test
fun `an unknown state is drawn quietly rather than borrowing a colour`() {
// A state invented on the gateway must reach an older television as a neutral chip.
// Giving it a tone would have it claim something this build cannot know.
assertEquals(RequestTone.NEUTRAL, requestStatusTone("approved"))
assertEquals(RequestTone.NEUTRAL, requestStatusTone(""))
assertEquals(RequestTone.NEUTRAL, requestStatusTone(RequestStatus.UNAVAILABLE))
}
@Test
fun `each meaningful state keeps its own tone`() {
assertEquals(RequestTone.POSITIVE, requestStatusTone(RequestStatus.AVAILABLE))
assertEquals(RequestTone.ACTIVE, requestStatusTone(RequestStatus.PROCESSING))
assertEquals(RequestTone.ACTIVE, requestStatusTone(RequestStatus.REQUESTED))
assertEquals(RequestTone.WAITING, requestStatusTone(RequestStatus.PENDING))
}
@Test
fun `the server's wording wins so a new state reads correctly on an old build`() {
assertEquals("Awaiting approval", requestStatusLabel("approval_pending", "Awaiting approval"))
// Only when the gateway sent nothing does the local table answer.
assertEquals("Pending", requestStatusLabel(RequestStatus.PENDING, ""))
assertEquals("Pending", requestStatusLabel(RequestStatus.PENDING, " "))
// And a state with no local wording still says something rather than going blank.
assertEquals("Requested", requestStatusLabel("approval_pending", ""))
}
@Test
fun `only a title nobody has and nobody asked for can be pressed`() {
assertTrue(requestCandidateActionable(RequestStatus.REQUESTABLE))
assertFalse(requestCandidateActionable(RequestStatus.AVAILABLE))
assertFalse(requestCandidateActionable(RequestStatus.PROCESSING))
assertFalse(requestCandidateActionable(RequestStatus.PENDING))
// Pressing an already-made request again would be a second identical ask.
assertFalse(requestCandidateActionable(RequestStatus.REQUESTED))
}
@Test
fun `a gateway that predates the status field keeps the old press-anything behaviour`() {
assertTrue(requestCandidateActionable(""))
}
@Test
fun `states collapse into the three bands somebody actually asks about`() {
assertEquals(RequestGroup.READY, requestGroupFor(RequestStatus.AVAILABLE))
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.PROCESSING))
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.PENDING))
assertEquals(RequestGroup.IN_PROGRESS, requestGroupFor(RequestStatus.REQUESTED))
assertEquals(RequestGroup.CLOSED, requestGroupFor(RequestStatus.UNAVAILABLE))
// An unknown state has nothing left to happen to it as far as this build knows.
assertEquals(RequestGroup.CLOSED, requestGroupFor("approved"))
}
@Test
fun `grouping keeps the server's order and drops empty bands`() {
val requests = listOf(
"b" to RequestStatus.PROCESSING,
"a" to RequestStatus.AVAILABLE,
"c" to RequestStatus.PENDING,
"d" to RequestStatus.AVAILABLE,
)
val groups = groupRequests(requests) { it.second }
// Nothing is closed, so that heading is not drawn over an empty band.
assertEquals(listOf(RequestGroup.READY, RequestGroup.IN_PROGRESS), groups.map { it.first })
// Within a band the server's newest-first order survives: "a" was listed before "d",
// and re-sorting alphabetically or by state would lose the only useful ordering.
assertEquals(listOf("a", "d"), groups[0].second.map { it.first })
assertEquals(listOf("b", "c"), groups[1].second.map { it.first })
}
@Test
fun `the summary counts what arrived rather than the total`() {
assertEquals("Nothing requested yet", requestSummaryLabel(0, 0))
assertEquals("1 request, none ready yet", requestSummaryLabel(1, 0))
assertEquals("4 requests, none ready yet", requestSummaryLabel(4, 0))
assertEquals("1 request, ready to watch", requestSummaryLabel(1, 1))
assertEquals("All 3 ready to watch", requestSummaryLabel(3, 3))
assertEquals("2 of 5 ready to watch", requestSummaryLabel(5, 2))
}
@Test
fun `the lookup threshold is stricter than the library search's`() {
// Every keystroke here reaches Radarr and Sonarr rather than a local index.
assertFalse(shouldLookup("du"))
assertTrue(shouldLookup("dun"))
assertFalse(shouldLookup(" a "))
assertTrue(shouldLookup(" dune "))
}
@Test
fun `the switcher's action count matches the rows actually drawn`() {
assertEquals(2, userSwitcherActionCount(showRequests = false))
assertEquals(3, userSwitcherActionCount(showRequests = true))
}
@Test
fun `the D-pad reaches the last switcher row whether or not Requests is shown`() {
// The bug this guards is the one that makes Manage users unreachable: an action
// count that disagrees with the number of rows caps the D-pad one row short.
val profiles = listOf("p1", "p2")
val withRequests = userSwitcherActionCount(showRequests = true)
var index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withRequests,
)
}
assertEquals(profiles.size + withRequests - 1, index)
val withoutRequests = userSwitcherActionCount(showRequests = false)
index = 0
repeat(10) {
index = userSwitcherNextIndex(
currentIndex = index,
profileCount = profiles.size,
direction = UserSwitcherDirection.DOWN,
actionCount = withoutRequests,
)
}
assertEquals(profiles.size + withoutRequests - 1, index)
}
}
@@ -0,0 +1,241 @@
package com.ponzischeme89.memby.ui.requests
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.GatewayMediaRequestItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders Requests to PNGs under `build/screenshots/requests/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*RequestsScreenshotTest"
* ```
*
* The states worth looking at are the ones a unit test cannot describe: whether the status
* badges are separable at television distance, whether a card carrying no artwork still
* reads (Radarr and Sonarr posters are remote and frequently slow or absent), and whether
* the skeletons look like the page that is about to arrive rather than like a fault.
*
* There is no network here — every URL is blank on purpose, so what these capture is the
* artwork-free fallback, which is the case the calendar's own screenshot test exists for
* too.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class RequestsScreenshotTest {
@get:Rule
val compose = createComposeRule()
/** The ordinary page: every band occupied, so the three headings can be compared. */
@Test
fun `my requests across every state`() {
capture("requests-mine", RequestsUiState(requests = sampleRequests, loadingRequests = false))
}
/** Nothing asked for yet. The empty state has a control, so focus has somewhere to go. */
@Test
fun `nothing requested yet`() {
capture("requests-mine-empty", RequestsUiState(loadingRequests = false))
}
/** The first load. Skeletons rather than a spinner, in the shape of the real cards. */
@Test
fun `loading the first list`() {
capture("requests-mine-loading", RequestsUiState(loadingRequests = true))
}
@Test
fun `the list could not be loaded`() {
capture(
"requests-mine-error",
RequestsUiState(
loadingRequests = false,
requestsError = "Memby could not reach the server. Check the connection and try again.",
),
)
}
/** Search results, with every state a candidate can be in showing at once. */
@Test
fun `search results`() {
capture(
"requests-discover",
RequestsUiState(
tab = RequestsTab.DISCOVER,
loadingRequests = false,
query = "dune",
hasSearched = true,
candidates = sampleCandidates,
),
)
}
/** A request in flight: only the pressed card shows it, the rest of the pane stays live. */
@Test
fun `a request being submitted`() {
capture(
"requests-discover-submitting",
RequestsUiState(
tab = RequestsTab.DISCOVER,
loadingRequests = false,
query = "dune",
hasSearched = true,
candidates = sampleCandidates,
submitting = setOf(candidateKey("movie", 693134)),
),
)
}
/** The keyboard with nothing typed. This is the pane's opening frame on every visit. */
@Test
fun `discover before anything is typed`() {
capture(
"requests-discover-empty",
RequestsUiState(tab = RequestsTab.DISCOVER, loadingRequests = false),
)
}
@Test
fun `a search that found nothing`() {
capture(
"requests-discover-no-matches",
RequestsUiState(
tab = RequestsTab.DISCOVER,
loadingRequests = false,
query = "qqqqq",
hasSearched = true,
),
)
}
/**
* Permission withdrawn while the page was open. Worth a capture because an empty list
* and "you are not allowed to ask" must not look like the same page.
*/
@Test
fun `requests no longer permitted`() {
capture(
"requests-not-allowed",
RequestsUiState(loadingRequests = false, allowed = false),
)
}
/**
* The way in. Captured beside the page because the entry and the permission are one
* feature: this pair is what shows whether the entry is hidden for somebody who may not
* request, rather than dimmed.
*/
@Test
fun `user menu offering requests`() {
capturePicker("requests-user-menu", showRequests = true)
}
@Test
fun `user menu for a viewer who may not request`() {
capturePicker("requests-user-menu-hidden", showRequests = false)
}
private fun capturePicker(name: String, showRequests: Boolean) {
compose.setContent {
MembyTheme {
UserSwitcherOverlay(
profiles = listOf(
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
EmbyProfile("b", "https://memby.example", "t", "u2", "Charlotte"),
),
activeProfileId = "a",
onProfileSelected = {},
onManageProfiles = {},
onDismiss = {},
alertCount = 2,
onOpenAlerts = {},
showRequests = showRequests,
onOpenRequests = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/requests/$name.png")
}
private fun capture(name: String, state: RequestsUiState) {
compose.setContent {
MembyTheme {
RequestsScreen(
state = state,
navigationFocusRequester = FocusRequester(),
contentFocusRequester = FocusRequester(),
onSelectTab = {},
onQueryChanged = {},
onAppendToQuery = {},
onBackspace = {},
onClearQuery = {},
onRequest = {},
onRemove = {},
onOpenItem = {},
onRetry = {},
onExit = {},
// No network in a screenshot test: this is the artwork-free fallback,
// which is the case worth looking at anyway.
posterUrlFor = { null },
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/requests/$name.png")
}
}
private val sampleRequests = listOf(
GatewayMediaRequestItem(
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
requestedAt = "2026-08-02T19:20:00Z", status = RequestStatus.AVAILABLE,
statusLabel = "Available", statusDetail = "Ready to watch now", embyItemId = "emby-1",
),
GatewayMediaRequestItem(
mediaType = "series", foreignId = 121361, title = "Silo", year = 2023,
requestedAt = "2026-08-01T08:05:00Z", status = RequestStatus.PROCESSING,
statusLabel = "Processing", statusDetail = "Searching for a copy",
),
GatewayMediaRequestItem(
mediaType = "movie", foreignId = 533535, title = "The Thursday Murder Club", year = 2026,
requestedAt = "2026-07-28T21:44:00Z", status = RequestStatus.PENDING,
statusLabel = "Pending", statusDetail = "Waiting for release",
),
GatewayMediaRequestItem(
mediaType = "series", foreignId = 94997, title = "A Show Nobody Is Tracking Any More",
year = 2022, requestedAt = "2026-06-11T12:00:00Z", status = RequestStatus.UNAVAILABLE,
statusLabel = "Unavailable", statusDetail = "No longer being tracked",
),
)
private val sampleCandidates = listOf(
GatewayRequestCandidate(
mediaType = "movie", foreignId = 693134, title = "Dune: Part Two", year = 2024,
status = RequestStatus.REQUESTABLE, statusLabel = "Request",
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 438631, title = "Dune", year = 2021,
status = RequestStatus.AVAILABLE, statusLabel = "Available", inLibrary = true,
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 331138, title = "Dune: Prophecy", year = 2024,
status = RequestStatus.REQUESTED, statusLabel = "Requested", mine = true,
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 841598, title = "Dune: Part Three", year = 2027,
status = RequestStatus.PENDING, statusLabel = "Pending", alreadyAdded = true,
),
)