| 1 | import XCTest |
| 2 | |
| 3 | @testable import CallsKit |
| 4 | |
| 5 | /// "<name> is connecting and can't hear you yet" (spec §15.24). Paul, |
| 6 | /// 2026-10-01, asking for Zoom's "… is connecting to audio and cannot hear |
| 7 | /// you yet": the gap after someone joins, said rather than talked into. |
| 8 | final class ConnectingWatchTests: XCTestCase { |
| 9 | private typealias Peer = ConnectingWatch.Peer |
| 10 | private let waiting = Peer(epoch: 1, hearsMe: false, reported: true, connected: false, acked: false) |
| 11 | |
| 12 | /// Runs the watch at 5 Hz from `from` to `to` seconds; when Sam's words |
| 13 | /// showed, and when they cleared with "can hear you now". `sam` nil is |
| 14 | /// Sam not in the roster. |
| 15 | private func run( |
| 16 | _ watch: inout ConnectingWatch, from: Double, to: Double, sam: (Double) -> Peer? |
| 17 | ) -> (shown: [Double], cleared: [Double]) { |
| 18 | var shown: [Double] = [] |
| 19 | var cleared: [Double] = [] |
| 20 | var t = from |
| 21 | while t < to { |
| 22 | let people = sam(t).map { ["sam": $0] } ?? [:] |
| 23 | if watch.observe(at: t, people: people).contains("sam") { cleared.append(t) } |
| 24 | if watch.shown.contains("sam") { shown.append(t) } |
| 25 | t = (t * 10 + 2).rounded() / 10 |
| 26 | } |
| 27 | return (shown, cleared) |
| 28 | } |
| 29 | |
| 30 | func testTheTimingsAreTheOnesTheSpecNames() { |
| 31 | XCTAssertEqual(ConnectingWatch.grace, 1) |
| 32 | XCTAssertEqual(ConnectingWatch.cap, 20) |
| 33 | } |
| 34 | |
| 35 | func testAnOrdinaryJoinHeardWithinTheGraceSaysNothing() { |
| 36 | var watch = ConnectingWatch() |
| 37 | var sam = waiting |
| 38 | let seen = run(&watch, from: 0, to: 10) { t in |
| 39 | sam.connected = t >= 0.4 |
| 40 | sam.hearsMe = t >= 0.8 |
| 41 | return sam |
| 42 | } |
| 43 | XCTAssertEqual(seen.shown, []) |
| 44 | XCTAssertEqual(seen.cleared, [], "nothing was said, so \"can hear you now\" is not said either") |
| 45 | XCTAssertFalse(watch.joining.contains("sam")) |
| 46 | } |
| 47 | |
| 48 | func testASlowJoinIsSaidAfterTheGraceAndClearsWhenTheyHearYou() { |
| 49 | var watch = ConnectingWatch() |
| 50 | var sam = waiting |
| 51 | let seen = run(&watch, from: 0, to: 12) { t in |
| 52 | sam.connected = t >= 5 |
| 53 | sam.hearsMe = t >= 6 |
| 54 | return sam |
| 55 | } |
| 56 | XCTAssertEqual(seen.shown.first, 1) |
| 57 | XCTAssertEqual(seen.shown.last, 5.8) |
| 58 | XCTAssertEqual(seen.cleared, [6]) |
| 59 | } |
| 60 | |
| 61 | func testOnceClearedItStaysClearedWhateverTheirListDoes() { |
| 62 | var watch = ConnectingWatch() |
| 63 | var sam = waiting |
| 64 | sam.connected = true |
| 65 | let seen = run(&watch, from: 0, to: 30) { t in |
| 66 | sam.hearsMe = t >= 3 && t < 10 |
| 67 | return sam |
| 68 | } |
| 69 | XCTAssertEqual(seen.cleared, [3]) |
| 70 | XCTAssertEqual(seen.shown.last, 2.8) |
| 71 | } |
| 72 | |
| 73 | func testItIsGivenUpAtTheCapSilently() { |
| 74 | var watch = ConnectingWatch() |
| 75 | let seen = run(&watch, from: 0, to: 40) { _ in self.waiting } |
| 76 | XCTAssertEqual(seen.shown.first, 1) |
| 77 | XCTAssertEqual(seen.shown.last, 19.8) |
| 78 | XCTAssertEqual(seen.cleared, [], "a cap is not their hearing you") |
| 79 | XCTAssertTrue(watch.joining.isEmpty) |
| 80 | } |
| 81 | |
| 82 | func testAClientThatDoesNotSayIsLetGoOnItsFirstReportAndAConnection() { |
| 83 | var watch = ConnectingWatch() |
| 84 | let seen = run(&watch, from: 0, to: 10) { t in |
| 85 | Peer(epoch: 1, hearsMe: nil, reported: t >= 4, connected: t >= 2, acked: false) |
| 86 | } |
| 87 | XCTAssertEqual(seen.cleared, [4]) |
| 88 | } |
| 89 | |
| 90 | func testOrOnAConnectionAndReceiverReportsShowingYourAudioReachesThem() { |
| 91 | var watch = ConnectingWatch() |
| 92 | let seen = run(&watch, from: 0, to: 10) { t in |
| 93 | Peer(epoch: 1, hearsMe: nil, reported: false, connected: t >= 2, acked: t >= 3) |
| 94 | } |
| 95 | XCTAssertEqual(seen.cleared, [3]) |
| 96 | var unconnected = ConnectingWatch() |
| 97 | let never = run(&unconnected, from: 0, to: 10) { _ in |
| 98 | Peer(epoch: 1, hearsMe: nil, reported: true, connected: false, acked: true) |
| 99 | } |
| 100 | XCTAssertEqual(never.cleared, [], "never without a connection") |
| 101 | } |
| 102 | |
| 103 | func testAClientThatSaysWhoItHearsIsBelievedOverYourOwnEvidence() { |
| 104 | var watch = ConnectingWatch() |
| 105 | let seen = run(&watch, from: 0, to: 10) { _ in |
| 106 | Peer(epoch: 1, hearsMe: false, reported: true, connected: true, acked: true) |
| 107 | } |
| 108 | XCTAssertEqual(seen.cleared, []) |
| 109 | XCTAssertFalse(seen.shown.isEmpty) |
| 110 | } |
| 111 | |
| 112 | func testARejoinStartsItAgainAndSoDoesANewConnection() { |
| 113 | var watch = ConnectingWatch() |
| 114 | var sam = waiting |
| 115 | let seen = run(&watch, from: 0, to: 20) { t in |
| 116 | if t >= 5 && t < 6 { return nil } |
| 117 | sam.connected = true |
| 118 | sam.hearsMe = t < 5 ? t >= 2 : t >= 9 |
| 119 | return sam |
| 120 | } |
| 121 | XCTAssertEqual(seen.cleared, [2, 9]) |
| 122 | XCTAssertTrue(seen.shown.contains(7)) |
| 123 | XCTAssertFalse(seen.shown.contains(6.8), "the grace again from the moment they are back") |
| 124 | |
| 125 | var rebuilt = ConnectingWatch() |
| 126 | var again = waiting |
| 127 | again.connected = true |
| 128 | let second = run(&rebuilt, from: 0, to: 20) { t in |
| 129 | again.epoch = t < 8 ? 1 : 2 |
| 130 | again.hearsMe = t < 1 || t >= 12 |
| 131 | return again |
| 132 | } |
| 133 | XCTAssertEqual(second.cleared, [12]) |
| 134 | XCTAssertEqual(second.shown.first, 9) |
| 135 | } |
| 136 | |
| 137 | func testAMoreSpecificReportReplacesConnectingAndReconnectingWaitsUntilAfter() { |
| 138 | let plain = PeerMedia(muted: false, cameraOff: false, hearing: []) |
| 139 | func tile( |
| 140 | _ media: PeerMedia, muted: Bool = false, joining: Bool = true, shown: Bool = true, |
| 141 | reconnecting: Bool = false |
| 142 | ) -> (notices: [String], connecting: String?) { |
| 143 | ConnectingWatch.tile( |
| 144 | name: "Sam", media: media, me: "me", muted: muted, joining: joining, shown: shown, |
| 145 | reconnecting: reconnecting) |
| 146 | } |
| 147 | XCTAssertNil(tile(plain, shown: false).connecting, "inside the grace") |
| 148 | XCTAssertEqual(tile(plain).connecting, "Sam is connecting and can’t hear you yet") |
| 149 | XCTAssertEqual(tile(plain).notices, []) |
| 150 | XCTAssertNotNil(tile(plain, muted: true).connecting, "your own mute does not matter") |
| 151 | XCTAssertEqual( |
| 152 | tile(plain, reconnecting: true).notices, [], "a connection that has never been up is not reconnecting") |
| 153 | let blocked = PeerMedia(muted: false, cameraOff: false, audioBlocked: true, hearing: []) |
| 154 | XCTAssertEqual(tile(blocked, shown: false).notices, [AudioWords.blocked("Sam")], "even inside the grace") |
| 155 | XCTAssertNil(tile(blocked).connecting) |
| 156 | let silent = PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"], hearing: ["me"]) |
| 157 | XCTAssertEqual(tile(silent).notices, [AudioWords.notHearingYou("Sam")]) |
| 158 | XCTAssertNil(tile(silent).connecting) |
| 159 | XCTAssertNotNil( |
| 160 | tile(PeerMedia(muted: false, cameraOff: false, noSoundFrom: ["me"]), muted: true).connecting, |
| 161 | "no sound from you while you are muted is expected, so it replaces nothing") |
| 162 | XCTAssertEqual( |
| 163 | tile(plain, joining: false, shown: false, reconnecting: true).notices, [ReconnectWords.peer("Sam")]) |
| 164 | XCTAssertEqual( |
| 165 | tile(blocked, joining: false, shown: false, reconnecting: true).notices, |
| 166 | [ReconnectWords.peer("Sam"), AudioWords.blocked("Sam")]) |
| 167 | XCTAssertEqual(tile(plain, joining: false, shown: false).notices, []) |
| 168 | } |
| 169 | |
| 170 | func testHearsYou() { |
| 171 | XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: ["me"]), me: "me"), true) |
| 172 | XCTAssertEqual(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false, hearing: []), me: "me"), false) |
| 173 | XCTAssertNil(HearingRule.hearsYou(PeerMedia(muted: false, cameraOff: false), me: "me")) |
| 174 | XCTAssertNil(HearingRule.hearsYou(nil, me: "me")) |
| 175 | } |
| 176 | |
| 177 | /// This app's own half: when a path counts as heard. A muted sender must |
| 178 | /// not look unheard. |
| 179 | func testHeardIsArrivingAndDecodedOrAMutedSenderOnALiveConnection() { |
| 180 | let base = HearingRule.Facts( |
| 181 | connected: true, bad: false, hasReceiver: true, receiverLive: true, packets: 0, decoded: 0, |
| 182 | senderMuted: false) |
| 183 | func with(_ change: (inout HearingRule.Facts) -> Void) -> HearingRule.Facts { |
| 184 | var f = base |
| 185 | change(&f) |
| 186 | return f |
| 187 | } |
| 188 | XCTAssertFalse(HearingRule.heard(was: false, base), "nothing has arrived") |
| 189 | XCTAssertFalse(HearingRule.heard(was: false, with { $0.packets = 10 }), "packets, nothing decoded") |
| 190 | XCTAssertTrue(HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3 })) |
| 191 | XCTAssertFalse( |
| 192 | HearingRule.heard(was: false, with { $0.packets = 10; $0.decoded = 3; $0.connected = false }), |
| 193 | "not before the connection is up") |
| 194 | XCTAssertTrue(HearingRule.heard(was: false, with { $0.senderMuted = true }), "a muted sender") |
| 195 | XCTAssertTrue( |
| 196 | HearingRule.heard(was: false, with { $0.senderMuted = true; $0.hasReceiver = false; $0.receiverLive = false }), |
| 197 | "nor one with no microphone at all") |
| 198 | XCTAssertFalse( |
| 199 | HearingRule.heard(was: false, with { $0.senderMuted = true; $0.receiverLive = false }), "but not a dead receiver") |
| 200 | XCTAssertTrue(HearingRule.heard(was: true, base), "once heard, kept") |
| 201 | XCTAssertTrue(HearingRule.heard(was: true, with { $0.connected = false }), "a blip is not losing them") |
| 202 | XCTAssertFalse(HearingRule.heard(was: true, with { $0.bad = true }), "a failed connection is") |
| 203 | } |
| 204 | |
| 205 | func testTheHearingListDecodesAndAbsentIsNotEmpty() throws { |
| 206 | func media(_ json: String) throws -> PeerMedia? { |
| 207 | let state = try JSONDecoder().decode( |
| 208 | RoomState.self, |
| 209 | from: Data( |
| 210 | #"{"phase":"admitted","peer":"me","boot":"b","waiting":[],"peers":[{"peer":"sam","name":"Sam","kind":"member","gen":1,"media":\#(json)}]}"# |
| 211 | .utf8)) |
| 212 | return Call.peerMedia("sam", in: state) |
| 213 | } |
| 214 | XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":["me"]}"#)?.hearing, ["me"]) |
| 215 | XCTAssertEqual(try media(#"{"muted":false,"cameraOff":false,"hearing":[]}"#)?.hearing, []) |
| 216 | XCTAssertNil(try media(#"{"muted":false,"cameraOff":false}"#)?.hearing) |
| 217 | } |
| 218 | |
| 219 | func testTheDotsTakeTurnsAndNeverGoOut() { |
| 220 | for dot in 0..<3 { |
| 221 | for step in 0..<28 { |
| 222 | let o = ConnectingText.opacity(at: Double(step) * 0.05, dot: dot) |
| 223 | XCTAssertGreaterThanOrEqual(o, 0.35) |
| 224 | XCTAssertLessThanOrEqual(o, 1.0001) |
| 225 | } |
| 226 | } |
| 227 | XCTAssertEqual(ConnectingText.opacity(at: 0.56, dot: 0), 1, accuracy: 0.001) |
| 228 | XCTAssertEqual(ConnectingText.opacity(at: 0.76, dot: 1), 1, accuracy: 0.001) |
| 229 | XCTAssertEqual(ConnectingText.opacity(at: 0.96, dot: 2), 1, accuracy: 0.001) |
| 230 | } |
| 231 | |
| 232 | func testAHearingReportGoesForAGainOrALossOfSomeoneStillHereAndNotForADeparture() { |
| 233 | let here: Set<String> = ["a", "b"] |
| 234 | XCTAssertTrue(HearingRule.moved(from: [], to: ["a"], present: here)) |
| 235 | XCTAssertTrue(HearingRule.moved(from: ["a"], to: ["a", "b"], present: here)) |
| 236 | XCTAssertTrue(HearingRule.moved(from: ["a", "b"], to: ["a"], present: here)) |
| 237 | XCTAssertFalse(HearingRule.moved(from: ["a", "c"], to: ["a"], present: here), "c has left") |
| 238 | XCTAssertFalse(HearingRule.moved(from: ["a"], to: ["a"], present: here)) |
| 239 | } |
| 240 | } |
| 241 | |