Phase 4 und Teile von Phase 5
Download-Regeln - Automatisches Löschen nach dem Ansehen, mit Frist statt sofort: wer die letzte Szene noch einmal sucht, soll sie nicht schon verloren haben. „Nie" ist die Voreinstellung. - „Halte N ungesehene Folgen offline": wartende und laufende Downloads zählen mit, sonst würde bei jedem Durchlauf nachgelegt, bis die ganze Serie auf dem Gerät liegt. - Beides als reine Rechnung ohne Zugriff auf Datenbank oder Dateien — bei einer Regel, die Filme löscht, ist Prüfbarkeit keine Formsache. 13 Tests. Qualitätswahl beim Download - transcodedDownloadURL über den Universal-Transcoder mit protocol=http statt über die umständliche Sync-API. NOCH NICHT AM SERVER GEPRÜFT: offen ist, ob Plex eine Content-Length mitschickt (sonst kein Fortschritt und kein Fortsetzen) und ob die Datei durchsuchbar ist. Steht so im Quelltext; Original bleibt Voreinstellung. Erscheinungsbild - Akzentfarbe: zehn kuratierte Kacheln plus freie Hex-Eingabe. Bei einer eigenen Farbe greift die Kontrastkorrektur und sagt es auch — sonst wirkt sie wie ein Fehler. - Vorschaufläche mit Knopf, Symbol und Fortschrittsbalken. Serveraktionen - Gesehen-Status umschalten und Sterne setzen über ein Menü auf der Detailseite. - Suchlauf der Mediathek aus der Werkzeugleiste. 115 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user