| 1 | import XCTest |
| 2 | |
| 3 | @testable @_spi(Fixtures) import CallsKit |
| 4 | |
| 5 | /// 0.4.0: the viewer's order, the active speaker, Speaker view's stage and |
| 6 | /// filmstrip, and Focus becoming Speaker. |
| 7 | final class ParticipantOrderTests: XCTestCase { |
| 8 | func testNewArrivalsGoOnTheEndAndLeaversAreRemoved() { |
| 9 | var order = ParticipantOrder() |
| 10 | order.merge(present: ["self", "name:Ana"]) |
| 11 | XCTAssertEqual(order.shown, ["self", "name:Ana"]) |
| 12 | order.merge(present: ["self", "name:Ana", "name:Tom"]) |
| 13 | XCTAssertEqual(order.shown, ["self", "name:Ana", "name:Tom"]) |
| 14 | order.move("name:Tom", to: 0) |
| 15 | order.merge(present: ["name:Li", "self", "name:Tom", "name:Ana"]) |
| 16 | XCTAssertEqual(order.shown, ["name:Tom", "self", "name:Ana", "name:Li"], "roster order does not undo the viewer's") |
| 17 | order.merge(present: ["self", "name:Tom", "name:Li"]) |
| 18 | XCTAssertEqual(order.shown, ["name:Tom", "self", "name:Li"]) |
| 19 | XCTAssertFalse(order.keys.contains("name:Ana"), "a leaver is gone, not remembered") |
| 20 | order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana"]) |
| 21 | XCTAssertEqual(order.shown.last, "name:Ana", "and comes back at the end") |
| 22 | } |
| 23 | |
| 24 | func testMovingTo() { |
| 25 | var order = ParticipantOrder() |
| 26 | order.merge(present: ["a", "b", "c", "d"]) |
| 27 | XCTAssertTrue(order.move("d", to: 1)) |
| 28 | XCTAssertEqual(order.shown, ["a", "d", "b", "c"]) |
| 29 | XCTAssertTrue(order.move("a", to: 99)) |
| 30 | XCTAssertEqual(order.shown, ["d", "b", "c", "a"], "past the end is last") |
| 31 | XCTAssertTrue(order.move("c", to: -3)) |
| 32 | XCTAssertEqual(order.shown, ["c", "d", "b", "a"], "before the start is first") |
| 33 | XCTAssertFalse(order.move("c", to: 0), "already there") |
| 34 | XCTAssertFalse(order.move("zz", to: 0), "not here") |
| 35 | } |
| 36 | |
| 37 | func testEarlierLaterFirst() { |
| 38 | var order = ParticipantOrder() |
| 39 | order.merge(present: ["a", "b", "c"]) |
| 40 | XCTAssertFalse(order.moveEarlier("a")) |
| 41 | XCTAssertFalse(order.moveLater("c")) |
| 42 | XCTAssertTrue(order.moveLater("a")) |
| 43 | XCTAssertEqual(order.shown, ["b", "a", "c"]) |
| 44 | XCTAssertTrue(order.moveEarlier("c")) |
| 45 | XCTAssertEqual(order.shown, ["b", "c", "a"]) |
| 46 | XCTAssertTrue(order.moveToFirst("a")) |
| 47 | XCTAssertEqual(order.shown, ["a", "b", "c"]) |
| 48 | XCTAssertFalse(order.moveToFirst("a")) |
| 49 | XCTAssertEqual(order.canMove("a").earlier, false) |
| 50 | XCTAssertEqual(order.canMove("a").later, true) |
| 51 | XCTAssertEqual(order.canMove("c").later, false) |
| 52 | } |
| 53 | |
| 54 | /// Put back for a rejoin: the people remembered keep their places as |
| 55 | /// they arrive, one by one, and nobody is lost for not being in the |
| 56 | /// first roster. |
| 57 | func testARestoredOrderKeepsItsPlacesAsPeopleArrive() throws { |
| 58 | var kept = ParticipantOrder() |
| 59 | kept.merge(present: ["self", "name:Ana", "name:Tom", "name:Li"]) |
| 60 | kept.move("name:Li", to: 0) |
| 61 | kept.move("self", to: 3) |
| 62 | let data = try JSONEncoder().encode(kept) |
| 63 | var order = try JSONDecoder().decode(ParticipantOrder.self, from: data) |
| 64 | XCTAssertEqual(order.shown, [], "nobody is here yet") |
| 65 | order.merge(present: ["self"]) |
| 66 | XCTAssertEqual(order.shown, ["self"]) |
| 67 | order.merge(present: ["self", "name:Tom"]) |
| 68 | order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana"]) |
| 69 | XCTAssertEqual(order.shown, ["name:Li", "name:Ana", "name:Tom", "self"]) |
| 70 | order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana", "name:Sam"]) |
| 71 | XCTAssertEqual(order.shown.last, "name:Sam") |
| 72 | } |
| 73 | |
| 74 | /// A move among the people here leaves remembered absentees where they |
| 75 | /// were. |
| 76 | func testMovingSkipsTheAbsent() { |
| 77 | var order = ParticipantOrder(keys: ["a", "gone", "b", "c"]) |
| 78 | order.merge(present: ["a", "b", "c"]) |
| 79 | // "gone" was never seen, so it is remembered, not a leaver. |
| 80 | XCTAssertEqual(order.keys, ["a", "gone", "b", "c"]) |
| 81 | order.move("c", to: 0) |
| 82 | XCTAssertEqual(order.shown, ["c", "a", "b"]) |
| 83 | XCTAssertEqual(order.keys, ["c", "gone", "a", "b"]) |
| 84 | } |
| 85 | |
| 86 | func testThePersistenceKey() { |
| 87 | XCTAssertEqual( |
| 88 | ParticipantOrder.storageKey(meeting: "https://calls.oficina.works/abc"), |
| 89 | "calls.order.https://calls.oficina.works/abc") |
| 90 | } |
| 91 | |
| 92 | @MainActor func testOrdersAreKeptPerMeetingForTheSession() { |
| 93 | ParticipantOrders.forgetAll() |
| 94 | defer { ParticipantOrders.forgetAll() } |
| 95 | var order = ParticipantOrder() |
| 96 | order.merge(present: ["self", "name:Ana"]) |
| 97 | order.move("name:Ana", to: 0) |
| 98 | ParticipantOrders.save(order, meeting: "s/one") |
| 99 | XCTAssertEqual(ParticipantOrders.load(meeting: "s/one").keys, ["name:Ana", "self"]) |
| 100 | XCTAssertEqual(ParticipantOrders.load(meeting: "s/two").keys, []) |
| 101 | ParticipantOrders.save(order, meeting: "") |
| 102 | XCTAssertEqual(ParticipantOrders.load(meeting: "").keys, [], "a call with no meeting keeps nothing") |
| 103 | } |
| 104 | |
| 105 | func testKeysAreNamesSoARejoinKeepsThem() { |
| 106 | let keys = ParticipantKeys.keys( |
| 107 | roster: [("p1", "Paul"), ("p2", "Ana"), ("p3", "Ana"), ("p4", "Tom")], me: "p1") |
| 108 | XCTAssertEqual(keys.map(\.key), ["self", "name:Ana", "name:Ana#2", "name:Tom"]) |
| 109 | let again = ParticipantKeys.keys(roster: [("q9", "Ana"), ("q1", "Paul")], me: "q1") |
| 110 | XCTAssertEqual(again.map(\.key), ["name:Ana", "self"], "new peer ids, the same keys") |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | final class ActiveSpeakerTests: XCTestCase { |
| 115 | typealias Voice = ActiveSpeaker.Voice |
| 116 | let everyone: Set<String> = ["ana", "tom", "li"] |
| 117 | |
| 118 | /// Feeds readings at 5 Hz, as the app takes them, from `from` to `to` |
| 119 | /// seconds, with `speaking` speaking at their levels. |
| 120 | private func run( |
| 121 | _ rule: inout ActiveSpeaker, from: Double, to: Double, _ speaking: [String: Float], |
| 122 | present: Set<String>? = nil, me: String? = "me" |
| 123 | ) { |
| 124 | var step = 0 |
| 125 | while from + Double(step) * 0.2 < to - 1e-9 { |
| 126 | let t = from + Double(step) * 0.2 |
| 127 | step += 1 |
| 128 | let voices = (present ?? everyone).union(me.map { [$0] } ?? []).sorted().map { peer in |
| 129 | Voice(peer: peer, speaking: speaking[peer] != nil, level: speaking[peer] ?? 0.001) |
| 130 | } |
| 131 | rule.update(voices, present: present ?? everyone, me: me, now: t) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func testTheThresholdsAreInTheRangeAsked() { |
| 136 | XCTAssertGreaterThanOrEqual(ActiveSpeaker.dominance, 1.2) |
| 137 | XCTAssertLessThanOrEqual(ActiveSpeaker.dominance, 1.5) |
| 138 | XCTAssertGreaterThanOrEqual(ActiveSpeaker.minimumHold, 3) |
| 139 | } |
| 140 | |
| 141 | func testTheStageGoesToSomeoneOnlyAfterTheDominanceDelay() { |
| 142 | var rule = ActiveSpeaker() |
| 143 | run(&rule, from: 0, to: 1.2, ["ana": 0.2]) |
| 144 | XCTAssertNil(rule.current, "1.2 s is not yet a turn") |
| 145 | run(&rule, from: 1.2, to: 1.5, ["ana": 0.2]) |
| 146 | XCTAssertEqual(rule.current, "ana") |
| 147 | XCTAssertEqual(rule.since, 1.4, accuracy: 0.001) |
| 148 | } |
| 149 | |
| 150 | func testTheLoudestSpeakerIsTheDominantOne() { |
| 151 | var rule = ActiveSpeaker() |
| 152 | run(&rule, from: 0, to: 2, ["ana": 0.05, "tom": 0.3]) |
| 153 | XCTAssertEqual(rule.current, "tom") |
| 154 | } |
| 155 | |
| 156 | func testAnInterjectionDoesNotTakeTheStage() { |
| 157 | var rule = ActiveSpeaker() |
| 158 | run(&rule, from: 0, to: 5, ["ana": 0.2]) |
| 159 | XCTAssertEqual(rule.current, "ana") |
| 160 | // Tom: "right, yes" for a second, louder than Ana. |
| 161 | run(&rule, from: 5, to: 6, ["ana": 0.1, "tom": 0.4]) |
| 162 | run(&rule, from: 6, to: 10, ["ana": 0.2]) |
| 163 | XCTAssertEqual(rule.current, "ana") |
| 164 | } |
| 165 | |
| 166 | func testPausesHoldTheCurrentSpeaker() { |
| 167 | var rule = ActiveSpeaker() |
| 168 | run(&rule, from: 0, to: 2, ["tom": 0.2]) |
| 169 | XCTAssertEqual(rule.current, "tom") |
| 170 | run(&rule, from: 2, to: 30, [:]) |
| 171 | XCTAssertEqual(rule.current, "tom", "silence changes nothing") |
| 172 | } |
| 173 | |
| 174 | func testTheMinimumHoldHoldsThroughATakeover() { |
| 175 | var rule = ActiveSpeaker() |
| 176 | run(&rule, from: 0, to: 1.5, ["ana": 0.2]) |
| 177 | XCTAssertEqual(rule.current, "ana") |
| 178 | let took = rule.since |
| 179 | // Tom starts at once and keeps going: dominant long enough after |
| 180 | // 1.3 s, but Ana has not had her 3 s yet. |
| 181 | run(&rule, from: 1.6, to: took + 2.9, ["tom": 0.3]) |
| 182 | XCTAssertEqual(rule.current, "ana") |
| 183 | run(&rule, from: took + 3, to: took + 3.1, ["tom": 0.3]) |
| 184 | XCTAssertEqual(rule.current, "tom") |
| 185 | } |
| 186 | |
| 187 | func testTwoPeopleTalkingOverEachOtherDoNotFlap() { |
| 188 | var rule = ActiveSpeaker() |
| 189 | var switches = 0 |
| 190 | var last: String? |
| 191 | var t = 0.0 |
| 192 | // Alternating every 0.8 s for a minute: each is loudest for less than |
| 193 | // the dominance delay. |
| 194 | while t < 60 { |
| 195 | let who = Int(t / 0.8) % 2 == 0 ? "ana" : "tom" |
| 196 | let other = who == "ana" ? "tom" : "ana" |
| 197 | rule.update( |
| 198 | [Voice(peer: who, speaking: true, level: 0.3), Voice(peer: other, speaking: true, level: 0.1)], |
| 199 | present: everyone, me: "me", now: t) |
| 200 | if rule.current != last { |
| 201 | switches += 1 |
| 202 | last = rule.current |
| 203 | } |
| 204 | t += 0.2 |
| 205 | } |
| 206 | XCTAssertEqual(switches, 0, "nobody held the floor for 1.3 s") |
| 207 | } |
| 208 | |
| 209 | func testNeverYou() { |
| 210 | var rule = ActiveSpeaker() |
| 211 | let present: Set<String> = ["ana", "me"] |
| 212 | for step in 0..<50 { |
| 213 | rule.update( |
| 214 | [Voice(peer: "me", speaking: true, level: 0.9), Voice(peer: "ana", speaking: false, level: 0)], |
| 215 | present: present, me: "me", now: Double(step) * 0.2) |
| 216 | } |
| 217 | XCTAssertNil(rule.current, "your own voice never puts you on the stage") |
| 218 | } |
| 219 | |
| 220 | func testALeaverLeavesTheStageAtOnce() { |
| 221 | var rule = ActiveSpeaker() |
| 222 | run(&rule, from: 0, to: 2, ["li": 0.2]) |
| 223 | XCTAssertEqual(rule.current, "li") |
| 224 | XCTAssertTrue(rule.update([], present: ["ana", "tom"], me: "me", now: 2.2)) |
| 225 | XCTAssertNil(rule.current) |
| 226 | // And the next speaker needs only the dominance delay, not a hold. |
| 227 | run(&rule, from: 2.4, to: 3.9, ["ana": 0.2], present: ["ana", "tom"]) |
| 228 | XCTAssertEqual(rule.current, "ana") |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | final class SpeakerStageTests: XCTestCase { |
| 233 | func testPinBeatsScreenBeatsSpeakerBeatsOrder() { |
| 234 | let others = ["ana", "tom", "li"] |
| 235 | XCTAssertEqual(Stage.speaker(pinned: nil, active: nil, others: others, screens: []), .camera("ana")) |
| 236 | XCTAssertEqual(Stage.speaker(pinned: nil, active: "li", others: others, screens: []), .camera("li")) |
| 237 | XCTAssertEqual(Stage.speaker(pinned: nil, active: "li", others: others, screens: ["tom"]), .screen("tom")) |
| 238 | XCTAssertEqual( |
| 239 | Stage.speaker(pinned: .camera("ana"), active: "li", others: others, screens: ["tom"]), .camera("ana"), |
| 240 | "a new screen does not take a pinned stage") |
| 241 | XCTAssertEqual( |
| 242 | Stage.speaker(pinned: .camera("me"), active: "li", others: others, screens: [], me: "me"), .camera("me"), |
| 243 | "you only by your own pin") |
| 244 | } |
| 245 | |
| 246 | func testTheSpeakerWhoLeftFallsBackToTheOrder() { |
| 247 | XCTAssertEqual(Stage.speaker(pinned: nil, active: "gone", others: ["tom", "ana"], screens: []), .camera("tom")) |
| 248 | } |
| 249 | |
| 250 | func testTheFilmstripIsEveryoneElseInTheViewersOrder() { |
| 251 | let order = ["tom", "me", "ana", "li"] |
| 252 | XCTAssertEqual( |
| 253 | Stage.filmstrip(stage: .camera("ana"), order: order, screens: []), |
| 254 | [.camera("tom"), .camera("me"), .camera("li")]) |
| 255 | // A screen on the stage: the sharer's camera is in the filmstrip. |
| 256 | XCTAssertEqual( |
| 257 | Stage.filmstrip(stage: .screen("li"), order: order, screens: ["li"]), |
| 258 | [.camera("tom"), .camera("me"), .camera("ana"), .camera("li")]) |
| 259 | // Two screens: the other one leads the filmstrip. |
| 260 | XCTAssertEqual( |
| 261 | Stage.filmstrip(stage: .screen("li"), order: order, screens: ["ana", "li"]).first, .screen("ana")) |
| 262 | } |
| 263 | |
| 264 | func testYouFloatOnlyWhenTheFilmstripWouldBeYouAlone() { |
| 265 | XCTAssertTrue(Stage.selfFloats(filmstrip: [.camera("me")], me: "me"), "a call of two") |
| 266 | XCTAssertTrue(Stage.selfFloats(filmstrip: [], me: "me")) |
| 267 | XCTAssertFalse(Stage.selfFloats(filmstrip: [.camera("me"), .camera("tom")], me: "me")) |
| 268 | XCTAssertFalse(Stage.selfFloats(filmstrip: [.camera("me"), .screen("tom")], me: "me")) |
| 269 | } |
| 270 | |
| 271 | func testStageLabels() { |
| 272 | XCTAssertEqual(stageLabel("Tony", onStage: true, pinned: false), "On stage: Tony") |
| 273 | XCTAssertEqual(stageLabel("Tony, muted", onStage: true, pinned: true), "On stage: Tony, muted, pinned") |
| 274 | XCTAssertEqual(stageLabel("Tony", onStage: false, pinned: true), "Tony") |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | final class VideoLayoutTests: XCTestCase { |
| 279 | private var defaults: UserDefaults! |
| 280 | private let suite = "calls.tests.videoLayout" |
| 281 | |
| 282 | override func setUp() { |
| 283 | defaults = UserDefaults(suiteName: suite) |
| 284 | defaults.removePersistentDomain(forName: suite) |
| 285 | } |
| 286 | override func tearDown() { defaults.removePersistentDomain(forName: suite) } |
| 287 | |
| 288 | func testFocusBecomesSpeaker() { |
| 289 | XCTAssertEqual(VideoLayout.stored("focus"), .speaker) |
| 290 | XCTAssertEqual(VideoLayout.stored("grid"), .grid) |
| 291 | XCTAssertEqual(VideoLayout.stored("speaker"), .speaker) |
| 292 | XCTAssertEqual(VideoLayout.stored(nil), .speaker) |
| 293 | XCTAssertEqual(VideoLayout.stored("mosaic"), .speaker) |
| 294 | } |
| 295 | |
| 296 | func testASavedFocusIsRewrittenOnce() { |
| 297 | defaults.set("focus", forKey: VideoLayout.key) |
| 298 | XCTAssertEqual(VideoLayout.load(defaults), .speaker) |
| 299 | XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "speaker") |
| 300 | defaults.set("grid", forKey: VideoLayout.key) |
| 301 | XCTAssertEqual(VideoLayout.load(defaults), .grid) |
| 302 | defaults.removeObject(forKey: VideoLayout.key) |
| 303 | XCTAssertEqual(VideoLayout.load(defaults), .speaker) |
| 304 | XCTAssertNil(defaults.string(forKey: VideoLayout.key), "a default is not written down") |
| 305 | } |
| 306 | |
| 307 | @MainActor func testChoosingIsKeptAndPinningChoosesSpeaker() { |
| 308 | let arrangement = CallArrangement(defaults: defaults) |
| 309 | XCTAssertEqual(arrangement.layout, .speaker) |
| 310 | arrangement.choose(.grid) |
| 311 | XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "grid") |
| 312 | XCTAssertEqual(CallArrangement(defaults: defaults).layout, .grid, "the next call starts in Grid") |
| 313 | arrangement.pin(.camera("ana")) |
| 314 | XCTAssertEqual(arrangement.layout, .speaker) |
| 315 | arrangement.show(.grid) |
| 316 | XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "speaker", "a fixture's layout is not kept") |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | /// The arrangement over a real roster: peer ids in, stable keys kept. |
| 321 | @MainActor final class CallArrangementTests: XCTestCase { |
| 322 | private let suite = "calls.tests.arrangement" |
| 323 | |
| 324 | private func roster(_ phase: String, me: String, _ people: [(String, String)]) -> RoomState { |
| 325 | let peers = people.map { #"{"peer":"\#($0.0)","name":"\#($0.1)","kind":"member"}"# } |
| 326 | let json = #"{"phase":"\#(phase)","peer":"\#(me)","boot":"t","peers":[\#(peers.joined(separator: ","))],"waiting":[]}"# |
| 327 | return try! JSONDecoder().decode(RoomState.self, from: Data(json.utf8)) |
| 328 | } |
| 329 | |
| 330 | func testARejoinPutsTheOrderBack() { |
| 331 | ParticipantOrders.forgetAll() |
| 332 | defer { ParticipantOrders.forgetAll() } |
| 333 | let defaults = UserDefaults(suiteName: suite)! |
| 334 | let arrangement = CallArrangement(defaults: defaults) |
| 335 | arrangement.begin(meeting: "https://x/m1") |
| 336 | arrangement.roster(roster("admitted", me: "p1", [("p1", "Paul"), ("p2", "Ana"), ("p3", "Tom")])) |
| 337 | XCTAssertEqual(arrangement.people, ["p1", "p2", "p3"]) |
| 338 | XCTAssertTrue(arrangement.move("p3", to: 0)) |
| 339 | XCTAssertTrue(arrangement.moveLater("p1")) |
| 340 | XCTAssertEqual(arrangement.people, ["p3", "p2", "p1"]) |
| 341 | XCTAssertEqual(arrangement.others, ["p3", "p2"]) |
| 342 | // Leaving: the roster goes, the order is kept. |
| 343 | arrangement.roster(nil) |
| 344 | // Rejoining: new peer ids, and the roster arriving in pieces. |
| 345 | let again = CallArrangement(defaults: defaults) |
| 346 | again.begin(meeting: "https://x/m1") |
| 347 | again.roster(roster("admitted", me: "q1", [("q1", "Paul")])) |
| 348 | again.roster(roster("admitted", me: "q1", [("q1", "Paul"), ("q7", "Ana"), ("q8", "Tom")])) |
| 349 | XCTAssertEqual(again.people, ["q8", "q7", "q1"]) |
| 350 | // A roster while reconnecting says nothing about who left. |
| 351 | again.roster(roster("reconnecting", me: "q1", [("q1", "Paul")])) |
| 352 | XCTAssertEqual(again.people, ["q8", "q7", "q1"]) |
| 353 | defaults.removePersistentDomain(forName: suite) |
| 354 | } |
| 355 | |
| 356 | func testThePinAndTheStage() { |
| 357 | let defaults = UserDefaults(suiteName: suite)! |
| 358 | defer { defaults.removePersistentDomain(forName: suite) } |
| 359 | let arrangement = CallArrangement(defaults: defaults) |
| 360 | arrangement.roster(roster("admitted", me: "p1", [("p1", "Paul"), ("p2", "Ana"), ("p3", "Tom")])) |
| 361 | XCTAssertEqual(arrangement.stage(active: "p3", screens: []), .camera("p3")) |
| 362 | arrangement.pin(.camera("p2")) |
| 363 | XCTAssertEqual(arrangement.stage(active: "p3", screens: ["p3"]), .camera("p2")) |
| 364 | arrangement.togglePin(.camera("p2")) |
| 365 | XCTAssertNil(arrangement.pinned) |
| 366 | XCTAssertEqual(arrangement.stage(active: "p3", screens: ["p3"]), .screen("p3")) |
| 367 | XCTAssertEqual( |
| 368 | arrangement.filmstrip(stage: .camera("p3"), screens: []), [.camera("p1"), .camera("p2")]) |
| 369 | } |
| 370 | } |
| 371 | |