Files
Flaro/Packages/FlixyKit/Tests/MediaKitTests/EpisodeSelectionTests.swift
T
ScarriffleandClaude Opus 5 a1ff5cb7bb Von der Kachel direkt abspielen, auch bei Serien
Wer weiss, was er sehen will, klickte bisher auf die Kachel, wartete auf
die Detailseite und drueckte dort „Abspielen\". Zwei Schritte fuer eine
Absicht. Jetzt gibt es oben rechts einen Knopf — auf dem Mac beim
Darueberfahren, auf iPad und iPhone ueber langes Druecken.

Bei einer Serie ist die Absicht nicht „spiel die Serie\", sondern „mach da
weiter, wo ich war\". Welche Folge das ist, entscheidet `EpisodeSelection`:
die aelteste ungesehene, fortgesetzt falls angefangen, und von vorn, wenn
alles gesehen ist. Ein reiner Typ mit achtzehn Tests — im App-Ziel gibt es
kein Unit-Test-Ziel, und „er hat die falsche Folge gestartet\" ist sonst ein
Befund, den man nicht nachstellen kann.

Ausdruecklich die aelteste ungesehene, nicht die am weitesten
fortgeschrittene: wer sich eine Folge aufgehoben hat, soll sie nicht
dadurch verlieren, dass er weiter vorn hineingeschaut hat.

Dazu `episodes(inSeries:)` im Vertrag — bei Jellyfin eine Anfrage statt
dreizehn, fuer alle anderen die Vorgabe ueber `children(of:)`. Nicht
`/Shows/NextUp`: der braucht eine Seriennummer, die eine Serie selbst nicht
hat, versteckt vollstaendig gesehene Serien und bringt eine eigene
Zeitgrenze mit. Welche Folge dran ist, soll eine Regel entscheiden, nicht
der Server.

Nebenbei behoben: der grosse Abspielknopf im Aufmacher scheiterte bisher
mit „keine abspielbare Datei\", sobald dort eine Serie stand — und Jellyfins
„Zuletzt hinzugefuegt\" liefert neue Folgen als ihre Serie.

Der Knopf liegt als Ueberzug auf dem NavigationLink, nicht darin: ein
Button im Label eines Links loest auf iOS nie aus, und `.plain` macht das
Versagen lautlos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 21:18:39 +02:00

194 lines
7.4 KiB
Swift

import Foundation
import Testing
@testable import MediaKit
@Suite("Welche Folge dran ist")
struct EpisodeSelectionTests {
/// Eine Folge, so knapp wie möglich. `duration` steht dabei, weil
/// `watchProgress` ohne sie immer `nil` ist — genau die Falle, die einen
/// Test lautlos wirkungslos macht.
private func folge(
_ staffel: Int?,
_ nummer: Int?,
gesehen: Bool = false,
fortschritt: Double? = nil,
id: String? = nil
) -> MediaItem {
MediaItem(
id: id ?? "s\(staffel.map(String.init) ?? "x")e\(nummer.map(String.init) ?? "x")",
kind: .episode,
title: "Folge \(nummer.map(String.init) ?? "?")",
duration: 1_000,
index: nummer,
parentIndex: staffel,
playCount: gesehen ? 1 : 0,
resumeMilliseconds: fortschritt.map { Int($0 * 1_000) }
)
}
// MARK: - Der Normalfall
/// Der Fall aus der Beschreibung: vier Folgen gesehen, die fünfte ist
/// dran.
@Test("Die älteste ungesehene Folge")
func firstUnwatched() {
let staffel = (1...6).map { folge(1, $0, gesehen: $0 <= 4) }
let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season)
#expect(wahl?.episode.id == "s1e5")
#expect(wahl?.resumes == false)
}
@Test("Über Staffeln hinweg gilt dieselbe Regel")
func crossesSeasons() {
let serie = (1...3).map { folge(1, $0, gesehen: true) }
+ (1...3).map { folge(2, $0, gesehen: $0 == 1) }
let wahl = EpisodeSelection.nextUp(in: serie, scope: .series)
#expect(wahl?.episode.id == "s2e2")
}
/// Drei Staffeln durch, in der vierten bei Folge zwei — der zweite Fall,
/// den der Nutzer ausdrücklich genannt hat.
@Test("Drei Staffeln durch, in der vierten weiter")
func deepIntoASeries() {
var serie: [MediaItem] = []
for staffel in 1...3 { serie += (1...8).map { folge(staffel, $0, gesehen: true) } }
serie += [folge(4, 1, gesehen: true)] + (2...8).map { folge(4, $0) }
serie += (1...8).map { folge(5, $0) }
#expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s4e2")
}
// MARK: - Fortsetzen
@Test("Eine angefangene Folge wird fortgesetzt")
func resumesPartial() {
let staffel = [folge(1, 1, gesehen: true), folge(1, 2, fortschritt: 0.4)]
let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season)
#expect(wahl?.episode.id == "s1e2")
#expect(wahl?.resumes == true)
}
/// Ein Fortschritt, den der Server kurz vor Schluss noch nicht gelöscht
/// hat, öffnete den Player sonst mitten im Abspann — und das sieht aus,
/// als hätte er die Folge übersprungen.
@Test("Kurz vor Schluss wird nicht fortgesetzt")
func doesNotResumeAtTheVeryEnd() {
let staffel = [folge(1, 1, fortschritt: 0.98)]
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.resumes == false)
}
@Test("Ohne Fortschritt wird von vorn begonnen")
func startsFromZero() {
#expect(EpisodeSelection.nextUp(in: [folge(1, 1)], scope: .season)?.resumes == false)
}
/// **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.**
/// Wer sich eine Folge aufgehoben hat, soll sie nicht dadurch verlieren,
/// dass er weiter vorn hineingeschaut hat.
@Test("Eine spätere angefangene Folge zieht nicht vor")
func laterProgressDoesNotWin() {
let staffel = [
folge(1, 1, gesehen: true),
folge(1, 2),
folge(1, 5, fortschritt: 0.5),
]
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e2")
}
// MARK: - Alles gesehen
@Test("Ist alles gesehen, beginnt die Serie von vorn")
func watchedSeriesRestarts() {
let serie = (1...3).map { folge(1, $0, gesehen: true) }
+ (1...3).map { folge(2, $0, gesehen: true) }
let wahl = EpisodeSelection.nextUp(in: serie, scope: .series)
#expect(wahl?.episode.id == "s1e1")
#expect(wahl?.resumes == false)
}
@Test("Ist die Staffel gesehen, beginnt sie von vorn")
func watchedSeasonRestarts() {
let staffel = (1...3).map { folge(4, $0, gesehen: true) }
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s4e1")
}
// MARK: - Reihenfolge
/// Jellyfin sortiert `children(of:)` nach dem Namen, und das stellt
/// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so
/// lange gut, bis eine Staffel zehn Folgen hat.
@Test("Die Reihenfolge des Servers zählt nicht")
func ignoresServerOrder() {
let durcheinander = [folge(1, 10), folge(1, 2), folge(1, 1)]
#expect(EpisodeSelection.nextUp(in: durcheinander, scope: .season)?.episode.id == "s1e1")
}
@Test("Lücken in der Nummerierung ändern nichts")
func gapsDoNotMatter() {
let staffel = [folge(1, 1, gesehen: true), folge(1, 7), folge(1, 9)]
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e7")
}
/// Eine fehlende Nummer ist keine Null, sondern „der Server hat nichts
/// gesagt". Solche Einträge gehören ans Ende.
@Test("Folgen ohne Nummer stehen hinten")
func unnumberedGoLast() {
let staffel = [folge(1, nil, id: "ohne"), folge(1, 1)]
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e1")
}
@Test("Zweimal dieselbe Eingabe ergibt zweimal dasselbe")
func isRepeatable() {
let staffel = [folge(1, nil, id: "b"), folge(1, nil, id: "a")]
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "a")
}
// MARK: - Specials
/// Staffel 0 steht vor Staffel 1 und ist trotzdem nicht der Anfang.
@Test("Specials werden bei der Serie übergangen")
func skipsSpecialsInSeries() {
let serie = [folge(0, 1), folge(1, 1)]
#expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s1e1")
}
/// Wer die Specials-Kachel angeklickt hat, meint die Specials.
@Test("Bei der Staffel bleiben sie stehen")
func keepsSpecialsInSeason() {
let specials = [folge(0, 1), folge(0, 2)]
#expect(EpisodeSelection.nextUp(in: specials, scope: .season)?.episode.id == "s0e1")
}
/// Gäbe es **nur** Specials, dürfte die Serie nicht wortlos nichts tun.
@Test("Eine Serie aus lauter Specials ergibt nichts")
func onlySpecialsIsNothing() {
#expect(EpisodeSelection.nextUp(in: [folge(0, 1)], scope: .series) == nil)
}
// MARK: - Was keine Folge ist
/// Jellyfin liefert in Listen nicht immer eine brauchbare Art. Sie
/// wegzuwerfen leerte die Liste — also bleibt `nil` stehen.
@Test("Behälter fliegen raus, Unbekanntes bleibt")
func dropsContainersKeepsUnknown() {
let gemischt = [
MediaItem(id: "staffel", kind: .season, title: "Staffel 1", index: 1),
MediaItem(id: "serie", kind: .show, title: "Serie"),
MediaItem(id: "unbekannt", kind: nil, title: "?", index: 1, parentIndex: 1),
]
let wahl = EpisodeSelection.nextUp(in: gemischt, scope: .series)
#expect(wahl?.episode.id == "unbekannt")
}
@Test("Eine leere Liste ergibt nichts")
func emptyIsNothing() {
#expect(EpisodeSelection.nextUp(in: [], scope: .series) == nil)
#expect(EpisodeSelection.nextUp(in: [], scope: .season) == nil)
}
}