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

144 lines
5.4 KiB
Swift

import Foundation
import Testing
@testable import HaloPlayback
@Suite("Wann hinaufgeschaltet wird")
struct UpgradeRuleTests {
/// Die Lage, in der es erlaubt ist — alle übrigen Tests weichen davon in
/// genau einem Punkt ab. So steht in jedem Test nur der Unterschied.
private func ruhig(
isAutomatic: Bool = true,
condition: StreamCondition = .healthy,
hasHigherRung: Bool = false,
ceilingAllowsMore: Bool = true,
bufferedAhead: Duration? = .seconds(30),
playedFor: Duration = .seconds(120),
sinceLastStall: Duration? = nil,
sinceLastSeek: Duration? = nil,
sinceLastUpgrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) -> UpgradeRule.Facts {
.init(
isAutomatic: isAutomatic,
condition: condition,
hasHigherRung: hasHigherRung,
ceilingAllowsMore: ceilingAllowsMore,
bufferedAhead: bufferedAhead,
playedFor: playedFor,
sinceLastStall: sinceLastStall,
sinceLastSeek: sinceLastSeek,
sinceLastUpgrade: sinceLastUpgrade,
used: used,
budget: budget
)
}
@Test("Läuft es ruhig und ist die Leiter ausgereizt, wird neu aufgebaut")
func calmAndCappedRebuilds() {
#expect(UpgradeRule.verdict(ruhig()) == .rebuildHigher)
}
// MARK: - Was es verbietet
/// Wer „720p · 3 Mbps" antippt, meint das — auch wenn die Leitung mehr
/// trüge.
@Test("Eine gewählte Stufe wird nicht überstimmt")
func neverOverridesAChoice() {
#expect(UpgradeRule.verdict(ruhig(isAutomatic: false)) == .notAutomatic)
}
/// **Der wichtigste Fall.** Solange die Leiter noch eine Sprosse hat,
/// kostet das Hinaufgehen kein schwarzes Bild — ein Neuaufbau dafür wäre
/// Verschwendung.
@Test("Solange die Leiter trägt, kein Neuaufbau")
func prefersTheCheapPath() {
#expect(UpgradeRule.verdict(ruhig(hasHigherRung: true)) == .cheaperPathRemains)
}
@Test("Über die eingestellte Grenze geht es nicht")
func respectsTheSetting() {
#expect(UpgradeRule.verdict(ruhig(ceilingAllowsMore: false)) == .atCeiling)
}
@Test("Bei angespannter Lage wird nicht hinaufgegangen")
func notWhenStrained() {
#expect(UpgradeRule.verdict(ruhig(condition: .strained)) == .notCalmEnough)
#expect(UpgradeRule.verdict(ruhig(condition: .failing)) == .notCalmEnough)
}
/// „Es läuft gerade" genügt nicht — es muss etwas auf der hohen Kante
/// liegen, sonst trägt es den Neuaufbau nicht.
@Test("Ohne Vorrat kein Neuaufbau")
func needsABuffer() {
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: .seconds(5))) == .notCalmEnough)
#expect(UpgradeRule.verdict(ruhig(bufferedAhead: nil)) == .notCalmEnough)
}
/// Der Puffer füllt sich nach dem Start immer, und das sieht eine halbe
/// Minute lang nach einer hervorragenden Leitung aus.
@Test("Am Anfang nicht, auch wenn alles gut aussieht")
func notRightAfterStart() {
#expect(UpgradeRule.verdict(ruhig(playedFor: .seconds(20))) == .tooEarly)
}
@Test("Nach einem Sprung nicht")
func notRightAfterSeek() {
#expect(UpgradeRule.verdict(ruhig(sinceLastSeek: .seconds(3))) == .tooEarly)
}
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau wäre
/// die zweite Unterbrechung binnen einer Minute.
@Test("Nach einem Rückler lange nicht")
func waitsAfterAStall() {
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(30))) == .tooEarly)
#expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(120))) == .rebuildHigher)
}
@Test("Die Sperrfrist gilt")
func respectsTheCooldown() {
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(60))) == .cooling)
#expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(200))) == .rebuildHigher)
}
@Test("Das Budget ist endlich")
func budgetIsFinite() {
#expect(UpgradeRule.verdict(ruhig(used: 2, budget: 2)) == .budgetSpent)
#expect(UpgradeRule.verdict(ruhig(used: 1, budget: 2)) == .rebuildHigher)
}
// MARK: - Die Reihenfolge der Gründe
/// Bei mehreren Gründen soll der **grundsätzlichste** gemeldet werden —
/// sonst steht im Protokoll „Sperrfrist", wo in Wahrheit die Einstellung
/// das Ende ist, und jemand wartet auf etwas, das nie kommt.
@Test("Der grundsätzlichste Grund gewinnt")
func reportsTheMostFundamentalReason() {
#expect(
UpgradeRule.verdict(ruhig(
isAutomatic: false, hasHigherRung: true, ceilingAllowsMore: false
)) == .notAutomatic
)
#expect(
UpgradeRule.verdict(ruhig(
hasHigherRung: true, ceilingAllowsMore: false
)) == .atCeiling
)
#expect(
UpgradeRule.verdict(ruhig(
condition: .failing, hasHigherRung: true
)) == .cheaperPathRemains
)
}
/// Das Hinaufschalten muss zurückhaltender sein als das Absenken: ein zu
/// spätes Absenken bedeutet Ruckeln, ein zu spätes Hinaufschalten ein
/// etwas weicheres Bild.
@Test("Zurückhaltender als das Absenken")
func moreCautiousThanDowngrading() {
#expect(UpgradeRule.settleIn > DowngradeRule.settleIn)
#expect(UpgradeRule.cooldown < DowngradeRule.cooldown)
}
}