| 1 | import LiveKitWebRTC |
| 2 | import XCTest |
| 3 | |
| 4 | @testable import CallsKit |
| 5 | |
| 6 | /// The screen's sending half: what each "Optimise for" mode asks of the |
| 7 | /// sender, the camera capped beside a share and given back after it, and |
| 8 | /// the statistics line that says how the screen is actually leaving. |
| 9 | /// room-share.test.mjs holds the browser's sharePlan and cameraCap to the |
| 10 | /// same numbers; a change here belongs there too. |
| 11 | final class ScreenSendTests: XCTestCase { |
| 12 | // MARK: - Policy |
| 13 | |
| 14 | func testDetailKeepsResolutionAtTheHighestCeiling() { |
| 15 | let plan = ScreenSendPolicy.screen(.detail, width: 1920, height: 1080) |
| 16 | XCTAssertEqual(plan.degradation, .maintainResolution) |
| 17 | XCTAssertEqual(plan.maxBitrate, 3_500_000) |
| 18 | XCTAssertEqual(plan.maxFramerate, 30) |
| 19 | XCTAssertNil(plan.scaleResolutionDownBy, "a 1080p screen is sent whole") |
| 20 | XCTAssertTrue(plan.highPriority) |
| 21 | XCTAssertEqual(plan.bitratePriority, 4) |
| 22 | } |
| 23 | |
| 24 | func testMotionKeepsTheFrameRateAt1280() { |
| 25 | let plan = ScreenSendPolicy.screen(.motion, width: 1920, height: 1080) |
| 26 | XCTAssertEqual(plan.degradation, .maintainFramerate) |
| 27 | XCTAssertEqual(plan.maxFramerate, 30) |
| 28 | XCTAssertEqual(plan.scaleResolutionDownBy ?? 0, 1.5, accuracy: 0.0001, "1920 down to 1280") |
| 29 | XCTAssertTrue(plan.highPriority) |
| 30 | // A portrait window is scaled by its long side, too — and then a |
| 31 | // little further, to H.264 level 3.1's 3,600 blocks (see |
| 32 | // realtimeMacroblocks): 800×1280 would be 4,000. |
| 33 | let portrait = ScreenSendPolicy.screen(.motion, width: 1000, height: 1600).scaleResolutionDownBy ?? 0 |
| 34 | XCTAssertGreaterThan(portrait, 1.25) |
| 35 | XCTAssertLessThanOrEqual(blocks(1000, 1600, portrait), 3600) |
| 36 | // Something already small enough is left alone. |
| 37 | XCTAssertNil(ScreenSendPolicy.screen(.motion, width: 1280, height: 720).scaleResolutionDownBy) |
| 38 | } |
| 39 | |
| 40 | /// Paul's freeze of 2026-09-28: a 16:10 MacBook display in motion mode |
| 41 | /// was 1280×830 at its cap, over level 3.1's 3,600 blocks, and the Mac's |
| 42 | /// encoder refused every frame at that size. Motion now fits the level; |
| 43 | /// a screencast source (every other mode) is not held to it. |
| 44 | func testMotionFitsH264Level31() { |
| 45 | for (w, h) in [(3024, 1964), (1920, 1246), (2560, 1600), (1920, 1200), (1470, 956), (1920, 1080)] { |
| 46 | let scale = ScreenSendPolicy.screen(.motion, width: w, height: h).scaleResolutionDownBy ?? 1 |
| 47 | XCTAssertLessThanOrEqual(blocks(w, h, scale), 3600, "\(w)×\(h) at 1/\(scale)") |
| 48 | XCTAssertGreaterThan(blocks(w, h, scale), 3200, "\(w)×\(h) is not shrunk further than it has to be") |
| 49 | } |
| 50 | // 1080p at 1280×720 is exactly the level, and unchanged. |
| 51 | XCTAssertEqual(ScreenSendPolicy.screen(.motion, width: 1920, height: 1080).scaleResolutionDownBy ?? 0, 1.5, accuracy: 0.0001) |
| 52 | // Balanced and detail are screencasts and keep their full size. |
| 53 | XCTAssertNil(ScreenSendPolicy.screen(.balanced, width: 1920, height: 1246).scaleResolutionDownBy) |
| 54 | XCTAssertNil(ScreenSendPolicy.screen(.detail, width: 1920, height: 1200).scaleResolutionDownBy) |
| 55 | // A share that started in motion keeps a camera-like source whatever |
| 56 | // the mode becomes, and stays within the level. |
| 57 | let switched = ScreenSendPolicy.screen(.detail, width: 1920, height: 1246, screencast: false) |
| 58 | XCTAssertLessThanOrEqual(blocks(1920, 1246, switched.scaleResolutionDownBy ?? 1), 3600) |
| 59 | } |
| 60 | |
| 61 | private func blocks(_ w: Int, _ h: Int, _ scale: Double) -> Int { |
| 62 | ((Int(Double(w) / scale) + 15) / 16) * ((Int(Double(h) / scale) + 15) / 16) |
| 63 | } |
| 64 | |
| 65 | func testBalancedIsTheDefaultAndKeepsTheFrameRateAtFullSize() { |
| 66 | XCTAssertEqual(ScreenShareMode.allCases.first, .balanced) |
| 67 | let plan = ScreenSendPolicy.screen(.balanced, width: 1920, height: 1080) |
| 68 | // Frames are held; resolution gives way only when it must, from full |
| 69 | // size rather than motion's 1280. |
| 70 | XCTAssertEqual(plan.degradation, .maintainFramerate) |
| 71 | XCTAssertNil(plan.scaleResolutionDownBy) |
| 72 | XCTAssertEqual(plan.maxBitrate, 3_000_000) |
| 73 | XCTAssertEqual(plan.maxFramerate, 30) |
| 74 | XCTAssertTrue(plan.highPriority) |
| 75 | XCTAssertTrue(ScreenSendPolicy.screencast(.balanced)) |
| 76 | XCTAssertTrue(ScreenSendPolicy.screencast(.detail)) |
| 77 | XCTAssertFalse(ScreenSendPolicy.screencast(.motion)) |
| 78 | } |
| 79 | |
| 80 | func testH264IsPreferredAndNothingIsDropped() { |
| 81 | let factory = RTCPeerConnectionFactory( |
| 82 | encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory()) |
| 83 | let codecs = factory.rtpSenderCapabilities(forKind: "video").codecs |
| 84 | let preferred = ScreenSendPolicy.preferringH264(codecs) |
| 85 | XCTAssertEqual(preferred.count, codecs.count, "every codec stays on offer") |
| 86 | XCTAssertEqual(preferred.first?.name, "H264") |
| 87 | XCTAssertTrue(preferred.contains { $0.name == "VP8" }, "a browser without H.264 still has VP8") |
| 88 | } |
| 89 | |
| 90 | func testAScreenLargerThan1920IsNeverSentLarger() { |
| 91 | for mode in ScreenShareMode.allCases { |
| 92 | let plan = ScreenSendPolicy.screen(mode, width: 3840, height: 2160) |
| 93 | XCTAssertGreaterThanOrEqual(plan.scaleResolutionDownBy ?? 1, 2, "\(mode)") |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func testAnUnknownSizeLeavesTheScaleAlone() { |
| 98 | for mode in ScreenShareMode.allCases { |
| 99 | XCTAssertNil(ScreenSendPolicy.screen(mode).scaleResolutionDownBy, "\(mode)") |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func testTheCameraIsCappedWhileSharingAndGivenBackAfter() { |
| 104 | let capped = ScreenSendPolicy.camera(sharing: true, height: 480) |
| 105 | XCTAssertEqual(capped.maxBitrate, 500_000) |
| 106 | XCTAssertEqual(capped.scaleResolutionDownBy ?? 0, 480.0 / 360.0, accuracy: 0.0001) |
| 107 | XCTAssertFalse(capped.highPriority) |
| 108 | XCTAssertNil(ScreenSendPolicy.camera(sharing: true, height: 360).scaleResolutionDownBy) |
| 109 | XCTAssertEqual(ScreenSendPolicy.camera(sharing: false), EncodingPlan(), "the encoder's own defaults") |
| 110 | } |
| 111 | |
| 112 | func testTheModeIsRemembered() { |
| 113 | let defaults = UserDefaults.standard |
| 114 | let saved = defaults.object(forKey: ScreenShareMode.key) |
| 115 | defer { defaults.set(saved, forKey: ScreenShareMode.key) } |
| 116 | defaults.removeObject(forKey: ScreenShareMode.key) |
| 117 | XCTAssertEqual(ScreenShareMode.remembered, .balanced) |
| 118 | ScreenShareMode.remembered = .motion |
| 119 | XCTAssertEqual(ScreenShareMode.remembered, .motion) |
| 120 | defaults.set("nonsense", forKey: ScreenShareMode.key) |
| 121 | XCTAssertEqual(ScreenShareMode.remembered, .balanced) |
| 122 | } |
| 123 | |
| 124 | // MARK: - Applying it to a real sender |
| 125 | |
| 126 | /// The plan reaches a real WebRTC sender, and the camera's defaults |
| 127 | /// really come back: set, read back, restored. No call is placed. |
| 128 | func testAPlanIsAppliedToASenderAndRestored() throws { |
| 129 | let factory = RTCPeerConnectionFactory() |
| 130 | let configuration = RTCConfiguration() |
| 131 | configuration.sdpSemantics = .unifiedPlan |
| 132 | let pc = try XCTUnwrap( |
| 133 | factory.peerConnection( |
| 134 | with: configuration, |
| 135 | constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil), |
| 136 | delegate: nil)) |
| 137 | defer { pc.close() } |
| 138 | let track = factory.videoTrack(with: factory.videoSource(), trackId: "camera") |
| 139 | let sender = try XCTUnwrap(pc.add(track, streamIds: ["calls"])) |
| 140 | |
| 141 | XCTAssertTrue(sender.apply(ScreenSendPolicy.camera(sharing: true, height: 480))) |
| 142 | var encoding = try XCTUnwrap(sender.parameters.encodings.first) |
| 143 | XCTAssertEqual(encoding.maxBitrateBps?.intValue, 500_000) |
| 144 | XCTAssertEqual(encoding.scaleResolutionDownBy?.doubleValue ?? 0, 480.0 / 360.0, accuracy: 0.001) |
| 145 | XCTAssertFalse( |
| 146 | sender.apply(ScreenSendPolicy.camera(sharing: true, height: 480)), "the same plan twice is a no-op") |
| 147 | |
| 148 | XCTAssertTrue(sender.apply(ScreenSendPolicy.camera(sharing: false))) |
| 149 | encoding = try XCTUnwrap(sender.parameters.encodings.first) |
| 150 | XCTAssertNil(encoding.maxBitrateBps) |
| 151 | XCTAssertEqual(encoding.scaleResolutionDownBy?.doubleValue ?? 1, 1, accuracy: 0.001) |
| 152 | |
| 153 | XCTAssertTrue(sender.apply(ScreenSendPolicy.screen(.motion, width: 1920, height: 1080))) |
| 154 | let parameters = sender.parameters |
| 155 | encoding = try XCTUnwrap(parameters.encodings.first) |
| 156 | XCTAssertEqual(encoding.maxBitrateBps?.intValue, 2_500_000) |
| 157 | XCTAssertEqual(encoding.maxFramerate?.intValue, 30) |
| 158 | XCTAssertEqual(encoding.networkPriority, .high) |
| 159 | XCTAssertEqual(encoding.bitratePriority, 4) |
| 160 | XCTAssertEqual( |
| 161 | parameters.degradationPreference?.intValue, RTCDegradationPreference.maintainFramerate.rawValue) |
| 162 | } |
| 163 | |
| 164 | // MARK: - The readout |
| 165 | |
| 166 | private typealias Stats = [String: (type: String, values: [String: Any])] |
| 167 | |
| 168 | private func stats(mid: String = "2", local: String = "host", remote: String = "host") -> Stats { |
| 169 | [ |
| 170 | "OT1": ( |
| 171 | "outbound-rtp", |
| 172 | [ |
| 173 | "mid": mid, "frameWidth": 1920, "frameHeight": 1080, "framesPerSecond": 8.0, |
| 174 | "framesSent": 400, "framesEncoded": 400, "qualityLimitationReason": "bandwidth", |
| 175 | "qualityLimitationDurations": ["none": 2.0, "bandwidth": 30.0, "cpu": 0.0, "other": 0.0], |
| 176 | "targetBitrate": 1_200_000, "bytesSent": 5_000_000, |
| 177 | "encoderImplementation": "VideoToolbox", "totalEncodeTime": 8.0, |
| 178 | "mediaSourceId": "SV1", "codecId": "C1", |
| 179 | ] |
| 180 | ), |
| 181 | "OT0": ("outbound-rtp", ["mid": "1", "frameWidth": 640, "framesPerSecond": 30.0]), |
| 182 | "SV1": ("media-source", ["frames": 900, "framesPerSecond": 30.0, "width": 1920, "height": 1080]), |
| 183 | "C1": ("codec", ["mimeType": "video/H264"]), |
| 184 | "RI1": ("remote-inbound-rtp", ["localId": "OT1", "packetsLost": 12, "roundTripTime": 0.085]), |
| 185 | "T1": ("transport", ["selectedCandidatePairId": "P1"]), |
| 186 | "P1": ("candidate-pair", ["localCandidateId": "L1", "remoteCandidateId": "R1"]), |
| 187 | "L1": ("local-candidate", ["candidateType": local]), |
| 188 | "R1": ("remote-candidate", ["candidateType": remote]), |
| 189 | ] |
| 190 | } |
| 191 | |
| 192 | func testTheScreensOwnStreamIsReadByMid() throws { |
| 193 | let r = try XCTUnwrap(ScreenSendReport.read(stats(), mid: "2")) |
| 194 | XCTAssertEqual(r.width, 1920) |
| 195 | XCTAssertEqual(r.height, 1080) |
| 196 | XCTAssertEqual(r.framesPerSecond, 8) |
| 197 | XCTAssertEqual(r.capturedPerSecond, 30, "the capture is fine; the sender is not keeping up") |
| 198 | XCTAssertEqual(r.framesCaptured, 900) |
| 199 | XCTAssertEqual(r.sourceWidth, 1920) |
| 200 | XCTAssertEqual(r.limitation, "bandwidth") |
| 201 | XCTAssertEqual(r.limitationSeconds["bandwidth"], 30) |
| 202 | XCTAssertEqual(r.targetBitrate, 1_200_000) |
| 203 | XCTAssertEqual(r.codec, "video/H264") |
| 204 | XCTAssertTrue(r.hardwareEncoder) |
| 205 | XCTAssertEqual(r.encodeMsPerFrame, 20, accuracy: 0.001) |
| 206 | XCTAssertEqual(r.packetsLost, 12) |
| 207 | XCTAssertEqual(r.roundTripTime, 0.085) |
| 208 | XCTAssertEqual(r.path, "host") |
| 209 | XCTAssertNil(ScreenSendReport.read(stats(), mid: "9"), "a stream not negotiated yet") |
| 210 | XCTAssertNil(ScreenSendReport.read(stats(), mid: "")) |
| 211 | } |
| 212 | |
| 213 | func testARelayAtEitherEndIsTheRelay() { |
| 214 | XCTAssertEqual(ScreenSendReport.path(stats(local: "relay")), "relay") |
| 215 | XCTAssertEqual(ScreenSendReport.path(stats(remote: "relay")), "relay") |
| 216 | XCTAssertEqual(ScreenSendReport.path(stats(local: "srflx", remote: "host")), "srflx") |
| 217 | } |
| 218 | |
| 219 | func testTheSummaryIsOneLine() throws { |
| 220 | var r = try XCTUnwrap(ScreenSendReport.read(stats(local: "relay"), mid: "2")) |
| 221 | XCTAssertEqual( |
| 222 | r.summary, "Screen: 1920×1080 at 8 fps, limited by bandwidth, 1.2 Mbit/s, relay (H264 hardware)") |
| 223 | var previous = r |
| 224 | previous.bytesSent = 4_900_000 |
| 225 | previous.timestamp = 9 |
| 226 | r.timestamp = 10 |
| 227 | r = r.measured(since: previous) |
| 228 | XCTAssertEqual(r.sentBitrate, 800_000, "the rate actually sent wins over the target") |
| 229 | XCTAssertTrue(r.summary.contains("800 kbit/s")) |
| 230 | XCTAssertEqual(ScreenSendReport().summary, "Screen: starting…") |
| 231 | } |
| 232 | |
| 233 | func testTheSlowestConnectionIsTheOneShown() { |
| 234 | var fast = ScreenSendReport() |
| 235 | fast.framesPerSecond = 29 |
| 236 | var slow = ScreenSendReport() |
| 237 | slow.framesPerSecond = 4 |
| 238 | XCTAssertEqual(ScreenSendReport.worst([fast, slow])?.framesPerSecond, 4) |
| 239 | XCTAssertNil(ScreenSendReport.worst([])) |
| 240 | } |
| 241 | } |
| 242 | |