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.
182 lines
6.7 KiB
Swift
182 lines
6.7 KiB
Swift
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)
|
|
}
|
|
}
|