Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,280 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Testing
|
||||
@testable import HaloDownloads
|
||||
|
||||
/// 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: "halo.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: "halo-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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Testing
|
||||
@testable import HaloDownloads
|
||||
|
||||
@Suite("Downloadrate und Restzeit")
|
||||
struct DownloadRateTests {
|
||||
/// Ein fester Anfangszeitpunkt, von dem aus gerechnet wird.
|
||||
///
|
||||
/// Die Uhr kommt von außen herein — deshalb braucht hier nichts zu warten,
|
||||
/// und eine Übertragung über eine Viertelstunde ist in Mikrosekunden
|
||||
/// nachgestellt.
|
||||
private let start = ContinuousClock.now
|
||||
|
||||
@Test("Vor dem ersten Abstand gibt es keine Zahl")
|
||||
func noRateFromASinglePoint() {
|
||||
var rate = DownloadRate()
|
||||
rate.record(bytes: 0, at: start)
|
||||
// Null wäre eine Auskunft („es geht nichts durch"), und die stimmt
|
||||
// nicht — es ist nur noch nichts gemessen.
|
||||
#expect(rate.bytesPerSecond == nil)
|
||||
}
|
||||
|
||||
@Test("Gleichmäßiger Zufluss ergibt eine ruhige Zahl")
|
||||
func steadyFlowIsSteady() {
|
||||
var rate = DownloadRate()
|
||||
for sekunde in 0...5 {
|
||||
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
|
||||
}
|
||||
// Auf das Byte genau: die Glättung zieht einen Messwert nur dann in
|
||||
// eine Richtung, wenn er vom bisherigen abweicht.
|
||||
#expect(rate.bytesPerSecond == 1_000_000)
|
||||
}
|
||||
|
||||
@Test("Ein Ausreißer verschiebt die Zahl, aber reißt sie nicht mit")
|
||||
func outlierIsDamped() {
|
||||
var rate = DownloadRate()
|
||||
for sekunde in 0...5 {
|
||||
rate.record(bytes: Int64(sekunde) * 1_000_000, at: start.advanced(by: .seconds(sekunde)))
|
||||
}
|
||||
// Eine einzelne Sekunde mit dem Zehnfachen.
|
||||
rate.record(bytes: 15_000_000, at: start.advanced(by: .seconds(6)))
|
||||
|
||||
let gemessen = try! #require(rate.bytesPerSecond)
|
||||
// Roh wären es zehn Millionen. Geglättet liegt die Zahl dazwischen —
|
||||
// sie folgt, statt zu springen.
|
||||
#expect(gemessen > 1_000_000)
|
||||
#expect(gemessen < 10_000_000)
|
||||
}
|
||||
|
||||
@Test("Messpunkte dichter als eine halbe Sekunde zählen nicht")
|
||||
func samplesTooCloseAreIgnored() {
|
||||
var rate = DownloadRate()
|
||||
rate.record(bytes: 0, at: start)
|
||||
rate.record(bytes: 50_000, at: start.advanced(by: .milliseconds(100)))
|
||||
rate.record(bytes: 100_000, at: start.advanced(by: .milliseconds(200)))
|
||||
#expect(rate.bytesPerSecond == nil)
|
||||
|
||||
// Und der Bezugspunkt ist dabei nicht weitergerückt: gemessen wird
|
||||
// über die volle Sekunde seit dem ersten Punkt, nicht über die letzten
|
||||
// achthundert Millisekunden.
|
||||
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||
#expect(rate.bytesPerSecond == 1_000_000)
|
||||
}
|
||||
|
||||
@Test("Eine lange Stille verfälscht die Rate nicht")
|
||||
func longGapResetsTheMeasurement() {
|
||||
var rate = DownloadRate()
|
||||
rate.record(bytes: 0, at: start)
|
||||
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||
#expect(rate.bytesPerSecond == 1_000_000)
|
||||
|
||||
// Eine halbe Minute ohne Rückmeldung heißt nicht „langsam", sondern
|
||||
// „stand still": App angehalten, Gerät gesperrt, Netz weg. Daraus eine
|
||||
// Rate zu rechnen ergäbe dreißig Kilobyte je Sekunde und eine Restzeit
|
||||
// von Stunden.
|
||||
rate.record(bytes: 1_030_000, at: start.advanced(by: .seconds(31)))
|
||||
#expect(rate.bytesPerSecond == nil)
|
||||
}
|
||||
|
||||
@Test("Fängt die Übertragung von vorn an, gilt keine alte Zahl")
|
||||
func restartFromZeroDiscardsTheRate() {
|
||||
var rate = DownloadRate()
|
||||
rate.record(bytes: 0, at: start)
|
||||
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||
#expect(rate.bytesPerSecond == 1_000_000)
|
||||
|
||||
// Genau das passiert, wenn ein gescheiterter Download ohne
|
||||
// Wiederaufnahme-Datensatz neu beginnt.
|
||||
rate.record(bytes: 0, at: start.advanced(by: .seconds(2)))
|
||||
#expect(rate.bytesPerSecond == nil)
|
||||
}
|
||||
|
||||
@Test("Die Restzeit folgt aus Rate und Rest")
|
||||
func remainingFollowsFromRate() {
|
||||
var rate = DownloadRate()
|
||||
rate.record(bytes: 0, at: start)
|
||||
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||
|
||||
let rest = try! #require(rate.remaining(received: 1_000_000, total: 11_000_000))
|
||||
#expect(rest == .seconds(10))
|
||||
}
|
||||
|
||||
@Test("Ohne Rate keine Restzeit")
|
||||
func noRemainingWithoutRate() {
|
||||
var rate = DownloadRate()
|
||||
rate.record(bytes: 0, at: start)
|
||||
// Eine Restzeit ohne Messung wäre geraten — und geraten sieht man ihr
|
||||
// nicht an.
|
||||
#expect(rate.remaining(received: 0, total: 1_000_000) == nil)
|
||||
}
|
||||
|
||||
@Test("Ist alles da, gibt es nichts mehr zu schätzen")
|
||||
func noRemainingWhenComplete() {
|
||||
var rate = DownloadRate()
|
||||
rate.record(bytes: 0, at: start)
|
||||
rate.record(bytes: 1_000_000, at: start.advanced(by: .seconds(1)))
|
||||
#expect(rate.remaining(received: 1_000_000, total: 1_000_000) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
import Testing
|
||||
@testable import HaloDownloads
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Testing
|
||||
@testable import HaloDownloads
|
||||
|
||||
@Suite("Download-Zustandsautomat")
|
||||
struct DownloadStateTests {
|
||||
|
||||
@Test("Ein abgeschlossener Download bleibt abgeschlossen")
|
||||
func completedIsTerminal() {
|
||||
// Der wichtigste Übergang, den es NICHT geben darf: wäre er erlaubt,
|
||||
// könnte ein verspäteter Rückruf aus der Hintergrundsitzung eine
|
||||
// fertige Datei wieder in die Warteschlange schieben — und sie damit
|
||||
// ein zweites Mal herunterladen.
|
||||
for next in DownloadState.allCases {
|
||||
#expect(!DownloadState.completed.canTransition(to: next))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Gescheiterte Downloads dürfen zurück in die Warteschlange")
|
||||
func failedCanBeRetried() {
|
||||
#expect(DownloadState.failed.canTransition(to: .queued))
|
||||
}
|
||||
|
||||
@Test("Gescheiterte Downloads springen nicht direkt ins Übertragen")
|
||||
func failedCannotSkipTheQueue() {
|
||||
// Sonst umgeht der Wiederholen-Knopf die Prüfungen auf WLAN und
|
||||
// Speicherplatz, die beim Verlassen der Warteschlange stattfinden.
|
||||
#expect(!DownloadState.failed.canTransition(to: .downloading))
|
||||
}
|
||||
|
||||
@Test("Angehalten und laufend zählen beim Speicherplatz mit")
|
||||
func partialTransfersOccupyStorage() {
|
||||
// Beide haben bereits Daten auf der Platte. Zählte man sie nicht mit,
|
||||
// liefe das Gerät trotz gesetzter Grenze voll.
|
||||
#expect(DownloadState.downloading.occupiesStorage)
|
||||
#expect(DownloadState.paused.occupiesStorage)
|
||||
#expect(DownloadState.completed.occupiesStorage)
|
||||
#expect(!DownloadState.queued.occupiesStorage)
|
||||
#expect(!DownloadState.waiting.occupiesStorage)
|
||||
#expect(!DownloadState.failed.occupiesStorage)
|
||||
}
|
||||
|
||||
@Test("Nur wartende Zustände dürfen mit dem Übertragen beginnen")
|
||||
func onlyWaitingStatesAreTransferable() {
|
||||
#expect(DownloadState.queued.isTransferable)
|
||||
#expect(DownloadState.waiting.isTransferable)
|
||||
#expect(!DownloadState.downloading.isTransferable)
|
||||
#expect(!DownloadState.paused.isTransferable)
|
||||
#expect(!DownloadState.completed.isTransferable)
|
||||
}
|
||||
|
||||
@Test("Kein Zustand geht in sich selbst über")
|
||||
func noSelfTransitions() {
|
||||
// Ein Übergang auf sich selbst wäre ein stilles Nichts und würde
|
||||
// Zählerstände wie „Versuche" doppelt hochzählen.
|
||||
for state in DownloadState.allCases {
|
||||
#expect(!state.canTransition(to: state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Jeder Zustand außer dem abgeschlossenen hat einen Ausgang")
|
||||
func everyStateCanProgress() {
|
||||
for state in DownloadState.allCases where state != .completed {
|
||||
let hasExit = DownloadState.allCases.contains { state.canTransition(to: $0) }
|
||||
#expect(hasExit, "\(state.rawValue) ist eine Sackgasse")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Die gespeicherten Namen bleiben stabil")
|
||||
func rawValuesAreStable() {
|
||||
// Sie landen in SwiftData. Ein umbenannter Fall würde beim nächsten
|
||||
// Start alle laufenden Downloads unlesbar machen.
|
||||
#expect(DownloadState.queued.rawValue == "queued")
|
||||
#expect(DownloadState.downloading.rawValue == "downloading")
|
||||
#expect(DownloadState.paused.rawValue == "paused")
|
||||
#expect(DownloadState.waiting.rawValue == "waiting")
|
||||
#expect(DownloadState.completed.rawValue == "completed")
|
||||
#expect(DownloadState.failed.rawValue == "failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Testing
|
||||
@testable import HaloDownloads
|
||||
|
||||
/// Warum gerade nichts übertragen wird.
|
||||
@Suite("Übertragungssperre")
|
||||
struct TransferHoldTests {
|
||||
|
||||
@Test("Mobilfunk hält auf, solange nur WLAN erlaubt ist")
|
||||
func cellularHolds() {
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: true, isReachable: true, isExpensive: true)
|
||||
== .waitingForWiFi
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Im WLAN läuft es")
|
||||
func wifiRuns() {
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: true, isReachable: true, isExpensive: false)
|
||||
== .none
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Ohne den WLAN-Zwang hält Mobilfunk nicht auf")
|
||||
func cellularAllowed() {
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: false, isReachable: true, isExpensive: true)
|
||||
== .none
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Ohne Verbindung hilft auch ein ausgeschalteter Zwang nichts")
|
||||
func offlineWins() {
|
||||
// Sonst hieße es „wartet auf WLAN", während schlicht gar nichts geht —
|
||||
// und der Betrachter sucht den Fehler an der falschen Stelle.
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: false, isReachable: false, isExpensive: false)
|
||||
== .offline
|
||||
)
|
||||
#expect(
|
||||
TransferHold.current(isWiFiOnly: true, isReachable: false, isExpensive: true)
|
||||
== .offline
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user