Files
Flaro/Packages/HaloKit/Tests/HaloPlaybackTests/DowngradeRuleTests.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

129 lines
4.7 KiB
Swift

import Testing
import Foundation
@testable import HaloPlayback
@Suite("Wann der teure Weg erlaubt ist")
struct DowngradeRuleTests {
/// Der Normalfall, in dem abgesenkt werden soll: keine Leiter da, es
/// hakt dauerhaft, und es läuft lange genug.
private func notlage(
isAutomatic: Bool = true,
hasLadder: Bool = false,
isAtLowestRung: Bool = false,
condition: StreamCondition = .failing,
stalls: Int = 3,
longest: Duration = .seconds(4),
playedFor: Duration = .seconds(120),
sinceLastSeek: Duration? = nil,
sinceLastDowngrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) -> DowngradeRule.Facts {
.init(
isAutomatic: isAutomatic,
hasLadder: hasLadder,
isAtLowestRung: isAtLowestRung,
condition: condition,
stallsInWindow: stalls,
longestStallInWindow: longest,
playedFor: playedFor,
sinceLastSeek: sinceLastSeek,
sinceLastDowngrade: sinceLastDowngrade,
used: used,
budget: budget
)
}
@Test("Ohne Leiter und mit drei Rücklern: ja")
func downgradesWhenNoLadder() {
#expect(DowngradeRule.verdict(notlage()) == .downgrade)
}
/// Eine ausdrücklich gewählte Stufe ist eine Ansage. `automatic` ist die
/// Erlaubnis einzugreifen, alles andere nicht.
@Test("Eine gewählte Stufe wird nicht überstimmt")
func neverOverridesAChoice() {
#expect(DowngradeRule.verdict(notlage(isAutomatic: false)) == .notAutomatic)
}
/// Solange es noch eine Sprosse tiefer gibt, kostet der billige Hebel
/// kein schwarzes Bild.
@Test("Solange die Leiter trägt, bleibt es beim billigen Hebel")
func prefersTheCheapLever() {
let facts = notlage(hasLadder: true, isAtLowestRung: false)
#expect(DowngradeRule.verdict(facts) == .cheaperPathRemains)
}
@Test("Auf der untersten Sprosse ist der billige Hebel erschöpft")
func lowestRungAllowsTheExpensivePath() {
let facts = notlage(hasLadder: true, isAtLowestRung: true)
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
@Test("Zwei Rückler genügen nicht")
func twoStallsAreNotEnough() {
#expect(DowngradeRule.verdict(notlage(stalls: 2)) == .notBadEnough)
}
/// Fünfzehn Sekunden Stillstand sind keine Schwankung. Darauf zu warten,
/// bis drei zusammenkommen, hieße viereinhalb Minuten zusehen.
@Test("Ein einzelner langer Rückler genügt allein")
func oneLongStallIsEnough() {
let facts = notlage(stalls: 1, longest: .seconds(20))
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
@Test("Ein gesunder Stream löst nichts aus")
func healthyNeverTriggers() {
#expect(DowngradeRule.verdict(notlage(condition: .healthy)) == .notBadEnough)
}
/// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist
/// keine.
@Test("In den ersten Sekunden passiert nichts")
func waitsAfterStart() {
#expect(DowngradeRule.verdict(notlage(playedFor: .seconds(5))) == .tooEarly)
}
/// Ein Sprung leert den Puffer immer. Sich dafür zu bestrafen wäre
/// absurd.
@Test("Kurz nach einem Sprung passiert nichts")
func waitsAfterSeek() {
let facts = notlage(sinceLastSeek: .seconds(3))
#expect(DowngradeRule.verdict(facts) == .tooEarly)
}
@Test("Ein länger zurückliegender Sprung stört nicht")
func oldSeekIsFine() {
#expect(DowngradeRule.verdict(notlage(sinceLastSeek: .seconds(30))) == .downgrade)
}
@Test("Innerhalb der Sperrfrist nicht noch einmal")
func respectsCooldown() {
let facts = notlage(sinceLastDowngrade: .seconds(60), used: 1)
#expect(DowngradeRule.verdict(facts) == .cooling)
}
@Test("Nach der Sperrfrist wieder")
func afterCooldownAgain() {
let facts = notlage(sinceLastDowngrade: .seconds(200), used: 1)
#expect(DowngradeRule.verdict(facts) == .downgrade)
}
/// Lautlos die ganze Leiter bis 240p hinunterzugehen ist schlimmer, als
/// es zu sagen.
@Test("Das Budget ist endlich")
func budgetIsFinite() {
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 2, budget: 2)
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
}
/// Bei Live-TV nur einmal: jeder Neuaufbau stimmt den Tuner neu ab, und
/// davon gibt es vier Stück.
@Test("Bei Live-TV ist nach einem Mal Schluss")
func liveBudgetIsOne() {
let facts = notlage(sinceLastDowngrade: .seconds(300), used: 1, budget: 1)
#expect(DowngradeRule.verdict(facts) == .budgetSpent)
}
}