Files
Flaro/Packages/HaloKit/Tests/HaloDownloadsTests/DownloadRulesTests.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

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