| 1 | import XCTest |
| 2 | |
| 3 | @testable @_spi(Fixtures) import CallsKit |
| 4 | |
| 5 | /// Spec §15.26: the quality report built from fixture statistics — counts |
| 6 | /// over the window, rates, the schema's enums and bounds — and nothing in |
| 7 | /// it that names a person, a device or an address; the notices tallied; |
| 8 | /// the device kinds; "Report a problem"'s anonymous diagnostics and its |
| 9 | /// sheet's states. |
| 10 | final class QualityReportTests: XCTestCase { |
| 11 | // MARK: - Fixtures |
| 12 | |
| 13 | static let client = QualityClient(app: "mac", version: "0.4.8", os: "macos", osVersion: "26.0.1") |
| 14 | |
| 15 | /// One connection's statistics, as WebRTC's report has them, with the |
| 16 | /// things that must never be reported planted in it: an address, a |
| 17 | /// port, a name in a track's label, a device's name. |
| 18 | static func sample( |
| 19 | generation: Int64 = 1, scale: Double = 1, screen: Bool = false, lostOut: Double = 3, |
| 20 | relayTLS: Bool = true, nan: Bool = false |
| 21 | ) -> QualityPeerSample { |
| 22 | var stats: [String: QualityStat] = [ |
| 23 | "OA": QualityStat( |
| 24 | type: "outbound-rtp", numbers: ["bytesSent": 120_000 * scale, "packetsSent": 1500 * scale], |
| 25 | strings: ["kind": "audio", "mid": "0", "trackIdentifier": "Vicky Wallace's microphone"]), |
| 26 | "RIA": QualityStat( |
| 27 | type: "remote-inbound-rtp", |
| 28 | numbers: ["packetsLost": lostOut * scale, "roundTripTime": 0.042, "jitter": 0.004], |
| 29 | strings: ["kind": "audio", "localId": "OA"]), |
| 30 | "OV": QualityStat( |
| 31 | type: "outbound-rtp", |
| 32 | numbers: [ |
| 33 | "bytesSent": 1_500_000 * scale, "packetsSent": 1800 * scale, "frameWidth": 1280, "frameHeight": 720, |
| 34 | "framesPerSecond": nan ? .nan : 29.97, |
| 35 | ], |
| 36 | strings: ["kind": "video", "mid": "1", "qualityLimitationReason": "bandwidth"]), |
| 37 | "IA": QualityStat( |
| 38 | type: "inbound-rtp", |
| 39 | numbers: [ |
| 40 | "bytesReceived": 110_000 * scale, "packetsReceived": 1490 * scale, "packetsLost": 10 * scale, |
| 41 | "jitter": 0.012, "totalSamplesReceived": 1_440_000 * scale, "concealedSamples": 4800 * scale, |
| 42 | "insertedSamplesForDeceleration": 960 * scale, "removedSamplesForAcceleration": 480 * scale, |
| 43 | "jitterBufferDelay": 72_000 * 0.06 * scale, "jitterBufferEmittedCount": 72_000 * scale, |
| 44 | ], |
| 45 | strings: ["kind": "audio", "mid": "0"]), |
| 46 | "IV": QualityStat( |
| 47 | type: "inbound-rtp", |
| 48 | numbers: [ |
| 49 | "bytesReceived": 1_200_000 * scale, "packetsReceived": 1700 * scale, "packetsLost": 5 * scale, |
| 50 | "jitter": 0.02, "freezeCount": 1 * scale, "totalFreezesDuration": 0.5 * scale, |
| 51 | "framesDecoded": 890 * scale, "framesDropped": 2 * scale, "frameWidth": 640, "frameHeight": 480, |
| 52 | "framesPerSecond": 30, |
| 53 | ], |
| 54 | strings: ["kind": "video", "mid": "1"]), |
| 55 | "T": QualityStat(type: "transport", strings: ["selectedCandidatePairId": "CP"]), |
| 56 | "CP": QualityStat( |
| 57 | type: "candidate-pair", numbers: ["currentRoundTripTime": 0.038, "nominated": 1], |
| 58 | strings: ["localCandidateId": "LC", "remoteCandidateId": "RC", "state": "succeeded"]), |
| 59 | "LC": QualityStat( |
| 60 | type: "local-candidate", numbers: ["port": 54321], |
| 61 | strings: [ |
| 62 | "candidateType": relayTLS ? "relay" : "host", "protocol": "udp", "relayProtocol": "tls", |
| 63 | "address": "192.168.1.20", "url": "turns:relay.example.com:443", |
| 64 | ]), |
| 65 | "RC": QualityStat( |
| 66 | type: "remote-candidate", numbers: ["port": 61000], |
| 67 | strings: ["candidateType": "srflx", "protocol": "udp", "address": "203.0.113.7"]), |
| 68 | "MS": QualityStat(type: "media-source", strings: ["kind": "audio", "deviceName": "Paul's AirPods Pro"]), |
| 69 | ] |
| 70 | if screen { |
| 71 | stats["OS"] = QualityStat( |
| 72 | type: "outbound-rtp", |
| 73 | numbers: [ |
| 74 | "bytesSent": 3_000_000 * scale, "packetsSent": 3000 * scale, "frameWidth": 1920, "frameHeight": 1080, |
| 75 | "framesPerSecond": 8, |
| 76 | ], |
| 77 | strings: ["kind": "video", "mid": "3", "qualityLimitationReason": "cpu"]) |
| 78 | stats["IS"] = QualityStat( |
| 79 | type: "inbound-rtp", |
| 80 | numbers: ["bytesReceived": 900_000 * scale, "framesDecoded": 300 * scale, "freezeCount": 2 * scale], |
| 81 | strings: ["kind": "video", "mid": "4"]) |
| 82 | } |
| 83 | return QualityPeerSample( |
| 84 | generation: generation, ownScreenMid: screen ? "3" : nil, screenMid: screen ? "4" : "", stats: stats) |
| 85 | } |
| 86 | |
| 87 | func build( |
| 88 | _ builder: inout QualityReportBuilder, at now: Double, _ samples: [String: QualityPeerSample], |
| 89 | tally: QualityEventTally = QualityEventTally(), final: Bool = false |
| 90 | ) -> QualityReport { |
| 91 | var tally = tally |
| 92 | return builder.build( |
| 93 | at: now, samples: samples, events: &tally, client: Self.client, |
| 94 | devices: QualityDevices(input: "bluetooth", output: "built-in"), final: final) |
| 95 | } |
| 96 | |
| 97 | // MARK: - Counts over the window |
| 98 | |
| 99 | func testCountsAreTheWindowsAndRatesAreOverIt() throws { |
| 100 | var builder = QualityReportBuilder() |
| 101 | builder.start(at: 100) |
| 102 | _ = build(&builder, at: 130, ["abc123def": Self.sample(scale: 1)]) |
| 103 | // Thirty seconds later every counter has doubled: the window's counts |
| 104 | // are the second half alone. |
| 105 | let report = build(&builder, at: 160, ["abc123def": Self.sample(scale: 2)]) |
| 106 | XCTAssertEqual(report.seconds, 30) |
| 107 | XCTAssertNil(report.isFinal) |
| 108 | let peer = try XCTUnwrap(report.peers?.first) |
| 109 | XCTAssertEqual(peer.peer, "abc123def") |
| 110 | let outAudio = try XCTUnwrap(peer.out?.audio) |
| 111 | XCTAssertEqual(outAudio.packets, 1500) |
| 112 | XCTAssertEqual(outAudio.kbps, 32, "120,000 bytes in 30 s is 32 kbit/s") |
| 113 | XCTAssertEqual(outAudio.lost, 3) |
| 114 | XCTAssertEqual(outAudio.rtt, 42) |
| 115 | XCTAssertEqual(outAudio.jitter, 4) |
| 116 | let outVideo = try XCTUnwrap(peer.out?.video) |
| 117 | XCTAssertEqual(outVideo.kbps, 400) |
| 118 | XCTAssertEqual(outVideo.limit, "bandwidth") |
| 119 | XCTAssertEqual(outVideo.width, 1280) |
| 120 | XCTAssertEqual(outVideo.fps, 30, "rounded to a tenth") |
| 121 | let inAudio = try XCTUnwrap(peer.in?.audio) |
| 122 | XCTAssertEqual(inAudio.packets, 1490) |
| 123 | XCTAssertEqual(inAudio.lost, 10) |
| 124 | XCTAssertEqual(inAudio.jitter, 12) |
| 125 | XCTAssertEqual(inAudio.samples, 1_440_000) |
| 126 | XCTAssertEqual(inAudio.concealed, 4800) |
| 127 | XCTAssertEqual(inAudio.inserted, 960) |
| 128 | XCTAssertEqual(inAudio.removed, 480) |
| 129 | XCTAssertEqual(inAudio.jitterBufferMs, 60) |
| 130 | let inVideo = try XCTUnwrap(peer.in?.video) |
| 131 | XCTAssertEqual(inVideo.freezes, 1) |
| 132 | XCTAssertEqual(inVideo.freezeSeconds, 0.5) |
| 133 | XCTAssertEqual(inVideo.decoded, 890) |
| 134 | XCTAssertEqual(inVideo.dropped, 2) |
| 135 | XCTAssertNil(peer.in?.screen) |
| 136 | XCTAssertNil(peer.out?.screen) |
| 137 | XCTAssertEqual(peer.path, QualityPath(local: "relay", remote: "srflx", protocol: "tls")) |
| 138 | XCTAssertEqual(peer.rtt, 38) |
| 139 | } |
| 140 | |
| 141 | func testTheFirstWindowOfAConnectionCountsFromItsStart() throws { |
| 142 | var builder = QualityReportBuilder() |
| 143 | builder.start(at: 0) |
| 144 | let report = build(&builder, at: 30, ["p1": Self.sample(scale: 1)]) |
| 145 | XCTAssertEqual(report.peers?.first?.out?.audio?.packets, 1500) |
| 146 | } |
| 147 | |
| 148 | func testANewGenerationOrACounterGoingBackStartsAgain() throws { |
| 149 | var builder = QualityReportBuilder() |
| 150 | builder.start(at: 0) |
| 151 | _ = build(&builder, at: 30, ["p1": Self.sample(generation: 1, scale: 4)]) |
| 152 | // A new connection: its counts are its own, not less than the old one's. |
| 153 | var report = build(&builder, at: 60, ["p1": Self.sample(generation: 2, scale: 1)]) |
| 154 | XCTAssertEqual(report.peers?.first?.in?.audio?.packets, 1490) |
| 155 | // The same connection, a counter gone backwards (a receiver rebuilt). |
| 156 | report = build(&builder, at: 90, ["p1": Self.sample(generation: 2, scale: 0.5)]) |
| 157 | XCTAssertEqual(report.peers?.first?.in?.audio?.packets, 745) |
| 158 | } |
| 159 | |
| 160 | func testScreensAreToldApartByMid() throws { |
| 161 | var builder = QualityReportBuilder() |
| 162 | builder.start(at: 0) |
| 163 | let report = build(&builder, at: 30, ["p1": Self.sample(screen: true)]) |
| 164 | let peer = try XCTUnwrap(report.peers?.first) |
| 165 | XCTAssertEqual(peer.out?.screen?.width, 1920) |
| 166 | XCTAssertEqual(peer.out?.screen?.limit, "cpu") |
| 167 | XCTAssertEqual(peer.out?.video?.width, 1280, "the camera is not the screen") |
| 168 | XCTAssertEqual(peer.in?.screen?.decoded, 300) |
| 169 | XCTAssertEqual(peer.in?.screen?.freezes, 2) |
| 170 | XCTAssertEqual(peer.in?.video?.decoded, 890) |
| 171 | } |
| 172 | |
| 173 | func testBoundsNaNAndNegativesAndThePeerCeiling() throws { |
| 174 | var builder = QualityReportBuilder() |
| 175 | builder.start(at: 0) |
| 176 | var samples: [String: QualityPeerSample] = [:] |
| 177 | for i in 0..<6 { samples["peer\(i)"] = Self.sample(lostOut: -5, nan: true) } |
| 178 | // A window longer than the schema's ten minutes. |
| 179 | let report = build(&builder, at: 900, samples) |
| 180 | XCTAssertEqual(report.seconds, 600) |
| 181 | XCTAssertEqual(report.peers?.count, 4, "no more than the schema's four") |
| 182 | XCTAssertEqual(report.peers?.map(\.peer), ["peer0", "peer1", "peer2", "peer3"]) |
| 183 | let video = try XCTUnwrap(report.peers?.first?.out?.video) |
| 184 | XCTAssertNil(video.fps, "NaN is left out, not sent") |
| 185 | XCTAssertNil(report.peers?.first?.out?.audio?.lost, "a negative count is left out") |
| 186 | XCTAssertEqual(QualityReportBuilder.bound(2e6, QualityBounds.kbps), 1e6) |
| 187 | XCTAssertNil(QualityReportBuilder.bound(.infinity, 10)) |
| 188 | XCTAssertNil(QualityReportBuilder.bound(-1, 10)) |
| 189 | XCTAssertEqual(QualityReportBuilder.bound(1.26, 10), 1.3) |
| 190 | } |
| 191 | |
| 192 | func testTheEnums() { |
| 193 | XCTAssertEqual(QualityWords.limit("none"), "none") |
| 194 | XCTAssertEqual(QualityWords.limit("cpu"), "cpu") |
| 195 | XCTAssertEqual(QualityWords.limit("something new"), "other") |
| 196 | XCTAssertNil(QualityWords.limit("")) |
| 197 | XCTAssertEqual(QualityWords.candidate("prflx"), "prflx") |
| 198 | XCTAssertEqual(QualityWords.candidate("mystery"), "unknown") |
| 199 | XCTAssertEqual(QualityWords.candidate(nil), "unknown") |
| 200 | XCTAssertEqual(QualityWords.transport("tcp", relayProtocol: nil, relay: false), "tcp") |
| 201 | XCTAssertEqual(QualityWords.transport("udp", relayProtocol: "tls", relay: true), "tls") |
| 202 | XCTAssertEqual(QualityWords.transport("udp", relayProtocol: "tls", relay: false), "udp") |
| 203 | XCTAssertEqual(QualityWords.transport(nil, relayProtocol: nil, relay: false), "unknown") |
| 204 | } |
| 205 | |
| 206 | // MARK: - Nothing that names anyone |
| 207 | |
| 208 | func testTheEncodedReportCarriesNoNamesAddressesOrDevices() throws { |
| 209 | var builder = QualityReportBuilder() |
| 210 | builder.start(at: 0) |
| 211 | var tally = QualityEventTally() |
| 212 | tally.observe(at: 1, call: false, reconnecting: ["p1"], cantHear: []) |
| 213 | let report = build(&builder, at: 30, ["p1": Self.sample(screen: true)], tally: tally, final: true) |
| 214 | let json = try XCTUnwrap(report.encoded()) |
| 215 | for forbidden in [ |
| 216 | "Vicky", "Wallace", "AirPods", "Paul", "192.168", "203.0.113", "54321", "61000", "relay.example.com", |
| 217 | "turns:", "address", "trackIdentifier", "deviceName", |
| 218 | ] { |
| 219 | XCTAssertFalse(json.contains(forbidden), "the report carries \(forbidden): \(json)") |
| 220 | } |
| 221 | // And it is the schema's shape. |
| 222 | let object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) |
| 223 | XCTAssertEqual(object["v"] as? Int, 1) |
| 224 | XCTAssertEqual(object["final"] as? Bool, true) |
| 225 | XCTAssertEqual(object["seconds"] as? Double, 30) |
| 226 | XCTAssertEqual((object["client"] as? [String: String])?["app"], "mac") |
| 227 | XCTAssertEqual((object["devices"] as? [String: String])?["input"], "bluetooth") |
| 228 | let peer = try XCTUnwrap((object["peers"] as? [[String: Any]])?.first) |
| 229 | XCTAssertNotNil(peer["in"], "\"in\" is the key, as the schema spells it") |
| 230 | XCTAssertNotNil((peer["path"] as? [String: Any])?["protocol"]) |
| 231 | XCTAssertEqual((peer["events"] as? [String: Any])?["reconnecting"] as? Double, 1) |
| 232 | XCTAssertNil(object["isFinal"]) |
| 233 | } |
| 234 | |
| 235 | @MainActor func testTheClientIsDigitsAndDots() { |
| 236 | XCTAssertEqual(QualityClientInfo.version("0.4.8"), "0.4.8") |
| 237 | XCTAssertNil(QualityClientInfo.version("0.4.8 (dev)")) |
| 238 | XCTAssertNil(QualityClientInfo.version("dev")) |
| 239 | XCTAssertNil(QualityClientInfo.version("1..2")) |
| 240 | XCTAssertNil(QualityClientInfo.version(nil)) |
| 241 | XCTAssertEqual( |
| 242 | QualityClientInfo.osVersion(OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 1)), "26.0.1") |
| 243 | let current = QualityClientInfo.current() |
| 244 | XCTAssertEqual(current.app, "mac") |
| 245 | XCTAssertEqual(current.os, "macos") |
| 246 | XCTAssertNotNil(current.osVersion) |
| 247 | } |
| 248 | |
| 249 | // MARK: - Notices |
| 250 | |
| 251 | func testNoticesAreCountedWhenTheyBeginAndTimedWhileTheyShow() { |
| 252 | var tally = QualityEventTally() |
| 253 | tally.observe(at: 0, call: false, reconnecting: [], cantHear: ["b"]) |
| 254 | tally.observe(at: 0.2, call: true, reconnecting: ["a"], cantHear: ["b"], noSound: ["a"]) |
| 255 | tally.observe(at: 0.4, call: true, reconnecting: ["a"], cantHear: []) |
| 256 | // A gap of a minute (the Mac asleep) counts as no more than a second. |
| 257 | tally.observe(at: 60.4, call: false, reconnecting: [], cantHear: [], noSoundFromUs: ["c"], audioBlocked: ["c"]) |
| 258 | tally.count(.keyframe, for: "a") |
| 259 | tally.count(.reattach, for: "c") |
| 260 | var builder = QualityReportBuilder() |
| 261 | builder.start(at: 0) |
| 262 | var mutable = tally |
| 263 | let report = builder.build( |
| 264 | at: 61, samples: [:], events: &mutable, client: Self.client, devices: nil, final: false) |
| 265 | XCTAssertEqual(report.events?.reconnecting, 1) |
| 266 | // Each look counts the time since the one before for what it sees. |
| 267 | XCTAssertEqual(report.events?.reconnectingSeconds, 0.4) |
| 268 | let peers = Dictionary(uniqueKeysWithValues: (report.peers ?? []).map { ($0.peer, $0) }) |
| 269 | XCTAssertEqual(peers["a"]?.events?.reconnecting, 1) |
| 270 | XCTAssertEqual(peers["a"]?.events?.reconnectingSeconds, 0.4) |
| 271 | XCTAssertEqual(peers["a"]?.events?.noSound, true) |
| 272 | XCTAssertEqual(peers["a"]?.out?.screen?.keyframes, 1) |
| 273 | XCTAssertEqual(peers["b"]?.events?.cantHearSeconds, 0.2) |
| 274 | XCTAssertEqual(peers["c"]?.events?.noSoundFromUs, true) |
| 275 | XCTAssertEqual(peers["c"]?.events?.audioBlocked, true) |
| 276 | XCTAssertEqual(peers["c"]?.in?.screen?.reattaches, 1) |
| 277 | XCTAssertNil(peers["b"]?.events?.noSound) |
| 278 | } |
| 279 | |
| 280 | func testANoticeStillShowingIsNotCountedAgainInTheNextWindow() { |
| 281 | var tally = QualityEventTally() |
| 282 | tally.observe(at: 0, call: true, reconnecting: ["a"], cantHear: []) |
| 283 | tally.observe(at: 1, call: true, reconnecting: ["a"], cantHear: []) |
| 284 | var builder = QualityReportBuilder() |
| 285 | builder.start(at: 0) |
| 286 | _ = builder.build(at: 1, samples: [:], events: &tally, client: Self.client, devices: nil, final: false) |
| 287 | tally.observe(at: 2, call: true, reconnecting: ["a"], cantHear: []) |
| 288 | let report = builder.build(at: 2, samples: [:], events: &tally, client: Self.client, devices: nil, final: false) |
| 289 | XCTAssertNil(report.events?.reconnecting, "the same notice, carried over") |
| 290 | XCTAssertEqual(report.events?.reconnectingSeconds, 1) |
| 291 | XCTAssertNil(report.peers?.first?.events?.reconnecting) |
| 292 | XCTAssertEqual(report.peers?.first?.events?.reconnectingSeconds, 1) |
| 293 | } |
| 294 | |
| 295 | // MARK: - Devices |
| 296 | |
| 297 | func testDeviceKindsFromCoreAudioTransports() { |
| 298 | XCTAssertEqual(DeviceKind.mac(transport: "built-in"), "built-in") |
| 299 | XCTAssertEqual(DeviceKind.mac(transport: "bluetooth"), "bluetooth") |
| 300 | XCTAssertEqual(DeviceKind.mac(transport: "usb"), "usb") |
| 301 | XCTAssertEqual(DeviceKind.mac(transport: "airplay"), "airplay") |
| 302 | XCTAssertEqual(DeviceKind.mac(transport: "virtual"), "virtual") |
| 303 | XCTAssertEqual(DeviceKind.mac(transport: "aggregate"), "virtual") |
| 304 | XCTAssertEqual(DeviceKind.mac(transport: "continuity"), "continuity") |
| 305 | XCTAssertEqual(DeviceKind.mac(transport: "thunderbolt"), "other") |
| 306 | XCTAssertEqual(DeviceKind.mac(transport: "0x12345678"), "other") |
| 307 | XCTAssertEqual(DeviceKind.mac(transport: nil), "unknown") |
| 308 | } |
| 309 | |
| 310 | func testDeviceKindsFromAudioSessionPorts() { |
| 311 | // AVAudioSession.Port's raw values. |
| 312 | for (port, kind) in [ |
| 313 | ("MicrophoneBuiltIn", "built-in"), ("Speaker", "built-in"), ("Receiver", "built-in"), |
| 314 | ("BluetoothHFP", "bluetooth"), ("BluetoothA2DPOutput", "bluetooth"), ("BluetoothLEOutput", "bluetooth"), |
| 315 | ("USBAudio", "usb"), ("Headphones", "wired"), ("MicrophoneWired", "wired"), ("AirPlay", "airplay"), |
| 316 | ("CarAudio", "other"), ("LineIn", "other"), |
| 317 | ] { |
| 318 | XCTAssertEqual(DeviceKind.ios(port: port), kind, port) |
| 319 | } |
| 320 | XCTAssertEqual(DeviceKind.ios(port: nil), "none") |
| 321 | } |
| 322 | |
| 323 | func testEveryKindIsOneTheSchemaAllows() { |
| 324 | let allowed: Set = ["built-in", "bluetooth", "usb", "wired", "airplay", "virtual", "continuity", "other", "none", "unknown"] |
| 325 | for t in ["built-in", "bluetooth", "usb", "airplay", "virtual", "aggregate", "continuity", "display", "", "x"] { |
| 326 | XCTAssertTrue(allowed.contains(DeviceKind.mac(transport: t))) |
| 327 | } |
| 328 | for p in ["Speaker", "BluetoothHFP", "USBAudio", "Headphones", "AirPlay", "CarAudio", "", "x"] { |
| 329 | XCTAssertTrue(allowed.contains(DeviceKind.ios(port: p))) |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | // MARK: - Report a problem |
| 334 | |
| 335 | static let roster = #""" |
| 336 | {"phase":"admitted","peer":"me0000aaaa","boot":"b","waiting":[],"peers":[ |
| 337 | {"peer":"me0000aaaa","name":"Paul Campbell","kind":"member","gen":1}, |
| 338 | {"peer":"abc123zzzz","name":"Vicky Wallace","kind":"member","gen":2, |
| 339 | "media":{"muted":true,"cameraOff":false,"noSoundFrom":["me0000aaaa"],"hearing":[]}} |
| 340 | ]} |
| 341 | """# |
| 342 | |
| 343 | @MainActor func testTheAnonymousDiagnosticsNameNobody() { |
| 344 | let call = Call(playAudio: false) |
| 345 | call.showFixture(title: "Planning with Vicky", state: Self.roster) |
| 346 | let named = call.audioDiagnosticsText() |
| 347 | XCTAssertTrue(named.contains("Vicky Wallace: "), "Copy call diagnostics still names people") |
| 348 | let anonymous = call.anonymousDiagnosticsText() |
| 349 | XCTAssertTrue(anonymous.contains("peer abc123: "), anonymous) |
| 350 | XCTAssertTrue(anonymous.contains("muted")) |
| 351 | XCTAssertTrue(anonymous.contains("hears no sound from you")) |
| 352 | for forbidden in ["Vicky", "Wallace", "Paul Campbell", "Planning"] { |
| 353 | XCTAssertFalse(anonymous.contains(forbidden), "the report names \(forbidden)") |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | func testTheBridgesAnswers() { |
| 358 | XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"ref":"K7Q2M9PD"}"#), .sent(ref: "K7Q2M9PD")) |
| 359 | XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"outcome":"too-many"}"#), .tooMany) |
| 360 | XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"outcome":"gone"}"#), .gone) |
| 361 | XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"outcome":"failed"}"#), .failed) |
| 362 | XCTAssertEqual(ProblemOutcome(bridgeAnswer: "nonsense"), .failed) |
| 363 | XCTAssertEqual(ProblemOutcome(bridgeAnswer: #"{"ref":""}"#), .failed) |
| 364 | } |
| 365 | |
| 366 | @MainActor func testTheSheetSendsAndShowsTheReference() async { |
| 367 | let sent = Box() |
| 368 | let model = ProblemReportModel { note in |
| 369 | await sent.set(note) |
| 370 | return .sent(ref: "K7Q2M9PD") |
| 371 | } |
| 372 | XCTAssertEqual(model.phase, .editing) |
| 373 | XCTAssertTrue(model.canSend, "the note is optional") |
| 374 | model.note = "Vicky sounded robotic" |
| 375 | XCTAssertEqual(model.counter, "21/1000") |
| 376 | await model.send() |
| 377 | XCTAssertEqual(model.phase, .sent(ref: "K7Q2M9PD")) |
| 378 | XCTAssertFalse(model.canSend, "sent once") |
| 379 | let note = await sent.value |
| 380 | XCTAssertEqual(note, "Vicky sounded robotic") |
| 381 | XCTAssertEqual(ProblemWords.reference("K7Q2M9PD"), "Reference K7Q2M9PD") |
| 382 | } |
| 383 | |
| 384 | @MainActor func testTheSheetSaysWhyNotAndCanTryAgain() async { |
| 385 | let answers = Answers([.tooMany, .gone, .failed, .sent(ref: "ABCDEFGH")]) |
| 386 | let model = ProblemReportModel { _ in await answers.next() } |
| 387 | await model.send() |
| 388 | XCTAssertEqual(model.phase, .refused("Too many reports from this call — try again in a minute.")) |
| 389 | XCTAssertTrue(model.canSend) |
| 390 | await model.send() |
| 391 | XCTAssertEqual(model.phase, .refused("This call can no longer be reported.")) |
| 392 | await model.send() |
| 393 | XCTAssertEqual(model.phase, .refused("The report could not be sent.")) |
| 394 | await model.send() |
| 395 | XCTAssertEqual(model.phase, .sent(ref: "ABCDEFGH")) |
| 396 | } |
| 397 | |
| 398 | @MainActor func testTheNoteIsAThousandCharactersAsTheServerCountsThem() { |
| 399 | let model = ProblemReportModel { _ in .failed } |
| 400 | model.note = String(repeating: "é", count: 1200) |
| 401 | XCTAssertEqual(model.note.unicodeScalars.count, 1000) |
| 402 | XCTAssertEqual(model.counter, "1000/1000") |
| 403 | } |
| 404 | |
| 405 | func testTheWordsAreTheRulings() { |
| 406 | XCTAssertEqual( |
| 407 | ProblemWords.disclosure, "Sends this call's connection diagnostics to the Calls server log. No audio or video.") |
| 408 | XCTAssertEqual(ProblemWords.sent, "Sent — thank you") |
| 409 | XCTAssertEqual( |
| 410 | QualityReporting.disclosure, "Calls sends anonymous connection quality reports to help fix call problems.") |
| 411 | } |
| 412 | |
| 413 | func testAProblemReportFitsTheServersLimit() throws { |
| 414 | let body = ProblemReportBody( |
| 415 | note: "x", diagnostics: String(repeating: "line of diagnostics\n", count: 10_000), client: Self.client |
| 416 | ).encoded() |
| 417 | XCTAssertLessThanOrEqual(body.utf8.count, QualityBounds.problemBody) |
| 418 | let object = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(body.utf8)) as? [String: Any]) |
| 419 | XCTAssertEqual(object["note"] as? String, "x") |
| 420 | XCTAssertTrue((object["diagnostics"] as? String)?.hasPrefix("(earlier lines cut)") == true) |
| 421 | } |
| 422 | |
| 423 | func testTheReportingSwitchIsOnUnlessTurnedOff() throws { |
| 424 | let defaults = try XCTUnwrap(UserDefaults(suiteName: "quality-\(UUID().uuidString)")) |
| 425 | XCTAssertTrue(QualityReporting.enabled(defaults)) |
| 426 | defaults.set(false, forKey: QualityReporting.enabledKey) |
| 427 | XCTAssertFalse(QualityReporting.enabled(defaults)) |
| 428 | } |
| 429 | |
| 430 | @MainActor func testAnEndedCallOffersReportAProblem() { |
| 431 | let ended = CallNotices.notices( |
| 432 | phase: "ended", status: "This meeting has ended", sharingScreen: false, screenNote: "", floor: false) |
| 433 | XCTAssertEqual(ended.first?.action, .reportProblem) |
| 434 | let call = Call(playAudio: false) |
| 435 | XCTAssertFalse(call.canReportProblem, "no call, nothing to report") |
| 436 | call.reportProblem() |
| 437 | XCTAssertNil(call.problemReport) |
| 438 | } |
| 439 | } |
| 440 | |
| 441 | private actor Box { |
| 442 | var value = "" |
| 443 | func set(_ v: String) { value = v } |
| 444 | } |
| 445 | |
| 446 | private actor Answers { |
| 447 | var queue: [ProblemOutcome] |
| 448 | init(_ queue: [ProblemOutcome]) { self.queue = queue } |
| 449 | func next() -> ProblemOutcome { queue.removeFirst() } |
| 450 | } |
| 451 | |