Flixy heißt ab hier Halo

Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,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)
}
}