From 4323c47db62fb0a4edecab9a60079feb585347e5 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 29 Sep 2026 06:25:15 +0200 Subject: [PATCH] Schneller anfangen, dann hinaufgehen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Es dauerte eine halbe Minute bis zum ersten Bild. Der Grund war nicht die Leitung, sondern der Server: „Auto\" begann mit acht Megabit, und das heisst 1080p von der ersten Sekunde an. So lange braucht er, um das zu rechnen. Jetzt wird mit drei Megabit begonnen — also 720p. Das laeuft in wenigen Sekunden an, sieht einen Augenblick lang weicher aus, und von dort geht es hinauf, sobald sich zeigt, dass die Leitung mehr traegt. Dabei steckt eine Falle, die den ganzen Gewinn aufgezehrt haette: der Server baut seine Leiter aus der Bestellung. Wer mit drei Megabit anfaengt, bekommt eine Leiter, deren oberste Sprosse drei Megabit ist — und der billige Hebel bewegt sich nur **innerhalb** dieser Leiter. Aus dem schnellen Start waere ein Film geworden, der den ganzen Abend in 720p bleibt. Hoeher geht es deshalb ueber einen Neuaufbau, und der braucht Regeln: nicht zu frueh, nicht nach einem Ruckler, nicht ohne Vorrat, nicht oefter als zweimal. Sie stehen in `UpgradeRule` — reiner Typ, dreizehn Tests, wie beim Absenken. Und deutlich zurueckhaltender als dort: ein zu spaetes Absenken bedeutet Ruckeln, ein zu spaetes Hinaufschalten ein etwas weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage. Bei Live-TV wird nicht hinaufgegangen: dort kostet jeder Neuaufbau den Tuner, und ein schaerferes Bild ist diesen Einsatz nicht wert. `StallLedger` kann jetzt sagen, wann der letzte Ruckler war — das brauchte die Regel, und „noch nie gestockt\" ist etwas anderes als „vor langer Zeit\". Co-Authored-By: Claude Opus 5 --- Flixy/Views/Player/PlayerModel.swift | 103 ++++++++++++- .../FlixyPlayback/Decision/StallLedger.swift | 15 ++ .../FlixyPlayback/Decision/UpgradeRule.swift | 140 +++++++++++++++++ .../FlixyPlaybackTests/StallLedgerTests.swift | 26 ++++ .../FlixyPlaybackTests/UpgradeRuleTests.swift | 143 ++++++++++++++++++ 5 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 Packages/FlixyKit/Sources/FlixyPlayback/Decision/UpgradeRule.swift create mode 100644 Packages/FlixyKit/Tests/FlixyPlaybackTests/UpgradeRuleTests.swift diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 27607dc..e44259b 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -215,6 +215,9 @@ final class PlayerModel { /// Einmal gesetzt, bleibt er aus: ein zweiter Anlauf mit demselben /// Aufbau scheiterte am selben Grund. private var prefetchGaveUp = false + /// Wie oft in dieser Wiedergabe nach oben neu aufgebaut wurde. + private var upgradesUsed = 0 + private var lastUpgradeAt: Duration? /// Die Notbremse, die eine hängengebliebene Abdeckung wegnimmt. @ObservationIgnored private var resumeGiveUp: Task? /// Der Server, von dem der Player holt — und der Vorrat dahinter. @@ -327,10 +330,20 @@ final class PlayerModel { /// Unterscheidung ist nicht geraten — sie steht in `isLocalConnection`. private var startingCeiling: Int? { guard !isLocalConnection else { return nil } - // Acht Megabit tragen 1080p ordentlich und laufen auch über eine - // mässige Fernverbindung an. Wer mehr will und mehr hat, kommt über - // das Hinaufschalten dorthin. - return 8_000 + // **Drei Megabit, nicht acht.** + // + // Nicht wegen der Leitung — acht Megabit trägt fast jede. Wegen des + // **Servers**: er muss den Film erst rechnen, und wie lange das + // dauert, hängt vor allem an der Auflösung. Acht Megabit bedeuteten + // 1080p von der ersten Sekunde an, und darauf wartet man eine halbe + // Minute vor einem schwarzen Bild. + // + // Drei Megabit bedeuten 720p. Das läuft in wenigen Sekunden an, sieht + // einen Augenblick lang weicher aus — und von dort geht es hinauf, + // sobald sich zeigt, dass es trägt. Ein Film, der sofort anfängt und + // nach einer Minute schärfer wird, ist besser als einer, auf den man + // erst wartet. + return 3_000 } /// Die Auflösung, die zur aktuellen Stufe gehört. @@ -946,8 +959,10 @@ final class PlayerModel { noteMissingLadder(urteil) } - // Und dann die Frage, ob der teure Weg nötig ist. + // Und dann die beiden teuren Wege: hinunter, wenn es hakt — + // und hinauf, wenn es trägt und die Leiter ausgereizt ist. considerTheHardWay(urteil, statistics: statistics, at: now) + considerGoingHigher(urteil, statistics: statistics, at: now) } // MARK: - Die Sicherheitsleine @@ -989,6 +1004,84 @@ final class PlayerModel { } } + /// Prüft, ob sich ein Neuaufbau **nach oben** lohnt — und tut es. + /// + /// **Warum es den überhaupt braucht.** Damit ein Film zügig anläuft, + /// beginnt er mit einer niedrigen Stufe. Der Server baut seine Leiter + /// aber aus genau dieser Bestellung: wer mit drei Megabit anfängt, + /// bekommt eine Leiter, deren oberste Sprosse drei Megabit ist. Der + /// billige Hebel bewegt sich **innerhalb** dieser Leiter und käme nie + /// darüber hinaus — aus dem schnellen Start würde ein Film, der den + /// ganzen Abend in 720p bleibt. + /// + /// Höher geht es nur mit einem Neuaufbau. Die Bedingungen dafür stehen + /// in `UpgradeRule`, nicht hier. + private func considerGoingHigher( + _ condition: StreamCondition, + statistics: PlaybackStatistics, + at now: Duration + ) { + guard !hasGivenUp, !isChangingQuality, resumeTarget == nil else { return } + + let decke = quality.policy(basedOn: policy).maximumBitrate + let aktuell = automaticCeiling ?? startingCeiling + let facts = UpgradeRule.Facts( + isAutomatic: quality == .automatic, + condition: condition, + // Noch eine Sprosse in der **gelieferten** Leiter? Dann ist der + // billige Weg der richtige. + hasHigherRung: statistics.variantBitrates.contains { $0 > (aktuell ?? .max) }, + ceilingAllowsMore: aktuell.map { jetzt in decke.map { jetzt < $0 } ?? true } ?? false, + bufferedAhead: statistics.bufferedAhead, + playedFor: now, + sinceLastStall: stallLedger.lastStall(at: now).map { now - $0 }, + sinceLastSeek: lastSeekAt.map { now - $0 }, + sinceLastUpgrade: lastUpgradeAt.map { now - $0 }, + used: upgradesUsed, + budget: 2 + ) + + guard UpgradeRule.verdict(facts) == .rebuildHigher else { return } + Task { await upgradeTheHardWay(at: now) } + } + + /// Baut den Stream mit **grösserer** Bitrate neu auf. + private func upgradeTheHardWay(at now: Duration) async { + guard let ziel = nextHigherRung() else { return } + + automaticCeiling = ziel + upgradesUsed += 1 + lastUpgradeAt = now + diagnostics.note("Neuaufbau mit bis zu \(ziel) kbit — die Leitung trägt mehr") + + // Bei Live-TV nicht: dort kostet jeder Neuaufbau den Tuner, und ein + // schärferes Bild ist diesen Einsatz nicht wert. + guard !isLive else { return } + await restart() + } + + /// Die nächste Sprosse **oberhalb** der aktuellen — aus unserer Leiter, + /// gedeckelt von dem, was eingestellt ist. + private func nextHigherRung() -> Int? { + let aktuell = automaticCeiling ?? startingCeiling ?? 0 + let decke = quality.policy(basedOn: policy).maximumBitrate + + let sprossen = PlayerQuality.available(forSourceHeight: sourceHeight) + .compactMap { option -> Int? in + guard case .limited(let rung) = option else { return nil } + return rung.bitrate + } + .sorted() + + // Die nächste darüber, die die Einstellung noch zulässt. Gibt es + // keine mehr, ist die Decke erreicht — dann bleibt es dabei. + return sprossen.first { sprosse in + guard sprosse > aktuell else { return false } + guard let decke else { return true } + return sprosse <= decke + } + } + /// Baut den Stream mit kleinerer Bitrate neu auf. private func downgradeTheHardWay(at now: Duration) async { guard let ziel = nextLowerRung() else { diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift index 5489509..e2b0a20 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift @@ -85,6 +85,21 @@ public struct StallLedger: Sendable, Equatable { .max() ?? .zero } + /// Wann der letzte Rückler zu Ende war. + /// + /// `nil`, wenn es im Fenster keinen gab — und das ist etwas anderes als + /// „vor langer Zeit": wer noch nie gestockt hat, wartet auf keine Frist. + /// + /// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine + /// Reserve, und ein Neuaufbau nach oben wäre die zweite Unterbrechung + /// binnen einer Minute. + public func lastStall(at now: Duration) -> Duration? { + episodes + .filter { $0.endedAt >= now - window } + .map(\.endedAt) + .max() + } + /// Wirft heraus, was aus dem Fenster gefallen ist. public mutating func prune(at now: Duration) { let horizon = now - window diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/UpgradeRule.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/UpgradeRule.swift new file mode 100644 index 0000000..c9241df --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/UpgradeRule.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Wann **hinauf** gegangen wird — und wann der teure Weg dafür erlaubt ist. +/// +/// **Die Falle, um die es hier geht.** Damit ein Film zügig anläuft, wird mit +/// einer niedrigen Stufe begonnen. Der Server baut seine Leiter aber aus +/// genau dieser Bestellung: wer mit drei Megabit anfängt, bekommt eine +/// Leiter, deren oberste Sprosse drei Megabit ist. Das billige Hinaufschalten +/// über `preferredPeakBitRate` bewegt sich **innerhalb** dieser Leiter — es +/// käme also nie darüber hinaus, und aus dem schnellen Start würde ein Film, +/// der den ganzen Abend in 720p bleibt. +/// +/// Höher geht es nur mit einem Neuaufbau: neu aushandeln, mit einer grösseren +/// Grenze. Der kostet Sekunden mit schwarzem Bild — deshalb dieselbe Bauform +/// wie `DowngradeRule`: eine Liste von Bedingungen, die **alle** gelten +/// müssen, kein Schwellwert. +/// +/// **Und deutlich zurückhaltender als das Absenken.** Ein zu spätes Absenken +/// bedeutet Ruckeln; ein zu spätes Hinaufschalten bedeutet ein etwas +/// weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage. +public enum UpgradeRule { + /// Wie lange es ruhig laufen muss, bevor überhaupt gefragt wird. + /// + /// Lang genug, dass es kein anfängliches Aufatmen ist: der Puffer füllt + /// sich nach dem Start immer, und das sieht zwanzig Sekunden lang nach + /// einer hervorragenden Leitung aus. + public static let settleIn: Duration = .seconds(45) + /// Sperrfrist zwischen zwei Neuaufbauten nach oben. + public static let cooldown: Duration = .seconds(150) + /// Wie lange nach einem Rückler gar nicht hinaufgegangen wird. + /// + /// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau nach + /// oben wäre dann die zweite Unterbrechung binnen einer Minute. + public static let afterStall: Duration = .seconds(90) + /// Wie viel Vorrat dasein muss. + /// + /// Nicht „es läuft gerade", sondern „es liegt etwas auf der hohen Kante". + /// Nur das trägt einen Neuaufbau. + public static let bufferNeeded: Duration = .seconds(20) + + /// Was zum Zeitpunkt der Frage gilt. + public struct Facts: Sendable, Equatable { + /// Eine ausdrücklich gewählte Stufe ist eine Ansage und wird nicht + /// überstimmt — auch nicht nach oben. + public let isAutomatic: Bool + public let condition: StreamCondition + /// Ob die Leiter noch eine Sprosse nach oben hat. Solange ja, ist der + /// **billige** Weg der richtige. + public let hasHigherRung: Bool + /// Ob die eingestellte Obergrenze überhaupt mehr zuliesse. + public let ceilingAllowsMore: Bool + /// Wie viel Vorrat vor der Abspielstelle liegt. + public let bufferedAhead: Duration? + /// Wie lange schon gespielt wird. + public let playedFor: Duration + /// Wie lange der letzte Rückler her ist. `nil` heisst: keiner gewesen. + public let sinceLastStall: Duration? + /// Wie lange der letzte Sprung her ist. + public let sinceLastSeek: Duration? + /// Wie lange der letzte Neuaufbau nach oben her ist. + public let sinceLastUpgrade: 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, + condition: StreamCondition, + hasHigherRung: Bool, + ceilingAllowsMore: Bool, + bufferedAhead: Duration?, + playedFor: Duration, + sinceLastStall: Duration? = nil, + sinceLastSeek: Duration? = nil, + sinceLastUpgrade: Duration? = nil, + used: Int = 0, + budget: Int = 2 + ) { + self.isAutomatic = isAutomatic + self.condition = condition + self.hasHigherRung = hasHigherRung + self.ceilingAllowsMore = ceilingAllowsMore + self.bufferedAhead = bufferedAhead + self.playedFor = playedFor + self.sinceLastStall = sinceLastStall + self.sinceLastSeek = sinceLastSeek + self.sinceLastUpgrade = sinceLastUpgrade + self.used = used + self.budget = budget + } + } + + /// Warum **nicht** hinaufgegangen wird. + /// + /// Als Aufzählung statt als `Bool`, aus demselben Grund wie beim + /// Absenken: „es läuft gut und es passiert nichts" ist ohne Begründung + /// von einem Fehler nicht zu unterscheiden. + public enum Verdict: Sendable, Equatable { + case rebuildHigher + /// Eine gewählte Stufe wird nicht überstimmt. + case notAutomatic + /// Die Leiter hat noch eine Sprosse — der billige Weg genügt. + case cheaperPathRemains + /// Die Einstellung lässt nicht mehr zu. Kein Fehler, eine Ansage. + case atCeiling + /// Es läuft nicht ruhig genug. + case notCalmEnough + /// Zu früh nach Start, Sprung oder Rückler. + case tooEarly + /// Sperrfrist läuft noch. + case cooling + /// Verbraucht. + case budgetSpent + } + + public static func verdict(_ facts: Facts) -> Verdict { + guard facts.isAutomatic else { return .notAutomatic } + guard facts.ceilingAllowsMore else { return .atCeiling } + + // **Erst die Leiter ausreizen.** Solange es eine Sprosse höher gibt, + // kostet das Hinaufgehen kein schwarzes Bild — und wäre ein + // Neuaufbau dafür Verschwendung. + if facts.hasHigherRung { return .cheaperPathRemains } + + guard facts.condition == .healthy else { return .notCalmEnough } + guard let vorrat = facts.bufferedAhead, vorrat >= bufferNeeded else { + return .notCalmEnough + } + + guard facts.playedFor >= settleIn else { return .tooEarly } + if let seit = facts.sinceLastSeek, seit < DowngradeRule.afterSeek { return .tooEarly } + if let seit = facts.sinceLastStall, seit < afterStall { return .tooEarly } + + if let seit = facts.sinceLastUpgrade, seit < cooldown { return .cooling } + guard facts.used < facts.budget else { return .budgetSpent } + + return .rebuildHigher + } +} diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift index 7e80caa..ed05886 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift @@ -86,4 +86,30 @@ struct StallLedgerTests { ledger.record(.init(endedAt: .seconds(30), length: .seconds(200))) #expect(ledger.share(at: .seconds(60)) == 1) } + + /// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine + /// Reserve für einen Neuaufbau. + @Test("Der letzte Rückler lässt sich datieren") + func findsTheLastStall() { + var buch = StallLedger(window: .seconds(180)) + buch.record(.init(endedAt: .seconds(30), length: .seconds(2))) + buch.record(.init(endedAt: .seconds(90), length: .seconds(1))) + + #expect(buch.lastStall(at: .seconds(100)) == .seconds(90)) + } + + /// „Noch nie gestockt" ist etwas anderes als „vor langer Zeit" — wer + /// keinen hatte, wartet auf keine Frist. + @Test("Ohne Rückler gibt es kein Datum") + func noStallNoDate() { + let leer = StallLedger(window: .seconds(180)) + #expect(leer.lastStall(at: .seconds(100)) == nil) + } + + @Test("Was aus dem Fenster gefallen ist, zählt nicht mehr") + func forgetsOldStalls() { + var buch = StallLedger(window: .seconds(60)) + buch.record(.init(endedAt: .seconds(10), length: .seconds(2))) + #expect(buch.lastStall(at: .seconds(200)) == nil) + } } diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/UpgradeRuleTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/UpgradeRuleTests.swift new file mode 100644 index 0000000..d1ab496 --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/UpgradeRuleTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +@testable import FlixyPlayback + +@Suite("Wann hinaufgeschaltet wird") +struct UpgradeRuleTests { + + /// Die Lage, in der es erlaubt ist — alle übrigen Tests weichen davon in + /// genau einem Punkt ab. So steht in jedem Test nur der Unterschied. + private func ruhig( + isAutomatic: Bool = true, + condition: StreamCondition = .healthy, + hasHigherRung: Bool = false, + ceilingAllowsMore: Bool = true, + bufferedAhead: Duration? = .seconds(30), + playedFor: Duration = .seconds(120), + sinceLastStall: Duration? = nil, + sinceLastSeek: Duration? = nil, + sinceLastUpgrade: Duration? = nil, + used: Int = 0, + budget: Int = 2 + ) -> UpgradeRule.Facts { + .init( + isAutomatic: isAutomatic, + condition: condition, + hasHigherRung: hasHigherRung, + ceilingAllowsMore: ceilingAllowsMore, + bufferedAhead: bufferedAhead, + playedFor: playedFor, + sinceLastStall: sinceLastStall, + sinceLastSeek: sinceLastSeek, + sinceLastUpgrade: sinceLastUpgrade, + used: used, + budget: budget + ) + } + + @Test("Läuft es ruhig und ist die Leiter ausgereizt, wird neu aufgebaut") + func calmAndCappedRebuilds() { + #expect(UpgradeRule.verdict(ruhig()) == .rebuildHigher) + } + + // MARK: - Was es verbietet + + /// Wer „720p · 3 Mbps" antippt, meint das — auch wenn die Leitung mehr + /// trüge. + @Test("Eine gewählte Stufe wird nicht überstimmt") + func neverOverridesAChoice() { + #expect(UpgradeRule.verdict(ruhig(isAutomatic: false)) == .notAutomatic) + } + + /// **Der wichtigste Fall.** Solange die Leiter noch eine Sprosse hat, + /// kostet das Hinaufgehen kein schwarzes Bild — ein Neuaufbau dafür wäre + /// Verschwendung. + @Test("Solange die Leiter trägt, kein Neuaufbau") + func prefersTheCheapPath() { + #expect(UpgradeRule.verdict(ruhig(hasHigherRung: true)) == .cheaperPathRemains) + } + + @Test("Über die eingestellte Grenze geht es nicht") + func respectsTheSetting() { + #expect(UpgradeRule.verdict(ruhig(ceilingAllowsMore: false)) == .atCeiling) + } + + @Test("Bei angespannter Lage wird nicht hinaufgegangen") + func notWhenStrained() { + #expect(UpgradeRule.verdict(ruhig(condition: .strained)) == .notCalmEnough) + #expect(UpgradeRule.verdict(ruhig(condition: .failing)) == .notCalmEnough) + } + + /// „Es läuft gerade" genügt nicht — es muss etwas auf der hohen Kante + /// liegen, sonst trägt es den Neuaufbau nicht. + @Test("Ohne Vorrat kein Neuaufbau") + func needsABuffer() { + #expect(UpgradeRule.verdict(ruhig(bufferedAhead: .seconds(5))) == .notCalmEnough) + #expect(UpgradeRule.verdict(ruhig(bufferedAhead: nil)) == .notCalmEnough) + } + + /// Der Puffer füllt sich nach dem Start immer, und das sieht eine halbe + /// Minute lang nach einer hervorragenden Leitung aus. + @Test("Am Anfang nicht, auch wenn alles gut aussieht") + func notRightAfterStart() { + #expect(UpgradeRule.verdict(ruhig(playedFor: .seconds(20))) == .tooEarly) + } + + @Test("Nach einem Sprung nicht") + func notRightAfterSeek() { + #expect(UpgradeRule.verdict(ruhig(sinceLastSeek: .seconds(3))) == .tooEarly) + } + + /// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau wäre + /// die zweite Unterbrechung binnen einer Minute. + @Test("Nach einem Rückler lange nicht") + func waitsAfterAStall() { + #expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(30))) == .tooEarly) + #expect(UpgradeRule.verdict(ruhig(sinceLastStall: .seconds(120))) == .rebuildHigher) + } + + @Test("Die Sperrfrist gilt") + func respectsTheCooldown() { + #expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(60))) == .cooling) + #expect(UpgradeRule.verdict(ruhig(sinceLastUpgrade: .seconds(200))) == .rebuildHigher) + } + + @Test("Das Budget ist endlich") + func budgetIsFinite() { + #expect(UpgradeRule.verdict(ruhig(used: 2, budget: 2)) == .budgetSpent) + #expect(UpgradeRule.verdict(ruhig(used: 1, budget: 2)) == .rebuildHigher) + } + + // MARK: - Die Reihenfolge der Gründe + + /// Bei mehreren Gründen soll der **grundsätzlichste** gemeldet werden — + /// sonst steht im Protokoll „Sperrfrist", wo in Wahrheit die Einstellung + /// das Ende ist, und jemand wartet auf etwas, das nie kommt. + @Test("Der grundsätzlichste Grund gewinnt") + func reportsTheMostFundamentalReason() { + #expect( + UpgradeRule.verdict(ruhig( + isAutomatic: false, hasHigherRung: true, ceilingAllowsMore: false + )) == .notAutomatic + ) + #expect( + UpgradeRule.verdict(ruhig( + hasHigherRung: true, ceilingAllowsMore: false + )) == .atCeiling + ) + #expect( + UpgradeRule.verdict(ruhig( + condition: .failing, hasHigherRung: true + )) == .cheaperPathRemains + ) + } + + /// Das Hinaufschalten muss zurückhaltender sein als das Absenken: ein zu + /// spätes Absenken bedeutet Ruckeln, ein zu spätes Hinaufschalten ein + /// etwas weicheres Bild. + @Test("Zurückhaltender als das Absenken") + func moreCautiousThanDowngrading() { + #expect(UpgradeRule.settleIn > DowngradeRule.settleIn) + #expect(UpgradeRule.cooldown < DowngradeRule.cooldown) + } +}