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,104 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
private func hub(_ identifier: String) -> MediaRowGroup {
|
||||
MediaRowGroup(id: identifier, title: identifier.capitalized)
|
||||
}
|
||||
|
||||
@Suite("Anordnung der Startseiten-Reihen")
|
||||
struct ArrangementTests {
|
||||
private let serverHubs = [hub("continue"), hub("recent"), hub("watchlist"), hub("collections")]
|
||||
|
||||
@Test("Ohne Einstellungen bleibt die Reihenfolge des Servers")
|
||||
func defaultKeepsServerOrder() {
|
||||
let result = Arrangement().apply(to: serverHubs)
|
||||
#expect(result.map(\.id) == ["continue", "recent", "watchlist", "collections"])
|
||||
}
|
||||
|
||||
@Test("Ausgeblendete Reihen verschwinden")
|
||||
func hiddenRowsAreRemoved() {
|
||||
let arrangement = Arrangement(hidden: ["watchlist", "collections"])
|
||||
let result = arrangement.apply(to: serverHubs)
|
||||
#expect(result.map(\.id) == ["continue", "recent"])
|
||||
}
|
||||
|
||||
@Test("Die gespeicherte Reihenfolge wird angewendet")
|
||||
func storedOrderIsApplied() {
|
||||
let arrangement = Arrangement(order: ["watchlist", "continue", "recent", "collections"])
|
||||
let result = arrangement.apply(to: serverHubs)
|
||||
#expect(result.map(\.id) == ["watchlist", "continue", "recent", "collections"])
|
||||
}
|
||||
|
||||
@Test("Neue Reihen vom Server landen am Ende statt zu verschwinden")
|
||||
func unknownRowsAppearAtTheEnd() {
|
||||
// Der Fall, der die einfachere Regel — nur Bekanntes anzeigen — zur
|
||||
// Falle macht: ein neu angelegtes Regal auf dem Server wäre in der App
|
||||
// nie aufgetaucht, ohne dass jemand den Grund gesehen hätte.
|
||||
let arrangement = Arrangement(order: ["recent", "continue"])
|
||||
let result = arrangement.apply(to: serverHubs)
|
||||
#expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"])
|
||||
}
|
||||
|
||||
@Test("Mehrere neue Reihen behalten die Reihenfolge des Servers")
|
||||
func multipleUnknownRowsKeepServerOrder() {
|
||||
// Ohne stabile Rückfalllinie wäre ihre Anordnung zufällig — und
|
||||
// änderte sich bei jedem Start.
|
||||
let arrangement = Arrangement(order: ["collections"])
|
||||
let result = arrangement.apply(to: serverHubs)
|
||||
#expect(result.map(\.id) == ["collections", "continue", "recent", "watchlist"])
|
||||
}
|
||||
|
||||
@Test("Gespeicherte Reihen, die es nicht mehr gibt, stören nicht")
|
||||
func staleOrderEntriesAreIgnored() {
|
||||
// Entsteht beim Löschen einer Mediathek auf dem Server.
|
||||
let arrangement = Arrangement(order: ["geloescht", "recent", "continue"])
|
||||
let result = arrangement.apply(to: serverHubs)
|
||||
#expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"])
|
||||
}
|
||||
|
||||
@Test("Ein doppelter Eintrag in der Reihenfolge stürzt nicht ab")
|
||||
func duplicateOrderEntriesAreTolerated() {
|
||||
// Kann nach einem abgebrochenen Schreibvorgang entstehen. Ein
|
||||
// Dictionary aus doppelten Schlüsseln wäre ein harter Absturz.
|
||||
let arrangement = Arrangement(order: ["recent", "recent", "continue"])
|
||||
let result = arrangement.apply(to: serverHubs)
|
||||
#expect(result.first?.id == "recent")
|
||||
#expect(result.count == 4)
|
||||
}
|
||||
|
||||
@Test("Ausblenden und Sortieren greifen zusammen")
|
||||
func hidingAndOrderingCombine() {
|
||||
let arrangement = Arrangement(
|
||||
hidden: ["recent"],
|
||||
order: ["collections", "recent", "continue"]
|
||||
)
|
||||
let result = arrangement.apply(to: serverHubs)
|
||||
#expect(result.map(\.id) == ["collections", "continue", "watchlist"])
|
||||
}
|
||||
|
||||
// MARK: - Die Liste für die Einstellungen
|
||||
|
||||
@Test("Der Einstellungsbildschirm zeigt Gespeichertes zuerst")
|
||||
func settingsListPutsStoredFirst() {
|
||||
let arrangement = Arrangement(order: ["watchlist", "continue"])
|
||||
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
|
||||
#expect(identifiers == ["watchlist", "continue", "recent", "collections"])
|
||||
}
|
||||
|
||||
@Test("Der Einstellungsbildschirm zeigt auch ausgeblendete Reihen")
|
||||
func settingsListIncludesHidden() {
|
||||
// Sonst ließe sich eine einmal ausgeblendete Reihe nie wieder
|
||||
// einschalten — eine Einbahnstraße.
|
||||
let arrangement = Arrangement(hidden: ["recent"])
|
||||
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
|
||||
#expect(identifiers.contains("recent"))
|
||||
}
|
||||
|
||||
@Test("Verschwundene Reihen erscheinen nicht in den Einstellungen")
|
||||
func settingsListDropsStaleEntries() {
|
||||
let arrangement = Arrangement(order: ["geloescht", "recent"])
|
||||
let identifiers = arrangement.arrangedIdentifiers(from: serverHubs)
|
||||
#expect(!identifiers.contains("geloescht"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
/// Die Umrechnung von `Duration` in die Einheiten der Server.
|
||||
@Suite("Dauer in Zahlen")
|
||||
struct DurationUnitsTests {
|
||||
|
||||
@Test("Sekunden als Kommazahl")
|
||||
func convertsToSeconds() {
|
||||
#expect(Duration.seconds(90).seconds == 90.0)
|
||||
#expect(Duration.milliseconds(1_500).seconds == 1.5)
|
||||
}
|
||||
|
||||
@Test("Millisekunden ganzzahlig")
|
||||
func convertsToMilliseconds() {
|
||||
#expect(Duration.seconds(2).milliseconds == 2_000)
|
||||
#expect(Duration.milliseconds(1_234).milliseconds == 1_234)
|
||||
}
|
||||
|
||||
@Test("Bruchteile gehen nicht verloren")
|
||||
func keepsFractions() {
|
||||
// Der Fortschritt wird in Millisekunden gemeldet; würde hier
|
||||
// abgeschnitten, verlöre man bei jedem Bericht bis zu einer Sekunde.
|
||||
#expect(Duration.milliseconds(1).milliseconds == 1)
|
||||
#expect(Duration.milliseconds(1).seconds == 0.001)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Welche Folge dran ist")
|
||||
struct EpisodeSelectionTests {
|
||||
|
||||
/// Eine Folge, so knapp wie möglich. `duration` steht dabei, weil
|
||||
/// `watchProgress` ohne sie immer `nil` ist — genau die Falle, die einen
|
||||
/// Test lautlos wirkungslos macht.
|
||||
private func folge(
|
||||
_ staffel: Int?,
|
||||
_ nummer: Int?,
|
||||
gesehen: Bool = false,
|
||||
fortschritt: Double? = nil,
|
||||
id: String? = nil
|
||||
) -> MediaItem {
|
||||
MediaItem(
|
||||
id: id ?? "s\(staffel.map(String.init) ?? "x")e\(nummer.map(String.init) ?? "x")",
|
||||
kind: .episode,
|
||||
title: "Folge \(nummer.map(String.init) ?? "?")",
|
||||
duration: 1_000,
|
||||
index: nummer,
|
||||
parentIndex: staffel,
|
||||
playCount: gesehen ? 1 : 0,
|
||||
resumeMilliseconds: fortschritt.map { Int($0 * 1_000) }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Der Normalfall
|
||||
|
||||
/// Der Fall aus der Beschreibung: vier Folgen gesehen, die fünfte ist
|
||||
/// dran.
|
||||
@Test("Die älteste ungesehene Folge")
|
||||
func firstUnwatched() {
|
||||
let staffel = (1...6).map { folge(1, $0, gesehen: $0 <= 4) }
|
||||
let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season)
|
||||
|
||||
#expect(wahl?.episode.id == "s1e5")
|
||||
#expect(wahl?.resumes == false)
|
||||
}
|
||||
|
||||
@Test("Über Staffeln hinweg gilt dieselbe Regel")
|
||||
func crossesSeasons() {
|
||||
let serie = (1...3).map { folge(1, $0, gesehen: true) }
|
||||
+ (1...3).map { folge(2, $0, gesehen: $0 == 1) }
|
||||
let wahl = EpisodeSelection.nextUp(in: serie, scope: .series)
|
||||
|
||||
#expect(wahl?.episode.id == "s2e2")
|
||||
}
|
||||
|
||||
/// Drei Staffeln durch, in der vierten bei Folge zwei — der zweite Fall,
|
||||
/// den der Nutzer ausdrücklich genannt hat.
|
||||
@Test("Drei Staffeln durch, in der vierten weiter")
|
||||
func deepIntoASeries() {
|
||||
var serie: [MediaItem] = []
|
||||
for staffel in 1...3 { serie += (1...8).map { folge(staffel, $0, gesehen: true) } }
|
||||
serie += [folge(4, 1, gesehen: true)] + (2...8).map { folge(4, $0) }
|
||||
serie += (1...8).map { folge(5, $0) }
|
||||
|
||||
#expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s4e2")
|
||||
}
|
||||
|
||||
// MARK: - Fortsetzen
|
||||
|
||||
@Test("Eine angefangene Folge wird fortgesetzt")
|
||||
func resumesPartial() {
|
||||
let staffel = [folge(1, 1, gesehen: true), folge(1, 2, fortschritt: 0.4)]
|
||||
let wahl = EpisodeSelection.nextUp(in: staffel, scope: .season)
|
||||
|
||||
#expect(wahl?.episode.id == "s1e2")
|
||||
#expect(wahl?.resumes == true)
|
||||
}
|
||||
|
||||
/// Ein Fortschritt, den der Server kurz vor Schluss noch nicht gelöscht
|
||||
/// hat, öffnete den Player sonst mitten im Abspann — und das sieht aus,
|
||||
/// als hätte er die Folge übersprungen.
|
||||
@Test("Kurz vor Schluss wird nicht fortgesetzt")
|
||||
func doesNotResumeAtTheVeryEnd() {
|
||||
let staffel = [folge(1, 1, fortschritt: 0.98)]
|
||||
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.resumes == false)
|
||||
}
|
||||
|
||||
@Test("Ohne Fortschritt wird von vorn begonnen")
|
||||
func startsFromZero() {
|
||||
#expect(EpisodeSelection.nextUp(in: [folge(1, 1)], scope: .season)?.resumes == false)
|
||||
}
|
||||
|
||||
/// **Die älteste ungesehene, nicht die am weitesten fortgeschrittene.**
|
||||
/// Wer sich eine Folge aufgehoben hat, soll sie nicht dadurch verlieren,
|
||||
/// dass er weiter vorn hineingeschaut hat.
|
||||
@Test("Eine spätere angefangene Folge zieht nicht vor")
|
||||
func laterProgressDoesNotWin() {
|
||||
let staffel = [
|
||||
folge(1, 1, gesehen: true),
|
||||
folge(1, 2),
|
||||
folge(1, 5, fortschritt: 0.5),
|
||||
]
|
||||
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e2")
|
||||
}
|
||||
|
||||
// MARK: - Alles gesehen
|
||||
|
||||
@Test("Ist alles gesehen, beginnt die Serie von vorn")
|
||||
func watchedSeriesRestarts() {
|
||||
let serie = (1...3).map { folge(1, $0, gesehen: true) }
|
||||
+ (1...3).map { folge(2, $0, gesehen: true) }
|
||||
let wahl = EpisodeSelection.nextUp(in: serie, scope: .series)
|
||||
|
||||
#expect(wahl?.episode.id == "s1e1")
|
||||
#expect(wahl?.resumes == false)
|
||||
}
|
||||
|
||||
@Test("Ist die Staffel gesehen, beginnt sie von vorn")
|
||||
func watchedSeasonRestarts() {
|
||||
let staffel = (1...3).map { folge(4, $0, gesehen: true) }
|
||||
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s4e1")
|
||||
}
|
||||
|
||||
// MARK: - Reihenfolge
|
||||
|
||||
/// Jellyfin sortiert `children(of:)` nach dem Namen, und das stellt
|
||||
/// „Folge 10" vor „Folge 2". Sich darauf zu verlassen ginge genau so
|
||||
/// lange gut, bis eine Staffel zehn Folgen hat.
|
||||
@Test("Die Reihenfolge des Servers zählt nicht")
|
||||
func ignoresServerOrder() {
|
||||
let durcheinander = [folge(1, 10), folge(1, 2), folge(1, 1)]
|
||||
#expect(EpisodeSelection.nextUp(in: durcheinander, scope: .season)?.episode.id == "s1e1")
|
||||
}
|
||||
|
||||
@Test("Lücken in der Nummerierung ändern nichts")
|
||||
func gapsDoNotMatter() {
|
||||
let staffel = [folge(1, 1, gesehen: true), folge(1, 7), folge(1, 9)]
|
||||
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e7")
|
||||
}
|
||||
|
||||
/// Eine fehlende Nummer ist keine Null, sondern „der Server hat nichts
|
||||
/// gesagt". Solche Einträge gehören ans Ende.
|
||||
@Test("Folgen ohne Nummer stehen hinten")
|
||||
func unnumberedGoLast() {
|
||||
let staffel = [folge(1, nil, id: "ohne"), folge(1, 1)]
|
||||
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "s1e1")
|
||||
}
|
||||
|
||||
@Test("Zweimal dieselbe Eingabe ergibt zweimal dasselbe")
|
||||
func isRepeatable() {
|
||||
let staffel = [folge(1, nil, id: "b"), folge(1, nil, id: "a")]
|
||||
#expect(EpisodeSelection.nextUp(in: staffel, scope: .season)?.episode.id == "a")
|
||||
}
|
||||
|
||||
// MARK: - Specials
|
||||
|
||||
/// Staffel 0 steht vor Staffel 1 und ist trotzdem nicht der Anfang.
|
||||
@Test("Specials werden bei der Serie übergangen")
|
||||
func skipsSpecialsInSeries() {
|
||||
let serie = [folge(0, 1), folge(1, 1)]
|
||||
#expect(EpisodeSelection.nextUp(in: serie, scope: .series)?.episode.id == "s1e1")
|
||||
}
|
||||
|
||||
/// Wer die Specials-Kachel angeklickt hat, meint die Specials.
|
||||
@Test("Bei der Staffel bleiben sie stehen")
|
||||
func keepsSpecialsInSeason() {
|
||||
let specials = [folge(0, 1), folge(0, 2)]
|
||||
#expect(EpisodeSelection.nextUp(in: specials, scope: .season)?.episode.id == "s0e1")
|
||||
}
|
||||
|
||||
/// Gäbe es **nur** Specials, dürfte die Serie nicht wortlos nichts tun.
|
||||
@Test("Eine Serie aus lauter Specials ergibt nichts")
|
||||
func onlySpecialsIsNothing() {
|
||||
#expect(EpisodeSelection.nextUp(in: [folge(0, 1)], scope: .series) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Was keine Folge ist
|
||||
|
||||
/// Jellyfin liefert in Listen nicht immer eine brauchbare Art. Sie
|
||||
/// wegzuwerfen leerte die Liste — also bleibt `nil` stehen.
|
||||
@Test("Behälter fliegen raus, Unbekanntes bleibt")
|
||||
func dropsContainersKeepsUnknown() {
|
||||
let gemischt = [
|
||||
MediaItem(id: "staffel", kind: .season, title: "Staffel 1", index: 1),
|
||||
MediaItem(id: "serie", kind: .show, title: "Serie"),
|
||||
MediaItem(id: "unbekannt", kind: nil, title: "?", index: 1, parentIndex: 1),
|
||||
]
|
||||
let wahl = EpisodeSelection.nextUp(in: gemischt, scope: .series)
|
||||
#expect(wahl?.episode.id == "unbekannt")
|
||||
}
|
||||
|
||||
@Test("Eine leere Liste ergibt nichts")
|
||||
func emptyIsNothing() {
|
||||
#expect(EpisodeSelection.nextUp(in: [], scope: .series) == nil)
|
||||
#expect(EpisodeSelection.nextUp(in: [], scope: .season) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Auswahl für den Aufmacher")
|
||||
struct HeroSelectionTests {
|
||||
|
||||
private func titel(_ id: String, backdrop: Bool = true) -> MediaItem {
|
||||
MediaItem(id: id, kind: .movie, title: "Film \(id)", art: backdrop ? "/art/\(id)" : nil)
|
||||
}
|
||||
|
||||
private var zwanzig: [MediaItem] { (1...20).map { titel(String($0)) } }
|
||||
|
||||
/// **Der Fehler, wegen dem es diesen Typ gibt.** Dieselbe Liste, dasselbe
|
||||
/// Zeitfenster, zweimal gefragt — und zweimal dasselbe. Vorher kam das
|
||||
/// nur heraus, solange die Liste sich nicht um einen einzigen Eintrag
|
||||
/// unterschied.
|
||||
@Test("Gleiche Eingabe, gleiches Ergebnis")
|
||||
func stableWithinWindow() {
|
||||
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
#expect(
|
||||
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
||||
== HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
||||
)
|
||||
}
|
||||
|
||||
/// Und der eigentliche Punkt: ein Titel mehr darf die übrigen nicht
|
||||
/// durcheinanderbringen. Genau das ist bei jedem Neustart passiert —
|
||||
/// „Weiterschauen" wächst und schrumpft ständig.
|
||||
@Test("Ein zusätzlicher Titel verschiebt die anderen nicht")
|
||||
func addingOneKeepsTheRest() {
|
||||
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
||||
let nachher = HeroSelection.ordered(zwanzig + [titel("99")], at: jetzt).map(\.id)
|
||||
|
||||
// Der Neue darf irgendwo stehen; die übrigen müssen in derselben
|
||||
// Reihenfolge zueinander stehen wie vorher.
|
||||
#expect(nachher.filter { $0 != "99" } == vorher)
|
||||
}
|
||||
|
||||
@Test("Ein fehlender Titel hinterlässt nur eine Lücke")
|
||||
func removingOneKeepsTheRest() {
|
||||
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let vorher = HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
||||
let ohne = HeroSelection.ordered(zwanzig.filter { $0.id != "7" }, at: jetzt).map(\.id)
|
||||
|
||||
#expect(ohne == vorher.filter { $0 != "7" })
|
||||
}
|
||||
|
||||
/// Sechs Stunden später eine andere Auswahl — sonst wäre es keine
|
||||
/// Abwechslung, sondern eine feste Reihenfolge.
|
||||
@Test("Im nächsten Zeitfenster steht es anders")
|
||||
func changesAcrossWindows() {
|
||||
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let später = jetzt.addingTimeInterval(7 * 3_600)
|
||||
#expect(
|
||||
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
||||
!= HeroSelection.ordered(zwanzig, at: später).map(\.id)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Eine Stunde später steht es noch gleich")
|
||||
func stableAcrossAnHour() {
|
||||
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
#expect(
|
||||
HeroSelection.ordered(zwanzig, at: jetzt).map(\.id)
|
||||
== HeroSelection.ordered(zwanzig, at: jetzt.addingTimeInterval(1_800)).map(\.id)
|
||||
)
|
||||
}
|
||||
|
||||
/// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
|
||||
/// Ausschliessen wäre zu hart — schlechter losen ist genug.
|
||||
@Test("Titel ohne Hintergrundbild landen meistens hinten")
|
||||
func backdropsWin() {
|
||||
let jetzt = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let gemischt = (1...10).map { titel("mit\($0)") }
|
||||
+ (1...10).map { titel("ohne\($0)", backdrop: false) }
|
||||
|
||||
let ersteFünf = HeroSelection.ordered(gemischt, at: jetzt).prefix(5).map(\.id)
|
||||
#expect(ersteFünf.allSatisfy { $0.hasPrefix("mit") })
|
||||
}
|
||||
|
||||
/// Gäbe es nur Titel ohne Bild, dürfte nicht einfach nichts kommen.
|
||||
@Test("Gibt es nur Titel ohne Bild, kommen sie trotzdem")
|
||||
func withoutBackdropsStillReturns() {
|
||||
let ohne = (1...5).map { titel(String($0), backdrop: false) }
|
||||
#expect(HeroSelection.ordered(ohne).count == 5)
|
||||
}
|
||||
|
||||
@Test("Eine leere Liste bleibt leer")
|
||||
func emptyStaysEmpty() {
|
||||
#expect(HeroSelection.ordered([]).isEmpty)
|
||||
}
|
||||
|
||||
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf zufällig —
|
||||
/// damit wäre die Auswahl nach jedem Start eine andere, also genau der
|
||||
/// Fehler, der hier behoben werden soll.
|
||||
@Test("Der Streuwert hängt nur am Text")
|
||||
func hashIsStable() {
|
||||
#expect(HeroSelection.stableHash("abc") == HeroSelection.stableHash("abc"))
|
||||
#expect(HeroSelection.stableHash("abc") != HeroSelection.stableHash("abd"))
|
||||
}
|
||||
|
||||
@Test("Die Lose liegen zwischen null und eins")
|
||||
func lotsAreInRange() {
|
||||
for id in 1...200 {
|
||||
let los = HeroSelection.unitRandom(seed: UInt64(id))
|
||||
#expect(los >= 0 && los < 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Mediatheken für den Aufmacher")
|
||||
struct HeroLibraryFilterTests {
|
||||
|
||||
private func reihe(_ id: String, library: String? = nil) -> MediaRowGroup {
|
||||
MediaRowGroup(
|
||||
id: id,
|
||||
title: id,
|
||||
libraryID: library,
|
||||
items: [MediaItem(id: "\(id).1", kind: .movie, title: "Titel")]
|
||||
)
|
||||
}
|
||||
|
||||
private var alle: [MediaRowGroup] {
|
||||
[reihe("resume"), reihe("latest.filme", library: "filme"),
|
||||
reihe("latest.serien", library: "serien"), reihe("favourites")]
|
||||
}
|
||||
|
||||
/// **Der wichtigste Fall.** Wer nichts ankreuzt, meint „mir ist es
|
||||
/// gleich" — nicht „nimm nichts". Ein leerer Aufmacher wäre hier der
|
||||
/// Fehler, den niemand mit der Einstellung in Verbindung brächte.
|
||||
@Test("Ohne Auswahl bleibt alles")
|
||||
func emptySelectionKeepsEverything() {
|
||||
#expect(HeroSelection.rows(alle, limitedTo: []).map(\.id) == alle.map(\.id))
|
||||
}
|
||||
|
||||
@Test("Eine gewählte Mediathek lässt nur ihre Reihe durch")
|
||||
func selectionKeepsOnlyThatLibrary() {
|
||||
let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id)
|
||||
#expect(übrig.contains("latest.filme"))
|
||||
#expect(!übrig.contains("latest.serien"))
|
||||
}
|
||||
|
||||
/// **Die Regel, die zuerst andersherum stand.** Weiterschauen und
|
||||
/// Favoriten tragen keine Mediathek; sie trotzdem durchzulassen hiess,
|
||||
/// dass bei „nur Filme" weiter eine Serie oben stand — und niemand
|
||||
/// brachte das mit der Einstellung in Verbindung.
|
||||
@Test("Reihen ohne Mediathek fallen weg, sobald etwas gewählt ist")
|
||||
func rowsWithoutLibraryDropOutOnSelection() {
|
||||
let übrig = HeroSelection.rows(alle, limitedTo: ["filme"]).map(\.id)
|
||||
#expect(übrig == ["latest.filme"])
|
||||
}
|
||||
|
||||
/// **Der Fall, der den Aufmacher verschwinden liess.** Eine Mediathek,
|
||||
/// die es nicht mehr gibt — umbenannt, gelöscht, Server gewechselt —,
|
||||
/// oder ein Server, dessen Reihen gar keine Mediathek nennen. Bliebe es
|
||||
/// bei „nichts übrig", stünde oben nichts mehr, und niemand brächte das
|
||||
/// mit einer Einstellung zwei Bildschirme weiter in Verbindung.
|
||||
@Test("Trifft die Auswahl nichts, gilt sie als wirkungslos")
|
||||
func unknownSelectionFallsBack() {
|
||||
#expect(HeroSelection.rows(alle, limitedTo: ["gibtsnicht"]).map(\.id) == alle.map(\.id))
|
||||
}
|
||||
|
||||
/// Reihen ohne Mediathek allein sind kein Rückfall: gibt es daneben
|
||||
/// passende, bleiben nur die. Sonst wäre die Ausnahme oben die Regel.
|
||||
@Test("Der Rückfall greift nur, wenn wirklich nichts passt")
|
||||
func fallbackOnlyWhenEmpty() {
|
||||
let übrig = HeroSelection.rows(alle, limitedTo: ["serien"]).map(\.id)
|
||||
#expect(übrig == ["latest.serien"])
|
||||
}
|
||||
|
||||
@Test("Mehrere gewählte Mediatheken kommen alle durch")
|
||||
func multipleSelection() {
|
||||
let übrig = HeroSelection.rows(alle, limitedTo: ["filme", "serien"]).map(\.id)
|
||||
#expect(übrig == ["latest.filme", "latest.serien"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Gespeicherte Mediatheken")
|
||||
struct LibraryCacheTests {
|
||||
|
||||
/// Jeder Test bekommt ein eigenes Verzeichnis. Ein gemeinsames hiesse,
|
||||
/// dass die Reihenfolge der Tests über ihr Ergebnis mitentscheidet.
|
||||
private func frischerSpeicher() -> LibraryCache {
|
||||
let ort = URL.temporaryDirectory.appending(path: "halo-cache-\(UUID().uuidString)")
|
||||
return LibraryCache(root: ort)
|
||||
}
|
||||
|
||||
private func titel(_ id: String, _ name: String) -> MediaItem {
|
||||
MediaItem(id: id, kind: .movie, title: name, year: 1999, thumb: "/bild/\(id)")
|
||||
}
|
||||
|
||||
@Test("Was hineingeht, kommt heraus")
|
||||
func roundTrip() async {
|
||||
let speicher = frischerSpeicher()
|
||||
let abzug = LibraryCache.Snapshot(
|
||||
queryKey: "titleAscending||||||",
|
||||
items: [titel("1", "Matrix"), titel("2", "Fight Club")],
|
||||
totalCount: 412
|
||||
)
|
||||
|
||||
await speicher.store(abzug, server: "jellyfin:daheim", library: "filme")
|
||||
let zurück = await speicher.snapshot(
|
||||
server: "jellyfin:daheim", library: "filme", queryKey: "titleAscending||||||"
|
||||
)
|
||||
|
||||
#expect(zurück?.items.map(\.title) == ["Matrix", "Fight Club"])
|
||||
// Ohne die Gesamtzahl wüsste die Ansicht nicht, ob noch etwas kommt.
|
||||
#expect(zurück?.totalCount == 412)
|
||||
#expect(zurück?.items.first?.thumb == "/bild/1")
|
||||
}
|
||||
|
||||
/// Der Fall, der stillschweigend falsche Listen zeigen würde: derselbe
|
||||
/// Ort, andere Sortierung.
|
||||
@Test("Ein Abzug zu einer anderen Abfrage gilt nicht")
|
||||
func wrongQueryIsNoAnswer() async {
|
||||
let speicher = frischerSpeicher()
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "titleAscending||||||", items: [titel("1", "A")], totalCount: 1),
|
||||
server: "s", library: "l"
|
||||
)
|
||||
|
||||
#expect(await speicher.snapshot(server: "s", library: "l", queryKey: "recentlyAdded||||||") == nil)
|
||||
}
|
||||
|
||||
@Test("Ohne Abzug kommt nichts")
|
||||
func emptyIsEmpty() async {
|
||||
#expect(await frischerSpeicher().snapshot(server: "s", library: "l", queryKey: "x") == nil)
|
||||
}
|
||||
|
||||
@Test("Zwei Mediatheken kommen sich nicht ins Gehege")
|
||||
func librariesAreSeparate() async {
|
||||
let speicher = frischerSpeicher()
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Film")], totalCount: 1),
|
||||
server: "s", library: "filme"
|
||||
)
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [titel("2", "Serie")], totalCount: 1),
|
||||
server: "s", library: "serien"
|
||||
)
|
||||
|
||||
#expect(await speicher.snapshot(server: "s", library: "filme", queryKey: "k")?.items.first?.title == "Film")
|
||||
#expect(await speicher.snapshot(server: "s", library: "serien", queryKey: "k")?.items.first?.title == "Serie")
|
||||
}
|
||||
|
||||
/// Dieselbe Mediathek auf zwei Servern hat dieselbe Kennung. Ohne Trennung
|
||||
/// nach Server zeigte der eine die Titel des anderen.
|
||||
@Test("Zwei Server kommen sich nicht ins Gehege")
|
||||
func serversAreSeparate() async {
|
||||
let speicher = frischerSpeicher()
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Daheim")], totalCount: 1),
|
||||
server: "jellyfin:daheim", library: "1"
|
||||
)
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "Fremd")], totalCount: 1),
|
||||
server: "jellyfin:fremd", library: "1"
|
||||
)
|
||||
|
||||
#expect(await speicher.snapshot(server: "jellyfin:daheim", library: "1", queryKey: "k")?.items.first?.title == "Daheim")
|
||||
}
|
||||
|
||||
@Test("Mehr als eine Seite wird nicht behalten")
|
||||
func onlyOnePage() async {
|
||||
let speicher = frischerSpeicher()
|
||||
let viele = (0..<500).map { titel(String($0), "Titel \($0)") }
|
||||
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: viele, totalCount: 500),
|
||||
server: "s", library: "l"
|
||||
)
|
||||
|
||||
let zurück = await speicher.snapshot(server: "s", library: "l", queryKey: "k")
|
||||
#expect(zurück?.items.count == LibraryCache.pageLimit)
|
||||
// Die Gesamtzahl bleibt, sonst hielte die Ansicht sechzig für alles.
|
||||
#expect(zurück?.totalCount == 500)
|
||||
}
|
||||
|
||||
/// Was eine Kachel nicht zeigt, wird nicht gespeichert — sonst wäre jeder
|
||||
/// Abzug das Zwanzigfache gross und müsste beim Aufrufen ausgepackt
|
||||
/// werden, also genau da, wo er Zeit sparen soll.
|
||||
@Test("Gespeichert wird nur, was eine Kachel zeigt")
|
||||
func storesOnlyWhatATileShows() async {
|
||||
let speicher = frischerSpeicher()
|
||||
let voll = MediaItem(
|
||||
id: "1",
|
||||
kind: .movie,
|
||||
title: "Matrix",
|
||||
summary: "Ein langer Text, der im Raster nirgends steht.",
|
||||
duration: 120_000,
|
||||
thumb: "/bild/1",
|
||||
resumeMilliseconds: 60_000,
|
||||
versions: [MediaVersion(id: "v1", videoCodec: "hevc", width: 3_840, height: 2_160)],
|
||||
genres: ["Science-Fiction"],
|
||||
cast: [MediaPerson(id: "p1", name: "Jemand")]
|
||||
)
|
||||
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [voll], totalCount: 1),
|
||||
server: "s", library: "l"
|
||||
)
|
||||
let zurück = try! #require(
|
||||
await speicher.snapshot(server: "s", library: "l", queryKey: "k")
|
||||
).items[0]
|
||||
|
||||
// Was die Kachel braucht, ist da.
|
||||
#expect(zurück.title == "Matrix")
|
||||
#expect(zurück.thumb == "/bild/1")
|
||||
#expect(zurück.watchProgress == 0.5)
|
||||
// Was sie nicht braucht, ist weg.
|
||||
#expect(zurück.versions.isEmpty)
|
||||
#expect(zurück.cast.isEmpty)
|
||||
#expect(zurück.genres.isEmpty)
|
||||
#expect(zurück.summary == nil)
|
||||
}
|
||||
|
||||
@Test("Abmelden räumt alles weg")
|
||||
func signOutClears() async {
|
||||
let speicher = frischerSpeicher()
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "A")], totalCount: 1),
|
||||
server: "s", library: "l"
|
||||
)
|
||||
await speicher.storeLibraries([MediaLibrary(id: "l", title: "Filme", kind: .movie)], server: "s")
|
||||
|
||||
await speicher.clearAll()
|
||||
|
||||
#expect(await speicher.snapshot(server: "s", library: "l", queryKey: "k") == nil)
|
||||
#expect(await speicher.libraries(server: "s") == nil)
|
||||
}
|
||||
|
||||
@Test("Einen Server vergessen lässt den anderen stehen")
|
||||
func clearIsPerServer() async {
|
||||
let speicher = frischerSpeicher()
|
||||
for server in ["a", "b"] {
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", server)], totalCount: 1),
|
||||
server: server, library: "l"
|
||||
)
|
||||
}
|
||||
|
||||
await speicher.clear(server: "a")
|
||||
|
||||
#expect(await speicher.snapshot(server: "a", library: "l", queryKey: "k") == nil)
|
||||
#expect(await speicher.snapshot(server: "b", library: "l", queryKey: "k") != nil)
|
||||
}
|
||||
|
||||
@Test("Die Mediathekenliste überlebt")
|
||||
func libraryListRoundTrip() async {
|
||||
let speicher = frischerSpeicher()
|
||||
let listen = [
|
||||
MediaLibrary(id: "1", title: "Filme", kind: .movie, thumb: "/a"),
|
||||
MediaLibrary(id: "2", title: "Serien", kind: .show),
|
||||
]
|
||||
|
||||
await speicher.storeLibraries(listen, server: "s")
|
||||
|
||||
#expect(await speicher.libraries(server: "s") == listen)
|
||||
}
|
||||
|
||||
// MARK: - Der Schlüssel
|
||||
|
||||
/// **Nicht `hashValue`.** Swift streut Hashes je Programmlauf; ein damit
|
||||
/// benannter Abzug wäre nach jedem Neustart unauffindbar, ohne dass
|
||||
/// irgendetwas kaputt aussähe.
|
||||
@Test("Derselbe Filter ergibt denselben Schlüssel")
|
||||
func keyIsStable() {
|
||||
let a = MediaQuery(sort: .recentlyAdded, unwatchedOnly: true, genre: "Krimi")
|
||||
let b = MediaQuery(sort: .recentlyAdded, unwatchedOnly: true, genre: "Krimi")
|
||||
#expect(a.cacheKey == b.cacheKey)
|
||||
}
|
||||
|
||||
@Test("Jeder Unterschied ändert den Schlüssel")
|
||||
func keySeparatesQueries() {
|
||||
let grund = MediaQuery()
|
||||
#expect(MediaQuery(sort: .recentlyAdded).cacheKey != grund.cacheKey)
|
||||
#expect(MediaQuery(unwatchedOnly: true).cacheKey != grund.cacheKey)
|
||||
#expect(MediaQuery(favouritesOnly: true).cacheKey != grund.cacheKey)
|
||||
#expect(MediaQuery(genre: "Krimi").cacheKey != grund.cacheKey)
|
||||
#expect(MediaQuery(year: 1999).cacheKey != grund.cacheKey)
|
||||
#expect(MediaQuery(contentRating: "FSK 16").cacheKey != grund.cacheKey)
|
||||
#expect(MediaQuery(resolution: "4k").cacheKey != grund.cacheKey)
|
||||
#expect(MediaQuery(searchText: "Matrix").cacheKey != grund.cacheKey)
|
||||
}
|
||||
|
||||
/// Kennungen kommen vom Server — Jellyfin-GUIDs, und in der
|
||||
/// Serverkennung steckt eine Adresse mit Schrägstrichen.
|
||||
@Test("Kennungen mit Schrägstrichen ergeben brauchbare Dateinamen")
|
||||
func nameIsFilesystemSafe() async {
|
||||
let speicher = frischerSpeicher()
|
||||
let wild = "jellyfin:https://medien.beispiel.tld:8096/:a+b/c"
|
||||
|
||||
await speicher.store(
|
||||
LibraryCache.Snapshot(queryKey: "k", items: [titel("1", "A")], totalCount: 1),
|
||||
server: wild, library: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"
|
||||
)
|
||||
|
||||
#expect(
|
||||
await speicher.snapshot(
|
||||
server: wild, library: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", queryKey: "k"
|
||||
)?.items.count == 1
|
||||
)
|
||||
#expect(!LibraryCache.safeName(wild).contains("/"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Wann nachgeladen wird")
|
||||
struct PagingWindowTests {
|
||||
private let fenster = PagingWindow(columns: 6, rowsAhead: 2)
|
||||
|
||||
/// **Der Fall, der die alte Regel entlarvt.** Wer ganz oben steht, ist
|
||||
/// nicht am Ende — auch wenn die letzten fünf Kacheln längst gezeichnet
|
||||
/// sind. Genau daran lag es, dass das Nachladen sofort losging.
|
||||
@Test("Am Anfang wird nicht nachgeladen")
|
||||
func notAtTheStart() {
|
||||
#expect(!fenster.shouldLoad(focusedIndex: 0, loaded: 60, total: 300))
|
||||
}
|
||||
|
||||
/// Zwei Zeilen vor Schluss bei sechs Spalten: ab Eintrag 48 von 60.
|
||||
@Test("Zwei Zeilen vor Schluss wird nachgeladen")
|
||||
func loadsTwoRowsEarly() {
|
||||
#expect(!fenster.shouldLoad(focusedIndex: 47, loaded: 60, total: 300))
|
||||
#expect(fenster.shouldLoad(focusedIndex: 48, loaded: 60, total: 300))
|
||||
}
|
||||
|
||||
/// Ein breiteres Raster braucht mehr Vorlauf — das ist der ganze Grund,
|
||||
/// in Zeilen zu rechnen statt in Einträgen.
|
||||
@Test("Mehr Spalten heißt früher nachladen")
|
||||
func widerGridLoadsEarlier() {
|
||||
let schmal = PagingWindow(columns: 3)
|
||||
let breit = PagingWindow(columns: 8)
|
||||
#expect(!schmal.shouldLoad(focusedIndex: 50, loaded: 60, total: 300))
|
||||
#expect(breit.shouldLoad(focusedIndex: 50, loaded: 60, total: 300))
|
||||
}
|
||||
|
||||
@Test("Ist alles da, wird nicht mehr gefragt")
|
||||
func stopsWhenComplete() {
|
||||
#expect(!fenster.shouldLoad(focusedIndex: 59, loaded: 60, total: 60))
|
||||
}
|
||||
|
||||
/// Ein Raster kennt seine Spaltenzahl erst nach dem ersten Zeichnen.
|
||||
/// Falsch zu antworten ist besser als durch null zu teilen.
|
||||
@Test("Ohne Spalten wird nichts entschieden")
|
||||
func survivesZeroColumns() {
|
||||
#expect(!PagingWindow(columns: 0).shouldLoad(focusedIndex: 10, loaded: 60, total: 300))
|
||||
}
|
||||
|
||||
@Test("Eine leere Mediathek löst nichts aus")
|
||||
func emptyLibrary() {
|
||||
#expect(!fenster.shouldLoad(focusedIndex: 0, loaded: 0, total: 0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Der Abzug fürs Regal")
|
||||
struct TopShelfSnapshotTests {
|
||||
|
||||
private func eintrag(_ id: String) -> TopShelfSnapshot.Entry {
|
||||
.init(id: id, title: "Titel \(id)", imageURL: URL(string: "https://example.test/\(id).jpg"))
|
||||
}
|
||||
|
||||
/// **Der Fall, der sonst erst auf dem Fernseher auffiele.** Die
|
||||
/// Erweiterung entschlüsselt, was die App geschrieben hat; passt eines
|
||||
/// von beiden nicht, bleibt das Regal leer — ohne Fehler, ohne Hinweis.
|
||||
@Test("Was geschrieben wurde, kommt gleich wieder heraus")
|
||||
func roundTrips() throws {
|
||||
let original = TopShelfSnapshot(sections: [
|
||||
.init(id: "continue", title: "Weiterschauen", entries: [eintrag("1"), eintrag("2")]),
|
||||
])
|
||||
let daten = try JSONEncoder().encode(original)
|
||||
let zurück = try JSONDecoder().decode(TopShelfSnapshot.self, from: daten)
|
||||
#expect(zurück == original)
|
||||
}
|
||||
|
||||
/// Ohne App-Gruppe gibt es den geteilten Ort nicht — und bis sie im
|
||||
/// Entwicklerkonto steht, ist das der Normalfall. Dann soll nichts
|
||||
/// passieren, nicht abgestürzt werden.
|
||||
///
|
||||
/// **Geprüft wird die Harmlosigkeit, nicht das `nil`.** Dass der Ort
|
||||
/// fehlt, lässt sich hier nicht behaupten: dieser Test läuft auf macOS,
|
||||
/// und dort reicht das System für eine unbekannte Gruppe trotzdem einen
|
||||
/// Ordner heraus — anders als auf tvOS. Die Zusage, auf die es ankommt,
|
||||
/// gilt überall: beides geht durch, und gelesen wird nur, was auch
|
||||
/// geschrieben wurde.
|
||||
@Test("Eine unbekannte App-Gruppe bringt nichts zum Absturz")
|
||||
func unknownGroupIsHarmless() {
|
||||
let unbekannt = "group.halo.test.\(UUID().uuidString)"
|
||||
TopShelfStore.write(TopShelfSnapshot(sections: []), group: unbekannt)
|
||||
let gelesen = TopShelfStore.read(group: unbekannt)
|
||||
#expect(gelesen?.sections.isEmpty ?? true)
|
||||
}
|
||||
|
||||
/// Die Grenzen sind kein Geschmack: das Regal zeigt eine knappe
|
||||
/// Bildschirmbreite, und die Erweiterung hat weniger als eine Sekunde.
|
||||
@Test("Die Grenzen sind eng genug, dass es in einer Sekunde reicht")
|
||||
func limitsAreTight() {
|
||||
#expect(TopShelfSnapshot.sectionLimit <= 3)
|
||||
#expect(TopShelfSnapshot.entryLimit <= 20)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Testing
|
||||
@testable import MediaKit
|
||||
|
||||
@Suite("Auflösung einstufen")
|
||||
struct VideoResolutionTests {
|
||||
|
||||
// MARK: - Nach der Breite
|
||||
|
||||
@Test("Die üblichen Breiten")
|
||||
func commonWidths() {
|
||||
#expect(VideoResolution(width: 7_680, height: 4_320) == .uhd8K)
|
||||
#expect(VideoResolution(width: 3_840, height: 2_160) == .uhd4K)
|
||||
#expect(VideoResolution(width: 4_096, height: 2_160) == .uhd4K)
|
||||
#expect(VideoResolution(width: 2_560, height: 1_440) == .qhd)
|
||||
#expect(VideoResolution(width: 1_920, height: 1_080) == .fullHD)
|
||||
#expect(VideoResolution(width: 1_280, height: 720) == .hd)
|
||||
#expect(VideoResolution(width: 720, height: 576) == .sd)
|
||||
}
|
||||
|
||||
/// Der Fall, wegen dem die Breite zuerst kommt: ein Kinofilm in 2,39:1 hat
|
||||
/// 804 Zeilen. Nach der Höhe wäre das „HD" — und das stand bisher an
|
||||
/// ausgerechnet den größten Filmen.
|
||||
@Test("Breitwand bleibt Full HD, nicht HD")
|
||||
func letterboxedStaysFullHD() {
|
||||
#expect(VideoResolution(width: 1_920, height: 804) == .fullHD)
|
||||
#expect(VideoResolution(width: 1_920, height: 800) == .fullHD)
|
||||
}
|
||||
|
||||
@Test("Breitwandiges 4K bleibt 4K")
|
||||
func letterboxed4K() {
|
||||
#expect(VideoResolution(width: 3_840, height: 1_608) == .uhd4K)
|
||||
#expect(VideoResolution(width: 3_840, height: 1_392) == .uhd4K)
|
||||
}
|
||||
|
||||
// MARK: - Nach der Höhe, wenn es die Breite nicht gibt
|
||||
|
||||
@Test("Ohne Breite entscheidet die Höhe")
|
||||
func fallsBackToHeight() {
|
||||
#expect(VideoResolution(width: nil, height: 2_160) == .uhd4K)
|
||||
#expect(VideoResolution(width: nil, height: 1_080) == .fullHD)
|
||||
#expect(VideoResolution(width: nil, height: 720) == .hd)
|
||||
#expect(VideoResolution(width: nil, height: 480) == .sd)
|
||||
#expect(VideoResolution(width: 0, height: 1_080) == .fullHD)
|
||||
}
|
||||
|
||||
// MARK: - Nach dem Namen des Servers
|
||||
|
||||
@Test("Was Plex hinschreibt")
|
||||
func plexLabels() {
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "4k") == .uhd4K)
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "1080") == .fullHD)
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "720") == .hd)
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "sd") == .sd)
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "576") == .sd)
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "4K") == .uhd4K)
|
||||
}
|
||||
|
||||
@Test("Zeilensprung wird als Zahl gelesen")
|
||||
func interlacedLabel() {
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "1080i") == .fullHD)
|
||||
}
|
||||
|
||||
@Test("Ohne jede Angabe keine erfundene Stufe")
|
||||
func nothingIsNothing() {
|
||||
#expect(VideoResolution(width: nil, height: nil) == nil)
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "") == nil)
|
||||
#expect(VideoResolution(width: nil, height: nil, serverLabel: "irgendwas") == nil)
|
||||
}
|
||||
|
||||
// MARK: - Die Namen
|
||||
|
||||
@Test("Die Namen, die dastehen sollen")
|
||||
func names() {
|
||||
#expect(VideoResolution.uhd4K.name == "4K")
|
||||
#expect(VideoResolution.fullHD.name == "Full HD")
|
||||
#expect(VideoResolution.hd.name == "HD")
|
||||
#expect(VideoResolution.sd.name == "SD")
|
||||
#expect(VideoResolution.fullHD.technicalName == "1080p")
|
||||
}
|
||||
|
||||
/// Gebraucht für „größer als 4K" in Filtern.
|
||||
@Test("Die Stufen lassen sich vergleichen")
|
||||
func ordered() {
|
||||
#expect(VideoResolution.sd < .hd)
|
||||
#expect(VideoResolution.fullHD < .uhd4K)
|
||||
#expect(VideoResolution.uhd8K > .uhd4K)
|
||||
}
|
||||
|
||||
// MARK: - An der Fassung
|
||||
|
||||
@Test("Die Fassung sagt Full HD statt 1080")
|
||||
func versionSpeaksPlainly() {
|
||||
let fassung = MediaVersion(
|
||||
id: "1", videoResolution: "1080", width: 1_920, height: 804
|
||||
)
|
||||
#expect(fassung.resolutionLabel == "Full HD")
|
||||
#expect(fassung.qualityLabel.hasPrefix("Full HD"))
|
||||
}
|
||||
|
||||
/// Ohne Zahlen bleibt die Angabe des Servers die einzige Quelle — sie darf
|
||||
/// dann nicht verlorengehen.
|
||||
@Test("Auch ohne Maße kommt ein Name heraus")
|
||||
func labelWithoutNumbers() {
|
||||
#expect(MediaVersion(id: "1", videoResolution: "4k").resolutionLabel == "4K")
|
||||
#expect(MediaVersion(id: "1", videoResolution: "4k").pixelHeight == 2_160)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user