„Automatisch" misst jetzt, statt die Einstellung durchzureichen
Bisher hieß `PlayerQuality.automatic` wörtlich: nimm die Zahl, die für WLAN oder Mobilfunk hinterlegt ist. Keine Messung, kein Nachregeln — die Entscheidung fiel einmal beim Start und galt bis zum Ende. `StreamHealth` beurteilt den Strom jetzt fortlaufend aus Proben im Sekundentakt. Ein reiner Typ: kein AVFoundation, keine Uhr, kein Netz. Damit lässt sich eine schlechte Zugfahrt als Zahlenreihe hinschreiben und nachsehen, was sie auslöst — zehn Tests tun genau das. **Welches Signal wann meldet**, ist der eigentliche Inhalt. Der Pufferverlauf ist der einzige echte Frühwarner: schrumpft er schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht, und das sieht man zehn bis zwanzig Sekunden vor dem Rückler. `isLikelyToKeepUp` ist früh, aber sprunghaft — es zählt erst, wenn es anhält. Der Rücklerzähler meldet zu spät zum Regeln und bleibt trotzdem der Massstab, an dem sich hinterher ablesen lässt, ob es besser wurde. Die **gemessene** Bitrate steht bewusst nicht drin: auf einer kleinen Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre. Ausdrücklich kein Bandbreitentest. Der kostet Daten, ist beim Vorliegen veraltet und misst die Vergangenheit. Der Pufferverlauf ist die bessere Messung, weil er die einzige Größe misst, auf die es ankommt: lädt es schneller, als es läuft. Gehandelt wird über `preferredPeakBitRate` — der billige Hebel, der ohne Neuaufbau wirkt. Gezielt wird dabei auf eine Sprosse, **die es in `AVAsset.variants` wirklich gibt**: ein Wert zwischen zweien wählt dieselbe wie die untere und machte jede Anzeige darüber zur Lüge. Absichtlich unsymmetrisch — runter sofort, hinauf frühestens nach einer Minute Ruhe. Und getrennt von `quality`: die Automatik ruft **nicht** `select(quality:)`. Die Methode schreibt `rememberedQuality` fort und setzte eine manuelle Stufe — womit die Automatik sich selbst abschaltete und eine schlechte Zugfahrt dauerhaft das Wohnzimmer verschlechterte. Hat die Playlist nur eine Stufe, greift der Hebel ins Leere. Das ist kein Fehler, sondern der Fall, für den die Sicherheitsleine gebaut wird — bis dahin steht er wenigstens im Protokoll. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cdff53fa0d
commit
52d6ebba43
@@ -0,0 +1,140 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import FlixyPlayback
|
||||
|
||||
@Suite("Zustand des Stroms")
|
||||
struct StreamHealthTests {
|
||||
/// Baut eine Folge von Proben im Sekundentakt.
|
||||
private func lauf(
|
||||
_ health: inout StreamHealth,
|
||||
von: Int,
|
||||
bis: Int,
|
||||
puffer: (Int) -> Double?,
|
||||
stalls: (Int) -> Int = { _ in 0 },
|
||||
keepUp: (Int) -> Bool? = { _ in true }
|
||||
) -> StreamCondition {
|
||||
var urteil = StreamCondition.healthy
|
||||
for sekunde in von...bis {
|
||||
urteil = health.record(StreamSample(
|
||||
at: .seconds(sekunde),
|
||||
bufferedAhead: puffer(sekunde).map { .milliseconds(Int($0 * 1000)) },
|
||||
isLikelyToKeepUp: keepUp(sekunde),
|
||||
stallCount: stalls(sekunde)
|
||||
))
|
||||
}
|
||||
return urteil
|
||||
}
|
||||
|
||||
@Test("Ein voller, stabiler Puffer ist gesund")
|
||||
func fullBufferIsHealthy() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 60, puffer: { _ in 30 })
|
||||
#expect(urteil == .healthy)
|
||||
}
|
||||
|
||||
/// Der Fall, für den das Ganze gebaut ist: der Puffer schrumpft stetig.
|
||||
/// Das muss auffallen, **bevor** er leer ist.
|
||||
@Test("Ein schrumpfender Puffer meldet sich vor dem Rückler")
|
||||
func shrinkingBufferWarnsEarly() {
|
||||
var health = StreamHealth()
|
||||
// Start mit 30 s, dann jede Sekunde eine weniger.
|
||||
_ = lauf(&health, von: 0, bis: 10, puffer: { _ in 30 })
|
||||
let urteil = lauf(&health, von: 11, bis: 36, puffer: { 30 - Double($0 - 10) })
|
||||
|
||||
#expect(urteil == .strained)
|
||||
// Und zwar bei noch fünf Sekunden Puffer — nicht bei null.
|
||||
#expect(health.condition != .failing)
|
||||
}
|
||||
|
||||
@Test("Ein kleiner, aber stabiler Puffer ist kein Alarm")
|
||||
func smallStableBufferIsNotAlarming() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 30, puffer: { _ in 4 })
|
||||
// Klein, aber er hält. Das ist kein Grund, die Stufe zu senken.
|
||||
#expect(urteil != .strained)
|
||||
#expect(urteil != .failing)
|
||||
}
|
||||
|
||||
@Test("Zwei Rückler in einer Minute heißen: trägt nicht")
|
||||
func twoStallsMeanFailing() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(
|
||||
&health,
|
||||
von: 0,
|
||||
bis: 40,
|
||||
puffer: { _ in 10 },
|
||||
stalls: { $0 < 10 ? 0 : ($0 < 25 ? 1 : 2) }
|
||||
)
|
||||
#expect(urteil == .failing)
|
||||
}
|
||||
|
||||
@Test("Ein leerer Puffer heißt sofort: trägt nicht")
|
||||
func emptyBufferIsFailing() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
|
||||
#expect(urteil == .failing)
|
||||
}
|
||||
|
||||
/// „Noch nichts geladen" ist etwas anderes als „Puffer leer". Beim Start
|
||||
/// darf das nicht als Notlage durchgehen.
|
||||
@Test("Ohne Messwert kein Alarm")
|
||||
func unknownBufferIsNotAlarming() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(&health, von: 0, bis: 10, puffer: { _ in nil })
|
||||
#expect(urteil == .healthy)
|
||||
}
|
||||
|
||||
/// Der Weg zurück ist absichtlich schmal: schnell runter, langsam rauf.
|
||||
@Test("Zurück nach gesund braucht eine halbe Minute Ruhe")
|
||||
func recoveryIsSlow() {
|
||||
var health = StreamHealth()
|
||||
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
|
||||
#expect(health.condition == .failing)
|
||||
|
||||
// Puffer sofort wieder voll — aber zu kurz.
|
||||
let früh = lauf(&health, von: 21, bis: 40, puffer: { _ in 30 })
|
||||
#expect(früh != .healthy)
|
||||
|
||||
// Nach einer halben Minute durchgehend gut: erholt.
|
||||
let spät = lauf(&health, von: 41, bis: 90, puffer: { _ in 30 })
|
||||
#expect(spät == .healthy)
|
||||
}
|
||||
|
||||
/// Ein einzelner Ausschlag von `isLikelyToKeepUp` kommt bei jedem Sprung
|
||||
/// vor und darf nichts auslösen.
|
||||
@Test("Ein einzelner Ausschlag löst nichts aus")
|
||||
func singleKeepUpDipIsIgnored() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(
|
||||
&health,
|
||||
von: 0,
|
||||
bis: 30,
|
||||
puffer: { _ in 25 },
|
||||
keepUp: { $0 == 15 ? false : true }
|
||||
)
|
||||
#expect(urteil == .healthy)
|
||||
}
|
||||
|
||||
@Test("Anhaltende Sorge des Systems zählt")
|
||||
func sustainedKeepUpFalseCounts() {
|
||||
var health = StreamHealth()
|
||||
let urteil = lauf(
|
||||
&health,
|
||||
von: 0,
|
||||
bis: 30,
|
||||
puffer: { _ in 25 },
|
||||
keepUp: { $0 >= 27 ? false : true }
|
||||
)
|
||||
#expect(urteil == .strained)
|
||||
}
|
||||
|
||||
@Test("Zurücksetzen macht einen frischen Anfang")
|
||||
func resetStartsOver() {
|
||||
var health = StreamHealth()
|
||||
_ = lauf(&health, von: 0, bis: 20, puffer: { $0 < 18 ? 10 : 0 })
|
||||
#expect(health.condition == .failing)
|
||||
|
||||
health.removeAll()
|
||||
#expect(health.condition == .healthy)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user