Die Sicherheitsleine — der teure Weg, mit engen Regeln
Wenn der billige Hebel nichts ausrichtet, weil die Playlist nur eine Stufe hat oder schon die unterste läuft, bleibt nur der Neuaufbau mit kleinerer Bitrate: Transcoder anhalten, Motor abreißen, neu aushandeln, zurückspringen. Sekunden mit schwarzem Bild. **Ein Notnagel, keine Regelschleife.** Zu oft ausgelöst erzeugt er genau das Ruckeln, das er beheben soll. `DowngradeRule` ist deshalb eine Liste von Bedingungen, die alle gelten müssen, und keine Schwelle: nur bei „automatisch", nur wenn der billige Weg erschöpft ist, nur ab drei Rücklern in anderthalb Minuten oder einem einzelnen über fünfzehn Sekunden, nicht in den ersten zehn Sekunden, nicht binnen zehn Sekunden nach einem Sprung, höchstens alle drei Minuten, höchstens zweimal. Ein Sprung leert den Puffer **immer**. Ihn als Beleg für eine schlechte Leitung zu nehmen hieße, sich für das eigene Tun zu bestrafen. Das Urteil ist eine Aufzählung und kein `Bool`: „es hakt, aber wir tun nichts" ist ohne Grund von einem Fehler nicht zu unterscheiden. Vierzehn Tests schreiben jede Bedingung einzeln hin. Ist das Budget verbraucht, **wird es gesagt** statt lautlos weiter abgesenkt. Still bis zur untersten Sprosse zu gehen ist schlimmer, als einmal zuzugeben, dass die Leitung es nicht trägt. **Live-TV ist dabei**, wie gewünscht — aber mit Tuner-Abbau vorweg und Budget eins statt zwei. Er ist ein Stück Hardware, vier Stück gibt es, und ein doppelt belegter ist schlimmer als ein Stottern. Bekannte Grenze: `endLiveSession` meldet nicht, **ob** die Freigabe geklappt hat — das Protokoll gibt nichts zurück. Deshalb der eine Versuch und jeder Schritt im Protokoll, damit ein verlorener Tuner nachvollziehbar bleibt. Nebenbei aufgeräumt: die Wahl von Hand und die Sicherheitsleine teilen sich jetzt denselben Neuaufbau. Was sie unterscheidet, steht in `effectivePolicy` — die Wahl setzt `quality`, die Automatik `automaticCeiling`, und beides fließt dort zusammen. Die Automatik schreibt dabei nie `rememberedQuality` fort: eine schlechte Zugfahrt soll nicht dauerhaft das Wohnzimmer verschlechtern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d878289929
commit
a79d562642
@@ -0,0 +1,128 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import FlixyPlayback
|
||||
|
||||
@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 Strom 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user