Files
Flaro/Packages/FlixyKit/Tests/FlixyDownloadsTests/DownloadCoordinatorTests.swift
T
GuidoandClaude Opus 5 62158303a2 Phase 3: Download-Engine und Offline-Wiedergabe
Engine
- Hintergrund-URLSession: läuft weiter, wenn die App zu ist. Der ganze
  Zustand liegt in SwiftData, weil es nach einem Neustart kein anderes
  Gedächtnis gibt.
- Pausieren über cancel(byProducingResumeData:) — nur so lässt sich
  weiterladen statt neu zu beginnen. Bei 40 GB der Unterschied
  zwischen brauchbar und unbrauchbar.
- Zwei Übertragungen gleichzeitig, Folgen in Staffel-/Folgenreihenfolge.
  Alles parallel zu laden hieße: keine Folge wird fertig.
- Speicherlimit und Geräteplatz werden vor dem Annehmen geprüft, ein
  Gigabyte bleibt als Puffer frei.
- Verwaiste Dateien werden beim Start entfernt; sie entstehen, wenn die
  App genau beim Abschluss beendet wird.
- Downloads liegen in Application Support, nicht in Caches (iOS räumt
  den eigenmächtig leer) und sind von der iCloud-Sicherung ausgenommen.

Oberfläche
- Download-Knopf auf Detailseiten mit vier Zuständen in einem Knopf.
- „Alle Folgen laden" für Staffel und Serie; ein abgelehnter Titel
  stoppt den Stapel nicht.
- Downloads-Ansicht mit Belegung, WLAN-Schalter, Pausieren, Fortsetzen,
  Löschen — nach Serie gruppiert.

Offline
- Der Player bevorzugt die lokale Datei, auch ohne erreichbaren Server.
- Beim Herunterladen wird ein Abzug der Serverangaben mitgesichert:
  offline lässt sich nichts nachfragen, und der PlaybackDecider braucht
  Container und Codecs.

Ein Fehler, den die Tests aufgedeckt haben: der Koordinator hielt nur
den ModelContext, nicht den ModelContainer. Ein ModelContext hält seinen
Container nicht am Leben — wird der anderswo freigegeben, stürzt jeder
Zugriff auf einen Eintrag ab, und die Meldung zeigt auf ModelContext.reset
statt auf die Ursache. Jetzt liegt die Zuständigkeit beim Koordinator.

102 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:34:57 +02:00

265 lines
10 KiB
Swift

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