186 lines
6.9 KiB
Swift
186 lines
6.9 KiB
Swift
import Domain
|
|
import Foundation
|
|
@testable import Playback
|
|
import Testing
|
|
|
|
actor RecordingEngine: OutputEngine {
|
|
nonisolated let events: AsyncStream<OutputEvent>
|
|
nonisolated let continuation: AsyncStream<OutputEvent>.Continuation
|
|
private(set) var loaded: [QueueItem.ID] = []
|
|
private(set) var prepared: [QueueItem.ID?] = []
|
|
|
|
init() {
|
|
(events, continuation) = AsyncStream<OutputEvent>.makeStream()
|
|
}
|
|
|
|
func load(_ item: QueueItem, at _: TimeInterval) {
|
|
loaded.append(item.id)
|
|
}
|
|
|
|
func prepare(_ item: QueueItem?) {
|
|
prepared.append(item?.id)
|
|
}
|
|
|
|
func play() {}
|
|
func pause() {}
|
|
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)
|
|
}
|