„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
@@ -190,6 +190,35 @@ final class PlayerModel {
|
||||
/// gedrosselten Leitung ermittelt.
|
||||
private static let forwardBuffer: Duration = .seconds(25)
|
||||
|
||||
/// Wie oft der Zustand des Stroms geprüft wird.
|
||||
///
|
||||
/// Einmal je Sekunde: fein genug, dass ein schrumpfender Puffer auffällt,
|
||||
/// bevor er leer ist, und grob genug, dass es nichts kostet.
|
||||
private static let healthInterval: Duration = .seconds(1)
|
||||
|
||||
/// Wie lange nach einem Hinaufschalten Ruhe herrscht.
|
||||
///
|
||||
/// Hinauf wird nur zögerlich gegangen: ein falscher Schritt nach unten
|
||||
/// kostet Schärfe, ein falscher nach oben einen Rückler.
|
||||
private static let stepUpCooldown: Duration = .seconds(60)
|
||||
|
||||
/// Der Zustand des Stroms, fortlaufend beurteilt.
|
||||
@ObservationIgnored private var health = StreamHealth()
|
||||
@ObservationIgnored private var healthTask: Task<Void, Never>?
|
||||
/// Wann zuletzt eine Stufe höher gegangen wurde.
|
||||
@ObservationIgnored private var lastStepUp: Duration?
|
||||
@ObservationIgnored private var hasNotedMissingLadder = false
|
||||
|
||||
/// Die Obergrenze, die die Automatik gerade setzt — in Kilobit.
|
||||
///
|
||||
/// **Getrennt von `quality`, und das ist wesentlich.** Würde die
|
||||
/// Automatik `select(quality:)` rufen, schriebe sie `rememberedQuality`
|
||||
/// fort und setzte eine manuelle Stufe — womit sie sich selbst
|
||||
/// abschaltete und eine schlechte Zugfahrt dauerhaft das Wohnzimmer
|
||||
/// verschlechterte. `quality` bleibt deshalb `.automatic`; nur dieser
|
||||
/// Wert wandert.
|
||||
private(set) var automaticCeiling: Int?
|
||||
|
||||
/// Das Buch über die Rückler dieser Wiedergabe.
|
||||
@ObservationIgnored private var stallLedger = StallLedger()
|
||||
/// Seit wann tatsächlich gespielt wird — der Nullpunkt für das Buch.
|
||||
@@ -467,6 +496,9 @@ final class PlayerModel {
|
||||
diagnostics.note("Engine gestartet")
|
||||
// Weder offline noch live gibt es einen Fortschritt zu melden.
|
||||
if !isOffline, !isLive { startReporting() }
|
||||
// Den Zustand des Stroms dagegen auch bei Live beobachten: dort
|
||||
// hakt es am ehesten, und der billige Hebel kostet nichts.
|
||||
startHealthWatch()
|
||||
} catch {
|
||||
diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)")
|
||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
||||
@@ -530,6 +562,102 @@ final class PlayerModel {
|
||||
|
||||
// MARK: - Steuerung
|
||||
|
||||
// MARK: - Die Qualität selbst nachregeln
|
||||
|
||||
/// Beobachtet den Strom und passt die Obergrenze an.
|
||||
///
|
||||
/// Läuft nur bei „automatisch". Eine ausdrücklich gewählte Stufe ist eine
|
||||
/// Ansage und wird nicht überstimmt — `automatic` ist die Erlaubnis
|
||||
/// einzugreifen, alles andere nicht.
|
||||
private func startHealthWatch() {
|
||||
healthTask?.cancel()
|
||||
guard quality == .automatic, !isOffline else { return }
|
||||
|
||||
healthTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: Self.healthInterval)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.sampleStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Probe nehmen und daraus handeln.
|
||||
private func sampleStream() {
|
||||
guard let engine, let startedAt = playbackStartedAt else { return }
|
||||
guard let statistics = engine.statistics else { return }
|
||||
|
||||
let now = ContinuousClock.now - startedAt
|
||||
let urteil = health.record(StreamSample(
|
||||
at: now,
|
||||
bufferedAhead: statistics.bufferedAhead,
|
||||
isLikelyToKeepUp: statistics.isLikelyToKeepUp,
|
||||
stallCount: statistics.stallCount,
|
||||
indicatedBitrate: statistics.indicatedBitrate
|
||||
))
|
||||
|
||||
// **Ohne Leiter greift der billige Hebel ins Leere.** AVPlayer spielt
|
||||
// die eine Stufe auch dann, wenn sie über der Grenze liegt. Das ist
|
||||
// kein Fehler, sondern der Fall, für den es die Sicherheitsleine gibt
|
||||
// — und bis die steht, gehört er wenigstens ins Protokoll.
|
||||
guard statistics.hasLadder else {
|
||||
noteMissingLadder(urteil)
|
||||
return
|
||||
}
|
||||
|
||||
adjustCeiling(for: urteil, statistics: statistics, at: now)
|
||||
}
|
||||
|
||||
/// Setzt die Obergrenze eine Sprosse tiefer oder höher.
|
||||
private func adjustCeiling(
|
||||
for condition: StreamCondition,
|
||||
statistics: PlaybackStatistics,
|
||||
at now: Duration
|
||||
) {
|
||||
let sprossen = statistics.variantBitrates
|
||||
guard let unterste = sprossen.first, let oberste = sprossen.last else { return }
|
||||
let aktuell = automaticCeiling ?? statistics.indicatedBitrate ?? oberste
|
||||
|
||||
let ziel: Int?
|
||||
switch condition {
|
||||
case .failing:
|
||||
ziel = unterste
|
||||
case .strained:
|
||||
// **Auf eine Sprosse, die es wirklich gibt.** Ein Wert zwischen
|
||||
// zweien wählt dieselbe wie die untere und macht jede Anzeige
|
||||
// darüber zur Lüge.
|
||||
ziel = statistics.rung(below: aktuell) ?? unterste
|
||||
case .healthy:
|
||||
// Hinauf nur zögerlich, und nur nach einer Ruhefrist.
|
||||
guard let lastStepUp, now - lastStepUp >= Self.stepUpCooldown else {
|
||||
if lastStepUp == nil { self.lastStepUp = now }
|
||||
return
|
||||
}
|
||||
guard let höher = sprossen.first(where: { $0 > aktuell }) else { return }
|
||||
ziel = höher
|
||||
}
|
||||
|
||||
guard let ziel, ziel != automaticCeiling else { return }
|
||||
|
||||
automaticCeiling = ziel
|
||||
if condition == .healthy { lastStepUp = now }
|
||||
// Die oberste Sprosse ist keine Grenze, sondern das Fehlen einer.
|
||||
engine?.setPeakBitrate(ziel >= oberste ? nil : ziel)
|
||||
|
||||
diagnostics.note(String(
|
||||
format: "Automatik: %@ → höchstens %d kbit",
|
||||
String(describing: condition),
|
||||
ziel
|
||||
))
|
||||
}
|
||||
|
||||
/// Protokolliert einmalig, dass es nichts zum Umschalten gibt.
|
||||
private func noteMissingLadder(_ condition: StreamCondition) {
|
||||
guard condition != .healthy, !hasNotedMissingLadder else { return }
|
||||
hasNotedMissingLadder = true
|
||||
diagnostics.note("Es hakt, aber die Playlist hat nur eine Stufe — nichts zum Ausweichen")
|
||||
}
|
||||
|
||||
/// Führt Buch über das Stocken — die **zweite** Uhr.
|
||||
///
|
||||
/// Der Wachhund darunter ist ein Totmann: vierzig Sekunden am Stück ohne
|
||||
@@ -699,6 +827,12 @@ final class PlayerModel {
|
||||
playbackStartedAt = nil
|
||||
stallBegan = nil
|
||||
isConnectionStruggling = false
|
||||
healthTask?.cancel()
|
||||
healthTask = nil
|
||||
health.removeAll()
|
||||
automaticCeiling = nil
|
||||
lastStepUp = nil
|
||||
hasNotedMissingLadder = false
|
||||
NowPlaying.shared.release(for: self)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine Probe vom laufenden Strom.
|
||||
///
|
||||
/// Alle Zeiten **seit Beginn dieser Wiedergabe**, nicht als Datum — dasselbe
|
||||
/// wie beim `StallLedger`, und aus demselben Grund: eine Systemuhr, die sich
|
||||
/// mitten im Film stellt, darf das Urteil nicht kippen.
|
||||
public struct StreamSample: Sendable, Equatable {
|
||||
public let at: Duration
|
||||
/// Wie weit der Puffer vor der Abspielstelle reicht. `nil` heißt
|
||||
/// „noch nichts geladen" — etwas anderes als ein leerer Puffer.
|
||||
public let bufferedAhead: Duration?
|
||||
public let isLikelyToKeepUp: Bool?
|
||||
/// Der fortlaufende Zähler, nicht die Zahl seit der letzten Probe.
|
||||
public let stallCount: Int
|
||||
/// Auf welcher Sprosse gerade gespielt wird, in Kilobit je Sekunde.
|
||||
public let indicatedBitrate: Int?
|
||||
|
||||
public init(
|
||||
at: Duration,
|
||||
bufferedAhead: Duration? = nil,
|
||||
isLikelyToKeepUp: Bool? = nil,
|
||||
stallCount: Int = 0,
|
||||
indicatedBitrate: Int? = nil
|
||||
) {
|
||||
self.at = at
|
||||
self.bufferedAhead = bufferedAhead
|
||||
self.isLikelyToKeepUp = isLikelyToKeepUp
|
||||
self.stallCount = stallCount
|
||||
self.indicatedBitrate = indicatedBitrate
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie es dem Strom geht.
|
||||
public enum StreamCondition: Sendable, Equatable {
|
||||
/// Trägt. Es darf wieder eine Stufe höher gehen.
|
||||
case healthy
|
||||
/// Wird knapp. Eine Stufe tiefer, bevor das Bild steht.
|
||||
case strained
|
||||
/// Trägt nicht. So tief wie möglich.
|
||||
case failing
|
||||
}
|
||||
|
||||
/// Beurteilt den Strom aus einer Folge von Proben.
|
||||
///
|
||||
/// **Warum als reiner Typ.** Kein AVFoundation, keine Uhr, kein Netz — Proben
|
||||
/// hinein, Urteil heraus. Dieselbe Bauform wie `PlaybackDecider`, und aus
|
||||
/// demselben Grund: eine Regelung, die man nur im laufenden Film beobachten
|
||||
/// kann, ist nicht zu beurteilen. Hier lässt sich eine schlechte Zugfahrt als
|
||||
/// Zahlenreihe hinschreiben und nachsehen, was sie auslöst.
|
||||
///
|
||||
/// **Welches Signal wann meldet** — das ist der eigentliche Inhalt:
|
||||
///
|
||||
/// - **Der Pufferverlauf ist der einzige echte Frühwarner.** Schrumpft er
|
||||
/// schneller, als die Zeit vergeht, kommt weniger herein als hinausgeht.
|
||||
/// Das sieht man zehn bis zwanzig Sekunden vor dem Rückler.
|
||||
/// - `isLikelyToKeepUp` ist früh, aber grob: Apple legt es um, wenn es sich
|
||||
/// schon sorgt. Als Bestätigung gut, als alleiniger Auslöser zu sprunghaft.
|
||||
/// - Der Rücklerzähler meldet **zu spät zum Regeln** — das Bild stand bereits.
|
||||
/// Er ist trotzdem unverzichtbar: an ihm misst sich hinterher, ob es besser
|
||||
/// wurde.
|
||||
/// - Die **gemessene** Bitrate steht hier bewusst nicht. Auf einer kleinen
|
||||
/// Sprosse holt der Player wenig, ohne dass die Leitung schlecht wäre —
|
||||
/// als Kriterium wäre sie irreführend.
|
||||
///
|
||||
/// **Absichtlich unsymmetrisch: schnell runter, langsam rauf.** Ein falscher
|
||||
/// Schritt nach unten kostet Schärfe, ein falscher nach oben einen Rückler.
|
||||
public struct StreamHealth: Sendable, Equatable {
|
||||
/// Wie viel Puffer als knapp gilt.
|
||||
public static let lowBuffer: Duration = .seconds(6)
|
||||
/// Ab wann er als reichlich gilt.
|
||||
public static let goodBuffer: Duration = .seconds(20)
|
||||
|
||||
private var samples: [StreamSample] = []
|
||||
private(set) public var condition: StreamCondition = .healthy
|
||||
/// Seit wann es ununterbrochen gut aussieht. `nil`, sobald es das nicht tut.
|
||||
private var healthySince: Duration?
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Nimmt eine Probe und gibt das aktuelle Urteil zurück.
|
||||
@discardableResult
|
||||
public mutating func record(_ sample: StreamSample) -> StreamCondition {
|
||||
samples.append(sample)
|
||||
samples.removeAll { $0.at < sample.at - .seconds(60) }
|
||||
|
||||
condition = judge(at: sample)
|
||||
return condition
|
||||
}
|
||||
|
||||
public mutating func removeAll() {
|
||||
samples.removeAll()
|
||||
healthySince = nil
|
||||
condition = .healthy
|
||||
}
|
||||
|
||||
// MARK: - Das Urteil
|
||||
|
||||
private mutating func judge(at now: StreamSample) -> StreamCondition {
|
||||
// **Zuerst das Schlimmste.** Zwei Rückler in einer Minute heißen: es
|
||||
// war kein Ausrutscher.
|
||||
if stalls(within: .seconds(60), at: now) >= 2 {
|
||||
healthySince = nil
|
||||
return .failing
|
||||
}
|
||||
// Puffer auf null, während gespielt wird. `nil` zählt nicht — das
|
||||
// heißt „noch nichts geladen" und ist der Zustand beim Start.
|
||||
if let buffer = now.bufferedAhead, buffer == .zero {
|
||||
healthySince = nil
|
||||
return .failing
|
||||
}
|
||||
|
||||
if isStrained(at: now) {
|
||||
healthySince = nil
|
||||
return .strained
|
||||
}
|
||||
|
||||
// **Der Weg zurück ist der schmalste.** Reichlich Puffer allein
|
||||
// genügt nicht: er muss eine halbe Minute lang reichlich geblieben
|
||||
// sein, und in der letzten Minute darf es nicht gehakt haben. Sonst
|
||||
// pendelt die Stufe im Takt der Aussetzer, und das ist schlimmer als
|
||||
// eine Stufe zu niedrig zu bleiben.
|
||||
guard let buffer = now.bufferedAhead, buffer >= Self.goodBuffer else {
|
||||
healthySince = nil
|
||||
return condition == .healthy ? .healthy : condition
|
||||
}
|
||||
|
||||
let seit = healthySince ?? now.at
|
||||
healthySince = seit
|
||||
|
||||
let langGenug = now.at - seit >= .seconds(30)
|
||||
let ruhig = stalls(within: .seconds(60), at: now) == 0
|
||||
guard langGenug, ruhig else {
|
||||
return condition == .healthy ? .healthy : condition
|
||||
}
|
||||
return .healthy
|
||||
}
|
||||
|
||||
private func isStrained(at now: StreamSample) -> Bool {
|
||||
// Knapp **und** fallend. Ein kleiner Puffer, der stabil bleibt, ist
|
||||
// kein Alarm — er ist nur klein.
|
||||
if let buffer = now.bufferedAhead, buffer < Self.lowBuffer, isFalling(at: now) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Apples eigene Sorge, aber erst wenn sie anhält: ein einzelner
|
||||
// Ausschlag kommt bei jedem Sprung vor.
|
||||
let letzte = samples.filter { $0.at >= now.at - .seconds(3) }
|
||||
if letzte.count >= 3, letzte.allSatisfy({ $0.isLikelyToKeepUp == false }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Ob der Puffer über die letzten Sekunden hinweg schrumpft.
|
||||
private func isFalling(at now: StreamSample) -> Bool {
|
||||
let fenster = samples.filter { $0.at >= now.at - .seconds(5) }
|
||||
guard fenster.count >= 3,
|
||||
let ältester = fenster.first?.bufferedAhead,
|
||||
let jüngster = fenster.last?.bufferedAhead
|
||||
else { return false }
|
||||
return jüngster < ältester
|
||||
}
|
||||
|
||||
/// Wie viele Rückler im Zeitfenster dazugekommen sind.
|
||||
///
|
||||
/// Aus der **Differenz** des fortlaufenden Zählers, nicht aus einer
|
||||
/// eigenen Zählung: der Zähler kommt vom System und weiß auch von
|
||||
/// Rücklern zwischen zwei Proben.
|
||||
private func stalls(within window: Duration, at now: StreamSample) -> Int {
|
||||
let fenster = samples.filter { $0.at >= now.at - window }
|
||||
guard let erster = fenster.first else { return 0 }
|
||||
return max(0, now.stallCount - erster.stallCount)
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,13 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
continuation?.yield(.timeChanged(time))
|
||||
}
|
||||
|
||||
public func setPeakBitrate(_ kilobits: Int?) {
|
||||
guard let item = player.currentItem else { return }
|
||||
// Null heißt bei AVFoundation „keine Grenze". Kein Sonderfall, nur
|
||||
// eine andere Schreibweise für dasselbe.
|
||||
item.preferredPeakBitRate = kilobits.map { Double($0) * 1_000 } ?? 0
|
||||
}
|
||||
|
||||
public func setRate(_ rate: Double) {
|
||||
player.rate = Float(rate)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,20 @@ public protocol VideoPlayerEngine: AnyObject {
|
||||
/// Leitung schlecht ist?" eine Vermutung.
|
||||
var statistics: PlaybackStatistics? { get }
|
||||
|
||||
/// Deckelt, welche Sprosse der Player höchstens ziehen darf.
|
||||
///
|
||||
/// **Der billige Hebel.** Er wirkt ohne Neuaufbau: kein Anhalten am
|
||||
/// Server, keine neue Aushandlung, kein Sprung zurück an die alte Stelle.
|
||||
/// `nil` hebt die Grenze auf.
|
||||
///
|
||||
/// Zwei Einschränkungen gehören dazugesagt. Der Wert greift erst an der
|
||||
/// nächsten Schnipselgrenze, also mit einigen Sekunden Verzug — ein Grund
|
||||
/// mehr, nicht bei jedem Ausschlag daran zu drehen. Und **bei einer
|
||||
/// Playlist mit einer einzigen Stufe tut er nichts**: der Player spielt
|
||||
/// sie auch dann, wenn sie über der Grenze liegt. Der Wert ist ein
|
||||
/// Wunsch, keine Vorschrift.
|
||||
func setPeakBitrate(_ kilobits: Int?)
|
||||
|
||||
/// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen,
|
||||
/// sonst behält besonders VLCKit den Speicher.
|
||||
func teardown()
|
||||
@@ -203,6 +217,10 @@ extension VideoPlayerEngine {
|
||||
/// Engines, die nichts melden, melden nichts — statt jeden zu zwingen.
|
||||
public var statistics: PlaybackStatistics? { nil }
|
||||
|
||||
/// Wer keine Leiter hat, kann auch nichts deckeln. Kein Fehler, nur
|
||||
/// nichts zu tun — bei einer Datei gibt es keine Stufen.
|
||||
public func setPeakBitrate(_ kilobits: Int?) {}
|
||||
|
||||
/// Wer nichts dazu sagt, kann es nicht.
|
||||
public var supportsAirPlay: Bool { false }
|
||||
}
|
||||
|
||||
@@ -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