import Foundation import MediaKit import Testing @testable import FlaroDownloads /// 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, announced: true ) != 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, announced: false ) != nil) } @Test("Eine abgebrochene Übertragung fällt auf") func truncatedTransferIsRejected() { #expect(DownloadCoordinator.implausibility( written: 200_000_000, expected: 873_000_000, announced: false ) != nil) } @Test("Bei sechzig Prozent einer angekündigten Größe ist die Datei kaputt") func truncatedAtSixtyPercentIsRejected() { // **Der Fehler, der diese Unterscheidung nötig gemacht hat.** Hier // galt dieselbe Hälfte-Regel wie für geschätzte Größen, und eine bei // sechzig Prozent abgerissene Datei kam durch: Haken dran, und beim // Abspielen brach sie mittendrin ab. Gemeldet wurde das als // „plötzlich war er fertig". #expect(DownloadCoordinator.implausibility( written: 1_800_000_000, expected: 3_000_000_000, announced: true ) != nil) } @Test("Dieselben sechzig Prozent einer Schätzung gehen durch") func truncatedAtSixtyPercentOfEstimatePasses() { // Gleiche Zahlen, andere Herkunft: hochgerechnet aus Bitrate und // Dauer. Dort ist eine Abweichung nach unten normal, und streng zu // prüfen hieße, gültige Downloads zu verwerfen. #expect(DownloadCoordinator.implausibility( written: 1_800_000_000, expected: 3_000_000_000, announced: false ) == nil) } @Test("Eine vollständige Datei geht durch") func completeFilePasses() { #expect(DownloadCoordinator.implausibility( written: 873_000_000, expected: 873_000_000, announced: true ) == nil) } @Test("Kleinere Abweichungen sind erlaubt, solange die Größe geschätzt ist") func smallDeviationPasses() { // Der Transcoder arbeitet mit variabler Bitrate und trifft die // geschätzte Größe nie genau. #expect(DownloadCoordinator.implausibility( written: 800_000_000, expected: 873_000_000, announced: false ) == nil) } @Test("Dieselbe Abweichung bei angekündigter Größe ist ein Abriss") func smallDeviationOfAnnouncedSizeIsRejected() { // Hat der Server die Größe genannt, gibt es keine erklärbare // Abweichung: was fehlt, fehlt. #expect(DownloadCoordinator.implausibility( written: 800_000_000, expected: 873_000_000, announced: true ) != nil) } @Test("Ohne Erwartung geht eine normale Datei durch") func noExpectationPasses() { #expect(DownloadCoordinator.implausibility( written: 500_000_000, expected: 0, announced: false ) == nil) } }