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>
264 lines
9.9 KiB
Swift
264 lines
9.9 KiB
Swift
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")
|
|
}
|
|
}
|