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>
194 lines
7.4 KiB
Swift
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)
|
|
}
|
|
}
|