import Foundation import Testing @testable import Voice @MainActor struct MediaReconcileTests { private func makeController() -> MediaPlayerController { let config = ServerConfig( baseURL: URL(string: "http://127.0.0.1:9")!, livekitURL: URL(string: "ws://127.0.0.1:9")! ) let api = MediaAPI(config: config) return MediaPlayerController(api: api, settings: SettingsStore()) } private func makeState( desired: String? = "stopped", trackID: String? = nil, audioURL: String? = nil ) -> MediaState { let track: MediaTrack? = trackID != nil ? MediaTrack( id: trackID!, kind: nil, videoID: nil, title: nil, channel: nil, duration: nil, status: nil, error: nil, audioURL: audioURL, artURL: nil ) : nil return MediaState( room: "default", track: track, desired: desired, reported: nil, seekHint: nil, choices: [], pending: [], version: 1 ) } @Test("reconcile updates published desired to playing") func reconcileDesiredPlaying() { let controller = makeController() #expect(controller.desired == "stopped") controller.reconcile(makeState(desired: "playing")) #expect(controller.desired == "playing") } @Test("reconcile updates published desired to paused") func reconcileDesiredPaused() { let controller = makeController() controller.reconcile(makeState(desired: "playing")) #expect(controller.desired == "playing") controller.reconcile(makeState(desired: "paused")) #expect(controller.desired == "paused") } @Test("reconcile updates published desired to stopped") func reconcileDesiredStopped() { let controller = makeController() controller.reconcile(makeState(desired: "playing")) #expect(controller.desired == "playing") controller.reconcile(makeState(desired: "stopped")) #expect(controller.desired == "stopped") } @Test("reconcile with nil desired defaults to stopped") func reconcileNilDesiredDefaultsToStopped() { let controller = makeController() controller.reconcile(makeState(desired: "playing")) #expect(controller.desired == "playing") controller.reconcile(makeState(desired: nil)) #expect(controller.desired == "stopped") } @Test("late audio: desired=playing before audio_url, then audio_url arrives") func lateAudioReconciliation() { let controller = makeController() // Phase 1: server says playing but the track has no audio_url yet // (still downloading). The source is not wired, so play cannot start. let stateNoURL = makeState(desired: "playing", trackID: "track-1", audioURL: nil) controller.reconcile(stateNoURL) #expect(controller.desired == "playing") #expect(controller.track?.id == "track-1") // Player rate should still be zero because no source was wired. #expect(controller.player.rate.isZero) // Phase 2: the same track now has an audio_url. The reconcile path // wires the source (wireSource) and then applyDesiredState retries // play because desired is still "playing" and rate is still zero. let stateWithURL = makeState(desired: "playing", trackID: "track-1", audioURL: "/stream/track-1.mp3") controller.reconcile(stateWithURL) #expect(controller.desired == "playing") // The source should now be wired (loadedAudioPath is set internally). // We can't directly observe loadedAudioPath, but we can verify the // player has a current item after wireSource ran. #expect(controller.player.currentItem != nil) } @Test("startDefaultPolling restarts after endConversation keeps scope") func pollingRestartsAfterEndConversation() async { let controller = makeController() controller.startDefaultPolling() await controller.endConversation() controller.startDefaultPolling() try? await Task.sleep(for: .milliseconds(50)) } @Test("togglePlayPause without a loaded track is a no-op") func toggleWithoutTrack() async { let controller = makeController() await controller.togglePlayPause() #expect(controller.track == nil) } }