Files
Flaro/Packages/FlixyKit/Tests/FlixyPlaybackTests/StreamHealthTests.swift
T
ScarriffleandClaude Opus 5 52d6ebba43 „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>
2026-09-27 15:40:48 +02:00

141 lines
4.7 KiB
Swift

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)
}
}