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
@@ -208,6 +208,42 @@ final class PlayerModel {
|
|||||||
/// Wann zuletzt eine Stufe höher gegangen wurde.
|
/// Wann zuletzt eine Stufe höher gegangen wurde.
|
||||||
@ObservationIgnored private var lastStepUp: Duration?
|
@ObservationIgnored private var lastStepUp: Duration?
|
||||||
@ObservationIgnored private var hasNotedMissingLadder = false
|
@ObservationIgnored private var hasNotedMissingLadder = false
|
||||||
|
/// Wann zuletzt gesprungen wurde — ein Sprung leert den Puffer immer.
|
||||||
|
@ObservationIgnored private var lastSeekAt: Duration?
|
||||||
|
@ObservationIgnored private var lastDowngradeAt: Duration?
|
||||||
|
@ObservationIgnored private var downgradesUsed = 0
|
||||||
|
|
||||||
|
/// Ob die Automatik aufgegeben hat.
|
||||||
|
///
|
||||||
|
/// Dann 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.
|
||||||
|
private(set) var hasGivenUp = false
|
||||||
|
|
||||||
|
/// Wie oft in **dieser** Wiedergabe der teure Weg gegangen werden darf.
|
||||||
|
///
|
||||||
|
/// Bei Live-TV nur einmal. Jeder Neuaufbau stimmt dort den Tuner neu ab,
|
||||||
|
/// und davon gibt es vier Stück — ein Stottern ist der kleinere Schaden
|
||||||
|
/// als ein Sender, der gar nicht mehr angeht.
|
||||||
|
private var downgradeBudget: Int { isLive ? 1 : 2 }
|
||||||
|
|
||||||
|
/// Die Richtlinie, wie sie **jetzt** gilt.
|
||||||
|
///
|
||||||
|
/// Die eingestellte Obergrenze, überschrieben von dem, was die Automatik
|
||||||
|
/// unterwegs gelernt hat. Getrennt von `quality`, damit eine schlechte
|
||||||
|
/// Zugfahrt nicht als gemerkte Stufe das Wohnzimmer verschlechtert.
|
||||||
|
private var effectivePolicy: PlaybackPolicy {
|
||||||
|
let base = quality.policy(basedOn: policy)
|
||||||
|
guard quality == .automatic, let ceiling = automaticCeiling else { return base }
|
||||||
|
return PlaybackPolicy(
|
||||||
|
allowsTranscoding: true,
|
||||||
|
// Ohne Ersatzmotor: sonst spielte VLC die Datei unverändert ab,
|
||||||
|
// und die abgesenkte Grenze bliebe wirkungslos.
|
||||||
|
allowsFallbackEngine: false,
|
||||||
|
maximumBitrate: min(ceiling, base.maximumBitrate ?? ceiling),
|
||||||
|
forcesTranscoding: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Die Obergrenze, die die Automatik gerade setzt — in Kilobit.
|
/// Die Obergrenze, die die Automatik gerade setzt — in Kilobit.
|
||||||
///
|
///
|
||||||
@@ -359,15 +395,33 @@ final class PlayerModel {
|
|||||||
let backend, let item, let media = currentMedia, let part = currentPart
|
let backend, let item, let media = currentMedia, let part = currentPart
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
|
quality = newQuality
|
||||||
|
// Von Hand gewählt: was die Automatik gelernt hatte, gilt nicht mehr.
|
||||||
|
automaticCeiling = nil
|
||||||
|
hasGivenUp = false
|
||||||
|
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
|
||||||
|
// einen Film.
|
||||||
|
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
|
||||||
|
|
||||||
|
await restart()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut den Strom mit der **aktuell geltenden** Richtlinie neu auf.
|
||||||
|
///
|
||||||
|
/// Geteilt zwischen der Wahl von Hand und der Sicherheitsleine — der
|
||||||
|
/// Ablauf ist derselbe, nur der Anlass nicht. Was sich unterscheidet,
|
||||||
|
/// steht in `effectivePolicy`, nicht hier: die Wahl setzt `quality`, die
|
||||||
|
/// Automatik setzt `automaticCeiling`, und beides fließt dort zusammen.
|
||||||
|
private func restart() async {
|
||||||
|
guard !isOffline,
|
||||||
|
let backend, let item, let media = currentMedia, let part = currentPart
|
||||||
|
else { return }
|
||||||
|
|
||||||
isChangingQuality = true
|
isChangingQuality = true
|
||||||
defer { isChangingQuality = false }
|
defer { isChangingQuality = false }
|
||||||
|
|
||||||
let resumeAt = currentTime
|
let resumeAt = currentTime
|
||||||
resumeTarget = resumeAt
|
resumeTarget = resumeAt
|
||||||
quality = newQuality
|
|
||||||
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
|
|
||||||
// einen Film.
|
|
||||||
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
|
|
||||||
|
|
||||||
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
||||||
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
||||||
@@ -380,10 +434,9 @@ final class PlayerModel {
|
|||||||
engine = nil
|
engine = nil
|
||||||
phase = .preparing
|
phase = .preparing
|
||||||
|
|
||||||
let newPolicy = newQuality.policy(basedOn: policy)
|
|
||||||
let newDecision = PlaybackDecider().decide(
|
let newDecision = PlaybackDecider().decide(
|
||||||
profile: media.profile(file: part),
|
profile: media.profile(file: part),
|
||||||
policy: newPolicy
|
policy: effectivePolicy
|
||||||
)
|
)
|
||||||
decision = newDecision
|
decision = newDecision
|
||||||
|
|
||||||
@@ -596,16 +649,150 @@ final class PlayerModel {
|
|||||||
indicatedBitrate: statistics.indicatedBitrate
|
indicatedBitrate: statistics.indicatedBitrate
|
||||||
))
|
))
|
||||||
|
|
||||||
// **Ohne Leiter greift der billige Hebel ins Leere.** AVPlayer spielt
|
// Erst der billige Hebel — er kostet kein schwarzes Bild.
|
||||||
// die eine Stufe auch dann, wenn sie über der Grenze liegt. Das ist
|
if statistics.hasLadder {
|
||||||
// kein Fehler, sondern der Fall, für den es die Sicherheitsleine gibt
|
adjustCeiling(for: urteil, statistics: statistics, at: now)
|
||||||
// — und bis die steht, gehört er wenigstens ins Protokoll.
|
} else {
|
||||||
guard statistics.hasLadder else {
|
|
||||||
noteMissingLadder(urteil)
|
noteMissingLadder(urteil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Und dann die Frage, ob der teure Weg nötig ist.
|
||||||
|
considerTheHardWay(urteil, statistics: statistics, at: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Die Sicherheitsleine
|
||||||
|
|
||||||
|
/// Prüft, ob der Strom neu aufgebaut werden muss — und tut es.
|
||||||
|
private func considerTheHardWay(
|
||||||
|
_ condition: StreamCondition,
|
||||||
|
statistics: PlaybackStatistics,
|
||||||
|
at now: Duration
|
||||||
|
) {
|
||||||
|
guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return }
|
||||||
|
|
||||||
|
let unterste = statistics.variantBitrates.first
|
||||||
|
let facts = DowngradeRule.Facts(
|
||||||
|
isAutomatic: quality == .automatic,
|
||||||
|
hasLadder: statistics.hasLadder,
|
||||||
|
isAtLowestRung: unterste.map { (automaticCeiling ?? .max) <= $0 } ?? false,
|
||||||
|
condition: condition,
|
||||||
|
stallsInWindow: stallLedger.count(at: now),
|
||||||
|
longestStallInWindow: stallLedger.longest(at: now),
|
||||||
|
playedFor: now,
|
||||||
|
sinceLastSeek: lastSeekAt.map { now - $0 },
|
||||||
|
sinceLastDowngrade: lastDowngradeAt.map { now - $0 },
|
||||||
|
used: downgradesUsed,
|
||||||
|
budget: downgradeBudget
|
||||||
|
)
|
||||||
|
|
||||||
|
switch DowngradeRule.verdict(facts) {
|
||||||
|
case .downgrade:
|
||||||
|
Task { await downgradeTheHardWay(at: now) }
|
||||||
|
case .budgetSpent:
|
||||||
|
// **Hier hört es auf und wird gesagt.** Lautlos die ganze Leiter
|
||||||
|
// hinunterzugehen ist schlimmer, als einmal zuzugeben, dass die
|
||||||
|
// Leitung es gerade nicht trägt.
|
||||||
|
hasGivenUp = true
|
||||||
|
diagnostics.note("Automatik aufgegeben — die Leitung trägt es nicht")
|
||||||
|
case .notAutomatic, .cheaperPathRemains, .notBadEnough, .tooEarly, .cooling:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baut den Strom mit kleinerer Bitrate neu auf.
|
||||||
|
private func downgradeTheHardWay(at now: Duration) async {
|
||||||
|
guard let ziel = nextLowerRung() else {
|
||||||
|
hasGivenUp = true
|
||||||
|
diagnostics.note("Keine kleinere Stufe mehr übrig")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
adjustCeiling(for: urteil, statistics: statistics, at: now)
|
automaticCeiling = ziel
|
||||||
|
downgradesUsed += 1
|
||||||
|
lastDowngradeAt = now
|
||||||
|
diagnostics.note("Neuaufbau mit höchstens \(ziel) kbit — es hakte zu oft")
|
||||||
|
|
||||||
|
if isLive {
|
||||||
|
await retuneLive(to: ziel)
|
||||||
|
} else {
|
||||||
|
await restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die nächste Sprosse unterhalb der aktuellen — aus **unserer** Leiter.
|
||||||
|
///
|
||||||
|
/// Nicht aus der Playlist: die hat in diesem Fall gerade keine, sonst
|
||||||
|
/// wären wir hier nicht. Die Sprossen richten sich nach der Höhe der
|
||||||
|
/// Quelle — eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild
|
||||||
|
/// dazu.
|
||||||
|
private func nextLowerRung() -> Int? {
|
||||||
|
let aktuell = automaticCeiling
|
||||||
|
?? statistics?.indicatedBitrate
|
||||||
|
?? policy.maximumBitrate
|
||||||
|
?? 8_000
|
||||||
|
|
||||||
|
let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight)
|
||||||
|
.compactMap { option -> Int? in
|
||||||
|
guard case .limited(let rung) = option else { return nil }
|
||||||
|
return rung.bitrate
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
return sprossen.last { $0 < aktuell }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stimmt einen Sender mit kleinerer Bitrate neu ab.
|
||||||
|
///
|
||||||
|
/// **Der Tuner wird zuerst freigegeben.** Er ist ein Stück Hardware, vier
|
||||||
|
/// Stück gibt es, und ein doppelt belegter ist im Alltag schlimmer als
|
||||||
|
/// ein Stottern. Erst danach wird neu abgestimmt.
|
||||||
|
///
|
||||||
|
/// Bekannte Grenze: `endLiveSession` meldet nicht, **ob** die Freigabe
|
||||||
|
/// geklappt hat — das Protokoll gibt nichts zurück. Deshalb hier nur ein
|
||||||
|
/// Versuch je Wiedergabe und jeder Schritt im Protokoll, damit ein
|
||||||
|
/// verlorener Tuner wenigstens nachvollziehbar bleibt.
|
||||||
|
private func retuneLive(to bitrate: Int) async {
|
||||||
|
guard let liveTV, let subscription = request.liveSubscriptionKey else {
|
||||||
|
hasGivenUp = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isChangingQuality = true
|
||||||
|
defer { isChangingQuality = false }
|
||||||
|
|
||||||
|
let stelle = currentTime
|
||||||
|
eventTask?.cancel()
|
||||||
|
engine?.teardown()
|
||||||
|
engine = nil
|
||||||
|
phase = .preparing
|
||||||
|
|
||||||
|
diagnostics.note("Tuner wird freigegeben")
|
||||||
|
await liveTV.endLiveSession(subscriptionKey: subscription)
|
||||||
|
|
||||||
|
let liste = await liveTV.liveChannels()
|
||||||
|
guard let sender = liste.channels.first(where: { $0.id == request.ratingKey }) else {
|
||||||
|
diagnostics.finish(outcome: "Sender nach dem Freigeben nicht mehr gefunden")
|
||||||
|
phase = .failed(message: String(localized: "player.stalled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let ergebnis = await liveTV.tune(
|
||||||
|
channel: sender,
|
||||||
|
program: nil,
|
||||||
|
maximumBitrate: bitrate
|
||||||
|
)
|
||||||
|
guard let url = ergebnis.url else {
|
||||||
|
diagnostics.finish(outcome: "Neues Abstimmen gescheitert")
|
||||||
|
phase = .failed(message: String(localized: "player.stalled"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics.note("Neu abgestimmt mit höchstens \(bitrate) kbit")
|
||||||
|
let entscheidung = PlaybackDecision(method: .transcode(maximumBitrate: bitrate), rationale: [])
|
||||||
|
self.decision = entscheidung
|
||||||
|
await load(
|
||||||
|
PlaybackSource(url: url, origin: .remote, startTime: stelle),
|
||||||
|
decision: entscheidung
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt die Obergrenze eine Sprosse tiefer oder höher.
|
/// Setzt die Obergrenze eine Sprosse tiefer oder höher.
|
||||||
@@ -774,6 +961,7 @@ final class PlayerModel {
|
|||||||
|
|
||||||
func seek(to time: Duration) {
|
func seek(to time: Duration) {
|
||||||
guard let engine else { return }
|
guard let engine else { return }
|
||||||
|
if let startedAt = playbackStartedAt { lastSeekAt = ContinuousClock.now - startedAt }
|
||||||
Task { await engine.seek(to: time) }
|
Task { await engine.seek(to: time) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -833,6 +1021,10 @@ final class PlayerModel {
|
|||||||
automaticCeiling = nil
|
automaticCeiling = nil
|
||||||
lastStepUp = nil
|
lastStepUp = nil
|
||||||
hasNotedMissingLadder = false
|
hasNotedMissingLadder = false
|
||||||
|
lastSeekAt = nil
|
||||||
|
lastDowngradeAt = nil
|
||||||
|
downgradesUsed = 0
|
||||||
|
hasGivenUp = false
|
||||||
NowPlaying.shared.release(for: self)
|
NowPlaying.shared.release(for: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wann der **teure** Weg gegangen werden darf.
|
||||||
|
///
|
||||||
|
/// Der billige Hebel ist `preferredPeakBitRate`: er wirkt ohne Neuaufbau.
|
||||||
|
/// Wenn er nichts ausrichtet — weil die Playlist nur eine Stufe hat oder weil
|
||||||
|
/// schon die unterste läuft —, bleibt nur der Neuaufbau mit kleinerer Bitrate:
|
||||||
|
/// Transcoder anhalten, Motor abreißen, neu aushandeln, zurückspringen. Das
|
||||||
|
/// sind Sekunden mit schwarzem Bild.
|
||||||
|
///
|
||||||
|
/// **Ein Notnagel, keine Regelschleife.** Zu oft ausgelöst erzeugt er genau
|
||||||
|
/// das Ruckeln, das er beheben soll. Deshalb steht hier eine Liste von
|
||||||
|
/// Bedingungen, die **alle** gelten müssen — und nicht eine Schwelle.
|
||||||
|
///
|
||||||
|
/// Eigener Typ ohne Uhr und ohne Netz, damit sich jede einzelne Bedingung
|
||||||
|
/// einzeln hinschreiben und prüfen lässt. Eine Regel, die nur im laufenden
|
||||||
|
/// Film zu beobachten ist, ist keine Regel.
|
||||||
|
public enum DowngradeRule {
|
||||||
|
/// Wie lange nach dem Start nichts ausgelöst wird.
|
||||||
|
///
|
||||||
|
/// Das anfängliche Füllen des Puffers sieht aus wie eine Notlage und ist
|
||||||
|
/// keine.
|
||||||
|
public static let settleIn: Duration = .seconds(10)
|
||||||
|
/// Wie lange nach einem Sprung nichts ausgelöst wird.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
public static let afterSeek: Duration = .seconds(10)
|
||||||
|
/// Sperrfrist zwischen zwei Absenkungen.
|
||||||
|
public static let cooldown: Duration = .seconds(180)
|
||||||
|
/// Das Fenster, in dem Rückler gezählt werden.
|
||||||
|
public static let window: Duration = .seconds(90)
|
||||||
|
/// Ab wie vielen Rücklern im Fenster es genügt.
|
||||||
|
public static let stallsNeeded = 3
|
||||||
|
/// Ab welcher Länge ein **einzelner** Rückler genügt.
|
||||||
|
///
|
||||||
|
/// Fünfzehn Sekunden Stillstand sind keine Schwankung mehr. Ihn
|
||||||
|
/// mitzuzählen, bis drei zusammenkommen, hieße viereinhalb Minuten
|
||||||
|
/// zusehen.
|
||||||
|
public static let longStall: Duration = .seconds(15)
|
||||||
|
|
||||||
|
/// Was zum Zeitpunkt der Frage gilt.
|
||||||
|
public struct Facts: Sendable, Equatable {
|
||||||
|
/// Ob überhaupt eingegriffen werden darf. Eine ausdrücklich gewählte
|
||||||
|
/// Stufe ist eine Ansage und wird nicht überstimmt.
|
||||||
|
public let isAutomatic: Bool
|
||||||
|
/// Ob die Playlist mehrere Stufen führt.
|
||||||
|
public let hasLadder: Bool
|
||||||
|
/// Ob schon die unterste davon läuft.
|
||||||
|
public let isAtLowestRung: Bool
|
||||||
|
public let condition: StreamCondition
|
||||||
|
public let stallsInWindow: Int
|
||||||
|
public let longestStallInWindow: Duration
|
||||||
|
/// Wie lange schon gespielt wird.
|
||||||
|
public let playedFor: Duration
|
||||||
|
/// Wie lange der letzte Sprung her ist. `nil` heißt: keiner gewesen.
|
||||||
|
public let sinceLastSeek: Duration?
|
||||||
|
/// Wie lange die letzte Absenkung her ist. `nil` heißt: keine gewesen.
|
||||||
|
public let sinceLastDowngrade: Duration?
|
||||||
|
/// Wie viele es in dieser Wiedergabe schon gab.
|
||||||
|
public let used: Int
|
||||||
|
/// Wie viele es höchstens geben darf.
|
||||||
|
public let budget: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
isAutomatic: Bool,
|
||||||
|
hasLadder: Bool,
|
||||||
|
isAtLowestRung: Bool,
|
||||||
|
condition: StreamCondition,
|
||||||
|
stallsInWindow: Int,
|
||||||
|
longestStallInWindow: Duration,
|
||||||
|
playedFor: Duration,
|
||||||
|
sinceLastSeek: Duration? = nil,
|
||||||
|
sinceLastDowngrade: Duration? = nil,
|
||||||
|
used: Int = 0,
|
||||||
|
budget: Int = 2
|
||||||
|
) {
|
||||||
|
self.isAutomatic = isAutomatic
|
||||||
|
self.hasLadder = hasLadder
|
||||||
|
self.isAtLowestRung = isAtLowestRung
|
||||||
|
self.condition = condition
|
||||||
|
self.stallsInWindow = stallsInWindow
|
||||||
|
self.longestStallInWindow = longestStallInWindow
|
||||||
|
self.playedFor = playedFor
|
||||||
|
self.sinceLastSeek = sinceLastSeek
|
||||||
|
self.sinceLastDowngrade = sinceLastDowngrade
|
||||||
|
self.used = used
|
||||||
|
self.budget = budget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warum **nicht** abgesenkt wird.
|
||||||
|
///
|
||||||
|
/// Als Aufzählung statt als `Bool`, weil genau das im Protokoll stehen
|
||||||
|
/// soll: „es hakt, aber wir tun nichts" ist ohne Grund von einem Fehler
|
||||||
|
/// nicht zu unterscheiden.
|
||||||
|
public enum Verdict: Sendable, Equatable {
|
||||||
|
case downgrade
|
||||||
|
/// Eine gewählte Stufe wird nicht überstimmt.
|
||||||
|
case notAutomatic
|
||||||
|
/// Der billige Hebel ist noch nicht ausgereizt.
|
||||||
|
case cheaperPathRemains
|
||||||
|
/// Es hakt nicht genug.
|
||||||
|
case notBadEnough
|
||||||
|
/// Zu früh nach Start oder Sprung.
|
||||||
|
case tooEarly
|
||||||
|
/// Sperrfrist läuft noch.
|
||||||
|
case cooling
|
||||||
|
/// Verbraucht. Ab hier gehört es dem Nutzer gesagt.
|
||||||
|
case budgetSpent
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func verdict(_ facts: Facts) -> Verdict {
|
||||||
|
guard facts.isAutomatic else { return .notAutomatic }
|
||||||
|
|
||||||
|
// Solange es noch eine Sprosse tiefer gibt, ist der billige Weg der
|
||||||
|
// richtige — er kostet kein schwarzes Bild.
|
||||||
|
if facts.hasLadder, !facts.isAtLowestRung { return .cheaperPathRemains }
|
||||||
|
|
||||||
|
let schlimmGenug = facts.stallsInWindow >= stallsNeeded
|
||||||
|
|| facts.longestStallInWindow >= longStall
|
||||||
|
guard schlimmGenug, facts.condition != .healthy else { return .notBadEnough }
|
||||||
|
|
||||||
|
guard facts.playedFor >= settleIn else { return .tooEarly }
|
||||||
|
if let seit = facts.sinceLastSeek, seit < afterSeek { return .tooEarly }
|
||||||
|
|
||||||
|
if let seit = facts.sinceLastDowngrade, seit < cooldown { return .cooling }
|
||||||
|
guard facts.used < facts.budget else { return .budgetSpent }
|
||||||
|
|
||||||
|
return .downgrade
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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