Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -0,0 +1,206 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FlaroDesign
|
||||
|
||||
@Suite("Kontrastkorrektur")
|
||||
struct ContrastTests {
|
||||
private let canvas = FlaroColor.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, FlaroColor.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: FlaroColor.canvas)
|
||||
#expect(corrected == AccentOption.default.color)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Gespeicherte Akzentfarbe")
|
||||
struct AccentStorageTests {
|
||||
private func freshDefaults() -> UserDefaults {
|
||||
UserDefaults(suiteName: "flaro.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 = FlaroTheme(defaults: defaults)
|
||||
#expect(theme.accentColor == AccentOption.storedAccentColor(in: defaults))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Testing
|
||||
@testable import FlaroDesign
|
||||
|
||||
@Suite("Auf welchem Gerät wir laufen")
|
||||
struct FlaroPlatformTests {
|
||||
|
||||
/// **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(!(FlaroPlatform.isMac && FlaroPlatform.isTV))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FlaroDesign
|
||||
|
||||
/// 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,101 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import FlaroDesign
|
||||
|
||||
/// 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 FlaroDownloads
|
||||
|
||||
/// 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: "flaro.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: "flaro-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 FlaroDownloads
|
||||
|
||||
@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 FlaroDownloads
|
||||
|
||||
/// 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 FlaroDownloads
|
||||
|
||||
@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 FlaroDownloads
|
||||
|
||||
/// 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 FlaroPlayback
|
||||
|
||||
@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 FlaroPlayback
|
||||
|
||||
@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 FlaroPlayback
|
||||
|
||||
/// 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 FlaroPlayback
|
||||
|
||||
@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 FlaroPlayback
|
||||
|
||||
/// 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 FlaroPlayback
|
||||
|
||||
/// 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 FlaroPlayback
|
||||
|
||||
@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 FlaroPlayback
|
||||
|
||||
@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 FlaroPlayback
|
||||
|
||||
@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 FlaroPlayback
|
||||
|
||||
@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 FlaroPlayback
|
||||
|
||||
@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: "Flaro", 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: "Flaro", 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: "Flaro", 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: "Flaro", 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: "flaro-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.flaro.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