Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:55 +12:00
parent 70914400b4
commit a265636139
73 changed files with 9593 additions and 0 deletions
@@ -0,0 +1,48 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class CastMetadataTest {
@Test
fun `cast contains actors and preserves Emby order`() {
val item = BaseItem(
id = "movie",
people = listOf(
EmbyPerson(id = "director", name = "Director", type = "Director"),
EmbyPerson(id = "lead", name = "Lead", role = "Detective", type = "Actor"),
EmbyPerson(id = "support", name = "Support", role = "Doctor", type = "Actor"),
),
)
assertEquals(listOf("lead", "support"), item.cast.map(EmbyPerson::id))
assertEquals("Detective", item.cast.first().role)
}
@Test
fun `decodes emby people and portrait tags`() {
val item = Json { ignoreUnknownKeys = true }.decodeFromString<BaseItem>(
"""
{
"Id":"movie",
"Name":"Example",
"Type":"Movie",
"People":[{
"Id":"person-1",
"Name":"Alex Actor",
"Role":"Morgan",
"Type":"Actor",
"PrimaryImageTag":"portrait-tag"
}]
}
""".trimIndent(),
)
assertEquals("person-1", item.cast.single().id)
assertEquals("portrait-tag", item.cast.single().primaryImageTag)
}
}
@@ -0,0 +1,89 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayAlert
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ServiceAlertTest {
private val json = Json { ignoreUnknownKeys = true }
private fun alert(id: String, title: String = "Northbound", message: String = "S02E04 aired") =
GatewayAlert(id = id, kind = "sonarr-aired", title = title, message = message)
@Test
fun `picks the first alert this tv has not seen`() {
val chosen = firstUnseenAlert(
listOf(alert("a"), alert("b"), alert("c")),
seen = setOf("a", "b"),
)
assertEquals("c", chosen?.id)
}
@Test
fun `an alert already shown never comes back`() {
assertNull(firstUnseenAlert(listOf(alert("a")), seen = setOf("a")))
}
@Test
fun `incomplete alerts are dropped rather than drawn empty`() {
val alerts = listOf(
alert(id = ""),
alert(id = "b", title = " "),
alert(id = "c", message = ""),
alert(id = "d"),
)
assertEquals("d", firstUnseenAlert(alerts, seen = emptySet())?.id)
}
@Test
fun `an alert still on offer keeps waiting for a screen`() {
assertFalse(
pendingAlertExpired(
pendingId = "a",
shownId = null,
alerts = listOf(alert("a"), alert("b")),
),
)
}
@Test
fun `an unshown alert is given up on once the gateway stops offering it`() {
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = listOf(alert("b"))))
assertTrue(pendingAlertExpired(pendingId = "a", shownId = null, alerts = emptyList()))
}
@Test
fun `an alert already on screen is left to its own timer`() {
assertFalse(pendingAlertExpired(pendingId = "a", shownId = "a", alerts = emptyList()))
}
@Test
fun `status decodes without alerts for a gateway that predates them`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"message":""}""",
)
assertTrue(status.alerts.isEmpty())
}
@Test
fun `status decodes the alert payload the gateway sends`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""
{"maintenance":false,"message":"","alerts":[{
"id":"sonarr:7:42:aired","kind":"sonarr-aired","title":"Northbound",
"message":"S02E04 aired at 9:00 PM and will be in Emby soon.",
"itemId":"sonarr:7:42","imageTag":"sonarr","airedAt":"2026-07-27T21:00:00+12:00"
}]}
""".trimIndent(),
)
val alert = status.alerts.single()
assertEquals("sonarr:7:42:aired", alert.id)
assertEquals("sonarr:7:42", alert.itemId)
assertEquals("sonarr", alert.imageTag)
}
}
@@ -0,0 +1,83 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SubtitleSupportTest {
@Test
fun `normalizes relative delivery urls and preserves selection metadata`() {
val tracks = subtitleTracks(
streams = listOf(
MediaStream(
index = 3,
type = "Subtitle",
codec = "srt",
displayTitle = "English SDH",
language = "eng",
isDefault = true,
isForced = true,
isTextSubtitleStream = true,
deliveryUrl = "/Videos/a/Subtitles/3/Stream.srt?x=1",
),
),
serverUrl = "https://emby.example",
token = "a b",
itemId = "item",
mediaSourceId = "source",
)
assertEquals(1, tracks.size)
assertEquals(
"https://emby.example/Videos/a/Subtitles/3/Stream.srt?x=1&api_key=a+b",
tracks.single().url,
)
assertEquals("application/x-subrip", tracks.single().mimeType)
assertEquals("eng", tracks.single().language)
assertTrue(tracks.single().isDefault)
assertTrue(tracks.single().isForced)
assertTrue(tracks.single().isHearingImpaired)
}
@Test
fun `keeps supported text subtitles and rejects bitmap formats`() {
assertEquals("text/vtt", subtitleMimeType("webvtt"))
assertEquals("text/x-ssa", subtitleMimeType(null, "https://host/subtitle.ass?token=x"))
assertEquals(null, subtitleMimeType("pgssub"))
assertEquals(null, subtitleMimeType("dvdsub"))
}
@Test
fun `does not duplicate authentication already present`() {
assertEquals(
"https://host/sub.srt?api_key=existing",
authenticatedDeliveryUrl("https://other", "https://host/sub.srt?api_key=existing", "new"),
)
}
@Test
fun `image subtitles remain visible as encode choices without an overlay url`() {
val track = subtitleTracks(
streams = listOf(
MediaStream(
index = 7,
type = "Subtitle",
codec = "pgssub",
language = "eng",
isForced = true,
deliveryMethod = "Encode",
),
),
serverUrl = "https://emby.example",
token = "token",
itemId = "item",
mediaSourceId = "source",
).single()
assertEquals("Encode", track.deliveryMethod)
assertEquals("", track.url)
assertEquals("", track.mimeType)
assertTrue(track.isForced)
}
}
@@ -0,0 +1,101 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringTodayTagsTest {
@Test
fun `tags matching recommended series from today's Sonarr schedule`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing today",
kind = "schedule",
items = listOf(
BaseItem(
id = "sonarr:7:42",
name = "The Bear",
type = "MembySonarrEpisode",
membySource = "sonarr",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
kind = "recommended",
items = listOf(
BaseItem(id = "series-1", name = "The Bear", type = "Series"),
BaseItem(id = "series-2", name = "Severance", type = "Series"),
),
),
),
)
val recommendations = home.withAiringTodayTags().rows.last().items
assertTrue(recommendations[0].membyAiringToday)
assertFalse(recommendations[1].membyAiringToday)
}
@Test
fun `show matching ignores punctuation spacing and case`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing today",
items = listOf(
BaseItem(
id = "sonarr:1:2",
name = "Marvel's DAREDEVIL",
type = "MembySonarrEpisode",
membySource = "sonarr",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
items = listOf(
BaseItem(id = "series-1", name = "Marvels Daredevil", type = "Series"),
),
),
),
)
assertTrue(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
}
@Test
fun `does not tag movies with a matching title`() {
val home = HomeSnapshot(
rows = listOf(
HomeRow(
id = "sonarr-airing-today",
title = "Shows airing today",
items = listOf(
BaseItem(
id = "sonarr:1:2",
name = "Fargo",
type = "MembySonarrEpisode",
membySource = "sonarr",
),
),
),
HomeRow(
id = "recommended",
title = "Recommended",
items = listOf(BaseItem(id = "movie-1", name = "Fargo", type = "Movie")),
),
),
)
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
}
}
@@ -0,0 +1,46 @@
package com.ponzischeme89.memby.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileViewModelStoreOwnerTest {
private val factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
ProbeViewModel() as T
}
@Test
fun `profiles have isolated view models and clearing cancels the old one`() {
val mattOwner = ProfileViewModelStoreOwner()
val familyOwner = ProfileViewModelStoreOwner()
val matt = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
val mattAgain = ViewModelProvider(mattOwner, factory)[ProbeViewModel::class.java]
val family = ViewModelProvider(familyOwner, factory)[ProbeViewModel::class.java]
assertSame(matt, mattAgain)
assertNotSame(matt, family)
assertFalse(matt.cleared)
assertFalse(family.cleared)
mattOwner.clear()
assertTrue(matt.cleared)
assertFalse(family.cleared)
}
private class ProbeViewModel : ViewModel() {
var cleared = false
private set
override fun onCleared() {
cleared = true
}
}
}
@@ -0,0 +1,63 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Test
class SeriesDetailsTest {
private fun episode(
id: String,
season: Int,
number: Int,
played: Boolean = false,
) = BaseItem(
id = id,
name = "Episode $number",
type = "Episode",
parentIndexNumber = season,
indexNumber = number,
userData = UserItemData(played = played),
)
@Test
fun `seasons are distinct and ordered with specials first`() {
val episodes = listOf(
episode("s2e1", 2, 1),
episode("s0e1", 0, 1),
episode("s1e1", 1, 1),
episode("s2e2", 2, 2),
)
assertEquals(listOf(0, 1, 2), availableSeasons(episodes))
}
@Test
fun `season list is ordered by episode number`() {
val episodes = listOf(
episode("e3", 1, 3),
episode("e1", 1, 1),
episode("e2", 1, 2),
)
assertEquals(listOf("e1", "e2", "e3"), episodesForSeason(episodes, 1).map(BaseItem::id))
}
@Test
fun `default season contains the first unwatched episode`() {
val episodes = listOf(
episode("s1e1", 1, 1, played = true),
episode("s1e2", 1, 2, played = true),
episode("s2e1", 2, 1),
)
assertEquals(2, defaultSeason(episodes))
}
@Test
fun `detail tabs default safely to episodes`() {
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
}
}
@@ -0,0 +1,132 @@
package com.ponzischeme89.memby.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.ServiceAlert
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 the aired banner to PNGs under `build/screenshots/`, so its layout can be
* looked at without deploying to a TV.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ServiceAlertBannerScreenshotTest"
* ```
*
* This is the **only** part of `app/src/test` that touches Android — rendering a
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
*
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
* own background, flush to the top edge exactly as `MainActivity` places it.
*
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
* are null because there is no network here — which makes these the check on the fallback
* tile too.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ServiceAlertBannerScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `awaiting download`() {
capture(
"alert-banner-awaiting",
ServiceAlert(
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
@Test
fun `downloading now`() {
capture(
"alert-banner-downloading",
ServiceAlert(
id = "sonarr:8:43:aired",
title = "The Long Dark",
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
posterUrl = null,
),
)
}
/** Both lines ellipsise at one line; this is the case that proves it. */
@Test
fun `long title and message`() {
capture(
"alert-banner-long-text",
ServiceAlert(
id = "sonarr:9:88:aired",
title = "A Very Long Programme Title That Will Not Fit In One Line",
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
"And Then Some More Happens After That aired at 10:30 PM and is " +
"downloading now.",
posterUrl = null,
),
)
}
/** Shortest plausible content: the bar keeps its height and the text stays put. */
@Test
fun `short title and message`() {
capture(
"alert-banner-short-text",
ServiceAlert(
id = "sonarr:3:12:aired",
title = "Dune",
message = "S01E01 aired and will be in Emby soon.",
posterUrl = null,
),
)
}
/**
* The countdown ring is full at t=0, which tells you nothing about whether it
* empties. Holding the clock and stepping it forward captures it mid-sweep.
*/
@Test
fun `countdown part way through`() {
compose.mainClock.autoAdvance = false
compose.setContent {
AlertBannerOnHomeBackground(
ServiceAlert(
id = "sonarr:7:42:aired",
title = "Northbound",
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
posterUrl = null,
),
)
}
compose.mainClock.advanceTimeBy(6_500L)
compose.onRoot().captureRoboImage("build/screenshots/alert-banner-countdown.png")
}
private fun capture(name: String, alert: ServiceAlert) {
compose.setContent { AlertBannerOnHomeBackground(alert) }
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
}
@Composable
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
PreviewSurface(alignment = Alignment.TopCenter) {
AlertBanner(alert)
}
}
}
@@ -0,0 +1,30 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PictureSizingTest {
@Test
fun `auto fills classic four by three video`() {
assertTrue(shouldZoomVideo("auto", 640, 480, 1f))
}
@Test
fun `auto accounts for anamorphic pixel aspect ratio`() {
assertTrue(shouldZoomVideo("auto", 720, 480, 8f / 9f))
}
@Test
fun `auto preserves modern widescreen video`() {
assertFalse(shouldZoomVideo("auto", 1920, 1080, 1f))
assertFalse(shouldZoomVideo("auto", 1920, 800, 1f))
}
@Test
fun `manual modes are deterministic`() {
assertFalse(shouldZoomVideo("original", 640, 480, 1f))
assertTrue(shouldZoomVideo("fill", 1920, 1080, 1f))
}
}
@@ -0,0 +1,37 @@
package com.ponzischeme89.memby.ui.player
import androidx.media3.common.PlaybackException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PlaybackRecoveryTest {
@Test
fun networkFailuresAreSafeToRetry() {
val failure = describePlaybackFailure(
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
)
assertEquals("Connection interrupted", failure.title)
assertTrue(failure.canAutoRetry)
}
@Test
fun decoderFailuresRequireViewerAction() {
val failure = describePlaybackFailure(
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
)
assertEquals("Video format not supported", failure.title)
assertFalse(failure.canAutoRetry)
}
@Test
fun automaticRetriesAreBoundedAndBackOff() {
assertEquals(1_000L, automaticRetryDelayMs(1))
assertEquals(3_000L, automaticRetryDelayMs(2))
assertNull(automaticRetryDelayMs(3))
}
}
@@ -0,0 +1,14 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Test
class PlayerTimingTest {
@Test
fun formatsRemainingTimeForViewerFriendlyDisplay() {
assertEquals("Less than a minute remaining", PlayerActivity.formatRemaining(30_000L))
assertEquals("42 min remaining", PlayerActivity.formatRemaining(42L * 60_000L))
assertEquals("1 hr remaining", PlayerActivity.formatRemaining(60L * 60_000L))
assertEquals("2 hr 5 min remaining", PlayerActivity.formatRemaining(125L * 60_000L))
}
}
@@ -0,0 +1,38 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.test.core.app.ApplicationProvider
import com.ponzischeme89.memby.R
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PrerollLayoutTest {
@Test
fun `player preroll and its background inflate`() {
val context = ApplicationProvider.getApplicationContext<Context>()
assertNotNull(context.getDrawable(R.drawable.player_preroll_video_background))
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_preroll,
FrameLayout(context),
false,
),
)
assertNotNull(
LayoutInflater.from(context).inflate(
R.layout.player_cast_overlay,
FrameLayout(context),
false,
),
)
}
}
@@ -0,0 +1,19 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PrerollSequenceTest {
@Test
fun `handoff waits only for the five second gate`() {
assertFalse(prerollCanHandOff(true, false, true))
assertTrue(prerollCanHandOff(true, true, true))
}
@Test
fun `handoff never starts playback in background`() {
assertFalse(prerollCanHandOff(true, true, false))
assertFalse(prerollCanHandOff(false, true, true))
}
}
@@ -0,0 +1,75 @@
package com.ponzischeme89.memby.ui.search
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SearchRankingTest {
private fun item(name: String, series: String? = null, id: String = name) =
BaseItem(id = id, name = name, seriesName = series)
private fun names(items: List<BaseItem>) = items.map { it.name }
@Test
fun `an exact title beats a title that merely starts with the query`() {
val ranked = rankSearchResults(
"dune",
listOf(item("Dune: Part Two"), item("Dunes of Mars"), item("Dune")),
)
assertEquals("Dune", ranked.first().name)
}
@Test
fun `titles starting with the query come before mid-word matches`() {
val ranked = rankSearchResults(
"star",
listOf(item("Costar"), item("Lone Star"), item("Stargate")),
)
// Prefix first, then the word-boundary match, then the buried one.
assertEquals(listOf("Stargate", "Lone Star", "Costar"), names(ranked))
}
@Test
fun `an episode is found by its series name when its own title does not match`() {
val ranked = rankSearchResults(
"severance",
listOf(item("Defiant Jazz", series = "Severance"), item("Severed Ties")),
)
assertEquals("Defiant Jazz", ranked.first().name)
}
@Test
fun `weaker matches are kept rather than dropped`() {
// A genre or overview match the backend returned: last, but still there, because
// "no exact match but here is something related" beats an empty pane.
val ranked = rankSearchResults("comedy", listOf(item("Some Unrelated Title")))
assertEquals(1, ranked.size)
}
@Test
fun `ranking is stable so the backend's own relevance order survives within a tier`() {
val backendOrder = listOf(item("Alien 3"), item("Alien"), item("Aliens"))
val ranked = rankSearchResults("alien", backendOrder)
// "Alien" is the exact match and is lifted; the other two keep the order the
// server chose rather than being re-sorted alphabetically.
assertEquals(listOf("Alien", "Alien 3", "Aliens"), names(ranked))
}
@Test
fun `an empty query leaves the list untouched`() {
val items = listOf(item("B"), item("A"))
assertEquals(items, rankSearchResults(" ", items))
}
@Test
fun `searching starts at two characters`() {
assertFalse(shouldSearch(""))
assertFalse(shouldSearch("a"))
assertFalse(shouldSearch(" a "))
assertTrue(shouldSearch("ab"))
assertTrue(shouldSearch(" the wire "))
}
}
@@ -0,0 +1,68 @@
package com.ponzischeme89.memby.ui.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.ui.PreviewSurface
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
/**
* Captures the real stateless Settings panel at TV size. The fixture deliberately mixes
* enabled and disabled values so the screenshot proves state is legible without focus;
* the first row also owns focus to prove the white focus ring and green active state are
* visually distinct.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SettingsSheetScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `active inactive and focused controls`() {
compose.setContent { SettingsPreviewFixture(overlay = false) }
compose.onRoot().captureRoboImage("build/screenshots/settings-panel.png")
}
@Test
fun `home overlay width`() {
compose.setContent { SettingsPreviewFixture(overlay = true) }
compose.onRoot().captureRoboImage("build/screenshots/settings-overlay.png")
}
@Composable
private fun SettingsPreviewFixture(overlay: Boolean) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() }
PreviewSurface(alignment = if (overlay) Alignment.CenterEnd else Alignment.Center) {
SettingsPanelContent(
state = SettingsPanelState(
showLogo = true,
autoPlayNext = false,
ringColor = "52B54B",
homeSections = setOf("continue", "latest"),
cardDensity = "standard",
showCardMetadata = false,
editableServer = true,
baseUrl = "https://mserver.example/releases/latest.json",
installedVersion = "0.1.60",
),
actions = SettingsPanelActions(),
overlay = overlay,
firstFocusRequester = firstFocus,
)
}
}
}