ios: add share extension to queue and play video links
Embed an iOS and Catalyst share extension using existing discovery and player APIs. Share saved players through an App Group snapshot, preserve the queue, and verify advancing playback before reporting success. Cover URL provider transport, settings migration, discovery addresses, duplicate handling, cancellation, and partial enqueue/play outcomes with focused tests. Document authentication and signing limits.
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user