Files
Flaro/Packages/FlixyKit/Tests/MediaKitTests/HeroSelectionTests.swift
T
ScarriffleandClaude Opus 5 3a6a6c4242 Intro und Abspann bei Jellyfin, und drei Dinge aus dem Gebrauch
**Intro überspringen gab es für Jellyfin nie.** Der Knopf, die Marken,
das automatische Überspringen, die Einstellungen dazu — alles stand seit
Langem im Player und auf dem Fernseher. Nur kam bei Jellyfin nie eine
Marke an: `JellyfinItem+Media.swift` setzte `markers: []`, weil Jellyfin
sie nicht am Titel liefert.

Seit 10.10 gibt es `/MediaSegments/{itemId}`, gefüllt von einem Anbieter
im Server wie dem Intro-Skipper. Die Anfrage läuft **neben** dem Abruf
des Titels, nicht danach — sonst wartete die Detailseite zweimal.

Zwei Dinge, die sonst eine Stunde kosten: `includeSegmentTypes` muss
**zweimal** stehen, nicht einmal mit Komma (mit Komma antwortet Jellyfin
mit null Treffern), und die Abfrage wirft nicht. Ein Server ohne Anbieter
antwortet leer, einer vor 10.10 mit einem 404 — und ein Film ohne Intro
ist kein Fehler, sondern der Normalfall.

Geprüft im Simulator: die Attrappe meldet jetzt Segmente, und im Player
steht „Intro überspringen".

---

**„Aus Weiterschauen entfernen".** Eine halbe Stunde gesehen und keine
Lust mehr — der Titel ist weder gesehen noch ungesehen, er soll nur nicht
ewig oben stehen. Beide Server können es, auf verschiedenen Wegen:

- **Plex** hat einen eigenen: `PUT /actions/removeFromContinueWatching`.
  **Nicht** `/:/unscrobble` — das setzt zugleich auf ungesehen.
- **Jellyfin** hat keinen. Was es gibt, ist das Benutzerdatenblatt; die
  Liste führt, was eine gemerkte Stelle grösser null hat. Also wird das
  Blatt gelesen, bekommt eine Null und geht so, wie es war, zurück.
  **Erst lesen, dann schreiben**, weil dort auch „gesehen", „Favorit" und
  die Bewertung stehen — wer seinen Favoriten verliert, weil er einen
  Film aus einer Liste genommen hat, sucht den Grund nie dort.

Der Eintrag erscheint nur, wenn es eine gemerkte Stelle gibt.

---

**Die Mediatheken für den Aufmacher taten zu wenig.** Die Regel „Reihen
ohne Mediathek bleiben immer drin" war freundlich gemeint und falsch: bei
„nur Filme" stand weiter eine Serie oben, weil sie aus „Weiterschauen"
kam. Gemessen und nicht vermutet — es lag nicht am Zeitfenster der
Auswahl, wie zuerst angenommen.

Jetzt gilt: ist etwas gewählt, speist sich der Aufmacher **nur** daraus.
Wer Weiterschauen oben haben will, wählt keine Mediathek. Der Hinweis
unter der Auswahl sagt das auch.

---

**Der „Bearbeiten"-Knopf stand über allem und tat dort nichts.** Er gilt
nur für die Reihen; jetzt steht er an deren Überschrift.

---

Die Attrappe kann drei Dinge mehr, sonst wäre nichts davon prüfbar:
Segmente, `parentId` bei den Neuzugängen (vorher stand in jeder Mediathek
dasselbe) und damit überhaupt eine Mediathekenauswahl, die man ansehen
kann.

iOS und tvOS bauen, 468 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 08:47:06 +02:00

170 lines
6.9 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"])
}
/// Eine Mediathek, die es nicht mehr gibt — umbenannt, gelöscht, Server
/// gewechselt. Dann bleibt nichts übrig, und der Aufmacher fällt auf den
/// ersten Titel der Reihen zurück; das ist ehrlicher, als heimlich doch
/// etwas anderes zu zeigen.
@Test("Eine unbekannte Kennung lässt nichts übrig")
func unknownSelectionLeavesNothing() {
#expect(HeroSelection.rows(alle, limitedTo: ["gibtsnicht"]).isEmpty)
}
@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"])
}
}