Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,263 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import MediaKit
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Sprachkürzel")
|
||||
struct LanguageCodeTests {
|
||||
/// Der Fall, an dem die alte Prüfung lautlos scheiterte: `ger` ist die
|
||||
/// ISO-639-2/B-Form von Deutsch, und ffmpeg schreibt sie in viele Dateien.
|
||||
@Test("ger, deu und de-DE meinen alle Deutsch")
|
||||
func germanHasThreeSpellings() {
|
||||
#expect(LanguageCode.matches("ger", "de"))
|
||||
#expect(LanguageCode.matches("deu", "de"))
|
||||
#expect(LanguageCode.matches("de-DE", "de"))
|
||||
#expect(LanguageCode.matches("GER", "de"))
|
||||
}
|
||||
|
||||
/// Schweizerdeutsch ist keine Schreibweise von Deutsch. Es gleichzusetzen
|
||||
/// wäre bequem und falsch — wer Hochdeutsch will, bekäme sonst
|
||||
/// Schweizerdeutsch untergeschoben.
|
||||
@Test("Schweizerdeutsch ist nicht Deutsch")
|
||||
func swissGermanStandsAlone() {
|
||||
#expect(!LanguageCode.matches("gsw", "de"))
|
||||
#expect(LanguageCode.matches("gsw", "gsw"))
|
||||
}
|
||||
|
||||
@Test("Auch die anderen Doppelformen")
|
||||
func otherDoubleForms() {
|
||||
#expect(LanguageCode.matches("fre", "fra"))
|
||||
#expect(LanguageCode.matches("dut", "nl"))
|
||||
#expect(LanguageCode.matches("cze", "cs"))
|
||||
#expect(LanguageCode.matches("chi", "zh"))
|
||||
}
|
||||
|
||||
@Test("Ohne Angabe kein Treffer")
|
||||
func missingIsNoMatch() {
|
||||
#expect(!LanguageCode.matches(nil, "de"))
|
||||
#expect(!LanguageCode.matches("de", nil))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Wahl der Tonspur")
|
||||
struct AudioTrackChooserTests {
|
||||
private func spur(
|
||||
_ index: Int,
|
||||
_ sprache: String?,
|
||||
title: String? = nil,
|
||||
channels: Int = 2,
|
||||
isDefault: Bool = false
|
||||
) -> MediaStream {
|
||||
MediaStream(
|
||||
id: String(index),
|
||||
kind: .audio,
|
||||
codec: "aac",
|
||||
language: sprache,
|
||||
languageCode: sprache,
|
||||
displayTitle: title,
|
||||
channels: channels,
|
||||
title: title,
|
||||
isDefault: isDefault
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Der ZDF-Fall
|
||||
|
||||
/// Wörtlich der Ärger, wegen dem es den Typ gibt: die als deutsch
|
||||
/// ausgezeichnete Spur ist die mit Audiodeskription, und die als englisch
|
||||
/// ausgezeichnete ist in Wahrheit die deutsche ohne.
|
||||
private var zdf: [MediaStream] {
|
||||
[
|
||||
spur(0, "ger", title: "Deutsch (Audiodeskription)", isDefault: true),
|
||||
spur(1, "eng", title: "Deutsch"),
|
||||
]
|
||||
}
|
||||
|
||||
@Test("Audiodeskription wird gemieden, auch wenn sie Standard ist")
|
||||
func avoidsAudioDescription() {
|
||||
let gewählt = AudioTrackChooser.choose(
|
||||
from: zdf,
|
||||
wish: .init(language: "de")
|
||||
)
|
||||
#expect(gewählt?.id == "1")
|
||||
}
|
||||
|
||||
/// Ohne das Meiden gewinnt die ausgezeichnete Sprache — der Beweis, dass
|
||||
/// der Schalter tatsächlich das Ruder herumreißt und nicht nur dabeisteht.
|
||||
@Test("Ohne das Meiden gewinnt das Kürzel")
|
||||
func withoutAvoidanceTheCodeWins() {
|
||||
let gewählt = AudioTrackChooser.choose(
|
||||
from: zdf,
|
||||
wish: .init(language: "de", avoidsAudioDescription: false)
|
||||
)
|
||||
#expect(gewählt?.id == "0")
|
||||
}
|
||||
|
||||
/// Und wenn der Nutzer einmal gewählt hat, gilt das — auch gegen die
|
||||
/// Sprache.
|
||||
@Test("Die gemerkte Spur schlägt den Sprachwunsch")
|
||||
func rememberedBeatsLanguage() {
|
||||
let gemerkt = AudioTrackChooser.TrackSignature(zdf[1])
|
||||
let gewählt = AudioTrackChooser.choose(
|
||||
from: zdf,
|
||||
wish: .init(language: "de", remembered: gemerkt, avoidsAudioDescription: false)
|
||||
)
|
||||
#expect(gewählt?.id == "1")
|
||||
}
|
||||
|
||||
// MARK: - Die Rangliste
|
||||
|
||||
@Test("Fehlt die Wunschsprache, greift die Rangliste")
|
||||
func fallbackListApplies() {
|
||||
let spuren = [spur(0, "fre"), spur(1, "eng"), spur(2, "ita")]
|
||||
let gewählt = AudioTrackChooser.choose(
|
||||
from: spuren,
|
||||
wish: .init(language: "de", fallbacks: ["gsw", "en", "fr"])
|
||||
)
|
||||
#expect(gewählt?.id == "1")
|
||||
}
|
||||
|
||||
@Test("Die Reihenfolge der Rangliste zählt")
|
||||
func fallbackOrderMatters() {
|
||||
let spuren = [spur(0, "fre"), spur(1, "eng")]
|
||||
let zuerstFranzösisch = AudioTrackChooser.choose(
|
||||
from: spuren,
|
||||
wish: .init(language: "de", fallbacks: ["fr", "en"])
|
||||
)
|
||||
#expect(zuerstFranzösisch?.id == "0")
|
||||
}
|
||||
|
||||
@Test("Schweizerdeutsch lässt sich in die Rangliste stellen")
|
||||
func swissGermanInTheList() {
|
||||
let spuren = [spur(0, "eng"), spur(1, "gsw")]
|
||||
let gewählt = AudioTrackChooser.choose(
|
||||
from: spuren,
|
||||
wish: .init(language: "de", fallbacks: ["gsw", "en"])
|
||||
)
|
||||
#expect(gewählt?.id == "1")
|
||||
}
|
||||
|
||||
// MARK: - Der Boden darunter
|
||||
|
||||
@Test("Ohne jede Übereinstimmung kommt trotzdem eine Spur")
|
||||
func alwaysAnswers() {
|
||||
let spuren = [spur(0, "jpn"), spur(1, "kor", isDefault: true)]
|
||||
let gewählt = AudioTrackChooser.choose(
|
||||
from: spuren,
|
||||
wish: .init(language: "de", fallbacks: ["en"])
|
||||
)
|
||||
// Die Standardspur — nachvollziehbar statt beliebig.
|
||||
#expect(gewählt?.id == "1")
|
||||
}
|
||||
|
||||
@Test("Gibt es die Sprache nur mit Audiodeskription, ist sie besser als nichts")
|
||||
func audioDescriptionBeatsSilence() {
|
||||
let spuren = [spur(0, "ger", title: "Deutsch (Audiodeskription)")]
|
||||
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||
#expect(gewählt?.id == "0")
|
||||
}
|
||||
|
||||
@Test("Ohne Tonspuren gibt es nichts zu wählen")
|
||||
func noAudioNoChoice() {
|
||||
let nurBild = [MediaStream(id: "0", kind: .video)]
|
||||
#expect(AudioTrackChooser.choose(from: nurBild, wish: .init()) == nil)
|
||||
}
|
||||
|
||||
@Test("Kommentarspuren werden gemieden")
|
||||
func avoidsCommentary() {
|
||||
let spuren = [
|
||||
spur(0, "ger", title: "Audiokommentar des Regisseurs", isDefault: true),
|
||||
spur(1, "ger", title: "Deutsch"),
|
||||
]
|
||||
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||
#expect(gewählt?.id == "1")
|
||||
}
|
||||
|
||||
/// „AD" als eigenes Wort, nicht als Teil von „Broadcast".
|
||||
@Test("AD erkennt nur das freistehende Wort")
|
||||
func adOnlyAsWholeWord() {
|
||||
let spuren = [
|
||||
spur(0, "ger", title: "Deutsch Broadcast Mix"),
|
||||
spur(1, "ger", title: "Deutsch AD"),
|
||||
]
|
||||
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||
#expect(gewählt?.id == "0")
|
||||
}
|
||||
|
||||
@Test("Mehr Kanäle gewinnen bei sonst gleichem Stand")
|
||||
func moreChannelsWin() {
|
||||
let spuren = [spur(0, "ger", title: "Deutsch", channels: 2),
|
||||
spur(1, "ger", title: "Deutsch", channels: 6)]
|
||||
let gewählt = AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))
|
||||
#expect(gewählt?.id == "1")
|
||||
}
|
||||
|
||||
/// Zweimal dieselbe Datei muss zweimal dasselbe ergeben — sonst ist ein
|
||||
/// Fehler nicht wiederholbar.
|
||||
@Test("Gleichstand entscheidet die Nummer")
|
||||
func tiesAreStable() {
|
||||
let spuren = [spur(1, "ger", title: "Deutsch"), spur(0, "ger", title: "Deutsch")]
|
||||
#expect(AudioTrackChooser.choose(from: spuren, wish: .init(language: "de"))?.id == "0")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Rangfolge der drei Ebenen")
|
||||
struct WishResolutionTests {
|
||||
/// Serie schlägt Mediathek schlägt allgemein. Das ist der ganze Zweck der
|
||||
/// Abstufung — und der einzige Ort, an dem sie steht.
|
||||
@Test("Die Serie gewinnt")
|
||||
func seriesWins() {
|
||||
let wunsch = AudioTrackChooser.resolve(series: "en", library: "fr", general: "de")
|
||||
#expect(wunsch.language == "en")
|
||||
}
|
||||
|
||||
@Test("Ohne Serie gewinnt die Mediathek")
|
||||
func libraryWinsWithoutSeries() {
|
||||
#expect(AudioTrackChooser.resolve(series: nil, library: "fr", general: "de").language == "fr")
|
||||
}
|
||||
|
||||
@Test("Ohne beides gilt die allgemeine")
|
||||
func generalIsTheFloor() {
|
||||
#expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "de").language == "de")
|
||||
}
|
||||
|
||||
/// In den Einstellungen steht eine leere Zeichenkette für „soll der Server
|
||||
/// entscheiden". Sie als Sprache zu behandeln hiesse, die Ebene darüber
|
||||
/// stillschweigend abzuschalten — genau der Fehler, den man erst bemerkt,
|
||||
/// wenn eine Einstellung scheinbar wirkungslos ist.
|
||||
@Test("Leer heisst nichts gesagt, nicht eine Sprache namens leer")
|
||||
func emptyIsNotAnAnswer() {
|
||||
#expect(AudioTrackChooser.resolve(series: "", library: "fr", general: "de").language == "fr")
|
||||
#expect(AudioTrackChooser.resolve(series: " ", library: "", general: "de").language == "de")
|
||||
#expect(AudioTrackChooser.resolve(series: nil, library: nil, general: "").language == nil)
|
||||
}
|
||||
|
||||
/// Die Rangliste ist keine vierte Ebene: sie gilt überall gleich und wird
|
||||
/// von keiner Ebene überstimmt.
|
||||
@Test("Rangliste und Schalter gehen unverändert durch")
|
||||
func theRestPassesThrough() {
|
||||
let wunsch = AudioTrackChooser.resolve(
|
||||
series: nil, library: nil, general: "de",
|
||||
fallbacks: ["gsw", "en"], avoidsAudioDescription: false
|
||||
)
|
||||
#expect(wunsch.fallbacks == ["gsw", "en"])
|
||||
#expect(wunsch.avoidsAudioDescription == false)
|
||||
}
|
||||
|
||||
/// Der ZDF-Fall durch die ganze Kette: die Mediathek sagt Deutsch, die
|
||||
/// gemerkte Spur sagt etwas anderes — und behält recht.
|
||||
@Test("Die gemerkte Spur überlebt die Rangfolge")
|
||||
func rememberedSurvives() {
|
||||
let spuren = [
|
||||
MediaStream(id: "0", kind: .audio, language: "ger", languageCode: "ger",
|
||||
channels: 2, title: "Deutsch (Audiodeskription)", isDefault: true),
|
||||
MediaStream(id: "1", kind: .audio, language: "eng", languageCode: "eng",
|
||||
channels: 2, title: "Deutsch"),
|
||||
]
|
||||
let wunsch = AudioTrackChooser.resolve(
|
||||
series: nil, library: "de", general: "en",
|
||||
remembered: AudioTrackChooser.TrackSignature(spuren[1])
|
||||
)
|
||||
#expect(AudioTrackChooser.choose(from: spuren, wish: wunsch)?.id == "1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Wann der teure Weg erlaubt ist")
|
||||
struct DowngradeRuleTests {
|
||||
/// Der Normalfall, in dem abgesenkt werden soll: keine Leiter da, es
|
||||
/// hakt dauerhaft, und es läuft lange genug.
|
||||
private func notlage(
|
||||
isAutomatic: Bool = true,
|
||||
hasLadder: Bool = false,
|
||||
isAtLowestRung: Bool = false,
|
||||
condition: StreamCondition = .failing,
|
||||
stalls: Int = 3,
|
||||
longest: Duration = .seconds(4),
|
||||
playedFor: Duration = .seconds(120),
|
||||
sinceLastSeek: Duration? = nil,
|
||||
sinceLastDowngrade: Duration? = nil,
|
||||
used: Int = 0,
|
||||
budget: Int = 2
|
||||
) -> DowngradeRule.Facts {
|
||||
.init(
|
||||
isAutomatic: isAutomatic,
|
||||
hasLadder: hasLadder,
|
||||
isAtLowestRung: isAtLowestRung,
|
||||
condition: condition,
|
||||
stallsInWindow: stalls,
|
||||
longestStallInWindow: longest,
|
||||
playedFor: playedFor,
|
||||
sinceLastSeek: sinceLastSeek,
|
||||
sinceLastDowngrade: sinceLastDowngrade,
|
||||
used: used,
|
||||
budget: budget
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Ohne Leiter und mit drei Rücklern: ja")
|
||||
func downgradesWhenNoLadder() {
|
||||
#expect(DowngradeRule.verdict(notlage()) == .downgrade)
|
||||
}
|
||||
|
||||
/// Eine ausdrücklich gewählte Stufe ist eine Ansage. `automatic` ist die
|
||||
/// Erlaubnis einzugreifen, alles andere nicht.
|
||||
@Test("Eine gewählte Stufe wird nicht überstimmt")
|
||||
func neverOverridesAChoice() {
|
||||
#expect(DowngradeRule.verdict(notlage(isAutomatic: false)) == .notAutomatic)
|
||||
}
|
||||
|
||||
/// Solange es noch eine Sprosse tiefer gibt, kostet der billige Hebel
|
||||
/// kein schwarzes Bild.
|
||||
@Test("Solange die Leiter trägt, bleibt es beim billigen Hebel")
|
||||
func prefersTheCheapLever() {
|
||||
let facts = notlage(hasLadder: true, isAtLowestRung: false)
|
||||
#expect(DowngradeRule.verdict(facts) == .cheaperPathRemains)
|
||||
}
|
||||
|
||||
@Test("Auf der untersten Sprosse ist der billige Hebel erschöpft")
|
||||
func lowestRungAllowsTheExpensivePath() {
|
||||
let facts = notlage(hasLadder: true, isAtLowestRung: true)
|
||||
#expect(DowngradeRule.verdict(facts) == .downgrade)
|
||||
}
|
||||
|
||||
@Test("Zwei Rückler genügen nicht")
|
||||
func twoStallsAreNotEnough() {
|
||||
#expect(DowngradeRule.verdict(notlage(stalls: 2)) == .notBadEnough)
|
||||
}
|
||||
|
||||
/// Fünfzehn Sekunden Stillstand sind keine Schwankung. Darauf zu warten,
|
||||
/// bis drei zusammenkommen, hieße viereinhalb Minuten zusehen.
|
||||
@Test("Ein einzelner langer Rückler genügt allein")
|
||||
func oneLongStallIsEnough() {
|
||||
let facts = notlage(stalls: 1, longest: .seconds(20))
|
||||
#expect(DowngradeRule.verdict(facts) == .downgrade)
|
||||
}
|
||||
|
||||
@Test("Ein gesunder Stream löst nichts aus")
|
||||
func healthyNeverTriggers() {
|
||||
#expect(DowngradeRule.verdict(notlage(condition: .healthy)) == .notBadEnough)
|
||||
}
|
||||
|
||||
/// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist
|
||||
/// keine.
|
||||
@Test("In den ersten Sekunden passiert nichts")
|
||||
func waitsAfterStart() {
|
||||
#expect(DowngradeRule.verdict(notlage(playedFor: .seconds(5))) == .tooEarly)
|
||||
}
|
||||
|
||||
/// Ein Sprung leert den Puffer immer. Sich dafür zu bestrafen wäre
|
||||
/// absurd.
|
||||
@Test("Kurz nach einem Sprung passiert nichts")
|
||||
func waitsAfterSeek() {
|
||||
let facts = notlage(sinceLastSeek: .seconds(3))
|
||||
#expect(DowngradeRule.verdict(facts) == .tooEarly)
|
||||
}
|
||||
|
||||
@Test("Ein länger zurückliegender Sprung stört nicht")
|
||||
func oldSeekIsFine() {
|
||||
#expect(DowngradeRule.verdict(notlage(sinceLastSeek: .seconds(30))) == .downgrade)
|
||||
}
|
||||
|
||||
@Test("Innerhalb der Sperrfrist nicht noch einmal")
|
||||
func respectsCooldown() {
|
||||
let facts = notlage(sinceLastDowngrade: .seconds(60), used: 1)
|
||||
#expect(DowngradeRule.verdict(facts) == .cooling)
|
||||
}
|
||||
|
||||
@Test("Nach der Sperrfrist wieder")
|
||||
func afterCooldownAgain() {
|
||||
let facts = notlage(sinceLastDowngrade: .seconds(200), used: 1)
|
||||
#expect(DowngradeRule.verdict(facts) == .downgrade)
|
||||
}
|
||||
|
||||
/// Lautlos die ganze Leiter bis 240p hinunterzugehen ist schlimmer, als
|
||||
/// es zu sagen.
|
||||
@Test("Das Budget ist endlich")
|
||||
func budgetIsFinite() {
|
||||
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 2, budget: 2)
|
||||
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
|
||||
}
|
||||
|
||||
/// Bei Live-TV nur einmal: jeder Neuaufbau stimmt den Tuner neu ab, und
|
||||
/// davon gibt es vier Stück.
|
||||
@Test("Bei Live-TV ist nach einem Mal Schluss")
|
||||
func liveBudgetIsOne() {
|
||||
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 1, budget: 1)
|
||||
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import HaloPlayback
|
||||
|
||||
/// Was der lokale Server dem Player wirklich schickt.
|
||||
///
|
||||
/// **Warum es diesen Test gibt.** Der Speicher steht zwischen Jellyfin und
|
||||
/// AVPlayer, und was dort schiefgeht, sieht von aussen aus wie „der Server
|
||||
/// liefert nichts" oder „der Player zeigt nichts". Sechs Reparaturversuche an
|
||||
/// den falschen Stellen später steht fest: ohne eine Stelle, an der man die
|
||||
/// Antwort **liest**, ist das nicht zu finden.
|
||||
///
|
||||
/// Hier steht ein eigener Herkunftsserver, der aufschreibt, was der Speicher
|
||||
/// ihn gefragt hat, und der Test liest die Antworten Byte für Byte. Kein
|
||||
/// Gerät, kein Simulator, kein echtes Jellyfin.
|
||||
@Suite("Der lokale Server", .serialized)
|
||||
struct LocalStreamServerTests {
|
||||
|
||||
// MARK: - Vorlagen
|
||||
|
||||
private static let master = """
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",LANGUAGE="ger",DEFAULT=YES,AUTOSELECT=YES,URI="subtitles/2/subtitles.m3u8?api_key=abc&SubtitleStreamIndex=2"
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080,SUBTITLES="subs"
|
||||
main.m3u8?api_key=abc
|
||||
"""
|
||||
|
||||
private static let bildListe = """
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:6.000000,
|
||||
hls1/main/0.ts?api_key=abc
|
||||
#EXTINF:6.000000,
|
||||
hls1/main/1.ts?api_key=abc
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
|
||||
private static let untertitelListe = """
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXTINF:6.000000,
|
||||
0.vtt?api_key=abc&SubtitleStreamIndex=2
|
||||
#EXTINF:6.000000,
|
||||
1.vtt?api_key=abc&SubtitleStreamIndex=2
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
|
||||
private static let vtt = """
|
||||
WEBVTT
|
||||
X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000
|
||||
|
||||
00:00:01.000 --> 00:00:04.000
|
||||
Hallo Welt.
|
||||
"""
|
||||
|
||||
/// Der Herkunftsserver mit den Antworten, die Jellyfin geben würde.
|
||||
private static func jellyfin() -> FakeOrigin {
|
||||
let server = FakeOrigin()
|
||||
server.add("/videos/x/master.m3u8", type: "application/vnd.apple.mpegurl", body: master)
|
||||
server.add("/videos/x/main.m3u8", type: "application/vnd.apple.mpegurl", body: bildListe)
|
||||
server.add("/videos/x/subtitles/2/subtitles.m3u8", type: "application/vnd.apple.mpegurl", body: untertitelListe)
|
||||
server.add("/videos/x/hls1/main/0.ts", type: "video/MP2T", body: "TS-DATEN-0")
|
||||
server.add("/videos/x/hls1/main/1.ts", type: "video/MP2T", body: "TS-DATEN-1")
|
||||
server.add("/videos/x/subtitles/2/0.vtt", type: "text/vtt", body: vtt)
|
||||
server.add("/videos/x/subtitles/2/1.vtt", type: "text/vtt", body: vtt)
|
||||
return server
|
||||
}
|
||||
|
||||
// MARK: - Der Aufbau
|
||||
|
||||
/// Startet Herkunftsserver und Speicher und gibt die Adresse zurück, die
|
||||
/// der Player bekäme.
|
||||
private func aufbauen() async throws -> (FakeOrigin, LocalStreamServer, URL) {
|
||||
let herkunft = Self.jellyfin()
|
||||
let port = try await herkunft.start()
|
||||
|
||||
let speicher = StreamCache()
|
||||
let server = LocalStreamServer(cache: speicher)
|
||||
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
|
||||
let lokal = try await server.start(upstream: oben)
|
||||
return (herkunft, server, lokal)
|
||||
}
|
||||
|
||||
/// Holt eine Adresse und gibt Status, Inhaltstyp und Körper zurück.
|
||||
private func hole(_ url: URL) async throws -> (status: Int, type: String?, body: String) {
|
||||
let (daten, antwort) = try await URLSession.shared.data(from: url)
|
||||
let http = antwort as? HTTPURLResponse
|
||||
return (
|
||||
http?.statusCode ?? 0,
|
||||
http?.value(forHTTPHeaderField: "Content-Type"),
|
||||
String(decoding: daten, as: UTF8.self)
|
||||
)
|
||||
}
|
||||
|
||||
/// Zieht die erste Adresse aus einem `URI="…"` der Liste.
|
||||
private func renditionAdresse(in liste: String, basis: URL) -> URL? {
|
||||
guard let start = liste.range(of: "URI=\""),
|
||||
let ende = liste[start.upperBound...].firstIndex(of: "\"")
|
||||
else { return nil }
|
||||
return URL(string: String(liste[start.upperBound..<ende]), relativeTo: basis)
|
||||
}
|
||||
|
||||
/// Zieht die erste Segment-Zeile aus einer Medienliste.
|
||||
private func ersteSegmentAdresse(in liste: String, basis: URL) -> URL? {
|
||||
for zeile in liste.components(separatedBy: .newlines) {
|
||||
let getrimmt = zeile.trimmingCharacters(in: .whitespaces)
|
||||
guard !getrimmt.isEmpty, !getrimmt.hasPrefix("#") else { continue }
|
||||
return URL(string: getrimmt, relativeTo: basis)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Die Tests
|
||||
|
||||
/// **Der entscheidende.** Ein WebVTT-Körper, angekündigt als `video/mp4`,
|
||||
/// geht bei AVFoundation an den falschen Parser, findet keine Box, und es
|
||||
/// entstehen keine Einblendungen — lautlos. Die Spur steht im Blatt, ist
|
||||
/// ausgewählt, und bleibt leer.
|
||||
@Test("Ein Untertitel-Schnipsel kommt als text/vtt zurück")
|
||||
func subtitleSegmentKeepsItsType() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
|
||||
let rendition = try await hole(renditionURL)
|
||||
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
|
||||
|
||||
let segment = try await hole(segmentURL)
|
||||
#expect(segment.status == 200)
|
||||
#expect(segment.body.hasPrefix("WEBVTT"))
|
||||
#expect(segment.type == "text/vtt", "geliefert wurde: \(segment.type ?? "nichts")")
|
||||
}
|
||||
|
||||
/// Gegentest, damit die Reparatur nicht zu breit wird.
|
||||
@Test("Ein Bild-Schnipsel behält seinen eigenen Typ")
|
||||
func videoSegmentKeepsItsType() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
// Die Bildstufe ist die Zeile ohne `URI=` — die erste gewöhnliche
|
||||
// Adresse in der Liste.
|
||||
let stufeURL = try #require(ersteSegmentAdresse(in: masterAntwort.body, basis: lokal))
|
||||
let stufe = try await hole(stufeURL)
|
||||
let segmentURL = try #require(ersteSegmentAdresse(in: stufe.body, basis: lokal))
|
||||
|
||||
let segment = try await hole(segmentURL)
|
||||
#expect(segment.status == 200)
|
||||
#expect(segment.type == "video/MP2T", "geliefert wurde: \(segment.type ?? "nichts")")
|
||||
}
|
||||
|
||||
/// Die Abfrage des Servers wandert durch unsere eigene Abfrage hindurch.
|
||||
/// Verliert `URLComponents` dabei etwas, holt der Speicher die falsche
|
||||
/// Datei — und niemand sähe es der Antwort an.
|
||||
@Test("Die Adresse übersteht die Rundreise zeichengenau")
|
||||
func addressSurvivesTheRoundTrip() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
_ = try await hole(renditionURL)
|
||||
|
||||
let gesehen = herkunft.requests
|
||||
#expect(
|
||||
gesehen.contains { $0.contains("/videos/x/subtitles/2/subtitles.m3u8")
|
||||
&& $0.contains("api_key=abc")
|
||||
&& $0.contains("SubtitleStreamIndex=2") },
|
||||
"der Herkunftsserver sah: \(gesehen)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Ohne diese Zeilen gibt es für AVPlayer keine Untertitelspur — egal was
|
||||
/// danach passiert.
|
||||
@Test("Die Untertitelspur bleibt in der Master-Liste stehen")
|
||||
func masterKeepsTheRendition() async throws {
|
||||
let (herkunft, server, lokal) = try await aufbauen()
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let antwort = try await hole(lokal)
|
||||
#expect(antwort.status == 200)
|
||||
#expect(antwort.type == "application/vnd.apple.mpegurl")
|
||||
#expect(antwort.body.contains("#EXT-X-MEDIA:TYPE=SUBTITLES"))
|
||||
#expect(antwort.body.contains("GROUP-ID=\"subs\""))
|
||||
#expect(antwort.body.contains("LANGUAGE=\"ger\""))
|
||||
#expect(antwort.body.contains("DEFAULT=YES"))
|
||||
#expect(antwort.body.contains("SUBTITLES=\"subs\""))
|
||||
}
|
||||
|
||||
/// Sonst lüde der Vorrat Untertitel statt Bild: der Film bliebe stehen,
|
||||
/// während die Anzeige reichlich Vorrat meldet.
|
||||
@Test("Die Untertitelspur wird nicht vorgeladen")
|
||||
func renditionIsNotPrefetched() async throws {
|
||||
let herkunft = Self.jellyfin()
|
||||
let port = try await herkunft.start()
|
||||
|
||||
let speicher = StreamCache()
|
||||
let server = LocalStreamServer(cache: speicher)
|
||||
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
|
||||
let lokal = try await server.start(upstream: oben)
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
_ = try await hole(renditionURL)
|
||||
|
||||
#expect(await speicher.bufferedAhead == .zero)
|
||||
}
|
||||
|
||||
/// Ein leeres 200 ist genauso unsichtbar wie der falsche Typ. Die beiden
|
||||
/// müssen auseinanderzuhalten sein, sonst sucht man wieder an der falschen
|
||||
/// Stelle.
|
||||
@Test("Ein Fehler von oben bleibt ein Fehler")
|
||||
func upstreamFailureStaysAFailure() async throws {
|
||||
let herkunft = Self.jellyfin()
|
||||
herkunft.add("/videos/x/subtitles/2/0.vtt", status: 401, type: "text/plain", body: "nope")
|
||||
let port = try await herkunft.start()
|
||||
|
||||
let speicher = StreamCache()
|
||||
let server = LocalStreamServer(cache: speicher)
|
||||
let oben = URL(string: "http://127.0.0.1:\(port)/videos/x/master.m3u8?api_key=abc")!
|
||||
let lokal = try await server.start(upstream: oben)
|
||||
defer { Task { await server.stop() }; herkunft.stop() }
|
||||
|
||||
let masterAntwort = try await hole(lokal)
|
||||
let renditionURL = try #require(renditionAdresse(in: masterAntwort.body, basis: lokal))
|
||||
let rendition = try await hole(renditionURL)
|
||||
let segmentURL = try #require(ersteSegmentAdresse(in: rendition.body, basis: lokal))
|
||||
|
||||
let segment = try await hole(segmentURL)
|
||||
#expect(segment.status == 504)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Der Herkunftsserver
|
||||
|
||||
/// Ein winziger HTTP-Server, der die Antworten gibt, die Jellyfin geben würde
|
||||
/// — und aufschreibt, wonach gefragt wurde.
|
||||
///
|
||||
/// Das Protokoll ist der eigentliche Gewinn: ohne es sieht man nur, was
|
||||
/// **heraus**kommt, nicht wonach der Speicher gefragt hat. Genau diese Lücke
|
||||
/// hat die Fehlersuche so lange gekostet.
|
||||
final class FakeOrigin: @unchecked Sendable {
|
||||
private struct Antwort {
|
||||
let status: Int
|
||||
let type: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var antworten: [String: Antwort] = [:]
|
||||
private var gesehen: [String] = []
|
||||
private var listener: NWListener?
|
||||
|
||||
func add(_ path: String, status: Int = 200, type: String, body: String) {
|
||||
lock.withLock { antworten[path] = Antwort(status: status, type: type, body: body) }
|
||||
}
|
||||
|
||||
/// Alles, wonach gefragt wurde — mit vollständiger Abfrage.
|
||||
var requests: [String] {
|
||||
lock.withLock { gesehen }
|
||||
}
|
||||
|
||||
func start() async throws -> UInt16 {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
|
||||
let lauscher = try NWListener(using: parameters)
|
||||
|
||||
lauscher.newConnectionHandler = { [weak self] verbindung in
|
||||
verbindung.start(queue: .global(qos: .userInitiated))
|
||||
self?.receive(on: verbindung, buffer: Data())
|
||||
}
|
||||
|
||||
let port: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in
|
||||
let riegel = TestOneShot()
|
||||
lauscher.stateUpdateHandler = { zustand in
|
||||
switch zustand {
|
||||
case .ready:
|
||||
guard riegel.claim() else { return }
|
||||
fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0)
|
||||
case .failed(let fehler):
|
||||
guard riegel.claim() else { return }
|
||||
fortsetzen.resume(throwing: fehler)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
lauscher.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
listener = lauscher
|
||||
return port
|
||||
}
|
||||
|
||||
func stop() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
}
|
||||
|
||||
private func receive(on verbindung: NWConnection, buffer: Data) {
|
||||
verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) {
|
||||
[weak self] daten, _, abgeschlossen, fehler in
|
||||
guard let self else { return }
|
||||
var gesammelt = buffer
|
||||
if let daten { gesammelt.append(daten) }
|
||||
|
||||
guard let ende = gesammelt.range(of: Data("\r\n\r\n".utf8)) else {
|
||||
if fehler == nil, !abgeschlossen {
|
||||
self.receive(on: verbindung, buffer: gesammelt)
|
||||
} else {
|
||||
verbindung.cancel()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let kopf = String(decoding: gesammelt[..<ende.lowerBound], as: UTF8.self)
|
||||
self.antworte(auf: kopf, verbindung: verbindung)
|
||||
}
|
||||
}
|
||||
|
||||
private func antworte(auf kopf: String, verbindung: NWConnection) {
|
||||
let zeile = kopf.components(separatedBy: "\r\n").first ?? ""
|
||||
let pfadMitAbfrage = zeile.components(separatedBy: " ").dropFirst().first ?? ""
|
||||
|
||||
let pfad = pfadMitAbfrage.components(separatedBy: "?").first ?? pfadMitAbfrage
|
||||
let antwort = lock.withLock { () -> Antwort? in
|
||||
gesehen.append(pfadMitAbfrage)
|
||||
return antworten[pfad]
|
||||
}
|
||||
|
||||
let a = antwort ?? Antwort(status: 404, type: "text/plain", body: "nicht da")
|
||||
let koerper = Data(a.body.utf8)
|
||||
let ausgabe = """
|
||||
HTTP/1.1 \(a.status) OK\r
|
||||
Content-Type: \(a.type)\r
|
||||
Content-Length: \(koerper.count)\r
|
||||
Connection: close\r
|
||||
\r
|
||||
|
||||
"""
|
||||
var alles = Data(ausgabe.utf8)
|
||||
alles.append(koerper)
|
||||
verbindung.send(content: alles, completion: .contentProcessed { _ in verbindung.cancel() })
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie im Original: ein Riegel, der genau einmal aufgeht.
|
||||
private final class TestOneShot: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var used = false
|
||||
|
||||
func claim() -> Bool {
|
||||
lock.withLock {
|
||||
guard !used else { return false }
|
||||
used = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Abspielliste lesen und umschreiben")
|
||||
struct MediaPlaylistTests {
|
||||
|
||||
private let medien = """
|
||||
#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:6
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-MAP:URI="hls1/main/-1.mp4"
|
||||
#EXTINF:6.000000,
|
||||
hls1/main/0.mp4
|
||||
#EXTINF:6.000000,
|
||||
hls1/main/1.mp4
|
||||
#EXTINF:5.400000,
|
||||
hls1/main/2.mp4
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
|
||||
private let stufen = """
|
||||
#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080
|
||||
main.m3u8?level=0
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720
|
||||
main.m3u8?level=1
|
||||
"""
|
||||
|
||||
@Test("Die Schnipsel kommen in der Reihenfolge der Liste")
|
||||
func readsSegmentsInOrder() {
|
||||
let liste = MediaPlaylist(medien)
|
||||
#expect(liste.segments == ["hls1/main/0.mp4", "hls1/main/1.mp4", "hls1/main/2.mp4"])
|
||||
#expect(!liste.isMaster)
|
||||
}
|
||||
|
||||
/// Die beiden Listenarten sehen sich ähnlich und meinen Verschiedenes.
|
||||
/// Wer sie verwechselt, lädt Abspiellisten vor statt Bild.
|
||||
@Test("Eine Liste von Stufen ist keine Liste von Schnipseln")
|
||||
func tellsMasterFromMedia() {
|
||||
#expect(MediaPlaylist(stufen).isMaster)
|
||||
#expect(MediaPlaylist(stufen).segments.count == 2)
|
||||
#expect(!MediaPlaylist(medien).isMaster)
|
||||
}
|
||||
|
||||
/// **Die teuerste Stelle.** Eine Liste, die stellenweise am Speicher
|
||||
/// vorbeizeigt, ergibt einen Film, der nur manchmal hakt — und so etwas
|
||||
/// findet man im laufenden Bild nicht.
|
||||
@Test("Umgeschrieben werden Adressen, sonst nichts")
|
||||
func rewritesOnlyReferences() {
|
||||
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "http://127.0.0.1:1234/s/\(adresse)" }
|
||||
|
||||
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/0.mp4"))
|
||||
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/2.mp4"))
|
||||
// Anweisungen bleiben, wie sie waren.
|
||||
#expect(neu.contains("#EXT-X-TARGETDURATION:6"))
|
||||
// **Mit einer Ausnahme**, und die hat mich ein Schwarzbild gekostet:
|
||||
// in `EXT-X-MAP` steckt eine Adresse, und die muss mit. Siehe unten.
|
||||
#expect(neu.contains("#EXT-X-MAP:URI=\"http://127.0.0.1:1234/s/hls1/main/-1.mp4\""))
|
||||
#expect(neu.contains("#EXT-X-ENDLIST"))
|
||||
#expect(neu.hasPrefix("#EXTM3U"))
|
||||
}
|
||||
|
||||
/// Ohne das Kopfstück ist kein einziger Schnipsel einer fragmentierten
|
||||
/// MP4 zu dekodieren. Bliebe die Adresse stehen, zeigte sie relativ auf
|
||||
/// den lokalen Server — schwarzes Bild, ohne dass an den Schnipseln
|
||||
/// irgendetwas falsch wäre.
|
||||
@Test("Das Kopfstück wandert mit")
|
||||
func rewritesInitSegment() {
|
||||
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" }
|
||||
#expect(neu.contains("#EXT-X-MAP:URI=\"/s/hls1/main/-1.mp4\""))
|
||||
}
|
||||
|
||||
@Test("Eine Anweisung ohne Adresse bleibt unberührt")
|
||||
func leavesOtherTagsAlone() {
|
||||
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in "/s/\(adresse)" }
|
||||
#expect(neu.contains("#EXT-X-VERSION:7"))
|
||||
#expect(neu.contains("#EXTINF:6.000000,"))
|
||||
}
|
||||
|
||||
@Test("Die Zeilenzahl bleibt gleich")
|
||||
func keepsStructure() {
|
||||
let neu = MediaPlaylist(medien).rewritingReferences { adresse, _ in adresse }
|
||||
#expect(neu.components(separatedBy: "\n").count == medien.components(separatedBy: "\n").count)
|
||||
}
|
||||
|
||||
@Test("Eine vollständige Liste ist als solche zu erkennen")
|
||||
func knowsWhenComplete() {
|
||||
#expect(MediaPlaylist(medien).isComplete)
|
||||
// Ohne Schlussmarke: ein laufender Sender, die Liste wächst noch.
|
||||
#expect(!MediaPlaylist(medien.replacingOccurrences(of: "#EXT-X-ENDLIST", with: "")).isComplete)
|
||||
}
|
||||
|
||||
@Test("Die Schnipseldauer wird gelesen")
|
||||
func readsTargetDuration() {
|
||||
#expect(MediaPlaylist(medien).targetDuration == .seconds(6))
|
||||
#expect(MediaPlaylist(stufen).targetDuration == nil)
|
||||
}
|
||||
|
||||
@Test("Eine leere Eingabe ergibt eine leere Liste, keinen Absturz")
|
||||
func emptyIsEmpty() {
|
||||
#expect(MediaPlaylist("").segments.isEmpty)
|
||||
#expect(MediaPlaylist("").rewritingReferences { adresse, _ in adresse } == "")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Nebenspuren in der Abspielliste")
|
||||
struct PlaylistRenditionTests {
|
||||
/// **Der Fehler, der mit „404" endete.** Untertitel stehen in einer
|
||||
/// eigenen Anweisung mit einer Adresse darin. Blieb sie stehen, zeigte
|
||||
/// sie relativ auf den lokalen Server — und dort gibt es sie nicht. Zu
|
||||
/// sehen war das erst, als jemand Untertitel einschaltete.
|
||||
private let mitUntertiteln = """
|
||||
#EXTM3U
|
||||
#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",URI="subtitles/0.m3u8"
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,SUBTITLES="subs"
|
||||
main.m3u8?level=0
|
||||
"""
|
||||
|
||||
@Test("Die Untertitelspur wird mit umgeschrieben")
|
||||
func rewritesSubtitleRendition() {
|
||||
let neu = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, _ in
|
||||
"/um/\(adresse)"
|
||||
}
|
||||
#expect(neu.contains("URI=\"/um/subtitles/0.m3u8\""))
|
||||
#expect(!neu.contains("URI=\"subtitles/0.m3u8\""))
|
||||
}
|
||||
|
||||
/// **Und sie darf nicht vorgeladen werden.** Sonst lüde der Vorrat
|
||||
/// Untertitel statt Bild: der Film bliebe stehen, während die Anzeige
|
||||
/// reichlich Vorrat meldet.
|
||||
@Test("Stufe und Nebenspur werden unterschieden")
|
||||
func tellsLevelFromRendition() {
|
||||
var arten: [String: MediaPlaylist.Reference] = [:]
|
||||
_ = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, art in
|
||||
arten[adresse] = art
|
||||
return adresse
|
||||
}
|
||||
|
||||
#expect(arten["main.m3u8?level=0"] == .level)
|
||||
#expect(arten["subtitles/0.m3u8"] == .rendition)
|
||||
}
|
||||
|
||||
@Test("Das Kopfstück gilt als Schnipsel, nicht als Liste")
|
||||
func mapIsASegment() {
|
||||
let liste = """
|
||||
#EXTM3U
|
||||
#EXT-X-MAP:URI="hls1/main/-1.mp4"
|
||||
#EXTINF:6.0,
|
||||
hls1/main/0.mp4
|
||||
"""
|
||||
var arten: [String: MediaPlaylist.Reference] = [:]
|
||||
_ = MediaPlaylist(liste).rewritingReferences { adresse, art in
|
||||
arten[adresse] = art
|
||||
return adresse
|
||||
}
|
||||
|
||||
#expect(arten["hls1/main/-1.mp4"] == .map)
|
||||
#expect(arten["hls1/main/0.mp4"] == .segment)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Adressen für den lokalen Speicher")
|
||||
struct LocalStreamServerPathTests {
|
||||
/// Der Fehler, an dem der erste Anlauf gescheitert ist: eine Liste von
|
||||
/// Stufen wurde durchgereicht, ihre **relativen** Adressen zeigten danach
|
||||
/// auf den lokalen Server — und dort gibt es sie nicht.
|
||||
@Test("Eine Stufe wird vollständig adressiert, nicht relativ")
|
||||
func levelPathIsAbsolute() {
|
||||
let pfad = LocalStreamServer.localPlaylistPath(
|
||||
for: "http://server:8096/videos/x/main.m3u8?level=1&api_key=t",
|
||||
prefetch: true
|
||||
)
|
||||
let teile = URLComponents(string: pfad)
|
||||
#expect(teile?.path == "/level.m3u8")
|
||||
#expect(
|
||||
teile?.queryItems?.first { $0.name == "u" }?.value
|
||||
== "http://server:8096/videos/x/main.m3u8?level=1&api_key=t"
|
||||
)
|
||||
}
|
||||
|
||||
/// Die Endung muss stehen bleiben: AVPlayer entscheidet am Namen, ob er
|
||||
/// eine Abspielliste oder Bild erwartet.
|
||||
@Test("Die Endung bleibt eine Abspielliste")
|
||||
func levelPathKeepsExtension() {
|
||||
#expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8", prefetch: true).hasPrefix("/level.m3u8"))
|
||||
}
|
||||
|
||||
/// Schrägstriche und Punkte im ursprünglichen Pfad dürfen nichts
|
||||
/// anrichten — deshalb wandert er in die Abfrage, nicht in den Pfad.
|
||||
@Test("Der Schnipselpfad überlebt Schrägstriche")
|
||||
func segmentPathSurvivesSlashes() {
|
||||
let pfad = LocalStreamServer.localPath(for: "hls1/main/12.mp4?runtimeTicks=0")
|
||||
let teile = URLComponents(string: pfad)
|
||||
#expect(teile?.path == "/s/")
|
||||
#expect(teile?.queryItems?.first { $0.name == "u" }?.value == "hls1/main/12.mp4?runtimeTicks=0")
|
||||
}
|
||||
|
||||
/// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen
|
||||
/// Server zeigen — und zwar jede Zeile.
|
||||
@Test("Jede Stufe zeigt danach auf uns")
|
||||
func everyLevelIsRewritten() {
|
||||
let stufen = """
|
||||
#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000
|
||||
main.m3u8?level=0
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3000000
|
||||
main.m3u8?level=1
|
||||
"""
|
||||
let basis = URL(string: "http://server:8096/videos/x/master.m3u8?api_key=t")!
|
||||
let neu = MediaPlaylist(stufen).rewritingReferences { eintrag, _ in
|
||||
let absolut = URL(string: eintrag, relativeTo: basis)?.absoluteString ?? eintrag
|
||||
return LocalStreamServer.localPlaylistPath(for: absolut, prefetch: true)
|
||||
}
|
||||
|
||||
#expect(neu.components(separatedBy: "/level.m3u8").count == 3)
|
||||
#expect(!neu.contains("\nmain.m3u8"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import HaloPlayback
|
||||
|
||||
/// Die Tabelle, die festhält, welche Datei auf welchem Weg landet.
|
||||
///
|
||||
/// Sie ist der Grund, warum `DeviceCapabilities` nicht beiläufig angefasst
|
||||
/// werden kann: jede Änderung dort schlägt hier sichtbar durch.
|
||||
@Suite("Wiedergabe-Entscheidung")
|
||||
struct PlaybackDeciderTests {
|
||||
|
||||
/// Beschreibt eine Datei mit den angegebenen Eigenschaften.
|
||||
///
|
||||
/// Seit die Entscheidung `MediaProfile` entgegennimmt, steht hier genau
|
||||
/// das, worauf es ankommt. Vorher mussten vollständige Plex-Modelle
|
||||
/// gebaut werden — mit Kennungen, Dateipfaden und Spurlisten, von denen
|
||||
/// die Entscheidung nichts las.
|
||||
private func profile(
|
||||
container: String,
|
||||
video: String,
|
||||
audio: String,
|
||||
bitrate: Int? = nil
|
||||
) -> MediaProfile {
|
||||
MediaProfile(
|
||||
container: container,
|
||||
videoCodec: video,
|
||||
audioCodec: audio,
|
||||
bitrate: bitrate
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Was AVPlayer nativ kann
|
||||
|
||||
@Test(
|
||||
"Native Formate gehen an AVPlayer",
|
||||
arguments: [
|
||||
("mp4", "h264", "aac"),
|
||||
("mp4", "hevc", "eac3"),
|
||||
("mov", "h264", "ac3"),
|
||||
("m4v", "hevc", "alac"),
|
||||
// Groß-/Kleinschreibung darf keinen Unterschied machen — Plex ist
|
||||
// darin nicht einheitlich.
|
||||
("MP4", "H264", "AAC"),
|
||||
]
|
||||
)
|
||||
func nativeFormatsUseAVPlayer(container: String, video: String, audio: String) {
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: container, video: video, audio: audio),
|
||||
policy: .directPlayOnly
|
||||
)
|
||||
|
||||
#expect(decision.method == .directPlay)
|
||||
#expect(decision.rationale.contains(.nativelySupported))
|
||||
}
|
||||
|
||||
// MARK: - Was ohne Serverhilfe übrig bleibt
|
||||
|
||||
/// **Hier stand einmal VLC.** Eine zweite Engine nahm all das an, was
|
||||
/// AVPlayer nicht öffnet. Es gibt sie nicht mehr: darf der Server helfen,
|
||||
/// packt er um oder rechnet um (siehe `RemuxDecisionTests`); darf er es
|
||||
/// nicht, bleibt nur der Versuch — und der Grund steht in der Begründung.
|
||||
@Test("Matroska ohne Serverhilfe: der Versuch, mit Begründung")
|
||||
func matroskaWithoutServerHelp() {
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mkv", video: "h264", audio: "aac"),
|
||||
policy: .directPlayOnly
|
||||
)
|
||||
|
||||
#expect(decision.method == .directPlay)
|
||||
#expect(decision.rationale.contains(.containerUnsupported("mkv")))
|
||||
// Die Begründung ist der ganze Wert dieses Falls: ohne sie sähe das
|
||||
// Scheitern aus wie ein Fehler der App.
|
||||
#expect(decision.rationale.contains(.transcodingUnavailable))
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Nicht unterstützter Ton ohne Transcoder: der Versuch",
|
||||
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
|
||||
)
|
||||
func unsupportedAudioWithoutServerHelp(codec: String) {
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: "h264", audio: codec),
|
||||
policy: .directPlayOnly
|
||||
)
|
||||
|
||||
#expect(decision.method == .directPlay)
|
||||
#expect(decision.rationale.contains(.audioCodecUnsupported(codec)))
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Nicht unterstütztes Bild geht an den Transcoder",
|
||||
arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"]
|
||||
)
|
||||
func unsupportedVideoTranscodes(codec: String) {
|
||||
// Steht das **Bild** im Weg, hilft Umpacken nicht — dieselben Ströme
|
||||
// in einer anderen Hülle blieben dieselben Ströme.
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: codec, audio: "aac"),
|
||||
policy: PlaybackPolicy(allowsTranscoding: true)
|
||||
)
|
||||
|
||||
#expect(decision.method == .transcode(maximumBitrate: nil))
|
||||
#expect(decision.rationale.contains(.videoCodecUnsupported(codec)))
|
||||
}
|
||||
|
||||
@Test("Die gewählte Tonspur zählt, nicht die erste beste")
|
||||
func decisionFollowsSelectedAudioTrack() {
|
||||
// Die Datei hat englisches DTS und deutsches AAC; gewählt ist
|
||||
// Deutsch. Entschieden wird über die Spur, die klingen soll — wer sie
|
||||
// aussucht, hat der Aufrufer beim Bauen des Profils entschieden.
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: "h264", audio: "aac"),
|
||||
policy: .directPlayOnly
|
||||
)
|
||||
#expect(decision.method == .directPlay)
|
||||
}
|
||||
|
||||
@Test("Unanalysierte Dateien gehen an den Transcoder")
|
||||
func unknownStreamsTranscode() {
|
||||
// Der Server hat die Datei noch nicht untersucht: keine Spuren, kein
|
||||
// bekannter Codec. Dann ist Raten die falsche Antwort — und seit es
|
||||
// keine tolerantere Engine mehr gibt, ist Umrechnen die sichere.
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: MediaProfile(container: "mp4", hasKnownStreams: false),
|
||||
policy: PlaybackPolicy(allowsTranscoding: true)
|
||||
)
|
||||
#expect(decision.method == .transcode(maximumBitrate: nil))
|
||||
#expect(decision.rationale.contains(.streamsUnknown))
|
||||
}
|
||||
|
||||
// MARK: - Die Bandbreitengrenze
|
||||
|
||||
@Test("Über der Grenze wird umgerechnet, sobald das erlaubt ist")
|
||||
func exceedingBitrateTranscodesWhenAllowed() {
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000),
|
||||
policy: policy
|
||||
)
|
||||
|
||||
#expect(decision.method == .transcode(maximumBitrate: 8_000))
|
||||
#expect(decision.rationale.contains(.bitrateExceedsLimit(mediaBitrate: 80_000, limit: 8_000)))
|
||||
}
|
||||
|
||||
@Test("Ohne Transcoder wird es trotzdem versucht — und der Grund vermerkt")
|
||||
func exceedingBitrateStillPlaysInPhaseOne() {
|
||||
// Das ist die Lage in Phase 1. Abbrechen wäre falsch: im WLAN läuft
|
||||
// auch ein 80-Mbit-Remux, die Grenze ist nur eine Vorsichtsmaßnahme.
|
||||
let policy = PlaybackPolicy(allowsTranscoding: false, maximumBitrate: 8_000)
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000),
|
||||
policy: policy
|
||||
)
|
||||
|
||||
#expect(decision.isDirectPlay)
|
||||
#expect(decision.rationale.contains(.transcodingUnavailable))
|
||||
}
|
||||
|
||||
@Test("Unter der Grenze bleibt alles beim Direktabspielen")
|
||||
func withinBitrateLimitPlaysDirectly() {
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000),
|
||||
policy: policy
|
||||
)
|
||||
#expect(decision.method == .directPlay)
|
||||
}
|
||||
|
||||
/// Früher landete eine Datei mit DTS-Ton beim vollen Neurechnen — Bild
|
||||
/// inbegriffen, obwohl mit dem Bild nichts war. Jetzt bleibt es beim
|
||||
/// Umpacken, und nur der Ton wird angefasst.
|
||||
@Test("Umpacken reicht, solange das Bild passt")
|
||||
func remuxIsEnough() {
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true)
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mkv", video: "h264", audio: "dts"),
|
||||
policy: policy
|
||||
)
|
||||
#expect(decision.method == .remux)
|
||||
}
|
||||
|
||||
/// Erst wenn auch das Bild nicht geht, muss wirklich gerechnet werden.
|
||||
@Test("Der Transcoder übernimmt, sobald das Bild nicht passt")
|
||||
func transcodingTakesOver() {
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true)
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mkv", video: "av1", audio: "aac"),
|
||||
policy: policy
|
||||
)
|
||||
#expect(decision.method == .transcode(maximumBitrate: nil))
|
||||
}
|
||||
|
||||
@Test("Der Transcode-Weg läuft über AVPlayer")
|
||||
func transcodingUsesAVPlayer() {
|
||||
// HLS ist genau die Kost, für die AVPlayer gebaut ist — inklusive
|
||||
// automatischem Bitratenwechsel.
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true)
|
||||
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mkv", video: "av1", audio: "aac"),
|
||||
policy: policy
|
||||
)
|
||||
#expect(decision.method == .transcode(maximumBitrate: nil))
|
||||
}
|
||||
|
||||
@Test("Eine gewählte Stufe wird umgerechnet, auch wenn die Datei darunter liegt")
|
||||
func honoursAnExplicitChoice() {
|
||||
// Ohne das verpuffte die Wahl im Player: wer für eine 8-Mbit-Datei
|
||||
// „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja unter der
|
||||
// Grenze liegt. Gemeint ist aber „rechne es mir auf diese Stufe um".
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000),
|
||||
policy: PlaybackPolicy(
|
||||
allowsTranscoding: true,
|
||||
maximumBitrate: 12_000,
|
||||
forcesTranscoding: true
|
||||
)
|
||||
)
|
||||
|
||||
#expect(decision.method == .transcode(maximumBitrate: 12_000))
|
||||
#expect(decision.rationale.contains(.qualityChosen))
|
||||
}
|
||||
|
||||
@Test("Ohne ausdrückliche Wahl bleibt es bei DirectPlay")
|
||||
func leavesAutomaticAlone() {
|
||||
let decision = PlaybackDecider().decide(
|
||||
profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000),
|
||||
policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 12_000)
|
||||
)
|
||||
|
||||
#expect(decision.method == .directPlay)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Umpacken statt neu rechnen")
|
||||
struct RemuxDecisionTests {
|
||||
private let decider = PlaybackDecider()
|
||||
|
||||
private var policy: PlaybackPolicy {
|
||||
PlaybackPolicy(allowsTranscoding: true)
|
||||
}
|
||||
|
||||
/// Der Blu-ray-Rip: Matroska aussen, lauter Dinge innen, die AVPlayer
|
||||
/// bestens kann. Im Weg steht allein die Verpackung — und dafür gibt es
|
||||
/// das Umpacken, und schon gar nicht den Transcoder.
|
||||
@Test("Matroska mit brauchbarem Inhalt wird umgepackt")
|
||||
func matroskaIsRemuxed() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .remux)
|
||||
}
|
||||
|
||||
/// Steht der Ton im Weg, bleibt das Bild trotzdem unberührt: der Server
|
||||
/// kopiert es und rechnet genau die eine Tonspur um. Das ist der Grund,
|
||||
/// warum DTS auf dem Apple TV nicht mehr das ganze Bild kostet.
|
||||
@Test(
|
||||
"Nicht unterstützter Ton wird umgepackt, nicht neu gerechnet",
|
||||
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
|
||||
)
|
||||
func unsupportedAudioIsRemuxed(codec: String) {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: codec,
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .remux)
|
||||
// Die Begründung ist das, woran die Oberfläche später erkennt, dass
|
||||
// der Ton angefasst wurde — ohne sie stünde dort „ohne Neukodierung".
|
||||
#expect(entscheidung.rationale.contains(.audioCodecUnsupported(codec)))
|
||||
}
|
||||
|
||||
/// Eine MP4 mit DTS steht mit einem reinen Tonhindernis da. Sie an der
|
||||
/// Liste umpackbarer Schachteln zu messen, hiesse sie an einer Frage
|
||||
/// scheitern zu lassen, die sich gar nicht stellt.
|
||||
@Test("Auch eine MP4 mit DTS wird umgepackt")
|
||||
func mp4WithDTSIsRemuxed() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mp4", videoCodec: "h264", audioCodec: "dts",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .remux)
|
||||
}
|
||||
|
||||
/// Schachtel und Ton zugleich — der häufigste Fall bei einem Remux mit
|
||||
/// Tonspur aus der Blu-ray.
|
||||
@Test("Schachtel und Ton zusammen gehen auch")
|
||||
func containerAndAudioTogether() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "h264", audioCodec: "truehd",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .remux)
|
||||
#expect(entscheidung.rationale.contains(.containerUnsupported("mkv")))
|
||||
#expect(entscheidung.rationale.contains(.audioCodecUnsupported("truehd")))
|
||||
}
|
||||
|
||||
/// Der Gegenbeweis, und die Grenze: stimmt das **Bild** nicht, hilft kein
|
||||
/// Durchreichen — dann müsste jede Bildzeile neu gerechnet werden, und
|
||||
/// dafür ist dies nicht der Weg.
|
||||
@Test("Bei falschem Bildcodec wird gerechnet")
|
||||
func wrongVideoCodecIsNotRemuxed() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "av1", audioCodec: "aac",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .transcode(maximumBitrate: nil))
|
||||
}
|
||||
|
||||
/// Eine MP4, die AVPlayer ohnehin öffnet, wird nicht angefasst. Umpacken
|
||||
/// wäre dort Arbeit ohne Wirkung.
|
||||
@Test("Was schon passt, bleibt unverändert")
|
||||
func mp4IsUntouched() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mp4", videoCodec: "h264", audioCodec: "aac",
|
||||
bitrate: 8_000, hasKnownStreams: true
|
||||
),
|
||||
policy: policy
|
||||
)
|
||||
#expect(entscheidung.method == .directPlay)
|
||||
}
|
||||
|
||||
/// Reicht die Leitung nicht, nützt Umpacken nichts: die Datei bliebe
|
||||
/// gleich gross. Dann muss wirklich gerechnet werden.
|
||||
@Test("Zu grosse Bitrate geht weiterhin an den Transcoder")
|
||||
func bitrateStillTranscodes() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
|
||||
bitrate: 80_000, hasKnownStreams: true
|
||||
),
|
||||
policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
|
||||
)
|
||||
#expect(entscheidung.method == .transcode(maximumBitrate: 8_000))
|
||||
}
|
||||
|
||||
/// Ohne Transcoder gibt es auch kein Umpacken — beides läuft über
|
||||
/// denselben Weg im Server.
|
||||
@Test("Ohne Serverhilfe bleibt nur der Versuch")
|
||||
func withoutServerHelp() {
|
||||
let entscheidung = decider.decide(
|
||||
profile: MediaProfile(
|
||||
container: "mkv", videoCodec: "hevc", audioCodec: "ac3",
|
||||
bitrate: 20_000, hasKnownStreams: true
|
||||
),
|
||||
policy: PlaybackPolicy(allowsTranscoding: false)
|
||||
)
|
||||
// Mehr als der Versuch bleibt nicht — aber der Grund steht dabei.
|
||||
#expect(entscheidung.method == .directPlay)
|
||||
#expect(entscheidung.rationale.contains(.transcodingUnavailable))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Was der Systemplayer öffnet")
|
||||
struct DeviceCapabilityTests {
|
||||
private let kann = DeviceCapabilities.avFoundation
|
||||
|
||||
@Test("Eine MP4 mit H.264 und AAC geht")
|
||||
func plainFilePasses() {
|
||||
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "aac"))
|
||||
}
|
||||
|
||||
@Test("Die Schachtel allein kann im Weg stehen")
|
||||
func containerBlocks() {
|
||||
// Inhaltlich tadellos, nur Matroska aussen — genau der Fall, für den
|
||||
// ein Download umgepackt wird.
|
||||
#expect(!kann.opens(container: "mkv", videoCodec: "h264", audioCodec: "ac3"))
|
||||
}
|
||||
|
||||
@Test("Der Ton allein kann im Weg stehen")
|
||||
func audioBlocks() {
|
||||
#expect(!kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "dts"))
|
||||
}
|
||||
|
||||
@Test("Grossschreibung ändert nichts")
|
||||
func caseDoesNotMatter() {
|
||||
#expect(kann.opens(container: "MP4", videoCodec: "H264", audioCodec: "AAC"))
|
||||
}
|
||||
|
||||
@Test("Was der Server nicht nennt, gilt als passend")
|
||||
func missingValuesPass() {
|
||||
// Vorsichtshalber umzupacken hiesse, aus einer tadellosen Datei eine
|
||||
// neu gerechnete zu machen.
|
||||
#expect(kann.opens(container: nil, videoCodec: nil, audioCodec: nil))
|
||||
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: ""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Testing
|
||||
@testable import HaloPlayback
|
||||
|
||||
/// Die gemerkte Qualitätsstufe über mehrere Filme hinweg.
|
||||
///
|
||||
/// Aufbewahrt wird eine Zahl, keine Sprosse: welche Sprossen es gibt, hängt
|
||||
/// von der Quelle ab. Eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe — und
|
||||
/// dann stillschweigend auf „automatisch" zurückzufallen wäre eine andere
|
||||
/// Antwort als die, um die gebeten wurde.
|
||||
@Suite("Gemerkte Qualität")
|
||||
struct QualityMemoryTests {
|
||||
|
||||
@Test("Null heißt: nichts gemerkt")
|
||||
func returnsNilForAutomatic() {
|
||||
#expect(PlayerQuality.automatic.storageValue == 0)
|
||||
#expect(PlayerQuality.restored(from: 0, forSourceHeight: 1080) == nil)
|
||||
}
|
||||
|
||||
@Test("Original und Nur-Ton kommen unverändert zurück")
|
||||
func restoresSpecialCases() {
|
||||
#expect(PlayerQuality.restored(from: PlayerQuality.original.storageValue,
|
||||
forSourceHeight: 1080) == .original)
|
||||
#expect(PlayerQuality.restored(from: PlayerQuality.audioOnly.storageValue,
|
||||
forSourceHeight: 1080) == .audioOnly)
|
||||
}
|
||||
|
||||
@Test("Eine vorhandene Sprosse kommt genau zurück")
|
||||
func restoresExactRung() {
|
||||
let chosen = PlayerQuality.limited(.init(bitrate: 3_000, height: 720))
|
||||
|
||||
let restored = PlayerQuality.restored(from: chosen.storageValue, forSourceHeight: 1080)
|
||||
|
||||
#expect(restored == chosen)
|
||||
}
|
||||
|
||||
@Test("Gibt es die Sprosse nicht, kommt die nächstgelegene")
|
||||
func fallsBackToClosestRung() {
|
||||
// Zwanzig Megabit gibt es nur für 1080p. Bei einer 480p-Quelle ist die
|
||||
// beste erreichbare Stufe eine andere — aber nicht „gar keine".
|
||||
let restored = PlayerQuality.restored(from: 20_000, forSourceHeight: 480)
|
||||
|
||||
guard case .limited(let rung) = restored else {
|
||||
Issue.record("keine Stufe zurückbekommen")
|
||||
return
|
||||
}
|
||||
#expect(rung.height <= 480)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Rückler-Buchführung")
|
||||
struct StallLedgerTests {
|
||||
/// Der Fall, wegen dem es den Typ gibt.
|
||||
///
|
||||
/// Zehn Rückler à dreißig Sekunden lösten beim alten Watchdog nichts aus,
|
||||
/// weil er sich bei jedem Erholen zurücksetzte. Hier muss sichtbar
|
||||
/// werden, dass die Wiedergabe unbrauchbar ist.
|
||||
@Test("Viele kurze Rückler bleiben sichtbar")
|
||||
func manyShortStallsAreVisible() {
|
||||
var ledger = StallLedger(window: .seconds(180))
|
||||
|
||||
// Alle 20 s ein Rückler von 5 s, drei Minuten lang.
|
||||
for step in 1...9 {
|
||||
ledger.record(.init(endedAt: .seconds(step * 20), length: .seconds(5)))
|
||||
}
|
||||
|
||||
let now = Duration.seconds(180)
|
||||
#expect(ledger.count(at: now) == 9)
|
||||
// 45 s Warten in 180 s Fenster.
|
||||
#expect(ledger.share(at: now) > 0.2)
|
||||
#expect(ledger.share(at: now) < 0.3)
|
||||
}
|
||||
|
||||
@Test("Was aus dem Fenster fällt, zählt nicht mehr")
|
||||
func oldEpisodesFallOut() {
|
||||
var ledger = StallLedger(window: .seconds(180))
|
||||
ledger.record(.init(endedAt: .seconds(10), length: .seconds(30)))
|
||||
|
||||
#expect(ledger.count(at: .seconds(60)) == 1)
|
||||
// Weit hinter dem Fenster: vergessen.
|
||||
#expect(ledger.count(at: .seconds(300)) == 0)
|
||||
#expect(ledger.share(at: .seconds(300)) == 0)
|
||||
}
|
||||
|
||||
/// Am Anfang ist das Fenster noch nicht voll — dann gegen die tatsächlich
|
||||
/// vergangene Zeit rechnen, sonst sieht ein früher Rückler harmlos aus.
|
||||
@Test("Früh im Film zählt die vergangene Zeit, nicht das ganze Fenster")
|
||||
func earlyStallsAreNotDiluted() {
|
||||
var ledger = StallLedger(window: .seconds(180))
|
||||
ledger.record(.init(endedAt: .seconds(20), length: .seconds(5)))
|
||||
|
||||
let share = ledger.share(at: .seconds(20))
|
||||
// 5 s von 20 s, nicht 5 s von 180 s.
|
||||
#expect(share > 0.2)
|
||||
#expect(share < 0.3)
|
||||
}
|
||||
|
||||
@Test("Der laufende Rückler zählt mit")
|
||||
func ongoingStallCounts() {
|
||||
var ledger = StallLedger(window: .seconds(180))
|
||||
ledger.record(.init(endedAt: .seconds(60), length: .seconds(10)))
|
||||
|
||||
let without = ledger.share(at: .seconds(180))
|
||||
let with = ledger.share(at: .seconds(180), ongoing: .seconds(20))
|
||||
#expect(with > without)
|
||||
}
|
||||
|
||||
/// Ein einzelner langer Rückler ist eine andere Aussage als viele kurze.
|
||||
@Test("Der längste Einzelfall lässt sich getrennt ablesen")
|
||||
func longestIsSeparate() {
|
||||
var ledger = StallLedger(window: .seconds(180))
|
||||
ledger.record(.init(endedAt: .seconds(30), length: .seconds(2)))
|
||||
ledger.record(.init(endedAt: .seconds(60), length: .seconds(18)))
|
||||
ledger.record(.init(endedAt: .seconds(90), length: .seconds(3)))
|
||||
|
||||
#expect(ledger.longest(at: .seconds(120)) == .seconds(18))
|
||||
#expect(ledger.count(at: .seconds(120)) == 3)
|
||||
}
|
||||
|
||||
@Test("Eine ruhige Wiedergabe meldet nichts")
|
||||
func quietPlaybackIsQuiet() {
|
||||
let ledger = StallLedger()
|
||||
#expect(ledger.count(at: .seconds(600)) == 0)
|
||||
#expect(ledger.share(at: .seconds(600)) == 0)
|
||||
#expect(ledger.longest(at: .seconds(600)) == .zero)
|
||||
}
|
||||
|
||||
@Test("Der Anteil übersteigt nie eins")
|
||||
func shareIsBounded() {
|
||||
var ledger = StallLedger(window: .seconds(60))
|
||||
// Absurd: mehr Wartezeit als Fenster.
|
||||
ledger.record(.init(endedAt: .seconds(30), length: .seconds(200)))
|
||||
#expect(ledger.share(at: .seconds(60)) == 1)
|
||||
}
|
||||
|
||||
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
|
||||
/// Reserve für einen Neuaufbau.
|
||||
@Test("Der letzte Rückler lässt sich datieren")
|
||||
func findsTheLastStall() {
|
||||
var buch = StallLedger(window: .seconds(180))
|
||||
buch.record(.init(endedAt: .seconds(30), length: .seconds(2)))
|
||||
buch.record(.init(endedAt: .seconds(90), length: .seconds(1)))
|
||||
|
||||
#expect(buch.lastStall(at: .seconds(100)) == .seconds(90))
|
||||
}
|
||||
|
||||
/// „Noch nie gestockt" ist etwas anderes als „vor langer Zeit" — wer
|
||||
/// keinen hatte, wartet auf keine Frist.
|
||||
@Test("Ohne Rückler gibt es kein Datum")
|
||||
func noStallNoDate() {
|
||||
let leer = StallLedger(window: .seconds(180))
|
||||
#expect(leer.lastStall(at: .seconds(100)) == nil)
|
||||
}
|
||||
|
||||
@Test("Was aus dem Fenster gefallen ist, zählt nicht mehr")
|
||||
func forgetsOldStalls() {
|
||||
var buch = StallLedger(window: .seconds(60))
|
||||
buch.record(.init(endedAt: .seconds(10), length: .seconds(2)))
|
||||
#expect(buch.lastStall(at: .seconds(200)) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Zustand des Streams")
|
||||
struct StreamHealthTests {
|
||||
/// Baut eine Folge von Proben im Sekundentakt.
|
||||
private func lauf(
|
||||
_ health: inout StreamHealth,
|
||||
von: Int,
|
||||
bis: Int,
|
||||
puffer: (Int) -> Double?,
|
||||
stalls: (Int) -> Int = { _ in 0 },
|
||||
keepUp: (Int) -> Bool? = { _ in true }
|
||||
) -> StreamCondition {
|
||||
var urteil = StreamCondition.healthy
|
||||
for sekunde in von...bis {
|
||||
urteil = health.record(StreamSample(
|
||||
at: .seconds(sekunde),
|
||||
bufferedAhead: puffer(sekunde).map { .milliseconds(Int($0 * 1000)) },
|
||||
isLikelyToKeepUp: keepUp(sekunde),
|
||||
stallCount: stalls(sekunde)
|
||||
))
|
||||
}
|
||||
return urteil
|
||||
}
|
||||
|
||||
@Test("Ein voller, stabiler Puffer ist gesund")
|
||||
func fullBufferIsHealthy() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 60, puffer: { _ in 30 })
|
||||
#expect(urteil == .healthy)
|
||||
}
|
||||
|
||||
/// Der Fall, für den das Ganze gebaut ist: der Puffer schrumpft stetig.
|
||||
/// Das muss auffallen, **bevor** er leer ist.
|
||||
@Test("Ein schrumpfender Puffer meldet sich vor dem Rückler")
|
||||
func shrinkingBufferWarnsEarly() {
|
||||
var health = StreamHealth()
|
||||
// Start mit 30 s, dann jede Sekunde eine weniger.
|
||||
_ = lauf(&health, von: 0, bis: 10, puffer: { _ in 30 })
|
||||
let urteil = lauf(&health, von: 11, bis: 36, puffer: { 30 - Double($0 - 10) })
|
||||
|
||||
#expect(urteil == .strained)
|
||||
// Und zwar bei noch fünf Sekunden Puffer — nicht bei null.
|
||||
#expect(health.condition != .failing)
|
||||
}
|
||||
|
||||
@Test("Ein kleiner, aber stabiler Puffer ist kein Alarm")
|
||||
func smallStableBufferIsNotAlarming() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 30, puffer: { _ in 4 })
|
||||
// Klein, aber er hält. Das ist kein Grund, die Stufe zu senken.
|
||||
#expect(urteil != .strained)
|
||||
#expect(urteil != .failing)
|
||||
}
|
||||
|
||||
@Test("Zwei Rückler in einer Minute heißen: trägt nicht")
|
||||
func twoStallsMeanFailing() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(
|
||||
&health,
|
||||
von: 0,
|
||||
bis: 40,
|
||||
puffer: { _ in 10 },
|
||||
stalls: { $0 < 10 ? 0 : ($0 < 25 ? 1 : 2) }
|
||||
)
|
||||
#expect(urteil == .failing)
|
||||
}
|
||||
|
||||
@Test("Ein leerer Puffer heißt sofort: trägt nicht")
|
||||
func emptyBufferIsFailing() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
|
||||
#expect(urteil == .failing)
|
||||
}
|
||||
|
||||
/// „Noch nichts geladen" ist etwas anderes als „Puffer leer". Beim Start
|
||||
/// darf das nicht als Notlage durchgehen.
|
||||
@Test("Ohne Messwert kein Alarm")
|
||||
func unknownBufferIsNotAlarming() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 10, puffer: { _ in nil })
|
||||
#expect(urteil == .healthy)
|
||||
}
|
||||
|
||||
/// Der Weg zurück ist absichtlich schmal: schnell runter, langsam rauf.
|
||||
@Test("Zurück nach gesund braucht eine halbe Minute Ruhe")
|
||||
func recoveryIsSlow() {
|
||||
var health = StreamHealth()
|
||||
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
|
||||
#expect(health.condition == .failing)
|
||||
|
||||
// Puffer sofort wieder voll — aber zu kurz.
|
||||
let früh = lauf(&health, von: 21, bis: 40, puffer: { _ in 30 })
|
||||
#expect(früh != .healthy)
|
||||
|
||||
// Nach einer halben Minute durchgehend gut: erholt.
|
||||
let spät = lauf(&health, von: 41, bis: 90, puffer: { _ in 30 })
|
||||
#expect(spät == .healthy)
|
||||
}
|
||||
|
||||
/// Ein einzelner Ausschlag von `isLikelyToKeepUp` kommt bei jedem Sprung
|
||||
/// vor und darf nichts auslösen.
|
||||
@Test("Ein einzelner Ausschlag löst nichts aus")
|
||||
func singleKeepUpDipIsIgnored() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(
|
||||
&health,
|
||||
von: 0,
|
||||
bis: 30,
|
||||
puffer: { _ in 25 },
|
||||
keepUp: { $0 == 15 ? false : true }
|
||||
)
|
||||
#expect(urteil == .healthy)
|
||||
}
|
||||
|
||||
@Test("Anhaltende Sorge des Systems zählt")
|
||||
func sustainedKeepUpFalseCounts() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(
|
||||
&health,
|
||||
von: 0,
|
||||
bis: 30,
|
||||
puffer: { _ in 25 },
|
||||
keepUp: { $0 >= 27 ? false : true }
|
||||
)
|
||||
#expect(urteil == .strained)
|
||||
}
|
||||
|
||||
@Test("Zurücksetzen macht einen frischen Anfang")
|
||||
func resetStartsOver() {
|
||||
var health = StreamHealth()
|
||||
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
|
||||
#expect(health.condition == .failing)
|
||||
|
||||
health.removeAll()
|
||||
#expect(health.condition == .healthy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Aussehen der Untertitel")
|
||||
struct SubtitleStyleTests {
|
||||
|
||||
private func attributes(_ style: SubtitleStyle) -> [String: Any]? {
|
||||
style.textStyleRules.first?.textMarkupAttributes
|
||||
}
|
||||
|
||||
private func colour(_ style: SubtitleStyle, _ key: CFString) -> [Double]? {
|
||||
(attributes(style)?[key as String] as? [NSNumber])?.map(\.doubleValue)
|
||||
}
|
||||
|
||||
// MARK: - Dass überhaupt etwas herauskommt
|
||||
|
||||
/// **Der Test, für den es den ganzen Typ gibt.** Der Initialisierer von
|
||||
/// `AVTextStyleRule` kann fehlschlagen: ein falscher Schlüssel ergibt
|
||||
/// `nil` und damit gar keine Regel — lautlos. Auf dem Bildschirm sieht
|
||||
/// „keine Regel" genauso aus wie „Regel ohne Wirkung".
|
||||
@Test("Jedes Paar ergibt genau eine Regel")
|
||||
func everyPaletteProducesOneRule() {
|
||||
for palette in SubtitleStyle.Palette.allCases {
|
||||
for size in SubtitleStyle.Size.allCases {
|
||||
let regeln = SubtitleStyle(size: size, palette: palette).textStyleRules
|
||||
#expect(regeln.count == 1, "\(palette) / \(size) ergab \(regeln.count) Regeln")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Farben
|
||||
|
||||
/// Alpha **zuerst**, dann Rot, Grün, Blau — der häufigste Fehler an
|
||||
/// dieser Stelle, und einer ohne Fehlermeldung.
|
||||
@Test("Die Farbe steht als A, R, G, B da")
|
||||
func colourOrderIsARGB() {
|
||||
let gelb = SubtitleStyle(palette: .yellowOnBlack)
|
||||
let werte = colour(gelb, kCMTextMarkupAttribute_ForegroundColorARGB)
|
||||
|
||||
#expect(werte?.count == 4)
|
||||
#expect(werte?[0] == 1) // Alpha
|
||||
#expect(werte?[1] == 1) // Rot
|
||||
#expect((werte?[3] ?? 1) < 0.5) // Blau — sonst wäre es nicht Gelb
|
||||
}
|
||||
|
||||
@Test("Alle Anteile liegen zwischen null und eins")
|
||||
func coloursAreInRange() {
|
||||
for palette in SubtitleStyle.Palette.allCases {
|
||||
let stil = SubtitleStyle(palette: palette)
|
||||
for schlüssel in [
|
||||
kCMTextMarkupAttribute_ForegroundColorARGB,
|
||||
kCMTextMarkupAttribute_BackgroundColorARGB,
|
||||
] {
|
||||
let werte = colour(stil, schlüssel) ?? []
|
||||
#expect(werte.count == 4, "\(palette): \(werte.count) Anteile")
|
||||
#expect(werte.allSatisfy { $0 >= 0 && $0 <= 1 }, "\(palette): \(werte)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Weiss auf Schwarz ist weiss auf schwarz")
|
||||
func whiteOnBlack() {
|
||||
let stil = SubtitleStyle(palette: .whiteOnBlack)
|
||||
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 1, 1, 1])
|
||||
|
||||
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB) ?? []
|
||||
#expect((grund.first ?? 0) > 0.5) // sichtbarer Kasten
|
||||
#expect(grund.dropFirst().allSatisfy { $0 == 0 })
|
||||
}
|
||||
|
||||
@Test("Schwarz auf Weiss ist die Umkehrung")
|
||||
func blackOnWhite() {
|
||||
let stil = SubtitleStyle(palette: .blackOnWhite)
|
||||
#expect(colour(stil, kCMTextMarkupAttribute_ForegroundColorARGB) == [1, 0, 0, 0])
|
||||
#expect((colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)?.first ?? 0) > 0.5)
|
||||
}
|
||||
|
||||
/// **Weglassen ist nicht dasselbe wie durchsichtig.** Ohne den Schlüssel
|
||||
/// gilt, was der Untertitel selbst mitbringt — und Jellyfins WebVTT
|
||||
/// bringt oft einen schwarzen Kasten mit. Dann bliebe „Weiss ohne
|
||||
/// Kasten" wirkungslos, und niemand wüsste warum.
|
||||
@Test("Durchsichtig wird ausdrücklich gesetzt, nicht weggelassen")
|
||||
func transparentIsStated() {
|
||||
for palette in [SubtitleStyle.Palette.whiteOnClear, .yellowOnClear] {
|
||||
let stil = SubtitleStyle(palette: palette)
|
||||
let grund = colour(stil, kCMTextMarkupAttribute_BackgroundColorARGB)
|
||||
|
||||
#expect(grund != nil, "\(palette): der Hintergrund fehlt ganz")
|
||||
#expect(grund?.first == 0, "\(palette): Alpha ist nicht null")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Grösse
|
||||
|
||||
/// Ein Prozentwert, keine Punktgrösse. Hundert heisst unverändert — und
|
||||
/// „unverändert" darf nicht wegoptimiert werden, sonst gilt wieder, was
|
||||
/// der Untertitel mitbringt.
|
||||
@Test("Die Grösse ist ein Prozentwert, auch bei hundert")
|
||||
func sizeIsAPercentage() {
|
||||
for size in SubtitleStyle.Size.allCases {
|
||||
let wert = attributes(SubtitleStyle(size: size))?[
|
||||
kCMTextMarkupAttribute_RelativeFontSize as String
|
||||
] as? Double
|
||||
#expect(wert == Double(size.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die zweite Grössenangabe bezieht sich auf etwas anderes. Beide zu
|
||||
/// setzen liesse sie sich streiten.
|
||||
@Test("Die andere Grössenangabe bleibt weg")
|
||||
func onlyOneSizeKey() {
|
||||
let gesetzt = attributes(SubtitleStyle(size: .huge)) ?? [:]
|
||||
#expect(gesetzt[
|
||||
kCMTextMarkupAttribute_BaseFontSizePercentageRelativeToVideoHeight as String
|
||||
] == nil)
|
||||
}
|
||||
|
||||
// MARK: - Der Rand
|
||||
|
||||
/// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer
|
||||
/// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten Unruhe.
|
||||
@Test("Den Schatten gibt es nur ohne Kasten")
|
||||
func edgeShadowOnlyWithoutBox() {
|
||||
for palette in SubtitleStyle.Palette.allCases {
|
||||
let stil = SubtitleStyle(palette: palette)
|
||||
let rand = attributes(stil)?[kCMTextMarkupAttribute_CharacterEdgeStyle as String]
|
||||
#expect((rand != nil) == stil.wantsEdgeShadow, "\(palette)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Die Vorgabe
|
||||
|
||||
@Test("Ohne Angabe: weisse Schrift, kein Kasten, normale Grösse")
|
||||
func defaults() {
|
||||
let stil = SubtitleStyle()
|
||||
#expect(stil.size == .normal)
|
||||
#expect(stil.palette == .whiteOnClear)
|
||||
#expect(stil.wantsEdgeShadow)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Was im Spurenmenü steht")
|
||||
struct TrackMenuTests {
|
||||
|
||||
private func spur(
|
||||
_ id: String,
|
||||
kind: MediaStream.Kind = .audio,
|
||||
sprache: String? = "Deutsch",
|
||||
code: String? = "deu",
|
||||
name: String? = nil,
|
||||
codec: String? = "AC3"
|
||||
) -> MediaStream {
|
||||
MediaStream(
|
||||
id: id, kind: kind, codec: codec, language: sprache, languageCode: code,
|
||||
displayTitle: name, channels: nil, isSelected: false, isForced: false,
|
||||
title: nil, isDefault: false, isHearingImpaired: false, isOriginal: false
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Wann es einen Neuaufbau kostet
|
||||
|
||||
/// **Der Fall, der bisher nirgends stand.** Rechnet der Server um, liegt
|
||||
/// im Stream genau eine Tonspur — jede andere Wahl verlangt eine neue
|
||||
/// Bestellung und damit Sekunden mit schwarzem Bild.
|
||||
@Test("Beim Umrechnen kostet jede Wahl einen Neuaufbau")
|
||||
func transcodeAlwaysRebuilds() {
|
||||
let einträge = TrackMenu.audio(
|
||||
from: [spur("1"), spur("2")],
|
||||
selected: nil, isDirectPlay: false, playerTrackCount: 1
|
||||
)
|
||||
#expect(einträge.allSatisfy { $0.rebuilds })
|
||||
}
|
||||
|
||||
@Test("Unverändert gespielt und gleiche Anzahl: kein Neuaufbau")
|
||||
func directPlayMatchingCountIsCheap() {
|
||||
let einträge = TrackMenu.audio(
|
||||
from: [spur("1"), spur("2")],
|
||||
selected: nil, isDirectPlay: true, playerTrackCount: 2
|
||||
)
|
||||
#expect(einträge.allSatisfy { !$0.rebuilds })
|
||||
}
|
||||
|
||||
/// **Die Regel aus `PlayerModel`, zum ersten Mal prüfbar.** Die Zuordnung
|
||||
/// geht über die Reihenfolge; stimmt die Anzahl nicht, ist die Annahme
|
||||
/// falsch, und es wäre die falsche Sprache statt einer Unterbrechung.
|
||||
/// Auslösen liess sich das bisher nur an einem echten Server.
|
||||
@Test("Abweichende Anzahl heißt Neuaufbau, auch unverändert gespielt")
|
||||
func directPlayMismatchRebuilds() {
|
||||
let einträge = TrackMenu.audio(
|
||||
from: [spur("1"), spur("2"), spur("3")],
|
||||
selected: nil, isDirectPlay: true, playerTrackCount: 2
|
||||
)
|
||||
#expect(einträge.allSatisfy { $0.rebuilds })
|
||||
}
|
||||
|
||||
// MARK: - Untertitel
|
||||
|
||||
@Test("„Aus“ steht vorn und kostet nie einen Neuaufbau")
|
||||
func offComesFirstAndIsFree() {
|
||||
let einträge = TrackMenu.subtitles(
|
||||
from: [spur("1", kind: .subtitle)],
|
||||
selected: nil, isDirectPlay: false, offLabel: "Aus"
|
||||
)
|
||||
#expect(einträge.first?.id == nil)
|
||||
#expect(einträge.first?.title == "Aus")
|
||||
#expect(einträge.first?.isSelected == true)
|
||||
#expect(einträge.first?.rebuilds == false)
|
||||
}
|
||||
|
||||
/// Nur was der Player wirklich hat, lässt sich ohne Neuaufbau schalten.
|
||||
@Test("Eine Untertitelspur, die der Player nicht hat, kostet einen Neuaufbau")
|
||||
func unmappableSubtitleRebuilds() {
|
||||
let deutsch = spur("1", kind: .subtitle, sprache: "Deutsch", code: "deu")
|
||||
let englisch = spur("2", kind: .subtitle, sprache: "English", code: "eng")
|
||||
let einträge = TrackMenu.subtitles(
|
||||
from: [deutsch, englisch],
|
||||
selected: nil, isDirectPlay: true,
|
||||
playerTrackLanguages: ["de"], offLabel: "Aus"
|
||||
)
|
||||
// „deu" und „de" sind dieselbe Sprache — das entscheidet LanguageCode.
|
||||
#expect(einträge[1].rebuilds == false)
|
||||
#expect(einträge[2].rebuilds == true)
|
||||
}
|
||||
|
||||
// MARK: - Beschriftung
|
||||
|
||||
@Test("Der Name des Servers gewinnt, sonst Sprache und Codec")
|
||||
func fallsBackToLanguageAndCodec() {
|
||||
let mitNamen = spur("1", name: "Deutsch (AC3 5.1)")
|
||||
let ohneNamen = spur("2", sprache: "English", codec: "eac3")
|
||||
let einträge = TrackMenu.audio(
|
||||
from: [mitNamen, ohneNamen],
|
||||
selected: mitNamen, isDirectPlay: true, playerTrackCount: 2
|
||||
)
|
||||
#expect(einträge[0].title == "Deutsch (AC3 5.1)")
|
||||
#expect(einträge[1].title == "English · EAC3")
|
||||
#expect(einträge[0].isSelected)
|
||||
#expect(!einträge[1].isSelected)
|
||||
}
|
||||
|
||||
/// Ohne alles bleibt ein Strich — besser als eine leere Zeile, bei der
|
||||
/// niemand weiss, ob sie anklickbar ist.
|
||||
@Test("Ohne Sprache und Codec bleibt ein Strich")
|
||||
func namelessStreamStillHasALabel() {
|
||||
let leer = spur("7", sprache: nil, code: nil, codec: nil)
|
||||
let einträge = TrackMenu.audio(
|
||||
from: [leer], selected: nil, isDirectPlay: true, playerTrackCount: 1
|
||||
)
|
||||
#expect(einträge[0].title == "—")
|
||||
#expect(einträge[0].id == 7)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import HaloPlayback
|
||||
|
||||
@Suite("Wann hinaufgeschaltet wird")
|
||||
struct UpgradeRuleTests {
|
||||
|
||||
/// Die Lage, in der es erlaubt ist — alle übrigen Tests weichen davon in
|
||||
/// genau einem Punkt ab. So steht in jedem Test nur der Unterschied.
|
||||
private func ruhig(
|
||||
isAutomatic: Bool = true,
|
||||
condition: StreamCondition = .healthy,
|
||||
hasHigherRung: Bool = false,
|
||||
ceilingAllowsMore: Bool = true,
|
||||
bufferedAhead: Duration? = .seconds(30),
|
||||
playedFor: Duration = .seconds(120),
|
||||
sinceLastStall: Duration? = nil,
|
||||
sinceLastSeek: Duration? = nil,
|
||||
sinceLastUpgrade: Duration? = nil,
|
||||
used: Int = 0,
|
||||
budget: Int = 2
|
||||
) -> UpgradeRule.Facts {
|
||||
.init(
|
||||
isAutomatic: isAutomatic,
|
||||
condition: condition,
|
||||
hasHigherRung: hasHigherRung,
|
||||
ceilingAllowsMore: ceilingAllowsMore,
|
||||
bufferedAhead: bufferedAhead,
|
||||
playedFor: playedFor,
|
||||
sinceLastStall: sinceLastStall,
|
||||
sinceLastSeek: sinceLastSeek,
|
||||
sinceLastUpgrade: sinceLastUpgrade,
|
||||
used: used,
|
||||
budget: budget
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Läuft es ruhig und ist die Leiter ausgereizt, wird neu aufgebaut")
|
||||
func calmAndCappedRebuilds() {
|
||||
#expect(UpgradeRule.verdict(ruhig()) == .rebuildHigher)
|
||||
}
|
||||
|
||||
// MARK: - Was es verbietet
|
||||
|
||||
/// Wer „720p · 3 Mbps" antippt, meint das — auch wenn die Leitung mehr
|
||||
/// trüge.
|
||||
@Test("Eine gewählte Stufe wird nicht überstimmt")
|
||||
func neverOverridesAChoice() {
|
||||
#expect(UpgradeRule.verdict(ruhig(isAutomatic: false)) == .notAutomatic)
|
||||
}
|
||||
|
||||
/// **Der wichtigste Fall.** Solange die Leiter noch eine Sprosse hat,
|
||||
/// kostet das Hinaufgehen kein schwarzes Bild — ein Neuaufbau dafür wäre
|
||||
/// Verschwendung.
|
||||
@Test("Solange die Leiter trägt, kein Neuaufbau")
|
||||
func prefersTheCheapPath() {
|
||||
#expect(UpgradeRule.verdict(ruhig(hasHigherRung: true)) == .cheaperPathRemains)
|
||||
}
|
||||
|
||||
@Test("Über die eingestellte Grenze geht es nicht")
|
||||
func respectsTheSetting() {
|
||||
#expect(UpgradeRule.verdict(ruhig(ceilingAllowsMore: false)) == .atCeiling)
|
||||
}
|
||||
|
||||
@Test("Bei angespannter Lage wird nicht hinaufgegangen")
|
||||
func notWhenStrained() {
|
||||
#expect(UpgradeRule.verdict(ruhig(condition: .strained)) == .notCalmEnough)
|
||||
#expect(UpgradeRule.verdict(ruhig(condition: .failing)) == .notCalmEnough)
|
||||
}
|
||||
|
||||
/// „Es läuft gerade" genügt nicht — es muss etwas auf der hohen Kante
|
||||
/// liegen, sonst trägt es den Neuaufbau nicht.
|
||||
@Test("Ohne Vorrat kein Neuaufbau")
|
||||
func needsABuffer() {
|
||||
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: .seconds(5))) == .notCalmEnough)
|
||||
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: nil)) == .notCalmEnough)
|
||||
}
|
||||
|
||||
/// Der Puffer füllt sich nach dem Start immer, und das sieht eine halbe
|
||||
/// Minute lang nach einer hervorragenden Leitung aus.
|
||||
@Test("Am Anfang nicht, auch wenn alles gut aussieht")
|
||||
func notRightAfterStart() {
|
||||
#expect(UpgradeRule.verdict(ruhig(playedFor: .seconds(20))) == .tooEarly)
|
||||
}
|
||||
|
||||
@Test("Nach einem Sprung nicht")
|
||||
func notRightAfterSeek() {
|
||||
#expect(UpgradeRule.verdict(ruhig(sinceLastSeek: .seconds(3))) == .tooEarly)
|
||||
}
|
||||
|
||||
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau wäre
|
||||
/// die zweite Unterbrechung binnen einer Minute.
|
||||
@Test("Nach einem Rückler lange nicht")
|
||||
func waitsAfterAStall() {
|
||||
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(30))) == .tooEarly)
|
||||
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(120))) == .rebuildHigher)
|
||||
}
|
||||
|
||||
@Test("Die Sperrfrist gilt")
|
||||
func respectsTheCooldown() {
|
||||
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(60))) == .cooling)
|
||||
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(200))) == .rebuildHigher)
|
||||
}
|
||||
|
||||
@Test("Das Budget ist endlich")
|
||||
func budgetIsFinite() {
|
||||
#expect(UpgradeRule.verdict(ruhig(used: 2, budget: 2)) == .budgetSpent)
|
||||
#expect(UpgradeRule.verdict(ruhig(used: 1, budget: 2)) == .rebuildHigher)
|
||||
}
|
||||
|
||||
// MARK: - Die Reihenfolge der Gründe
|
||||
|
||||
/// Bei mehreren Gründen soll der **grundsätzlichste** gemeldet werden —
|
||||
/// sonst steht im Protokoll „Sperrfrist", wo in Wahrheit die Einstellung
|
||||
/// das Ende ist, und jemand wartet auf etwas, das nie kommt.
|
||||
@Test("Der grundsätzlichste Grund gewinnt")
|
||||
func reportsTheMostFundamentalReason() {
|
||||
#expect(
|
||||
UpgradeRule.verdict(ruhig(
|
||||
isAutomatic: false, hasHigherRung: true, ceilingAllowsMore: false
|
||||
)) == .notAutomatic
|
||||
)
|
||||
#expect(
|
||||
UpgradeRule.verdict(ruhig(
|
||||
hasHigherRung: true, ceilingAllowsMore: false
|
||||
)) == .atCeiling
|
||||
)
|
||||
#expect(
|
||||
UpgradeRule.verdict(ruhig(
|
||||
condition: .failing, hasHigherRung: true
|
||||
)) == .cheaperPathRemains
|
||||
)
|
||||
}
|
||||
|
||||
/// Das Hinaufschalten muss zurückhaltender sein als das Absenken: ein zu
|
||||
/// spätes Absenken bedeutet Ruckeln, ein zu spätes Hinaufschalten ein
|
||||
/// etwas weicheres Bild.
|
||||
@Test("Zurückhaltender als das Absenken")
|
||||
func moreCautiousThanDowngrading() {
|
||||
#expect(UpgradeRule.settleIn > DowngradeRule.settleIn)
|
||||
#expect(UpgradeRule.cooldown < DowngradeRule.cooldown)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user