Files
castarr/tests/unit/DispatcharrJsonTest.kt
be-nj d269618ebf
All checks were successful
Build TV app / build (push) Successful in 53s
Work through the 1.0 milestone: focus, backend tests, remote, login code
#20 Each settings column is a focus group now. The middle column is shorter
than the left, so "down" past its last row used to search wider and land
back in the left column — "Erweitert" was only reachable by going right,
down, left.

#22 The whole backend path had no coverage, which is why an afternoon
against the real instance found three bugs. Parsing moved out of
DispatcharrRepository into DispatcharrJson and is covered by twelve tests
built from the shapes that instance actually returns: pagination, channels
without a uuid or hidden from output, effective vs plain names and logos,
favourites without the key, inactive output profiles, and all three EPG
envelopes the fork has shipped. Android's stub org.json throws in unit
tests, so the real implementation is on the test classpath.
What no test can reach is written down in tests/dispatcharr-checkliste.md.

#24 The remote's channel tab was only ever read in source. Driven through
the DevTools protocol against the real backend it lists 503 channels with
logos and EPG, the search filters, the club chip switches view, and a star
survives the round trip to the backend and back — set and unset. It also
showed the phone still spelling out "BL: Bayer Leverkusen - VfB Stuttgart,
tipico Topspiel der Woche, 16. Spieltag" where the TV shortens to the
fixture; the payload shortens now too.

#23 The TV says "Der alte Code war abgelaufen — neuer Code 566071643"
instead of swapping the digits without a word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:33:29 +02:00

146 lines
6.1 KiB
Kotlin

package dev.castarr.tv.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Shapes taken from the household's own Dispatcharr fork. The backend path
* had no coverage at all, and the first run against a real instance found
* three bugs — these are the shapes that produced them.
*/
class DispatcharrJsonTest {
private fun streamUrl(uuid: String) = "https://tv.example/proxy/ts/stream/$uuid"
private fun logoUrl(id: Int) = "https://tv.example/api/channels/logos/$id/cache/"
private val groups = mapOf(1 to "Free TV / HD+", 2 to "Sky Sport")
@Test
fun `a paginated page yields its channels`() {
val body = """
{"count": 2, "next": null, "results": [
{"id": 12, "uuid": "aaa", "name": "Das Erste", "effective_name": "Das Erste HD",
"channel_group_id": 1, "effective_logo_id": 7, "effective_tvg_id": "ard.de"},
{"id": 13, "uuid": "bbb", "name": "ZDF HD", "channel_group_id": 1, "logo_id": 8}
]}
""".trimIndent()
val channels = DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl)
assertEquals(2, channels.size)
val first = channels.first()
assertEquals("Das Erste HD", first.name)
assertEquals("Free TV / HD+", first.group)
assertEquals("ard.de", first.tvgId)
assertEquals(12, first.backendId)
assertEquals("https://tv.example/proxy/ts/stream/aaa", first.url)
assertEquals("https://tv.example/api/channels/logos/7/cache/", first.logo)
// Without an effective name the plain one is used, same for the logo.
assertEquals("ZDF HD", channels[1].name)
assertEquals("https://tv.example/api/channels/logos/8/cache/", channels[1].logo)
}
/** The proxy resolves by uuid; an integer id gives a 404. */
@Test
fun `a channel without uuid is dropped`() {
val body = """{"results": [{"id": 1, "uuid": "", "name": "Kaputt"}]}"""
assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty())
}
@Test
fun `a channel hidden from output is dropped`() {
val body = """
{"results": [{"id": 1, "uuid": "aaa", "name": "Intern", "hidden_from_output": true}]}
""".trimIndent()
assertTrue(DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).isEmpty())
}
@Test
fun `an unknown group leaves the group empty instead of failing`() {
val body = """{"results": [{"id": 1, "uuid": "aaa", "name": "X", "channel_group_id": 99}]}"""
assertEquals("", DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).first().group)
}
@Test
fun `a bare array works like a paginated page`() {
val body = """[{"id": 5, "uuid": "ccc", "name": "Bar"}]"""
assertEquals(1, DispatcharrJson.parseChannels(body, groups, ::streamUrl, ::logoUrl).size)
}
@Test
fun `the next page is followed only while there is one`() {
assertEquals(
"https://tv.example/api/channels/channels/?page=2",
DispatcharrJson.nextPage("""{"next": "https://tv.example/api/channels/channels/?page=2"}"""),
)
assertNull(DispatcharrJson.nextPage("""{"next": null}"""))
assertNull(DispatcharrJson.nextPage("""{"next": ""}"""))
assertNull(DispatcharrJson.nextPage("""[{"id": 1}]"""))
}
@Test
fun `groups come back as id to name`() {
val body = """{"results": [{"id": 1, "name": "Free TV / HD+"}, {"id": 2, "name": "Sky Sport"}]}"""
assertEquals(groups, DispatcharrJson.parseGroups(body))
}
@Test
fun `favourites are the ids under channels`() {
assertEquals(setOf(46, 118), DispatcharrJson.parseFavorites("""{"channels": [46, 118]}"""))
assertEquals(emptySet<Int>(), DispatcharrJson.parseFavorites("""{"channels": []}"""))
// A user who never starred anything gets an envelope without the key.
assertEquals(emptySet<Int>(), DispatcharrJson.parseFavorites("""{}"""))
}
@Test
fun `inactive output profiles are not offered`() {
val body = """
{"results": [
{"name": "raw", "is_active": true},
{"name": "alt", "is_active": false},
{"name": "default"}
]}
""".trimIndent()
assertEquals(listOf("raw", "default"), DispatcharrJson.parseProfiles(body))
}
@Test
fun `the epg grid is keyed by tvg id and sorted`() {
val body = """
{"data": [
{"tvg_id": "ard.de", "title": "Tagesschau",
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"},
{"tvg_id": "ard.de", "title": "Sportschau",
"start_time": "2026-08-26T17:00:00Z", "end_time": "2026-08-26T18:00:00Z"}
]}
""".trimIndent()
val grid = DispatcharrJson.parseEpgGrid(body)
assertEquals(listOf("Sportschau", "Tagesschau"), grid.getValue("ard.de").map { it.title })
}
@Test
fun `grid entries without a usable time or id are skipped`() {
val body = """
{"data": [
{"tvg_id": "", "title": "Ohne Sender",
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"},
{"tvg_id": "zdf.de", "title": "Kaputte Zeit",
"start_time": "kein Datum", "end_time": "2026-08-26T18:15:00Z"},
{"tvg_id": "zdf.de", "title": "Ende vor Start",
"start_time": "2026-08-26T18:15:00Z", "end_time": "2026-08-26T18:00:00Z"}
]}
""".trimIndent()
assertTrue(DispatcharrJson.parseEpgGrid(body).isEmpty())
}
/** The fork has shipped "data", "results" and a bare array over time. */
@Test
fun `all three grid envelopes are accepted`() {
val entry = """{"tvg_id": "ard.de", "title": "X",
"start_time": "2026-08-26T18:00:00Z", "end_time": "2026-08-26T18:15:00Z"}"""
listOf("""{"data": [$entry]}""", """{"results": [$entry]}""", """[$entry]""").forEach { body ->
assertEquals(1, DispatcharrJson.parseEpgGrid(body).getValue("ard.de").size)
}
}
}