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