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>
129 lines
4.7 KiB
Swift
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)
|
|
}
|
|
}
|