370 lines
15 KiB
Swift
370 lines
15 KiB
Swift
import Foundation
|
|||
|
|
import XCTest
|
||
|
|
@testable import QueueCubeShareCore
|
||
|
|
|
||
|
|
final class SharePlaybackTests: XCTestCase {
|
||
|
|
private let url = URL(string: "https://video.example/watch?v=shared")!
|
||
|
|
|
||
|
|
func testSelectsNewDuplicateByIDAtItsLatestQueueIndex() async throws {
|
||
|
|
let existing = item(10)
|
||
|
|
let other = item(11, filename: "https://video.example/other")
|
||
|
|
let appended = item(12)
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([existing, other]), .success([existing, other, appended]), .success([other, appended, existing])],
|
||
|
|
nowPlaying: [
|
||
|
|
.success(nowPlaying(appended, paused: true)),
|
||
|
|
.success(nowPlaying(appended, position: 0)),
|
||
|
|
.success(nowPlaying(appended, position: 0.25))
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
try await enqueue(using: api)
|
||
|
|
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||
|
|
XCTAssertEqual(calls.skippedIndices, [1])
|
||
|
|
XCTAssertEqual(calls.playCount, 1)
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 3, "A paused player or a single position sample cannot prove playback")
|
||
|
|
}
|
||
|
|
|
||
|
|
func testEmptyQueueWaitsForAsynchronousAppendThenStartsPlayer() async throws {
|
||
|
|
let appended = item(42)
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([]), .success([]), .success([appended]), .success([appended])],
|
||
|
|
nowPlaying: [
|
||
|
|
.success(nowPlaying(nil)),
|
||
|
|
.success(nowPlaying(appended, position: 0)),
|
||
|
|
.success(nowPlaying(appended, position: 0.25))
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
try await enqueue(using: api)
|
||
|
|
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs.count, 1)
|
||
|
|
XCTAssertEqual(calls.skippedIndices, [0])
|
||
|
|
XCTAssertEqual(calls.playCount, 1)
|
||
|
|
XCTAssertEqual(calls.playlistReads, 5)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testPreflightFailureAllowsRetryAndDoesNotMutatePlayer() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.failure(.connection)], nowPlaying: [])
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertTrue(failure.canRetry)
|
||
|
|
XCTAssertTrue(failure.message.contains("No link was added"))
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertTrue(calls.addedURLs.isEmpty)
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testUncertainAppendResponseDoesNotRepeatMutationOrAllowRetry() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], addError: .connection)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
XCTAssertTrue(failure.message.contains("may already be queued"))
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testAppendNeverAppearsDoesNotRepeatMutation() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.success([])], nowPlaying: [])
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs.count, 1)
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testConcurrentIdenticalAppendsFailWithoutSelectingAnArbitraryItem() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.success([]), .success([item(1), item(2)])], nowPlaying: [])
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs.count, 1)
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testDuplicateAppearingInFinalSnapshotDoesNotSelectAnArbitraryItem() async throws {
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([item(1)]), .success([item(1), item(2)])],
|
||
|
|
nowPlaying: []
|
||
|
|
)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs.count, 1)
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testDifferentCandidateInFinalSnapshotDoesNotSelectAnotherClientsAppend() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([item(2)])], nowPlaying: [])
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testAppendedItemRemovedBeforeSelectionDoesNotSkipAnotherItem() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.success([]), .success([item(1)]), .success([])], nowPlaying: [])
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
XCTAssertTrue(failure.message.contains("was queued"))
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testPlayRequestFailureReportsPartialSuccessWithoutRetry() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
let api = FakePlayer(playlists: [.success([]), .success([appended])], nowPlaying: [], playError: .connection)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
XCTAssertTrue(failure.message.contains("was queued, but playback could not be confirmed"))
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs.count, 1)
|
||
|
|
XCTAssertEqual(calls.skippedIndices, [0])
|
||
|
|
XCTAssertEqual(calls.playCount, 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testPlaybackErrorIsNotReportedAsSuccess() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
let errored = item(1, playbackError: "Unsupported video")
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([appended])],
|
||
|
|
nowPlaying: [.success(nowPlaying(errored))]
|
||
|
|
)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 1)
|
||
|
|
XCTAssertEqual(calls.addedURLs.count, 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testWrongPlayingItemAndPausedPlaybackDoNotReportSuccess() async throws {
|
||
|
|
for info in [nowPlaying(item(8)), nowPlaying(item(1), paused: true)] {
|
||
|
|
let api = FakePlayer(playlists: [.success([]), .success([item(1)])], nowPlaying: [.success(info)])
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||
|
|
XCTAssertEqual(calls.playCount, 1)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func testLoadingItemFollowedByLoadErrorDoesNotReportSuccess() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
let errored = item(1, playbackError: "Failed to load video")
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([appended])],
|
||
|
|
nowPlaying: [.success(nowPlaying(appended)), .success(nowPlaying(errored))]
|
||
|
|
)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 2)
|
||
|
|
XCTAssertEqual(calls.playCount, 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testLoadingItemFollowedByDifferentCurrentItemDoesNotReportSuccess() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([appended])],
|
||
|
|
nowPlaying: [
|
||
|
|
.success(nowPlaying(appended)),
|
||
|
|
.success(nowPlaying(item(2), position: 0)),
|
||
|
|
.success(nowPlaying(item(2), position: 0.25))
|
||
|
|
]
|
||
|
|
)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testLoadingItemFollowedByMatchingPlaybackProgressSucceeds() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([appended])],
|
||
|
|
nowPlaying: [
|
||
|
|
.success(nowPlaying(appended)),
|
||
|
|
.success(nowPlaying(appended, position: 0)),
|
||
|
|
.success(nowPlaying(appended, position: 0.25))
|
||
|
|
]
|
||
|
|
)
|
||
|
|
try await enqueue(using: api)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 3)
|
||
|
|
XCTAssertEqual(calls.addedURLs.count, 1)
|
||
|
|
XCTAssertEqual(calls.playCount, 1)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testFrozenPlaybackPositionDoesNotReportSuccess() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([appended])],
|
||
|
|
nowPlaying: [.success(nowPlaying(appended, position: 0))]
|
||
|
|
)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testInvalidPositionCannotEstablishPlaybackProgress() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
for invalidPosition in [-1, Double.infinity, -Double.infinity, Double.nan] {
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([appended])],
|
||
|
|
nowPlaying: [
|
||
|
|
.success(nowPlaying(appended, position: invalidPosition)),
|
||
|
|
.success(nowPlaying(appended, position: 1))
|
||
|
|
]
|
||
|
|
)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func testInterruptedPlaybackObservationsResetProgressBaseline() async throws {
|
||
|
|
let appended = item(1)
|
||
|
|
let interruptions = [
|
||
|
|
nowPlaying(appended),
|
||
|
|
nowPlaying(appended, paused: true, position: 0.25),
|
||
|
|
nowPlaying(item(2), position: 0.25)
|
||
|
|
]
|
||
|
|
for interruption in interruptions {
|
||
|
|
let api = FakePlayer(
|
||
|
|
playlists: [.success([]), .success([appended])],
|
||
|
|
nowPlaying: [
|
||
|
|
.success(nowPlaying(appended, position: 0)),
|
||
|
|
.success(interruption),
|
||
|
|
.success(nowPlaying(appended, position: 1))
|
||
|
|
]
|
||
|
|
)
|
||
|
|
let failure = await failure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.nowPlayingReads, 4)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func testCancellationBeforeAppendDoesNotMutateAndAllowsRetry() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .preflight)
|
||
|
|
let failure = await cancelledFailure(using: api)
|
||
|
|
XCTAssertTrue(failure.canRetry)
|
||
|
|
XCTAssertTrue(failure.message.contains("No link was added"))
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertTrue(calls.addedURLs.isEmpty)
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
func testCancellationAfterAppendDoesNotRepeatMutationOrAllowRetry() async throws {
|
||
|
|
let api = FakePlayer(playlists: [.success([])], nowPlaying: [], cancellation: .afterAppend)
|
||
|
|
let failure = await cancelledFailure(using: api)
|
||
|
|
XCTAssertFalse(failure.canRetry)
|
||
|
|
XCTAssertTrue(failure.message.contains("may already be queued"))
|
||
|
|
let calls = await api.calls
|
||
|
|
XCTAssertEqual(calls.addedURLs, [url.absoluteString])
|
||
|
|
XCTAssertTrue(calls.skippedIndices.isEmpty)
|
||
|
|
XCTAssertEqual(calls.playCount, 0)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func cancelledFailure(using api: FakePlayer) async -> SharePlayback.Failure {
|
||
|
|
// Cancel only this child operation, leaving XCTest's task unaffected.
|
||
|
|
await Task { await failure(using: api) }.value
|
||
|
|
}
|
||
|
|
|
||
|
|
private func enqueue(using api: FakePlayer) async throws {
|
||
|
|
try await SharePlayback.enqueueAndPlay(url, using: api, attempts: 4, pollInterval: .zero)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func failure(using api: FakePlayer, file: StaticString = #filePath, line: UInt = #line) async -> SharePlayback.Failure {
|
||
|
|
do {
|
||
|
|
try await enqueue(using: api)
|
||
|
|
XCTFail("Expected playback to fail", file: file, line: line)
|
||
|
|
} catch let failure as SharePlayback.Failure {
|
||
|
|
return failure
|
||
|
|
} catch {
|
||
|
|
XCTFail("Unexpected error: \(error)", file: file, line: line)
|
||
|
|
}
|
||
|
|
return .init(message: "Test did not receive the expected failure", canRetry: true)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func item(_ id: Int, filename: String? = nil, playbackError: String? = nil) -> MediaItem {
|
||
|
|
MediaItem(filename: filename ?? url.absoluteString, title: nil, id: id, current: nil, playing: nil, metadata: nil, playbackError: playbackError)
|
||
|
|
}
|
||
|
|
|
||
|
|
private func nowPlaying(_ item: MediaItem?, paused: Bool = false, position: Double? = nil) -> NowPlayingInfo {
|
||
|
|
NowPlayingInfo(playingItem: item, isPaused: paused, volume: 50, timePosition: position, duration: nil, seekable: nil)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private enum TestError: Error { case connection }
|
||
|
|
|
||
|
|
private actor FakePlayer: SharePlayerAPI {
|
||
|
|
enum Cancellation { case preflight, afterAppend }
|
||
|
|
|
||
|
|
struct Calls {
|
||
|
|
var addedURLs: [String] = []
|
||
|
|
var skippedIndices: [Int?] = []
|
||
|
|
var playCount = 0
|
||
|
|
var playlistReads = 0
|
||
|
|
var nowPlayingReads = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
private var playlists: [Result<[MediaItem], TestError>]
|
||
|
|
private var nowPlaying: [Result<NowPlayingInfo, TestError>]
|
||
|
|
private let addError: TestError?
|
||
|
|
private let playError: TestError?
|
||
|
|
private let cancellation: Cancellation?
|
||
|
|
private(set) var calls = Calls()
|
||
|
|
|
||
|
|
init(playlists: [Result<[MediaItem], TestError>], nowPlaying: [Result<NowPlayingInfo, TestError>], addError: TestError? = nil, playError: TestError? = nil, cancellation: Cancellation? = nil) {
|
||
|
|
self.playlists = playlists
|
||
|
|
self.nowPlaying = nowPlaying
|
||
|
|
self.addError = addError
|
||
|
|
self.playError = playError
|
||
|
|
self.cancellation = cancellation
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchPlaylist() async throws -> [MediaItem] {
|
||
|
|
calls.playlistReads += 1
|
||
|
|
if cancellation == .preflight, calls.playlistReads == 1 {
|
||
|
|
withUnsafeCurrentTask { $0?.cancel() }
|
||
|
|
}
|
||
|
|
guard let next = playlists.first else { throw TestError.connection }
|
||
|
|
if playlists.count > 1 { playlists.removeFirst() }
|
||
|
|
return try next.get()
|
||
|
|
}
|
||
|
|
|
||
|
|
func add(mediaURL: String) async throws {
|
||
|
|
calls.addedURLs.append(mediaURL)
|
||
|
|
if cancellation == .afterAppend { withUnsafeCurrentTask { $0?.cancel() } }
|
||
|
|
if let addError { throw addError }
|
||
|
|
}
|
||
|
|
|
||
|
|
func skip(_ to: Int?) async throws { calls.skippedIndices.append(to) }
|
||
|
|
|
||
|
|
func play() async throws {
|
||
|
|
calls.playCount += 1
|
||
|
|
if let playError { throw playError }
|
||
|
|
}
|
||
|
|
|
||
|
|
func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
||
|
|
calls.nowPlayingReads += 1
|
||
|
|
guard let next = nowPlaying.first else { throw TestError.connection }
|
||
|
|
if nowPlaying.count > 1 { nowPlaying.removeFirst() }
|
||
|
|
return try next.get()
|
||
|
|
}
|
||
|
|
}
|