| 1 | import SwiftUI |
| 2 | import XCTest |
| 3 | |
| 4 | @_spi(Fixtures) @testable import CallsKit |
| 5 | |
| 6 | /// The screen watchdogs (ScreenWatchdog.swift): the sharer's, which puts |
| 7 | /// its own screen right, and the receiver's, which says when someone |
| 8 | /// else's has stopped. Written for Paul's report of 2026-09-28. |
| 9 | final class ScreenWatchdogTests: XCTestCase { |
| 10 | private func counts(_ beats: UInt64, _ delivered: UInt64) -> ScreenCaptureCounts { |
| 11 | ScreenCaptureCounts(heartbeats: beats, delivered: delivered) |
| 12 | } |
| 13 | private func sending(_ n: UInt64) -> [String: ScreenSendCounts] { |
| 14 | ["vicky": ScreenSendCounts(encoded: n, sent: n, keyframes: 1)] |
| 15 | } |
| 16 | |
| 17 | func testAHealthyShareAsksForNothing() { |
| 18 | var dog = ScreenWatchdog() |
| 19 | for t in 0..<30 { |
| 20 | let n = UInt64(t * 30) |
| 21 | XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: []), []) |
| 22 | } |
| 23 | XCTAssertEqual(dog.status, "watching") |
| 24 | XCTAssertFalse(dog.recovering) |
| 25 | } |
| 26 | |
| 27 | func testAStillScreenIsNotAStall() { |
| 28 | // ScreenCaptureKit reports a still screen as idle, and the capture |
| 29 | // repeats the last frame once a second: heartbeats keep coming and |
| 30 | // frames trickle, encoded as they go. |
| 31 | var dog = ScreenWatchdog() |
| 32 | for t in 0..<30 { |
| 33 | let actions = dog.observe( |
| 34 | at: Double(t), capture: counts(UInt64(t * 30), UInt64(t)), sending: sending(UInt64(t)), stalledBy: []) |
| 35 | XCTAssertEqual(actions, []) |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func testACaptureThatStopsReportingIsRestartedWithAGrowingWait() { |
| 40 | var dog = ScreenWatchdog() |
| 41 | var restarts: [Int] = [] |
| 42 | for t in 0..<40 { |
| 43 | let beats: UInt64 = t < 5 ? UInt64(t * 30) : 120 |
| 44 | let actions = dog.observe(at: Double(t), capture: counts(beats, beats), sending: sending(beats), stalledBy: []) |
| 45 | if actions.contains(.restartCapture) { restarts.append(t) } |
| 46 | } |
| 47 | // Stalled from t=4 on: first restart three seconds later, then 5, 10, 20 s apart. |
| 48 | XCTAssertEqual(restarts, [7, 12, 22]) |
| 49 | XCTAssertTrue(dog.captureStalled) |
| 50 | XCTAssertEqual(dog.captureRestarts, 3) |
| 51 | // And it comes back. |
| 52 | XCTAssertEqual(dog.observe(at: 40, capture: counts(500, 500), sending: sending(500), stalledBy: []), []) |
| 53 | XCTAssertFalse(dog.captureStalled) |
| 54 | } |
| 55 | |
| 56 | func testACaptureThatCannotTellIdleFromStalledIsNotWatched() { |
| 57 | // A ReplayKit broadcast sends nothing for a still screen. |
| 58 | var dog = ScreenWatchdog() |
| 59 | for t in 0..<20 { |
| 60 | let actions = dog.observe( |
| 61 | at: Double(t), capture: ScreenCaptureCounts(heartbeats: nil, delivered: 7), sending: sending(7), stalledBy: []) |
| 62 | XCTAssertEqual(actions, []) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func testAnEncoderThatStopsGetsAKeyframeThenAFreshEncoderThenANewTransceiver() { |
| 67 | var dog = ScreenWatchdog() |
| 68 | var log: [(Int, ScreenRecovery)] = [] |
| 69 | for t in 0..<47 { |
| 70 | let delivered = UInt64(t * 30) |
| 71 | let encoded: UInt64 = t < 5 ? delivered : 120 |
| 72 | for a in dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: []) { |
| 73 | log.append((t, a)) |
| 74 | } |
| 75 | } |
| 76 | XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky"), .keyframe("vicky")]) |
| 77 | // Five seconds after it stopped (t=4), three seconds apart, then a |
| 78 | // rest of thirty seconds before starting over. |
| 79 | // (not before the connection's first ten seconds are up) |
| 80 | XCTAssertEqual(log.map(\.0), [10, 13, 16, 46]) |
| 81 | XCTAssertEqual(dog.keyframes, 2) |
| 82 | XCTAssertEqual(dog.encoderRecoveries, 1) |
| 83 | XCTAssertEqual(dog.reAdds, 1) |
| 84 | } |
| 85 | |
| 86 | func testAnEncoderStoppedForOnePersonIsMendedForThemAlone() { |
| 87 | // Each connection has an encoder of its own: the browser's (H.264 at |
| 88 | // the level Chromium answers) can refuse frames the app's (High 5.2) |
| 89 | // takes. The steps go to the stopped one only. |
| 90 | var dog = ScreenWatchdog() |
| 91 | var log: [(Int, ScreenRecovery)] = [] |
| 92 | for t in 0..<20 { |
| 93 | let delivered = UInt64(t * 30) |
| 94 | let sending = [ |
| 95 | "browser": ScreenSendCounts(encoded: t < 5 ? delivered : 120, sent: t < 5 ? delivered : 120, keyframes: 1), |
| 96 | "app": ScreenSendCounts(encoded: delivered, sent: delivered, keyframes: 1), |
| 97 | ] |
| 98 | for a in dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending, stalledBy: []) { |
| 99 | log.append((t, a)) |
| 100 | } |
| 101 | } |
| 102 | XCTAssertEqual(log.map(\.1), [.keyframe("browser"), .recreateEncoder("browser"), .reAdd("browser")]) |
| 103 | } |
| 104 | |
| 105 | func testAnEncoderThatRecoversAfterTheKeyframeIsLeftAlone() { |
| 106 | var dog = ScreenWatchdog() |
| 107 | var actions: [ScreenRecovery] = [] |
| 108 | for t in 0..<30 { |
| 109 | let delivered = UInt64(t * 30) |
| 110 | let encoded: UInt64 = (5..<11).contains(t) ? 120 : delivered |
| 111 | actions += dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: []) |
| 112 | } |
| 113 | XCTAssertEqual(actions, [.keyframe("vicky")]) |
| 114 | XCTAssertFalse(dog.recovering) |
| 115 | } |
| 116 | |
| 117 | func testNothingEncodedInTheFirstTenSecondsIsAWarmUp() { |
| 118 | var dog = ScreenWatchdog() |
| 119 | for t in 0..<10 { |
| 120 | let delivered = UInt64(t * 30) |
| 121 | XCTAssertEqual(dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(1), stalledBy: []), []) |
| 122 | } |
| 123 | XCTAssertEqual(dog.observe(at: 10, capture: counts(300, 300), sending: sending(1), stalledBy: []), [.keyframe("vicky")]) |
| 124 | } |
| 125 | |
| 126 | func testALinkThatHoldsTheScreenBackIsNotAnEncoderStall() { |
| 127 | // The estimate collapses and WebRTC stops encoding for a while: the |
| 128 | // link's business, and a smaller encoder would only stay smaller. |
| 129 | var dog = ScreenWatchdog() |
| 130 | for t in 0..<20 { |
| 131 | let delivered = UInt64(t * 30) |
| 132 | let encoded: UInt64 = t < 5 ? delivered : 120 |
| 133 | let counts = ["vicky": ScreenSendCounts(encoded: encoded, sent: encoded, keyframes: 1, bandwidthLimited: t >= 5)] |
| 134 | XCTAssertEqual(dog.observe(at: Double(t), capture: self.counts(delivered, delivered), sending: counts, stalledBy: []), []) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func testNothingGoingInIsNotAnEncoderStall() { |
| 139 | // No frames delivered (a broadcast of a still screen): nothing to encode. |
| 140 | var dog = ScreenWatchdog() |
| 141 | for t in 0..<20 { |
| 142 | let actions = dog.observe( |
| 143 | at: Double(t), capture: ScreenCaptureCounts(heartbeats: nil, delivered: 50), sending: sending(50), stalledBy: []) |
| 144 | XCTAssertEqual(actions, []) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func testAReceiversReportWhileTheScreenLeavesNormallyGetsKeyframesFirstAndNeverAShrink() { |
| 149 | var dog = ScreenWatchdog() |
| 150 | var log: [(Int, ScreenRecovery)] = [] |
| 151 | for t in 0..<20 { |
| 152 | let n = UInt64(t * 30) |
| 153 | let stalledBy: Set<String> = t >= 3 ? ["vicky"] : [] |
| 154 | for a in dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: stalledBy) { |
| 155 | log.append((t, a)) |
| 156 | } |
| 157 | } |
| 158 | let keyframes = log.filter { $0.1 == .keyframe("vicky") } |
| 159 | XCTAssertEqual(keyframes.map(\.0), [3, 7, 11]) |
| 160 | // What comes after the keyframes (spec §15.22) is a rebuild at the |
| 161 | // same size, never a smaller encoder. |
| 162 | XCTAssertFalse(log.contains { $0.1 == .recreateEncoder("vicky") }) |
| 163 | XCTAssertEqual(log.first?.1, .keyframe("vicky")) |
| 164 | XCTAssertEqual(dog.stallReports, 1, "one report, however long it lasts") |
| 165 | XCTAssertTrue(dog.recovering) |
| 166 | } |
| 167 | |
| 168 | func testAReceiversReportWhileTheScreenHasStoppedHereTooGoesFurther() { |
| 169 | var dog = ScreenWatchdog() |
| 170 | var actions: [ScreenRecovery] = [] |
| 171 | for t in 0..<30 { |
| 172 | let delivered = UInt64(t * 30) |
| 173 | let encoded: UInt64 = t < 5 ? delivered : 120 |
| 174 | actions += dog.observe( |
| 175 | at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), |
| 176 | stalledBy: t >= 6 ? ["vicky"] : []) |
| 177 | } |
| 178 | XCTAssertTrue(actions.contains(.recreateEncoder("vicky"))) |
| 179 | } |
| 180 | |
| 181 | func testAReportThatClearsEndsTheRecovery() { |
| 182 | var dog = ScreenWatchdog() |
| 183 | var actions: [ScreenRecovery] = [] |
| 184 | for t in 0..<20 { |
| 185 | let n = UInt64(t * 30) |
| 186 | let stalledBy: Set<String> = (3..<5).contains(t) ? ["vicky"] : [] |
| 187 | actions += dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: stalledBy) |
| 188 | } |
| 189 | XCTAssertEqual(actions, [.keyframe("vicky")]) |
| 190 | XCTAssertFalse(dog.recovering) |
| 191 | XCTAssertTrue(dog.status.hasPrefix("watching, 1 recovery")) |
| 192 | } |
| 193 | |
| 194 | func testAReAddedTransceiverStartingFromZeroIsNotAStall() { |
| 195 | var dog = ScreenWatchdog() |
| 196 | for t in 0..<10 { |
| 197 | let n = UInt64(t * 30) |
| 198 | XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(n), stalledBy: []), []) |
| 199 | } |
| 200 | // The connection's counters start again at zero on a new transceiver. |
| 201 | for t in 10..<20 { |
| 202 | let n = UInt64(t * 30) |
| 203 | let fresh = UInt64((t - 10) * 30) |
| 204 | XCTAssertEqual(dog.observe(at: Double(t), capture: counts(n, n), sending: sending(fresh), stalledBy: []), []) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // MARK: - A receiver that keeps reporting (spec §15.22) |
| 209 | |
| 210 | private func escalating( |
| 211 | _ range: Range<Int>, stalledBy: (Int) -> Set<String>, sent: (Int) -> [String: ScreenSendCounts]? = { _ in nil }, |
| 212 | dog: inout ScreenWatchdog |
| 213 | ) -> [(Int, ScreenRecovery)] { |
| 214 | var log: [(Int, ScreenRecovery)] = [] |
| 215 | for t in range { |
| 216 | let n = UInt64(t * 30) |
| 217 | for a in dog.observe(at: Double(t), capture: counts(n, n), sending: sent(t) ?? sending(n), stalledBy: stalledBy(t)) { |
| 218 | log.append((t, a)) |
| 219 | } |
| 220 | } |
| 221 | return log |
| 222 | } |
| 223 | |
| 224 | func testAReportKeyframesDoNotAnswerIsRebuiltThenSharedAgainThenGivenUp() { |
| 225 | var dog = ScreenWatchdog() |
| 226 | let log = escalating(0..<45, stalledBy: { $0 >= 3 ? ["vicky"] : [] }, dog: &dog) |
| 227 | let steps = log.filter { if case .keyframe = $0.1 { return false } else { return true } } |
| 228 | // Reported from 3: keyframes at once; six seconds on, still reported, |
| 229 | // the connection is rebuilt; ten more, everyone's screen shared again; |
| 230 | // ten more, given up. |
| 231 | XCTAssertEqual(steps.map(\.1), [.rebuild("vicky"), .reshare]) |
| 232 | XCTAssertEqual(steps.map(\.0), [9, 19]) |
| 233 | XCTAssertEqual(dog.escalation(of: "vicky"), .failed) |
| 234 | XCTAssertEqual(dog.rebuilds, 1) |
| 235 | XCTAssertEqual(dog.reshares, 1) |
| 236 | XCTAssertEqual(dog.failures, 1) |
| 237 | // A report alone never shrinks the screen or recreates its encoder: |
| 238 | // Text and detail stays Text and detail. |
| 239 | XCTAssertFalse(log.contains { if case .recreateEncoder = $0.1 { return true } else { return false } }) |
| 240 | XCTAssertEqual(dog.status, "a receiver's view could not be restored") |
| 241 | } |
| 242 | |
| 243 | func testAFailureStaysUntilTheReportClears() { |
| 244 | var dog = ScreenWatchdog() |
| 245 | _ = escalating(0..<45, stalledBy: { $0 >= 3 ? ["vicky"] : [] }, dog: &dog) |
| 246 | let later = escalating(45..<120, stalledBy: { $0 < 100 ? ["vicky"] : [] }, dog: &dog) |
| 247 | XCTAssertFalse(later.contains { $0.1 == .reshare || $0.1 == .rebuild("vicky") }, "nothing more is tried") |
| 248 | XCTAssertEqual(dog.escalation(of: "vicky"), .none, "and a view that comes back is forgotten") |
| 249 | } |
| 250 | |
| 251 | func testARebuildThatWorksEndsIt() { |
| 252 | var dog = ScreenWatchdog() |
| 253 | let log = escalating(0..<60, stalledBy: { (3..<12).contains($0) ? ["vicky"] : [] }, dog: &dog) |
| 254 | let steps = log.filter { if case .keyframe = $0.1 { return false } else { return true } } |
| 255 | XCTAssertEqual(steps.map(\.1), [.rebuild("vicky")]) |
| 256 | XCTAssertEqual(dog.escalation(of: "vicky"), .none) |
| 257 | XCTAssertEqual(dog.reshares, 0) |
| 258 | } |
| 259 | |
| 260 | func testRebuildsAndSharesAgainAreRationed() { |
| 261 | var dog = ScreenWatchdog() |
| 262 | // First stall: rebuilt at 9, and that works. |
| 263 | _ = escalating(0..<20, stalledBy: { (3..<12).contains($0) ? ["vicky"] : [] }, dog: &dog) |
| 264 | // A second, within the minute: no rebuild so soon, so straight to a |
| 265 | // share again; that works too. |
| 266 | let second = escalating(20..<50, stalledBy: { (25..<34).contains($0) ? ["vicky"] : [] }, dog: &dog) |
| 267 | XCTAssertEqual(second.filter { $0.1 == .reshare || $0.1 == .rebuild("vicky") }.map(\.1), [.reshare]) |
| 268 | // A third, past the minute but inside five: rebuilt, and then there is |
| 269 | // no share again to be had, so it is given up. |
| 270 | let third = escalating(80..<120, stalledBy: { $0 >= 85 ? ["vicky"] : [] }, dog: &dog) |
| 271 | XCTAssertEqual(third.filter { $0.1 == .reshare || $0.1 == .rebuild("vicky") }.map(\.1), [.rebuild("vicky")]) |
| 272 | XCTAssertEqual(dog.escalation(of: "vicky"), .failed) |
| 273 | } |
| 274 | |
| 275 | func testNothingSentIsNothingToEscalate() { |
| 276 | // A still screen with nothing new to send, or a link holding it all |
| 277 | // back: the receiver cannot be failing to decode frames that never |
| 278 | // left, so keyframes are all it gets. |
| 279 | var dog = ScreenWatchdog() |
| 280 | var log: [(Int, ScreenRecovery)] = [] |
| 281 | for t in 0..<40 { |
| 282 | // Reporting all along (idle reports), nothing new delivered or sent |
| 283 | // after t=3. |
| 284 | let n = UInt64(min(t, 3) * 30) |
| 285 | let still = ["vicky": ScreenSendCounts(encoded: n, sent: n, keyframes: 1)] |
| 286 | for a in dog.observe( |
| 287 | at: Double(t), capture: counts(UInt64(t * 30), n), sending: still, stalledBy: t >= 3 ? ["vicky"] : []) |
| 288 | { |
| 289 | log.append((t, a)) |
| 290 | } |
| 291 | } |
| 292 | XCTAssertFalse(log.isEmpty) |
| 293 | XCTAssertTrue(log.allSatisfy { if case .keyframe = $0.1 { return true } else { return false } }, "\(log)") |
| 294 | XCTAssertEqual(dog.escalation(of: "vicky"), .keyframes(since: 3)) |
| 295 | } |
| 296 | |
| 297 | func testARebuildThatNeverComesBackStillEndsInWords() { |
| 298 | // The rebuilt connection never negotiates a screen again: there is |
| 299 | // nothing to count, and the steps go on regardless. |
| 300 | var dog = ScreenWatchdog() |
| 301 | let log = escalating( |
| 302 | 0..<45, stalledBy: { $0 >= 3 ? ["vicky"] : [] }, sent: { $0 > 9 ? [:] : nil }, dog: &dog) |
| 303 | XCTAssertTrue(log.contains { $0.1 == .reshare }) |
| 304 | XCTAssertEqual(dog.escalation(of: "vicky"), .failed) |
| 305 | } |
| 306 | |
| 307 | func testOneShareAgainAnswersEveryReport() { |
| 308 | var dog = ScreenWatchdog() |
| 309 | var log: [(Int, ScreenRecovery)] = [] |
| 310 | for t in 0..<30 { |
| 311 | let n = UInt64(t * 30) |
| 312 | let both = ["vicky": ScreenSendCounts(encoded: n, sent: n, keyframes: 1), "tony": ScreenSendCounts(encoded: n, sent: n, keyframes: 1)] |
| 313 | let stalled: Set<String> = t >= 3 ? ["vicky", "tony"] : [] |
| 314 | for a in dog.observe(at: Double(t), capture: counts(n, n), sending: both, stalledBy: stalled) { log.append((t, a)) } |
| 315 | } |
| 316 | XCTAssertEqual(log.filter { $0.1 == .reshare }.count, 1) |
| 317 | XCTAssertEqual(log.filter { if case .rebuild = $0.1 { return true } else { return false } }.count, 2) |
| 318 | } |
| 319 | |
| 320 | func testTheNoticeMovesOnInWords() { |
| 321 | XCTAssertEqual(ScreenWords.notice(recovering: [], restarting: [], failed: []), "") |
| 322 | XCTAssertEqual( |
| 323 | ScreenWords.notice(recovering: ["Samuel"], restarting: [], failed: []), |
| 324 | "Samuel\u{2019}s view of your screen stalled \u{2014} recovering") |
| 325 | XCTAssertEqual( |
| 326 | ScreenWords.notice(recovering: [], restarting: ["Samuel"], failed: []), "Restarting your share for Samuel\u{2026}") |
| 327 | XCTAssertEqual( |
| 328 | ScreenWords.notice(recovering: [], restarting: [], failed: ["Samuel"]), |
| 329 | "Couldn\u{2019}t restore Samuel\u{2019}s view \u{2014} try Stop and Share again") |
| 330 | XCTAssertEqual( |
| 331 | ScreenWords.notice(recovering: ["Tony"], restarting: [], failed: ["Samuel"]), |
| 332 | "Couldn\u{2019}t restore Samuel\u{2019}s view \u{2014} try Stop and Share again. Tony\u{2019}s view of your screen stalled \u{2014} recovering") |
| 333 | XCTAssertEqual( |
| 334 | ScreenWords.notice(recovering: [], restarting: ["A", "B", "C"], failed: []), "Restarting your share for 3 people\u{2026}") |
| 335 | } |
| 336 | |
| 337 | func testTheReceiversNumbersNameTheStall() { |
| 338 | var before = ScreenReceiveReport() |
| 339 | before.mid = "3" |
| 340 | before.packetsReceived = 1000 |
| 341 | before.framesReceived = 300 |
| 342 | before.framesDecoded = 300 |
| 343 | var wedged = before |
| 344 | wedged.packetsReceived = 1400 |
| 345 | wedged.framesReceived = 420 |
| 346 | XCTAssertEqual(wedged.diagnosis(since: before), "frames arriving, none decoded (the decoder)") |
| 347 | XCTAssertTrue(wedged.moved(since: before).hasPrefix("packets +400, frames received +120, decoded +0")) |
| 348 | XCTAssertEqual(before.diagnosis(since: before), "no packets arriving (the sender or the path)") |
| 349 | var rebuilt = wedged |
| 350 | rebuilt.mid = "5" |
| 351 | XCTAssertEqual(rebuilt.diagnosis(since: before), "no earlier reading") |
| 352 | } |
| 353 | |
| 354 | // MARK: - The receiver |
| 355 | |
| 356 | func testAScreenThatStopsMovingIsReportedAfterFourSecondsAndForThreeMinutesAtMost() { |
| 357 | var watch = ScreenReceiveWatch() |
| 358 | var reported: [Int] = [] |
| 359 | for t in 0..<200 { |
| 360 | let decoded: UInt64 = t < 10 ? UInt64(t * 10) : 90 |
| 361 | if !watch.observe(at: Double(t), decoded: ["paul": decoded]).isEmpty { reported.append(t) } |
| 362 | } |
| 363 | XCTAssertEqual(reported.first, 13, "last moved at 9: stalled from 13") |
| 364 | XCTAssertEqual(reported.last, 188, "and no longer than three minutes after it last moved") |
| 365 | } |
| 366 | |
| 367 | func testARebuiltScreenStartingFromZeroIsMovingNotStalled() { |
| 368 | // 2026-09-30: a screen the sharer rebuilt arrives on a new receiver |
| 369 | // whose count starts again. Up to 0.4.4 only a larger count was |
| 370 | // movement, so a rebuilt screen that was moving again stayed stalled |
| 371 | // here — and the sharer's notice with it — until the new count passed |
| 372 | // the old. |
| 373 | var watch = ScreenReceiveWatch() |
| 374 | for t in 0..<10 { _ = watch.observe(at: Double(t), decoded: ["paul": 900 + UInt64(t * 30)]) } |
| 375 | for t in 10..<16 { _ = watch.observe(at: Double(t), decoded: ["paul": 1170]) } |
| 376 | XCTAssertEqual(watch.observe(at: 16, decoded: ["paul": 1170]), ["paul"]) |
| 377 | for t in 17..<40 { |
| 378 | XCTAssertEqual(watch.observe(at: Double(t), decoded: ["paul": UInt64((t - 17) * 30)]), [], "t=\(t)") |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | func testAStalledPictureIsAttachedAgainOnceFourSecondsIn() { |
| 383 | var watch = ScreenReceiveWatch() |
| 384 | var reattached: [Int] = [] |
| 385 | for t in 0..<60 { |
| 386 | let decoded: UInt64 = t < 10 ? UInt64(t * 10) : 90 |
| 387 | _ = watch.observe(at: Double(t), decoded: ["paul": decoded]) |
| 388 | if watch.reattach == ["paul"] { reattached.append(t) } |
| 389 | } |
| 390 | XCTAssertEqual(reattached, [17], "stalled from 13, attached again four seconds later, once") |
| 391 | // Moving again and stalled again: a new stall, a new try. |
| 392 | for t in 60..<63 { _ = watch.observe(at: Double(t), decoded: ["paul": UInt64(t)]) } |
| 393 | var again: [Int] = [] |
| 394 | for t in 63..<80 { |
| 395 | _ = watch.observe(at: Double(t), decoded: ["paul": 62]) |
| 396 | if !watch.reattach.isEmpty { again.append(t) } |
| 397 | } |
| 398 | XCTAssertEqual(again, [70]) |
| 399 | } |
| 400 | |
| 401 | func testAMovingScreenIsNeverReported() { |
| 402 | var watch = ScreenReceiveWatch() |
| 403 | for t in 0..<30 { |
| 404 | XCTAssertEqual(watch.observe(at: Double(t), decoded: ["paul": UInt64(t)]), []) |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | func testAScreenThatGoesIsForgotten() { |
| 409 | var watch = ScreenReceiveWatch() |
| 410 | for t in 0..<10 { _ = watch.observe(at: Double(t), decoded: ["paul": 5]) } |
| 411 | XCTAssertEqual(watch.observe(at: 10, decoded: ["paul": 5]), ["paul"]) |
| 412 | XCTAssertEqual(watch.observe(at: 11, decoded: [:]), []) |
| 413 | // Back again, a fresh start. |
| 414 | XCTAssertEqual(watch.observe(at: 12, decoded: ["paul": 5]), []) |
| 415 | } |
| 416 | |
| 417 | // MARK: - Words and wires |
| 418 | |
| 419 | func testTheSharersNotice() { |
| 420 | XCTAssertEqual(ScreenWords.recovering(names: []), "") |
| 421 | XCTAssertEqual(ScreenWords.recovering(names: ["Vicky"]), "Vicky\u{2019}s view of your screen stalled \u{2014} recovering") |
| 422 | XCTAssertEqual( |
| 423 | ScreenWords.recovering(names: ["Tony", "Vicky"]), |
| 424 | "Tony\u{2019}s and Vicky\u{2019}s views of your screen stalled \u{2014} recovering") |
| 425 | XCTAssertEqual(ScreenWords.recovering(names: ["A", "B", "C"]), "3 people\u{2019}s views of your screen stalled \u{2014} recovering") |
| 426 | } |
| 427 | |
| 428 | func testTheStalledListIsReadFromTheRoster() throws { |
| 429 | let json = #"{"muted":false,"cameraOff":false,"screenStalled":["0123456789abcdef0123456789abcdef"]}"# |
| 430 | let media = try JSONDecoder().decode(PeerMedia.self, from: Data(json.utf8)) |
| 431 | XCTAssertEqual(media.screenStalled, ["0123456789abcdef0123456789abcdef"]) |
| 432 | let plain = try JSONDecoder().decode(PeerMedia.self, from: Data(#"{"muted":true,"cameraOff":false}"#.utf8)) |
| 433 | XCTAssertNil(plain.screenStalled) |
| 434 | } |
| 435 | |
| 436 | func testTheDetailLineSaysWhatTheEncoderAndTheWatchdogAreDoing() { |
| 437 | var before = ScreenSendReport() |
| 438 | before.timestamp = 10 |
| 439 | before.framesEncoded = 100 |
| 440 | before.framesSent = 100 |
| 441 | var now = before |
| 442 | now.timestamp = 12 |
| 443 | now.framesEncoded = 160 |
| 444 | now.framesSent = 158 |
| 445 | now.keyFramesEncoded = 3 |
| 446 | now.keyframeRequests = 2 |
| 447 | XCTAssertEqual( |
| 448 | now.detail(since: before, watchdog: "watching"), |
| 449 | "Encoded 30 fps, sent 29 fps, keyframes 3 (2 asked for); watchdog: watching") |
| 450 | } |
| 451 | |
| 452 | // MARK: - ICE renewal (§5.3) |
| 453 | |
| 454 | private func ice(_ renewAt: Int64, servers: Int = 1) -> ICEConfig { |
| 455 | ICEConfig( |
| 456 | iceServers: (0..<servers).map { _ in .init(urls: ["turn:relay"], username: "u", credential: "c") }, |
| 457 | renewAt: renewAt, expiresAt: renewAt + 1800) |
| 458 | } |
| 459 | |
| 460 | func testAFreshListRestartsOnlyWhenARenewalIsDue() { |
| 461 | // Every resume brings a fresh list, five minutes apart; 0.4.1 |
| 462 | // restarted ICE on every one of them. |
| 463 | let built = ice(10_000) |
| 464 | XCTAssertFalse(ICERenewal.restart(gathered: built, next: ice(10_300), now: 1_300)) |
| 465 | XCTAssertFalse(ICERenewal.restart(gathered: built, next: ice(10_600), now: 9_999)) |
| 466 | XCTAssertTrue(ICERenewal.restart(gathered: built, next: ice(19_000), now: 10_000)) |
| 467 | XCTAssertTrue(ICERenewal.restart(gathered: built, next: ice(19_000), now: 10_290)) |
| 468 | } |
| 469 | |
| 470 | func testTheFloorHasNothingToRenewAndARelayArrivingIsAdopted() { |
| 471 | XCTAssertFalse(ICERenewal.restart(gathered: ice(0, servers: 0), next: ice(0, servers: 0), now: 5)) |
| 472 | XCTAssertTrue(ICERenewal.restart(gathered: ice(0, servers: 0), next: ice(10_000), now: 5)) |
| 473 | XCTAssertFalse(ICERenewal.restart(gathered: ice(10_000), next: ice(0, servers: 0), now: 20_000)) |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | /// The sharing notice's own Stop sharing button (Paul, 2026-09-28: "I |
| 478 | /// would expect this notification to have a 'Stop sharing' button |
| 479 | /// inline"), and the recovery notice stacked under it. |
| 480 | @MainActor final class SharingNoticeTests: XCTestCase { |
| 481 | func testTheSharingNoticeCarriesStopSharingAndTheRecoveryNoticeSitsUnderIt() { |
| 482 | let notices = CallNotices.notices( |
| 483 | phase: "admitted", status: "In the meeting", sharingScreen: true, screenNote: "", floor: false, |
| 484 | screenRecovery: ScreenWords.recovering(names: ["Vicky"])) |
| 485 | XCTAssertEqual(notices.map(\.kind), [.sharing, .screenRecovering]) |
| 486 | XCTAssertEqual(notices[0].text, "You are sharing your screen") |
| 487 | XCTAssertEqual(notices[0].action, .stopSharing) |
| 488 | XCTAssertNil(notices[1].action, "the recovery notice has no button of its own") |
| 489 | XCTAssertTrue(notices.allSatisfy(\.important), "both stay while the controls fade") |
| 490 | XCTAssertFalse(notices.contains(where: \.dismissible)) |
| 491 | XCTAssertNotEqual(notices[0].id, notices[1].id, "two rows, never one over the other") |
| 492 | } |
| 493 | |
| 494 | func testNotSharingHasNoStopButton() { |
| 495 | let notices = CallNotices.notices( |
| 496 | phase: "admitted", status: "In the meeting", sharingScreen: false, screenNote: "", floor: false, |
| 497 | screenRecovery: "ignored while not sharing") |
| 498 | XCTAssertTrue(notices.allSatisfy { $0.action == nil }) |
| 499 | XCTAssertFalse(notices.contains { $0.kind == .screenRecovering }) |
| 500 | } |
| 501 | |
| 502 | func testTheButtonStopsTheShare() { |
| 503 | let call = Call(playAudio: false) |
| 504 | call.showFixtureSelf(muted: false, cameraOff: false, sharingScreen: true) |
| 505 | XCTAssertTrue(call.sharingScreen) |
| 506 | call.perform(.stopSharing) |
| 507 | XCTAssertFalse(call.sharingScreen) |
| 508 | } |
| 509 | } |
| 510 | |