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