import Foundation import SwiftData import Testing @testable import FlixyDownloads /// Baut einen Koordinator, der nichts überträgt. /// /// `maximumConcurrentDownloads = 0` hält die Warteschlange an: geprüft wird /// die Buchführung — Annehmen, Ablehnen, Reihenfolge —, nicht das Netz. @MainActor private func makeCoordinator( limitBytes: Int64? = nil, root: URL ) throws -> DownloadCoordinator { let container = try ModelContainer( for: DownloadItem.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true) ) let defaults = UserDefaults(suiteName: "flixy.tests.\(UUID().uuidString)")! let coordinator = DownloadCoordinator( container: container, fileStore: try DownloadFileStore(root: root), defaults: defaults, sessionConfiguration: .ephemeral ) coordinator.maximumConcurrentDownloads = 0 coordinator.storageLimitBytes = limitBytes return coordinator } private func plan( _ key: String, bytes: Int64 = 1_000, series: String? = nil, season: Int? = nil, episode: Int? = nil ) -> DownloadPlan { DownloadPlan( ratingKey: key, title: "Titel \(key)", sourceURL: URL(string: "http://server:32400/library/parts/\(key)/file.mkv")!, seriesKey: series, seasonIndex: season, episodeIndex: episode, expectedBytes: bytes ) } private func temporaryRoot() -> URL { FileManager.default.temporaryDirectory.appending(path: "flixy-tests-\(UUID().uuidString)") } @MainActor @Suite("Download-Warteschlange") struct DownloadCoordinatorTests { @Test("Ein Titel wird angenommen und steht in der Warteschlange") func acceptsFirstDownload() throws { let coordinator = try makeCoordinator(root: temporaryRoot()) let item = try coordinator.enqueue(plan("1")) #expect(item.state == .queued) #expect(coordinator.items.count == 1) } @Test("Derselbe Titel wird nicht zweimal angenommen") func rejectsDuplicates() throws { // Ohne diese Prüfung entstünde bei zweimaligem Tippen auf den // Download-Knopf eine zweite Übertragung derselben Datei — doppelte // Bandbreite, doppelter Platz. let coordinator = try makeCoordinator(root: temporaryRoot()) _ = try coordinator.enqueue(plan("1")) #expect(throws: DownloadRefusal.alreadyExists) { try coordinator.enqueue(plan("1")) } #expect(coordinator.items.count == 1) } @Test("Ein gescheiterter Download darf erneut in die Warteschlange") func failedDownloadsCanBeRetried() throws { let coordinator = try makeCoordinator(root: temporaryRoot()) let item = try coordinator.enqueue(plan("1")) item.state = .failed item.failureMessage = "Verbindung verloren" let retried = try coordinator.enqueue(plan("1")) #expect(retried.state == .queued) #expect(retried.failureMessage == nil) #expect(coordinator.items.count == 1) } @Test("Das Speicherlimit lehnt ab, bevor etwas geladen wird") func respectsStorageLimit() throws { let coordinator = try makeCoordinator(limitBytes: 5_000, root: temporaryRoot()) let first = try coordinator.enqueue(plan("1", bytes: 3_000)) // Fertige Downloads zählen zur Belegung; wartende noch nicht. first.state = .completed first.receivedBytes = 3_000 // Ohne die Belegung neu zu berechnen wüsste der Koordinator nichts // vom ersten Download — dieser Aufruf bildet den Weg nach, den auch // der Abschluss einer echten Übertragung nimmt. coordinator.refreshUsage() #expect(throws: DownloadRefusal.self) { try coordinator.enqueue(plan("2", bytes: 3_000)) } } @Test("Unterhalb des Limits wird weiter angenommen") func acceptsWithinLimit() throws { let coordinator = try makeCoordinator(limitBytes: 10_000, root: temporaryRoot()) _ = try coordinator.enqueue(plan("1", bytes: 3_000)) _ = try coordinator.enqueue(plan("2", bytes: 3_000)) #expect(coordinator.items.count == 2) } @Test("Ein Stapel nimmt an, was geht, und meldet den Rest") func batchAcceptsWhatItCan() throws { // Bei einer Staffel, von der eine Folge schon offline ist, darf nicht // der ganze Stapel scheitern. let coordinator = try makeCoordinator(root: temporaryRoot()) _ = try coordinator.enqueue(plan("2")) let result = coordinator.enqueueBatch([plan("1"), plan("2"), plan("3")]) #expect(result.accepted.count == 2) #expect(result.refused.count == 1) #expect(result.refused.first?.1 == .alreadyExists) } @Test("Entfernen löscht Eintrag und Belegung") func removeClearsEverything() throws { let coordinator = try makeCoordinator(root: temporaryRoot()) let item = try coordinator.enqueue(plan("1")) coordinator.remove(item) #expect(coordinator.items.isEmpty) #expect(coordinator.usedBytes == 0) } @Test("Folgen einer Serie werden in Reihenfolge abgearbeitet") func episodesKeepTheirOrder() throws { // Sonst wäre bei einer Staffel Folge 7 vor Folge 1 fertig — und // unterwegs nützt die siebte Folge wenig, wenn die erste fehlt. let coordinator = try makeCoordinator(root: temporaryRoot()) _ = try coordinator.enqueue(plan("c", series: "s1", season: 1, episode: 3)) _ = try coordinator.enqueue(plan("a", series: "s1", season: 1, episode: 1)) _ = try coordinator.enqueue(plan("b", series: "s1", season: 1, episode: 2)) let order = coordinator.transferOrder.map(\.ratingKey) #expect(order == ["a", "b", "c"]) } @Test("Staffeln werden vor Folgennummern sortiert") func seasonsSortBeforeEpisodes() throws { let coordinator = try makeCoordinator(root: temporaryRoot()) _ = try coordinator.enqueue(plan("s2e1", series: "s1", season: 2, episode: 1)) _ = try coordinator.enqueue(plan("s1e9", series: "s1", season: 1, episode: 9)) let order = coordinator.transferOrder.map(\.ratingKey) #expect(order == ["s1e9", "s2e1"]) } @Test("Als gesehen markieren vermerkt den Zeitpunkt") func markWatchedRecordsTime() throws { // Grundlage fürs automatische Löschen nach dem Ansehen. let coordinator = try makeCoordinator(root: temporaryRoot()) let item = try coordinator.enqueue(plan("1")) #expect(item.watchedAt == nil) coordinator.markWatched(ratingKey: "1") #expect(item.watchedAt != nil) } } @Suite("Download-Dateien") struct DownloadFileStoreTests { @Test("Der Dateiname stammt vom Schlüssel, nicht vom Titel") func fileNameUsesRatingKey() throws { // Titel enthalten Schrägstriche, Doppelpunkte und Umlaute — als // Dateiname wäre das eine Quelle schwer auffindbarer Fehler. let store = try DownloadFileStore(root: temporaryRoot()) let name = store.fileName( forRatingKey: "45231", sourceURL: URL(string: "http://server/parts/1/Blade Runner: 2049.mkv")! ) #expect(name == "45231.mkv") } @Test("Ohne Endung in der Adresse wird mp4 angenommen") func defaultsToMP4() throws { let store = try DownloadFileStore(root: temporaryRoot()) let name = store.fileName( forRatingKey: "1", sourceURL: URL(string: "http://server/library/parts/1/1699887654")! ) #expect(name == "1.mp4") } @Test("Verwaiste Dateien werden entfernt, bekannte bleiben") func removesOrphansOnly() throws { // Verwaiste Dateien entstehen, wenn die App genau beim Abschluss // beendet wird: die Datei liegt da, der Eintrag fehlt. let root = temporaryRoot() let store = try DownloadFileStore(root: root) for name in ["bekannt.mkv", "verwaist.mkv"] { try Data("x".utf8).write(to: store.url(forFileName: name)) } let removed = store.removeOrphans(keeping: ["bekannt.mkv"]) #expect(removed == 1) #expect(store.exists(fileName: "bekannt.mkv")) #expect(!store.exists(fileName: "verwaist.mkv")) } @Test("Die Gesamtgröße zählt alle Dateien") func totalSizeSumsFiles() throws { let root = temporaryRoot() let store = try DownloadFileStore(root: root) try Data(repeating: 0, count: 100).write(to: store.url(forFileName: "a.mkv")) try Data(repeating: 0, count: 250).write(to: store.url(forFileName: "b.mkv")) #expect(store.totalSize() == 350) } @Test("Eine fertige Datei wird verschoben, nicht kopiert") func adoptMovesTheFile() throws { // Kopieren hieße, denselben Platzbedarf ein zweites Mal — bei 40 GB // der Unterschied zwischen „geht" und „geht nicht". let root = temporaryRoot() let store = try DownloadFileStore(root: root) let temporary = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) try Data(repeating: 0, count: 42).write(to: temporary) let destination = try store.adopt(temporaryFile: temporary, as: "ziel.mkv") #expect(FileManager.default.fileExists(atPath: destination.path(percentEncoded: false))) #expect(!FileManager.default.fileExists(atPath: temporary.path(percentEncoded: false))) #expect(store.size(ofFileName: "ziel.mkv") == 42) } @Test("Eine bestehende Datei wird ersetzt") func adoptReplacesExisting() throws { // Beim Wiederholen eines Downloads liegt die halbe Datei schon da. let root = temporaryRoot() let store = try DownloadFileStore(root: root) try Data(repeating: 0, count: 10).write(to: store.url(forFileName: "ziel.mkv")) let temporary = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) try Data(repeating: 0, count: 99).write(to: temporary) _ = try store.adopt(temporaryFile: temporary, as: "ziel.mkv") #expect(store.size(ofFileName: "ziel.mkv") == 99) } }