Files

370 lines
15 KiB
Swift
Raw Permalink Normal View History

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()
}
}