Files
onyx/Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift
Guido Schmit 433cdcc3dd 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.
2026-08-10 21:02:13 +02:00

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)
}
}