Halo heisst ab hier Flaro — die Marke stand im Weg

Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

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