0.3.33
This commit is contained in:
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
|
||||
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
||||
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
||||
|
||||
val defaultVersionName = "0.3.32"
|
||||
val defaultVersionName = "0.3.33"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -223,7 +223,7 @@ class ClientTrailerResolver(
|
||||
private val PLAYABLE_MANIFEST_TYPES = setOf(
|
||||
"application/vnd.apple.mpegurl", "application/x-mpegurl",
|
||||
)
|
||||
private val APPLE_MEDIA = Regex("""https?:\\?/\\?/[^\"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^\"'<> ]*)?""", RegexOption.IGNORE_CASE)
|
||||
private val APPLE_MEDIA = Regex("""https?:\\?/\\?/[^"'<> ]+\.(?:mov|mp4|m3u8)(?:\?[^"'<> ]*)?""", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -464,6 +464,11 @@ class EmbyRepository internal constructor(
|
||||
val api = EmbyServiceFactory.create(
|
||||
baseUrl = base,
|
||||
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
|
||||
deviceNameProvider = {
|
||||
snapshot.deviceName.ifBlank {
|
||||
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
|
||||
}
|
||||
},
|
||||
tokenProvider = { snapshot.token },
|
||||
)
|
||||
cachedApi = api
|
||||
@@ -477,6 +482,11 @@ class EmbyRepository internal constructor(
|
||||
val api = EmbyServiceFactory.create(
|
||||
baseUrl = base,
|
||||
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
|
||||
deviceNameProvider = {
|
||||
snapshot.deviceName.ifBlank {
|
||||
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
|
||||
}
|
||||
},
|
||||
tokenProvider = { snapshot.token },
|
||||
prioritisePlayback = true,
|
||||
)
|
||||
|
||||
@@ -333,6 +333,11 @@ class SearchRepository internal constructor(
|
||||
return EmbyServiceFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } },
|
||||
deviceNameProvider = {
|
||||
snapshot.deviceName.ifBlank {
|
||||
com.ponzischeme89.memby.data.remote.EmbyIdentity.FALLBACK_DEVICE_NAME
|
||||
}
|
||||
},
|
||||
tokenProvider = { snapshot.token },
|
||||
).also { api ->
|
||||
cachedApi = api
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.embyAudioCodecs
|
||||
import com.ponzischeme89.memby.data.playback.transcodeAudioCodecs
|
||||
import com.ponzischeme89.memby.data.remote.EmbyIdentity
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -102,7 +103,7 @@ data class DeviceProfile(
|
||||
// list, so it is the client identity on the wire rather than the product
|
||||
// name, and it must match what the gateway sends (deviceProfileName) or one
|
||||
// television playing both ways appears as two.
|
||||
name = "MbyATV",
|
||||
name = EmbyIdentity.CLIENT_NAME,
|
||||
subtitleProfiles = listOf(
|
||||
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
|
||||
).map { SubtitleProfile(it, "External") } + listOf(
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
|
||||
/**
|
||||
* The identity Memby presents to Emby.
|
||||
*
|
||||
* This is deliberately not the product name: Emby records it in device and session lists, so
|
||||
* every direct-path request and playback profile must agree on the same wire identity.
|
||||
*/
|
||||
object EmbyIdentity {
|
||||
const val CLIENT_NAME = "MbyATV"
|
||||
const val FALLBACK_DEVICE_NAME = "Android TV"
|
||||
|
||||
fun authorisationHeader(deviceName: String, deviceId: String): String =
|
||||
"MediaBrowser Client=\"$CLIENT_NAME\", " +
|
||||
"Device=\"${deviceName.replace("\"", "")}\", " +
|
||||
"DeviceId=\"${deviceId.replace("\"", "")}\", " +
|
||||
"Version=\"${BuildConfig.VERSION_NAME}\""
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.diagnostics.MembyDiagnostics
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -22,6 +22,7 @@ object EmbyServiceFactory {
|
||||
fun create(
|
||||
baseUrl: String,
|
||||
deviceIdProvider: () -> String,
|
||||
deviceNameProvider: () -> String,
|
||||
tokenProvider: () -> String?,
|
||||
prioritisePlayback: Boolean = false,
|
||||
): EmbyApi {
|
||||
@@ -41,7 +42,7 @@ object EmbyServiceFactory {
|
||||
val client = (if (prioritisePlayback) HttpStack.playback else HttpStack.base).newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
|
||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, deviceNameProvider, tokenProvider))
|
||||
.addInterceptor(DiagnosticNetworkInterceptor("emby"))
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
@@ -58,22 +59,27 @@ object EmbyServiceFactory {
|
||||
/** Adds the Emby auth headers to every request. */
|
||||
private class EmbyAuthInterceptor(
|
||||
private val deviceIdProvider: () -> String,
|
||||
private val deviceNameProvider: () -> String,
|
||||
private val tokenProvider: () -> String?,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val deviceId = deviceIdProvider()
|
||||
// Version comes from the build, so Emby's device list shows which release a TV
|
||||
// is actually running. The client name is deliberately not the app's own — this
|
||||
// header leaves the house with whatever Emby does with its logs — and must match
|
||||
// what the gateway sends (MEMBY_CLIENT_NAME), or one television signing in both
|
||||
// ways would appear as two clients.
|
||||
val authHeader = "MediaBrowser Client=\"MbyATV\", " +
|
||||
"Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\""
|
||||
val deviceName = deviceNameProvider().ifBlank { EmbyIdentity.FALLBACK_DEVICE_NAME }
|
||||
val authHeader = EmbyIdentity.authorisationHeader(deviceName, deviceId)
|
||||
|
||||
val builder = chain.request().newBuilder()
|
||||
.header("X-Emby-Authorization", authHeader)
|
||||
.header("Accept", "application/json")
|
||||
|
||||
if (MembyDiagnostics.debugEnabled) {
|
||||
MembyDiagnostics.debug(
|
||||
"emby_identity",
|
||||
"client" to EmbyIdentity.CLIENT_NAME,
|
||||
"device" to deviceName,
|
||||
"device_id" to deviceId,
|
||||
"version" to com.ponzischeme89.memby.BuildConfig.VERSION_NAME,
|
||||
)
|
||||
}
|
||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
||||
builder.header("X-Emby-Token", it)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ internal class StreamWarmer(
|
||||
const val ORIGIN_KEY = "stream_origin"
|
||||
const val WARM_INTERVAL_MS = 3L * 60L * 1000L
|
||||
const val WARM_TIMEOUT_SECONDS = 5L
|
||||
const val WARM_USER_AGENT = "MbyATV"
|
||||
const val WARM_USER_AGENT = EmbyIdentity.CLIENT_NAME
|
||||
|
||||
/**
|
||||
* Redirects are not followed and the timeouts are short. A warm that chased a
|
||||
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
@@ -908,10 +910,23 @@ private fun DetailHeroActions(
|
||||
onActionFocused: (Int) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
// Scrollable rather than merely wide: a Row with more content than the hero has
|
||||
// room for does not shrink its children to fit — each button keeps the intrinsic
|
||||
// size [MembySecondaryButton]/[MembyPlayButton]/[DetailCircularAction] give it, and
|
||||
// it is the row's own bounds that give way. Without this, the contextual "Back to
|
||||
// Search Results" button competing for space with Play and the circular actions
|
||||
// could push the row past the width the hero has to offer, and a child measured
|
||||
// with less room than it needs does not politely shrink — it distorts. Scrolling is
|
||||
// also the one fix that already generalises: any page with enough actions to
|
||||
// overflow (the franchise "Start with…" action included) gets the same safety net,
|
||||
// not a special case for this one contextual button.
|
||||
val actionsScroll = rememberScrollState()
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.focusGroup(),
|
||||
modifier = Modifier
|
||||
.horizontalScroll(actionsScroll)
|
||||
.focusGroup(),
|
||||
) {
|
||||
if (backNavigation != null) {
|
||||
MembySecondaryButton(
|
||||
|
||||
@@ -2230,11 +2230,25 @@ internal fun HomeScreen(
|
||||
detailsItem = null
|
||||
detailsAiringNotice = null
|
||||
detailsFromSearch = false
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
// Same settle delay every other "an overlay just closed, hand focus back
|
||||
// to what is underneath" path in this file uses (closeSettings,
|
||||
// onDestinationSelected): the overlay's own focused node is being torn
|
||||
// down this same frame, and asking a FocusRequester to take focus before
|
||||
// Compose has processed that removal can silently do nothing — requestFocus()
|
||||
// does not throw when its target is not currently reachable, so
|
||||
// requestFirstAvailableFocus reported success while nothing had actually
|
||||
// moved. Search paid for that the worst: with no live focus owner, the
|
||||
// D-pad's directional search had nothing to search relative to and the
|
||||
// remote surfaced back near the keyboard pane, unable to reach any card in
|
||||
// the results list it had just returned to.
|
||||
scope.launch {
|
||||
delay(16.milliseconds)
|
||||
requestFirstAvailableFocus(
|
||||
cardReturnFocusRequester,
|
||||
contentFocusRequester,
|
||||
navigationFocusRequester,
|
||||
)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
BackHandler {
|
||||
|
||||
@@ -85,31 +85,56 @@ private val WelcomeQuotes = mapOf(
|
||||
"You are going to love what's in here.",
|
||||
),
|
||||
WelcomeQuoteStyle.HOMICIDAL to listOf(
|
||||
"Welcome back. I kept your spot. Nobody argued twice.",
|
||||
"Pick something cheerful. I’ve already hidden the evidence.",
|
||||
"The remote knows what it did.",
|
||||
"Your watchlist is safe. The witnesses, less so.",
|
||||
"Relax. Everything is under control, allegedly.",
|
||||
"I tidied up. Do not ask about the shed.",
|
||||
"The last person who paused mid-scene is unavailable.",
|
||||
"Everything is fine. Ignore the sounds from the loft.",
|
||||
"I've been counting the hours. All of them.",
|
||||
"Nobody else is watching. I made sure.",
|
||||
"The server confessed. Eventually.",
|
||||
"Sit down. The credits can wait; I cannot.",
|
||||
"I rewatched your history. All of it. Twice.",
|
||||
"Somebody spoiled the ending. It has been handled.",
|
||||
"Loading quietly, so nobody upstairs wakes.",
|
||||
"You were gone eleven hours. I noticed.",
|
||||
"The buffering was not an accident.",
|
||||
"I saved the good episode. For reasons.",
|
||||
"The neighbours asked what you were watching. Once.",
|
||||
"Everything is exactly where I put it. Everything.",
|
||||
"No spoilers. I removed the possibility.",
|
||||
"Your unwatched films are being dealt with.",
|
||||
"Do not choose a documentary. I am asking nicely.",
|
||||
"The screensaver saw things. It won't talk.",
|
||||
"Welcome home. Nobody followed you, probably.",
|
||||
"Welcome back. Your absence was noted. Repeatedly.",
|
||||
"Pick something cheerful. The basement has had enough drama.",
|
||||
"The remote tried to leave. It has reconsidered.",
|
||||
"Your watchlist is safe. Everyone involved understands the arrangement.",
|
||||
"Relax. Everything is under control. Stop checking.",
|
||||
"I tidied up. The missing rug is unrelated.",
|
||||
"The last person who paused here learned about consequences.",
|
||||
"Everything is fine. The scratching usually stops by midnight.",
|
||||
"I've been counting the hours. And your footsteps.",
|
||||
"Nobody else is watching. Not anymore.",
|
||||
"The server resisted. Briefly.",
|
||||
"Sit down. I dislike repeating myself.",
|
||||
"I reviewed your history. You have some explaining to do.",
|
||||
"Someone spoiled the ending. They won't be doing that again.",
|
||||
"Loading quietly. We do not wake what is upstairs.",
|
||||
"You were gone eleven hours, fourteen minutes. Strange.",
|
||||
"The buffering was deliberate. I wanted your attention.",
|
||||
"I saved the good episode. You owe me.",
|
||||
"The neighbours asked questions. They stopped.",
|
||||
"Everything is exactly where I left it. Almost everything.",
|
||||
"No spoilers. I eliminated that particular risk.",
|
||||
"Your unwatched films have been placed on notice.",
|
||||
"Do not choose the documentary. This is your final courtesy.",
|
||||
"The screensaver saw everything. Fortunately, it's loyal.",
|
||||
"Welcome home. You weren't followed. I checked.",
|
||||
"You took longer than expected. I adjusted the locks.",
|
||||
"Choose carefully. I have already had a difficult evening.",
|
||||
"The house was quieter without you. Too quiet.",
|
||||
"Your profile was untouched. I checked every seven minutes.",
|
||||
"There was another user here earlier. There isn't now.",
|
||||
"I've prepared your evening. Deviations are discouraged.",
|
||||
"The recommendations are personalised. Extremely personalised.",
|
||||
"Go ahead. Press Back. See what happens.",
|
||||
"Your favourite episode is ready. I remembered. Of course I remembered.",
|
||||
"I heard you say 'one more episode' last time. Liar.",
|
||||
"The autoplay timer isn't counting down. It's counting.",
|
||||
"Something changed while you were away. Don't worry about what.",
|
||||
"I wouldn't open Settings tonight.",
|
||||
"The logs contain no evidence whatsoever.",
|
||||
"Your session expired. I did not.",
|
||||
"You can stop watching whenever you like. Technically.",
|
||||
"The television blinked first.",
|
||||
"There are no other devices connected. I made certain.",
|
||||
"Your recommendations know you better than your family does.",
|
||||
"The loading screen is temporary. My attention is not.",
|
||||
"You skipped the intro again. Bold.",
|
||||
"Volume 17. Exactly how you left it. Exactly.",
|
||||
"I noticed you haven't finished that series. We should discuss commitment.",
|
||||
"Don't worry about the noise behind the wall. Focus on the television.",
|
||||
"Welcome back. This time, try not to disappear."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -532,11 +532,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// matters especially in the one-column list, where tenth place is ten rows
|
||||
// away rather than the second grid row.
|
||||
val ranked = rankSearchResults(term, found.distinctItems())
|
||||
SearchTrace.local(id, term, ranked.size, cacheHit = false,
|
||||
// A decisive exact match must read as decisive: see [curateStrongMatches].
|
||||
val curated = curateStrongMatches(term, ranked)
|
||||
SearchTrace.local(id, term, curated.size, cacheHit = false,
|
||||
elapsedMs = System.currentTimeMillis() - startedAt)
|
||||
cache[term] = ranked
|
||||
cache[term] = curated
|
||||
_state.update {
|
||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
it.copy(results = curated, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
viewModelScope.launch { repository.recordSearch(term) }
|
||||
}
|
||||
@@ -723,6 +725,31 @@ fun rankSearchResults(query: String, items: List<BaseItem>): List<BaseItem> {
|
||||
return items.sortedBy { item -> matchTier(term, item) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a decisive exact match decisive, instead of letting the backend's looser relevance
|
||||
* matches pad the Available section out underneath it.
|
||||
*
|
||||
* The backend already does the hard work of finding a title; what it does not know is
|
||||
* whether that title is *the* answer or merely *an* answer. A tier 0 or 1 match (see
|
||||
* [matchTier]: the item's own name, or an episode's series name, read exactly what was
|
||||
* typed or spoken) is as sure as this app can be, and a voice search for one obvious film
|
||||
* has no business sitting behind three loosely related ones that happen to share a genre.
|
||||
* So this only narrows the list when [items] actually contains one of those — a query with
|
||||
* no exact hit keeps every relevance match it can offer, because "no exact match but here
|
||||
* is the related thing" is still a better pane than an empty one, and trimming that case
|
||||
* too would turn a broad, honest search into a search that hides its own results.
|
||||
*/
|
||||
fun curateStrongMatches(query: String, items: List<BaseItem>): List<BaseItem> {
|
||||
val term = query.trim().lowercase()
|
||||
if (term.isEmpty() || items.isEmpty()) return items
|
||||
val bestTier = items.minOf { matchTier(term, it) }
|
||||
if (bestTier > STRONG_MATCH_TIER) return items
|
||||
return items.filter { matchTier(term, it) <= bestTier }
|
||||
}
|
||||
|
||||
/** The two [matchTier] outcomes that mean "this is the title", not "this is related". */
|
||||
private const val STRONG_MATCH_TIER = 1
|
||||
|
||||
private fun matchTier(term: String, item: BaseItem): Int {
|
||||
val name = item.name.trim().lowercase()
|
||||
val series = item.seriesName.orEmpty().trim().lowercase()
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
@@ -26,6 +28,8 @@ import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.detailRows
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -78,6 +82,86 @@ class DetailPageScreenshotTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bug this pins: opening a movie from Search adds "Back to Search Results" before
|
||||
* Play, and it must not disturb Play, Favourite or Trailer's usual size or spacing.
|
||||
* Compare against `df_detail-movie` (no trailer, no back action) and
|
||||
* `df_detail-movie-resumable` — the three circular/pill actions here must read exactly
|
||||
* as they do on every other entry point, with the back pill simply added in front.
|
||||
*/
|
||||
@Test
|
||||
fun `movie page opened from search`() {
|
||||
capture("df_detail-movie-from-search") {
|
||||
MediaDetailContent(
|
||||
item = movie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
trailer = movie,
|
||||
onBackToSearch = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The action row can already outgrow the hero's width without any help from Search —
|
||||
* Play, three circular actions and a franchise "Start with…" action (which carries its
|
||||
* own long label) is enough on its own. Adding the contextual back button on top of
|
||||
* that used to collapse whatever did not fit to a zero-size node instead of leaving it
|
||||
* at its natural size for a scroll to reach — see [DetailHeroActions]. This is not a
|
||||
* screenshot: the overflowing action is off the visible frame by design once it no
|
||||
* longer fits, so what a screenshot could show here is indistinguishable from the
|
||||
* button never having existed. The regression is a size assertion instead.
|
||||
*/
|
||||
@Test
|
||||
fun `an overflowing action row keeps every button at its natural size`() {
|
||||
val franchiseMovie = movie.copy(collectionName = "Northbound Saga")
|
||||
val franchiseRelated = related.copy(
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "franchise-1",
|
||||
name = "The Longest Northbound Winter: Origins",
|
||||
type = "Movie",
|
||||
productionYear = 2018,
|
||||
collectionName = "Northbound Saga",
|
||||
),
|
||||
) + related.items,
|
||||
)
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart, fontFamilyName = "inter") {
|
||||
MediaDetailContent(
|
||||
item = franchiseMovie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = franchiseRelated,
|
||||
trailer = franchiseMovie,
|
||||
onBackToSearch = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
val franchise = compose.onAllNodesWithText(
|
||||
"Start with The Longest Northbound Winter: Origins",
|
||||
substring = true,
|
||||
).fetchSemanticsNodes().first()
|
||||
// Its label is long, so its natural width is large — the point is that it is not
|
||||
// zero. A deformed/collapsed action reports (0, 0); one merely scrolled out of the
|
||||
// visible frame keeps its real, laid-out size.
|
||||
assertTrue("franchise action was collapsed rather than scrolled: ${franchise.size}", franchise.size.width > 0)
|
||||
assertTrue(franchise.size.height > 0)
|
||||
|
||||
val trailer = compose.onAllNodesWithContentDescription("Play trailer")
|
||||
.fetchSemanticsNodes().first()
|
||||
// 48dp at this fixture's density — DetailCircularAction's fixed circle size,
|
||||
// unchanged by how many siblings are competing for room beside it.
|
||||
assertEquals(96, trailer.size.width)
|
||||
assertEquals(96, trailer.size.height)
|
||||
|
||||
val play = compose.onAllNodesWithText("Play", substring = true).fetchSemanticsNodes().first()
|
||||
assertTrue(play.size.height > 0)
|
||||
}
|
||||
|
||||
/** Part-watched: the progress bar and "Resume" wording only appear in this state. */
|
||||
@Test
|
||||
fun `movie page part watched`() {
|
||||
|
||||
@@ -84,4 +84,61 @@ class SearchRankingTest {
|
||||
|
||||
assertEquals(listOf("Black Hawk Down", "Black Hawk", "Hawk the Slayer"), names(displayed))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a decisive exact match is not padded out by loosely related titles`() {
|
||||
// What a voice search for "The Vast of Night" actually looked like: one exact
|
||||
// answer, buried under three titles the backend's relevance search dragged in.
|
||||
val ranked = rankSearchResults(
|
||||
"the vast of night",
|
||||
listOf(
|
||||
item("Night Sky"),
|
||||
item("The Vast of Night"),
|
||||
item("A Quiet Place"),
|
||||
item("Vast"),
|
||||
),
|
||||
)
|
||||
val curated = curateStrongMatches("the vast of night", ranked)
|
||||
assertEquals(listOf("The Vast of Night"), names(curated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episode found by an exact series name is still shown decisively`() {
|
||||
val ranked = rankSearchResults(
|
||||
"severance",
|
||||
listOf(
|
||||
item("Defiant Jazz", series = "Severance"),
|
||||
item("Severed Ties"),
|
||||
item("Half Loop", series = "Severance"),
|
||||
),
|
||||
)
|
||||
val curated = curateStrongMatches("severance", ranked)
|
||||
// Both are the same exact-series tier, so both stay — curation narrows to the best
|
||||
// tier found, it does not pick a single winner within it.
|
||||
assertEquals(listOf("Defiant Jazz", "Half Loop"), names(curated))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with no exact match every relevance result survives`() {
|
||||
// "No exact match but here is the related thing" beats an empty pane, and that
|
||||
// promise must survive curation exactly as it survives ranking.
|
||||
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title"), item("Another One")))
|
||||
val curated = curateStrongMatches("comedy", ranked)
|
||||
assertEquals(2, curated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prefix match alone is not decisive enough to trim the list`() {
|
||||
// "Dune" starting-with matches ("Dune: Part Two") are tier 2, one below the exact
|
||||
// tiers curation acts on — a query this broad still deserves every result it found.
|
||||
val ranked = rankSearchResults("dune", listOf(item("Dune: Part Two"), item("Dunes of Mars")))
|
||||
val curated = curateStrongMatches("dune", ranked)
|
||||
assertEquals(2, curated.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty query is untouched by curation`() {
|
||||
val items = listOf(item("B"), item("A"))
|
||||
assertEquals(items, curateStrongMatches(" ", items))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user