820 lines
27 KiB
Swift
820 lines
27 KiB
Swift
import AVFoundation
|
|
import Libmpv
|
|
import UIKit
|
|
import Flutter
|
|
import XCTest
|
|
|
|
@testable import Runner
|
|
|
|
final class ControllablePropertyCore: MpvPlayerCoreBase {
|
|
var nextResult: Result<Void, Error>?
|
|
private(set) var propertyCalls: [(String, String)] = []
|
|
private var pendingCompletion: ((Result<Void, Error>) -> Void)?
|
|
|
|
override func setPropertyAsync(
|
|
_ name: String,
|
|
value: String,
|
|
completion: @escaping (Result<Void, Error>) -> Void
|
|
) {
|
|
propertyCalls.append((name, value))
|
|
if let nextResult {
|
|
self.nextResult = nil
|
|
completion(nextResult)
|
|
} else {
|
|
pendingCompletion = completion
|
|
}
|
|
}
|
|
|
|
func finish(_ result: Result<Void, Error>) {
|
|
let completion = pendingCompletion
|
|
pendingCompletion = nil
|
|
completion?(result)
|
|
}
|
|
}
|
|
|
|
final class RecordingMpvPlugin: MpvPluginShared {
|
|
var coreBase: MpvPlayerCoreBase?
|
|
var eventSink: FlutterEventSink?
|
|
var nameToId: [String: Int] = [:]
|
|
private(set) var pauseHookValues: [String] = []
|
|
|
|
init(core: MpvPlayerCoreBase?) {
|
|
coreBase = core
|
|
}
|
|
|
|
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {}
|
|
func updatePlayerFrame() {}
|
|
|
|
func didSetPauseProperty(value: String) {
|
|
pauseHookValues.append(value)
|
|
}
|
|
}
|
|
|
|
final class RecordingLifecycleDelegate: MpvPlayerDelegate {
|
|
private(set) var events: [String] = []
|
|
private(set) var properties: [String] = []
|
|
|
|
func onPropertyChange(name: String, value: Any?) {
|
|
properties.append(name)
|
|
}
|
|
|
|
func onEvent(name: String, data: [String: Any]?) {
|
|
events.append(name)
|
|
}
|
|
}
|
|
|
|
final class FakePictureInPictureController: MpvPictureInPictureControlling {
|
|
var isPictureInPicturePossible = false
|
|
private(set) var startCount = 0
|
|
private(set) var stopCount = 0
|
|
private(set) var automaticStartValues: [Bool] = []
|
|
private(set) var invalidateCount = 0
|
|
|
|
func startPictureInPicture() { startCount += 1 }
|
|
func stopPictureInPicture() { stopCount += 1 }
|
|
func setAutomaticStart(_ enabled: Bool) { automaticStartValues.append(enabled) }
|
|
func invalidatePlaybackState() { invalidateCount += 1 }
|
|
}
|
|
|
|
final class RecordingPipDelegate: MpvPipDelegate {
|
|
private(set) var events: [String] = []
|
|
var onDidStart: (() -> Void)?
|
|
func pipWillStart() { events.append("willStart") }
|
|
func pipDidStart() {
|
|
onDidStart?()
|
|
events.append("didStart")
|
|
}
|
|
func pipDidStop(restored: Bool) { events.append("didStop:\(restored)") }
|
|
func pipDidFailToStart(error: Error?) { events.append("failed") }
|
|
func pipSetPlaying(_ playing: Bool) {}
|
|
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) { completion() }
|
|
var isPipPlaying: Bool { true }
|
|
var pipDuration: Double { 60 }
|
|
}
|
|
|
|
final class ReleaseTrackingCore: MpvPlayerCoreBase {
|
|
let onDeinit: () -> Void
|
|
init(onDeinit: @escaping () -> Void) {
|
|
self.onDeinit = onDeinit
|
|
super.init()
|
|
}
|
|
deinit { onDeinit() }
|
|
}
|
|
|
|
final class ProbeURLProtocol: URLProtocol {
|
|
private static let lock = NSLock()
|
|
private static var startHandler: ((ProbeURLProtocol) -> Void)?
|
|
private static var stopHandler: (() -> Void)?
|
|
|
|
static func configure(
|
|
start: @escaping (ProbeURLProtocol) -> Void,
|
|
stop: (() -> Void)? = nil
|
|
) {
|
|
lock.lock()
|
|
startHandler = start
|
|
stopHandler = stop
|
|
lock.unlock()
|
|
}
|
|
|
|
static func reset() {
|
|
lock.lock()
|
|
startHandler = nil
|
|
stopHandler = nil
|
|
lock.unlock()
|
|
}
|
|
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
|
override func startLoading() {
|
|
Self.lock.lock()
|
|
let handler = Self.startHandler
|
|
Self.lock.unlock()
|
|
handler?(self)
|
|
}
|
|
|
|
override func stopLoading() {
|
|
Self.lock.lock()
|
|
let handler = Self.stopHandler
|
|
Self.lock.unlock()
|
|
handler?()
|
|
}
|
|
}
|
|
|
|
final class MpvPlayerContractTests: XCTestCase {
|
|
private let failure = NSError(
|
|
domain: "MpvPlayerContractTests",
|
|
code: 1,
|
|
userInfo: [NSLocalizedDescriptionKey: "controlled failure"]
|
|
)
|
|
|
|
func testSharedSetPropertyMapsSuccessFailureMissingCoreAndInvalidArguments() {
|
|
let core = ControllablePropertyCore()
|
|
let plugin = RecordingMpvPlugin(core: core)
|
|
|
|
core.nextResult = .success(())
|
|
let success = invokeSetProperty(plugin, name: "pause", value: "no")
|
|
XCTAssertEqual(success.count, 1)
|
|
XCTAssertNil(success[0])
|
|
XCTAssertEqual(plugin.pauseHookValues, ["no"])
|
|
|
|
core.nextResult = .failure(failure)
|
|
let rejected = invokeSetProperty(plugin, name: "pause", value: "yes")
|
|
XCTAssertEqual(rejected.count, 1)
|
|
XCTAssertEqual((rejected[0] as? FlutterError)?.code, "SET_PROPERTY_FAILED")
|
|
XCTAssertEqual(plugin.pauseHookValues, ["no"])
|
|
|
|
plugin.coreBase = nil
|
|
let missing = invokeSetProperty(plugin, name: "volume", value: "50")
|
|
XCTAssertEqual(missing.count, 1)
|
|
XCTAssertEqual((missing[0] as? FlutterError)?.code, "NOT_INITIALIZED")
|
|
|
|
var invalidResults: [Any?] = []
|
|
plugin.handleSetProperty(
|
|
call: FlutterMethodCall(methodName: "setProperty", arguments: ["name": "pause"])
|
|
) {
|
|
invalidResults.append($0)
|
|
}
|
|
XCTAssertEqual(invalidResults.count, 1)
|
|
XCTAssertEqual((invalidResults[0] as? FlutterError)?.code, "INVALID_ARGS")
|
|
}
|
|
|
|
func testSharedSetPropertyMapsLifecycleCancellationAsNotInitialized() {
|
|
let core = ControllablePropertyCore()
|
|
let plugin = RecordingMpvPlugin(core: core)
|
|
core.nextResult = .failure(MpvLifecycleUnavailableError("controlled cancellation"))
|
|
|
|
let cancelled = invokeSetProperty(plugin, name: "volume", value: "50")
|
|
|
|
XCTAssertEqual(cancelled.count, 1)
|
|
XCTAssertEqual((cancelled[0] as? FlutterError)?.code, "NOT_INITIALIZED")
|
|
}
|
|
|
|
func testRealSetPropertyValidInvalidNonexistentAndPauseCache() {
|
|
let core = MpvAudioPlayerCore()
|
|
XCTAssertTrue(core.initialize())
|
|
defer {
|
|
core.dispose()
|
|
core.queue.sync {}
|
|
}
|
|
|
|
XCTAssertSuccess(awaitProperty(core, name: "volume", value: "50"))
|
|
XCTAssertTrue(core.isPaused)
|
|
|
|
XCTAssertFailure(awaitProperty(core, name: "pause", value: "not-a-flag"))
|
|
XCTAssertTrue(core.isPaused, "A rejected raw pause write must not change the cache")
|
|
|
|
XCTAssertFailure(
|
|
awaitProperty(core, name: "plezy-property-does-not-exist", value: "ignored")
|
|
)
|
|
XCTAssertTrue(core.isPaused)
|
|
|
|
XCTAssertSuccess(awaitProperty(core, name: "pause", value: "no"))
|
|
XCTAssertFalse(core.isPaused, "The accepted pause write must commit before completion")
|
|
}
|
|
|
|
func testPauseIntentUpdatesCacheBeforeAsyncWriteCompletes() {
|
|
let core = MpvAudioPlayerCore()
|
|
XCTAssertTrue(core.initialize())
|
|
defer {
|
|
core.dispose()
|
|
core.queue.sync {}
|
|
}
|
|
|
|
let queueEntered = expectation(description: "mpv queue blocked")
|
|
let releaseQueue = DispatchSemaphore(value: 0)
|
|
core.queue.async {
|
|
queueEntered.fulfill()
|
|
releaseQueue.wait()
|
|
}
|
|
wait(for: [queueEntered], timeout: 2)
|
|
|
|
let completion = expectation(description: "pause write completed")
|
|
core.setPropertyAsync("pause", value: "no") { result in
|
|
if case .failure(let error) = result {
|
|
XCTFail("Pause write failed: \(error)")
|
|
}
|
|
completion.fulfill()
|
|
}
|
|
|
|
XCTAssertFalse(core.isPaused, "The public pause intent must be visible before the native write completes")
|
|
releaseQueue.signal()
|
|
wait(for: [completion], timeout: 2)
|
|
}
|
|
|
|
func testOlderPauseReplyCannotOverwriteNewerUserIntent() {
|
|
let core = ControllablePropertyCore()
|
|
let olderResume = core.beginCachedPauseIntent(false)
|
|
let newerPause = core.beginCachedPauseIntent(true)
|
|
XCTAssertTrue(core.isPaused)
|
|
|
|
core.finishCachedPauseIntent(olderResume, result: .success(()))
|
|
XCTAssertTrue(
|
|
core.isPaused,
|
|
"An older resume reply must not overwrite a newer pending pause intent"
|
|
)
|
|
|
|
core.finishCachedPauseIntent(newerPause, result: .success(()))
|
|
XCTAssertTrue(core.isPaused)
|
|
}
|
|
|
|
func testPauseObservationAndUserIntentResolveInEventOrder() {
|
|
let core = ControllablePropertyCore()
|
|
let olderResume = core.beginCachedPauseIntent(false)
|
|
|
|
core.observeCachedPauseForTesting(true)
|
|
core.finishCachedPauseIntent(olderResume, result: .success(()))
|
|
XCTAssertTrue(
|
|
core.isPaused,
|
|
"A native pause observation must invalidate the older resume write's delayed reply"
|
|
)
|
|
|
|
let newerResume = core.beginCachedPauseIntent(false)
|
|
core.finishCachedPauseIntent(newerResume, result: .success(()))
|
|
XCTAssertFalse(
|
|
core.isPaused,
|
|
"A user intent created after the native observation must remain authoritative"
|
|
)
|
|
}
|
|
|
|
func testPauseObservationRetiresOutOfOrderIntentsForSuccessAndFailure() {
|
|
let newerResults: [Result<Void, Error>] = [
|
|
.success(()),
|
|
.failure(failure),
|
|
]
|
|
|
|
for newerResult in newerResults {
|
|
let core = ControllablePropertyCore()
|
|
let generationOneResume = core.beginCachedPauseIntent(false)
|
|
let generationTwoPause = core.beginCachedPauseIntent(true)
|
|
|
|
core.observeCachedPauseForTesting(true)
|
|
core.finishCachedPauseIntent(generationTwoPause, result: newerResult)
|
|
XCTAssertTrue(
|
|
core.isPaused,
|
|
"The observed native pause must survive the newer pending pause's completion"
|
|
)
|
|
|
|
core.finishCachedPauseIntent(generationOneResume, result: .success(()))
|
|
XCTAssertTrue(
|
|
core.isPaused,
|
|
"A late older resume must be inert after a newer intent resolves"
|
|
)
|
|
|
|
let postObservationResume = core.beginCachedPauseIntent(false)
|
|
core.finishCachedPauseIntent(postObservationResume, result: .success(()))
|
|
XCTAssertFalse(
|
|
core.isPaused,
|
|
"A resume created after the native observation must remain authoritative"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testPendingSetPropertyIsCancelledExactlyOnceOnDispose() {
|
|
let core = MpvAudioPlayerCore()
|
|
XCTAssertTrue(core.initialize())
|
|
|
|
let queueEntered = expectation(description: "mpv queue blocked")
|
|
let releaseQueue = DispatchSemaphore(value: 0)
|
|
core.queue.async {
|
|
queueEntered.fulfill()
|
|
releaseQueue.wait()
|
|
}
|
|
wait(for: [queueEntered], timeout: 2)
|
|
|
|
let completion = expectation(description: "cancelled property completion")
|
|
completion.assertForOverFulfill = true
|
|
var completionCount = 0
|
|
var completionError: Error?
|
|
core.setPropertyAsync("volume", value: "51") { result in
|
|
completionCount += 1
|
|
switch result {
|
|
case .success:
|
|
XCTFail("Disposal must fail an accepted-but-pending property request")
|
|
case .failure(let error):
|
|
completionError = error
|
|
}
|
|
completion.fulfill()
|
|
}
|
|
|
|
core.dispose()
|
|
releaseQueue.signal()
|
|
wait(for: [completion], timeout: 2)
|
|
core.queue.sync {}
|
|
XCTAssertEqual(completionCount, 1)
|
|
XCTAssertTrue(completionError is MpvLifecycleUnavailableError)
|
|
let unavailableResult = awaitProperty(core, name: "volume", value: "52")
|
|
XCTAssertFailure(unavailableResult)
|
|
if case .failure(let error) = unavailableResult {
|
|
XCTAssertTrue(error is MpvLifecycleUnavailableError)
|
|
}
|
|
}
|
|
|
|
func testRapidAudioCoreReplacementOwnsLifecycleOnce() {
|
|
for _ in 0..<5 {
|
|
autoreleasepool {
|
|
let core = MpvAudioPlayerCore()
|
|
XCTAssertTrue(core.initialize())
|
|
core.dispose()
|
|
core.dispose()
|
|
core.queue.sync {}
|
|
XCTAssertFalse(core.hasActiveMpv)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testQueuedDelegateDeliveryIsDroppedAfterTerminalTransition() {
|
|
let core = MpvPlayerCoreBase()
|
|
let delegate = RecordingLifecycleDelegate()
|
|
core.delegate = delegate
|
|
let enqueueAndDispose = {
|
|
core.dispatchDelegateEvent(name: "file-loaded", data: nil)
|
|
core.dispatchDelegateProperty(name: "time-pos", value: 1.0)
|
|
XCTAssertTrue(core.beginDisposal())
|
|
}
|
|
if Thread.isMainThread {
|
|
enqueueAndDispose()
|
|
} else {
|
|
DispatchQueue.main.sync(execute: enqueueAndDispose)
|
|
}
|
|
|
|
let drained = expectation(description: "main delivery drained")
|
|
DispatchQueue.main.async { drained.fulfill() }
|
|
wait(for: [drained], timeout: 2)
|
|
XCTAssertTrue(delegate.events.isEmpty)
|
|
XCTAssertTrue(delegate.properties.isEmpty)
|
|
}
|
|
|
|
func testWakeupContextDoesNotRetainCallbackTarget() {
|
|
let released = expectation(description: "callback target released")
|
|
var core: ReleaseTrackingCore? = ReleaseTrackingCore { released.fulfill() }
|
|
weak var weakCore = core
|
|
let context = MpvWakeupCallbackContext(core: core!)
|
|
|
|
core = nil
|
|
wait(for: [released], timeout: 2)
|
|
XCTAssertNil(weakCore)
|
|
context.dispatchWakeup()
|
|
context.detach()
|
|
}
|
|
|
|
func testUnavailablePropertyCompletionRunsExactlyOnceOnMainThread() {
|
|
let core = MpvAudioPlayerCore()
|
|
XCTAssertTrue(core.initialize())
|
|
core.dispose()
|
|
core.queue.sync {}
|
|
|
|
let completed = expectation(description: "unavailable property completed")
|
|
completed.assertForOverFulfill = true
|
|
var completionCount = 0
|
|
DispatchQueue.global().async {
|
|
core.getPropertyAsync("volume") { result in
|
|
XCTAssertTrue(Thread.isMainThread)
|
|
if case .success = result { XCTFail("Expected unavailable property failure") }
|
|
completionCount += 1
|
|
completed.fulfill()
|
|
}
|
|
}
|
|
wait(for: [completed], timeout: 2)
|
|
XCTAssertEqual(completionCount, 1)
|
|
}
|
|
|
|
func testNormalizedPlaybackDelayStringsPassThroughUnchanged() {
|
|
let core = ControllablePropertyCore()
|
|
let plugin = RecordingMpvPlugin(core: core)
|
|
let values = ["0.25", "-0.5", "0", "0.25"]
|
|
|
|
for value in values {
|
|
core.nextResult = .success(())
|
|
let result = invokeSetProperty(plugin, name: "audio-delay", value: value)
|
|
XCTAssertEqual(result.count, 1)
|
|
XCTAssertNil(result[0])
|
|
}
|
|
XCTAssertEqual(core.propertyCalls.map(\.1), values)
|
|
}
|
|
|
|
func testNodeConversionBoundsAndDiscardsMalformedSiblings() {
|
|
let core = MpvPlayerCoreBase()
|
|
var valid = mpv_node()
|
|
valid.format = MPV_FORMAT_INT64
|
|
valid.u.int64 = 7
|
|
var malformed = mpv_node()
|
|
malformed.format = MPV_FORMAT_NONE
|
|
var values = [valid, malformed, valid]
|
|
var decoded: Any?
|
|
|
|
let valueCount = values.count
|
|
values.withUnsafeMutableBufferPointer { valuesPointer in
|
|
var list = mpv_node_list()
|
|
list.num = Int32(valueCount)
|
|
list.values = valuesPointer.baseAddress
|
|
withUnsafeMutablePointer(to: &list) { listPointer in
|
|
var root = mpv_node()
|
|
root.format = MPV_FORMAT_NODE_ARRAY
|
|
root.u.list = listPointer
|
|
decoded = core.convertNode(root)
|
|
}
|
|
}
|
|
XCTAssertEqual(decoded as? [Int64], [7, 7])
|
|
|
|
var oversizedBytes = mpv_byte_array()
|
|
oversizedBytes.size = 16 * 1_024 * 1_024 + 1
|
|
withUnsafeMutablePointer(to: &oversizedBytes) { bytePointer in
|
|
var root = mpv_node()
|
|
root.format = MPV_FORMAT_BYTE_ARRAY
|
|
root.u.ba = bytePointer
|
|
XCTAssertNil(core.convertNode(root))
|
|
}
|
|
|
|
var invalidList = mpv_node_list()
|
|
invalidList.num = -1
|
|
withUnsafeMutablePointer(to: &invalidList) { listPointer in
|
|
var root = mpv_node()
|
|
root.format = MPV_FORMAT_NODE_ARRAY
|
|
root.u.list = listPointer
|
|
XCTAssertNil(core.convertNode(root))
|
|
}
|
|
XCTAssertTrue(core.validateSideDataDimensions(width: 3_840, height: 2_160))
|
|
XCTAssertFalse(core.validateSideDataDimensions(width: 0, height: 2_160))
|
|
XCTAssertFalse(core.validateSideDataDimensions(width: 65_536, height: 2_160))
|
|
XCTAssertFalse(core.validateSideDataDimensions(width: 16_384, height: 16_384))
|
|
}
|
|
|
|
func testRawEc3LoaderBoundsAndIgnoresLateCallbacksForBothModes() {
|
|
for finiteLength in [false, true] {
|
|
let loader = RawEc3Loader(
|
|
source: URL(string: "https://example.invalid/test.ec3")!,
|
|
finiteLength: finiteLength,
|
|
maximumBufferedBytes: 8,
|
|
sessionConfiguration: .ephemeral
|
|
)
|
|
let session = URLSession(configuration: .ephemeral)
|
|
let task = session.dataTask(with: URL(string: "https://example.invalid/test.ec3")!)
|
|
|
|
loader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
|
|
var snapshot = loader.statusSnapshot()
|
|
XCTAssertEqual(snapshot.bytesReceived, 4)
|
|
XCTAssertEqual(snapshot.retainedBytes, 4)
|
|
XCTAssertNil(snapshot.errorCode)
|
|
|
|
loader.urlSession(session, dataTask: task, didReceive: Data([5, 6, 7, 8, 9]))
|
|
snapshot = loader.statusSnapshot()
|
|
XCTAssertEqual(snapshot.bytesReceived, 4)
|
|
XCTAssertEqual(snapshot.retainedBytes, 0)
|
|
XCTAssertEqual(snapshot.errorCode, "response_too_large")
|
|
|
|
loader.urlSession(session, dataTask: task, didReceive: Data([10]))
|
|
let lateSnapshot = loader.statusSnapshot()
|
|
XCTAssertEqual(lateSnapshot.bytesReceived, snapshot.bytesReceived)
|
|
XCTAssertEqual(lateSnapshot.retainedBytes, 0)
|
|
loader.cancel()
|
|
loader.cancel()
|
|
XCTAssertEqual(loader.statusSnapshot().pendingRequestCount, 0)
|
|
let cancelledLoader = RawEc3Loader(
|
|
source: URL(string: "https://example.invalid/cancel.ec3")!,
|
|
finiteLength: finiteLength,
|
|
maximumBufferedBytes: 8,
|
|
sessionConfiguration: .ephemeral
|
|
)
|
|
cancelledLoader.urlSession(session, dataTask: task, didReceive: Data([1, 2, 3, 4]))
|
|
XCTAssertEqual(cancelledLoader.statusSnapshot().retainedBytes, 4)
|
|
cancelledLoader.cancel()
|
|
cancelledLoader.cancel()
|
|
let cancelledSnapshot = cancelledLoader.statusSnapshot()
|
|
XCTAssertEqual(cancelledSnapshot.retainedBytes, 0)
|
|
XCTAssertEqual(cancelledSnapshot.pendingRequestCount, 0)
|
|
session.invalidateAndCancel()
|
|
}
|
|
}
|
|
|
|
func testRawEc3LoaderCompletesThroughInjectedURLProtocolForBothModes() {
|
|
defer { ProbeURLProtocol.reset() }
|
|
for finiteLength in [false, true] {
|
|
let requestStarted = expectation(description: "probe request started")
|
|
let loaderFinished = expectation(description: "probe loader finished")
|
|
ProbeURLProtocol.configure { protocolInstance in
|
|
let response = URLResponse(
|
|
url: protocolInstance.request.url!,
|
|
mimeType: "audio/eac3",
|
|
expectedContentLength: -1,
|
|
textEncodingName: nil
|
|
)
|
|
protocolInstance.client?.urlProtocol(
|
|
protocolInstance,
|
|
didReceive: response,
|
|
cacheStoragePolicy: .notAllowed
|
|
)
|
|
protocolInstance.client?.urlProtocol(protocolInstance, didLoad: Data([1, 2, 3, 4]))
|
|
protocolInstance.client?.urlProtocolDidFinishLoading(protocolInstance)
|
|
requestStarted.fulfill()
|
|
}
|
|
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [ProbeURLProtocol.self]
|
|
let loader = RawEc3Loader(
|
|
source: URL(string: "https://probe.test/audio.ec3")!,
|
|
finiteLength: finiteLength,
|
|
maximumBufferedBytes: 8,
|
|
sessionConfiguration: configuration,
|
|
terminalHandlerForTesting: { loaderFinished.fulfill() }
|
|
)
|
|
loader.begin()
|
|
wait(for: [requestStarted, loaderFinished], timeout: 2)
|
|
|
|
let snapshot = loader.statusSnapshot()
|
|
XCTAssertTrue(snapshot.isFinished)
|
|
XCTAssertEqual(snapshot.bytesReceived, 4)
|
|
XCTAssertEqual(snapshot.retainedBytes, 4)
|
|
XCTAssertNil(snapshot.errorCode)
|
|
|
|
loader.cancel()
|
|
let cancelled = loader.statusSnapshot()
|
|
XCTAssertEqual(cancelled.retainedBytes, 0)
|
|
XCTAssertEqual(cancelled.pendingRequestCount, 0)
|
|
}
|
|
}
|
|
|
|
func testPipStartWaitsForDelegateAndCompletesOnce() {
|
|
let fake = FakePictureInPictureController()
|
|
fake.isPictureInPicturePossible = true
|
|
let controller = MpvPipController(
|
|
controller: fake,
|
|
readiness: { (true, true, true) },
|
|
retryScheduler: { $0() }
|
|
)
|
|
let delegate = RecordingPipDelegate()
|
|
controller.delegate = delegate
|
|
var results: [Bool] = []
|
|
|
|
delegate.onDidStart = {
|
|
XCTAssertEqual(results, [true], "Manual result must resolve before delegate suspension")
|
|
}
|
|
controller.startPip { results.append($0) }
|
|
XCTAssertEqual(fake.startCount, 1)
|
|
XCTAssertTrue(results.isEmpty)
|
|
controller.pictureInPictureWillStart()
|
|
controller.pictureInPictureDidStart()
|
|
XCTAssertEqual(Array(delegate.events.prefix(2)), ["willStart", "didStart"])
|
|
XCTAssertEqual(results, [true])
|
|
|
|
controller.pictureInPictureDidStart()
|
|
controller.teardown()
|
|
XCTAssertEqual(results, [true])
|
|
}
|
|
|
|
func testRepeatedAutoStartDuringCurrentControllerStartDoesNotRejectDidStart() {
|
|
let fake = FakePictureInPictureController()
|
|
let controller = MpvPipController(
|
|
controller: fake,
|
|
readiness: { (true, true, true) },
|
|
retryScheduler: { $0() }
|
|
)
|
|
let delegate = RecordingPipDelegate()
|
|
controller.delegate = delegate
|
|
|
|
controller.setAutoStart(true)
|
|
controller.pictureInPictureWillStart(from: fake)
|
|
controller.setAutoStart(true)
|
|
controller.pictureInPictureDidStart(from: fake)
|
|
|
|
XCTAssertEqual(fake.automaticStartValues, [true, true])
|
|
XCTAssertEqual(delegate.events, ["willStart", "didStart"])
|
|
XCTAssertEqual(
|
|
fake.stopCount,
|
|
0,
|
|
"Reasserting an enabled auto-start setting must not reject the in-flight system start"
|
|
)
|
|
}
|
|
|
|
func testPipStartTimesOutWithoutDelegateOutcome() {
|
|
let fake = FakePictureInPictureController()
|
|
var timeouts: [() -> Void] = []
|
|
let controller = MpvPipController(
|
|
controller: fake,
|
|
readiness: { (true, true, true) },
|
|
retryScheduler: { $0() },
|
|
startTimeoutScheduler: { timeouts.append($0) }
|
|
)
|
|
var results: [Bool] = []
|
|
|
|
controller.startPip { results.append($0) }
|
|
XCTAssertEqual(fake.startCount, 1)
|
|
XCTAssertTrue(results.isEmpty)
|
|
XCTAssertEqual(timeouts.count, 1)
|
|
|
|
timeouts[0]()
|
|
XCTAssertEqual(results, [false])
|
|
XCTAssertEqual(fake.stopCount, 1)
|
|
|
|
controller.pictureInPictureDidStart()
|
|
controller.pictureInPictureFailedToStart(error: NSError(domain: "late", code: 1))
|
|
XCTAssertEqual(results, [false])
|
|
}
|
|
|
|
func testPipTimeoutRecreatesControllerAndRejectsRetiredCallbacks() {
|
|
let displayLayer = AVSampleBufferDisplayLayer()
|
|
let retired = FakePictureInPictureController()
|
|
let replacement = FakePictureInPictureController()
|
|
retired.isPictureInPicturePossible = true
|
|
replacement.isPictureInPicturePossible = true
|
|
var timeouts: [() -> Void] = []
|
|
var replacementLayers: [AVSampleBufferDisplayLayer?] = []
|
|
let controller = MpvPipController(
|
|
controller: retired,
|
|
sampleBufferDisplayLayer: displayLayer,
|
|
readiness: { (true, true, true) },
|
|
retryScheduler: { $0() },
|
|
startTimeoutScheduler: { timeouts.append($0) },
|
|
replacementControllerFactory: { layer in
|
|
replacementLayers.append(layer)
|
|
return replacement
|
|
}
|
|
)
|
|
let delegate = RecordingPipDelegate()
|
|
controller.delegate = delegate
|
|
controller.setAutoStart(true)
|
|
var results: [Bool] = []
|
|
|
|
controller.startPip { results.append($0) }
|
|
XCTAssertEqual(retired.startCount, 1)
|
|
XCTAssertEqual(timeouts.count, 1)
|
|
|
|
timeouts[0]()
|
|
XCTAssertEqual(results, [false])
|
|
XCTAssertEqual(retired.stopCount, 1)
|
|
XCTAssertEqual(retired.automaticStartValues, [true, false])
|
|
XCTAssertEqual(replacementLayers.count, 1)
|
|
XCTAssertTrue(replacementLayers[0] === displayLayer)
|
|
XCTAssertEqual(replacement.automaticStartValues, [true])
|
|
|
|
controller.startPip { results.append($0) }
|
|
XCTAssertEqual(replacement.startCount, 1)
|
|
XCTAssertEqual(timeouts.count, 2)
|
|
|
|
controller.pictureInPictureWillStart(from: retired)
|
|
controller.pictureInPictureDidStart(from: retired)
|
|
controller.pictureInPictureFailedToStart(
|
|
from: retired,
|
|
error: NSError(domain: "late-retired-controller", code: 1)
|
|
)
|
|
controller.pictureInPictureDidStop(from: retired)
|
|
XCTAssertEqual(results, [false])
|
|
XCTAssertTrue(delegate.events.isEmpty)
|
|
XCTAssertEqual(retired.stopCount, 1)
|
|
XCTAssertEqual(
|
|
replacement.startCount,
|
|
1,
|
|
"A retired controller callback must not disturb the replacement's pending start"
|
|
)
|
|
|
|
controller.pictureInPictureWillStart(from: replacement)
|
|
controller.pictureInPictureDidStart(from: replacement)
|
|
XCTAssertEqual(results, [false, true])
|
|
XCTAssertEqual(delegate.events, ["willStart", "didStart"])
|
|
XCTAssertEqual(replacementLayers.count, 1)
|
|
}
|
|
|
|
func testPipTeardownCancelsRetryAndLateWork() {
|
|
let fake = FakePictureInPictureController()
|
|
var possible = false
|
|
var retries: [() -> Void] = []
|
|
let controller = MpvPipController(
|
|
controller: fake,
|
|
readiness: { (possible, true, true) },
|
|
retryScheduler: { retries.append($0) }
|
|
)
|
|
var results: [Bool] = []
|
|
|
|
controller.startPip { results.append($0) }
|
|
XCTAssertEqual(retries.count, 1)
|
|
controller.teardown()
|
|
XCTAssertEqual(results, [false])
|
|
possible = true
|
|
retries.forEach { $0() }
|
|
XCTAssertEqual(fake.startCount, 0)
|
|
XCTAssertEqual(results, [false])
|
|
}
|
|
|
|
func testPipFailureAndLateRestoreRemainSingleShot() {
|
|
let fake = FakePictureInPictureController()
|
|
let controller = MpvPipController(
|
|
controller: fake,
|
|
readiness: { (true, true, true) },
|
|
retryScheduler: { $0() }
|
|
)
|
|
let delegate = RecordingPipDelegate()
|
|
controller.delegate = delegate
|
|
var startResults: [Bool] = []
|
|
|
|
controller.startPip { startResults.append($0) }
|
|
controller.pictureInPictureWillStart()
|
|
controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 1))
|
|
controller.pictureInPictureFailedToStart(error: NSError(domain: "test", code: 2))
|
|
XCTAssertEqual(startResults, [false])
|
|
XCTAssertEqual(delegate.events.filter { $0 == "failed" }.count, 1)
|
|
|
|
controller.startPip { startResults.append($0) }
|
|
controller.pictureInPictureWillStart()
|
|
controller.pictureInPictureDidStart()
|
|
XCTAssertEqual(startResults, [false, true])
|
|
|
|
var restoreResults: [Bool] = []
|
|
controller.restoreUserInterface { restoreResults.append($0) }
|
|
controller.teardown()
|
|
controller.restoreUserInterface { restoreResults.append($0) }
|
|
XCTAssertEqual(restoreResults, [true, false])
|
|
}
|
|
|
|
private func invokeSetProperty(
|
|
_ plugin: RecordingMpvPlugin,
|
|
name: String,
|
|
value: String
|
|
) -> [Any?] {
|
|
var results: [Any?] = []
|
|
plugin.handleSetProperty(
|
|
call: FlutterMethodCall(
|
|
methodName: "setProperty",
|
|
arguments: ["name": name, "value": value]
|
|
)
|
|
) {
|
|
results.append($0)
|
|
}
|
|
return results
|
|
}
|
|
|
|
private func awaitProperty(
|
|
_ core: MpvPlayerCoreBase,
|
|
name: String,
|
|
value: String
|
|
) -> Result<Void, Error> {
|
|
let completion = expectation(description: "set \(name)")
|
|
var propertyResult: Result<Void, Error>?
|
|
core.setPropertyAsync(name, value: value) {
|
|
propertyResult = $0
|
|
completion.fulfill()
|
|
}
|
|
wait(for: [completion], timeout: 2)
|
|
return propertyResult ?? .failure(failure)
|
|
}
|
|
|
|
private func XCTAssertSuccess(
|
|
_ result: Result<Void, Error>,
|
|
file: StaticString = #filePath,
|
|
line: UInt = #line
|
|
) {
|
|
if case .failure(let error) = result {
|
|
XCTFail("Expected success, received \(error)", file: file, line: line)
|
|
}
|
|
}
|
|
|
|
private func XCTAssertFailure(
|
|
_ result: Result<Void, Error>,
|
|
file: StaticString = #filePath,
|
|
line: UInt = #line
|
|
) {
|
|
if case .success = result {
|
|
XCTFail("Expected failure", file: file, line: line)
|
|
}
|
|
}
|
|
}
|