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>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,206 @@
import Foundation
import Testing
@testable import HaloDesign
@Suite("Kontrastkorrektur")
struct ContrastTests {
private let canvas = HaloColor.canvas
@Test("Schwarz zu Weiß ergibt das Maximum von 21:1")
func extremesProduceMaximumRatio() {
let ratio = Contrast.ratio(RGB(hex: 0x000000), RGB(hex: 0xFFFFFF))
#expect(abs(ratio - 21) < 0.01)
}
@Test("Gleiche Farben ergeben 1:1")
func identicalColorsProduceMinimumRatio() {
#expect(abs(Contrast.ratio(canvas, canvas) - 1) < 0.0001)
}
@Test("Farben mit genug Kontrast bleiben unangetastet")
func sufficientColorsAreLeftAlone() {
// Wichtig, weil die kuratierte Palette sonst beim Zeichnen verfälscht
// würde — die Kachel in den Einstellungen und die Farbe in der App
// müssen übereinstimmen.
let amber = RGB(hex: 0xFFB224)
#expect(Contrast.legible(amber, on: canvas) == amber)
}
@Test(
"Zu dunkle Farben werden bis zur Lesbarkeit angehoben",
arguments: [0x00008B, 0x1A1A2E, 0x2D0A31, 0x003300, 0x4A0000, 0x111111]
)
func darkColorsAreLifted(hex: Int) {
let original = RGB(hex: UInt32(hex))
let corrected = Contrast.legible(original, on: canvas)
#expect(Contrast.ratio(original, canvas) < Contrast.minimumForText)
#expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText)
}
@Test("Die Korrektur hebt nur so weit an wie nötig")
func correctionIsMinimal() {
// Ein Sprung auf Weiß wäre die einfache Lösung, würde aber jede
// dunkle Wunschfarbe in dieselbe Farbe verwandeln.
let corrected = Contrast.legible(RGB(hex: 0x00008B), on: canvas)
#expect(Contrast.ratio(corrected, canvas) < Contrast.minimumForText + 0.5)
#expect(corrected != RGB(hex: 0xFFFFFF))
}
@Test("Der Farbton überlebt die Korrektur")
func correctionPreservesHue() {
let darkBlue = RGB(hex: 0x00008B)
let corrected = Contrast.legible(darkBlue, on: canvas)
// Blau muss blau bleiben. Ein Abstand von 0,02 im Farbkreis sind rund
// sieben Grad — für das Auge dieselbe Farbe.
#expect(abs(HSB(corrected).hue - HSB(darkBlue).hue) < 0.02)
}
@Test("Selbst Schwarz wird lesbar gemacht")
func blackIsHandled() {
// Schwarz hat keinen Farbton, an dem sich festhalten ließe — hier
// muss die Sättigungsstufe greifen, sonst käme die Suche nie an.
let corrected = Contrast.legible(RGB(hex: 0x000000), on: canvas)
#expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText)
}
}
@Suite("Akzentpalette")
struct AccentPaletteTests {
@Test("Jede kuratierte Farbe ist ohne Korrektur lesbar")
func curatedColorsAreLegibleAsIs() {
// Das ist der eigentliche Zweck des Kuratierens. Fällt eine neue Farbe
// hier durch, gehört sie nicht in die Palette — nicht in die
// Korrektur.
for option in AccentOption.palette {
let ratio = Contrast.ratio(option.color, HaloColor.canvas)
#expect(
ratio >= Contrast.minimumForText,
"\(option.id) erreicht nur \(String(format: "%.2f", ratio)):1"
)
}
}
@Test("Die Kennungen sind eindeutig")
func identifiersAreUnique() {
// Sie dienen als Schlüssel in den Voreinstellungen; ein Duplikat
// hieße, dass eine Auswahl still eine andere überschreibt.
let identifiers = AccentOption.palette.map(\.id)
#expect(Set(identifiers).count == identifiers.count)
}
@Test("Keine kuratierte Farbe nimmt die Kennung der eigenen Farbe ein")
func paletteDoesNotCollideWithCustom() {
#expect(!AccentOption.palette.contains { $0.id == AccentOption.customIdentifier })
}
}
@Suite("Farbwerte")
struct RGBTests {
@Test(
"Hex-Eingaben werden gelesen",
arguments: [
("#1DB954", 0x1DB954),
("1db954", 0x1DB954),
(" #FFB224 ", 0xFFB224),
// Kurzform: #ABC meint #AABBCC.
("#ABC", 0xAABBCC),
]
)
func validHexStringsParse(input: String, expected: Int) {
#expect(RGB(string: input) == RGB(hex: UInt32(expected)))
}
@Test(
"Unbrauchbare Eingaben werden abgewiesen",
arguments: ["", "#", "xyz", "#12345", "#GGGGGG", "1234567"]
)
func invalidHexStringsFail(input: String) {
#expect(RGB(string: input) == nil)
}
@Test("Hex hin und zurück ergibt denselben Wert")
func hexRoundTrips() {
for option in AccentOption.palette {
#expect(RGB(string: option.color.hexString) == option.color)
}
}
}
@Suite("Standard-Akzentfarbe")
struct DefaultAccentTests {
@Test("Die Voreinstellung ist Lilac")
func defaultIsLilac() {
// Festgehalten, damit ein Umsortieren der Palette sie nicht still
// verändert. Wer sie bewusst wechselt, ändert auch diesen Test — und
// genau das ist hier passiert: vorher stand Mint da, was mit dem
// violetten Icon nichts zu tun hatte.
#expect(AccentOption.default.id == "lilac")
#expect(AccentOption.default.color == RGB(hex: 0x9B8AD4))
}
@Test("Die Voreinstellung steht in der Palette")
func defaultIsPartOfThePalette() {
#expect(AccentOption.palette.contains(AccentOption.default))
}
@Test("Die Voreinstellung braucht keine Kontrastkorrektur")
func defaultNeedsNoCorrection() {
let corrected = Contrast.legible(AccentOption.default.color, on: HaloColor.canvas)
#expect(corrected == AccentOption.default.color)
}
}
@Suite("Gespeicherte Akzentfarbe")
struct AccentStorageTests {
private func freshDefaults() -> UserDefaults {
UserDefaults(suiteName: "halo.accent.tests.\(UUID().uuidString)")!
}
@Test("Ohne gespeicherte Auswahl gilt die Voreinstellung")
func defaultWithoutSelection() {
#expect(AccentOption.stored(in: freshDefaults()) == .default)
}
@Test("Eine kuratierte Auswahl kommt zurück")
func curatedRoundTrips() {
let defaults = freshDefaults()
let sky = AccentOption.palette.first { $0.id == "sky" }!
sky.store(in: defaults)
#expect(AccentOption.stored(in: defaults) == sky)
}
@Test("Eine eigene Farbe kommt mit ihrem Wert zurück")
func customRoundTrips() {
let defaults = freshDefaults()
let purple = AccentOption.custom(RGB(hex: 0x8B5CF6))
purple.store(in: defaults)
let restored = AccentOption.stored(in: defaults)
#expect(restored.id == AccentOption.customIdentifier)
#expect(restored.color == RGB(hex: 0x8B5CF6))
}
@Test("Eine unlesbare eigene Farbe fällt auf die Voreinstellung zurück")
func brokenCustomFallsBack() {
// Entsteht, wenn jemand die Voreinstellungen von Hand bearbeitet.
let defaults = freshDefaults()
defaults.set(AccentOption.customIdentifier, forKey: AccentOption.StorageKey.identifier)
defaults.set("kein Hexwert", forKey: AccentOption.StorageKey.customHex)
#expect(AccentOption.stored(in: defaults) == .default)
}
@Test("Der Umgebungswert und das Theme zeigen dieselbe Farbe")
@MainActor
func environmentMatchesTheme() {
// Der eigentliche Punkt: gäbe es zwei getrennte Speicherwege, liefe
// einer der beiden irgendwann auf die Standardfarbe — und genau das
// sah man als Picker-Wert in Mint neben Schaltern in Violett.
let defaults = freshDefaults()
AccentOption.custom(RGB(hex: 0x8B5CF6)).store(in: defaults)
let theme = HaloTheme(defaults: defaults)
#expect(theme.accentColor == AccentOption.storedAccentColor(in: defaults))
}
}
@@ -0,0 +1,81 @@
import Foundation
import Testing
@testable import HaloDesign
/// Die Geometrie des Programmrasters.
@Suite("Raster-Geometrie")
struct GuideLayoutTests {
/// 13:47 Uhr — bewusst krumm, denn genau das ist der Normalfall.
private let now = Date(timeIntervalSince1970: 1_757_684_820)
private var layout: GuideLayout {
GuideLayout(now: now, latestEnd: now.addingTimeInterval(6 * 3_600), pointsPerMinute: 4)
}
@Test("Das Fenster beginnt auf der halben Stunde, nicht jetzt")
func snapsToHalfHour() {
let start = layout.start
let minutes = Calendar(identifier: .gregorian).component(.minute, from: start)
#expect(minutes % 30 == 0)
#expect(start <= now)
#expect(now.timeIntervalSince(start) < 30 * 60)
}
@Test("Eine Sendung, die vor dem Fenster begann, klebt am linken Rand")
func clampsEarlierStart() {
// Sonst säße die laufende Sendung bei negativem Versatz außerhalb —
// ausgerechnet die, die man am ehesten sehen will.
#expect(layout.x(for: now.addingTimeInterval(-9 * 3_600)) == 0)
}
@Test("Eine angebrochene Sendung behält nur ihren sichtbaren Teil")
func clipsToWindow() {
let start = layout.start.addingTimeInterval(-1_800)
let end = layout.start.addingTimeInterval(1_800)
// Eine Stunde lang, aber nur die zweite Hälfte liegt im Fenster.
#expect(layout.width(from: start, to: end) == 120.0)
}
@Test("Eine Kurzmeldung bleibt lesbar breit")
func keepsShortProgramsReadable() {
let start = layout.start
let end = start.addingTimeInterval(120)
#expect(layout.width(from: start, to: end) == layout.minimumBlockWidth)
}
@Test("Ohne Programm hat das Raster trotzdem Breite")
func emptyGuideHasWidth() {
let empty = GuideLayout(now: now, latestEnd: nil, pointsPerMinute: 4)
#expect(empty.totalWidth > 0)
#expect(empty.end.timeIntervalSince(empty.start) == 3 * 3_600)
}
@Test("Das Fenster endet auf der halben Stunde")
func snapsEndToHalfHour() {
let minutes = Calendar(identifier: .gregorian).component(.minute, from: layout.end)
#expect(minutes % 30 == 0)
#expect(layout.end >= now.addingTimeInterval(6 * 3_600))
}
@Test("Zwei Wochen Vorschau werden auf vierzehn Stunden gekürzt")
func capsLongGuides() {
let long = GuideLayout(now: now, latestEnd: now.addingTimeInterval(14 * 24 * 3_600))
#expect(long.end.timeIntervalSince(long.start) == 14 * 3_600)
}
@Test("Die Achse ist halbstündlich und deckt das Fenster ab")
func ticksCoverWindow() {
// 13:30 bis 19:47, aufgerundet auf 20:00 — dreizehn halbe Stunden.
#expect(layout.ticks.count == 13)
#expect(layout.ticks.first == layout.start)
#expect(layout.ticks.allSatisfy { $0 < layout.end })
#expect(layout.tickWidth == 120.0)
}
}
@@ -0,0 +1,22 @@
import Testing
@testable import HaloDesign
@Suite("Auf welchem Gerät wir laufen")
struct HaloPlatformTests {
/// **Der Test, den es vorher nicht gab — und der gefehlt hat.**
///
/// `isMac` hatte einen `#else`-Zweig, der `true` zurückgab. Auf tvOS fiel
/// er dort hindurch: kein Übersetzungsfehler, keine Warnung, nur ein
/// Fernseher, der sich für einen Mac hielt. Zwei Wahrheitswerte, von denen
/// beide gelten, sind immer ein Fehler — und dieser eine Ausdruck hätte
/// ihn gefangen.
///
/// Er läuft hier auf macOS und beweist damit nur die Hälfte. Die andere
/// Hälfte beweist er, sobald er auf einem Fernseher läuft — und bis dahin
/// hält er wenigstens fest, dass die Frage gestellt gehört.
@Test("Ein Gerät ist nicht zugleich Mac und Fernseher")
func neverBoth() {
#expect(!(HaloPlatform.isMac && HaloPlatform.isTV))
}
}
@@ -0,0 +1,101 @@
import Foundation
import Testing
@testable import HaloDesign
/// Wo die schwebende Videokachel landet.
@Suite("Kachel-Platzierung")
struct MiniPlayerPlacementTests {
/// Ein iPhone im Hochformat, Kachel 180 × 101.
private let placement = MiniPlayerPlacement(
bounds: CGSize(width: 390, height: 844),
tile: CGSize(width: 180, height: 101),
inset: 12
)
@Test("Die nähere Seite gewinnt")
func picksTheNearestSide() {
#expect(placement.nearestSide(toCenter: CGPoint(x: 50, y: 400)) == .leading)
#expect(placement.nearestSide(toCenter: CGPoint(x: 340, y: 400)) == .trailing)
}
@Test("Die Höhe bleibt frei, nicht auf Ecken festgelegt")
func keepsTheVerticalPosition() {
// Der Kern des Umbaus: vier Ecken sind schnell gebaut, aber im Weg.
let middle = placement.center(side: .trailing, verticalFraction: 0.5)
let top = placement.center(side: .trailing, verticalFraction: 0)
let bottom = placement.center(side: .trailing, verticalFraction: 1)
#expect(top.y < middle.y)
#expect(middle.y < bottom.y)
#expect(top.x == middle.x && middle.x == bottom.x)
}
@Test("Anteil und Mittelpunkt rechnen ineinander um")
func roundTripsTheFraction() {
for fraction in [0.0, 0.25, 0.5, 0.9, 1.0] {
let center = placement.center(side: .leading, verticalFraction: fraction)
let back = placement.verticalFraction(forCenter: center)
#expect(abs(back - fraction) < 0.0001)
}
}
@Test("Der Anteil bleibt zwischen null und eins")
func clampsTheFraction() {
// Sonst wandert die Kachel beim Drehen aus dem Bild: der Anteil wird
// gespeichert, und ein Wert über eins landete unterhalb des Randes.
#expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: -9_000)) == 0)
#expect(placement.verticalFraction(forCenter: CGPoint(x: 0, y: 9_000)) == 1)
#expect(placement.center(side: .leading, verticalFraction: 5).y <= 844 - 12)
}
@Test("An jeder Seite bleibt der Sicherheitsabstand")
func keepsTheInset() {
for side in MiniPlayerPlacement.Side.allCases {
for fraction in [0.0, 0.5, 1.0] {
let center = placement.center(side: side, verticalFraction: fraction)
#expect(center.x - 90 >= 12)
#expect(center.x + 90 <= 390 - 12)
#expect(center.y - 50.5 >= 12)
#expect(center.y + 50.5 <= 844 - 12)
}
}
}
@Test("Beim Ziehen rutscht nichts über den Rand")
func clampsWhileDragging() {
// Ohne diese Grenze zieht man die Kachel hinter den Bildschirmrand,
// und dort ist sie mit dem Finger nicht mehr einzuholen.
let farOut = placement.clamped(center: CGPoint(x: -500, y: -500))
#expect(farOut.x == 102.0)
#expect(farOut.y == 62.5)
let alsoFarOut = placement.clamped(center: CGPoint(x: 9_000, y: 9_000))
#expect(alsoFarOut.x == 288.0)
#expect(alsoFarOut.y == 781.5)
}
@Test("Ein Punkt in der Mitte bleibt, wo er ist")
func leavesValidPointsAlone() {
let inside = CGPoint(x: 195, y: 400)
#expect(placement.clamped(center: inside) == inside)
}
@Test("Ein zu enger Bereich schiebt die Kachel nicht hinaus")
func survivesTinyBounds() {
// Kommt beim Drehen und in der Übergangsphase vor: der Bereich ist
// kurzzeitig schmaler als die Kachel. Ein falsch herum geklammerter
// Wert schöbe sie dann dauerhaft aus dem Bild.
let narrow = MiniPlayerPlacement(
bounds: CGSize(width: 100, height: 80),
tile: CGSize(width: 180, height: 101),
inset: 12
)
#expect(narrow.clamped(center: CGPoint(x: 0, y: 0)) == CGPoint(x: 102, y: 62.5))
#expect(narrow.center(side: .trailing, verticalFraction: 1) == CGPoint(x: 102, y: 62.5))
}
}
@@ -0,0 +1,280 @@
import Foundation
import SwiftData
import Testing
@testable import HaloDownloads
/// Baut einen Koordinator, der nichts überträgt.
///
/// `maximumConcurrentDownloads = 0` hält die Warteschlange an: geprüft wird
/// die Buchführung — Annehmen, Ablehnen, Reihenfolge —, nicht das Netz.
@MainActor
private func makeCoordinator(
limitBytes: Int64? = nil,
root: URL
) throws -> DownloadCoordinator {
let container = try ModelContainer(
for: DownloadItem.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let defaults = UserDefaults(suiteName: "halo.tests.\(UUID().uuidString)")!
let coordinator = DownloadCoordinator(
container: container,
fileStore: try DownloadFileStore(root: root),
defaults: defaults,
sessionConfiguration: .ephemeral
)
coordinator.maximumConcurrentDownloads = 0
coordinator.storageLimitBytes = limitBytes
return coordinator
}
private func plan(
_ key: String,
bytes: Int64 = 1_000,
series: String? = nil,
season: Int? = nil,
episode: Int? = nil
) -> DownloadPlan {
DownloadPlan(
ratingKey: key,
title: "Titel \(key)",
sourceURL: URL(string: "http://server:32400/library/parts/\(key)/file.mkv")!,
seriesKey: series,
seasonIndex: season,
episodeIndex: episode,
expectedBytes: bytes
)
}
private func temporaryRoot() -> URL {
FileManager.default.temporaryDirectory.appending(path: "halo-tests-\(UUID().uuidString)")
}
@MainActor
@Suite("Download-Warteschlange")
struct DownloadCoordinatorTests {
@Test("Ob die Größe genau ist, kommt am Eintrag an")
func carriesExactSizeFlag() throws {
let coordinator = try makeCoordinator(root: temporaryRoot())
var genau = plan("a", bytes: 20_000_000)
genau.expectedIsExact = true
let eins = try coordinator.enqueue(genau)
#expect(eins.expectedIsExact)
// Die Voreinstellung ist „geschätzt" — die umgerechneten Stufen
// dürfen abweichen, und wer das Feld vergisst, bekommt die mildere
// Regel statt verworfener Downloads.
let zwei = try coordinator.enqueue(plan("b", bytes: 20_000_000))
#expect(!zwei.expectedIsExact)
}
@Test("Ein Titel wird angenommen und steht in der Warteschlange")
func acceptsFirstDownload() throws {
let coordinator = try makeCoordinator(root: temporaryRoot())
let item = try coordinator.enqueue(plan("1"))
#expect(item.state == .queued)
#expect(coordinator.items.count == 1)
}
@Test("Derselbe Titel wird nicht zweimal angenommen")
func rejectsDuplicates() throws {
// Ohne diese Prüfung entstünde bei zweimaligem Tippen auf den
// Download-Knopf eine zweite Übertragung derselben Datei — doppelte
// Bandbreite, doppelter Platz.
let coordinator = try makeCoordinator(root: temporaryRoot())
_ = try coordinator.enqueue(plan("1"))
#expect(throws: DownloadRefusal.alreadyExists) {
try coordinator.enqueue(plan("1"))
}
#expect(coordinator.items.count == 1)
}
@Test("Ein gescheiterter Download darf erneut in die Warteschlange")
func failedDownloadsCanBeRetried() throws {
let coordinator = try makeCoordinator(root: temporaryRoot())
let item = try coordinator.enqueue(plan("1"))
item.state = .failed
item.failureMessage = "Verbindung verloren"
let retried = try coordinator.enqueue(plan("1"))
#expect(retried.state == .queued)
#expect(retried.failureMessage == nil)
#expect(coordinator.items.count == 1)
}
@Test("Das Speicherlimit lehnt ab, bevor etwas geladen wird")
func respectsStorageLimit() throws {
let coordinator = try makeCoordinator(limitBytes: 5_000, root: temporaryRoot())
let first = try coordinator.enqueue(plan("1", bytes: 3_000))
// Fertige Downloads zählen zur Belegung; wartende noch nicht.
first.state = .completed
first.receivedBytes = 3_000
// Ohne die Belegung neu zu berechnen wüsste der Koordinator nichts
// vom ersten Download — dieser Aufruf bildet den Weg nach, den auch
// der Abschluss einer echten Übertragung nimmt.
coordinator.refreshUsage()
#expect(throws: DownloadRefusal.self) {
try coordinator.enqueue(plan("2", bytes: 3_000))
}
}
@Test("Unterhalb des Limits wird weiter angenommen")
func acceptsWithinLimit() throws {
let coordinator = try makeCoordinator(limitBytes: 10_000, root: temporaryRoot())
_ = try coordinator.enqueue(plan("1", bytes: 3_000))
_ = try coordinator.enqueue(plan("2", bytes: 3_000))
#expect(coordinator.items.count == 2)
}
@Test("Ein Stapel nimmt an, was geht, und meldet den Rest")
func batchAcceptsWhatItCan() throws {
// Bei einer Staffel, von der eine Folge schon offline ist, darf nicht
// der ganze Stapel scheitern.
let coordinator = try makeCoordinator(root: temporaryRoot())
_ = try coordinator.enqueue(plan("2"))
let result = coordinator.enqueueBatch([plan("1"), plan("2"), plan("3")])
#expect(result.accepted.count == 2)
#expect(result.refused.count == 1)
#expect(result.refused.first?.1 == .alreadyExists)
}
@Test("Entfernen löscht Eintrag und Belegung")
func removeClearsEverything() throws {
let coordinator = try makeCoordinator(root: temporaryRoot())
let item = try coordinator.enqueue(plan("1"))
coordinator.remove(item)
#expect(coordinator.items.isEmpty)
#expect(coordinator.usedBytes == 0)
}
@Test("Folgen einer Serie werden in Reihenfolge abgearbeitet")
func episodesKeepTheirOrder() throws {
// Sonst wäre bei einer Staffel Folge 7 vor Folge 1 fertig — und
// unterwegs nützt die siebte Folge wenig, wenn die erste fehlt.
let coordinator = try makeCoordinator(root: temporaryRoot())
_ = try coordinator.enqueue(plan("c", series: "s1", season: 1, episode: 3))
_ = try coordinator.enqueue(plan("a", series: "s1", season: 1, episode: 1))
_ = try coordinator.enqueue(plan("b", series: "s1", season: 1, episode: 2))
let order = coordinator.transferOrder.map(\.ratingKey)
#expect(order == ["a", "b", "c"])
}
@Test("Staffeln werden vor Folgennummern sortiert")
func seasonsSortBeforeEpisodes() throws {
let coordinator = try makeCoordinator(root: temporaryRoot())
_ = try coordinator.enqueue(plan("s2e1", series: "s1", season: 2, episode: 1))
_ = try coordinator.enqueue(plan("s1e9", series: "s1", season: 1, episode: 9))
let order = coordinator.transferOrder.map(\.ratingKey)
#expect(order == ["s1e9", "s2e1"])
}
@Test("Als gesehen markieren vermerkt den Zeitpunkt")
func markWatchedRecordsTime() throws {
// Grundlage fürs automatische Löschen nach dem Ansehen.
let coordinator = try makeCoordinator(root: temporaryRoot())
let item = try coordinator.enqueue(plan("1"))
#expect(item.watchedAt == nil)
coordinator.markWatched(ratingKey: "1")
#expect(item.watchedAt != nil)
}
}
@Suite("Download-Dateien")
struct DownloadFileStoreTests {
@Test("Der Dateiname stammt vom Schlüssel, nicht vom Titel")
func fileNameUsesRatingKey() throws {
// Titel enthalten Schrägstriche, Doppelpunkte und Umlaute — als
// Dateiname wäre das eine Quelle schwer auffindbarer Fehler.
let store = try DownloadFileStore(root: temporaryRoot())
let name = store.fileName(
forRatingKey: "45231",
sourceURL: URL(string: "http://server/parts/1/Blade Runner: 2049.mkv")!
)
#expect(name == "45231.mkv")
}
@Test("Ohne Endung in der Adresse wird mp4 angenommen")
func defaultsToMP4() throws {
let store = try DownloadFileStore(root: temporaryRoot())
let name = store.fileName(
forRatingKey: "1",
sourceURL: URL(string: "http://server/library/parts/1/1699887654")!
)
#expect(name == "1.mp4")
}
@Test("Verwaiste Dateien werden entfernt, bekannte bleiben")
func removesOrphansOnly() throws {
// Verwaiste Dateien entstehen, wenn die App genau beim Abschluss
// beendet wird: die Datei liegt da, der Eintrag fehlt.
let root = temporaryRoot()
let store = try DownloadFileStore(root: root)
for name in ["bekannt.mkv", "verwaist.mkv"] {
try Data("x".utf8).write(to: store.url(forFileName: name))
}
let removed = store.removeOrphans(keeping: ["bekannt.mkv"])
#expect(removed == 1)
#expect(store.exists(fileName: "bekannt.mkv"))
#expect(!store.exists(fileName: "verwaist.mkv"))
}
@Test("Die Gesamtgröße zählt alle Dateien")
func totalSizeSumsFiles() throws {
let root = temporaryRoot()
let store = try DownloadFileStore(root: root)
try Data(repeating: 0, count: 100).write(to: store.url(forFileName: "a.mkv"))
try Data(repeating: 0, count: 250).write(to: store.url(forFileName: "b.mkv"))
#expect(store.totalSize() == 350)
}
@Test("Eine fertige Datei wird verschoben, nicht kopiert")
func adoptMovesTheFile() throws {
// Kopieren hieße, denselben Platzbedarf ein zweites Mal — bei 40 GB
// der Unterschied zwischen „geht" und „geht nicht".
let root = temporaryRoot()
let store = try DownloadFileStore(root: root)
let temporary = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try Data(repeating: 0, count: 42).write(to: temporary)
let destination = try store.adopt(temporaryFile: temporary, as: "ziel.mkv")
#expect(FileManager.default.fileExists(atPath: destination.path(percentEncoded: false)))
#expect(!FileManager.default.fileExists(atPath: temporary.path(percentEncoded: false)))
#expect(store.size(ofFileName: "ziel.mkv") == 42)
}
@Test("Eine bestehende Datei wird ersetzt")
func adoptReplacesExisting() throws {
// Beim Wiederholen eines Downloads liegt die halbe Datei schon da.
let root = temporaryRoot()
let store = try DownloadFileStore(root: root)
try Data(repeating: 0, count: 10).write(to: store.url(forFileName: "ziel.mkv"))
let temporary = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try Data(repeating: 0, count: 99).write(to: temporary)
_ = try store.adopt(temporaryFile: temporary, as: "ziel.mkv")
#expect(store.size(ofFileName: "ziel.mkv") == 99)
}
}
@@ -0,0 +1,118 @@
import Testing
@testable import HaloDownloads
@Suite("Downloadrate und Restzeit")
struct DownloadRateTests {
/// Ein fester Anfangszeitpunkt, von dem aus gerechnet wird.
///
/// Die Uhr kommt von außen herein — deshalb braucht hier nichts zu warten,
/// und eine Übertragung über eine Viertelstunde ist in Mikrosekunden
/// nachgestellt.
private let start = ContinuousClock.now
@Test("Vor dem ersten Abstand gibt es keine Zahl")
func noRateFromASinglePoint() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
// Null wäre eine Auskunft („es geht nichts durch"), und die stimmt
// nicht — es ist nur noch nichts gemessen.
#expect(rate.bytesPerSecond == nil)
}
@Test("Gleichmäßiger Zufluss ergibt eine ruhige Zahl")
func steadyFlowIsSteady() {
var rate = DownloadRate()
for sekunde in 0...5 {
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
}
// Auf das Byte genau: die Glättung zieht einen Messwert nur dann in
// eine Richtung, wenn er vom bisherigen abweicht.
#expect(rate.bytesPerSecond == 1_000_000)
}
@Test("Ein Ausreißer verschiebt die Zahl, aber reißt sie nicht mit")
func outlierIsDamped() {
var rate = DownloadRate()
for sekunde in 0...5 {
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
}
// Eine einzelne Sekunde mit dem Zehnfachen.
rate.record(bytes: 15_000_000, at: start.advanced(by: .seconds(6)))
let gemessen = try! #require(rate.bytesPerSecond)
// Roh wären es zehn Millionen. Geglättet liegt die Zahl dazwischen —
// sie folgt, statt zu springen.
#expect(gemessen > 1_000_000)
#expect(gemessen < 10_000_000)
}
@Test("Messpunkte dichter als eine halbe Sekunde zählen nicht")
func samplesTooCloseAreIgnored() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 50_000, at: start.advanced(by: .milliseconds(100)))
rate.record(bytes: 100_000, at: start.advanced(by: .milliseconds(200)))
#expect(rate.bytesPerSecond == nil)
// Und der Bezugspunkt ist dabei nicht weitergerückt: gemessen wird
// über die volle Sekunde seit dem ersten Punkt, nicht über die letzten
// achthundert Millisekunden.
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.bytesPerSecond == 1_000_000)
}
@Test("Eine lange Stille verfälscht die Rate nicht")
func longGapResetsTheMeasurement() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.bytesPerSecond == 1_000_000)
// Eine halbe Minute ohne Rückmeldung heißt nicht „langsam", sondern
// „stand still": App angehalten, Gerät gesperrt, Netz weg. Daraus eine
// Rate zu rechnen ergäbe dreißig Kilobyte je Sekunde und eine Restzeit
// von Stunden.
rate.record(bytes: 1_030_000, at: start.advanced(by: .seconds(31)))
#expect(rate.bytesPerSecond == nil)
}
@Test("Fängt die Übertragung von vorn an, gilt keine alte Zahl")
func restartFromZeroDiscardsTheRate() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.bytesPerSecond == 1_000_000)
// Genau das passiert, wenn ein gescheiterter Download ohne
// Wiederaufnahme-Datensatz neu beginnt.
rate.record(bytes: 0, at: start.advanced(by: .seconds(2)))
#expect(rate.bytesPerSecond == nil)
}
@Test("Die Restzeit folgt aus Rate und Rest")
func remainingFollowsFromRate() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
let rest = try! #require(rate.remaining(received: 1_000_000, total: 11_000_000))
#expect(rest == .seconds(10))
}
@Test("Ohne Rate keine Restzeit")
func noRemainingWithoutRate() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
// Eine Restzeit ohne Messung wäre geraten — und geraten sieht man ihr
// nicht an.
#expect(rate.remaining(received: 0, total: 1_000_000) == nil)
}
@Test("Ist alles da, gibt es nichts mehr zu schätzen")
func noRemainingWhenComplete() {
var rate = DownloadRate()
rate.record(bytes: 0, at: start)
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
#expect(rate.remaining(received: 1_000_000, total: 1_000_000) == nil)
}
}
@@ -0,0 +1,240 @@
import Foundation
import MediaKit
import Testing
@testable import HaloDownloads
/// Baut einen Eintrag im gewünschten Zustand, ohne Datenbank.
@MainActor
private func item(
_ key: String,
state: DownloadState = .completed,
watchedAgo: Duration? = nil,
series: String? = nil
) -> DownloadItem {
let item = DownloadItem(
ratingKey: key,
title: "Titel \(key)",
sourceURLString: "http://server/\(key)",
state: state,
seriesKey: series
)
if let watchedAgo {
item.watchedAt = Date().addingTimeInterval(-watchedAgo.seconds)
}
return item
}
@MainActor
@Suite("Download-Regeln")
struct DownloadRulesTests {
// MARK: - Automatisches Löschen
@Test("Ohne Regel wird nichts gelöscht")
func disabledRulesDeleteNothing() {
// Die wichtigste Zusicherung im ganzen Regelwerk: wer nichts
// eingestellt hat, verliert nichts.
let items = [item("1", watchedAgo: .afterOneWeek)]
#expect(DownloadRules.disabled.itemsToDelete(from: items).isEmpty)
}
@Test("Ungesehene Titel werden nie gelöscht")
func unwatchedIsNeverDeleted() {
let rules = DownloadRules(deleteWatchedAfter: .immediately)
let items = [item("1"), item("2"), item("3")]
#expect(rules.itemsToDelete(from: items).isEmpty)
}
@Test("Nach Ablauf der Frist wird gelöscht")
func deletesAfterGracePeriod() {
let rules = DownloadRules(deleteWatchedAfter: .afterOneDay)
let items = [
item("alt", watchedAgo: .afterOneWeek),
item("frisch", watchedAgo: .seconds(60)),
]
let doomed = rules.itemsToDelete(from: items).map(\.ratingKey)
#expect(doomed == ["alt"])
}
@Test("Die Frist schützt frisch Gesehenes")
func gracePeriodProtectsRecent() {
// Wer eine Folge zu Ende sieht und gleich noch einmal die letzte
// Szene sucht, soll sie nicht schon verloren haben.
let rules = DownloadRules(deleteWatchedAfter: .afterOneDay)
let items = [item("1", watchedAgo: .seconds(3_600))]
#expect(rules.itemsToDelete(from: items).isEmpty)
}
@Test("Unfertige Downloads werden nicht gelöscht")
func incompleteDownloadsSurvive() {
// Ein laufender Download hat nichts, was gesehen worden sein könnte —
// ein alter watchedAt-Wert aus einem früheren Durchlauf darf ihn
// nicht abräumen.
let rules = DownloadRules(deleteWatchedAfter: .immediately)
let running = item("1", state: .downloading, watchedAgo: .afterOneWeek)
#expect(rules.itemsToDelete(from: [running]).isEmpty)
}
// MARK: - Vorrat an Folgen
@Test("Ohne Regel wird nichts nachgeladen")
func noRefillWithoutRule() {
#expect(DownloadRules.disabled.seriesNeedingRefill(in: [item("1", series: "s")]).isEmpty)
}
@Test("Ein leerer Vorrat verlangt die volle Zahl")
func emptyStockNeedsFullCount() {
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
#expect(rules.missingEpisodeCount(forSeries: "s", in: []) == 3)
}
@Test("Gesehene Folgen zählen nicht zum Vorrat")
func watchedEpisodesDoNotCount() {
// Sonst bliebe der Vorrat bei drei gesehenen Folgen stehen und es käme
// nie etwas Neues nach — genau das Gegenteil des Gewünschten.
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
let items = [
item("1", watchedAgo: .seconds(10), series: "s"),
item("2", watchedAgo: .seconds(10), series: "s"),
item("3", series: "s"),
]
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 2)
}
@Test("Wartende Downloads zählen zum Vorrat")
func queuedEpisodesCount() {
// Sonst würde bei jedem Durchlauf erneut nachgelegt, bis die ganze
// Serie auf dem Gerät liegt.
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
let items = [
item("1", state: .completed, series: "s"),
item("2", state: .queued, series: "s"),
item("3", state: .downloading, series: "s"),
]
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 0)
}
@Test("Gescheiterte Downloads zählen nicht zum Vorrat")
func failedEpisodesDoNotCount() {
let rules = DownloadRules(keepUnwatchedEpisodes: 2)
let items = [
item("1", state: .completed, series: "s"),
item("2", state: .failed, series: "s"),
]
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 1)
}
@Test("Ein voller Vorrat verlangt nichts")
func fullStockNeedsNothing() {
let rules = DownloadRules(keepUnwatchedEpisodes: 2)
let items = [item("1", series: "s"), item("2", series: "s"), item("3", series: "s")]
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 0)
}
@Test("Serien werden getrennt gezählt")
func seriesAreCountedSeparately() {
let rules = DownloadRules(keepUnwatchedEpisodes: 2)
let items = [
item("a1", series: "a"),
item("a2", series: "a"),
item("b1", series: "b"),
]
let refill = rules.seriesNeedingRefill(in: items)
#expect(refill["a"] == nil)
#expect(refill["b"] == 1)
}
@Test("Filme ohne Serie lösen kein Nachladen aus")
func moviesDoNotTriggerRefill() {
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
#expect(rules.seriesNeedingRefill(in: [item("1"), item("2")]).isEmpty)
}
}
@MainActor
@Suite("Plausibilität fertiger Downloads")
struct DownloadPlausibilityTests {
@Test("Eine Fehlerseite geht nicht als Film durch")
func tinyFileIsRejected() {
// Der Fall aus der Praxis: 873 MB angekündigt, in einer Sekunde
// „fertig". Angekommen war eine HTML-Fehlerseite, und weil die
// empfangene Größe auch als erwartete notiert wurde, stand die
// Anzeige auf hundert Prozent.
#expect(DownloadCoordinator.implausibility(
written: 2_048, expected: 873_000_000, announced: true
) != nil)
}
@Test("Auch ohne angekündigte Größe fällt eine Fehlerseite auf")
func tinyFileWithoutExpectation() {
// Beim umgerechneten Download kennt der Server die Endgröße vorab
// nicht — die Untergrenze muss deshalb allein tragen.
#expect(DownloadCoordinator.implausibility(
written: 900, expected: 0, announced: false
) != nil)
}
@Test("Eine abgebrochene Übertragung fällt auf")
func truncatedTransferIsRejected() {
#expect(DownloadCoordinator.implausibility(
written: 200_000_000, expected: 873_000_000, announced: false
) != nil)
}
@Test("Bei sechzig Prozent einer angekündigten Größe ist die Datei kaputt")
func truncatedAtSixtyPercentIsRejected() {
// **Der Fehler, der diese Unterscheidung nötig gemacht hat.** Hier
// galt dieselbe Hälfte-Regel wie für geschätzte Größen, und eine bei
// sechzig Prozent abgerissene Datei kam durch: Haken dran, und beim
// Abspielen brach sie mittendrin ab. Gemeldet wurde das als
// „plötzlich war er fertig".
#expect(DownloadCoordinator.implausibility(
written: 1_800_000_000, expected: 3_000_000_000, announced: true
) != nil)
}
@Test("Dieselben sechzig Prozent einer Schätzung gehen durch")
func truncatedAtSixtyPercentOfEstimatePasses() {
// Gleiche Zahlen, andere Herkunft: hochgerechnet aus Bitrate und
// Dauer. Dort ist eine Abweichung nach unten normal, und streng zu
// prüfen hieße, gültige Downloads zu verwerfen.
#expect(DownloadCoordinator.implausibility(
written: 1_800_000_000, expected: 3_000_000_000, announced: false
) == nil)
}
@Test("Eine vollständige Datei geht durch")
func completeFilePasses() {
#expect(DownloadCoordinator.implausibility(
written: 873_000_000, expected: 873_000_000, announced: true
) == nil)
}
@Test("Kleinere Abweichungen sind erlaubt, solange die Größe geschätzt ist")
func smallDeviationPasses() {
// Der Transcoder arbeitet mit variabler Bitrate und trifft die
// geschätzte Größe nie genau.
#expect(DownloadCoordinator.implausibility(
written: 800_000_000, expected: 873_000_000, announced: false
) == nil)
}
@Test("Dieselbe Abweichung bei angekündigter Größe ist ein Abriss")
func smallDeviationOfAnnouncedSizeIsRejected() {
// Hat der Server die Größe genannt, gibt es keine erklärbare
// Abweichung: was fehlt, fehlt.
#expect(DownloadCoordinator.implausibility(
written: 800_000_000, expected: 873_000_000, announced: true
) != nil)
}
@Test("Ohne Erwartung geht eine normale Datei durch")
func noExpectationPasses() {
#expect(DownloadCoordinator.implausibility(
written: 500_000_000, expected: 0, announced: false
) == nil)
}
}
@@ -0,0 +1,79 @@
import Testing
@testable import HaloDownloads
@Suite("Download-Zustandsautomat")
struct DownloadStateTests {
@Test("Ein abgeschlossener Download bleibt abgeschlossen")
func completedIsTerminal() {
// Der wichtigste Übergang, den es NICHT geben darf: wäre er erlaubt,
// könnte ein verspäteter Rückruf aus der Hintergrundsitzung eine
// fertige Datei wieder in die Warteschlange schieben — und sie damit
// ein zweites Mal herunterladen.
for next in DownloadState.allCases {
#expect(!DownloadState.completed.canTransition(to: next))
}
}
@Test("Gescheiterte Downloads dürfen zurück in die Warteschlange")
func failedCanBeRetried() {
#expect(DownloadState.failed.canTransition(to: .queued))
}
@Test("Gescheiterte Downloads springen nicht direkt ins Übertragen")
func failedCannotSkipTheQueue() {
// Sonst umgeht der Wiederholen-Knopf die Prüfungen auf WLAN und
// Speicherplatz, die beim Verlassen der Warteschlange stattfinden.
#expect(!DownloadState.failed.canTransition(to: .downloading))
}
@Test("Angehalten und laufend zählen beim Speicherplatz mit")
func partialTransfersOccupyStorage() {
// Beide haben bereits Daten auf der Platte. Zählte man sie nicht mit,
// liefe das Gerät trotz gesetzter Grenze voll.
#expect(DownloadState.downloading.occupiesStorage)
#expect(DownloadState.paused.occupiesStorage)
#expect(DownloadState.completed.occupiesStorage)
#expect(!DownloadState.queued.occupiesStorage)
#expect(!DownloadState.waiting.occupiesStorage)
#expect(!DownloadState.failed.occupiesStorage)
}
@Test("Nur wartende Zustände dürfen mit dem Übertragen beginnen")
func onlyWaitingStatesAreTransferable() {
#expect(DownloadState.queued.isTransferable)
#expect(DownloadState.waiting.isTransferable)
#expect(!DownloadState.downloading.isTransferable)
#expect(!DownloadState.paused.isTransferable)
#expect(!DownloadState.completed.isTransferable)
}
@Test("Kein Zustand geht in sich selbst über")
func noSelfTransitions() {
// Ein Übergang auf sich selbst wäre ein stilles Nichts und würde
// Zählerstände wie „Versuche" doppelt hochzählen.
for state in DownloadState.allCases {
#expect(!state.canTransition(to: state))
}
}
@Test("Jeder Zustand außer dem abgeschlossenen hat einen Ausgang")
func everyStateCanProgress() {
for state in DownloadState.allCases where state != .completed {
let hasExit = DownloadState.allCases.contains { state.canTransition(to: $0) }
#expect(hasExit, "\(state.rawValue) ist eine Sackgasse")
}
}
@Test("Die gespeicherten Namen bleiben stabil")
func rawValuesAreStable() {
// Sie landen in SwiftData. Ein umbenannter Fall würde beim nächsten
// Start alle laufenden Downloads unlesbar machen.
#expect(DownloadState.queued.rawValue == "queued")
#expect(DownloadState.downloading.rawValue == "downloading")
#expect(DownloadState.paused.rawValue == "paused")
#expect(DownloadState.waiting.rawValue == "waiting")
#expect(DownloadState.completed.rawValue == "completed")
#expect(DownloadState.failed.rawValue == "failed")
}
}
@@ -0,0 +1,45 @@
import Testing
@testable import HaloDownloads
/// Warum gerade nichts übertragen wird.
@Suite("Übertragungssperre")
struct TransferHoldTests {
@Test("Mobilfunk hält auf, solange nur WLAN erlaubt ist")
func cellularHolds() {
#expect(
TransferHold.current(isWiFiOnly: true, isReachable: true, isExpensive: true)
== .waitingForWiFi
)
}
@Test("Im WLAN läuft es")
func wifiRuns() {
#expect(
TransferHold.current(isWiFiOnly: true, isReachable: true, isExpensive: false)
== .none
)
}
@Test("Ohne den WLAN-Zwang hält Mobilfunk nicht auf")
func cellularAllowed() {
#expect(
TransferHold.current(isWiFiOnly: false, isReachable: true, isExpensive: true)
== .none
)
}
@Test("Ohne Verbindung hilft auch ein ausgeschalteter Zwang nichts")
func offlineWins() {
// Sonst hieße es „wartet auf WLAN", während schlicht gar nichts geht —
// und der Betrachter sucht den Fehler an der falschen Stelle.
#expect(
TransferHold.current(isWiFiOnly: false, isReachable: false, isExpensive: false)
== .offline
)
#expect(
TransferHold.current(isWiFiOnly: true, isReachable: false, isExpensive: true)
== .offline
)
}
}
@@ -0,0 +1,263 @@
import Testing
import Foundation
import MediaKit
@testable import HaloPlayback
@Suite("Sprachkürzel")
struct LanguageCodeTests {
/// Der Fall, an dem die alte Prüfung lautlos scheiterte: `ger` ist die
/// ISO-639-2/B-Form von Deutsch, und ffmpeg schreibt sie in viele Dateien.
@Test("ger, deu und de-DE meinen alle Deutsch")
func germanHasThreeSpellings() {
#expect(LanguageCode.matches("ger", "de"))
#expect(LanguageCode.matches("deu", "de"))
#expect(LanguageCode.matches("de-DE", "de"))
#expect(LanguageCode.matches("GER", "de"))
}
/// Schweizerdeutsch ist keine Schreibweise von Deutsch. Es gleichzusetzen
/// wäre bequem und falsch — wer Hochdeutsch will, bekäme sonst
/// Schweizerdeutsch untergeschoben.
@Test("Schweizerdeutsch ist nicht Deutsch")
func swissGermanStandsAlone() {
#expect(!LanguageCode.matches("gsw", "de"))
#expect(LanguageCode.matches("gsw", "gsw"))
}
@Test("Auch die anderen Doppelformen")
func otherDoubleForms() {
#expect(LanguageCode.matches("fre", "fra"))
#expect(LanguageCode.matches("dut", "nl"))
#expect(LanguageCode.matches("cze", "cs"))
#expect(LanguageCode.matches("chi", "zh"))
}
@Test("Ohne Angabe kein Treffer")
func missingIsNoMatch() {
#expect(!LanguageCode.matches(nil, "de"))
#expect(!LanguageCode.matches("de", nil))
}
}
@Suite("Wahl der Tonspur")
struct AudioTrackChooserTests {
private func spur(
_ index: Int,
_ sprache: String?,
title: String? = nil,
channels: Int = 2,
isDefault: Bool = false
) -> MediaStream {
MediaStream(
id: String(index),
kind: .audio,
codec: "aac",
language: sprache,
languageCode: sprache,
displayTitle: title,
channels: channels,
title: title,
isDefault: isDefault
)
}
// MARK: - Der ZDF-Fall
/// Wörtlich der Ärger, wegen dem es den Typ gibt: die als deutsch
/// ausgezeichnete Spur ist die mit Audiodeskription, und die als englisch
/// ausgezeichnete ist in Wahrheit die deutsche ohne.
private var zdf: [MediaStream] {
[
spur(0, "ger", title: "Deutsch (Audiodeskription)", isDefault: true),
spur(1, "eng", title: "Deutsch"),
]
}
@Test("Audiodeskription wird gemieden, auch wenn sie Standard ist")
func avoidsAudioDescription() {
let gewählt = AudioTrackChooser.choose(
from: zdf,
wish: .init(language: "de")
)
#expect(gewählt?.id == "1")
}
/// Ohne das Meiden gewinnt die ausgezeichnete Sprache — der Beweis, dass
/// der Schalter tatsächlich das Ruder herumreißt und nicht nur dabeisteht.
@Test("Ohne das Meiden gewinnt das Kürzel")
func withoutAvoidanceTheCodeWins() {
let gewählt = AudioTrackChooser.choose(
from: zdf,
wish: .init(language: "de", avoidsAudioDescription: false)
)
#expect(gewählt?.id == "0")
}
/// Und wenn der Nutzer einmal gewählt hat, gilt das — auch gegen die
/// Sprache.
@Test("Die gemerkte Spur schlägt den Sprachwunsch")
func rememberedBeatsLanguage() {
let gemerkt = AudioTrackChooser.TrackSignature(zdf[1])
let gewählt = AudioTrackChooser.choose(
from: zdf,
wish: .init(language: "de", remembered: gemerkt, avoidsAudioDescription: false)
)
#expect(gewählt?.id == "1")
}
// MARK: - Die Rangliste
@Test("Fehlt die Wunschsprache, greift die Rangliste")
func fallbackListApplies() {
let spuren = [spur(0, "fre"), spur(1, "eng"), spur(2, "ita")]
let gewählt = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["gsw", "en", "fr"])
)
#expect(gewählt?.id == "1")
}
@Test("Die Reihenfolge der Rangliste zählt")
func fallbackOrderMatters() {
let spuren = [spur(0, "fre"), spur(1, "eng")]
let zuerstFranzösisch = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["fr", "en"])
)
#expect(zuerstFranzösisch?.id == "0")
}
@Test("Schweizerdeutsch lässt sich in die Rangliste stellen")
func swissGermanInTheList() {
let spuren = [spur(0, "eng"), spur(1, "gsw")]
let gewählt = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["gsw", "en"])
)
#expect(gewählt?.id == "1")
}
// MARK: - Der Boden darunter
@Test("Ohne jede Übereinstimmung kommt trotzdem eine Spur")
func alwaysAnswers() {
let spuren = [spur(0, "jpn"), spur(1, "kor", isDefault: true)]
let gewählt = AudioTrackChooser.choose(
from: spuren,
wish: .init(language: "de", fallbacks: ["en"])
)
// Die Standardspur — nachvollziehbar statt beliebig.
#expect(gewählt?.id == "1")
}
@Test("Gibt es die Sprache nur mit Audiodeskription, ist sie besser als nichts")
func audioDescriptionBeatsSilence() {
let spuren = [spur(0, "ger", title: "Deutsch (Audiodeskription)")]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "0")
}
@Test("Ohne Tonspuren gibt es nichts zu wählen")
func noAudioNoChoice() {
let nurBild = [MediaStream(id: "0", kind: .video)]
#expect(AudioTrackChooser.choose(from: nurBild, wish: .init()) == nil)
}
@Test("Kommentarspuren werden gemieden")
func avoidsCommentary() {
let spuren = [
spur(0, "ger", title: "Audiokommentar des Regisseurs", isDefault: true),
spur(1, "ger", title: "Deutsch"),
]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "1")
}
/// „AD" als eigenes Wort, nicht als Teil von „Broadcast".
@Test("AD erkennt nur das freistehende Wort")
func adOnlyAsWholeWord() {
let spuren = [
spur(0, "ger", title: "Deutsch Broadcast Mix"),
spur(1, "ger", title: "Deutsch AD"),
]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "0")
}
@Test("Mehr Kanäle gewinnen bei sonst gleichem Stand")
func moreChannelsWin() {
let spuren = [spur(0, "ger", title: "Deutsch", channels: 2),
spur(1, "ger", title: "Deutsch", channels: 6)]
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
#expect(gewählt?.id == "1")
}
/// Zweimal dieselbe Datei muss zweimal dasselbe ergeben — sonst ist ein
/// Fehler nicht wiederholbar.
@Test("Gleichstand entscheidet die Nummer")
func tiesAreStable() {
let spuren = [spur(1, "ger", title: "Deutsch"), spur(0, "ger", title: "Deutsch")]
#expect(AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))?.id == "0")
}
}
@Suite("Rangfolge der drei Ebenen")
struct WishResolutionTests {
/// Serie schlägt Mediathek schlägt allgemein. Das ist der ganze Zweck der
/// Abstufung — und der einzige Ort, an dem sie steht.
@Test("Die Serie gewinnt")
func seriesWins() {
let wunsch = AudioTrackChooser.resolve(series: "en", library: "fr", general: "de")
#expect(wunsch.language == "en")
}
@Test("Ohne Serie gewinnt die Mediathek")
func libraryWinsWithoutSeries() {
#expect(AudioTrackChooser.resolve(series: nil, library: "fr", general: "de").language == "fr")
}
@Test("Ohne beides gilt die allgemeine")
func generalIsTheFloor() {
#expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "de").language == "de")
}
/// In den Einstellungen steht eine leere Zeichenkette für „soll der Server
/// entscheiden". Sie als Sprache zu behandeln hiesse, die Ebene darüber
/// stillschweigend abzuschalten — genau der Fehler, den man erst bemerkt,
/// wenn eine Einstellung scheinbar wirkungslos ist.
@Test("Leer heisst nichts gesagt, nicht eine Sprache namens leer")
func emptyIsNotAnAnswer() {
#expect(AudioTrackChooser.resolve(series: "", library: "fr", general: "de").language == "fr")
#expect(AudioTrackChooser.resolve(series: " ", library: "", general: "de").language == "de")
#expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "").language == nil)
}
/// Die Rangliste ist keine vierte Ebene: sie gilt überall gleich und wird
/// von keiner Ebene überstimmt.
@Test("Rangliste und Schalter gehen unverändert durch")
func theRestPassesThrough() {
let wunsch = AudioTrackChooser.resolve(
series: nil, library: nil, general: "de",
fallbacks: ["gsw", "en"], avoidsAudioDescription: false
)
#expect(wunsch.fallbacks == ["gsw", "en"])
#expect(wunsch.avoidsAudioDescription == false)
}
/// Der ZDF-Fall durch die ganze Kette: die Mediathek sagt Deutsch, die
/// gemerkte Spur sagt etwas anderes — und behält recht.
@Test("Die gemerkte Spur überlebt die Rangfolge")
func rememberedSurvives() {
let spuren = [
MediaStream(id: "0", kind: .audio, language: "ger", languageCode: "ger",
channels: 2, title: "Deutsch (Audiodeskription)", isDefault: true),
MediaStream(id: "1", kind: .audio, language: "eng", languageCode: "eng",
channels: 2, title: "Deutsch"),
]
let wunsch = AudioTrackChooser.resolve(
series: nil, library: "de", general: "en",
remembered: AudioTrackChooser.TrackSignature(spuren[1])
)
#expect(AudioTrackChooser.choose(from: spuren, wish: wunsch)?.id == "1")
}
}
@@ -0,0 +1,128 @@
import Testing
import Foundation
@testable import HaloPlayback
@Suite("Wann der teure Weg erlaubt ist")
struct DowngradeRuleTests {
/// Der Normalfall, in dem abgesenkt werden soll: keine Leiter da, es
/// hakt dauerhaft, und es läuft lange genug.
private func notlage(
isAutomatic: Bool = true,
hasLadder: Bool = false,
isAtLowestRung: Bool = false,
condition: StreamCondition = .failing,
stalls: Int = 3,
longest: Duration = .seconds(4),
playedFor: Duration = .seconds(120),
sinceLastSeek: Duration? = nil,
sinceLastDowngrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) -> DowngradeRule.Facts {
.init(
isAutomatic: isAutomatic,
hasLadder: hasLadder,
isAtLowestRung: isAtLowestRung,
condition: condition,
stallsInWindow: stalls,
longestStallInWindow: longest,
playedFor: playedFor,
sinceLastSeek: sinceLastSeek,
sinceLastDowngrade: sinceLastDowngrade,
used: used,
budget: budget
)
}
@Test("Ohne Leiter und mit drei Rücklern: ja")
func downgradesWhenNoLadder() {
#expect(DowngradeRule.verdict(notlage()) == .downgrade)
}
/// Eine ausdrücklich gewählte Stufe ist eine Ansage. `automatic` ist die
/// Erlaubnis einzugreifen, alles andere nicht.
@Test("Eine gewählte Stufe wird nicht überstimmt")
func neverOverridesAChoice() {
#expect(DowngradeRule.verdict(notlage(isAutomatic: false)) == .notAutomatic)
}
/// Solange es noch eine Sprosse tiefer gibt, kostet der billige Hebel
/// kein schwarzes Bild.
@Test("Solange die Leiter trägt, bleibt es beim billigen Hebel")
func prefersTheCheapLever() {
let facts = notlage(hasLadder: true, isAtLowestRung: false)
#expect(DowngradeRule.verdict(facts) == .cheaperPathRemains)
}
@Test("Auf der untersten Sprosse ist der billige Hebel erschöpft")
func lowestRungAllowsTheExpensivePath() {
let facts = notlage(hasLadder: true, isAtLowestRung: true)
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
@Test("Zwei Rückler genügen nicht")
func twoStallsAreNotEnough() {
#expect(DowngradeRule.verdict(notlage(stalls: 2)) == .notBadEnough)
}
/// Fünfzehn Sekunden Stillstand sind keine Schwankung. Darauf zu warten,
/// bis drei zusammenkommen, hieße viereinhalb Minuten zusehen.
@Test("Ein einzelner langer Rückler genügt allein")
func oneLongStallIsEnough() {
let facts = notlage(stalls: 1, longest: .seconds(20))
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
@Test("Ein gesunder Stream löst nichts aus")
func healthyNeverTriggers() {
#expect(DowngradeRule.verdict(notlage(condition: .healthy)) == .notBadEnough)
}
/// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist
/// keine.
@Test("In den ersten Sekunden passiert nichts")
func waitsAfterStart() {
#expect(DowngradeRule.verdict(notlage(playedFor: .seconds(5))) == .tooEarly)
}
/// Ein Sprung leert den Puffer immer. Sich dafür zu bestrafen wäre
/// absurd.
@Test("Kurz nach einem Sprung passiert nichts")
func waitsAfterSeek() {
let facts = notlage(sinceLastSeek: .seconds(3))
#expect(DowngradeRule.verdict(facts) == .tooEarly)
}
@Test("Ein länger zurückliegender Sprung stört nicht")
func oldSeekIsFine() {
#expect(DowngradeRule.verdict(notlage(sinceLastSeek: .seconds(30))) == .downgrade)
}
@Test("Innerhalb der Sperrfrist nicht noch einmal")
func respectsCooldown() {
let facts = notlage(sinceLastDowngrade: .seconds(60), used: 1)
#expect(DowngradeRule.verdict(facts) == .cooling)
}
@Test("Nach der Sperrfrist wieder")
func afterCooldownAgain() {
let facts = notlage(sinceLastDowngrade: .seconds(200), used: 1)
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
/// Lautlos die ganze Leiter bis 240p hinunterzugehen ist schlimmer, als
/// es zu sagen.
@Test("Das Budget ist endlich")
func budgetIsFinite() {
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 2, budget: 2)
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
}
/// Bei Live-TV nur einmal: jeder Neuaufbau stimmt den Tuner neu ab, und
/// davon gibt es vier Stück.
@Test("Bei Live-TV ist nach einem Mal Schluss")
func liveBudgetIsOne() {
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 1, budget: 1)
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
}
}
@@ -0,0 +1,367 @@
import Foundation
import Network
import Testing
@testable import HaloPlayback
/// Was der lokale Server dem Player wirklich schickt.
///
/// **Warum es diesen Test gibt.** Der Speicher steht zwischen Jellyfin und
/// AVPlayer, und was dort schiefgeht, sieht von aussen aus wie „der Server
/// liefert nichts" oder „der Player zeigt nichts". Sechs Reparaturversuche an
/// den falschen Stellen später steht fest: ohne eine Stelle, an der man die
/// Antwort **liest**, ist das nicht zu finden.
///
/// Hier steht ein eigener Herkunftsserver, der aufschreibt, was der Speicher
/// ihn gefragt hat, und der Test liest die Antworten Byte für Byte. Kein
/// Gerät, kein Simulator, kein echtes Jellyfin.
@Suite("Der lokale Server", .serialized)
struct LocalStreamServerTests {
// MARK: - Vorlagen
private static let master = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",LANGUAGE="ger",DEFAULT=YES,AUTOSELECT=YES,URI="subtitles/2/subtitles.m3u8?api_key=abc&SubtitleStreamIndex=2"
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,SUBTITLES="subs"
main.m3u8?api_key=abc
"""
private static let bildListe = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.000000,
hls1/main/0.ts?api_key=abc
#EXTINF:6.000000,
hls1/main/1.ts?api_key=abc
#EXT-X-ENDLIST
"""
private static let untertitelListe = """
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:6
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:6.000000,
0.vtt?api_key=abc&SubtitleStreamIndex=2
#EXTINF:6.000000,
1.vtt?api_key=abc&SubtitleStreamIndex=2
#EXT-X-ENDLIST
"""
private static let vtt = """
WEBVTT
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
00:00:01.000 --> 00:00:04.000
Hallo Welt.
"""
/// Der Herkunftsserver mit den Antworten, die Jellyfin geben würde.
private static func jellyfin() -> FakeOrigin {
let server = FakeOrigin()
server.add("/videos/x/master.m3u8", type: "application/vnd.apple.mpegurl", body: master)
server.add("/videos/x/main.m3u8", type: "application/vnd.apple.mpegurl", body: bildListe)
server.add("/videos/x/subtitles/2/subtitles.m3u8", type: "application/vnd.apple.mpegurl", body: untertitelListe)
server.add("/videos/x/hls1/main/0.ts", type: "video/MP2T", body: "TS-DATEN-0")
server.add("/videos/x/hls1/main/1.ts", type: "video/MP2T", body: "TS-DATEN-1")
server.add("/videos/x/subtitles/2/0.vtt", type: "text/vtt", body: vtt)
server.add("/videos/x/subtitles/2/1.vtt", type: "text/vtt", body: vtt)
return server
}
// MARK: - Der Aufbau
/// Startet Herkunftsserver und Speicher und gibt die Adresse zurück, die
/// der Player bekäme.
private func aufbauen() async throws -> (FakeOrigin, LocalStreamServer, URL) {
let herkunft = Self.jellyfin()
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
return (herkunft, server, lokal)
}
/// Holt eine Adresse und gibt Status, Inhaltstyp und Körper zurück.
private func hole(_ url: URL) async throws -> (status: Int, type: String?, body: String) {
let (daten, antwort) = try await URLSession.shared.data(from: url)
let http = antwort as? HTTPURLResponse
return (
http?.statusCode ?? 0,
http?.value(forHTTPHeaderField: "Content-Type"),
String(decoding: daten, as: UTF8.self)
)
}
/// Zieht die erste Adresse aus einem `URI="…"` der Liste.
private func renditionAdresse(in liste: String, basis: URL) -> URL? {
guard let start = liste.range(of: "URI=\""),
let ende = liste[start.upperBound...].firstIndex(of: "\"")
else { return nil }
return URL(string: String(liste[start.upperBound..<ende]), relativeTo: basis)
}
/// Zieht die erste Segment-Zeile aus einer Medienliste.
private func ersteSegmentAdresse(in liste: String, basis: URL) -> URL? {
for zeile in liste.components(separatedBy: .newlines) {
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
guard !getrimmt.isEmpty, !getrimmt.hasPrefix("#") else { continue }
return URL(string: getrimmt, relativeTo: basis)
}
return nil
}
// MARK: - Die Tests
/// **Der entscheidende.** Ein WebVTT-Körper, angekündigt als `video/mp4`,
/// geht bei AVFoundation an den falschen Parser, findet keine Box, und es
/// entstehen keine Einblendungen — lautlos. Die Spur steht im Blatt, ist
/// ausgewählt, und bleibt leer.
@Test("Ein Untertitel-Schnipsel kommt als text/vtt zurück")
func subtitleSegmentKeepsItsType() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
let rendition = try await hole(renditionURL)
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 200)
#expect(segment.body.hasPrefix("WEBVTT"))
#expect(segment.type == "text/vtt", "geliefert wurde: \(segment.type ?? "nichts")")
}
/// Gegentest, damit die Reparatur nicht zu breit wird.
@Test("Ein Bild-Schnipsel behält seinen eigenen Typ")
func videoSegmentKeepsItsType() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
// Die Bildstufe ist die Zeile ohne `URI=` — die erste gewöhnliche
// Adresse in der Liste.
let stufeURL = try #require(ersteSegmentAdresse(in: masterAntwort.body, basis: lokal))
let stufe = try await hole(stufeURL)
let segmentURL = try #require(ersteSegmentAdresse(in: stufe.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 200)
#expect(segment.type == "video/MP2T", "geliefert wurde: \(segment.type ?? "nichts")")
}
/// Die Abfrage des Servers wandert durch unsere eigene Abfrage hindurch.
/// Verliert `URLComponents` dabei etwas, holt der Speicher die falsche
/// Datei — und niemand sähe es der Antwort an.
@Test("Die Adresse übersteht die Rundreise zeichengenau")
func addressSurvivesTheRoundTrip() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
_ = try await hole(renditionURL)
let gesehen = herkunft.requests
#expect(
gesehen.contains { $0.contains("/videos/x/subtitles/2/subtitles.m3u8")
&& $0.contains("api_key=abc")
&& $0.contains("SubtitleStreamIndex=2") },
"der Herkunftsserver sah: \(gesehen)"
)
}
/// Ohne diese Zeilen gibt es für AVPlayer keine Untertitelspur — egal was
/// danach passiert.
@Test("Die Untertitelspur bleibt in der Master-Liste stehen")
func masterKeepsTheRendition() async throws {
let (herkunft, server, lokal) = try await aufbauen()
defer { Task { await server.stop() }; herkunft.stop() }
let antwort = try await hole(lokal)
#expect(antwort.status == 200)
#expect(antwort.type == "application/vnd.apple.mpegurl")
#expect(antwort.body.contains("#EXT-X-MEDIA:TYPE=SUBTITLES"))
#expect(antwort.body.contains("GROUP-ID=\"subs\""))
#expect(antwort.body.contains("LANGUAGE=\"ger\""))
#expect(antwort.body.contains("DEFAULT=YES"))
#expect(antwort.body.contains("SUBTITLES=\"subs\""))
}
/// Sonst lüde der Vorrat Untertitel statt Bild: der Film bliebe stehen,
/// während die Anzeige reichlich Vorrat meldet.
@Test("Die Untertitelspur wird nicht vorgeladen")
func renditionIsNotPrefetched() async throws {
let herkunft = Self.jellyfin()
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
_ = try await hole(renditionURL)
#expect(await speicher.bufferedAhead == .zero)
}
/// Ein leeres 200 ist genauso unsichtbar wie der falsche Typ. Die beiden
/// müssen auseinanderzuhalten sein, sonst sucht man wieder an der falschen
/// Stelle.
@Test("Ein Fehler von oben bleibt ein Fehler")
func upstreamFailureStaysAFailure() async throws {
let herkunft = Self.jellyfin()
herkunft.add("/videos/x/subtitles/2/0.vtt", status: 401, type: "text/plain", body: "nope")
let port = try await herkunft.start()
let speicher = StreamCache()
let server = LocalStreamServer(cache: speicher)
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
let lokal = try await server.start(upstream: oben)
defer { Task { await server.stop() }; herkunft.stop() }
let masterAntwort = try await hole(lokal)
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
let rendition = try await hole(renditionURL)
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
let segment = try await hole(segmentURL)
#expect(segment.status == 504)
}
}
// MARK: - Der Herkunftsserver
/// Ein winziger HTTP-Server, der die Antworten gibt, die Jellyfin geben würde
/// — und aufschreibt, wonach gefragt wurde.
///
/// Das Protokoll ist der eigentliche Gewinn: ohne es sieht man nur, was
/// **heraus**kommt, nicht wonach der Speicher gefragt hat. Genau diese Lücke
/// hat die Fehlersuche so lange gekostet.
final class FakeOrigin: @unchecked Sendable {
private struct Antwort {
let status: Int
let type: String
let body: String
}
private let lock = NSLock()
private var antworten: [String: Antwort] = [:]
private var gesehen: [String] = []
private var listener: NWListener?
func add(_ path: String, status: Int = 200, type: String, body: String) {
lock.withLock { antworten[path] = Antwort(status: status, type: type, body: body) }
}
/// Alles, wonach gefragt wurde — mit vollständiger Abfrage.
var requests: [String] {
lock.withLock { gesehen }
}
func start() async throws -> UInt16 {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
let lauscher = try NWListener(using: parameters)
lauscher.newConnectionHandler = { [weak self] verbindung in
verbindung.start(queue: .global(qos: .userInitiated))
self?.receive(on: verbindung, buffer: Data())
}
let port: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
let riegel = TestOneShot()
lauscher.stateUpdateHandler = { zustand in
switch zustand {
case .ready:
guard riegel.claim() else { return }
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
case .failed(let fehler):
guard riegel.claim() else { return }
fortsetzen.resume(throwing: fehler)
default:
break
}
}
lauscher.start(queue: .global(qos: .userInitiated))
}
listener = lauscher
return port
}
func stop() {
listener?.cancel()
listener = nil
}
private func receive(on verbindung: NWConnection, buffer: Data) {
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
[weak self] daten, _, abgeschlossen, fehler in
guard let self else { return }
var gesammelt = buffer
if let daten { gesammelt.append(daten) }
guard let ende = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
if fehler == nil, !abgeschlossen {
self.receive(on: verbindung, buffer: gesammelt)
} else {
verbindung.cancel()
}
return
}
let kopf = String(decoding: gesammelt[..<ende.lowerBound], as: UTF8.self)
self.antworte(auf: kopf, verbindung: verbindung)
}
}
private func antworte(auf kopf: String, verbindung: NWConnection) {
let zeile = kopf.components(separatedBy: "\r\n").first ?? ""
let pfadMitAbfrage = zeile.components(separatedBy: " ").dropFirst().first ?? ""
let pfad = pfadMitAbfrage.components(separatedBy: "?").first ?? pfadMitAbfrage
let antwort = lock.withLock { () -> Antwort? in
gesehen.append(pfadMitAbfrage)
return antworten[pfad]
}
let a = antwort ?? Antwort(status: 404, type: "text/plain", body: "nicht da")
let koerper = Data(a.body.utf8)
let ausgabe = """
HTTP/1.1 \(a.status) OK\r
Content-Type: \(a.type)\r
Content-Length: \(koerper.count)\r
Connection: close\r
\r
"""
var alles = Data(ausgabe.utf8)
alles.append(koerper)
verbindung.send(content: alles, completion: .contentProcessed { _ in verbindung.cancel() })
}
}
/// Wie im Original: ein Riegel, der genau einmal aufgeht.
private final class TestOneShot: @unchecked Sendable {
private let lock = NSLock()
private var used = false
func claim() -> Bool {
lock.withLock {
guard !used else { return false }
used = true
return true
}
}
}
@@ -0,0 +1,220 @@
import Testing
import Foundation
@testable import HaloPlayback
@Suite("Abspielliste lesen und umschreiben")
struct MediaPlaylistTests {
private let medien = """
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="hls1/main/-1.mp4"
#EXTINF:6.000000,
hls1/main/0.mp4
#EXTINF:6.000000,
hls1/main/1.mp4
#EXTINF:5.400000,
hls1/main/2.mp4
#EXT-X-ENDLIST
"""
private let stufen = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080
main.m3u8?level=0
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720
main.m3u8?level=1
"""
@Test("Die Schnipsel kommen in der Reihenfolge der Liste")
func readsSegmentsInOrder() {
let liste = MediaPlaylist(medien)
#expect(liste.segments == ["hls1/main/0.mp4", "hls1/main/1.mp4", "hls1/main/2.mp4"])
#expect(!liste.isMaster)
}
/// Die beiden Listenarten sehen sich ähnlich und meinen Verschiedenes.
/// Wer sie verwechselt, lädt Abspiellisten vor statt Bild.
@Test("Eine Liste von Stufen ist keine Liste von Schnipseln")
func tellsMasterFromMedia() {
#expect(MediaPlaylist(stufen).isMaster)
#expect(MediaPlaylist(stufen).segments.count == 2)
#expect(!MediaPlaylist(medien).isMaster)
}
/// **Die teuerste Stelle.** Eine Liste, die stellenweise am Speicher
/// vorbeizeigt, ergibt einen Film, der nur manchmal hakt — und so etwas
/// findet man im laufenden Bild nicht.
@Test("Umgeschrieben werden Adressen, sonst nichts")
func rewritesOnlyReferences() {
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "http://127.0.0.1:1234/s/\(adresse)" }
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/0.mp4"))
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/2.mp4"))
// Anweisungen bleiben, wie sie waren.
#expect(neu.contains("#EXT-X-TARGETDURATION:6"))
// **Mit einer Ausnahme**, und die hat mich ein Schwarzbild gekostet:
// in `EXT-X-MAP` steckt eine Adresse, und die muss mit. Siehe unten.
#expect(neu.contains("#EXT-X-MAP:URI=\"http://127.0.0.1:1234/s/hls1/main/-1.mp4\""))
#expect(neu.contains("#EXT-X-ENDLIST"))
#expect(neu.hasPrefix("#EXTM3U"))
}
/// Ohne das Kopfstück ist kein einziger Schnipsel einer fragmentierten
/// MP4 zu dekodieren. Bliebe die Adresse stehen, zeigte sie relativ auf
/// den lokalen Server — schwarzes Bild, ohne dass an den Schnipseln
/// irgendetwas falsch wäre.
@Test("Das Kopfstück wandert mit")
func rewritesInitSegment() {
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" }
#expect(neu.contains("#EXT-X-MAP:URI=\"/s/hls1/main/-1.mp4\""))
}
@Test("Eine Anweisung ohne Adresse bleibt unberührt")
func leavesOtherTagsAlone() {
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" }
#expect(neu.contains("#EXT-X-VERSION:7"))
#expect(neu.contains("#EXTINF:6.000000,"))
}
@Test("Die Zeilenzahl bleibt gleich")
func keepsStructure() {
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in adresse }
#expect(neu.components(separatedBy: "\n").count == medien.components(separatedBy: "\n").count)
}
@Test("Eine vollständige Liste ist als solche zu erkennen")
func knowsWhenComplete() {
#expect(MediaPlaylist(medien).isComplete)
// Ohne Schlussmarke: ein laufender Sender, die Liste wächst noch.
#expect(!MediaPlaylist(medien.replacingOccurrences(of: "#EXT-X-ENDLIST", with: "")).isComplete)
}
@Test("Die Schnipseldauer wird gelesen")
func readsTargetDuration() {
#expect(MediaPlaylist(medien).targetDuration == .seconds(6))
#expect(MediaPlaylist(stufen).targetDuration == nil)
}
@Test("Eine leere Eingabe ergibt eine leere Liste, keinen Absturz")
func emptyIsEmpty() {
#expect(MediaPlaylist("").segments.isEmpty)
#expect(MediaPlaylist("").rewritingReferences { adresse, _ in adresse } == "")
}
}
@Suite("Nebenspuren in der Abspielliste")
struct PlaylistRenditionTests {
/// **Der Fehler, der mit „404" endete.** Untertitel stehen in einer
/// eigenen Anweisung mit einer Adresse darin. Blieb sie stehen, zeigte
/// sie relativ auf den lokalen Server — und dort gibt es sie nicht. Zu
/// sehen war das erst, als jemand Untertitel einschaltete.
private let mitUntertiteln = """
#EXTM3U
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",URI="subtitles/0.m3u8"
#EXT-X-STREAM-INF:BANDWIDTH=8000000,SUBTITLES="subs"
main.m3u8?level=0
"""
@Test("Die Untertitelspur wird mit umgeschrieben")
func rewritesSubtitleRendition() {
let neu = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, _ in
"/um/\(adresse)"
}
#expect(neu.contains("URI=\"/um/subtitles/0.m3u8\""))
#expect(!neu.contains("URI=\"subtitles/0.m3u8\""))
}
/// **Und sie darf nicht vorgeladen werden.** Sonst lüde der Vorrat
/// Untertitel statt Bild: der Film bliebe stehen, während die Anzeige
/// reichlich Vorrat meldet.
@Test("Stufe und Nebenspur werden unterschieden")
func tellsLevelFromRendition() {
var arten: [String: MediaPlaylist.Reference] = [:]
_ = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, art in
arten[adresse] = art
return adresse
}
#expect(arten["main.m3u8?level=0"] == .level)
#expect(arten["subtitles/0.m3u8"] == .rendition)
}
@Test("Das Kopfstück gilt als Schnipsel, nicht als Liste")
func mapIsASegment() {
let liste = """
#EXTM3U
#EXT-X-MAP:URI="hls1/main/-1.mp4"
#EXTINF:6.0,
hls1/main/0.mp4
"""
var arten: [String: MediaPlaylist.Reference] = [:]
_ = MediaPlaylist(liste).rewritingReferences { adresse, art in
arten[adresse] = art
return adresse
}
#expect(arten["hls1/main/-1.mp4"] == .map)
#expect(arten["hls1/main/0.mp4"] == .segment)
}
}
@Suite("Adressen für den lokalen Speicher")
struct LocalStreamServerPathTests {
/// Der Fehler, an dem der erste Anlauf gescheitert ist: eine Liste von
/// Stufen wurde durchgereicht, ihre **relativen** Adressen zeigten danach
/// auf den lokalen Server — und dort gibt es sie nicht.
@Test("Eine Stufe wird vollständig adressiert, nicht relativ")
func levelPathIsAbsolute() {
let pfad = LocalStreamServer.localPlaylistPath(
for: "http://server:8096/videos/x/main.m3u8?level=1&api_key=t",
prefetch: true
)
let teile = URLComponents(string: pfad)
#expect(teile?.path == "/level.m3u8")
#expect(
teile?.queryItems?.first { $0.name == "u" }?.value
== "http://server:8096/videos/x/main.m3u8?level=1&api_key=t"
)
}
/// Die Endung muss stehen bleiben: AVPlayer entscheidet am Namen, ob er
/// eine Abspielliste oder Bild erwartet.
@Test("Die Endung bleibt eine Abspielliste")
func levelPathKeepsExtension() {
#expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8", prefetch: true).hasPrefix("/level.m3u8"))
}
/// Schrägstriche und Punkte im ursprünglichen Pfad dürfen nichts
/// anrichten — deshalb wandert er in die Abfrage, nicht in den Pfad.
@Test("Der Schnipselpfad überlebt Schrägstriche")
func segmentPathSurvivesSlashes() {
let pfad = LocalStreamServer.localPath(for: "hls1/main/12.mp4?runtimeTicks=0")
let teile = URLComponents(string: pfad)
#expect(teile?.path == "/s/")
#expect(teile?.queryItems?.first { $0.name == "u" }?.value == "hls1/main/12.mp4?runtimeTicks=0")
}
/// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen
/// Server zeigen — und zwar jede Zeile.
@Test("Jede Stufe zeigt danach auf uns")
func everyLevelIsRewritten() {
let stufen = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=8000000
main.m3u8?level=0
#EXT-X-STREAM-INF:BANDWIDTH=3000000
main.m3u8?level=1
"""
let basis = URL(string: "http://server:8096/videos/x/master.m3u8?api_key=t")!
let neu = MediaPlaylist(stufen).rewritingReferences { eintrag, _ in
let absolut = URL(string: eintrag, relativeTo: basis)?.absoluteString ?? eintrag
return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: true)
}
#expect(neu.components(separatedBy: "/level.m3u8").count == 3)
#expect(!neu.contains("\nmain.m3u8"))
}
}
@@ -0,0 +1,408 @@
import Testing
import MediaKit
@testable import HaloPlayback
/// Die Tabelle, die festhält, welche Datei auf welchem Weg landet.
///
/// Sie ist der Grund, warum `DeviceCapabilities` nicht beiläufig angefasst
/// werden kann: jede Änderung dort schlägt hier sichtbar durch.
@Suite("Wiedergabe-Entscheidung")
struct PlaybackDeciderTests {
/// Beschreibt eine Datei mit den angegebenen Eigenschaften.
///
/// Seit die Entscheidung `MediaProfile` entgegennimmt, steht hier genau
/// das, worauf es ankommt. Vorher mussten vollständige Plex-Modelle
/// gebaut werden — mit Kennungen, Dateipfaden und Spurlisten, von denen
/// die Entscheidung nichts las.
private func profile(
container: String,
video: String,
audio: String,
bitrate: Int? = nil
) -> MediaProfile {
MediaProfile(
container: container,
videoCodec: video,
audioCodec: audio,
bitrate: bitrate
)
}
// MARK: - Was AVPlayer nativ kann
@Test(
"Native Formate gehen an AVPlayer",
arguments: [
("mp4", "h264", "aac"),
("mp4", "hevc", "eac3"),
("mov", "h264", "ac3"),
("m4v", "hevc", "alac"),
// Groß-/Kleinschreibung darf keinen Unterschied machen — Plex ist
// darin nicht einheitlich.
("MP4", "H264", "AAC"),
]
)
func nativeFormatsUseAVPlayer(container: String, video: String, audio: String) {
let decision = PlaybackDecider().decide(
profile: profile(container: container, video: video, audio: audio),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
#expect(decision.rationale.contains(.nativelySupported))
}
// MARK: - Was ohne Serverhilfe übrig bleibt
/// **Hier stand einmal VLC.** Eine zweite Engine nahm all das an, was
/// AVPlayer nicht öffnet. Es gibt sie nicht mehr: darf der Server helfen,
/// packt er um oder rechnet um (siehe `RemuxDecisionTests`); darf er es
/// nicht, bleibt nur der Versuch — und der Grund steht in der Begründung.
@Test("Matroska ohne Serverhilfe: der Versuch, mit Begründung")
func matroskaWithoutServerHelp() {
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "h264", audio: "aac"),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
#expect(decision.rationale.contains(.containerUnsupported("mkv")))
// Die Begründung ist der ganze Wert dieses Falls: ohne sie sähe das
// Scheitern aus wie ein Fehler der App.
#expect(decision.rationale.contains(.transcodingUnavailable))
}
@Test(
"Nicht unterstützter Ton ohne Transcoder: der Versuch",
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
)
func unsupportedAudioWithoutServerHelp(codec: String) {
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: codec),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
#expect(decision.rationale.contains(.audioCodecUnsupported(codec)))
}
@Test(
"Nicht unterstütztes Bild geht an den Transcoder",
arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"]
)
func unsupportedVideoTranscodes(codec: String) {
// Steht das **Bild** im Weg, hilft Umpacken nicht — dieselben Ströme
// in einer anderen Hülle blieben dieselben Ströme.
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: codec, audio: "aac"),
policy: PlaybackPolicy(allowsTranscoding: true)
)
#expect(decision.method == .transcode(maximumBitrate: nil))
#expect(decision.rationale.contains(.videoCodecUnsupported(codec)))
}
@Test("Die gewählte Tonspur zählt, nicht die erste beste")
func decisionFollowsSelectedAudioTrack() {
// Die Datei hat englisches DTS und deutsches AAC; gewählt ist
// Deutsch. Entschieden wird über die Spur, die klingen soll — wer sie
// aussucht, hat der Aufrufer beim Bauen des Profils entschieden.
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac"),
policy: .directPlayOnly
)
#expect(decision.method == .directPlay)
}
@Test("Unanalysierte Dateien gehen an den Transcoder")
func unknownStreamsTranscode() {
// Der Server hat die Datei noch nicht untersucht: keine Spuren, kein
// bekannter Codec. Dann ist Raten die falsche Antwort — und seit es
// keine tolerantere Engine mehr gibt, ist Umrechnen die sichere.
let decision = PlaybackDecider().decide(
profile: MediaProfile(container: "mp4", hasKnownStreams: false),
policy: PlaybackPolicy(allowsTranscoding: true)
)
#expect(decision.method == .transcode(maximumBitrate: nil))
#expect(decision.rationale.contains(.streamsUnknown))
}
// MARK: - Die Bandbreitengrenze
@Test("Über der Grenze wird umgerechnet, sobald das erlaubt ist")
func exceedingBitrateTranscodesWhenAllowed() {
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000),
policy: policy
)
#expect(decision.method == .transcode(maximumBitrate: 8_000))
#expect(decision.rationale.contains(.bitrateExceedsLimit(mediaBitrate: 80_000, limit: 8_000)))
}
@Test("Ohne Transcoder wird es trotzdem versucht — und der Grund vermerkt")
func exceedingBitrateStillPlaysInPhaseOne() {
// Das ist die Lage in Phase 1. Abbrechen wäre falsch: im WLAN läuft
// auch ein 80-Mbit-Remux, die Grenze ist nur eine Vorsichtsmaßnahme.
let policy = PlaybackPolicy(allowsTranscoding: false, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000),
policy: policy
)
#expect(decision.isDirectPlay)
#expect(decision.rationale.contains(.transcodingUnavailable))
}
@Test("Unter der Grenze bleibt alles beim Direktabspielen")
func withinBitrateLimitPlaysDirectly() {
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000),
policy: policy
)
#expect(decision.method == .directPlay)
}
/// Früher landete eine Datei mit DTS-Ton beim vollen Neurechnen — Bild
/// inbegriffen, obwohl mit dem Bild nichts war. Jetzt bleibt es beim
/// Umpacken, und nur der Ton wird angefasst.
@Test("Umpacken reicht, solange das Bild passt")
func remuxIsEnough() {
let policy = PlaybackPolicy(allowsTranscoding: true)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "h264", audio: "dts"),
policy: policy
)
#expect(decision.method == .remux)
}
/// Erst wenn auch das Bild nicht geht, muss wirklich gerechnet werden.
@Test("Der Transcoder übernimmt, sobald das Bild nicht passt")
func transcodingTakesOver() {
let policy = PlaybackPolicy(allowsTranscoding: true)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "av1", audio: "aac"),
policy: policy
)
#expect(decision.method == .transcode(maximumBitrate: nil))
}
@Test("Der Transcode-Weg läuft über AVPlayer")
func transcodingUsesAVPlayer() {
// HLS ist genau die Kost, für die AVPlayer gebaut ist — inklusive
// automatischem Bitratenwechsel.
let policy = PlaybackPolicy(allowsTranscoding: true)
let decision = PlaybackDecider().decide(
profile: profile(container: "mkv", video: "av1", audio: "aac"),
policy: policy
)
#expect(decision.method == .transcode(maximumBitrate: nil))
}
@Test("Eine gewählte Stufe wird umgerechnet, auch wenn die Datei darunter liegt")
func honoursAnExplicitChoice() {
// Ohne das verpuffte die Wahl im Player: wer für eine 8-Mbit-Datei
// „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja unter der
// Grenze liegt. Gemeint ist aber „rechne es mir auf diese Stufe um".
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000),
policy: PlaybackPolicy(
allowsTranscoding: true,
maximumBitrate: 12_000,
forcesTranscoding: true
)
)
#expect(decision.method == .transcode(maximumBitrate: 12_000))
#expect(decision.rationale.contains(.qualityChosen))
}
@Test("Ohne ausdrückliche Wahl bleibt es bei DirectPlay")
func leavesAutomaticAlone() {
let decision = PlaybackDecider().decide(
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000),
policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 12_000)
)
#expect(decision.method == .directPlay)
}
}
@Suite("Umpacken statt neu rechnen")
struct RemuxDecisionTests {
private let decider = PlaybackDecider()
private var policy: PlaybackPolicy {
PlaybackPolicy(allowsTranscoding: true)
}
/// Der Blu-ray-Rip: Matroska aussen, lauter Dinge innen, die AVPlayer
/// bestens kann. Im Weg steht allein die Verpackung — und dafür gibt es
/// das Umpacken, und schon gar nicht den Transcoder.
@Test("Matroska mit brauchbarem Inhalt wird umgepackt")
func matroskaIsRemuxed() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
}
/// Steht der Ton im Weg, bleibt das Bild trotzdem unberührt: der Server
/// kopiert es und rechnet genau die eine Tonspur um. Das ist der Grund,
/// warum DTS auf dem Apple TV nicht mehr das ganze Bild kostet.
@Test(
"Nicht unterstützter Ton wird umgepackt, nicht neu gerechnet",
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
)
func unsupportedAudioIsRemuxed(codec: String) {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: codec,
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
// Die Begründung ist das, woran die Oberfläche später erkennt, dass
// der Ton angefasst wurde — ohne sie stünde dort „ohne Neukodierung".
#expect(entscheidung.rationale.contains(.audioCodecUnsupported(codec)))
}
/// Eine MP4 mit DTS steht mit einem reinen Tonhindernis da. Sie an der
/// Liste umpackbarer Schachteln zu messen, hiesse sie an einer Frage
/// scheitern zu lassen, die sich gar nicht stellt.
@Test("Auch eine MP4 mit DTS wird umgepackt")
func mp4WithDTSIsRemuxed() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mp4", videoCodec: "h264", audioCodec: "dts",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
}
/// Schachtel und Ton zugleich — der häufigste Fall bei einem Remux mit
/// Tonspur aus der Blu-ray.
@Test("Schachtel und Ton zusammen gehen auch")
func containerAndAudioTogether() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "h264", audioCodec: "truehd",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .remux)
#expect(entscheidung.rationale.contains(.containerUnsupported("mkv")))
#expect(entscheidung.rationale.contains(.audioCodecUnsupported("truehd")))
}
/// Der Gegenbeweis, und die Grenze: stimmt das **Bild** nicht, hilft kein
/// Durchreichen — dann müsste jede Bildzeile neu gerechnet werden, und
/// dafür ist dies nicht der Weg.
@Test("Bei falschem Bildcodec wird gerechnet")
func wrongVideoCodecIsNotRemuxed() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "av1", audioCodec: "aac",
bitrate: 20_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .transcode(maximumBitrate: nil))
}
/// Eine MP4, die AVPlayer ohnehin öffnet, wird nicht angefasst. Umpacken
/// wäre dort Arbeit ohne Wirkung.
@Test("Was schon passt, bleibt unverändert")
func mp4IsUntouched() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mp4", videoCodec: "h264", audioCodec: "aac",
bitrate: 8_000, hasKnownStreams: true
),
policy: policy
)
#expect(entscheidung.method == .directPlay)
}
/// Reicht die Leitung nicht, nützt Umpacken nichts: die Datei bliebe
/// gleich gross. Dann muss wirklich gerechnet werden.
@Test("Zu grosse Bitrate geht weiterhin an den Transcoder")
func bitrateStillTranscodes() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
bitrate: 80_000, hasKnownStreams: true
),
policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
)
#expect(entscheidung.method == .transcode(maximumBitrate: 8_000))
}
/// Ohne Transcoder gibt es auch kein Umpacken — beides läuft über
/// denselben Weg im Server.
@Test("Ohne Serverhilfe bleibt nur der Versuch")
func withoutServerHelp() {
let entscheidung = decider.decide(
profile: MediaProfile(
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
bitrate: 20_000, hasKnownStreams: true
),
policy: PlaybackPolicy(allowsTranscoding: false)
)
// Mehr als der Versuch bleibt nicht — aber der Grund steht dabei.
#expect(entscheidung.method == .directPlay)
#expect(entscheidung.rationale.contains(.transcodingUnavailable))
}
}
@Suite("Was der Systemplayer öffnet")
struct DeviceCapabilityTests {
private let kann = DeviceCapabilities.avFoundation
@Test("Eine MP4 mit H.264 und AAC geht")
func plainFilePasses() {
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "aac"))
}
@Test("Die Schachtel allein kann im Weg stehen")
func containerBlocks() {
// Inhaltlich tadellos, nur Matroska aussen — genau der Fall, für den
// ein Download umgepackt wird.
#expect(!kann.opens(container: "mkv", videoCodec: "h264", audioCodec: "ac3"))
}
@Test("Der Ton allein kann im Weg stehen")
func audioBlocks() {
#expect(!kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "dts"))
}
@Test("Grossschreibung ändert nichts")
func caseDoesNotMatter() {
#expect(kann.opens(container: "MP4", videoCodec: "H264", audioCodec: "AAC"))
}
@Test("Was der Server nicht nennt, gilt als passend")
func missingValuesPass() {
// Vorsichtshalber umzupacken hiesse, aus einer tadellosen Datei eine
// neu gerechnete zu machen.
#expect(kann.opens(container: nil, videoCodec: nil, audioCodec: nil))
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: ""))
}
}
@@ -0,0 +1,48 @@
import Testing
@testable import HaloPlayback
/// Die gemerkte Qualitätsstufe über mehrere Filme hinweg.
///
/// Aufbewahrt wird eine Zahl, keine Sprosse: welche Sprossen es gibt, hängt
/// von der Quelle ab. Eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe — und
/// dann stillschweigend auf „automatisch" zurückzufallen wäre eine andere
/// Antwort als die, um die gebeten wurde.
@Suite("Gemerkte Qualität")
struct QualityMemoryTests {
@Test("Null heißt: nichts gemerkt")
func returnsNilForAutomatic() {
#expect(PlayerQuality.automatic.storageValue == 0)
#expect(PlayerQuality.restored(from: 0, forSourceHeight: 1080) == nil)
}
@Test("Original und Nur-Ton kommen unverändert zurück")
func restoresSpecialCases() {
#expect(PlayerQuality.restored(from: PlayerQuality.original.storageValue,
forSourceHeight: 1080) == .original)
#expect(PlayerQuality.restored(from: PlayerQuality.audioOnly.storageValue,
forSourceHeight: 1080) == .audioOnly)
}
@Test("Eine vorhandene Sprosse kommt genau zurück")
func restoresExactRung() {
let chosen = PlayerQuality.limited(.init(bitrate: 3_000, height: 720))
let restored = PlayerQuality.restored(from: chosen.storageValue, forSourceHeight: 1080)
#expect(restored == chosen)
}
@Test("Gibt es die Sprosse nicht, kommt die nächstgelegene")
func fallsBackToClosestRung() {
// Zwanzig Megabit gibt es nur für 1080p. Bei einer 480p-Quelle ist die
// beste erreichbare Stufe eine andere — aber nicht „gar keine".
let restored = PlayerQuality.restored(from: 20_000, forSourceHeight: 480)
guard case .limited(let rung) = restored else {
Issue.record("keine Stufe zurückbekommen")
return
}
#expect(rung.height <= 480)
}
}
@@ -0,0 +1,115 @@
import Testing
import Foundation
@testable import HaloPlayback
@Suite("Rückler-Buchführung")
struct StallLedgerTests {
/// Der Fall, wegen dem es den Typ gibt.
///
/// Zehn Rückler à dreißig Sekunden lösten beim alten Watchdog nichts aus,
/// weil er sich bei jedem Erholen zurücksetzte. Hier muss sichtbar
/// werden, dass die Wiedergabe unbrauchbar ist.
@Test("Viele kurze Rückler bleiben sichtbar")
func manyShortStallsAreVisible() {
var ledger = StallLedger(window: .seconds(180))
// Alle 20 s ein Rückler von 5 s, drei Minuten lang.
for step in 1...9 {
ledger.record(.init(endedAt: .seconds(step * 20), length: .seconds(5)))
}
let now = Duration.seconds(180)
#expect(ledger.count(at: now) == 9)
// 45 s Warten in 180 s Fenster.
#expect(ledger.share(at: now) > 0.2)
#expect(ledger.share(at: now) < 0.3)
}
@Test("Was aus dem Fenster fällt, zählt nicht mehr")
func oldEpisodesFallOut() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(10), length: .seconds(30)))
#expect(ledger.count(at: .seconds(60)) == 1)
// Weit hinter dem Fenster: vergessen.
#expect(ledger.count(at: .seconds(300)) == 0)
#expect(ledger.share(at: .seconds(300)) == 0)
}
/// Am Anfang ist das Fenster noch nicht voll — dann gegen die tatsächlich
/// vergangene Zeit rechnen, sonst sieht ein früher Rückler harmlos aus.
@Test("Früh im Film zählt die vergangene Zeit, nicht das ganze Fenster")
func earlyStallsAreNotDiluted() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(20), length: .seconds(5)))
let share = ledger.share(at: .seconds(20))
// 5 s von 20 s, nicht 5 s von 180 s.
#expect(share > 0.2)
#expect(share < 0.3)
}
@Test("Der laufende Rückler zählt mit")
func ongoingStallCounts() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(10)))
let without = ledger.share(at: .seconds(180))
let with = ledger.share(at: .seconds(180), ongoing: .seconds(20))
#expect(with > without)
}
/// Ein einzelner langer Rückler ist eine andere Aussage als viele kurze.
@Test("Der längste Einzelfall lässt sich getrennt ablesen")
func longestIsSeparate() {
var ledger = StallLedger(window: .seconds(180))
ledger.record(.init(endedAt: .seconds(30), length: .seconds(2)))
ledger.record(.init(endedAt: .seconds(60), length: .seconds(18)))
ledger.record(.init(endedAt: .seconds(90), length: .seconds(3)))
#expect(ledger.longest(at: .seconds(120)) == .seconds(18))
#expect(ledger.count(at: .seconds(120)) == 3)
}
@Test("Eine ruhige Wiedergabe meldet nichts")
func quietPlaybackIsQuiet() {
let ledger = StallLedger()
#expect(ledger.count(at: .seconds(600)) == 0)
#expect(ledger.share(at: .seconds(600)) == 0)
#expect(ledger.longest(at: .seconds(600)) == .zero)
}
@Test("Der Anteil übersteigt nie eins")
func shareIsBounded() {
var ledger = StallLedger(window: .seconds(60))
// Absurd: mehr Wartezeit als Fenster.
ledger.record(.init(endedAt: .seconds(30), length: .seconds(200)))
#expect(ledger.share(at: .seconds(60)) == 1)
}
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
/// Reserve für einen Neuaufbau.
@Test("Der letzte Rückler lässt sich datieren")
func findsTheLastStall() {
var buch = StallLedger(window: .seconds(180))
buch.record(.init(endedAt: .seconds(30), length: .seconds(2)))
buch.record(.init(endedAt: .seconds(90), length: .seconds(1)))
#expect(buch.lastStall(at: .seconds(100)) == .seconds(90))
}
/// „Noch nie gestockt" ist etwas anderes als „vor langer Zeit" — wer
/// keinen hatte, wartet auf keine Frist.
@Test("Ohne Rückler gibt es kein Datum")
func noStallNoDate() {
let leer = StallLedger(window: .seconds(180))
#expect(leer.lastStall(at: .seconds(100)) == nil)
}
@Test("Was aus dem Fenster gefallen ist, zählt nicht mehr")
func forgetsOldStalls() {
var buch = StallLedger(window: .seconds(60))
buch.record(.init(endedAt: .seconds(10), length: .seconds(2)))
#expect(buch.lastStall(at: .seconds(200)) == nil)
}
}
@@ -0,0 +1,140 @@
import Testing
import Foundation
@testable import HaloPlayback
@Suite("Zustand des Streams")
struct StreamHealthTests {
/// Baut eine Folge von Proben im Sekundentakt.
private func lauf(
_ health: inout StreamHealth,
von: Int,
bis: Int,
puffer: (Int) -> Double?,
stalls: (Int) -> Int = { _ in 0 },
keepUp: (Int) -> Bool? = { _ in true }
) -> StreamCondition {
var urteil = StreamCondition.healthy
for sekunde in von...bis {
urteil = health.record(StreamSample(
at: .seconds(sekunde),
bufferedAhead: puffer(sekunde).map { .milliseconds(Int($0 * 1000)) },
isLikelyToKeepUp: keepUp(sekunde),
stallCount: stalls(sekunde)
))
}
return urteil
}
@Test("Ein voller, stabiler Puffer ist gesund")
func fullBufferIsHealthy() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 60, puffer: { _ in 30 })
#expect(urteil == .healthy)
}
/// Der Fall, für den das Ganze gebaut ist: der Puffer schrumpft stetig.
/// Das muss auffallen, **bevor** er leer ist.
@Test("Ein schrumpfender Puffer meldet sich vor dem Rückler")
func shrinkingBufferWarnsEarly() {
var health = StreamHealth()
// Start mit 30 s, dann jede Sekunde eine weniger.
_ = lauf(&health, von: 0, bis: 10, puffer: { _ in 30 })
let urteil = lauf(&health, von: 11, bis: 36, puffer: { 30 - Double($0 - 10) })
#expect(urteil == .strained)
// Und zwar bei noch fünf Sekunden Puffer — nicht bei null.
#expect(health.condition != .failing)
}
@Test("Ein kleiner, aber stabiler Puffer ist kein Alarm")
func smallStableBufferIsNotAlarming() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 30, puffer: { _ in 4 })
// Klein, aber er hält. Das ist kein Grund, die Stufe zu senken.
#expect(urteil != .strained)
#expect(urteil != .failing)
}
@Test("Zwei Rückler in einer Minute heißen: trägt nicht")
func twoStallsMeanFailing() {
var health = StreamHealth()
let urteil = lauf(
&health,
von: 0,
bis: 40,
puffer: { _ in 10 },
stalls: { $0 < 10 ? 0 : ($0 < 25 ? 1 : 2) }
)
#expect(urteil == .failing)
}
@Test("Ein leerer Puffer heißt sofort: trägt nicht")
func emptyBufferIsFailing() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
#expect(urteil == .failing)
}
/// „Noch nichts geladen" ist etwas anderes als „Puffer leer". Beim Start
/// darf das nicht als Notlage durchgehen.
@Test("Ohne Messwert kein Alarm")
func unknownBufferIsNotAlarming() {
var health = StreamHealth()
let urteil = lauf(&health, von: 0, bis: 10, puffer: { _ in nil })
#expect(urteil == .healthy)
}
/// Der Weg zurück ist absichtlich schmal: schnell runter, langsam rauf.
@Test("Zurück nach gesund braucht eine halbe Minute Ruhe")
func recoveryIsSlow() {
var health = StreamHealth()
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
#expect(health.condition == .failing)
// Puffer sofort wieder voll — aber zu kurz.
let früh = lauf(&health, von: 21, bis: 40, puffer: { _ in 30 })
#expect(früh != .healthy)
// Nach einer halben Minute durchgehend gut: erholt.
let spät = lauf(&health, von: 41, bis: 90, puffer: { _ in 30 })
#expect(spät == .healthy)
}
/// Ein einzelner Ausschlag von `isLikelyToKeepUp` kommt bei jedem Sprung
/// vor und darf nichts auslösen.
@Test("Ein einzelner Ausschlag löst nichts aus")
func singleKeepUpDipIsIgnored() {
var health = StreamHealth()
let urteil = lauf(
&health,
von: 0,
bis: 30,
puffer: { _ in 25 },
keepUp: { $0 == 15 ? false : true }
)
#expect(urteil == .healthy)
}
@Test("Anhaltende Sorge des Systems zählt")
func sustainedKeepUpFalseCounts() {
var health = StreamHealth()
let urteil = lauf(
&health,
von: 0,
bis: 30,
puffer: { _ in 25 },
keepUp: { $0 >= 27 ? false : true }
)
#expect(urteil == .strained)
}
@Test("Zurücksetzen macht einen frischen Anfang")
func resetStartsOver() {
var health = StreamHealth()
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
#expect(health.condition == .failing)
health.removeAll()
#expect(health.condition == .healthy)
}
}
@@ -0,0 +1,143 @@
import AVFoundation
import CoreMedia
import Foundation
import Testing
@testable import HaloPlayback
@Suite("Aussehen der Untertitel")
struct SubtitleStyleTests {
private func attributes(_ style: SubtitleStyle) -> [String: Any]? {
style.textStyleRules.first?.textMarkupAttributes
}
private func colour(_ style: SubtitleStyle, _ key: CFString) -> [Double]? {
(attributes(style)?[key as String] as? [NSNumber])?.map(\.doubleValue)
}
// MARK: - Dass überhaupt etwas herauskommt
/// **Der Test, für den es den ganzen Typ gibt.** Der Initialisierer von
/// `AVTextStyleRule` kann fehlschlagen: ein falscher Schlüssel ergibt
/// `nil` und damit gar keine Regel — lautlos. Auf dem Bildschirm sieht
/// „keine Regel" genauso aus wie „Regel ohne Wirkung".
@Test("Jedes Paar ergibt genau eine Regel")
func everyPaletteProducesOneRule() {
for palette in SubtitleStyle.Palette.allCases {
for size in SubtitleStyle.Size.allCases {
let regeln = SubtitleStyle(size: size, palette: palette).textStyleRules
#expect(regeln.count == 1, "\(palette) / \(size) ergab \(regeln.count) Regeln")
}
}
}
// MARK: - Die Farben
/// Alpha **zuerst**, dann Rot, Grün, Blau — der häufigste Fehler an
/// dieser Stelle, und einer ohne Fehlermeldung.
@Test("Die Farbe steht als A, R, G, B da")
func colourOrderIsARGB() {
let gelb = SubtitleStyle(palette: .yellowOnBlack)
let werte = colour(gelb, kCMTextMarkupAttribute_ForegroundColorARGB)
#expect(werte?.count == 4)
#expect(werte?[0] == 1) // Alpha
#expect(werte?[1] == 1) // Rot
#expect((werte?[3] ?? 1) < 0.5) // Blau — sonst wäre es nicht Gelb
}
@Test("Alle Anteile liegen zwischen null und eins")
func coloursAreInRange() {
for palette in SubtitleStyle.Palette.allCases {
let stil = SubtitleStyle(palette: palette)
for schlüssel in [
kCMTextMarkupAttribute_ForegroundColorARGB,
kCMTextMarkupAttribute_BackgroundColorARGB,
] {
let werte = colour(stil, schlüssel) ?? []
#expect(werte.count == 4, "\(palette): \(werte.count) Anteile")
#expect(werte.allSatisfy { $0 >= 0 && $0 <= 1 }, "\(palette): \(werte)")
}
}
}
@Test("Weiss auf Schwarz ist weiss auf schwarz")
func whiteOnBlack() {
let stil = SubtitleStyle(palette: .whiteOnBlack)
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 1, 1, 1])
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB) ?? []
#expect((grund.first ?? 0) > 0.5) // sichtbarer Kasten
#expect(grund.dropFirst().allSatisfy { $0 == 0 })
}
@Test("Schwarz auf Weiss ist die Umkehrung")
func blackOnWhite() {
let stil = SubtitleStyle(palette: .blackOnWhite)
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 0, 0, 0])
#expect((colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)?.first ?? 0) > 0.5)
}
/// **Weglassen ist nicht dasselbe wie durchsichtig.** Ohne den Schlüssel
/// gilt, was der Untertitel selbst mitbringt — und Jellyfins WebVTT
/// bringt oft einen schwarzen Kasten mit. Dann bliebe „Weiss ohne
/// Kasten" wirkungslos, und niemand wüsste warum.
@Test("Durchsichtig wird ausdrücklich gesetzt, nicht weggelassen")
func transparentIsStated() {
for palette in [SubtitleStyle.Palette.whiteOnClear, .yellowOnClear] {
let stil = SubtitleStyle(palette: palette)
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)
#expect(grund != nil, "\(palette): der Hintergrund fehlt ganz")
#expect(grund?.first == 0, "\(palette): Alpha ist nicht null")
}
}
// MARK: - Die Grösse
/// Ein Prozentwert, keine Punktgrösse. Hundert heisst unverändert — und
/// „unverändert" darf nicht wegoptimiert werden, sonst gilt wieder, was
/// der Untertitel mitbringt.
@Test("Die Grösse ist ein Prozentwert, auch bei hundert")
func sizeIsAPercentage() {
for size in SubtitleStyle.Size.allCases {
let wert = attributes(SubtitleStyle(size: size))?[
kCMTextMarkupAttribute_RelativeFontSize as String
] as? Double
#expect(wert == Double(size.rawValue))
}
}
/// Die zweite Grössenangabe bezieht sich auf etwas anderes. Beide zu
/// setzen liesse sie sich streiten.
@Test("Die andere Grössenangabe bleibt weg")
func onlyOneSizeKey() {
let gesetzt = attributes(SubtitleStyle(size: .huge)) ?? [:]
#expect(gesetzt[
kCMTextMarkupAttribute_BaseFontSizePercentageRelativeToVideoHeight as String
] == nil)
}
// MARK: - Der Rand
/// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer
/// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten Unruhe.
@Test("Den Schatten gibt es nur ohne Kasten")
func edgeShadowOnlyWithoutBox() {
for palette in SubtitleStyle.Palette.allCases {
let stil = SubtitleStyle(palette: palette)
let rand = attributes(stil)?[kCMTextMarkupAttribute_CharacterEdgeStyle as String]
#expect((rand != nil) == stil.wantsEdgeShadow, "\(palette)")
}
}
// MARK: - Die Vorgabe
@Test("Ohne Angabe: weisse Schrift, kein Kasten, normale Grösse")
func defaults() {
let stil = SubtitleStyle()
#expect(stil.size == .normal)
#expect(stil.palette == .whiteOnClear)
#expect(stil.wantsEdgeShadow)
}
}
@@ -0,0 +1,115 @@
import Testing
import MediaKit
@testable import HaloPlayback
@Suite("Was im Spurenmenü steht")
struct TrackMenuTests {
private func spur(
_ id: String,
kind: MediaStream.Kind = .audio,
sprache: String? = "Deutsch",
code: String? = "deu",
name: String? = nil,
codec: String? = "AC3"
) -> MediaStream {
MediaStream(
id: id, kind: kind, codec: codec, language: sprache, languageCode: code,
displayTitle: name, channels: nil, isSelected: false, isForced: false,
title: nil, isDefault: false, isHearingImpaired: false, isOriginal: false
)
}
// MARK: - Wann es einen Neuaufbau kostet
/// **Der Fall, der bisher nirgends stand.** Rechnet der Server um, liegt
/// im Stream genau eine Tonspur — jede andere Wahl verlangt eine neue
/// Bestellung und damit Sekunden mit schwarzem Bild.
@Test("Beim Umrechnen kostet jede Wahl einen Neuaufbau")
func transcodeAlwaysRebuilds() {
let einträge = TrackMenu.audio(
from: [spur("1"), spur("2")],
selected: nil, isDirectPlay: false, playerTrackCount: 1
)
#expect(einträge.allSatisfy { $0.rebuilds })
}
@Test("Unverändert gespielt und gleiche Anzahl: kein Neuaufbau")
func directPlayMatchingCountIsCheap() {
let einträge = TrackMenu.audio(
from: [spur("1"), spur("2")],
selected: nil, isDirectPlay: true, playerTrackCount: 2
)
#expect(einträge.allSatisfy { !$0.rebuilds })
}
/// **Die Regel aus `PlayerModel`, zum ersten Mal prüfbar.** Die Zuordnung
/// geht über die Reihenfolge; stimmt die Anzahl nicht, ist die Annahme
/// falsch, und es wäre die falsche Sprache statt einer Unterbrechung.
/// Auslösen liess sich das bisher nur an einem echten Server.
@Test("Abweichende Anzahl heißt Neuaufbau, auch unverändert gespielt")
func directPlayMismatchRebuilds() {
let einträge = TrackMenu.audio(
from: [spur("1"), spur("2"), spur("3")],
selected: nil, isDirectPlay: true, playerTrackCount: 2
)
#expect(einträge.allSatisfy { $0.rebuilds })
}
// MARK: - Untertitel
@Test("„Aus“ steht vorn und kostet nie einen Neuaufbau")
func offComesFirstAndIsFree() {
let einträge = TrackMenu.subtitles(
from: [spur("1", kind: .subtitle)],
selected: nil, isDirectPlay: false, offLabel: "Aus"
)
#expect(einträge.first?.id == nil)
#expect(einträge.first?.title == "Aus")
#expect(einträge.first?.isSelected == true)
#expect(einträge.first?.rebuilds == false)
}
/// Nur was der Player wirklich hat, lässt sich ohne Neuaufbau schalten.
@Test("Eine Untertitelspur, die der Player nicht hat, kostet einen Neuaufbau")
func unmappableSubtitleRebuilds() {
let deutsch = spur("1", kind: .subtitle, sprache: "Deutsch", code: "deu")
let englisch = spur("2", kind: .subtitle, sprache: "English", code: "eng")
let einträge = TrackMenu.subtitles(
from: [deutsch, englisch],
selected: nil, isDirectPlay: true,
playerTrackLanguages: ["de"], offLabel: "Aus"
)
// „deu" und „de" sind dieselbe Sprache — das entscheidet LanguageCode.
#expect(einträge[1].rebuilds == false)
#expect(einträge[2].rebuilds == true)
}
// MARK: - Beschriftung
@Test("Der Name des Servers gewinnt, sonst Sprache und Codec")
func fallsBackToLanguageAndCodec() {
let mitNamen = spur("1", name: "Deutsch (AC3 5.1)")
let ohneNamen = spur("2", sprache: "English", codec: "eac3")
let einträge = TrackMenu.audio(
from: [mitNamen, ohneNamen],
selected: mitNamen, isDirectPlay: true, playerTrackCount: 2
)
#expect(einträge[0].title == "Deutsch (AC3 5.1)")
#expect(einträge[1].title == "English · EAC3")
#expect(einträge[0].isSelected)
#expect(!einträge[1].isSelected)
}
/// Ohne alles bleibt ein Strich — besser als eine leere Zeile, bei der
/// niemand weiss, ob sie anklickbar ist.
@Test("Ohne Sprache und Codec bleibt ein Strich")
func namelessStreamStillHasALabel() {
let leer = spur("7", sprache: nil, code: nil, codec: nil)
let einträge = TrackMenu.audio(
from: [leer], selected: nil, isDirectPlay: true, playerTrackCount: 1
)
#expect(einträge[0].title == "—")
#expect(einträge[0].id == 7)
}
}
@@ -0,0 +1,143 @@
import Foundation
import Testing
@testable import HaloPlayback
@Suite("Wann hinaufgeschaltet wird")
struct UpgradeRuleTests {
/// Die Lage, in der es erlaubt ist — alle übrigen Tests weichen davon in
/// genau einem Punkt ab. So steht in jedem Test nur der Unterschied.
private func ruhig(
isAutomatic: Bool = true,
condition: StreamCondition = .healthy,
hasHigherRung: Bool = false,
ceilingAllowsMore: Bool = true,
bufferedAhead: Duration? = .seconds(30),
playedFor: Duration = .seconds(120),
sinceLastStall: Duration? = nil,
sinceLastSeek: Duration? = nil,
sinceLastUpgrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) -> UpgradeRule.Facts {
.init(
isAutomatic: isAutomatic,
condition: condition,
hasHigherRung: hasHigherRung,
ceilingAllowsMore: ceilingAllowsMore,
bufferedAhead: bufferedAhead,
playedFor: playedFor,
sinceLastStall: sinceLastStall,
sinceLastSeek: sinceLastSeek,
sinceLastUpgrade: sinceLastUpgrade,
used: used,
budget: budget
)
}
@Test("Läuft es ruhig und ist die Leiter ausgereizt, wird neu aufgebaut")
func calmAndCappedRebuilds() {
#expect(UpgradeRule.verdict(ruhig()) == .rebuildHigher)
}
// MARK: - Was es verbietet
/// Wer „720p · 3 Mbps" antippt, meint das — auch wenn die Leitung mehr
/// trüge.
@Test("Eine gewählte Stufe wird nicht überstimmt")
func neverOverridesAChoice() {
#expect(UpgradeRule.verdict(ruhig(isAutomatic: false)) == .notAutomatic)
}
/// **Der wichtigste Fall.** Solange die Leiter noch eine Sprosse hat,
/// kostet das Hinaufgehen kein schwarzes Bild — ein Neuaufbau dafür wäre
/// Verschwendung.
@Test("Solange die Leiter trägt, kein Neuaufbau")
func prefersTheCheapPath() {
#expect(UpgradeRule.verdict(ruhig(hasHigherRung: true)) == .cheaperPathRemains)
}
@Test("Über die eingestellte Grenze geht es nicht")
func respectsTheSetting() {
#expect(UpgradeRule.verdict(ruhig(ceilingAllowsMore: false)) == .atCeiling)
}
@Test("Bei angespannter Lage wird nicht hinaufgegangen")
func notWhenStrained() {
#expect(UpgradeRule.verdict(ruhig(condition: .strained)) == .notCalmEnough)
#expect(UpgradeRule.verdict(ruhig(condition: .failing)) == .notCalmEnough)
}
/// „Es läuft gerade" genügt nicht — es muss etwas auf der hohen Kante
/// liegen, sonst trägt es den Neuaufbau nicht.
@Test("Ohne Vorrat kein Neuaufbau")
func needsABuffer() {
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: .seconds(5))) == .notCalmEnough)
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: nil)) == .notCalmEnough)
}
/// Der Puffer füllt sich nach dem Start immer, und das sieht eine halbe
/// Minute lang nach einer hervorragenden Leitung aus.
@Test("Am Anfang nicht, auch wenn alles gut aussieht")
func notRightAfterStart() {
#expect(UpgradeRule.verdict(ruhig(playedFor: .seconds(20))) == .tooEarly)
}
@Test("Nach einem Sprung nicht")
func notRightAfterSeek() {
#expect(UpgradeRule.verdict(ruhig(sinceLastSeek: .seconds(3))) == .tooEarly)
}
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau wäre
/// die zweite Unterbrechung binnen einer Minute.
@Test("Nach einem Rückler lange nicht")
func waitsAfterAStall() {
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(30))) == .tooEarly)
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(120))) == .rebuildHigher)
}
@Test("Die Sperrfrist gilt")
func respectsTheCooldown() {
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(60))) == .cooling)
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(200))) == .rebuildHigher)
}
@Test("Das Budget ist endlich")
func budgetIsFinite() {
#expect(UpgradeRule.verdict(ruhig(used: 2, budget: 2)) == .budgetSpent)
#expect(UpgradeRule.verdict(ruhig(used: 1, budget: 2)) == .rebuildHigher)
}
// MARK: - Die Reihenfolge der Gründe
/// Bei mehreren Gründen soll der **grundsätzlichste** gemeldet werden —
/// sonst steht im Protokoll „Sperrfrist", wo in Wahrheit die Einstellung
/// das Ende ist, und jemand wartet auf etwas, das nie kommt.
@Test("Der grundsätzlichste Grund gewinnt")
func reportsTheMostFundamentalReason() {
#expect(
UpgradeRule.verdict(ruhig(
isAutomatic: false, hasHigherRung: true, ceilingAllowsMore: false
)) == .notAutomatic
)
#expect(
UpgradeRule.verdict(ruhig(
hasHigherRung: true, ceilingAllowsMore: false
)) == .atCeiling
)
#expect(
UpgradeRule.verdict(ruhig(
condition: .failing, hasHigherRung: true
)) == .cheaperPathRemains
)
}
/// Das Hinaufschalten muss zurückhaltender sein als das Absenken: ein zu
/// spätes Absenken bedeutet Ruckeln, ein zu spätes Hinaufschalten ein
/// etwas weicheres Bild.
@Test("Zurückhaltender als das Absenken")
func moreCautiousThanDowngrading() {
#expect(UpgradeRule.settleIn > DowngradeRule.settleIn)
#expect(UpgradeRule.cooldown < DowngradeRule.cooldown)
}
}
@@ -0,0 +1,76 @@
import Testing
import Foundation
import MediaKit
@testable import JellyfinKit
@Suite("Leiter bestellen")
struct AdaptiveURLTests {
private func url(_ text: String) -> URL { URL(string: text)! }
private func werte(_ url: URL, _ name: String) -> [String] {
(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [])
.filter { $0.name.lowercased() == name.lowercased() }
.compactMap(\.value)
}
/// `main.m3u8` ist die Liste der Schnipsel **einer** Stufe. Auf ihr kann
/// der Parameter nichts bewirken — es gibt dort keine Varianten.
@Test("Aus der Schnipselliste wird die Stufenliste")
func mainBecomesMaster() {
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/main.m3u8?Tag=1"))
#expect(ergebnis.lastPathComponent == "master.m3u8")
// Die übrige Abfrage bleibt unangetastet.
#expect(werte(ergebnis, "Tag") == ["1"])
}
@Test("Eine Stufenliste bleibt, wie sie ist")
func masterStays() {
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8"))
#expect(ergebnis.lastPathComponent == "master.m3u8")
}
/// Die Falle, an der `maxHeight` schon einmal hängen geblieben ist: bei
/// zwei gleichnamigen Werten gilt beim Server der erste. Ein
/// danebengestellter zweiter bewirkt nichts.
@Test("Der Parameter steht genau einmal da")
func parameterAppearsOnce() {
let ergebnis = JellyfinBackend.adaptive(
url("http://h/videos/abc/master.m3u8?enableAdaptiveBitrateStreaming=false&Tag=1")
)
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
#expect(werte(ergebnis, "Tag") == ["1"])
}
@Test("Ohne vorhandenen Wert kommt er dazu")
func parameterIsAdded() {
let ergebnis = JellyfinBackend.adaptive(url("http://h/videos/abc/master.m3u8?Tag=1"))
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
}
/// Groß- und Kleinschreibung ist beim Server gleichgültig — beim
/// Herausfiltern darf sie es nicht sein, sonst steht er doch zweimal da.
@Test("Auch anders geschrieben wird er ersetzt")
func parameterIsReplacedRegardlessOfCase() {
let ergebnis = JellyfinBackend.adaptive(
url("http://h/videos/abc/master.m3u8?EnableAdaptiveBitrateStreaming=false")
)
let alle = (URLComponents(url: ergebnis, resolvingAgainstBaseURL: false)?.queryItems ?? [])
.filter { $0.name.lowercased() == "enableadaptivebitratestreaming" }
#expect(alle.count == 1)
#expect(alle.first?.value == "true")
}
/// Die Höhe gehört weiterhin dran: die Leiter ist die Abstufung, die
/// Obergrenze bleibt unsere.
@Test("Höhenbegrenzung und Leiter vertragen sich")
func heightAndLadderCoexist() {
let begrenzt = JellyfinBackend.limiting(
height: "1280x720",
in: url("http://h/videos/abc/main.m3u8")
)
let ergebnis = JellyfinBackend.adaptive(begrenzt)
#expect(werte(ergebnis, "maxHeight") == ["720"])
#expect(werte(ergebnis, "enableAdaptiveBitrateStreaming") == ["true"])
#expect(ergebnis.lastPathComponent == "master.m3u8")
}
}
@@ -0,0 +1,67 @@
import Foundation
import Testing
@testable import JellyfinKit
/// Was aus dem wird, was jemand in das Adressfeld tippt.
///
/// Die Reihenfolge ist der Prüfgegenstand, nicht die bloße Menge: probiert
/// die App bei einer IP-Adresse zuerst HTTPS, wartet man auf jedem Server
/// ohne Zertifikat erst einmal auf eine Zeitüberschreitung.
@Suite("Adressen ergänzen")
struct AddressTests {
@Test("Ein getipptes Schema wird nicht angetastet")
func keepsExplicitScheme() {
let candidates = JellyfinAddress.candidates(for: "https://medien.example.com/jellyfin")
#expect(candidates.map(\.absoluteString) == ["https://medien.example.com/jellyfin"])
}
@Test("Nachlaufende Schrägstriche fallen weg")
func trimsTrailingSlash() {
let candidates = JellyfinAddress.candidates(for: " https://example.com/ ")
#expect(candidates.map(\.absoluteString) == ["https://example.com"])
}
@Test("Bei einer IP-Adresse zuerst der Standardport, unverschlüsselt")
func prefersPlainHTTPForAddresses() {
let candidates = JellyfinAddress.candidates(for: "192.168.1.50")
#expect(candidates.map(\.absoluteString) == [
"http://192.168.1.50:8096",
"http://192.168.1.50",
"https://192.168.1.50",
])
}
@Test("Bei einem Domänennamen zuerst HTTPS")
func prefersHTTPSForDomains() {
let candidates = JellyfinAddress.candidates(for: "medien.example.com")
#expect(candidates.first?.absoluteString == "https://medien.example.com")
#expect(candidates.map(\.absoluteString).contains("http://medien.example.com:8096"))
}
@Test("Ein genannter Port wird nicht überschrieben")
func keepsExplicitPort() {
let candidates = JellyfinAddress.candidates(for: "medien.example.com:8920")
#expect(candidates.map(\.absoluteString) == [
"http://medien.example.com:8920",
"https://medien.example.com:8920",
])
}
@Test("Der Standardport landet vor dem Pfad, nicht dahinter")
func insertsPortBeforePath() {
let candidates = JellyfinAddress.candidates(for: "server.local/jellyfin")
#expect(candidates.first?.absoluteString == "http://server.local:8096/jellyfin")
}
@Test("Leere Eingabe ergibt keine Adresse")
func rejectsEmptyInput() {
#expect(JellyfinAddress.candidates(for: " ").isEmpty)
}
}
@@ -0,0 +1,91 @@
{
"Items": [
{
"Name": "Der Anfang",
"Id": "a1b2c3d4e5f6",
"Type": "Episode",
"Overview": "Es f\u00e4ngt an.",
"ProductionYear": 2024,
"RunTimeTicks": 26400000000,
"IndexNumber": 3,
"ParentIndexNumber": 2,
"SeriesName": "Eine Serie",
"SeriesId": "series-1",
"SeasonId": "season-2",
"SeasonName": "Staffel 2",
"OfficialRating": "12",
"CommunityRating": 8.4,
"DateCreated": "2026-01-15T10:30:00.0000000Z",
"Genres": [
"Drama",
"Mystery"
],
"People": [
{
"Id": "p1",
"Name": "Eine Person",
"Role": "Die Hauptrolle",
"Type": "Actor",
"PrimaryImageTag": "t1"
},
{
"Id": "p2",
"Name": "Wer Regie f\u00fchrte",
"Type": "Director"
}
],
"UserData": {
"PlaybackPositionTicks": 6000000000,
"PlayCount": 1,
"Played": false,
"IsFavorite": true
},
"ImageTags": {
"Primary": "tag-primary",
"Logo": "tag-logo"
},
"BackdropImageTags": [
"tag-backdrop"
],
"SeriesPrimaryImageTag": "tag-series",
"MediaSources": [
{
"Id": "source-1",
"Container": "mkv",
"Size": 5368709120,
"Bitrate": 12000000,
"RunTimeTicks": 26400000000,
"SupportsDirectPlay": true,
"SupportsTranscoding": true,
"MediaStreams": [
{
"Index": 0,
"Type": "Video",
"Codec": "hevc",
"Height": 2160,
"Width": 3840
},
{
"Index": 1,
"Type": "Audio",
"Codec": "dts",
"Language": "ger",
"Channels": 6,
"DisplayTitle": "Deutsch (DTS 5.1)",
"IsDefault": true
},
{
"Index": 2,
"Type": "Subtitle",
"Codec": "subrip",
"Language": "eng",
"IsForced": false
}
]
}
]
}
],
"TotalRecordCount": 1,
"StartIndex": 0
}
@@ -0,0 +1,17 @@
{
"Items": [
{
"Name": "Eine Serie",
"Id": "series-1",
"Type": "Series",
"RecursiveItemCount": 24,
"ChildCount": 3,
"UserData": {
"UnplayedItemCount": 9,
"PlayCount": 0,
"IsFavorite": false
}
}
],
"TotalRecordCount": 1
}
@@ -0,0 +1,443 @@
import Foundation
import Testing
import MediaKit
@testable import JellyfinKit
/// Was die App dem Server zum Abspielen schickt — und was sie daraus macht.
///
/// Adressen und Anfragekörper entstehen ohne Netz und lassen sich deshalb
/// genau prüfen. Genau hier lagen bei Plex die Fehler, die am teuersten waren:
/// ein Wert, der nicht kodiert wurde, ein Feld, das der Server anders nennt.
@Suite("Wiedergabe bei Jellyfin")
struct PlaybackTests {
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
let client = JellyfinClient(
baseURL: URL(string: "http://server:8096")!,
identity: JellyfinIdentity(
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
),
token: "tok",
transport: transport
)
return JellyfinBackend(
client: client,
account: JellyfinAccount(
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
),
serverName: "Attrappe"
)
}
private var file: MediaFile {
MediaFile(id: "src-1", key: "src-1", container: "mp4")
}
@Test("Unverändert: die Datei selbst, mit Token in der Adresse")
func buildsDirectPlayURL() async throws {
let target = try await backend(RecordingTransport())
.preparePlayback(PlaybackIntent(itemID: "film-1", versionIndex: 0, file: file))
let url = try #require(URLComponents(url: target.url, resolvingAgainstBaseURL: false))
#expect(url.path == "/Videos/film-1/stream")
// Der Systemplayer schickt keine Kopfzeilen mit. Steht der Token nicht
// in der Adresse, antwortet der Server mit „nicht angemeldet".
#expect(url.queryItems?.contains(URLQueryItem(name: "ApiKey", value: "tok")) == true)
#expect(url.queryItems?.contains(URLQueryItem(name: "static", value: "true")) == true)
#expect(url.queryItems?.contains(URLQueryItem(name: "mediaSourceId", value: "src-1")) == true)
// Auch ohne Umrechnung eine Sitzung: sonst ordnet Jellyfin den
// Fortschritt keiner Wiedergabe zu.
#expect(target.sessionID != nil)
#expect(target.versionID == "src-1")
}
@Test("Umgerechnet: die Adresse kommt vom Server")
func usesServerTranscodingURL() async throws {
let transport = RecordingTransport(response: """
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
"PlaySessionId": "ps-7"}
""")
let target = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1",
versionIndex: 0,
file: file,
transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720")
))
// **`master.m3u8`, nicht `main.m3u8`.** Der Pfad des Servers bleibt,
// nur die Liste wechselt: `main` führt die Schnipsel einer Stufe,
// `master` die Spuren. Nur in der zweiten darf `#EXT-X-MEDIA` stehen
// — die Zeile, mit der eine Untertitelspur angekündigt wird. Dieser
// Test hielt vorher das Gegenteil fest und war damit mitschuldig,
// dass nie ein Untertitel erschien.
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/master.m3u8"))
#expect(target.sessionID == "ps-7")
// Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu
// wenig, und aus vier Megabit werden vier Kilobit.
let asked = try #require(transport.lastRequest)
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
#expect(query.contains(URLQueryItem(name: "maxStreamingBitrate", value: "4000000")))
#expect(query.contains(URLQueryItem(name: "enableDirectPlay", value: "false")))
#expect(asked.httpMethod == "POST")
}
@Test("Lehnt der Server ab, steht der Grund im Fehler")
func refusesWithoutTranscodingURL() async throws {
let transport = RecordingTransport(response: """
{"MediaSources": [{"Id": "src-1"}], "PlaySessionId": "ps-7"}
""")
await #expect(throws: MediaBackendError.self) {
try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1",
versionIndex: 0,
file: file,
transcode: TranscodeSettings(maximumBitrate: nil, resolution: nil)
))
}
}
@Test("Die Bildhöhe ersetzt eine vorhandene Angabe, statt daneben zu stehen")
func replacesExistingHeight() {
let url = URL(string: "http://s/main.m3u8?maxHeight=1080&videoCodec=h264")!
let limited = JellyfinBackend.limiting(height: "1280x720", in: url)
let query = URLComponents(url: limited, resolvingAgainstBaseURL: false)?.queryItems ?? []
// Bei zwei gleichnamigen Werten gilt beim Server der erste — und das
// wäre dann gerade nicht der gewählte.
#expect(query.filter { $0.name.lowercased() == "maxheight" }.count == 1)
#expect(query.contains(URLQueryItem(name: "maxHeight", value: "720")))
#expect(query.contains(URLQueryItem(name: "videoCodec", value: "h264")))
}
@Test("Ohne gewählte Auflösung bleibt die Adresse, wie sie ist")
func leavesURLAloneWithoutResolution() {
let url = URL(string: "http://s/main.m3u8?videoCodec=h264")!
#expect(JellyfinBackend.limiting(height: nil, in: url) == url)
}
@Test("Der Fortschritt geht in Ticks und mit allen Kennungen hinaus")
func reportsProgressInTicks() async throws {
let transport = RecordingTransport()
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1",
sessionID: "ps-7",
versionID: "src-1",
state: .playing,
time: .seconds(90),
duration: .seconds(600)
))
let asked = try #require(transport.lastRequest)
#expect(asked.url?.path == "/Sessions/Playing/Progress")
let body = try #require(asked.httpBody)
let fields = try #require(
try JSONSerialization.jsonObject(with: body) as? [String: Any]
)
// 90 Sekunden sind 900.000.000 Ticks. Zehntausend je Millisekunde.
#expect(fields["PositionTicks"] as? Int == 900_000_000)
#expect(fields["PlaySessionId"] as? String == "ps-7")
#expect(fields["MediaSourceId"] as? String == "src-1")
#expect(fields["IsPaused"] as? Bool == false)
}
@Test("Verkleinert herunterladen ergibt eine einzelne Datei")
func buildsCompressedDownloadURL() throws {
let url = try #require(backend(RecordingTransport()).compressedDownloadURL(
itemID: "film-1",
versionIndex: 0,
maximumBitrate: 4_000,
resolution: "1280x720"
))
// `.mp4`, nicht `.m3u8`: eine Abspielliste mit hunderten Schnipseln
// lässt sich nicht als Datei ablegen.
#expect(url.path == "/Videos/film-1/stream.mp4")
let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
// In Bit, nicht in Kilobit — eine Null zu wenig, und aus vier Megabit
// werden vier Kilobit.
#expect(query.contains(URLQueryItem(name: "videoBitRate", value: "4000000")))
#expect(query.contains(URLQueryItem(name: "maxHeight", value: "720")))
#expect(query.contains(URLQueryItem(name: "ApiKey", value: "tok")))
}
@Test("Der erste Bericht legt die Sitzung an")
func reportsStartSeparately() async throws {
let transport = RecordingTransport()
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1", state: .started, time: .zero, duration: .seconds(600)
))
// Ohne diese Meldung erscheint der Titel nirgends als „läuft gerade",
// und der Server kennt die Sitzung nicht, unter der alles Weitere
// gemeldet wird.
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing")
}
@Test("Der letzte Bericht geht an einen eigenen Endpunkt")
func reportsStopSeparately() async throws {
let transport = RecordingTransport()
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1", state: .stopped, time: .seconds(5), duration: .seconds(600)
))
// „Gestoppt" als Fortschritt zu melden nähme den Titel nicht aus
// „wird gerade abgespielt" heraus.
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing/Stopped")
}
@Test("Abräumen nennt Gerät und Sitzung")
func endsEncodingWithDeviceAndSession() async throws {
let transport = RecordingTransport()
await backend(transport).endPlayback(sessionID: "ps-7")
let asked = try #require(transport.lastRequest)
#expect(asked.httpMethod == "DELETE")
#expect(asked.url?.path == "/Videos/ActiveEncodings")
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
// Über die Sitzung allein hält Jellyfin nichts an.
#expect(query.contains(URLQueryItem(name: "deviceId", value: "gerät-1")))
#expect(query.contains(URLQueryItem(name: "playSessionId", value: "ps-7")))
}
}
/// Ein Transport, der nichts verschickt und alles behält.
private final class RecordingTransport: JellyfinTransport, @unchecked Sendable {
private let response: String
private let lock = NSLock()
private var recorded: URLRequest?
init(response: String = "{}") {
self.response = response
}
var lastRequest: URLRequest? {
lock.withLock { recorded }
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
lock.withLock { recorded = request }
return (
Data(response.utf8),
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
)
}
}
@Suite("Tonspur ausdrücklich bestellen")
struct AudioStreamIndexTests {
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
let client = JellyfinClient(
baseURL: URL(string: "http://server:8096")!,
identity: JellyfinIdentity(
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
),
token: "tok",
transport: transport
)
return JellyfinBackend(
client: client,
account: JellyfinAccount(
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
),
serverName: "Attrappe"
)
}
private func intent(audio: Int?) -> PlaybackIntent {
PlaybackIntent(
itemID: "abc",
versionIndex: 0,
file: MediaFile(id: "src", key: "src", itemID: "abc"),
transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil),
audioStreamIndex: audio
)
}
private func query(_ transport: RecordingTransport) throws -> [URLQueryItem] {
let asked = try #require(transport.lastRequest)
return URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
}
/// Ohne diese Angabe wählt der Server — und bei einem umgerechneten
/// Stream kommt danach nur noch **eine** Tonspur an. Dann ist im Player
/// nichts mehr umzustellen: die Wahl muss in der Aushandlung stattfinden
/// oder gar nicht.
@Test("Die Spurnummer steht in der Anfrage")
func indexIsSent() async throws {
let transport = RecordingTransport()
_ = try? await backend(transport).preparePlayback(intent(audio: 3))
let werte = try query(transport)
#expect(werte.contains(URLQueryItem(name: "audioStreamIndex", value: "3")))
}
@Test("Ohne Wunsch steht sie nicht da")
func indexIsOmitted() async throws {
let transport = RecordingTransport()
_ = try? await backend(transport).preparePlayback(intent(audio: nil))
let werte = try query(transport)
#expect(!werte.contains { $0.name == "audioStreamIndex" })
}
}
@Suite("Untertitel ausdrücklich bestellen")
struct SubtitleStreamIndexTests {
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
JellyfinBackend(
client: JellyfinClient(
baseURL: URL(string: "http://server:8096")!,
identity: JellyfinIdentity(
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
),
token: "tok",
transport: transport
),
account: JellyfinAccount(
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
),
serverName: "Attrappe"
)
}
private func intent(subtitle: Int?) -> PlaybackIntent {
PlaybackIntent(
itemID: "abc",
versionIndex: 0,
file: MediaFile(id: "src", key: "src", itemID: "abc"),
transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil),
subtitleStreamIndex: subtitle
)
}
private func query(_ transport: RecordingTransport) throws -> [URLQueryItem] {
let asked = try #require(transport.lastRequest)
return URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
}
@Test("Die Nummer steht in der Anfrage")
func indexIsSent() async throws {
let transport = RecordingTransport()
_ = try? await backend(transport).preparePlayback(intent(subtitle: 4))
#expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "4")))
}
/// **Der Unterschied, der zählt.** Ohne Angabe sucht der Server selbst
/// einen aus; „keine" muss man ihm ausdrücklich sagen, sonst steht
/// plötzlich ein Untertitel im Bild, den niemand bestellt hat.
@Test("Minus eins heisst ausdrücklich keine")
func minusOneMeansNone() async throws {
let transport = RecordingTransport()
_ = try? await backend(transport).preparePlayback(intent(subtitle: -1))
#expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "-1")))
}
@Test("Ohne Angabe steht sie nicht da")
func indexIsOmitted() async throws {
let transport = RecordingTransport()
_ = try? await backend(transport).preparePlayback(intent(subtitle: nil))
#expect(!(try query(transport)).contains { $0.name == "subtitleStreamIndex" })
}
}
@Suite("Die Master-Liste, auch ohne Leiter")
struct MasterPlaylistTests {
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
JellyfinBackend(
client: JellyfinClient(
baseURL: URL(string: "http://server:8096")!,
identity: JellyfinIdentity(
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
),
token: "tok",
transport: transport
),
account: JellyfinAccount(
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
),
serverName: "Attrappe"
)
}
private func transport() -> RecordingTransport {
RecordingTransport(response: """
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
"PlaySessionId": "ps-7"}
""")
}
private func target(remux: Bool, adaptive: Bool) async throws -> URL {
try await backend(transport()).preparePlayback(PlaybackIntent(
itemID: "film-1",
versionIndex: 0,
file: MediaFile(id: "src-1", key: "src-1", container: "mkv"),
transcode: TranscodeSettings(
maximumBitrate: nil,
resolution: nil,
prefersAdaptive: adaptive,
prefersRemux: remux
)
)).url
}
/// **Der Fehler, wegen dem nie ein Untertitel erschien.** `main.m3u8` ist
/// eine Liste von Schnipseln; `#EXT-X-MEDIA` — die Zeile, mit der ein
/// Server eine Untertitelspur ankündigt — darf nach RFC 8216 nur in einer
/// Master-Liste stehen. Auf `main.m3u8` kann es strukturell keine
/// Untertitel geben. Und ein Matroska geht immer über das Umpacken.
@Test("Umgepackt kommt die Master-Liste")
func remuxGetsMaster() async throws {
let adresse = try await target(remux: true, adaptive: false)
#expect(adresse.lastPathComponent == "master.m3u8")
}
/// Die Leiter ist eine **andere** Frage als die Master-Liste. Beim
/// Umpacken gibt es nur eine Fassung — sie zu bestellen wäre sinnlos.
@Test("Umgepackt aber keine Leiter")
func remuxHasNoLadder() async throws {
let adresse = try await target(remux: true, adaptive: true)
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
#expect(adresse.lastPathComponent == "master.m3u8")
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
}
/// Auch eine von Hand gewählte Stufe braucht die Master-Liste — sonst
/// verliert man die Untertitel, sobald man die Qualität einstellt.
@Test("Eine feste Stufe bekommt sie auch")
func fixedRungGetsMaster() async throws {
let adresse = try await target(remux: false, adaptive: false)
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
#expect(adresse.lastPathComponent == "master.m3u8")
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
}
@Test("Bei automatisch kommt zusätzlich die Leiter")
func automaticGetsLadder() async throws {
let adresse = try await target(remux: false, adaptive: true)
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
#expect(adresse.lastPathComponent == "master.m3u8")
#expect(werte.contains(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true")))
}
/// Heisst die Adresse schon so, bleibt sie — und wird nicht doppelt
/// umgeschrieben.
@Test("Was schon Master heisst, bleibt es")
func masterStaysMaster() {
let schon = URL(string: "http://s/Videos/x/master.m3u8?api_key=t")!
#expect(JellyfinBackend.masterPlaylist(schon) == schon)
}
}
@@ -0,0 +1,223 @@
import Foundation
import Testing
import MediaKit
@testable import JellyfinKit
/// Die Übersetzung der Jellyfin-Antworten in die neutrale Form.
///
/// Gegen aufgezeichnete Antworten, weil genau dort die Fallen liegen: Zeiten
/// in Ticks, großgeschriebene Feldnamen, und Zählwerte, die das Gegenteil von
/// dem meinen, was man erwartet.
@Suite("Übersetzung aus Jellyfin")
struct TranslationTests {
private func item(_ fixture: String) throws -> JellyfinItem {
let url = try #require(Bundle.module.url(forResource: "Fixtures/\(fixture)", withExtension: "json"))
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let page = try decoder.decode(JellyfinItemPage.self, from: try Data(contentsOf: url))
return try #require(page.items.first)
}
private func media(_ fixture: String) throws -> MediaItem {
try item(fixture).asMediaItem(imageBase: JellyfinImagePaths())
}
@Test("Ticks werden zu Millisekunden")
func convertsTicks() throws {
let episode = try media("episode")
// 26.400.000.000 Ticks sind 44 Minuten. Zehntausend Ticks je
// Millisekunde — verrechnet man sich hier um eine Null, zeigt die App
// Laufzeiten von siebeneinhalb Stunden.
#expect(episode.duration == 2_640_000)
#expect(episode.resumeMilliseconds == 600_000)
}
@Test("Der Fortschritt ergibt sich richtig")
func computesProgress() throws {
let episode = try media("episode")
let progress = try #require(episode.watchProgress)
// Zehn Minuten von 44 — knapp 23 Prozent.
#expect(abs(progress - 10.0 / 44.0) < 0.001)
}
@Test("Serie, Staffel und Nummern kommen an")
func translatesHierarchy() throws {
let episode = try media("episode")
#expect(episode.kind == .episode)
#expect(episode.title == "Der Anfang")
#expect(episode.seriesTitle == "Eine Serie")
#expect(episode.seriesID == "series-1")
#expect(episode.parentTitle == "Staffel 2")
#expect(episode.index == 3)
#expect(episode.parentIndex == 2)
}
@Test("Favoriten gibt es hier wirklich")
func readsFavourite() throws {
// Das Feld, das bei Plex immer false ist.
#expect(try media("episode").isFavourite)
}
@Test("Aus ungesehenen Folgen werden gesehene")
func invertsUnplayedCount() throws {
let series = try media("series")
// Jellyfin meldet **ungesehene** Folgen. Die Zahl, die die Oberfläche
// zeigt, ist die andere: 24 minus 9 sind 15 gesehene. Ohne diese
// Umkehrung stünde bei einer fast fertigen Serie „9 von 24".
#expect(series.episodeCount == 24)
#expect(series.playedEpisodeCount == 15)
}
@Test("Bitrate kommt in Kilobit an, nicht in Bit")
func convertsBitrate() throws {
let version = try #require(try media("episode").versions.first)
// Jellyfin rechnet in Bit je Sekunde. Ungeprüft übernommen stünde in
// der App „12000000 Mbit/s".
#expect(version.bitrate == 12_000)
#expect(version.qualityLabel.contains("12 Mbit/s"))
}
@Test("Die Fassung trägt Codecs und Spuren")
func translatesVersion() throws {
let version = try #require(try media("episode").versions.first)
let file = try #require(version.files.first)
#expect(version.container == "mkv")
#expect(version.videoCodec == "hevc")
#expect(version.pixelHeight == 2_160)
#expect(version.resolutionLabel == "4K")
// Die gewählte Tonspur ist die als Standard markierte.
#expect(file.selectedAudioStream?.codec == "dts")
#expect(file.subtitleStreams.count == 1)
#expect(file.size == 5_368_709_120)
}
@Test("Das Wiedergabe-Profil beschreibt, was iOS nicht kann")
func buildsProfile() throws {
let version = try #require(try media("episode").versions.first)
let profile = version.profile(file: version.files.first)
// MKV mit DTS: genau der Fall, für den es den zweiten Player gibt.
#expect(profile.container == "mkv")
#expect(profile.audioCodec == "dts")
#expect(profile.hasKnownStreams)
}
@Test("Besetzung und Regie werden getrennt")
func separatesPeople() throws {
let episode = try media("episode")
#expect(episode.cast.count == 1)
#expect(episode.cast.first?.role == "Die Hauptrolle")
#expect(episode.directors == ["Wer Regie führte"])
#expect(episode.genres == ["Drama", "Mystery"])
}
@Test("Bildadressen tragen ihre Marke")
func buildsImagePaths() throws {
let episode = try media("episode")
// Ohne die Marke liefert der Server zwar ein Bild, aber der
// Zwischenspeicher merkt nicht, wenn es sich ändert.
#expect(episode.thumb == "/Items/a1b2c3d4e5f6/Images/Primary?tag=tag-primary")
#expect(episode.clearLogoPath == "/Items/a1b2c3d4e5f6/Images/Logo?tag=tag-logo")
#expect(episode.seriesThumb == "/Items/series-1/Images/Primary?tag=tag-series")
#expect(episode.art == "/Items/a1b2c3d4e5f6/Images/Backdrop?tag=tag-backdrop")
}
@Test("Ohne eigenes Hintergrundbild kommt es von der Serie")
func borrowsBackdropFromParent() throws {
let item = try decode("""
{"Id": "ohne-bild", "Name": "Folge ohne Bild", "Type": "Episode",
"ParentBackdropItemId": "series-1"}
""")
// Die Bildadressen entstehen aus der Kennung, nicht aus einer Antwort
// des Servers. Ohne diese Unterscheidung stünde hinter jeder Folge
// eine Adresse, die mit „nicht gefunden" antwortet — und das Feld, das
// Jellyfin dafür mitschickt, wäre totes Gewicht.
#expect(item.art == "/Items/series-1/Images/Backdrop")
}
@Test("Gibt es weder eigenes noch geliehenes Bild, bleibt das Feld leer")
func leavesBackdropEmpty() throws {
let item = try decode("""
{"Id": "ganz-ohne", "Name": "Nichts da", "Type": "Movie"}
""")
#expect(item.art == nil)
}
@Test("Die Neuzugänge kommen als nacktes Feld, nicht als Objekt")
func decodesBareArray() throws {
// `/Users/{id}/Items/Latest` antwortet anders als jede andere Liste:
// ohne `Items`-Hülle und ohne Gesamtzahl. Wer das übersieht, bekommt
// einen Entschlüsselungsfehler statt einer Reihe — und die Startseite
// bliebe leer, ohne dass klar wäre, warum.
let json = """
[{"Id": "neu-1", "Name": "Frisch dabei", "Type": "Movie"},
{"Id": "neu-2", "Name": "Auch neu", "Type": "Series"}]
"""
let items = try JSONDecoder().decode([JellyfinItem].self, from: Data(json.utf8))
#expect(items.map(\.id) == ["neu-1", "neu-2"])
#expect(items.map { $0.asMediaItem(imageBase: JellyfinImagePaths()).kind } == [.movie, .show])
}
/// Ein einzelner Eintrag, wie er über die Leitung käme.
///
/// Für die Fälle, die in den aufgezeichneten Antworten nicht vorkommen —
/// eine Folge ohne eigenes Bild etwa. Eine weitere Datei dafür anzulegen
/// verstellt den Blick darauf, worum es im Test geht.
private func decode(_ json: String) throws -> MediaItem {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let item = try decoder.decode(JellyfinItem.self, from: Data(json.utf8))
return item.asMediaItem(imageBase: JellyfinImagePaths())
}
}
@Suite("Die Art einer Mediathek")
struct LibraryKindTests {
@Test(
"Die bekannten Arten",
arguments: [
("movies", MediaLibrary.Kind.movie),
("tvshows", .show),
("music", .music),
("photos", .photo),
("livetv", .liveTV),
]
)
func mapsKnownTypes(type: String, expected: MediaLibrary.Kind) {
#expect(MediaLibrary.Kind(jellyfinCollectionType: type) == expected)
}
/// **Der Fall, der eine ganze Mediathek verschwinden liess.** Jellyfin
/// nennt „Heimvideos & Fotos" `homevideos`; weil Fotos im Namen stehen,
/// lag es bei den Fotos — und fiel damit unter `isSupported` heraus. Die
/// Mediathek war in der App nicht vorhanden, nicht einmal in der Auswahl
/// für den Aufmacher.
@Test("Heimvideos sind Videos, keine Fotos")
func homeVideosAreWatchable() {
let art = MediaLibrary.Kind(jellyfinCollectionType: "homevideos")
#expect(art == .other)
#expect(MediaLibrary(id: "1", title: "Videos", kind: art).isSupported)
}
/// Eine gemischte Mediathek meldet gar keine Art. Sie deshalb
/// wegzulassen hiesse, den häufigsten selbstgebauten Fall zu übersehen.
@Test("Ohne Angabe bleibt sie trotzdem da")
func unknownStays() {
let art = MediaLibrary.Kind(jellyfinCollectionType: nil)
#expect(art == .other)
#expect(MediaLibrary(id: "1", title: "Gemischtes", kind: art).isSupported)
}
}
@@ -0,0 +1,104 @@
import Foundation
import Testing
@testable import MediaKit
private func hub(_ identifier: String) -> MediaRowGroup {
MediaRowGroup(id: identifier, title: identifier.capitalized)
}
@Suite("Anordnung der Startseiten-Reihen")
struct ArrangementTests {
private let serverHubs = [hub("continue"), hub("recent"), hub("watchlist"), hub("collections")]
@Test("Ohne Einstellungen bleibt die Reihenfolge des Servers")
func defaultKeepsServerOrder() {
let result = Arrangement().apply(to: serverHubs)
#expect(result.map(\.id) == ["continue", "recent", "watchlist", "collections"])
}
@Test("Ausgeblendete Reihen verschwinden")
func hiddenRowsAreRemoved() {
let arrangement = Arrangement(hidden: ["watchlist", "collections"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.id) == ["continue", "recent"])
}
@Test("Die gespeicherte Reihenfolge wird angewendet")
func storedOrderIsApplied() {
let arrangement = Arrangement(order: ["watchlist", "continue", "recent", "collections"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.id) == ["watchlist", "continue", "recent", "collections"])
}
@Test("Neue Reihen vom Server landen am Ende statt zu verschwinden")
func unknownRowsAppearAtTheEnd() {
// Der Fall, der die einfachere Regel — nur Bekanntes anzeigen — zur
// Falle macht: ein neu angelegtes Regal auf dem Server wäre in der App
// nie aufgetaucht, ohne dass jemand den Grund gesehen hätte.
let arrangement = Arrangement(order: ["recent", "continue"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"])
}
@Test("Mehrere neue Reihen behalten die Reihenfolge des Servers")
func multipleUnknownRowsKeepServerOrder() {
// Ohne stabile Rückfalllinie wäre ihre Anordnung zufällig — und
// änderte sich bei jedem Start.
let arrangement = Arrangement(order: ["collections"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.id) == ["collections", "continue", "recent", "watchlist"])
}
@Test("Gespeicherte Reihen, die es nicht mehr gibt, stören nicht")
func staleOrderEntriesAreIgnored() {
// Entsteht beim Löschen einer Mediathek auf dem Server.
let arrangement = Arrangement(order: ["geloescht", "recent", "continue"])
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"])
}
@Test("Ein doppelter Eintrag in der Reihenfolge stürzt nicht ab")
func duplicateOrderEntriesAreTolerated() {
// Kann nach einem abgebrochenen Schreibvorgang entstehen. Ein
// Dictionary aus doppelten Schlüsseln wäre ein harter Absturz.
let arrangement = Arrangement(order: ["recent", "recent", "continue"])
let result = arrangement.apply(to: serverHubs)
#expect(result.first?.id == "recent")
#expect(result.count == 4)
}
@Test("Ausblenden und Sortieren greifen zusammen")
func hidingAndOrderingCombine() {
let arrangement = Arrangement(
hidden: ["recent"],
order: ["collections", "recent", "continue"]
)
let result = arrangement.apply(to: serverHubs)
#expect(result.map(\.id) == ["collections", "continue", "watchlist"])
}
// MARK: - Die Liste für die Einstellungen
@Test("Der Einstellungsbildschirm zeigt Gespeichertes zuerst")
func settingsListPutsStoredFirst() {
let arrangement = Arrangement(order: ["watchlist", "continue"])
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
#expect(identifiers == ["watchlist", "continue", "recent", "collections"])
}
@Test("Der Einstellungsbildschirm zeigt auch ausgeblendete Reihen")
func settingsListIncludesHidden() {
// Sonst ließe sich eine einmal ausgeblendete Reihe nie wieder
// einschalten — eine Einbahnstraße.
let arrangement = Arrangement(hidden: ["recent"])
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
#expect(identifiers.contains("recent"))
}
@Test("Verschwundene Reihen erscheinen nicht in den Einstellungen")
func settingsListDropsStaleEntries() {
let arrangement = Arrangement(order: ["geloescht", "recent"])
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
#expect(!identifiers.contains("geloescht"))
}
}
@@ -0,0 +1,28 @@
import Foundation
import Testing
@testable import MediaKit
/// Die Umrechnung von `Duration` in die Einheiten der Server.
@Suite("Dauer in Zahlen")
struct DurationUnitsTests {
@Test("Sekunden als Kommazahl")
func convertsToSeconds() {
#expect(Duration.seconds(90).seconds == 90.0)
#expect(Duration.milliseconds(1_500).seconds == 1.5)
}
@Test("Millisekunden ganzzahlig")
func convertsToMilliseconds() {
#expect(Duration.seconds(2).milliseconds == 2_000)
#expect(Duration.milliseconds(1_234).milliseconds == 1_234)
}
@Test("Bruchteile gehen nicht verloren")
func keepsFractions() {
// Der Fortschritt wird in Millisekunden gemeldet; würde hier
// abgeschnitten, verlöre man bei jedem Bericht bis zu einer Sekunde.
#expect(Duration.milliseconds(1).milliseconds == 1)
#expect(Duration.milliseconds(1).seconds == 0.001)
}
}
@@ -0,0 +1,193 @@
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)
}
}
@@ -0,0 +1,178 @@
import Foundation
import Testing
@testable import MediaKit
@Suite("Auswahl für den Aufmacher")
struct HeroSelectionTests {
private func titel(_ id: String, backdrop: Bool = true) -> MediaItem {
MediaItem(id: id, kind: .movie, title: "Film \(id)", art: backdrop ? "/art/\(id)" : nil)
}
private var zwanzig: [MediaItem] { (1...20).map { titel(String($0)) } }
/// **Der Fehler, wegen dem es diesen Typ gibt.** Dieselbe Liste, dasselbe
/// Zeitfenster, zweimal gefragt — und zweimal dasselbe. Vorher kam das
/// nur heraus, solange die Liste sich nicht um einen einzigen Eintrag
/// unterschied.
@Test("Gleiche Eingabe, gleiches Ergebnis")
func stableWithinWindow() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
#expect(
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
== HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
)
}
/// Und der eigentliche Punkt: ein Titel mehr darf die übrigen nicht
/// durcheinanderbringen. Genau das ist bei jedem Neustart passiert —
/// „Weiterschauen" wächst und schrumpft ständig.
@Test("Ein zusätzlicher Titel verschiebt die anderen nicht")
func addingOneKeepsTheRest() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
let nachher = HeroSelection.ordered(zwanzig + [titel("99")], at: jetzt).map(\.id)
// Der Neue darf irgendwo stehen; die übrigen müssen in derselben
// Reihenfolge zueinander stehen wie vorher.
#expect(nachher.filter { $0 != "99" } == vorher)
}
@Test("Ein fehlender Titel hinterlässt nur eine Lücke")
func removingOneKeepsTheRest() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
let ohne = HeroSelection.ordered(zwanzig.filter { $0.id != "7" }, at: jetzt).map(\.id)
#expect(ohne == vorher.filter { $0 != "7" })
}
/// Sechs Stunden später eine andere Auswahl — sonst wäre es keine
/// Abwechslung, sondern eine feste Reihenfolge.
@Test("Im nächsten Zeitfenster steht es anders")
func changesAcrossWindows() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let später = jetzt.addingTimeInterval(7 * 3_600)
#expect(
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
!= HeroSelection.ordered(zwanzig, at: später).map(\.id)
)
}
@Test("Eine Stunde später steht es noch gleich")
func stableAcrossAnHour() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
#expect(
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
== HeroSelection.ordered(zwanzig, at: jetzt.addingTimeInterval(1_800)).map(\.id)
)
}
/// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
/// Ausschliessen wäre zu hart — schlechter losen ist genug.
@Test("Titel ohne Hintergrundbild landen meistens hinten")
func backdropsWin() {
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
let gemischt = (1...10).map { titel("mit\($0)") }
+ (1...10).map { titel("ohne\($0)", backdrop: false) }
let ersteFünf = HeroSelection.ordered(gemischt, at: jetzt).prefix(5).map(\.id)
#expect(ersteFünf.allSatisfy { $0.hasPrefix("mit") })
}
/// Gäbe es nur Titel ohne Bild, dürfte nicht einfach nichts kommen.
@Test("Gibt es nur Titel ohne Bild, kommen sie trotzdem")
func withoutBackdropsStillReturns() {
let ohne = (1...5).map { titel(String($0), backdrop: false) }
#expect(HeroSelection.ordered(ohne).count == 5)
}
@Test("Eine leere Liste bleibt leer")
func emptyStaysEmpty() {
#expect(HeroSelection.ordered([]).isEmpty)
}
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig —
/// damit wäre die Auswahl nach jedem Start eine andere, also genau der
/// Fehler, der hier behoben werden soll.
@Test("Der Streuwert hängt nur am Text")
func hashIsStable() {
#expect(HeroSelection.stableHash("abc") == HeroSelection.stableHash("abc"))
#expect(HeroSelection.stableHash("abc") != HeroSelection.stableHash("abd"))
}
@Test("Die Lose liegen zwischen null und eins")
func lotsAreInRange() {
for id in 1...200 {
let los = HeroSelection.unitRandom(seed: UInt64(id))
#expect(los >= 0 && los < 1)
}
}
}
@Suite("Mediatheken für den Aufmacher")
struct HeroLibraryFilterTests {
private func reihe(_ id: String, library: String? = nil) -> MediaRowGroup {
MediaRowGroup(
id: id,
title: id,
libraryID: library,
items: [MediaItem(id: "\(id).1", kind: .movie, title: "Titel")]
)
}
private var alle: [MediaRowGroup] {
[reihe("resume"), reihe("latest.filme", library: "filme"),
reihe("latest.serien", library: "serien"), reihe("favourites")]
}
/// **Der wichtigste Fall.** Wer nichts ankreuzt, meint „mir ist es
/// gleich" — nicht „nimm nichts". Ein leerer Aufmacher wäre hier der
/// Fehler, den niemand mit der Einstellung in Verbindung brächte.
@Test("Ohne Auswahl bleibt alles")
func emptySelectionKeepsEverything() {
#expect(HeroSelection.rows(alle, limitedTo: []).map(\.id) == alle.map(\.id))
}
@Test("Eine gewählte Mediathek lässt nur ihre Reihe durch")
func selectionKeepsOnlyThatLibrary() {
let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id)
#expect(übrig.contains("latest.filme"))
#expect(!übrig.contains("latest.serien"))
}
/// **Die Regel, die zuerst andersherum stand.** Weiterschauen und
/// Favoriten tragen keine Mediathek; sie trotzdem durchzulassen hiess,
/// dass bei „nur Filme" weiter eine Serie oben stand — und niemand
/// brachte das mit der Einstellung in Verbindung.
@Test("Reihen ohne Mediathek fallen weg, sobald etwas gewählt ist")
func rowsWithoutLibraryDropOutOnSelection() {
let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id)
#expect(übrig == ["latest.filme"])
}
/// **Der Fall, der den Aufmacher verschwinden liess.** Eine Mediathek,
/// die es nicht mehr gibt — umbenannt, gelöscht, Server gewechselt —,
/// oder ein Server, dessen Reihen gar keine Mediathek nennen. Bliebe es
/// bei „nichts übrig", stünde oben nichts mehr, und niemand brächte das
/// mit einer Einstellung zwei Bildschirme weiter in Verbindung.
@Test("Trifft die Auswahl nichts, gilt sie als wirkungslos")
func unknownSelectionFallsBack() {
#expect(HeroSelection.rows(alle, limitedTo: ["gibtsnicht"]).map(\.id) == alle.map(\.id))
}
/// Reihen ohne Mediathek allein sind kein Rückfall: gibt es daneben
/// passende, bleiben nur die. Sonst wäre die Ausnahme oben die Regel.
@Test("Der Rückfall greift nur, wenn wirklich nichts passt")
func fallbackOnlyWhenEmpty() {
let übrig = HeroSelection.rows(alle, limitedTo: ["serien"]).map(\.id)
#expect(übrig == ["latest.serien"])
}
@Test("Mehrere gewählte Mediatheken kommen alle durch")
func multipleSelection() {
let übrig = HeroSelection.rows(alle, limitedTo: ["filme", "serien"]).map(\.id)
#expect(übrig == ["latest.filme", "latest.serien"])
}
}
@@ -0,0 +1,232 @@
import Foundation
import Testing
@testable import MediaKit
@Suite("Gespeicherte Mediatheken")
struct LibraryCacheTests {
/// Jeder Test bekommt ein eigenes Verzeichnis. Ein gemeinsames hiesse,
/// dass die Reihenfolge der Tests über ihr Ergebnis mitentscheidet.
private func frischerSpeicher() -> LibraryCache {
let ort = URL.temporaryDirectory.appending(path: "halo-cache-\(UUID().uuidString)")
return LibraryCache(root: ort)
}
private func titel(_ id: String, _ name: String) -> MediaItem {
MediaItem(id: id, kind: .movie, title: name, year: 1999, thumb: "/bild/\(id)")
}
@Test("Was hineingeht, kommt heraus")
func roundTrip() async {
let speicher = frischerSpeicher()
let abzug = LibraryCache.Snapshot(
queryKey: "titleAscending||||||",
items: [titel("1", "Matrix"), titel("2", "Fight Club")],
totalCount: 412
)
await speicher.store(abzug, server: "jellyfin:daheim", library: "filme")
let zurück = await speicher.snapshot(
server: "jellyfin:daheim", library: "filme", queryKey: "titleAscending||||||"
)
#expect(zurück?.items.map(\.title) == ["Matrix", "Fight Club"])
// Ohne die Gesamtzahl wüsste die Ansicht nicht, ob noch etwas kommt.
#expect(zurück?.totalCount == 412)
#expect(zurück?.items.first?.thumb == "/bild/1")
}
/// Der Fall, der stillschweigend falsche Listen zeigen würde: derselbe
/// Ort, andere Sortierung.
@Test("Ein Abzug zu einer anderen Abfrage gilt nicht")
func wrongQueryIsNoAnswer() async {
let speicher = frischerSpeicher()
await speicher.store(
LibraryCache.Snapshot(queryKey: "titleAscending||||||", items: [titel("1", "A")], totalCount: 1),
server: "s", library: "l"
)
#expect(await speicher.snapshot(server: "s", library: "l", queryKey: "recentlyAdded||||||") == nil)
}
@Test("Ohne Abzug kommt nichts")
func emptyIsEmpty() async {
#expect(await frischerSpeicher().snapshot(server: "s", library: "l", queryKey: "x") == nil)
}
@Test("Zwei Mediatheken kommen sich nicht ins Gehege")
func librariesAreSeparate() async {
let speicher = frischerSpeicher()
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Film")], totalCount: 1),
server: "s", library: "filme"
)
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [titel("2", "Serie")], totalCount: 1),
server: "s", library: "serien"
)
#expect(await speicher.snapshot(server: "s", library: "filme", queryKey: "k")?.items.first?.title == "Film")
#expect(await speicher.snapshot(server: "s", library: "serien", queryKey: "k")?.items.first?.title == "Serie")
}
/// Dieselbe Mediathek auf zwei Servern hat dieselbe Kennung. Ohne Trennung
/// nach Server zeigte der eine die Titel des anderen.
@Test("Zwei Server kommen sich nicht ins Gehege")
func serversAreSeparate() async {
let speicher = frischerSpeicher()
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Daheim")], totalCount: 1),
server: "jellyfin:daheim", library: "1"
)
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Fremd")], totalCount: 1),
server: "jellyfin:fremd", library: "1"
)
#expect(await speicher.snapshot(server: "jellyfin:daheim", library: "1", queryKey: "k")?.items.first?.title == "Daheim")
}
@Test("Mehr als eine Seite wird nicht behalten")
func onlyOnePage() async {
let speicher = frischerSpeicher()
let viele = (0..<500).map { titel(String($0), "Titel \($0)") }
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: viele, totalCount: 500),
server: "s", library: "l"
)
let zurück = await speicher.snapshot(server: "s", library: "l", queryKey: "k")
#expect(zurück?.items.count == LibraryCache.pageLimit)
// Die Gesamtzahl bleibt, sonst hielte die Ansicht sechzig für alles.
#expect(zurück?.totalCount == 500)
}
/// Was eine Kachel nicht zeigt, wird nicht gespeichert — sonst wäre jeder
/// Abzug das Zwanzigfache gross und müsste beim Aufrufen ausgepackt
/// werden, also genau da, wo er Zeit sparen soll.
@Test("Gespeichert wird nur, was eine Kachel zeigt")
func storesOnlyWhatATileShows() async {
let speicher = frischerSpeicher()
let voll = MediaItem(
id: "1",
kind: .movie,
title: "Matrix",
summary: "Ein langer Text, der im Raster nirgends steht.",
duration: 120_000,
thumb: "/bild/1",
resumeMilliseconds: 60_000,
versions: [MediaVersion(id: "v1", videoCodec: "hevc", width: 3_840, height: 2_160)],
genres: ["Science-Fiction"],
cast: [MediaPerson(id: "p1", name: "Jemand")]
)
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [voll], totalCount: 1),
server: "s", library: "l"
)
let zurück = try! #require(
await speicher.snapshot(server: "s", library: "l", queryKey: "k")
).items[0]
// Was die Kachel braucht, ist da.
#expect(zurück.title == "Matrix")
#expect(zurück.thumb == "/bild/1")
#expect(zurück.watchProgress == 0.5)
// Was sie nicht braucht, ist weg.
#expect(zurück.versions.isEmpty)
#expect(zurück.cast.isEmpty)
#expect(zurück.genres.isEmpty)
#expect(zurück.summary == nil)
}
@Test("Abmelden räumt alles weg")
func signOutClears() async {
let speicher = frischerSpeicher()
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "A")], totalCount: 1),
server: "s", library: "l"
)
await speicher.storeLibraries([MediaLibrary(id: "l", title: "Filme", kind: .movie)], server: "s")
await speicher.clearAll()
#expect(await speicher.snapshot(server: "s", library: "l", queryKey: "k") == nil)
#expect(await speicher.libraries(server: "s") == nil)
}
@Test("Einen Server vergessen lässt den anderen stehen")
func clearIsPerServer() async {
let speicher = frischerSpeicher()
for server in ["a", "b"] {
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", server)], totalCount: 1),
server: server, library: "l"
)
}
await speicher.clear(server: "a")
#expect(await speicher.snapshot(server: "a", library: "l", queryKey: "k") == nil)
#expect(await speicher.snapshot(server: "b", library: "l", queryKey: "k") != nil)
}
@Test("Die Mediathekenliste überlebt")
func libraryListRoundTrip() async {
let speicher = frischerSpeicher()
let listen = [
MediaLibrary(id: "1", title: "Filme", kind: .movie, thumb: "/a"),
MediaLibrary(id: "2", title: "Serien", kind: .show),
]
await speicher.storeLibraries(listen, server: "s")
#expect(await speicher.libraries(server: "s") == listen)
}
// MARK: - Der Schlüssel
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf; ein damit
/// benannter Abzug wäre nach jedem Neustart unauffindbar, ohne dass
/// irgendetwas kaputt aussähe.
@Test("Derselbe Filter ergibt denselben Schlüssel")
func keyIsStable() {
let a = MediaQuery(sort: .recentlyAdded, unwatchedOnly: true, genre: "Krimi")
let b = MediaQuery(sort: .recentlyAdded, unwatchedOnly: true, genre: "Krimi")
#expect(a.cacheKey == b.cacheKey)
}
@Test("Jeder Unterschied ändert den Schlüssel")
func keySeparatesQueries() {
let grund = MediaQuery()
#expect(MediaQuery(sort: .recentlyAdded).cacheKey != grund.cacheKey)
#expect(MediaQuery(unwatchedOnly: true).cacheKey != grund.cacheKey)
#expect(MediaQuery(favouritesOnly: true).cacheKey != grund.cacheKey)
#expect(MediaQuery(genre: "Krimi").cacheKey != grund.cacheKey)
#expect(MediaQuery(year: 1999).cacheKey != grund.cacheKey)
#expect(MediaQuery(contentRating: "FSK 16").cacheKey != grund.cacheKey)
#expect(MediaQuery(resolution: "4k").cacheKey != grund.cacheKey)
#expect(MediaQuery(searchText: "Matrix").cacheKey != grund.cacheKey)
}
/// Kennungen kommen vom Server — Jellyfin-GUIDs, und in der
/// Serverkennung steckt eine Adresse mit Schrägstrichen.
@Test("Kennungen mit Schrägstrichen ergeben brauchbare Dateinamen")
func nameIsFilesystemSafe() async {
let speicher = frischerSpeicher()
let wild = "jellyfin:https://medien.beispiel.tld:8096/:a+b/c"
await speicher.store(
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "A")], totalCount: 1),
server: wild, library: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
)
#expect(
await speicher.snapshot(
server: wild, library: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", queryKey: "k"
)?.items.count == 1
)
#expect(!LibraryCache.safeName(wild).contains("/"))
}
}
@@ -0,0 +1,49 @@
import Testing
@testable import MediaKit
@Suite("Wann nachgeladen wird")
struct PagingWindowTests {
private let fenster = PagingWindow(columns: 6, rowsAhead: 2)
/// **Der Fall, der die alte Regel entlarvt.** Wer ganz oben steht, ist
/// nicht am Ende — auch wenn die letzten fünf Kacheln längst gezeichnet
/// sind. Genau daran lag es, dass das Nachladen sofort losging.
@Test("Am Anfang wird nicht nachgeladen")
func notAtTheStart() {
#expect(!fenster.shouldLoad(focusedIndex: 0, loaded: 60, total: 300))
}
/// Zwei Zeilen vor Schluss bei sechs Spalten: ab Eintrag 48 von 60.
@Test("Zwei Zeilen vor Schluss wird nachgeladen")
func loadsTwoRowsEarly() {
#expect(!fenster.shouldLoad(focusedIndex: 47, loaded: 60, total: 300))
#expect(fenster.shouldLoad(focusedIndex: 48, loaded: 60, total: 300))
}
/// Ein breiteres Raster braucht mehr Vorlauf — das ist der ganze Grund,
/// in Zeilen zu rechnen statt in Einträgen.
@Test("Mehr Spalten heißt früher nachladen")
func widerGridLoadsEarlier() {
let schmal = PagingWindow(columns: 3)
let breit = PagingWindow(columns: 8)
#expect(!schmal.shouldLoad(focusedIndex: 50, loaded: 60, total: 300))
#expect(breit.shouldLoad(focusedIndex: 50, loaded: 60, total: 300))
}
@Test("Ist alles da, wird nicht mehr gefragt")
func stopsWhenComplete() {
#expect(!fenster.shouldLoad(focusedIndex: 59, loaded: 60, total: 60))
}
/// Ein Raster kennt seine Spaltenzahl erst nach dem ersten Zeichnen.
/// Falsch zu antworten ist besser als durch null zu teilen.
@Test("Ohne Spalten wird nichts entschieden")
func survivesZeroColumns() {
#expect(!PagingWindow(columns: 0).shouldLoad(focusedIndex: 10, loaded: 60, total: 300))
}
@Test("Eine leere Mediathek löst nichts aus")
func emptyLibrary() {
#expect(!fenster.shouldLoad(focusedIndex: 0, loaded: 0, total: 0))
}
}
@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import MediaKit
@Suite("Der Abzug fürs Regal")
struct TopShelfSnapshotTests {
private func eintrag(_ id: String) -> TopShelfSnapshot.Entry {
.init(id: id, title: "Titel \(id)", imageURL: URL(string: "https://example.test/\(id).jpg"))
}
/// **Der Fall, der sonst erst auf dem Fernseher auffiele.** Die
/// Erweiterung entschlüsselt, was die App geschrieben hat; passt eines
/// von beiden nicht, bleibt das Regal leer — ohne Fehler, ohne Hinweis.
@Test("Was geschrieben wurde, kommt gleich wieder heraus")
func roundTrips() throws {
let original = TopShelfSnapshot(sections: [
.init(id: "continue", title: "Weiterschauen", entries: [eintrag("1"), eintrag("2")]),
])
let daten = try JSONEncoder().encode(original)
let zurück = try JSONDecoder().decode(TopShelfSnapshot.self, from: daten)
#expect(zurück == original)
}
/// Ohne App-Gruppe gibt es den geteilten Ort nicht — und bis sie im
/// Entwicklerkonto steht, ist das der Normalfall. Dann soll nichts
/// passieren, nicht abgestürzt werden.
///
/// **Geprüft wird die Harmlosigkeit, nicht das `nil`.** Dass der Ort
/// fehlt, lässt sich hier nicht behaupten: dieser Test läuft auf macOS,
/// und dort reicht das System für eine unbekannte Gruppe trotzdem einen
/// Ordner heraus — anders als auf tvOS. Die Zusage, auf die es ankommt,
/// gilt überall: beides geht durch, und gelesen wird nur, was auch
/// geschrieben wurde.
@Test("Eine unbekannte App-Gruppe bringt nichts zum Absturz")
func unknownGroupIsHarmless() {
let unbekannt = "group.halo.test.\(UUID().uuidString)"
TopShelfStore.write(TopShelfSnapshot(sections: []), group: unbekannt)
let gelesen = TopShelfStore.read(group: unbekannt)
#expect(gelesen?.sections.isEmpty ?? true)
}
/// Die Grenzen sind kein Geschmack: das Regal zeigt eine knappe
/// Bildschirmbreite, und die Erweiterung hat weniger als eine Sekunde.
@Test("Die Grenzen sind eng genug, dass es in einer Sekunde reicht")
func limitsAreTight() {
#expect(TopShelfSnapshot.sectionLimit <= 3)
#expect(TopShelfSnapshot.entryLimit <= 20)
}
}
@@ -0,0 +1,107 @@
import Testing
@testable import MediaKit
@Suite("Auflösung einstufen")
struct VideoResolutionTests {
// MARK: - Nach der Breite
@Test("Die üblichen Breiten")
func commonWidths() {
#expect(VideoResolution(width: 7_680, height: 4_320) == .uhd8K)
#expect(VideoResolution(width: 3_840, height: 2_160) == .uhd4K)
#expect(VideoResolution(width: 4_096, height: 2_160) == .uhd4K)
#expect(VideoResolution(width: 2_560, height: 1_440) == .qhd)
#expect(VideoResolution(width: 1_920, height: 1_080) == .fullHD)
#expect(VideoResolution(width: 1_280, height: 720) == .hd)
#expect(VideoResolution(width: 720, height: 576) == .sd)
}
/// Der Fall, wegen dem die Breite zuerst kommt: ein Kinofilm in 2,39:1 hat
/// 804 Zeilen. Nach der Höhe wäre das „HD" — und das stand bisher an
/// ausgerechnet den größten Filmen.
@Test("Breitwand bleibt Full HD, nicht HD")
func letterboxedStaysFullHD() {
#expect(VideoResolution(width: 1_920, height: 804) == .fullHD)
#expect(VideoResolution(width: 1_920, height: 800) == .fullHD)
}
@Test("Breitwandiges 4K bleibt 4K")
func letterboxed4K() {
#expect(VideoResolution(width: 3_840, height: 1_608) == .uhd4K)
#expect(VideoResolution(width: 3_840, height: 1_392) == .uhd4K)
}
// MARK: - Nach der Höhe, wenn es die Breite nicht gibt
@Test("Ohne Breite entscheidet die Höhe")
func fallsBackToHeight() {
#expect(VideoResolution(width: nil, height: 2_160) == .uhd4K)
#expect(VideoResolution(width: nil, height: 1_080) == .fullHD)
#expect(VideoResolution(width: nil, height: 720) == .hd)
#expect(VideoResolution(width: nil, height: 480) == .sd)
#expect(VideoResolution(width: 0, height: 1_080) == .fullHD)
}
// MARK: - Nach dem Namen des Servers
@Test("Was Plex hinschreibt")
func plexLabels() {
#expect(VideoResolution(width: nil, height: nil, serverLabel: "4k") == .uhd4K)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "1080") == .fullHD)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "720") == .hd)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "sd") == .sd)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "576") == .sd)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "4K") == .uhd4K)
}
@Test("Zeilensprung wird als Zahl gelesen")
func interlacedLabel() {
#expect(VideoResolution(width: nil, height: nil, serverLabel: "1080i") == .fullHD)
}
@Test("Ohne jede Angabe keine erfundene Stufe")
func nothingIsNothing() {
#expect(VideoResolution(width: nil, height: nil) == nil)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "") == nil)
#expect(VideoResolution(width: nil, height: nil, serverLabel: "irgendwas") == nil)
}
// MARK: - Die Namen
@Test("Die Namen, die dastehen sollen")
func names() {
#expect(VideoResolution.uhd4K.name == "4K")
#expect(VideoResolution.fullHD.name == "Full HD")
#expect(VideoResolution.hd.name == "HD")
#expect(VideoResolution.sd.name == "SD")
#expect(VideoResolution.fullHD.technicalName == "1080p")
}
/// Gebraucht für „größer als 4K" in Filtern.
@Test("Die Stufen lassen sich vergleichen")
func ordered() {
#expect(VideoResolution.sd < .hd)
#expect(VideoResolution.fullHD < .uhd4K)
#expect(VideoResolution.uhd8K > .uhd4K)
}
// MARK: - An der Fassung
@Test("Die Fassung sagt Full HD statt 1080")
func versionSpeaksPlainly() {
let fassung = MediaVersion(
id: "1", videoResolution: "1080", width: 1_920, height: 804
)
#expect(fassung.resolutionLabel == "Full HD")
#expect(fassung.qualityLabel.hasPrefix("Full HD"))
}
/// Ohne Zahlen bleibt die Angabe des Servers die einzige Quelle — sie darf
/// dann nicht verlorengehen.
@Test("Auch ohne Maße kommt ein Name heraus")
func labelWithoutNumbers() {
#expect(MediaVersion(id: "1", videoResolution: "4k").resolutionLabel == "4K")
#expect(MediaVersion(id: "1", videoResolution: "4k").pixelHeight == 2_160)
}
}