Work through the 1.0 milestone: focus, backend tests, remote, login code
All checks were successful
Build TV app / build (push) Successful in 53s
All checks were successful
Build TV app / build (push) Successful in 53s
#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>
This commit is contained in:
78
tests/dispatcharr-checkliste.md
Normal file
78
tests/dispatcharr-checkliste.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Dispatcharr-Pfad: Ablauf vor einem Release
|
||||
|
||||
Was hier steht, deckt kein Test ab: Anmeldung, Backend-Abruf und alles, was
|
||||
ein echtes Konto braucht. `tests/smoke.sh` hilft dabei nicht — er
|
||||
deinstalliert die App und damit die Anmeldung.
|
||||
|
||||
Dauer: etwa zehn Minuten. Am Emulator oder auf einem echten Fernseher.
|
||||
|
||||
## Vorbereitet
|
||||
|
||||
- Emulator läuft (`tests/helpers/emulator.sh start`)
|
||||
- Aktuelles APK installiert
|
||||
- Jemand ist erreichbar, der die Anmeldung im Identity Provider bestätigen
|
||||
kann — der Gerätecode lebt derzeit 60 Sekunden (siehe Issue #23)
|
||||
|
||||
## 1. Anmelden
|
||||
|
||||
- [ ] App zurücksetzen: `adb shell pm clear dev.castarr.tv`
|
||||
- [ ] App starten — der Willkommensbildschirm zeigt QR, Adresse **und** den
|
||||
vierstelligen Kopplungscode
|
||||
- [ ] Fernbedienung am Handy öffnen, Server eintragen
|
||||
- [ ] Der Fernseher zeigt „Anmeldung am Handy bestätigen" mit Code
|
||||
- [ ] Code bestätigen; der Fernseher wechselt von allein in die Senderliste
|
||||
- [ ] Läuft der Code ab, holt der Fernseher selbstständig einen neuen —
|
||||
er darf **nicht** kommentarlos auf Schritt 1 zurückfallen
|
||||
|
||||
## 2. Senderliste
|
||||
|
||||
- [ ] Die Zahl neben „Alle Sender" entspricht der Senderzahl im Backend
|
||||
- [ ] Sender tragen Logos und Namen aus dem Backend
|
||||
- [ ] Sender mit Programm zeigen es rechts, Sender ohne zeigen ihre Gruppe
|
||||
- [ ] Die Kopfleiste zeigt **keinen** Hinweis „Server nicht erreichbar"
|
||||
- [ ] Sendertasten springen zum nächsten Anfangsbuchstaben
|
||||
- [ ] Rechts auf einer Senderzeile öffnet den Tagesplan; keine Sendung steht
|
||||
doppelt drin, höchstens eine ist als „läuft" markiert
|
||||
|
||||
## 3. Favoriten (die Stelle, an der es zuletzt still kaputt war)
|
||||
|
||||
- [ ] „★ Favoriten" zeigt die Zahl, die das Backend kennt — nicht 0
|
||||
- [ ] Langer Druck auf einen Sender setzt den Stern, die Zahl steigt sofort
|
||||
- [ ] Erneuter langer Druck entfernt ihn, die Zahl sinkt
|
||||
- [ ] App neu starten: die Favoriten sind noch da (sie kommen vom Backend)
|
||||
- [ ] Am Handy: derselbe Stern, dieselbe Zahl
|
||||
|
||||
## 4. Vereinsmenüs
|
||||
|
||||
- [ ] Für einen Verein mit Spiel heute steht ein Menü mit Kurznamen in der
|
||||
Leiste („Stuttgart", nicht „1893")
|
||||
- [ ] Läuft ein Spiel, steht „läuft" im Chip, sonst „ab HH:MM"
|
||||
- [ ] In der Vereinsansicht steht die Paarung, nicht der EPG-Rohtitel —
|
||||
und der Vereinsname ist vollständig zu lesen
|
||||
- [ ] Ein Titel ohne Paarung („Vereinsprofil VfB Stuttgart") wird unverändert
|
||||
gezeigt, nicht zu einer Begegnung verbogen
|
||||
|
||||
## 5. Wiedergabe und Profile
|
||||
|
||||
- [ ] Ein Sender startet und zeigt Bild
|
||||
- [ ] Sendertasten hoch/runter wechseln den Sender
|
||||
- [ ] Die Sender-Zurück-Taste springt zum vorherigen Sender
|
||||
- [ ] Einstellungen → Stream-Qualität listet die Profile des Backends
|
||||
- [ ] Ein anderes Profil auswählen, Sender neu starten — er läuft weiter
|
||||
|
||||
## 6. Abmelden
|
||||
|
||||
- [ ] Einstellungen → Abmelden führt zurück zum Willkommensbildschirm
|
||||
- [ ] Nach erneutem Start bleibt der Willkommensbildschirm stehen
|
||||
(keine Senderliste aus dem Zwischenspeicher)
|
||||
|
||||
## Wenn etwas hakt
|
||||
|
||||
Logcat mitlesen:
|
||||
|
||||
```
|
||||
adb -s emulator-5554 logcat -s DispatcharrRepo DeviceAuth Onboarding
|
||||
```
|
||||
|
||||
Fehler beim Abruf tauchen dort auf; ein stiller Fehlschlag ohne Logzeile ist
|
||||
selbst ein Befund und gehört als Issue aufgeschrieben.
|
||||
145
tests/unit/DispatcharrJsonTest.kt
Normal file
145
tests/unit/DispatcharrJsonTest.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user