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:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user