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>
144 lines
5.4 KiB
Swift
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)
|
|
}
|
|
}
|