Bezugsgröße war die volle Höhe des Akkus. Der Rahmen nimmt aber oben und unten je einen Punkt, die Füllung sitzt zwei Punkte eingerückt, und um das Zeichen kommt noch sein eigener Rand — so füllte der Blitz mitsamt Rand elf von zwölf Punkten und stieß an den Rahmen. Jetzt ist der Innenraum die Bezugsgröße: sechs Punkte Zeichen plus zwei Rand in acht Punkten Innenraum. Ein Test hält fest, dass jedes Zeichen samt Rand hineinpasst — und dass es nicht ins andere Extrem fällt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
9.6 KiB
Swift
257 lines
9.6 KiB
Swift
import Testing
|
|
import CoreGraphics
|
|
import AppKit
|
|
@testable import MetricsProvider
|
|
|
|
// SF Symbols kennt fünf Füllstufen und keine Blitz-Varianten außer bei 100 %.
|
|
// Beides passt nicht zu dem, was macOS selbst zeigt: eine stufenlose Füllung
|
|
// und einen Blitz **im** Akku. Also selbst gezeichnet.
|
|
|
|
@Suite("Akkuglyph")
|
|
struct BatteryGlyphTests {
|
|
|
|
private let inner: CGFloat = 20
|
|
|
|
@Test("Die Füllung folgt dem Ladestand stufenlos")
|
|
func fillIsProportional() {
|
|
// Der eigentliche Gewinn gegenüber fünf festen Stufen: 60 % sieht aus
|
|
// wie 60 % und nicht wie 50 %.
|
|
#expect(BatteryGlyph.fillWidth(charge: 0.5, inner: inner) == 10)
|
|
#expect(BatteryGlyph.fillWidth(charge: 1.0, inner: inner) == 20)
|
|
}
|
|
|
|
@Test("Ein leerer Akku bleibt leer")
|
|
func emptyStaysEmpty() {
|
|
#expect(BatteryGlyph.fillWidth(charge: 0, inner: inner) == 0)
|
|
}
|
|
|
|
@Test("Ein fast leerer Akku ist trotzdem zu sehen")
|
|
func nearlyEmptyIsStillVisible() {
|
|
// Zwei Prozent ergäben 0,4 Punkte — unsichtbar, und damit
|
|
// ununterscheidbar von „leer". Ein Rest bleibt stehen.
|
|
let width = BatteryGlyph.fillWidth(charge: 0.02, inner: inner)
|
|
#expect(width >= BatteryGlyph.minimumFill)
|
|
}
|
|
|
|
@Test("Über hundert Prozent läuft nichts über")
|
|
func neverOverflows() {
|
|
#expect(BatteryGlyph.fillWidth(charge: 2, inner: inner) == inner)
|
|
#expect(BatteryGlyph.fillWidth(charge: -1, inner: inner) == 0)
|
|
}
|
|
|
|
@Test("Wenig Ladung gilt als kritisch")
|
|
func lowIsFlagged() {
|
|
// Dieselbe Schwelle wie in macOS: ab zwanzig Prozent abwärts wird die
|
|
// Farbe zum Signal.
|
|
#expect(BatteryGlyph.isLow(charge: 0.19))
|
|
#expect(!BatteryGlyph.isLow(charge: 0.21))
|
|
}
|
|
|
|
@Test("Beim Laden gilt nichts als kritisch")
|
|
func chargingIsNeverLow() {
|
|
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
|
|
// sich schon erledigt.
|
|
#expect(!BatteryGlyph.isLow(charge: 0.05, isCharging: true))
|
|
}
|
|
|
|
@Test("Das Glyph hat die Maße der Menüleiste")
|
|
func sizeFitsMenuBar() {
|
|
// Breiter als hoch, wie ein Akku aussieht — und niedrig genug, dass es
|
|
// in die 22 Punkte der Leiste passt.
|
|
#expect(BatteryGlyph.size.width > BatteryGlyph.size.height)
|
|
#expect(BatteryGlyph.size.height <= 14)
|
|
}
|
|
}
|
|
|
|
// Am Netz, aber es tut sich nichts — weil das Ladelimit erreicht ist. Ohne
|
|
// eigenes Zeichen sieht das genauso aus wie Akkubetrieb, und man fragt sich,
|
|
// ob das Kabel wirklich steckt.
|
|
|
|
@Suite("Ladezustand im Glyph")
|
|
struct BatteryIndicatorTests {
|
|
|
|
@Test("Beim Laden der Blitz")
|
|
func chargingShowsBolt() {
|
|
#expect(BatteryGlyph.indicator(isCharging: true, isPluggedIn: true) == .bolt)
|
|
}
|
|
|
|
@Test("Am Netz ohne Laden das Pausenzeichen")
|
|
func pluggedButIdleShowsPause() {
|
|
// Der Fall, um den es geht: Ladelimit erreicht. „Laden angehalten" ist
|
|
// die Aussage — ein Stecker sagte nur „Kabel steckt", und das sieht man
|
|
// am Kabel.
|
|
#expect(BatteryGlyph.indicator(isCharging: false, isPluggedIn: true) == .paused)
|
|
}
|
|
|
|
@Test("Im Akkubetrieb gar nichts")
|
|
func onBatteryShowsNothing() {
|
|
#expect(BatteryGlyph.indicator(isCharging: false, isPluggedIn: false) == .none)
|
|
}
|
|
|
|
@Test("Laden schlägt alles andere")
|
|
func chargingWins() {
|
|
// Sollte der Netzzustand einmal widersprüchlich gemeldet werden, ist
|
|
// „lädt" die Auskunft, die zählt.
|
|
#expect(BatteryGlyph.indicator(isCharging: true, isPluggedIn: false) == .bolt)
|
|
}
|
|
|
|
@Test("Jedes Zeichen gibt es auch wirklich")
|
|
func everyIndicatorResolves() {
|
|
for indicator in BatteryGlyph.Indicator.allCases {
|
|
guard let name = indicator.symbolName else { continue }
|
|
#expect(NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil,
|
|
"\(name) gibt es nicht")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Der Blitz ist ein Loch in der Füllung. Über dem gefüllten Teil liest man ihn,
|
|
// über dem leeren ist es Transparenz auf Transparenz — bei wenig Ladung sitzt
|
|
// er fast ganz im Leeren und verschwindet.
|
|
//
|
|
// Also geteilt zeichnen: über der Füllung ausgestanzt, daneben ausgemalt.
|
|
|
|
@Suite("Blitz über Füllung und Leerraum")
|
|
struct BatteryBoltSplitTests {
|
|
|
|
private let inner = CGRect(x: 10, y: 0, width: 20, height: 8)
|
|
|
|
@Test("Bei voller Ladung liegt alles auf der Füllung")
|
|
func fullIsAllFilled() {
|
|
let split = BatteryGlyph.boltSplit(charge: 1, inner: inner)
|
|
#expect(split.filled == inner)
|
|
#expect(split.empty.width == 0)
|
|
}
|
|
|
|
@Test("Bei leerem Akku liegt alles im Leerraum")
|
|
func emptyIsAllEmpty() {
|
|
let split = BatteryGlyph.boltSplit(charge: 0, inner: inner)
|
|
#expect(split.filled.width == 0)
|
|
#expect(split.empty == inner)
|
|
}
|
|
|
|
@Test("Dazwischen teilt sich der Blitz an der Kante der Füllung")
|
|
func splitFollowsFill() {
|
|
let split = BatteryGlyph.boltSplit(charge: 0.5, inner: inner)
|
|
#expect(split.filled.maxX == split.empty.minX)
|
|
#expect(split.filled.width + split.empty.width == inner.width)
|
|
}
|
|
|
|
@Test("Die beiden Teile stoßen aneinander, statt sich zu überlappen")
|
|
func partsMeetWithoutOverlap() {
|
|
// Sonst wird der Blitz an der Nahtstelle doppelt gezeichnet und sieht
|
|
// dort dicker aus. Geprüft wird die Kante selbst und nicht
|
|
// `intersects`: das meldet bei einem Teil der Breite null trotzdem
|
|
// eine Überschneidung.
|
|
for percent in stride(from: 0.0, through: 1.0, by: 0.05) {
|
|
let split = BatteryGlyph.boltSplit(charge: percent, inner: inner)
|
|
#expect(split.filled.maxX <= split.empty.minX + 0.001)
|
|
}
|
|
}
|
|
|
|
@Test("Zusammen decken sie den ganzen Innenraum")
|
|
func partsCoverEverything() {
|
|
for percent in stride(from: 0.0, through: 1.0, by: 0.05) {
|
|
let split = BatteryGlyph.boltSplit(charge: percent, inner: inner)
|
|
#expect(abs(split.filled.width + split.empty.width - inner.width) < 0.001)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suite("Blitz beim Laden")
|
|
struct BatteryBoltFadeTests {
|
|
|
|
@Test("Die Kurve läuft von Weiß nach Schwarz und zurück")
|
|
func fadeIsAPingPong() {
|
|
#expect(BatteryGlyph.fadeAmount(phase: 0) == 0)
|
|
#expect(BatteryGlyph.fadeAmount(phase: 0.5) == 1)
|
|
#expect(abs(BatteryGlyph.fadeAmount(phase: 1) - 0) < 0.0001)
|
|
}
|
|
|
|
@Test("Sie ist an den Umkehrpunkten weich")
|
|
func endsAreSmooth() {
|
|
// Ein linearer Ping-Pong knickt an den Enden sichtbar. Der Kosinus
|
|
// wird dort flach — das Faden wirkt dadurch ruhig statt gehetzt.
|
|
let nearStart = BatteryGlyph.fadeAmount(phase: 0.02)
|
|
let nearMiddle = BatteryGlyph.fadeAmount(phase: 0.5)
|
|
- BatteryGlyph.fadeAmount(phase: 0.48)
|
|
#expect(nearStart < nearMiddle)
|
|
}
|
|
|
|
@Test("Die Phase läuft rundherum weiter")
|
|
func phaseWrapsAround() {
|
|
// Sonst gäbe es nach einer Umdrehung einen Sprung.
|
|
#expect(abs(BatteryGlyph.fadeAmount(phase: 1.25)
|
|
- BatteryGlyph.fadeAmount(phase: 0.25)) < 0.0001)
|
|
}
|
|
|
|
@Test("Die Enden sind wirklich Schwarz und Weiß")
|
|
func extremesAreBlackAndWhite() {
|
|
// Genau darum geht es: bei wenig Ladung sitzt der Blitz im leeren Teil
|
|
// und braucht Weiß, über der Füllung braucht er Schwarz. Ein Faden
|
|
// dazwischen ist in beiden Fällen irgendwann lesbar.
|
|
#expect(BatteryGlyph.boltWhiteness(phase: 0) == 1)
|
|
#expect(BatteryGlyph.boltWhiteness(phase: 0.5) == 0)
|
|
}
|
|
}
|
|
|
|
@Suite("Umrandetes Zeichen")
|
|
struct BatteryOutlineTests {
|
|
|
|
private let glyph = CGRect(x: 10, y: 4, width: 8, height: 12)
|
|
|
|
@Test("Der Rand liegt außen herum und bleibt mittig")
|
|
func outlineSurroundsAndCentres() {
|
|
let outline = BatteryGlyph.outlineRect(glyph)
|
|
#expect(outline.minX < glyph.minX)
|
|
#expect(outline.maxX > glyph.maxX)
|
|
#expect(abs(outline.midX - glyph.midX) < 0.001)
|
|
#expect(abs(outline.midY - glyph.midY) < 0.001)
|
|
}
|
|
|
|
@Test("Er ist dünn")
|
|
func outlineIsThin() {
|
|
// Ein dicker Rand frisst das Zeichen auf.
|
|
#expect(BatteryGlyph.outlineRect(glyph).width - glyph.width <= 2.5)
|
|
}
|
|
|
|
@Test("Das Pausenzeichen ist niedriger als der Blitz")
|
|
func pauseIsShorter() {
|
|
// Es ist von Haus aus höher gebaut und stößt sonst oben und unten an
|
|
// den Rahmen des Akkus.
|
|
let bolt = BatteryGlyph.glyphHeight(for: .bolt, in: 12)
|
|
let paused = BatteryGlyph.glyphHeight(for: .paused, in: 12)
|
|
#expect(paused < bolt)
|
|
}
|
|
|
|
@Test("Beide bleiben im Akku")
|
|
func bothFitInside() {
|
|
for indicator in BatteryGlyph.Indicator.allCases {
|
|
#expect(BatteryGlyph.glyphHeight(for: indicator, in: 12) < 12)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suite("Größe des Zeichens")
|
|
struct BatteryGlyphSizeTests {
|
|
|
|
private let height = BatteryGlyph.size.height
|
|
|
|
@Test("Das Zeichen passt samt Rand in den Innenraum")
|
|
func glyphAndOutlineFitInside() {
|
|
// Der Fehler, um den es ging: von der Außenhöhe ausgegangen, füllte der
|
|
// Blitz den ganzen Akku und stieß an den Rahmen.
|
|
let inner = height - 2 * BatteryGlyph.fillInset
|
|
for indicator in BatteryGlyph.Indicator.allCases {
|
|
let glyph = BatteryGlyph.glyphHeight(for: indicator, in: height)
|
|
#expect(glyph + 2 * BatteryGlyph.outlineWidth <= inner + 0.001)
|
|
}
|
|
}
|
|
|
|
@Test("Es bleibt trotzdem sichtbar groß")
|
|
func glyphIsNotTiny() {
|
|
// Auf die andere Seite zu übertreiben hilft auch niemandem.
|
|
#expect(BatteryGlyph.glyphHeight(for: .bolt, in: height) >= height * 0.4)
|
|
}
|
|
}
|