Phase 4: Medien-Widget mit dreistufiger Kaskade
Adapter für alles inklusive Browser, AppleScript für Spotify und Musik, Medientasten als letzte Reserve. Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die Now-Playing-Schnittstelle in macOS 15.4 schon einmal zugemacht. Fällt der Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion. Deshalb bleiben die Transporttasten auch dann bedienbar, wenn gar keine Metadaten da sind. Der Fortschritt zählt zwischen den Ereignissen selbst hoch. Spike B hatte gezeigt, dass elapsedTime nicht laufend nachgeliefert wird: ein Scrubber, der stur den letzten Wert anzeigt, steht still, während der Titel läuft. Gerechnet wird aus asOf und playbackRate, begrenzt auf die Titellänge — sonst zeigt die Anzeige nach dem Ende weiter steigende Zahlen. Teilmeldungen (diff = true) ergänzen den Stand, statt ihn zu ersetzen. Eine Meldung mit nur der Position würde sonst Titel, Interpret und Cover löschen. In AnyDecodable wird Bool vor Double geprüft. Andernfalls wird "playing": true zu 1 und die Wiedergabe gilt für immer als angehalten. Play/Pause schaltet sofort um, statt auf das nächste Ereignis zu warten. Ein Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt an; das echte Ereignis korrigiert den Stand ohnehin. Der Adapter liegt als Quellcode unter Vendor/ (BSD-3, Stand in ONYX-VERSION.txt) und wird beim Bauen kompiliert, statt als fertige Binary eingecheckt zu werden. Er wird bewusst nicht gelinkt: geladen wird er von /usr/bin/perl, im eigenen Prozess greift Apples Entitlement-Prüfung. 124 Tests grün.
This commit is contained in:
181
Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift
Normal file
181
Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import MediaProvider
|
||||
|
||||
@Suite("Wiedergabe-Fortschritt")
|
||||
struct NowPlayingProgressTests {
|
||||
|
||||
private let t0 = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
|
||||
private func playing(elapsed: TimeInterval = 30, duration: TimeInterval? = 200,
|
||||
rate: Double = 1) -> NowPlaying {
|
||||
NowPlaying(title: "Titel", isPlaying: true, duration: duration,
|
||||
elapsed: elapsed, playbackRate: rate, asOf: t0)
|
||||
}
|
||||
|
||||
@Test("Während der Wiedergabe zählt die Position zwischen den Ereignissen weiter")
|
||||
func elapsedAdvancesWhilePlaying() {
|
||||
// Der Adapter liefert `elapsedTime` nicht laufend nach. Ohne dieses
|
||||
// Weiterzählen steht der Scrubber still, obwohl der Titel läuft.
|
||||
let state = playing(elapsed: 30)
|
||||
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 40)
|
||||
}
|
||||
|
||||
@Test("Im pausierten Zustand bleibt die Position stehen")
|
||||
func elapsedFreezesWhenPaused() {
|
||||
var state = playing(elapsed: 30)
|
||||
state.isPlaying = false
|
||||
#expect(state.elapsed(at: t0.addingTimeInterval(60)) == 30)
|
||||
}
|
||||
|
||||
@Test("Doppelte Geschwindigkeit zählt doppelt so schnell")
|
||||
func playbackRateIsHonoured() {
|
||||
let state = playing(elapsed: 0, rate: 2)
|
||||
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 20)
|
||||
}
|
||||
|
||||
@Test("Die Position läuft nie über die Länge hinaus")
|
||||
func elapsedNeverExceedsDuration() {
|
||||
// Sonst zeigt die Anzeige nach dem Ende eines Titels weiter steigende
|
||||
// Zahlen, solange kein neues Ereignis kommt.
|
||||
let state = playing(elapsed: 190, duration: 200)
|
||||
#expect(state.elapsed(at: t0.addingTimeInterval(3600)) == 200)
|
||||
}
|
||||
|
||||
@Test("Ohne bekannte Länge zählt sie einfach weiter — Livestreams haben kein Ende")
|
||||
func liveStreamKeepsCounting() {
|
||||
let state = playing(elapsed: 10, duration: nil)
|
||||
#expect(state.elapsed(at: t0.addingTimeInterval(100)) == 110)
|
||||
}
|
||||
|
||||
@Test("Eine negative Position wird auf null gezogen")
|
||||
func elapsedNeverGoesNegative() {
|
||||
var state = playing(elapsed: 5)
|
||||
state.isPlaying = false
|
||||
state.elapsed = -3
|
||||
#expect(state.elapsed(at: t0) == 0)
|
||||
}
|
||||
|
||||
@Test("Fortschritt liegt zwischen 0 und 1")
|
||||
func progressIsNormalised() {
|
||||
let state = playing(elapsed: 50, duration: 200)
|
||||
#expect(state.progress(at: t0) == 0.25)
|
||||
}
|
||||
|
||||
@Test("Ohne Länge gibt es keinen Fortschritt statt eines falschen Wertes")
|
||||
func progressIsNilWithoutDuration() {
|
||||
#expect(playing(duration: nil).progress(at: t0) == nil)
|
||||
}
|
||||
|
||||
@Test("Länge null ergibt keinen Fortschritt statt einer Division durch null")
|
||||
func progressIsNilForZeroDuration() {
|
||||
#expect(playing(duration: 0).progress(at: t0) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Adapter-Payload")
|
||||
struct AdapterPayloadTests {
|
||||
|
||||
private func line(_ json: String) -> String { json }
|
||||
|
||||
@Test("Vollmeldung setzt alle Felder")
|
||||
func fullPayloadPopulates() {
|
||||
let state = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","album":"LP",\
|
||||
"playing":true,"duration":210.5,"elapsedTime":42.0,"playbackRate":1,\
|
||||
"bundleIdentifier":"com.google.Chrome"}}
|
||||
""", to: nil)
|
||||
|
||||
#expect(state?.title == "Song")
|
||||
#expect(state?.artist == "Band")
|
||||
#expect(state?.isPlaying == true)
|
||||
#expect(state?.duration == 210.5)
|
||||
#expect(state?.elapsed == 42)
|
||||
#expect(state?.bundleIdentifier == "com.google.Chrome")
|
||||
#expect(state?.origin == .adapter)
|
||||
}
|
||||
|
||||
@Test("Teilmeldung ergänzt den bisherigen Stand, statt ihn zu ersetzen")
|
||||
func diffPayloadMerges() {
|
||||
// Der eigentliche Grund für diese Unterscheidung: eine Teilmeldung mit
|
||||
// nur der Position würde sonst Titel, Interpret und Cover löschen.
|
||||
let full = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","playing":true}}
|
||||
""", to: nil)
|
||||
|
||||
let updated = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":true,"payload":{"elapsedTime":99.0}}
|
||||
""", to: full)
|
||||
|
||||
#expect(updated?.title == "Song")
|
||||
#expect(updated?.artist == "Band")
|
||||
#expect(updated?.elapsed == 99)
|
||||
}
|
||||
|
||||
@Test("Leere Vollmeldung heißt: es läuft nichts")
|
||||
func emptyFullPayloadMeansNothingPlaying() {
|
||||
#expect(AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":false,"payload":{}}
|
||||
""", to: nil) == nil)
|
||||
}
|
||||
|
||||
@Test("Leere Teilmeldung lässt den Stand unangetastet")
|
||||
func emptyDiffKeepsState() {
|
||||
let previous = NowPlaying(title: "Song")
|
||||
let result = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":true,"payload":{}}
|
||||
""", to: previous)
|
||||
|
||||
#expect(result?.title == "Song")
|
||||
}
|
||||
|
||||
@Test("Der Zeitstempel der Quelle wird übernommen, nicht der Empfangszeitpunkt")
|
||||
func timestampComesFromPayload() {
|
||||
// Sonst springt der Scrubber bei jedem Ereignis, weil die Position auf
|
||||
// einen falschen Bezugszeitpunkt gerechnet wird.
|
||||
let state = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":false,"payload":{"title":"S","elapsedTime":10.0,\
|
||||
"timestamp":"2026-08-10T14:22:16Z"}}
|
||||
""", to: nil)
|
||||
|
||||
let expected = ISO8601DateFormatter().date(from: "2026-08-10T14:22:16Z")
|
||||
#expect(state?.asOf == expected)
|
||||
}
|
||||
|
||||
@Test("Cover kommt als base64 an")
|
||||
func artworkIsDecoded() {
|
||||
let raw = Data([0xFF, 0xD8, 0xFF]).base64EncodedString()
|
||||
let state = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":false,"payload":{"title":"S","artworkData":"\(raw)"}}
|
||||
""", to: nil)
|
||||
|
||||
#expect(state?.artwork == Data([0xFF, 0xD8, 0xFF]))
|
||||
}
|
||||
|
||||
@Test("Länge null wird zu 'unbekannt' statt zu einer Null-Länge")
|
||||
func zeroDurationBecomesNil() {
|
||||
let state = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":false,"payload":{"title":"Stream","duration":0}}
|
||||
""", to: nil)
|
||||
|
||||
#expect(state?.duration == nil)
|
||||
}
|
||||
|
||||
@Test("Kaputte Zeilen werden verworfen, nicht interpretiert")
|
||||
func garbageIsIgnored() {
|
||||
for junk in ["", "kein json", "{}", "{\"type\":\"log\"}", "null"] {
|
||||
#expect(AdapterPayload.apply(line: junk, to: nil) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("`playing: true` wird nicht als Zahl gelesen")
|
||||
func boolIsNotMistakenForNumber() {
|
||||
// Reihenfolge in AnyDecodable: Bool vor Double. Andernfalls wird `true`
|
||||
// zu 1 und `playing` bliebe für immer falsch.
|
||||
let state = AdapterPayload.apply(line: """
|
||||
{"type":"data","diff":false,"payload":{"title":"S","playing":true}}
|
||||
""", to: nil)
|
||||
|
||||
#expect(state?.isPlaying == true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user