Player hing sich auf Die Qualitätsauswahl war ein Menu. Schloss man es durch einen Tipp ins Bild, blieb eine unsichtbare Ebene zurück, die alle weiteren Tipps schluckte — die Bedienelemente kamen nicht mehr, pausieren ging nicht mehr. Jetzt ein confirmationDialog, der sich selbst wegräumt. Nach dem Schließen werden die Bedienelemente eingeblendet und die Ausblendfrist neu gestartet. Downloads galten als fertig, waren aber leer Zwei Fehler übereinander. Erstens gilt ein URLSessionDownloadTask auch bei 404 oder 500 als erfolgreich und legt die Fehlerseite als Datei ab. Zweitens überschrieb ich danach die erwartete Größe mit der empfangenen — womit die Anzeige auf hundert Prozent stand und der Beweis weg war. 873 MB waren in einer Sekunde „fertig", und beim Abspielen kam nur „Wiedergabe wurde unterbrochen". Jetzt wird der Statuscode geprüft, die erwartete Größe bleibt stehen, und eine unplausible Datei wird verworfen statt verbucht: unter 100 KB ist keine Videodatei, unter der Hälfte der angekündigten Größe ist eine abgebrochene Übertragung. Sechs Tests dazu. Stufenliste ergab keinen Sinn Für einen 720p-Inhalt standen Original, Full HD und HD mit derselben geschätzten Größe untereinander — das sah aus, als wäre die Schätzung kaputt. Sie war es nicht, die Stufen waren es: hochrechnen bringt nur eine größere Datei. Angeboten wird jetzt nur noch, was unter der Auflösung der Datei liegt, und beim Original steht dabei, was es ist: „Original · 720p · 873 MB". Kopfleiste Kein Hintergrund mehr statt eines weichgezeichneten — die Knöpfe bringen ihre eigene Glasfläche mit. Der Titel ist weg: der Name der App gehört auf den Homescreen, nicht über jeden Bildschirm. 145 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
7.3 KiB
Swift
199 lines
7.3 KiB
Swift
import Foundation
|
|
import PlexKit
|
|
import Testing
|
|
@testable import FlixyDownloads
|
|
|
|
/// Baut einen Eintrag im gewünschten Zustand, ohne Datenbank.
|
|
@MainActor
|
|
private func item(
|
|
_ key: String,
|
|
state: DownloadState = .completed,
|
|
watchedAgo: Duration? = nil,
|
|
series: String? = nil
|
|
) -> DownloadItem {
|
|
let item = DownloadItem(
|
|
ratingKey: key,
|
|
title: "Titel \(key)",
|
|
sourceURLString: "http://server/\(key)",
|
|
state: state,
|
|
seriesKey: series
|
|
)
|
|
if let watchedAgo {
|
|
item.watchedAt = Date().addingTimeInterval(-watchedAgo.seconds)
|
|
}
|
|
return item
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("Download-Regeln")
|
|
struct DownloadRulesTests {
|
|
|
|
// MARK: - Automatisches Löschen
|
|
|
|
@Test("Ohne Regel wird nichts gelöscht")
|
|
func disabledRulesDeleteNothing() {
|
|
// Die wichtigste Zusicherung im ganzen Regelwerk: wer nichts
|
|
// eingestellt hat, verliert nichts.
|
|
let items = [item("1", watchedAgo: .afterOneWeek)]
|
|
#expect(DownloadRules.disabled.itemsToDelete(from: items).isEmpty)
|
|
}
|
|
|
|
@Test("Ungesehene Titel werden nie gelöscht")
|
|
func unwatchedIsNeverDeleted() {
|
|
let rules = DownloadRules(deleteWatchedAfter: .immediately)
|
|
let items = [item("1"), item("2"), item("3")]
|
|
#expect(rules.itemsToDelete(from: items).isEmpty)
|
|
}
|
|
|
|
@Test("Nach Ablauf der Frist wird gelöscht")
|
|
func deletesAfterGracePeriod() {
|
|
let rules = DownloadRules(deleteWatchedAfter: .afterOneDay)
|
|
let items = [
|
|
item("alt", watchedAgo: .afterOneWeek),
|
|
item("frisch", watchedAgo: .seconds(60)),
|
|
]
|
|
|
|
let doomed = rules.itemsToDelete(from: items).map(\.ratingKey)
|
|
#expect(doomed == ["alt"])
|
|
}
|
|
|
|
@Test("Die Frist schützt frisch Gesehenes")
|
|
func gracePeriodProtectsRecent() {
|
|
// Wer eine Folge zu Ende sieht und gleich noch einmal die letzte
|
|
// Szene sucht, soll sie nicht schon verloren haben.
|
|
let rules = DownloadRules(deleteWatchedAfter: .afterOneDay)
|
|
let items = [item("1", watchedAgo: .seconds(3_600))]
|
|
#expect(rules.itemsToDelete(from: items).isEmpty)
|
|
}
|
|
|
|
@Test("Unfertige Downloads werden nicht gelöscht")
|
|
func incompleteDownloadsSurvive() {
|
|
// Ein laufender Download hat nichts, was gesehen worden sein könnte —
|
|
// ein alter watchedAt-Wert aus einem früheren Durchlauf darf ihn
|
|
// nicht abräumen.
|
|
let rules = DownloadRules(deleteWatchedAfter: .immediately)
|
|
let running = item("1", state: .downloading, watchedAgo: .afterOneWeek)
|
|
#expect(rules.itemsToDelete(from: [running]).isEmpty)
|
|
}
|
|
|
|
// MARK: - Vorrat an Folgen
|
|
|
|
@Test("Ohne Regel wird nichts nachgeladen")
|
|
func noRefillWithoutRule() {
|
|
#expect(DownloadRules.disabled.seriesNeedingRefill(in: [item("1", series: "s")]).isEmpty)
|
|
}
|
|
|
|
@Test("Ein leerer Vorrat verlangt die volle Zahl")
|
|
func emptyStockNeedsFullCount() {
|
|
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
|
|
#expect(rules.missingEpisodeCount(forSeries: "s", in: []) == 3)
|
|
}
|
|
|
|
@Test("Gesehene Folgen zählen nicht zum Vorrat")
|
|
func watchedEpisodesDoNotCount() {
|
|
// Sonst bliebe der Vorrat bei drei gesehenen Folgen stehen und es käme
|
|
// nie etwas Neues nach — genau das Gegenteil des Gewünschten.
|
|
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
|
|
let items = [
|
|
item("1", watchedAgo: .seconds(10), series: "s"),
|
|
item("2", watchedAgo: .seconds(10), series: "s"),
|
|
item("3", series: "s"),
|
|
]
|
|
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 2)
|
|
}
|
|
|
|
@Test("Wartende Downloads zählen zum Vorrat")
|
|
func queuedEpisodesCount() {
|
|
// Sonst würde bei jedem Durchlauf erneut nachgelegt, bis die ganze
|
|
// Serie auf dem Gerät liegt.
|
|
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
|
|
let items = [
|
|
item("1", state: .completed, series: "s"),
|
|
item("2", state: .queued, series: "s"),
|
|
item("3", state: .downloading, series: "s"),
|
|
]
|
|
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 0)
|
|
}
|
|
|
|
@Test("Gescheiterte Downloads zählen nicht zum Vorrat")
|
|
func failedEpisodesDoNotCount() {
|
|
let rules = DownloadRules(keepUnwatchedEpisodes: 2)
|
|
let items = [
|
|
item("1", state: .completed, series: "s"),
|
|
item("2", state: .failed, series: "s"),
|
|
]
|
|
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 1)
|
|
}
|
|
|
|
@Test("Ein voller Vorrat verlangt nichts")
|
|
func fullStockNeedsNothing() {
|
|
let rules = DownloadRules(keepUnwatchedEpisodes: 2)
|
|
let items = [item("1", series: "s"), item("2", series: "s"), item("3", series: "s")]
|
|
#expect(rules.missingEpisodeCount(forSeries: "s", in: items) == 0)
|
|
}
|
|
|
|
@Test("Serien werden getrennt gezählt")
|
|
func seriesAreCountedSeparately() {
|
|
let rules = DownloadRules(keepUnwatchedEpisodes: 2)
|
|
let items = [
|
|
item("a1", series: "a"),
|
|
item("a2", series: "a"),
|
|
item("b1", series: "b"),
|
|
]
|
|
|
|
let refill = rules.seriesNeedingRefill(in: items)
|
|
#expect(refill["a"] == nil)
|
|
#expect(refill["b"] == 1)
|
|
}
|
|
|
|
@Test("Filme ohne Serie lösen kein Nachladen aus")
|
|
func moviesDoNotTriggerRefill() {
|
|
let rules = DownloadRules(keepUnwatchedEpisodes: 3)
|
|
#expect(rules.seriesNeedingRefill(in: [item("1"), item("2")]).isEmpty)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite("Plausibilität fertiger Downloads")
|
|
struct DownloadPlausibilityTests {
|
|
|
|
@Test("Eine Fehlerseite geht nicht als Film durch")
|
|
func tinyFileIsRejected() {
|
|
// Der Fall aus der Praxis: 873 MB angekündigt, in einer Sekunde
|
|
// „fertig". Angekommen war eine HTML-Fehlerseite, und weil die
|
|
// empfangene Größe auch als erwartete notiert wurde, stand die
|
|
// Anzeige auf hundert Prozent.
|
|
#expect(DownloadCoordinator.implausibility(written: 2_048, expected: 873_000_000) != nil)
|
|
}
|
|
|
|
@Test("Auch ohne angekündigte Größe fällt eine Fehlerseite auf")
|
|
func tinyFileWithoutExpectation() {
|
|
// Beim umgerechneten Download kennt der Server die Endgröße vorab
|
|
// nicht — die Untergrenze muss deshalb allein tragen.
|
|
#expect(DownloadCoordinator.implausibility(written: 900, expected: 0) != nil)
|
|
}
|
|
|
|
@Test("Eine abgebrochene Übertragung fällt auf")
|
|
func truncatedTransferIsRejected() {
|
|
#expect(DownloadCoordinator.implausibility(written: 200_000_000, expected: 873_000_000) != nil)
|
|
}
|
|
|
|
@Test("Eine vollständige Datei geht durch")
|
|
func completeFilePasses() {
|
|
#expect(DownloadCoordinator.implausibility(written: 873_000_000, expected: 873_000_000) == nil)
|
|
}
|
|
|
|
@Test("Kleinere Abweichungen sind erlaubt")
|
|
func smallDeviationPasses() {
|
|
// Der Transcoder arbeitet mit variabler Bitrate und trifft die
|
|
// geschätzte Größe nie genau. Zu streng zu prüfen hieße, gültige
|
|
// Downloads zu verwerfen.
|
|
#expect(DownloadCoordinator.implausibility(written: 800_000_000, expected: 873_000_000) == nil)
|
|
}
|
|
|
|
@Test("Ohne Erwartung geht eine normale Datei durch")
|
|
func noExpectationPasses() {
|
|
#expect(DownloadCoordinator.implausibility(written: 500_000_000, expected: 0) == nil)
|
|
}
|
|
}
|