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>
281 lines
11 KiB
Swift
281 lines
11 KiB
Swift
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)
|
|
}
|
|
}
|