Der Akku bekommt ein Gedächtnis
Verlauf über 24 Stunden, sieben Tage und dreißig Tage, aufgezeichnet alle fünf Minuten. Als Säulen und nicht als Linie: eine Linie verbindet auch dort, wo nichts gemessen wurde, und ein zugeklappter Rechner hatte keinen Ladestand — eine Linie darüber behauptet einen. Dazu Segelmodus und Hitzeschutz als reine Regeln im Helfer. Beide haben zwei Schwellen, eine zum Anhalten und eine zum Weitermachen. Ohne diesen Abstand hinge der Akku am Limit und würde bei jedem verlorenen Prozent nachgeladen — hunderte winzige Ladevorgänge am Tag, und genau die altern ihn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
edb327cabe
commit
f289d02c37
@@ -0,0 +1,92 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import MetricsProvider
|
||||
|
||||
@Suite("Akkuverlauf")
|
||||
struct BatteryHistoryTests {
|
||||
|
||||
private func sample(_ seconds: TimeInterval, charge: Double) -> BatterySample {
|
||||
BatterySample(at: Date(timeIntervalSince1970: seconds), charge: charge,
|
||||
celsius: nil, watts: nil)
|
||||
}
|
||||
|
||||
private let now = Date(timeIntervalSince1970: 100 * 86_400)
|
||||
|
||||
@Test("Ohne vorherige Messung wird sofort aufgezeichnet")
|
||||
func firstSampleIsRecorded() {
|
||||
#expect(BatteryHistoryRules.shouldRecord(last: nil, now: now))
|
||||
}
|
||||
|
||||
@Test("Vor Ablauf des Abstands nicht noch einmal")
|
||||
func waitsForTheInterval() {
|
||||
let recent = now.addingTimeInterval(-60)
|
||||
#expect(!BatteryHistoryRules.shouldRecord(last: recent, now: now))
|
||||
}
|
||||
|
||||
@Test("Nach dem Abstand wieder")
|
||||
func recordsAfterTheInterval() {
|
||||
let old = now.addingTimeInterval(-BatteryHistoryRules.interval)
|
||||
#expect(BatteryHistoryRules.shouldRecord(last: old, now: now))
|
||||
}
|
||||
|
||||
@Test("Zu alte Messpunkte fallen heraus")
|
||||
func oldSamplesArePruned() {
|
||||
let samples = [
|
||||
sample(now.timeIntervalSince1970 - 40 * 86_400, charge: 0.5),
|
||||
sample(now.timeIntervalSince1970 - 1 * 86_400, charge: 0.6),
|
||||
]
|
||||
let kept = BatteryHistoryRules.pruned(samples, now: now)
|
||||
#expect(kept.count == 1)
|
||||
#expect(kept.first?.charge == 0.6)
|
||||
}
|
||||
|
||||
@Test("Die Säulen bekommen den Mittelwert ihres Abschnitts")
|
||||
func bucketsAverage() {
|
||||
// Zwei Messungen in derselben halben Stunde ergeben eine Säule mit
|
||||
// ihrem Mittel — nicht die erste und nicht die letzte.
|
||||
let base = now.timeIntervalSince1970 - 600
|
||||
let samples = [sample(base, charge: 0.4), sample(base + 60, charge: 0.6)]
|
||||
let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now)
|
||||
let filled = buckets.compactMap { $0 }
|
||||
#expect(filled.count == 1)
|
||||
#expect(abs(filled[0] - 0.5) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Abschnitte ohne Messung bleiben leer")
|
||||
func gapsStayEmpty() {
|
||||
// Ein zugeklappter Rechner hat dort keinen Ladestand gehabt. Eine
|
||||
// durchgezogene Linie darüber wäre erfunden.
|
||||
let samples = [sample(now.timeIntervalSince1970 - 600, charge: 0.5)]
|
||||
let buckets = BatteryHistoryRules.buckets(samples, window: .day, now: now)
|
||||
#expect(buckets.count == HistoryWindow.day.buckets)
|
||||
#expect(buckets.filter { $0 == nil }.count == HistoryWindow.day.buckets - 1)
|
||||
}
|
||||
|
||||
@Test("Messpunkte außerhalb des Fensters zählen nicht mit")
|
||||
func outOfWindowIsIgnored() {
|
||||
let samples = [sample(now.timeIntervalSince1970 - 10 * 86_400, charge: 0.9)]
|
||||
#expect(BatteryHistoryRules.buckets(samples, window: .day, now: now).allSatisfy { $0 == nil })
|
||||
#expect(BatteryHistoryRules.summary(samples, window: .day, now: now) == nil)
|
||||
}
|
||||
|
||||
@Test("Die Zusammenfassung nennt Tief, Hoch und Mittel")
|
||||
func summaryReportsRange() {
|
||||
let base = now.timeIntervalSince1970 - 3600
|
||||
let samples = [sample(base, charge: 0.2), sample(base + 60, charge: 0.8)]
|
||||
let summary = BatteryHistoryRules.summary(samples, window: .day, now: now)
|
||||
#expect(summary?.low == 0.2)
|
||||
#expect(summary?.high == 0.8)
|
||||
#expect(abs((summary?.average ?? 0) - 0.5) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Jedes Fenster hat weniger Säulen als Messpunkte")
|
||||
func bucketsAreFewerThanSamples() {
|
||||
// Über einen Monat wären es bei fünf Minuten Abstand über achttausend
|
||||
// Punkte — mehr Punkte als Bildpunkte zu zeichnen kostet Zeit, ohne
|
||||
// etwas zu zeigen.
|
||||
for window in HistoryWindow.allCases {
|
||||
let possible = Int(window.duration / BatteryHistoryRules.interval)
|
||||
#expect(window.buckets < possible)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Testing
|
||||
@testable import OnyxHelperProtocol
|
||||
|
||||
@Suite("Segelmodus und Hitzeschutz")
|
||||
struct ChargePolicyTests {
|
||||
|
||||
// MARK: - Segeln
|
||||
|
||||
@Test("Unter dem Limit wird geladen")
|
||||
func chargesBelowLimit() {
|
||||
#expect(ChargePolicy.shouldCharge(charge: 0.5, limit: 80, celsius: nil,
|
||||
wasCharging: true))
|
||||
}
|
||||
|
||||
@Test("Am Limit hört es auf")
|
||||
func stopsAtLimit() {
|
||||
#expect(!ChargePolicy.shouldCharge(charge: 0.80, limit: 80, celsius: nil,
|
||||
wasCharging: true))
|
||||
}
|
||||
|
||||
@Test("Nach dem Anhalten wird nicht sofort wieder geladen")
|
||||
func doesNotResumeImmediately() {
|
||||
// Der Kern des Segelns: ohne Abstand hinge der Akku am Limit und würde
|
||||
// bei jedem verlorenen Prozent nachgeladen — hunderte winzige
|
||||
// Ladevorgänge am Tag, und genau die altern ihn.
|
||||
#expect(!ChargePolicy.shouldCharge(charge: 0.79, limit: 80, celsius: nil,
|
||||
wasCharging: false))
|
||||
#expect(!ChargePolicy.shouldCharge(charge: 0.76, limit: 80, celsius: nil,
|
||||
wasCharging: false))
|
||||
}
|
||||
|
||||
@Test("Erst unter dem Abstand geht es wieder los")
|
||||
func resumesBelowHysteresis() {
|
||||
#expect(ChargePolicy.shouldCharge(charge: 0.75, limit: 80, celsius: nil,
|
||||
wasCharging: false))
|
||||
}
|
||||
|
||||
@Test("Ohne Limit gilt nur der Hitzeschutz")
|
||||
func noLimitMeansAlwaysCharge() {
|
||||
#expect(ChargePolicy.shouldCharge(charge: 0.99, limit: 100, celsius: nil,
|
||||
wasCharging: false))
|
||||
}
|
||||
|
||||
// MARK: - Hitze
|
||||
|
||||
@Test("Zu heiß heißt: nicht laden")
|
||||
func heatStopsCharging() {
|
||||
#expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 41,
|
||||
wasCharging: true))
|
||||
}
|
||||
|
||||
@Test("Nach dem Abkühlen nicht gleich an der Schwelle wieder los")
|
||||
func heatHasHysteresisToo() {
|
||||
// Genau an der Schwelle schaltete es sonst im Sekundentakt an und aus.
|
||||
#expect(!ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 37,
|
||||
wasCharging: false))
|
||||
#expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: 35,
|
||||
wasCharging: false))
|
||||
}
|
||||
|
||||
@Test("Ohne Temperaturmessung kein Hitzeschutz")
|
||||
func unknownTemperatureIsNotAnExcuse() {
|
||||
// Eine Temperatur zu erfinden wäre schlimmer als keine zu haben.
|
||||
#expect(ChargePolicy.shouldCharge(charge: 0.3, limit: 100, celsius: nil,
|
||||
wasCharging: true))
|
||||
}
|
||||
|
||||
// MARK: - Der Grund, den die Anzeige nennt
|
||||
|
||||
@Test("Der Grund unterscheidet Limit, Segeln und Hitze")
|
||||
func reasonsAreDistinct() {
|
||||
#expect(ChargePolicy.reason(charge: 0.5, limit: 80, celsius: nil,
|
||||
wasCharging: true) == .charging)
|
||||
#expect(ChargePolicy.reason(charge: 0.80, limit: 80, celsius: nil,
|
||||
wasCharging: true) == .atLimit(80))
|
||||
#expect(ChargePolicy.reason(charge: 0.78, limit: 80, celsius: nil,
|
||||
wasCharging: false) == .sailing(80))
|
||||
#expect(ChargePolicy.reason(charge: 0.3, limit: 100, celsius: 42,
|
||||
wasCharging: true) == .tooHot(42))
|
||||
}
|
||||
|
||||
@Test("Hitze schlägt jeden anderen Grund")
|
||||
func heatWinsOverLimit() {
|
||||
// Wer bei 42 Grad „am Limit" liest, sucht den Fehler an der falschen
|
||||
// Stelle.
|
||||
#expect(ChargePolicy.reason(charge: 0.95, limit: 80, celsius: 42,
|
||||
wasCharging: true) == .tooHot(42))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user