Files
Flaro/Packages/HaloKit/Tests/MediaKitTests/EpisodeSelectionTests.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 11:12:32 +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)
}
}