| 1 | import LiveKitWebRTC |
| 2 | import XCTest |
| 3 | |
| 4 | @testable import CallsKit |
| 5 | |
| 6 | /// The camera rules from Paul's ATEM Mini report against 0.3.1: the camera |
| 7 | /// remembered, followed as it comes and goes, and watched for frames. |
| 8 | @MainActor final class CameraRecoveryTests: XCTestCase { |
| 9 | private let atem = CameraDevice(id: "atem", name: "ATEM Mini") |
| 10 | private let faceTime = CameraDevice(id: "facetime", name: "FaceTime HD Camera", builtIn: true) |
| 11 | private let phone = CameraDevice(id: "phone", name: "Paul’s iPhone Camera") |
| 12 | |
| 13 | private func defaults() -> UserDefaults { |
| 14 | let name = "calls-camera-tests-\(UUID().uuidString)" |
| 15 | let defaults = UserDefaults(suiteName: name)! |
| 16 | addTeardownBlock { defaults.removePersistentDomain(forName: name) } |
| 17 | return defaults |
| 18 | } |
| 19 | |
| 20 | private func director(_ defaults: UserDefaults, formats: Int = 2) -> CameraDirector { |
| 21 | CameraDirector(defaults: defaults, formats: { _ in formats }) |
| 22 | } |
| 23 | |
| 24 | // MARK: Choosing |
| 25 | |
| 26 | func testJoinUsesTheRememberedCamera() { |
| 27 | let store = defaults() |
| 28 | CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| 29 | let d = director(store) |
| 30 | XCTAssertEqual(d.start(devices: [faceTime, atem]), .open("atem", format: 0)) |
| 31 | XCTAssertEqual(d.status, .starting("ATEM Mini")) |
| 32 | XCTAssertEqual(d.note, "") |
| 33 | } |
| 34 | |
| 35 | func testJoinFallsBackToTheBuiltInCameraAndSaysSo() { |
| 36 | let store = defaults() |
| 37 | CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| 38 | let d = director(store) |
| 39 | // The built-in camera, not merely the first listed. |
| 40 | XCTAssertEqual(d.start(devices: [phone, faceTime]), .open("facetime", format: 0)) |
| 41 | XCTAssertEqual(d.note, "ATEM Mini isn’t connected; using FaceTime HD Camera") |
| 42 | } |
| 43 | |
| 44 | func testWithNothingRememberedTheBuiltInCameraIsFirstChoice() { |
| 45 | let d = director(defaults()) |
| 46 | XCTAssertEqual(d.start(devices: [atem, faceTime]), .open("facetime", format: 0)) |
| 47 | XCTAssertEqual(d.note, "") |
| 48 | XCTAssertEqual(CameraDirector.choose([atem, phone], preferred: nil)?.id, "atem") |
| 49 | XCTAssertNil(CameraDirector.choose([], preferred: CameraPreference(id: "atem", name: "ATEM Mini"))) |
| 50 | } |
| 51 | |
| 52 | func testNoCameraAtAllIsSaidAndStopped() { |
| 53 | let d = director(defaults()) |
| 54 | XCTAssertEqual(d.start(devices: []), .stop) |
| 55 | XCTAssertEqual(d.status, .noCamera) |
| 56 | XCTAssertEqual(d.status.tileText, "No camera found") |
| 57 | // One arriving later is used. |
| 58 | XCTAssertEqual(d.devicesChanged([atem]), .open("atem", format: 0)) |
| 59 | } |
| 60 | |
| 61 | func testChoosingACameraRemembersIt() { |
| 62 | let store = defaults() |
| 63 | let d = director(store) |
| 64 | _ = d.start(devices: [faceTime, atem]) |
| 65 | XCTAssertEqual(d.choose("atem"), .open("atem", format: 0)) |
| 66 | XCTAssertEqual(CameraPreference.load(store), CameraPreference(id: "atem", name: "ATEM Mini")) |
| 67 | XCTAssertNil(d.choose("missing"), "a camera that is not connected cannot be chosen") |
| 68 | } |
| 69 | |
| 70 | // MARK: Paul's 0.3.3 report: "This keeps forgetting my input" |
| 71 | // |
| 72 | // A MacBook Air in clamshell, an ATEM Mini ("Blackmagic Design") on USB. |
| 73 | // The remembered camera was the MacBook's own, which is listed with the |
| 74 | // lid closed but sends nothing; every call fell back to the Blackmagic |
| 75 | // and said "No signal from MacBook Air Camera; using Blackmagic Design", |
| 76 | // and choosing the Blackmagic in the panel did not stick. |
| 77 | |
| 78 | private let macBook = CameraDevice( |
| 79 | id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera") |
| 80 | private let lidClosed = CameraDevice( |
| 81 | id: "builtin", name: "MacBook Air Camera", builtIn: true, model: "FaceTime HD Camera", suspended: true) |
| 82 | private let blackmagic = CameraDevice( |
| 83 | id: "0x1100000_1edb_be55", name: "Blackmagic Design", model: "UVC Camera VendorID_7899 ProductID_48725") |
| 84 | |
| 85 | /// The fallback, as 0.3.3 reached it: preferred camera silent, the |
| 86 | /// Blackmagic live. |
| 87 | private func onBlackmagicByFallback(_ store: UserDefaults) -> CameraDirector { |
| 88 | CameraPreference(macBook).save(store) |
| 89 | let d = director(store, formats: 1) |
| 90 | _ = d.start(devices: [macBook, blackmagic]) |
| 91 | d.opened("builtin", at: 0, frames: 0) |
| 92 | _ = d.tick(frames: 0, at: 3) |
| 93 | d.opened("builtin", at: 3, frames: 0) |
| 94 | XCTAssertEqual(d.tick(frames: 0, at: 6), .open(blackmagic.id, format: 0)) |
| 95 | d.opened(blackmagic.id, at: 6, frames: 0) |
| 96 | _ = d.tick(frames: 1, at: 6.5) |
| 97 | XCTAssertEqual(d.status, .live("Blackmagic Design")) |
| 98 | return d |
| 99 | } |
| 100 | |
| 101 | func testChoosingTheCameraYouAreOnByFallbackRemembersItWithoutRestartingIt() { |
| 102 | let store = defaults() |
| 103 | let d = onBlackmagicByFallback(store) |
| 104 | XCTAssertNil(d.choose(blackmagic.id), "already live: nothing to reopen, no glitch") |
| 105 | XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) |
| 106 | XCTAssertEqual(d.note, "") |
| 107 | XCTAssertEqual(d.status, .live("Blackmagic Design")) |
| 108 | XCTAssertNil(d.cameraToKeep, "nothing left to offer") |
| 109 | // And the next call starts on it. |
| 110 | let next = director(store) |
| 111 | XCTAssertEqual(next.start(devices: [macBook, blackmagic]), .open(blackmagic.id, format: 0)) |
| 112 | } |
| 113 | |
| 114 | func testFallbacksAndDefaultsNeverWriteThePreference() { |
| 115 | let store = defaults() |
| 116 | _ = onBlackmagicByFallback(store) |
| 117 | XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "a fallback is not a choice") |
| 118 | let fresh = defaults() |
| 119 | let d = director(fresh) |
| 120 | _ = d.start(devices: [blackmagic, macBook]) |
| 121 | _ = d.devicesChanged([blackmagic]) |
| 122 | _ = d.openFailed(blackmagic.id) |
| 123 | XCTAssertNil(CameraPreference.load(fresh), "a first-run default is not a choice either") |
| 124 | } |
| 125 | |
| 126 | func testTheCameraInUseIsOfferedWhenItIsNotTheRememberedOne() { |
| 127 | let store = defaults() |
| 128 | let d = onBlackmagicByFallback(store) |
| 129 | XCTAssertEqual(d.cameraToKeep, blackmagic) |
| 130 | XCTAssertEqual(d.choose(blackmagic.id), nil) |
| 131 | XCTAssertEqual(CameraPreference.load(store)?.id, blackmagic.id) |
| 132 | // Nothing remembered and on the default: nothing to offer. |
| 133 | let plain = director(defaults()) |
| 134 | _ = plain.start(devices: [macBook, blackmagic]) |
| 135 | plain.opened("builtin", at: 0, frames: 0) |
| 136 | _ = plain.tick(frames: 1, at: 0.5) |
| 137 | XCTAssertNil(plain.cameraToKeep) |
| 138 | } |
| 139 | |
| 140 | func testAReenumeratedCameraStillMatches() { |
| 141 | // Moved to another USB port: a new uniqueID, the same model. |
| 142 | let store = defaults() |
| 143 | CameraPreference(blackmagic).save(store) |
| 144 | let moved = CameraDevice(id: "0x1400000_1edb_be55", name: "Blackmagic Design", model: blackmagic.model) |
| 145 | XCTAssertEqual(director(store).start(devices: [macBook, moved]), .open(moved.id, format: 0)) |
| 146 | // A choice saved by 0.3.3 has no model: the name finds it. |
| 147 | let old = defaults() |
| 148 | CameraPreference(id: blackmagic.id, name: "Blackmagic Design").save(old) |
| 149 | let d = director(old) |
| 150 | XCTAssertEqual(d.start(devices: [macBook, moved]), .open(moved.id, format: 0)) |
| 151 | XCTAssertEqual(d.note, "", "found, so not \"isn’t connected\"") |
| 152 | XCTAssertNil(d.cameraToKeep, "it is the remembered camera") |
| 153 | // The id still wins over the model when both are there. |
| 154 | let twin = CameraDevice(id: "twin", name: "Blackmagic Design", model: blackmagic.model) |
| 155 | XCTAssertEqual(director(store).start(devices: [twin, blackmagic]), .open(blackmagic.id, format: 0)) |
| 156 | } |
| 157 | |
| 158 | func testLidClosedSkipsTheBuiltInCameraAtJoinAndSaysWhy() { |
| 159 | let store = defaults() |
| 160 | CameraPreference(macBook).save(store) |
| 161 | let d = director(store) |
| 162 | XCTAssertEqual(d.start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| 163 | XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| 164 | XCTAssertEqual(CameraPreference.load(store), CameraPreference(macBook), "skipped, not forgotten") |
| 165 | d.opened(blackmagic.id, at: 0, frames: 0) |
| 166 | _ = d.tick(frames: 1, at: 0.5) |
| 167 | XCTAssertEqual(d.cameraToKeep, blackmagic) |
| 168 | // With nothing remembered, a closed lid is not the first choice either. |
| 169 | XCTAssertEqual(director(defaults()).start(devices: [lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| 170 | // And with nothing else, it is tried anyway. |
| 171 | XCTAssertEqual(director(defaults()).start(devices: [lidClosed]), .open("builtin", format: 0)) |
| 172 | } |
| 173 | |
| 174 | func testLidClosingMidCallFallsBackAtOnceAndOpeningItReturns() { |
| 175 | let store = defaults() |
| 176 | CameraPreference(macBook).save(store) |
| 177 | let d = director(store) |
| 178 | _ = d.start(devices: [macBook, blackmagic]) |
| 179 | d.opened("builtin", at: 0, frames: 0) |
| 180 | _ = d.tick(frames: 1, at: 0.5) |
| 181 | XCTAssertEqual(d.devicesChanged([lidClosed, blackmagic]), .open(blackmagic.id, format: 0)) |
| 182 | XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| 183 | XCTAssertEqual(d.devicesChanged([macBook, blackmagic]), .open("builtin", format: 0)) |
| 184 | XCTAssertEqual(d.note, "") |
| 185 | } |
| 186 | |
| 187 | func testNoSignalFromALidClosedCameraSaysSo() { |
| 188 | // The lid closed after the list was read: the watchdog finds it. |
| 189 | let store = defaults() |
| 190 | CameraPreference(macBook).save(store) |
| 191 | let d = director(store, formats: 1) |
| 192 | _ = d.start(devices: [macBook, blackmagic]) |
| 193 | d.opened("builtin", at: 0, frames: 0) |
| 194 | _ = d.tick(frames: 0, at: 3) |
| 195 | d.opened("builtin", at: 3, frames: 0) |
| 196 | _ = d.devicesChanged([lidClosed, blackmagic]) |
| 197 | XCTAssertEqual(d.active, blackmagic.id) |
| 198 | XCTAssertEqual(d.note, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| 199 | } |
| 200 | |
| 201 | // MARK: Coming and going |
| 202 | |
| 203 | func testActiveCameraDisconnectingFallsBackAtOnceAndSaysSo() { |
| 204 | let store = defaults() |
| 205 | CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| 206 | let d = director(store) |
| 207 | _ = d.start(devices: [faceTime, atem]) |
| 208 | d.opened("atem", at: 0, frames: 0) |
| 209 | XCTAssertEqual(d.devicesChanged([faceTime]), .open("facetime", format: 0)) |
| 210 | XCTAssertEqual(d.note, "ATEM Mini disconnected; using FaceTime HD Camera") |
| 211 | } |
| 212 | |
| 213 | func testRememberedCameraReconnectingIsReturnedTo() { |
| 214 | let store = defaults() |
| 215 | CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| 216 | let d = director(store) |
| 217 | _ = d.start(devices: [faceTime, atem]) |
| 218 | _ = d.devicesChanged([faceTime]) |
| 219 | XCTAssertEqual(d.active, "facetime") |
| 220 | // Something unrelated changing does not move the call. |
| 221 | XCTAssertNil(d.devicesChanged([faceTime, phone])) |
| 222 | XCTAssertEqual(d.devicesChanged([faceTime, phone, atem]), .open("atem", format: 0)) |
| 223 | XCTAssertEqual(d.note, "", "the fallback note goes when the camera is back") |
| 224 | } |
| 225 | |
| 226 | func testRememberedCameraAppearingLaterIsSwitchedTo() { |
| 227 | let store = defaults() |
| 228 | CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| 229 | let d = director(store) |
| 230 | XCTAssertEqual(d.start(devices: [faceTime]), .open("facetime", format: 0)) |
| 231 | XCTAssertEqual(d.devicesChanged([faceTime, atem]), .open("atem", format: 0)) |
| 232 | } |
| 233 | |
| 234 | func testLastCameraDisconnectingStopsAndSaysDisconnected() { |
| 235 | let d = director(defaults()) |
| 236 | _ = d.start(devices: [atem]) |
| 237 | XCTAssertEqual(d.devicesChanged([]), .stop) |
| 238 | XCTAssertEqual(d.status, .disconnected("ATEM Mini")) |
| 239 | XCTAssertEqual(d.status.tileText, "Camera disconnected") |
| 240 | XCTAssertEqual(d.devicesChanged([atem]), .open("atem", format: 0), "and back when it is") |
| 241 | } |
| 242 | |
| 243 | func testOpenFailureTriesAnotherCameraThenGivesUpWithRetry() { |
| 244 | let d = director(defaults()) |
| 245 | _ = d.start(devices: [atem, faceTime]) // facetime (built in) |
| 246 | XCTAssertEqual(d.openFailed("facetime"), .open("atem", format: 0)) |
| 247 | XCTAssertEqual(d.note, "FaceTime HD Camera would not start; using ATEM Mini") |
| 248 | XCTAssertNil(d.openFailed("atem")) |
| 249 | XCTAssertEqual(d.status, .failed("ATEM Mini")) |
| 250 | XCTAssertEqual(d.retry(), .open("facetime", format: 0), "Try again forgives both") |
| 251 | } |
| 252 | |
| 253 | // MARK: The watchdog |
| 254 | |
| 255 | func testWatchdogLadder() { |
| 256 | var dog = CameraWatchdog() |
| 257 | dog.opened(at: 0, frames: 0) |
| 258 | XCTAssertNil(dog.observe(frames: 0, at: 2.9)) |
| 259 | XCTAssertEqual(dog.observe(frames: 0, at: 3), .restart) |
| 260 | XCTAssertNil(dog.observe(frames: 0, at: 10), "nothing more until reopened") |
| 261 | dog.opened(at: 10, frames: 0) |
| 262 | XCTAssertEqual(dog.observe(frames: 0, at: 13), .nextFormat) |
| 263 | dog.opened(at: 13, frames: 0) |
| 264 | XCTAssertEqual(dog.observe(frames: 0, at: 16), .fallback) |
| 265 | } |
| 266 | |
| 267 | func testWatchdogStallAfterFramesAndRecovery() { |
| 268 | var dog = CameraWatchdog() |
| 269 | dog.opened(at: 0, frames: 0) |
| 270 | XCTAssertNil(dog.observe(frames: 5, at: 1)) |
| 271 | XCTAssertTrue(dog.flowing) |
| 272 | XCTAssertNil(dog.observe(frames: 9, at: 2)) |
| 273 | XCTAssertNil(dog.observe(frames: 9, at: 4.9)) |
| 274 | XCTAssertEqual(dog.observe(frames: 9, at: 5), .restart) |
| 275 | dog.opened(at: 5, frames: 9) |
| 276 | XCTAssertNil(dog.observe(frames: 12, at: 6)) |
| 277 | XCTAssertEqual(dog.level, 0, "frames put it back at the bottom of the ladder") |
| 278 | } |
| 279 | |
| 280 | func testNoFirstFrameRestartsThenNextFormatThenFallsBack() { |
| 281 | let store = defaults() |
| 282 | CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| 283 | let d = director(store) |
| 284 | _ = d.start(devices: [faceTime, atem]) |
| 285 | d.opened("atem", at: 0, frames: 0) |
| 286 | XCTAssertEqual(d.tick(frames: 0, at: 3), .open("atem", format: 0), "restart, same format") |
| 287 | d.opened("atem", at: 4, frames: 0) |
| 288 | XCTAssertEqual(d.tick(frames: 0, at: 7), .open("atem", format: 1), "then the next format") |
| 289 | d.opened("atem", at: 8, frames: 0) |
| 290 | XCTAssertEqual(d.tick(frames: 0, at: 11), .open("facetime", format: 0), "then another camera") |
| 291 | XCTAssertEqual(d.note, "No signal from ATEM Mini; using FaceTime HD Camera") |
| 292 | d.opened("facetime", at: 12, frames: 0) |
| 293 | XCTAssertNil(d.tick(frames: 3, at: 12.5)) |
| 294 | XCTAssertEqual(d.status, .live("FaceTime HD Camera")) |
| 295 | // The silent ATEM is not returned to just because something else |
| 296 | // changed; only its reconnecting (a new session) forgives it. |
| 297 | XCTAssertNil(d.devicesChanged([faceTime, atem, phone])) |
| 298 | _ = d.devicesChanged([faceTime, phone]) |
| 299 | XCTAssertEqual(d.devicesChanged([faceTime, phone, atem]), .open("atem", format: 0)) |
| 300 | } |
| 301 | |
| 302 | func testOneFormatSkipsStraightToFallback() { |
| 303 | let d = director(defaults(), formats: 1) |
| 304 | _ = d.start(devices: [atem]) |
| 305 | d.opened("atem", at: 0, frames: 0) |
| 306 | XCTAssertEqual(d.tick(frames: 0, at: 3), .open("atem", format: 0)) |
| 307 | d.opened("atem", at: 3, frames: 0) |
| 308 | // No next format, no other camera: give up, say so, keep listening. |
| 309 | XCTAssertNil(d.tick(frames: 0, at: 6)) |
| 310 | XCTAssertEqual(d.status, .noSignal("ATEM Mini")) |
| 311 | XCTAssertEqual(d.status.tileText, "No signal from ATEM Mini — check its input") |
| 312 | XCTAssertNil(d.tick(frames: 0, at: 60), "no restarting forever") |
| 313 | XCTAssertNil(d.tick(frames: 1, at: 61)) |
| 314 | XCTAssertEqual(d.status, .live("ATEM Mini"), "its signal coming back is noticed") |
| 315 | } |
| 316 | |
| 317 | func testHeldClockDoesNotClimbTheLadder() { |
| 318 | let d = director(defaults()) |
| 319 | _ = d.start(devices: [atem]) |
| 320 | d.opened("atem", at: 0, frames: 0) |
| 321 | for second in stride(from: 1.0, through: 30, by: 1) { d.hold(frames: 0, at: second) } |
| 322 | XCTAssertNil(d.tick(frames: 0, at: 31), "the background is not a stall") |
| 323 | XCTAssertEqual(d.tick(frames: 0, at: 33), .open("atem", format: 0)) |
| 324 | } |
| 325 | |
| 326 | func testRestartsAreRateLimited() { |
| 327 | let d = director(defaults()) |
| 328 | _ = d.start(devices: [atem]) |
| 329 | XCTAssertEqual(d.restart(at: 100), .open("atem", format: 0)) |
| 330 | XCTAssertNil(d.restart(at: 102)) |
| 331 | XCTAssertEqual(d.restart(at: 105), .open("atem", format: 0)) |
| 332 | } |
| 333 | |
| 334 | // MARK: Formats |
| 335 | |
| 336 | func testFrameRateIsAlwaysOneTheFormatSupports() { |
| 337 | XCTAssertEqual(CameraFormatChoice.rate(for: [1...30]), 30) |
| 338 | XCTAssertEqual(CameraFormatChoice.rate(for: [29.97...29.97]), 29.97) |
| 339 | XCTAssertEqual(CameraFormatChoice.rate(for: [25...25]), 25) |
| 340 | XCTAssertEqual(CameraFormatChoice.rate(for: [50...50, 59.94...59.94]), 50) |
| 341 | XCTAssertEqual(CameraFormatChoice.rate(for: [23.976...23.976, 60...60]), 23.976) |
| 342 | XCTAssertNil(CameraFormatChoice.rate(for: [])) |
| 343 | } |
| 344 | |
| 345 | func testATEMFormatsRankByRateNearestThirty() { |
| 346 | let rates: [Double] = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60] |
| 347 | let formats = rates.map { |
| 348 | CameraFormatChoice.Candidate(width: 1920, height: 1080, ranges: [$0...$0]) |
| 349 | } |
| 350 | let ranked = CameraFormatChoice.rank(formats).map { rates[$0] } |
| 351 | XCTAssertEqual(ranked.prefix(3), [30, 29.97, 25]) |
| 352 | XCTAssertEqual(ranked.count, rates.count) |
| 353 | } |
| 354 | |
| 355 | func testSmallerFormatsComeFirstAndDuplicatesGo() { |
| 356 | let formats = [ |
| 357 | CameraFormatChoice.Candidate(width: 1920, height: 1080, ranges: [1...30]), |
| 358 | CameraFormatChoice.Candidate(width: 640, height: 480, ranges: [1...30]), |
| 359 | CameraFormatChoice.Candidate(width: 640, height: 480, ranges: [1...30]), |
| 360 | CameraFormatChoice.Candidate(width: 1280, height: 720, ranges: [1...60]), |
| 361 | ] |
| 362 | XCTAssertEqual(CameraFormatChoice.rank(formats), [1, 3, 0]) |
| 363 | } |
| 364 | |
| 365 | // MARK: The panel |
| 366 | |
| 367 | func testCameraRowStates() { |
| 368 | XCTAssertEqual(CameraRowState(cameras: 0, offers: false, status: nil), .hidden) |
| 369 | XCTAssertEqual(CameraRowState(cameras: 0, offers: true, status: nil), .noCamera) |
| 370 | XCTAssertTrue(CameraRowState(cameras: 0, offers: true, status: .noCamera).offersRetry) |
| 371 | XCTAssertEqual( |
| 372 | CameraRowState(cameras: 2, offers: true, status: .live("ATEM Mini")), .picker(line: nil, retry: false)) |
| 373 | XCTAssertEqual( |
| 374 | CameraRowState(cameras: 1, offers: true, status: .starting("ATEM Mini")), |
| 375 | .picker(line: "Starting ATEM Mini…", retry: false)) |
| 376 | let silent = CameraRowState(cameras: 1, offers: true, status: .noSignal("ATEM Mini")) |
| 377 | XCTAssertTrue(silent.offersRetry) |
| 378 | XCTAssertEqual( |
| 379 | silent, |
| 380 | .picker( |
| 381 | line: "ATEM Mini is connected but sending no picture. Check its input, or choose another camera.", |
| 382 | retry: true)) |
| 383 | XCTAssertTrue(CameraRowState(cameras: 1, offers: true, status: .failed("ATEM Mini")).offersRetry) |
| 384 | } |
| 385 | |
| 386 | func testSelfTileSaysWhatItIsWaitingFor() { |
| 387 | XCTAssertEqual(CameraStatus.starting("ATEM Mini").tileText, "Starting camera…") |
| 388 | XCTAssertEqual(CameraStatus.disconnected("ATEM Mini").tileText, "Camera disconnected") |
| 389 | XCTAssertNil(CameraStatus.live("ATEM Mini").tileText) |
| 390 | } |
| 391 | |
| 392 | // MARK: The managed camera, end to end with a fake backend |
| 393 | |
| 394 | private final class FakeBackend: CameraBackend { |
| 395 | var devices: [CameraDevice] |
| 396 | var opened: [String] = [] |
| 397 | var refuse: Set<String> = [] |
| 398 | var devicesChanged: (@MainActor () -> Void)? |
| 399 | var trouble: (@MainActor () -> Void)? |
| 400 | init(_ devices: [CameraDevice]) { self.devices = devices } |
| 401 | func requestAccess() async throws {} |
| 402 | func formatCount(_ id: String) -> Int { 1 } |
| 403 | func open(_ id: String, format: Int, delegate: RTCVideoCapturerDelegate) async throws { |
| 404 | if refuse.contains(id) { throw NSError(domain: "test", code: 1) } |
| 405 | opened.append(id) |
| 406 | } |
| 407 | func close() async {} |
| 408 | } |
| 409 | |
| 410 | func testManagedCameraFollowsDisconnectAndReturn() async throws { |
| 411 | let store = defaults() |
| 412 | CameraPreference(id: "atem", name: "ATEM Mini").save(store) |
| 413 | let backend = FakeBackend([faceTime, atem]) |
| 414 | let camera = ManagedCamera(backend: backend, defaults: store) |
| 415 | var changes = 0 |
| 416 | camera.observe { changes += 1 } |
| 417 | RTCInitializeSSL() |
| 418 | let factory = RTCPeerConnectionFactory() |
| 419 | try await camera.start(source: factory.videoSource()) |
| 420 | XCTAssertEqual(camera.currentCamera, "atem") |
| 421 | XCTAssertEqual(camera.cameraStatus, .starting("ATEM Mini")) |
| 422 | |
| 423 | backend.devices = [faceTime] |
| 424 | backend.devicesChanged?() |
| 425 | try await waitFor { camera.currentCamera == "facetime" } |
| 426 | XCTAssertEqual(camera.cameraNote, "ATEM Mini disconnected; using FaceTime HD Camera") |
| 427 | |
| 428 | backend.devices = [faceTime, atem] |
| 429 | backend.devicesChanged?() |
| 430 | try await waitFor { camera.currentCamera == "atem" } |
| 431 | XCTAssertEqual(camera.cameraNote, "") |
| 432 | XCTAssertEqual(backend.opened, ["atem", "facetime", "atem"]) |
| 433 | XCTAssertGreaterThan(changes, 0) |
| 434 | camera.stop() |
| 435 | XCTAssertNil(camera.cameraStatus) |
| 436 | } |
| 437 | |
| 438 | func testManagedCameraRefusedOpenFallsBack() async throws { |
| 439 | let backend = FakeBackend([faceTime, atem]) |
| 440 | backend.refuse = ["facetime"] |
| 441 | let camera = ManagedCamera(backend: backend, defaults: defaults()) |
| 442 | RTCInitializeSSL() |
| 443 | try await camera.start(source: RTCPeerConnectionFactory().videoSource()) |
| 444 | XCTAssertEqual(camera.currentCamera, "atem") |
| 445 | XCTAssertEqual(camera.cameraNote, "FaceTime HD Camera would not start; using ATEM Mini") |
| 446 | do { |
| 447 | try await camera.use(camera: "nothing") |
| 448 | XCTFail("an unknown camera is refused") |
| 449 | } catch {} |
| 450 | camera.stop() |
| 451 | } |
| 452 | |
| 453 | func testManagedCameraOnTheLidClosedFallbackRemembersThePick() async throws { |
| 454 | let store = defaults() |
| 455 | CameraPreference(macBook).save(store) |
| 456 | let backend = FakeBackend([lidClosed, blackmagic]) |
| 457 | let camera = ManagedCamera(backend: backend, defaults: store) |
| 458 | RTCInitializeSSL() |
| 459 | try await camera.start(source: RTCPeerConnectionFactory().videoSource()) |
| 460 | XCTAssertEqual(backend.opened, [blackmagic.id], "the closed lid's camera is never opened") |
| 461 | XCTAssertEqual(camera.cameraNote, "MacBook Air Camera is off while the lid is closed; using Blackmagic Design") |
| 462 | // Picking the camera it is already on is remembered. |
| 463 | try await camera.use(camera: blackmagic.id) |
| 464 | XCTAssertEqual(CameraPreference.load(store), CameraPreference(blackmagic)) |
| 465 | XCTAssertEqual(camera.cameraNote, "") |
| 466 | camera.stop() |
| 467 | } |
| 468 | |
| 469 | private func waitFor(_ condition: @escaping @MainActor () -> Bool) async throws { |
| 470 | for _ in 0..<200 { |
| 471 | if condition() { return } |
| 472 | try await Task.sleep(for: .milliseconds(10)) |
| 473 | } |
| 474 | XCTFail("condition never held") |
| 475 | } |
| 476 | } |
| 477 | |