Dein Bericht: bei rund 3 GB abgebrochen, beim Fortsetzen wieder bei null,
und später „plötzlich bei 60 Prozent fertig". Beide Hälften standen so im
Code.
**Fertig war sie nicht, sie war halb.**
if expected > 0, Double(written) < Double(expected) * 0.5 { verwerfen }
Alles über der Hälfte galt als vollständig. Diese Regel war nie für diesen
Fall gedacht, sondern für die **hochgerechnete** Größe der Transcoder-Stufen
(Bitrate × Dauer) — dort ist Abweichung normal. Beim Original nennt der
Server die Dateigröße in den Metadaten, und da ist jedes fehlende Byte ein
Abriss.
Das unterscheidet jetzt `expectedIsExact`, gesetzt beim Planen und
mitgeführt am Eintrag. Dazu merkt sich der Koordinator, für welche Titel der
Server eine `Content-Length` geschickt hat — auch das ist eine Zusage.
**„Fortsetzen" war eine Zusage, die der Code nicht halten konnte.**
`NSURLSessionDownloadTaskResumeData` kam im ganzen Projekt nicht vor; den
Wiederaufnahme-Datensatz gab es nur beim Pausieren von Hand. Nach einem
Netzfehler war er weg, und der Knopf daneben hieß trotzdem „Fortsetzen".
Jetzt wird er aus dem Fehler gerettet, und wo es ihn nicht gibt, heißt der
Knopf „Erneut versuchen".
**Und es merkte niemand, wenn das WLAN zurückkam.** `RootView` reicht das
jetzt weiter — nur für Einträge mit Wiederaufnahme-Datensatz, damit ein 404
sich nicht im Kreis versucht.
**Rate und Restzeit** stehen in der Zeile: „12 MB / 20 MB · 3,4 MB/s ·
gleich fertig". `DownloadRate` ist ein reiner Wertetyp mit Uhr von außen —
geglättet, weil rohe Differenzen um ein Vielfaches springen, und ohne Zahl,
solange nichts gemessen ist. Neun Tests.
**Zwei Dinge, die erst die Messung gezeigt hat** — und die beide nicht in
der App liegen:
Erstens: reißt eine Übertragung ab, **die eine `Content-Length` angekündigt
hatte**, wiederholt die Hintergrundsitzung von iOS sie selbst — stumm, ohne
den Delegaten zu fragen, und **jedes Mal ab Byte null**. Gemessen: sieben
Anläufe, jeder die vollen 12 MB. Das ist genau dein „fing wieder bei null
an", und dagegen hilft kein Wiederaufnahme-Datensatz.
Zweitens: ein Fortsetzen braucht eine Prüfmarke vom Server. Ohne `ETag`
oder `Last-Modified` kann iOS sein `If-Range` nicht stellen und lädt alles
noch einmal — gemessen, 20 MB zweimal. Mit Marke kam die zweite Anfrage als
`Range: bytes=5636096-` und holte nur den Rest.
Die Attrappe kann das jetzt alles nachstellen: eine beliebig große Datei,
die auf Wunsch mittendrin abreißt, mit oder ohne angekündigte Länge, und
gedrosselt. Ohne sie ist nichts davon beweisbar — und genau daran ist der
Fehler vorbeigekommen.
Im Simulator gegen die Attrappe: 12 von 20 MB → „Fehlgeschlagen:
Übertragung unvollständig", nicht Haken. Vollständig → Haken. iOS und tvOS
bauen, 489 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
281 lines
11 KiB
Swift
281 lines
11 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("Ob die Größe genau ist, kommt am Eintrag an")
|
|
func carriesExactSizeFlag() throws {
|
|
let coordinator = try makeCoordinator(root: temporaryRoot())
|
|
|
|
var genau = plan("a", bytes: 20_000_000)
|
|
genau.expectedIsExact = true
|
|
let eins = try coordinator.enqueue(genau)
|
|
#expect(eins.expectedIsExact)
|
|
|
|
// Die Voreinstellung ist „geschätzt" — die umgerechneten Stufen
|
|
// dürfen abweichen, und wer das Feld vergisst, bekommt die mildere
|
|
// Regel statt verworfener Downloads.
|
|
let zwei = try coordinator.enqueue(plan("b", bytes: 20_000_000))
|
|
#expect(!zwei.expectedIsExact)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|