import Domain import Foundation @testable import Playback import Testing actor RecordingEngine: OutputEngine { nonisolated let events: AsyncStream nonisolated let continuation: AsyncStream.Continuation private(set) var volumes: [Double] = [] private(set) var loaded: [QueueItem.ID] = [] private(set) var prepared: [QueueItem.ID?] = [] private(set) var isPlaying = false init() { (events, continuation) = AsyncStream.makeStream() } func load(_ item: QueueItem, at _: TimeInterval) { loaded.append(item.id) } func prepare(_ item: QueueItem?) { prepared.append(item?.id) } func setVolume(_ volume: Double) { volumes.append(volume) } func play() { isPlaying = true } func pause() { isPlaying = false } func seek(to _: TimeInterval) {} func stop() {} } final class FakeClock: @unchecked Sendable { private let lock = NSLock() private var instant = ContinuousClock.now var now: ContinuousClock.Instant { lock.withLock { instant } } func advance(_ seconds: Double) { lock.withLock { instant += .seconds(seconds) } } } private func tracks(_ count: Int) -> [Track] { let source = SourceID() return (0 ..< count).map { Track( id: EntityID(source: source, remote: "\($0)"), title: "Track \($0)", albumID: nil, albumTitle: "", artistID: nil, artistName: "", duration: 200 ) } } @Test func queueStartsAtIndexAndAnchorsPosition() async { let clock = FakeClock() let session = PlaybackSession(engine: RecordingEngine()) { clock.now } await session.send(.setQueue(tracks: tracks(5), startIndex: 2, shuffled: false)) clock.advance(10) let state = await session.snapshot #expect(state.current?.track.title == "Track 2") #expect(state.history.count == 2 && state.upcoming.count == 2) #expect(state.status == .playing) #expect(abs(state.anchor.position(at: clock.now) - 10) < 0.001) } @Test func pauseFreezesAnchor() async { let clock = FakeClock() let session = PlaybackSession(engine: RecordingEngine()) { clock.now } await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false)) clock.advance(30) await session.send(.pause) clock.advance(30) let state = await session.snapshot #expect(abs(state.anchor.position(at: clock.now) - 30) < 0.001) } @Test func previousRestartsAfterThresholdAndGoesBackBefore() async { let clock = FakeClock() let session = PlaybackSession(engine: RecordingEngine()) { clock.now } await session.send(.setQueue(tracks: tracks(3), startIndex: 1, shuffled: false)) clock.advance(10) await session.send(.previous) #expect(await session.snapshot.current?.track.title == "Track 1") await session.send(.previous) #expect(await session.snapshot.current?.track.title == "Track 0") } @Test func queueEditsAddressItemsByID() async { let session = PlaybackSession(engine: RecordingEngine()) await session.send(.setQueue(tracks: tracks(4), startIndex: 0, shuffled: false)) let upcoming = await session.snapshot.upcoming await session.send(.move(item: upcoming[2].id, after: nil)) await session.send(.remove(upcoming[0].id)) #expect(await session.snapshot.upcoming.map(\.track.title) == ["Track 3", "Track 2"]) await session.send(.jump(to: upcoming[1].id)) let state = await session.snapshot #expect(state.current?.track.title == "Track 2" && state.upcoming.isEmpty) } @Test func finishedTrackAdvancesAndStopsAtEndWithoutRepeat() async throws { let engine = RecordingEngine() let session = PlaybackSession(engine: engine) let states = await session.states() await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false)) for expected in [("Track 1", PlaybackStatus.playing), ("Track 0", .paused)] { let current = try #require(await session.snapshot.current) engine.continuation.yield(.finished(current.id)) for await state in states where state.current?.id != current.id { #expect(state.current?.track.title == expected.0 && state.status == expected.1) break } } } @Test func unshuffleRestoresOriginalOrder() async { let session = PlaybackSession(engine: RecordingEngine()) await session.send(.setQueue(tracks: tracks(8), startIndex: 0, shuffled: false)) await session.send(.setShuffle(true)) await session.send(.setShuffle(false)) #expect(await session.snapshot.upcoming.map(\.track.title) == (1 ..< 8).map { "Track \($0)" }) } @Test func prepareFollowsHeadOfUpcoming() async { let engine = RecordingEngine() let session = PlaybackSession(engine: engine) await session.send(.setQueue(tracks: tracks(3), startIndex: 0, shuffled: false)) var state = await session.snapshot #expect(await engine.prepared == [state.upcoming[0].id]) await session.send(.next) state = await session.snapshot #expect(await engine.prepared.last == state.upcoming[0].id) await session.send(.next) #expect(await engine.prepared.last == .some(nil)) } @Test func anchorEventReanchorsCurrentAndIgnoresStaleID() async throws { let clock = FakeClock() let engine = RecordingEngine() let session = PlaybackSession(engine: engine) { clock.now } let states = await session.states() await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false)) let current = try #require(await session.snapshot.current) engine.continuation.yield(.anchor(UUID(), position: 50, rate: 1)) engine.continuation.yield(.anchor(current.id, position: 70, rate: 0)) for await state in states where state.anchor.rate == 0 { #expect(state.status == .playing && state.current?.id == current.id) #expect(abs(state.anchor.position(at: clock.now) - 70) < 0.001) break } } @Test func failedTrackAdvancesToNext() async throws { let engine = RecordingEngine() let session = PlaybackSession(engine: engine) let states = await session.states() await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false)) let current = try #require(await session.snapshot.current) engine.continuation.yield(.failed(current.id)) for await state in states where state.current?.id != current.id { #expect(state.current?.track.title == "Track 1" && state.status == .playing) break } } @Test func everyTrackFailingStopsInsteadOfLooping() async throws { let engine = RecordingEngine() let session = PlaybackSession(engine: engine) let states = await session.states() await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false)) await session.send(.setRepeat(.all)) for _ in 0 ..< 3 { let current = try #require(await session.snapshot.current) engine.continuation.yield(.failed(current.id)) for await state in states where state.current?.id != current.id || state.status == .paused { break } } let state = await session.snapshot #expect(state.status == .paused && state.anchor.rate == 0) #expect(await engine.loaded.count == 2) } @Test func volumeReachesEngineAndSurvivesQueueChanges() async { let engine = RecordingEngine() let session = PlaybackSession(engine: engine) await session.send(.setVolume(0.35)) await session.send(.setQueue(tracks: tracks(2), startIndex: 0, shuffled: false)) await session.send(.next) #expect(await session.snapshot.volume == 0.35) #expect(await engine.volumes == [0.35]) await session.send(.setVolume(-1)) await session.send(.setVolume(2)) await session.send(.setVolume(.nan)) #expect(await engine.volumes == [0.35, 0, 1]) #expect(await session.snapshot.volume == 1) } @Test func skippingPastTheEndStopsTheEngine() async { let engine = RecordingEngine() let session = PlaybackSession(engine: engine) await session.send(.setQueue(tracks: tracks(1), startIndex: 0, shuffled: false)) #expect(await engine.isPlaying) await session.send(.next) #expect(await session.snapshot.status == .paused) #expect(await !engine.isPlaying) } @Test func clearingHistoryKeepsCurrentAndUpcoming() async { let session = PlaybackSession(engine: RecordingEngine()) await session.send(.setQueue(tracks: tracks(4), startIndex: 2, shuffled: false)) await session.send(.clearHistory) let state = await session.snapshot #expect(state.history.isEmpty) #expect(state.current?.track.title == "Track 2" && state.upcoming.count == 1) }