Live Activities unter der Notch, nicht daneben

Neben ihr wären die Arme zwangsläufig gleich breit — die Silhouette zeichnet den
Steg mittig um die Hardware. Ein Symbol links und ein Balken rechts hieße dann
132 Punkte Schwarz für ein Symbol. Darunter hängt die Fläche frei und wird so
breit, wie ihr Inhalt es braucht.

Lautstärke, Strom, Titelwechsel und Helligkeit. Gemeldet wird nur, was sich
**geändert** hat: `PowerWatcher` schweigt beim ersten gelesenen Zustand, sonst
begrüßte Onyx jeden Start mit „am Netz".

Die Helligkeit kostete einen Spike. Von drei Wegen trägt nur `DisplayServices`,
und nur für den eingebauten Bildschirm. `CoreDisplay` — der Weg, den jede
Anleitung empfiehlt — gibt für alle Bildschirme 1.0 zurück, auch für den, der
auf 62 % stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:05:13 +02:00
co-authored by Claude Opus 5
parent c05ccb8ae1
commit 9b600de9a9
11 changed files with 1193 additions and 0 deletions
@@ -0,0 +1,150 @@
import Testing
import Foundation
import CoreGraphics
import SwiftUI
import OnyxNotch
@testable import ActivityProvider
@Suite("Warteschlange der Live Activities")
struct ActivityQueueTests {
private let now = Date(timeIntervalSince1970: 1_780_000_000)
private func ticket(_ id: String, rank: Int, at offset: TimeInterval = 0,
for duration: TimeInterval? = 2) -> ActivityTicket {
ActivityTicket(id: id, rank: rank,
raised: now.addingTimeInterval(offset), duration: duration)
}
@Test("Leer heißt: nichts zu zeigen")
func emptyShowsNothing() {
#expect(ActivityQueue().current(now: now) == nil)
}
/// Ohne diese Regel verschluckt ein beiläufiger Lautstärkeschritt die
/// Meldung, dass der Akku fast leer ist.
@Test("Rang schlägt Aktualität")
func rankBeatsRecency() {
var queue = ActivityQueue()
queue.raise(ticket("battery", rank: 90))
queue.raise(ticket("volume", rank: 10, at: 1))
#expect(queue.current(now: now.addingTimeInterval(1))?.id == "battery")
}
@Test("Bei gleichem Rang gilt die jüngere")
func sameRankPrefersNewest() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10))
queue.raise(ticket("brightness", rank: 10, at: 1))
#expect(queue.current(now: now.addingTimeInterval(1))?.id == "brightness")
}
/// Wer die Lautstärke dreimal verstellt, will dreimal die volle Standzeit —
/// nicht drei Einträge in einer Schlange.
@Test("Dieselbe Kennung ersetzt, statt sich anzureihen")
func raisingAgainReplaces() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10))
queue.raise(ticket("volume", rank: 10, at: 1))
#expect(queue.all.count == 1)
#expect(queue.current(now: now.addingTimeInterval(1))?.raised
== now.addingTimeInterval(1))
}
@Test("Abgelaufene zählen nicht mehr")
func expiredIsGone() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10, for: 2))
#expect(queue.current(now: now.addingTimeInterval(1)) != nil)
#expect(queue.current(now: now.addingTimeInterval(2)) == nil)
}
/// Genau dafür gibt es dauerhafte: etwas, das steht, wenn sonst nichts
/// ansteht — das Wetter etwa.
@Test("Eine dauerhafte läuft nie ab")
func persistentNeverExpires() {
var queue = ActivityQueue()
queue.raise(ticket("weather", rank: 1, for: nil))
#expect(queue.current(now: now.addingTimeInterval(86_400))?.id == "weather")
}
@Test("Nach dem Ablauf der kurzen kommt die dauerhafte zurück")
func persistentReturns() {
var queue = ActivityQueue()
queue.raise(ticket("weather", rank: 1, for: nil))
queue.raise(ticket("volume", rank: 10, for: 2))
#expect(queue.current(now: now)?.id == "volume")
#expect(queue.current(now: now.addingTimeInterval(3))?.id == "weather")
}
@Test("Zurückziehen wirkt sofort")
func withdrawWorks() {
var queue = ActivityQueue()
queue.raise(ticket("media", rank: 20, for: nil))
queue.withdraw(id: "media")
#expect(queue.current(now: now) == nil)
}
/// Eine Notch, die zehnmal je Sekunde nachfragt, ob etwas abgelaufen ist,
/// hält den Rechner wach. Deshalb rechnet die Schlange den Weckzeitpunkt aus.
@Test("Der nächste Ablauf ist der früheste noch offene")
func nextExpiryIsEarliest() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10, for: 5))
queue.raise(ticket("charge", rank: 50, for: 2))
queue.raise(ticket("weather", rank: 1, for: nil))
#expect(queue.nextExpiry(after: now) == now.addingTimeInterval(2))
}
@Test("Ohne offene Fristen gibt es keinen Weckzeitpunkt")
func noExpiryWithoutDurations() {
var queue = ActivityQueue()
queue.raise(ticket("weather", rank: 1, for: nil))
#expect(queue.nextExpiry(after: now) == nil)
}
@Test("Aufräumen entfernt genau die abgelaufenen")
func pruneRemovesExpiredOnly() {
var queue = ActivityQueue()
queue.raise(ticket("volume", rank: 10, for: 1))
queue.raise(ticket("weather", rank: 1, for: nil))
queue.prune(now: now.addingTimeInterval(2))
#expect(queue.all.map(\.id) == ["weather"])
}
}
@Suite("Fläche unter der Notch")
@MainActor
struct AmbientSizeTests {
private struct Row: NotchActivity {
let id = "volume"
let rank = ActivityRank.response
let duration: TimeInterval? = 2
var size: CGSize { CGSize(width: 196, height: 34) }
func makeContent() -> AnyView { AnyView(EmptyView()) }
}
@Test("Die angemeldete Activity gibt die Fläche vor")
func sizeComesFromActivity() {
let presenter = ActivityPresenter()
presenter.raise(Row())
#expect(presenter.contentSize == CGSize(width: 196, height: 34))
}
@Test("Ohne Activity gibt es keine Fläche")
func noActivityNoSize() {
#expect(ActivityPresenter().contentSize == nil)
}
}
@@ -0,0 +1,71 @@
import Testing
@testable import MetricsProvider
@Suite("Strom: was gemeldet wird")
struct PowerWatcherTests {
@Test("Der erste gelesene Zustand meldet nichts")
func firstReadingIsSilent() {
var watcher = PowerWatcher()
// Beim Start ist „am Netz" kein Ereignis, sondern die Lage.
#expect(watcher.observe(isCharging: false, isPluggedIn: true,
charge: 0.8, limit: nil) == nil)
#expect(watcher.current == .plugged)
}
@Test("Derselbe Zustand meldet sich nicht noch einmal")
func repeatedStateIsSilent() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil)
#expect(watcher.observe(isCharging: true, isPluggedIn: true,
charge: 0.51, limit: nil) == nil)
#expect(watcher.observe(isCharging: true, isPluggedIn: true,
charge: 0.99, limit: nil) == nil)
}
@Test("Netzteil abgezogen: das ist ein Ereignis")
func unplugging() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil)
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
charge: 0.5, limit: nil) == .unplugged)
}
@Test("Fast leer schlägt „am Akku\"")
func lowBeatsUnplugged() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
// Beides trifft zu — gemeldet wird das, was man nicht verpassen darf.
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
charge: 0.15, limit: nil) == .low)
}
@Test("Von fast leer zurück auf normal ist wieder ein Wechsel")
func recoveringFromLow() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: false, isPluggedIn: false, charge: 0.1, limit: nil)
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
charge: 0.5, limit: nil) == .unplugged)
}
@Test("Die Schwelle liegt bei zwanzig Prozent, einschließlich")
func thresholdIsInclusive() {
var atThreshold = PowerWatcher()
_ = atThreshold.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
#expect(atThreshold.observe(isCharging: false, isPluggedIn: false,
charge: 0.2, limit: nil) == .low)
var above = PowerWatcher()
_ = above.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
#expect(above.observe(isCharging: false, isPluggedIn: false,
charge: 0.21, limit: nil) == .unplugged)
}
@Test("Am Ladelimit ist ein eigener Zustand, nicht „am Netz\"")
func limitIsItsOwnState() {
var watcher = PowerWatcher()
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: 80)
#expect(watcher.observe(isCharging: false, isPluggedIn: true,
charge: 0.80, limit: 80) == .atLimit(80))
}
}