Phase 5: Hardware- und Netzwerkmonitor, Panel und Menüleiste

Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben
sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten.

Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife
über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit
denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets
ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die
Schleife auf; im Ruhezustand misst Onyx nichts.

Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler
gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen
zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte
4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach
470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die
Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte.
Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s".

Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und
laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`.

Sensoren kommen über den SMC-Leser aus Spike A statt über die private
IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die
dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste
benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine
Information.

Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit
kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei
Lüfter, en0 mit 661 KB/s.

Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die
Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je
Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her.

Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären
eine Zumutung.

145 Tests grün.
This commit is contained in:
Guido Schmit
2026-08-10 21:48:47 +02:00
parent a198d88104
commit 594d7c422a
21 changed files with 2897 additions and 350 deletions

View File

@@ -0,0 +1,180 @@
import Testing
import Foundation
@testable import MetricsProvider
// Alle Messwerte hier sind Differenzen zwischen zwei Abtastungen. Das klingt
// harmlos und ist die Stelle, an der Systemmonitore falsche Zahlen anzeigen:
// die Zähler sind 32 Bit breit und laufen über, Intervalle sind nie exakt
// gleich lang, und beim Interface-Wechsel fangen sie wieder bei null an.
@Suite("CPU-Auslastung")
struct CPUUsageTests {
@Test("Halb beschäftigt, halb untätig ergibt 50 Prozent")
func halfBusy() {
let before = CPUTicks(user: 100, system: 0, idle: 100, nice: 0)
let after = CPUTicks(user: 200, system: 0, idle: 200, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 0.5)
}
@Test("Nur Leerlauf ergibt null")
func onlyIdle() {
let before = CPUTicks(user: 0, system: 0, idle: 0, nice: 0)
let after = CPUTicks(user: 0, system: 0, idle: 100, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 0)
}
@Test("Kein Leerlauf ergibt volle Auslastung")
func fullyBusy() {
let before = CPUTicks(user: 0, system: 0, idle: 50, nice: 0)
let after = CPUTicks(user: 100, system: 0, idle: 50, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 1)
}
@Test("System- und Nice-Zeit zählen als Beschäftigung mit")
func systemAndNiceCount() {
let before = CPUTicks(user: 0, system: 0, idle: 0, nice: 0)
let after = CPUTicks(user: 25, system: 25, idle: 50, nice: 0)
#expect(CPUTicks.usage(from: before, to: after) == 0.5)
}
@Test("Zwei identische Abtastungen ergeben null statt einer Division durch null")
func identicalSamples() {
let ticks = CPUTicks(user: 10, system: 10, idle: 10, nice: 10)
#expect(CPUTicks.usage(from: ticks, to: ticks) == 0)
}
@Test("Ein Zählerüberlauf ergibt keinen Ausreißer")
func counterWraparound() {
// 32-Bit-Zähler laufen nach gut 400 Tagen bei 100 Hz über. Rechnet man
// naiv, springt die Anzeige einmalig auf einen absurden Wert.
let before = CPUTicks(user: .max - 10, system: 0, idle: .max - 10, nice: 0)
let after = CPUTicks(user: 10, system: 0, idle: 10, nice: 0)
let usage = CPUUsageRange.contains(CPUTicks.usage(from: before, to: after))
#expect(usage)
}
@Test("Das Ergebnis liegt immer zwischen 0 und 1")
func alwaysNormalised() {
for _ in 0..<50 {
let before = CPUTicks(user: .random(in: 0...100_000), system: .random(in: 0...100_000),
idle: .random(in: 0...100_000), nice: .random(in: 0...100_000))
let after = CPUTicks(user: .random(in: 0...100_000), system: .random(in: 0...100_000),
idle: .random(in: 0...100_000), nice: .random(in: 0...100_000))
#expect(CPUUsageRange.contains(CPUTicks.usage(from: before, to: after)))
}
}
}
private let CPUUsageRange = 0.0...1.0
@Suite("Speicherdruck")
struct MemoryPressureTests {
@Test("Viel frei bedeutet grüner Bereich")
func plentyFree() {
#expect(MemoryPressure.classify(free: 0.5, compressed: 0.05, swapUsed: 0) == .normal)
}
@Test("Wenig frei und viel komprimiert bedeutet Warnbereich")
func compressedMeansWarning() {
#expect(MemoryPressure.classify(free: 0.08, compressed: 0.25, swapUsed: 0) == .warning)
}
@Test("Aktive Auslagerung ist immer kritisch, egal wie viel frei scheint")
func swapIsAlwaysCritical() {
// Sobald ausgelagert wird, bremst der Rechner spürbar auch wenn die
// Freispeicheranzeige noch harmlos aussieht.
#expect(MemoryPressure.classify(free: 0.4, compressed: 0.01,
swapUsed: 2 * 1024 * 1024 * 1024) == .critical)
}
@Test("Die Grenzwerte springen nicht bei winzigen Änderungen hin und her")
func classificationIsStable() {
let a = MemoryPressure.classify(free: 0.30, compressed: 0.10, swapUsed: 0)
let b = MemoryPressure.classify(free: 0.301, compressed: 0.101, swapUsed: 0)
#expect(a == b)
}
}
@Suite("Durchsatz")
struct ThroughputTests {
@Test("Tausend Byte in einer Sekunde sind tausend Byte pro Sekunde")
func simpleRate() {
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: 1) == 1000)
}
@Test("Die Rate hängt nicht von der Abtastrate ab")
func rateIsIndependentOfInterval() {
// Der eigentliche Zweck: das Menüleisten-Modul tastet je nach
// Einstellung alle 1 bis 10 Sekunden ab. Ohne Normierung zeigte es bei
// 5 Sekunden das Fünffache an.
let oneSecond = Throughput.rate(previous: 0, current: 1000, elapsed: 1)
let fiveSeconds = Throughput.rate(previous: 0, current: 5000, elapsed: 5)
#expect(oneSecond == fiveSeconds)
}
@Test("Ein Zählerüberlauf ergibt keinen Ausreißer")
func wraparoundIsHandled() {
let rate = Throughput.rate(previous: UInt64(UInt32.max) - 100, current: 100, elapsed: 1)
#expect(rate >= 0)
#expect(rate < 1_000_000)
}
@Test("Ein zurückgesetzter Zähler ergibt null statt eines Sprungs")
func counterResetGivesZero() {
// Beim Interface-Wechsel WLAN aus, VPN an fangen die Zähler wieder
// bei null an. Ein naiver Vergleich zeigte dann einen riesigen Wert.
#expect(Throughput.rate(previous: 5_000_000, current: 1000, elapsed: 1) == 0)
}
@Test("Kein vergangenes Intervall ergibt null statt einer Division durch null")
func zeroElapsed() {
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: 0) == 0)
}
@Test("Ein negatives Intervall wird verworfen")
func negativeElapsed() {
// Kann bei einer Zeitumstellung vorkommen.
#expect(Throughput.rate(previous: 0, current: 1000, elapsed: -5) == 0)
}
}
@Suite("Zahlenformat")
struct FormattingTests {
@Test("Byteraten wechseln die Einheit statt fünfstellig zu werden")
func rateUsesSensibleUnits() {
#expect(Throughput.formatted(0).contains("0"))
#expect(Throughput.formatted(2_400_000).contains("MB"))
#expect(Throughput.formatted(42_000).contains("KB"))
}
@Test("Die Rate trägt immer die Zeiteinheit — sonst liest man sie als Menge")
func rateAlwaysCarriesPerSecond() {
for value in [0.0, 500, 42_000, 2_400_000, 900_000_000] {
#expect(Throughput.formatted(value).hasSuffix("/s"))
}
}
@Test("Prozentwerte sind ganzzahlig")
func percentIsWhole() {
// Nachkommastellen bei der CPU-Auslastung täuschen eine Genauigkeit
// vor, die die Messung nicht hat und sie zappeln im Sekundentakt.
#expect(MetricFormat.percent(0.4567) == "46 %")
#expect(MetricFormat.percent(0) == "0 %")
#expect(MetricFormat.percent(1) == "100 %")
}
@Test("Temperaturen bekommen ein Gradzeichen und keine Nachkommastelle")
func temperatureFormat() {
#expect(MetricFormat.temperature(46.7) == "47°")
}
}