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>
179 lines
7.4 KiB
Swift
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"])
|
|
}
|
|
}
|