Files
Flaro/Packages/HaloKit/Tests/MediaKitTests/HeroSelectionTests.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

179 lines
7.4 KiB
Swift

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"])
}
}