import LiveKitWebRTC import XCTest @testable import CallsKit /// The camera rules from Paul's ATEM Mini report against 0.3.1: the camera /// remembered, followed as it comes and goes, and watched for frames. @MainActor final class CameraRecoveryTests: XCTestCase { private let atem = CameraDevice(id: "atem", name: "ATEM Mini") private let faceTime = CameraDevice(id: "facetime", name: "FaceTime HD Camera", builtIn: true) private let phone = CameraDevice(id: "phone", name: "Paul’s iPhone Camera") private func defaults() -> UserDefaults { let name = "calls-camera-tests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: name)! addTeardownBlock { defaults.removePersistentDomain(forName: name) } return defaults } private func director(_ defaults: UserDefaults, formats: Int = 2) -> CameraDirector { CameraDirector(defaults: defaults, formats: { _ in formats }) } // MARK: Choosing func testJoinUsesTheRememberedCamera() { let store = defaults() CameraPreference(id: "atem", name: "ATEM Mini").save(store) let d = director(store) XCTAssertEqual(d.start(devices: [faceTime, atem]), .open("atem", format: 0)) XCTAssertEqual(d.status, .starting("ATEM Mini")) XCTAssertEqual(d.note, "") } func testJoinFallsBackToTheBuiltInCameraAndSaysSo() { let store = defaults() CameraPreference(id: "atem", name: "ATEM Mini").save(store) let d = director(store) // The built-in camera, not merely the first listed. XCTAssertEqual(d.start(devices: [phone, faceTime]), .open("facetime", format: 0)) XCTAssertEqual(d.note, "ATEM Mini isn’t connected; using FaceTime HD Camera") } func testWithNothingRememberedTheBuiltInCameraIsFirstChoice() { let d = director(defaults()) XCTAssertEqual(d.start(devices: [atem, faceTime]), .open("facetime", format: 0)) XCTAssertEqual(d.note, "") XCTAssertEqual(CameraDirector.choose([atem, phone], preferred: nil)?.id, "atem") XCTAssertNil(CameraDirector.choose([], preferred: CameraPreference(id: "atem", name: "ATEM Mini"))) } func testNoCameraAtAllIsSaidAndStopped() { let d = director(defaults()) XCTAssertEqual(d.start(devices: []), .stop) XCTAssertEqual(d.status, .noCamera) XCTAssertEqual(d.status.tileText, "No camera found") // One arriving later is used. XCTAssertEqual(d.devicesChanged([atem]), .open("atem", format: 0)) } func testChoosingACameraRemembersIt() { let store = defaults() let d = director(store) _ = d.start(devices: [faceTime, atem]) XCTAssertEqual(d.choose("atem"), .open("atem", format: 0)) XCTAssertEqual(CameraPreference.load(store), CameraPreference(id: "atem", name: "ATEM Mini")) XCTAssertNil(d.choose("missing"), "a camera that is not connected cannot be chosen") } // MARK: Paul's 0.3.3 report: "This keeps forgetting my input" // // A MacBook Air in clamshell, an ATEM Mini ("Blackmagic Design") on USB. // The remembered camera was the MacBook's own, which is listed with the // lid closed but sends nothing; every call fell back to the Blackmagic // and said "No signal from MacBook Air Camera; using Blackmagic Design", // and choosing the Blackmagic in the panel did not stick. private let macBook = CameraDevice( id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera") private let lidClosed = CameraDevice( id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera", suspended: true) private let blackmagic = CameraDevice( id: "0x1100000_1edb_be55", name: "Blackmagic Design", model: "UVC Camera VendorID_7899 ProductID_48725") /// The fallback, as 0.3.3 reached it: preferred camera silent, the /// Blackmagic live. private func onBlackmagicByFallback(_ store: UserDefaults) -> CameraDirector { CameraPreference(macBook).save(store) let d = director(store, formats: 1) _ = d.start(devices: [macBook, blackmagic]) d.opened("builtin", at: 0, frames: 0) _ = d.tick(frames: 0, at: 3) d.opened("builtin", at: 3, frames: 0) XCTAssertEqual(d.tick(frames: 0, at: 6), .open(blackmagic.id, format: 0)) d.opened(blackmagic.id, at: 6, frames: 0) _ = d.tick(frames: 1, at: 6.5) XCTAssertEqual(d.status, .live("Blackmagic Design")) return d } func testChoosingTheCameraYouAreOnByFallbackRemembersItWithoutRestartingIt() { let store = defaults() let d = onBlackmagicByFallback(store) XCTAssertNil(d.choose(blackmagic.id), "already live: nothing to reopen, no glitch") XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) XCTAssertEqual(d.note, "") XCTAssertEqual(d.status, .live("Blackmagic Design")) XCTAssertNil(d.cameraToKeep, "nothing left to offer") // And the next call starts on it. let next = director(store) XCTAssertEqual(next.start(devices: [macBook, blackmagic]), .open(blackmagic.id, format: 0)) } func testFallbacksAndDefaultsNeverWriteThePreference() { let store = defaults() _ = onBlackmagicByFallback(store) XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "a fallback is not a choice") let fresh = defaults() let d = director(fresh) _ = d.start(devices: [blackmagic, macBook]) _ = d.devicesChanged([blackmagic]) _ = d.openFailed(blackmagic.id) XCTAssertNil(CameraPreference.load(fresh), "a first-run default is not a choice either") } func testTheCameraInUseIsOfferedWhenItIsNotTheRememberedOne() { let store = defaults() let d = onBlackmagicByFallback(store) XCTAssertEqual(d.cameraToKeep, blackmagic) XCTAssertEqual(d.choose(blackmagic.id), nil) XCTAssertEqual(CameraPreference.load(store)?.id, blackmagic.id) // Nothing remembered and on the default: nothing to offer. let plain = director(defaults()) _ = plain.start(devices: [macBook, blackmagic]) plain.opened("builtin", at: 0, frames: 0) _ = plain.tick(frames: 1, at: 0.5) XCTAssertNil(plain.cameraToKeep) } func testAReenumeratedCameraStillMatches() { // Moved to another USB port: a new uniqueID, the same model. let store = defaults() CameraPreference(blackmagic).save(store) let moved = CameraDevice(id: "0x1400000_1edb_be55", name: "Blackmagic Design", model: blackmagic.model) XCTAssertEqual(director(store).start(devices: [macBook, moved]), .open(moved.id, format: 0)) // A choice saved by 0.3.3 has no model: the name finds it. let old = defaults() CameraPreference(id: blackmagic.id, name: "Blackmagic Design").save(old) let d = director(old) XCTAssertEqual(d.start(devices: [macBook, moved]), .open(moved.id, format: 0)) XCTAssertEqual(d.note, "", "found, so not \"isn’t connected\"") XCTAssertNil(d.cameraToKeep, "it is the remembered camera") // The id still wins over the model when both are there. let twin = CameraDevice(id: "twin", name: "Blackmagic Design", model: blackmagic.model) XCTAssertEqual(director(store).start(devices: [twin, blackmagic]), .open(blackmagic.id, format: 0)) } func testLidClosedSkipsTheBuiltInCameraAtJoinAndSaysWhy() { let store = defaults() CameraPreference(macBook).save(store) let d = director(store) XCTAssertEqual(d.start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "skipped, not forgotten") d.opened(blackmagic.id, at: 0, frames: 0) _ = d.tick(frames: 1, at: 0.5) XCTAssertEqual(d.cameraToKeep, blackmagic) // With nothing remembered, a closed lid is not the first choice either. XCTAssertEqual(director(defaults()).start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) // And with nothing else, it is tried anyway. XCTAssertEqual(director(defaults()).start(devices: [lidClosed]), .open("builtin", format: 0)) } func testLidClosingMidCallFallsBackAtOnceAndOpeningItReturns() { let store = defaults() CameraPreference(macBook).save(store) let d = director(store) _ = d.start(devices: [macBook, blackmagic]) d.opened("builtin", at: 0, frames: 0) _ = d.tick(frames: 1, at: 0.5) XCTAssertEqual(d.devicesChanged([lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") XCTAssertEqual(d.devicesChanged([macBook, blackmagic]), .open("builtin", format: 0)) XCTAssertEqual(d.note, "") } func testNoSignalFromALidClosedCameraSaysSo() { // The lid closed after the list was read: the watchdog finds it. let store = defaults() CameraPreference(macBook).save(store) let d = director(store, formats: 1) _ = d.start(devices: [macBook, blackmagic]) d.opened("builtin", at: 0, frames: 0) _ = d.tick(frames: 0, at: 3) d.opened("builtin", at: 3, frames: 0) _ = d.devicesChanged([lidClosed, blackmagic]) XCTAssertEqual(d.active, blackmagic.id) XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") } // MARK: Coming and going func testActiveCameraDisconnectingFallsBackAtOnceAndSaysSo() { let store = defaults() CameraPreference(id: "atem", name: "ATEM Mini").save(store) let d = director(store) _ = d.start(devices: [faceTime, atem]) d.opened("atem", at: 0, frames: 0) XCTAssertEqual(d.devicesChanged([faceTime]), .open("facetime", format: 0)) XCTAssertEqual(d.note, "ATEM Mini disconnected; using FaceTime HD Camera") } func testRememberedCameraReconnectingIsReturnedTo() { let store = defaults() CameraPreference(id: "atem", name: "ATEM Mini").save(store) let d = director(store) _ = d.start(devices: [faceTime, atem]) _ = d.devicesChanged([faceTime]) XCTAssertEqual(d.active, "facetime") // Something unrelated changing does not move the call. XCTAssertNil(d.devicesChanged([faceTime, phone])) XCTAssertEqual(d.devicesChanged([faceTime, phone, atem]), .open("atem", format: 0)) XCTAssertEqual(d.note, "", "the fallback note goes when the camera is back") } func testRememberedCameraAppearingLaterIsSwitchedTo() { let store = defaults() CameraPreference(id: "atem", name: "ATEM Mini").save(store) let d = director(store) XCTAssertEqual(d.start(devices: [faceTime]), .open("facetime", format: 0)) XCTAssertEqual(d.devicesChanged([faceTime, atem]), .open("atem", format: 0)) } func testLastCameraDisconnectingStopsAndSaysDisconnected() { let d = director(defaults()) _ = d.start(devices: [atem]) XCTAssertEqual(d.devicesChanged([]), .stop) XCTAssertEqual(d.status, .disconnected("ATEM Mini")) XCTAssertEqual(d.status.tileText, "Camera disconnected") XCTAssertEqual(d.devicesChanged([atem]), .open("atem", format: 0), "and back when it is") } func testOpenFailureTriesAnotherCameraThenGivesUpWithRetry() { let d = director(defaults()) _ = d.start(devices: [atem, faceTime]) // facetime (built in) XCTAssertEqual(d.openFailed("facetime"), .open("atem", format: 0)) XCTAssertEqual(d.note, "FaceTime HD Camera would not start; using ATEM Mini") XCTAssertNil(d.openFailed("atem")) XCTAssertEqual(d.status, .failed("ATEM Mini")) XCTAssertEqual(d.retry(), .open("facetime", format: 0), "Try again forgives both") } // MARK: The watchdog func testWatchdogLadder() { var dog = CameraWatchdog() dog.opened(at: 0, frames: 0) XCTAssertNil(dog.observe(frames: 0, at: 2.9)) XCTAssertEqual(dog.observe(frames: 0, at: 3), .restart) XCTAssertNil(dog.observe(frames: 0, at: 10), "nothing more until reopened") dog.opened(at: 10, frames: 0) XCTAssertEqual(dog.observe(frames: 0, at: 13), .nextFormat) dog.opened(at: 13, frames: 0) XCTAssertEqual(dog.observe(frames: 0, at: 16), .fallback) } func testWatchdogStallAfterFramesAndRecovery() { var dog = CameraWatchdog() dog.opened(at: 0, frames: 0) XCTAssertNil(dog.observe(frames: 5, at: 1)) XCTAssertTrue(dog.flowing) XCTAssertNil(dog.observe(frames: 9, at: 2)) XCTAssertNil(dog.observe(frames: 9, at: 4.9)) XCTAssertEqual(dog.observe(frames: 9, at: 5), .restart) dog.opened(at: 5, frames: 9) XCTAssertNil(dog.observe(frames: 12, at: 6)) XCTAssertEqual(dog.level, 0, "frames put it back at the bottom of the ladder") } func testNoFirstFrameRestartsThenNextFormatThenFallsBack() { let store = defaults() CameraPreference(id: "atem", name: "ATEM Mini").save(store) let d = director(store) _ = d.start(devices: [faceTime, atem]) d.opened("atem", at: 0, frames: 0) XCTAssertEqual(d.tick(frames: 0, at: 3), .open("atem", format: 0), "restart, same format") d.opened("atem", at: 4, frames: 0) XCTAssertEqual(d.tick(frames: 0, at: 7), .open("atem", format: 1), "then the next format") d.opened("atem", at: 8, frames: 0) XCTAssertEqual(d.tick(frames: 0, at: 11), .open("facetime", format: 0), "then another camera") XCTAssertEqual(d.note, "No signal from ATEM Mini; using FaceTime HD Camera") d.opened("facetime", at: 12, frames: 0) XCTAssertNil(d.tick(frames: 3, at: 12.5)) XCTAssertEqual(d.status, .live("FaceTime HD Camera")) // The silent ATEM is not returned to just because something else // changed; only its reconnecting (a new session) forgives it. XCTAssertNil(d.devicesChanged([faceTime, atem, phone])) _ = d.devicesChanged([faceTime, phone]) XCTAssertEqual(d.devicesChanged([faceTime, phone, atem]), .open("atem", format: 0)) } func testOneFormatSkipsStraightToFallback() { let d = director(defaults(), formats: 1) _ = d.start(devices: [atem]) d.opened("atem", at: 0, frames: 0) XCTAssertEqual(d.tick(frames: 0, at: 3), .open("atem", format: 0)) d.opened("atem", at: 3, frames: 0) // No next format, no other camera: give up, say so, keep listening. XCTAssertNil(d.tick(frames: 0, at: 6)) XCTAssertEqual(d.status, .noSignal("ATEM Mini")) XCTAssertEqual(d.status.tileText, "No signal from ATEM Mini — check its input") XCTAssertNil(d.tick(frames: 0, at: 60), "no restarting forever") XCTAssertNil(d.tick(frames: 1, at: 61)) XCTAssertEqual(d.status, .live("ATEM Mini"), "its signal coming back is noticed") } func testHeldClockDoesNotClimbTheLadder() { let d = director(defaults()) _ = d.start(devices: [atem]) d.opened("atem", at: 0, frames: 0) for second in stride(from: 1.0, through: 30, by: 1) { d.hold(frames: 0, at: second) } XCTAssertNil(d.tick(frames: 0, at: 31), "the background is not a stall") XCTAssertEqual(d.tick(frames: 0, at: 33), .open("atem", format: 0)) } func testRestartsAreRateLimited() { let d = director(defaults()) _ = d.start(devices: [atem]) XCTAssertEqual(d.restart(at: 100), .open("atem", format: 0)) XCTAssertNil(d.restart(at: 102)) XCTAssertEqual(d.restart(at: 105), .open("atem", format: 0)) } // MARK: Formats func testFrameRateIsAlwaysOneTheFormatSupports() { XCTAssertEqual(CameraFormatChoice.rate(for: [1...30]), 30) XCTAssertEqual(CameraFormatChoice.rate(for: [29.97...29.97]), 29.97) XCTAssertEqual(CameraFormatChoice.rate(for: [25...25]), 25) XCTAssertEqual(CameraFormatChoice.rate(for: [50...50, 59.94...59.94]), 50) XCTAssertEqual(CameraFormatChoice.rate(for: [23.976...23.976, 60...60]), 23.976) XCTAssertNil(CameraFormatChoice.rate(for: [])) } func testATEMFormatsRankByRateNearestThirty() { let rates: [Double] = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60] let formats = rates.map { CameraFormatChoice.Candidate(width: 1920, height: 1080, ranges: [$0...$0]) } let ranked = CameraFormatChoice.rank(formats).map { rates[$0] } XCTAssertEqual(ranked.prefix(3), [30, 29.97, 25]) XCTAssertEqual(ranked.count, rates.count) } func testSmallerFormatsComeFirstAndDuplicatesGo() { let formats = [ CameraFormatChoice.Candidate(width: 1920, height: 1080, ranges: [1...30]), CameraFormatChoice.Candidate(width: 640, height: 480, ranges: [1...30]), CameraFormatChoice.Candidate(width: 640, height: 480, ranges: [1...30]), CameraFormatChoice.Candidate(width: 1280, height: 720, ranges: [1...60]), ] XCTAssertEqual(CameraFormatChoice.rank(formats), [1, 3, 0]) } // MARK: The panel func testCameraRowStates() { XCTAssertEqual(CameraRowState(cameras: 0, offers: false, status: nil), .hidden) XCTAssertEqual(CameraRowState(cameras: 0, offers: true, status: nil), .noCamera) XCTAssertTrue(CameraRowState(cameras: 0, offers: true, status: .noCamera).offersRetry) XCTAssertEqual( CameraRowState(cameras: 2, offers: true, status: .live("ATEM Mini")), .picker(line: nil, retry: false)) XCTAssertEqual( CameraRowState(cameras: 1, offers: true, status: .starting("ATEM Mini")), .picker(line: "Starting ATEM Mini…", retry: false)) let silent = CameraRowState(cameras: 1, offers: true, status: .noSignal("ATEM Mini")) XCTAssertTrue(silent.offersRetry) XCTAssertEqual( silent, .picker( line: "ATEM Mini is connected but sending no picture. Check its input, or choose another camera.", retry: true)) XCTAssertTrue(CameraRowState(cameras: 1, offers: true, status: .failed("ATEM Mini")).offersRetry) } func testSelfTileSaysWhatItIsWaitingFor() { XCTAssertEqual(CameraStatus.starting("ATEM Mini").tileText, "Starting camera…") XCTAssertEqual(CameraStatus.disconnected("ATEM Mini").tileText, "Camera disconnected") XCTAssertNil(CameraStatus.live("ATEM Mini").tileText) } // MARK: The managed camera, end to end with a fake backend private final class FakeBackend: CameraBackend { var devices: [CameraDevice] var opened: [String] = [] var refuse: Set = [] var devicesChanged: (@MainActor () -> Void)? var trouble: (@MainActor () -> Void)? init(_ devices: [CameraDevice]) { self.devices = devices } func requestAccess() async throws {} func formatCount(_ id: String) -> Int { 1 } func open(_ id: String, format: Int, delegate: RTCVideoCapturerDelegate) async throws { if refuse.contains(id) { throw NSError(domain: "test", code: 1) } opened.append(id) } func close() async {} } func testManagedCameraFollowsDisconnectAndReturn() async throws { let store = defaults() CameraPreference(id: "atem", name: "ATEM Mini").save(store) let backend = FakeBackend([faceTime, atem]) let camera = ManagedCamera(backend: backend, defaults: store) var changes = 0 camera.observe { changes += 1 } RTCInitializeSSL() let factory = RTCPeerConnectionFactory() try await camera.start(source: factory.videoSource()) XCTAssertEqual(camera.currentCamera, "atem") XCTAssertEqual(camera.cameraStatus, .starting("ATEM Mini")) backend.devices = [faceTime] backend.devicesChanged?() try await waitFor { camera.currentCamera == "facetime" } XCTAssertEqual(camera.cameraNote, "ATEM Mini disconnected; using FaceTime HD Camera") backend.devices = [faceTime, atem] backend.devicesChanged?() try await waitFor { camera.currentCamera == "atem" } XCTAssertEqual(camera.cameraNote, "") XCTAssertEqual(backend.opened, ["atem", "facetime", "atem"]) XCTAssertGreaterThan(changes, 0) camera.stop() XCTAssertNil(camera.cameraStatus) } func testManagedCameraRefusedOpenFallsBack() async throws { let backend = FakeBackend([faceTime, atem]) backend.refuse = ["facetime"] let camera = ManagedCamera(backend: backend, defaults: defaults()) RTCInitializeSSL() try await camera.start(source: RTCPeerConnectionFactory().videoSource()) XCTAssertEqual(camera.currentCamera, "atem") XCTAssertEqual(camera.cameraNote, "FaceTime HD Camera would not start; using ATEM Mini") do { try await camera.use(camera: "nothing") XCTFail("an unknown camera is refused") } catch {} camera.stop() } func testManagedCameraOnTheLidClosedFallbackRemembersThePick() async throws { let store = defaults() CameraPreference(macBook).save(store) let backend = FakeBackend([lidClosed, blackmagic]) let camera = ManagedCamera(backend: backend, defaults: store) RTCInitializeSSL() try await camera.start(source: RTCPeerConnectionFactory().videoSource()) XCTAssertEqual(backend.opened, [blackmagic.id], "the closed lid's camera is never opened") XCTAssertEqual(camera.cameraNote, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") // Picking the camera it is already on is remembered. try await camera.use(camera: blackmagic.id) XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) XCTAssertEqual(camera.cameraNote, "") camera.stop() } private func waitFor(_ condition: @escaping @MainActor () -> Bool) async throws { for _ in 0..<200 { if condition() { return } try await Task.sleep(for: .milliseconds(10)) } XCTFail("condition never held") } }