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>
241 lines
9.0 KiB
Swift
241 lines
9.0 KiB
Swift
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)
|
|
}
|
|
}
|