import Foundation import XCTest import VelodyDomain @testable import VelodyPlayback @MainActor final class PlaybackControllerTests: XCTestCase { func testPlayPauseRestartsTrackAfterPlaybackFinishesAtQueueEnd() { let engine = FakePlaybackEngine() let sessionStore = InMemoryPlaybackSessionStore() let controller = PlaybackController( engine: engine, sessionStore: sessionStore ) let track = LibraryTrack( id: "track-1", title: "Finished Track", artist: "Tester", durationSeconds: 120, localFilePath: "/tmp/finished-track.mp3" ) controller.setCatalogTracks([track]) controller.play(trackID: track.id) XCTAssertEqual(engine.loadTrackCallCount, 1) XCTAssertEqual(engine.playCallCount, 1) XCTAssertTrue(controller.nowPlayingState.isPlaying) engine.simulatePlaybackFinished() XCTAssertFalse(controller.nowPlayingState.isPlaying) XCTAssertEqual(controller.nowPlayingState.currentTime, 120) controller.playPause() XCTAssertEqual(engine.loadTrackCallCount, 2) XCTAssertEqual(engine.playCallCount, 2) XCTAssertEqual(engine.lastLoadedStartTime, 0) XCTAssertTrue(controller.nowPlayingState.isPlaying) XCTAssertEqual(controller.nowPlayingState.currentTime, 0) } func testPlayMissingFileCapturesPlaybackErrorWithoutStartingPlayback() { let engine = FakePlaybackEngine() let sessionStore = InMemoryPlaybackSessionStore() let controller = PlaybackController( engine: engine, sessionStore: sessionStore ) let track = LibraryTrack( id: "missing-track", title: "Missing Track", artist: "Tester", durationSeconds: 180, localFilePath: "/tmp/missing-track.mp3" ) engine.loadTrackErrorsByPath[track.localFilePath] = .missingLocalFile(path: track.localFilePath) controller.setCatalogTracks([track]) controller.play(trackID: track.id) XCTAssertEqual(engine.loadTrackCallCount, 1) XCTAssertEqual(engine.playCallCount, 0) XCTAssertFalse(controller.nowPlayingState.isPlaying) XCTAssertEqual(controller.nowPlayingState.currentTrackID, track.id) XCTAssertEqual( controller.nowPlayingState.error, .missingLocalFile(path: track.localFilePath) ) XCTAssertEqual(controller.nowPlayingState.duration, track.durationSeconds) } func testMissingFileKeepsQueueStableAndAllowsNextTrackPlayback() { let engine = FakePlaybackEngine() let sessionStore = InMemoryPlaybackSessionStore() let controller = PlaybackController( engine: engine, sessionStore: sessionStore ) let missingTrack = LibraryTrack( id: "missing-track", title: "Missing Track", artist: "Tester", durationSeconds: 180, localFilePath: "/tmp/missing-track.mp3" ) let nextTrack = LibraryTrack( id: "next-track", title: "Next Track", artist: "Tester", durationSeconds: 90, localFilePath: "/tmp/next-track.mp3" ) engine.loadTrackErrorsByPath[missingTrack.localFilePath] = .missingLocalFile( path: missingTrack.localFilePath ) engine.durationByPath[nextTrack.localFilePath] = 90 controller.setCatalogTracks([missingTrack, nextTrack]) controller.play(trackID: missingTrack.id) controller.next() XCTAssertEqual(controller.nowPlayingState.currentTrackID, nextTrack.id) XCTAssertTrue(controller.nowPlayingState.isPlaying) XCTAssertNil(controller.nowPlayingState.error) XCTAssertEqual(controller.nowPlayingState.queueTrackIDs, [missingTrack.id, nextTrack.id]) XCTAssertEqual(engine.loadTrackCallCount, 2) XCTAssertEqual(engine.playCallCount, 1) } } @MainActor private final class FakePlaybackEngine: PlaybackEngine { var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)? var currentTime: Double = 0 var duration: Double = 120 var isPlaying = false var durationByPath: [String: Double] = [:] var loadTrackErrorsByPath: [String: PlaybackError] = [:] private(set) var loadTrackCallCount = 0 private(set) var playCallCount = 0 private(set) var lastLoadedStartTime: Double? func loadTrack(at fileURL: URL, startTime: Double) throws { loadTrackCallCount += 1 if let error = loadTrackErrorsByPath[fileURL.path] { currentTime = 0 duration = 0 isPlaying = false throw error } lastLoadedStartTime = startTime currentTime = startTime duration = durationByPath[fileURL.path] ?? duration isPlaying = false } func play() throws { playCallCount += 1 isPlaying = true } func pause() { isPlaying = false } func stop() { isPlaying = false currentTime = 0 } func seek(to time: Double) throws { currentTime = min(max(time, 0), duration) } func simulatePlaybackFinished() { isPlaying = false currentTime = duration onEvent?(.finishedPlaying) } }