0.2.58 - Requests module
This commit is contained in:
@@ -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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user