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] private let addError: TestError? private let playError: TestError? private let cancellation: Cancellation? private(set) var calls = Calls() init(playlists: [Result<[MediaItem], TestError>], nowPlaying: [Result], 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() } }