import XCTest @testable @_spi(Fixtures) import CallsKit /// 0.4.0: the viewer's order, the active speaker, Speaker view's stage and /// filmstrip, and Focus becoming Speaker. final class ParticipantOrderTests: XCTestCase { func testNewArrivalsGoOnTheEndAndLeaversAreRemoved() { var order = ParticipantOrder() order.merge(present: ["self", "name:Ana"]) XCTAssertEqual(order.shown, ["self", "name:Ana"]) order.merge(present: ["self", "name:Ana", "name:Tom"]) XCTAssertEqual(order.shown, ["self", "name:Ana", "name:Tom"]) order.move("name:Tom", to: 0) order.merge(present: ["name:Li", "self", "name:Tom", "name:Ana"]) XCTAssertEqual(order.shown, ["name:Tom", "self", "name:Ana", "name:Li"], "roster order does not undo the viewer's") order.merge(present: ["self", "name:Tom", "name:Li"]) XCTAssertEqual(order.shown, ["name:Tom", "self", "name:Li"]) XCTAssertFalse(order.keys.contains("name:Ana"), "a leaver is gone, not remembered") order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana"]) XCTAssertEqual(order.shown.last, "name:Ana", "and comes back at the end") } func testMovingTo() { var order = ParticipantOrder() order.merge(present: ["a", "b", "c", "d"]) XCTAssertTrue(order.move("d", to: 1)) XCTAssertEqual(order.shown, ["a", "d", "b", "c"]) XCTAssertTrue(order.move("a", to: 99)) XCTAssertEqual(order.shown, ["d", "b", "c", "a"], "past the end is last") XCTAssertTrue(order.move("c", to: -3)) XCTAssertEqual(order.shown, ["c", "d", "b", "a"], "before the start is first") XCTAssertFalse(order.move("c", to: 0), "already there") XCTAssertFalse(order.move("zz", to: 0), "not here") } func testEarlierLaterFirst() { var order = ParticipantOrder() order.merge(present: ["a", "b", "c"]) XCTAssertFalse(order.moveEarlier("a")) XCTAssertFalse(order.moveLater("c")) XCTAssertTrue(order.moveLater("a")) XCTAssertEqual(order.shown, ["b", "a", "c"]) XCTAssertTrue(order.moveEarlier("c")) XCTAssertEqual(order.shown, ["b", "c", "a"]) XCTAssertTrue(order.moveToFirst("a")) XCTAssertEqual(order.shown, ["a", "b", "c"]) XCTAssertFalse(order.moveToFirst("a")) XCTAssertEqual(order.canMove("a").earlier, false) XCTAssertEqual(order.canMove("a").later, true) XCTAssertEqual(order.canMove("c").later, false) } /// Put back for a rejoin: the people remembered keep their places as /// they arrive, one by one, and nobody is lost for not being in the /// first roster. func testARestoredOrderKeepsItsPlacesAsPeopleArrive() throws { var kept = ParticipantOrder() kept.merge(present: ["self", "name:Ana", "name:Tom", "name:Li"]) kept.move("name:Li", to: 0) kept.move("self", to: 3) let data = try JSONEncoder().encode(kept) var order = try JSONDecoder().decode(ParticipantOrder.self, from: data) XCTAssertEqual(order.shown, [], "nobody is here yet") order.merge(present: ["self"]) XCTAssertEqual(order.shown, ["self"]) order.merge(present: ["self", "name:Tom"]) order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana"]) XCTAssertEqual(order.shown, ["name:Li", "name:Ana", "name:Tom", "self"]) order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana", "name:Sam"]) XCTAssertEqual(order.shown.last, "name:Sam") } /// A move among the people here leaves remembered absentees where they /// were. func testMovingSkipsTheAbsent() { var order = ParticipantOrder(keys: ["a", "gone", "b", "c"]) order.merge(present: ["a", "b", "c"]) // "gone" was never seen, so it is remembered, not a leaver. XCTAssertEqual(order.keys, ["a", "gone", "b", "c"]) order.move("c", to: 0) XCTAssertEqual(order.shown, ["c", "a", "b"]) XCTAssertEqual(order.keys, ["c", "gone", "a", "b"]) } func testThePersistenceKey() { XCTAssertEqual( ParticipantOrder.storageKey(meeting: "https://calls.oficina.works/abc"), "calls.order.https://calls.oficina.works/abc") } @MainActor func testOrdersAreKeptPerMeetingForTheSession() { ParticipantOrders.forgetAll() defer { ParticipantOrders.forgetAll() } var order = ParticipantOrder() order.merge(present: ["self", "name:Ana"]) order.move("name:Ana", to: 0) ParticipantOrders.save(order, meeting: "s/one") XCTAssertEqual(ParticipantOrders.load(meeting: "s/one").keys, ["name:Ana", "self"]) XCTAssertEqual(ParticipantOrders.load(meeting: "s/two").keys, []) ParticipantOrders.save(order, meeting: "") XCTAssertEqual(ParticipantOrders.load(meeting: "").keys, [], "a call with no meeting keeps nothing") } func testKeysAreNamesSoARejoinKeepsThem() { let keys = ParticipantKeys.keys( roster: [("p1", "Paul"), ("p2", "Ana"), ("p3", "Ana"), ("p4", "Tom")], me: "p1") XCTAssertEqual(keys.map(\.key), ["self", "name:Ana", "name:Ana#2", "name:Tom"]) let again = ParticipantKeys.keys(roster: [("q9", "Ana"), ("q1", "Paul")], me: "q1") XCTAssertEqual(again.map(\.key), ["name:Ana", "self"], "new peer ids, the same keys") } } final class ActiveSpeakerTests: XCTestCase { typealias Voice = ActiveSpeaker.Voice let everyone: Set = ["ana", "tom", "li"] /// Feeds readings at 5 Hz, as the app takes them, from `from` to `to` /// seconds, with `speaking` speaking at their levels. private func run( _ rule: inout ActiveSpeaker, from: Double, to: Double, _ speaking: [String: Float], present: Set? = nil, me: String? = "me" ) { var step = 0 while from + Double(step) * 0.2 < to - 1e-9 { let t = from + Double(step) * 0.2 step += 1 let voices = (present ?? everyone).union(me.map { [$0] } ?? []).sorted().map { peer in Voice(peer: peer, speaking: speaking[peer] != nil, level: speaking[peer] ?? 0.001) } rule.update(voices, present: present ?? everyone, me: me, now: t) } } func testTheThresholdsAreInTheRangeAsked() { XCTAssertGreaterThanOrEqual(ActiveSpeaker.dominance, 1.2) XCTAssertLessThanOrEqual(ActiveSpeaker.dominance, 1.5) XCTAssertGreaterThanOrEqual(ActiveSpeaker.minimumHold, 3) } func testTheStageGoesToSomeoneOnlyAfterTheDominanceDelay() { var rule = ActiveSpeaker() run(&rule, from: 0, to: 1.2, ["ana": 0.2]) XCTAssertNil(rule.current, "1.2 s is not yet a turn") run(&rule, from: 1.2, to: 1.5, ["ana": 0.2]) XCTAssertEqual(rule.current, "ana") XCTAssertEqual(rule.since, 1.4, accuracy: 0.001) } func testTheLoudestSpeakerIsTheDominantOne() { var rule = ActiveSpeaker() run(&rule, from: 0, to: 2, ["ana": 0.05, "tom": 0.3]) XCTAssertEqual(rule.current, "tom") } func testAnInterjectionDoesNotTakeTheStage() { var rule = ActiveSpeaker() run(&rule, from: 0, to: 5, ["ana": 0.2]) XCTAssertEqual(rule.current, "ana") // Tom: "right, yes" for a second, louder than Ana. run(&rule, from: 5, to: 6, ["ana": 0.1, "tom": 0.4]) run(&rule, from: 6, to: 10, ["ana": 0.2]) XCTAssertEqual(rule.current, "ana") } func testPausesHoldTheCurrentSpeaker() { var rule = ActiveSpeaker() run(&rule, from: 0, to: 2, ["tom": 0.2]) XCTAssertEqual(rule.current, "tom") run(&rule, from: 2, to: 30, [:]) XCTAssertEqual(rule.current, "tom", "silence changes nothing") } func testTheMinimumHoldHoldsThroughATakeover() { var rule = ActiveSpeaker() run(&rule, from: 0, to: 1.5, ["ana": 0.2]) XCTAssertEqual(rule.current, "ana") let took = rule.since // Tom starts at once and keeps going: dominant long enough after // 1.3 s, but Ana has not had her 3 s yet. run(&rule, from: 1.6, to: took + 2.9, ["tom": 0.3]) XCTAssertEqual(rule.current, "ana") run(&rule, from: took + 3, to: took + 3.1, ["tom": 0.3]) XCTAssertEqual(rule.current, "tom") } func testTwoPeopleTalkingOverEachOtherDoNotFlap() { var rule = ActiveSpeaker() var switches = 0 var last: String? var t = 0.0 // Alternating every 0.8 s for a minute: each is loudest for less than // the dominance delay. while t < 60 { let who = Int(t / 0.8) % 2 == 0 ? "ana" : "tom" let other = who == "ana" ? "tom" : "ana" rule.update( [Voice(peer: who, speaking: true, level: 0.3), Voice(peer: other, speaking: true, level: 0.1)], present: everyone, me: "me", now: t) if rule.current != last { switches += 1 last = rule.current } t += 0.2 } XCTAssertEqual(switches, 0, "nobody held the floor for 1.3 s") } func testNeverYou() { var rule = ActiveSpeaker() let present: Set = ["ana", "me"] for step in 0..<50 { rule.update( [Voice(peer: "me", speaking: true, level: 0.9), Voice(peer: "ana", speaking: false, level: 0)], present: present, me: "me", now: Double(step) * 0.2) } XCTAssertNil(rule.current, "your own voice never puts you on the stage") } func testALeaverLeavesTheStageAtOnce() { var rule = ActiveSpeaker() run(&rule, from: 0, to: 2, ["li": 0.2]) XCTAssertEqual(rule.current, "li") XCTAssertTrue(rule.update([], present: ["ana", "tom"], me: "me", now: 2.2)) XCTAssertNil(rule.current) // And the next speaker needs only the dominance delay, not a hold. run(&rule, from: 2.4, to: 3.9, ["ana": 0.2], present: ["ana", "tom"]) XCTAssertEqual(rule.current, "ana") } } final class SpeakerStageTests: XCTestCase { func testPinBeatsScreenBeatsSpeakerBeatsOrder() { let others = ["ana", "tom", "li"] XCTAssertEqual(Stage.speaker(pinned: nil, active: nil, others: others, screens: []), .camera("ana")) XCTAssertEqual(Stage.speaker(pinned: nil, active: "li", others: others, screens: []), .camera("li")) XCTAssertEqual(Stage.speaker(pinned: nil, active: "li", others: others, screens: ["tom"]), .screen("tom")) XCTAssertEqual( Stage.speaker(pinned: .camera("ana"), active: "li", others: others, screens: ["tom"]), .camera("ana"), "a new screen does not take a pinned stage") XCTAssertEqual( Stage.speaker(pinned: .camera("me"), active: "li", others: others, screens: [], me: "me"), .camera("me"), "you only by your own pin") } func testTheSpeakerWhoLeftFallsBackToTheOrder() { XCTAssertEqual(Stage.speaker(pinned: nil, active: "gone", others: ["tom", "ana"], screens: []), .camera("tom")) } func testTheFilmstripIsEveryoneElseInTheViewersOrder() { let order = ["tom", "me", "ana", "li"] XCTAssertEqual( Stage.filmstrip(stage: .camera("ana"), order: order, screens: []), [.camera("tom"), .camera("me"), .camera("li")]) // A screen on the stage: the sharer's camera is in the filmstrip. XCTAssertEqual( Stage.filmstrip(stage: .screen("li"), order: order, screens: ["li"]), [.camera("tom"), .camera("me"), .camera("ana"), .camera("li")]) // Two screens: the other one leads the filmstrip. XCTAssertEqual( Stage.filmstrip(stage: .screen("li"), order: order, screens: ["ana", "li"]).first, .screen("ana")) } func testYouFloatOnlyWhenTheFilmstripWouldBeYouAlone() { XCTAssertTrue(Stage.selfFloats(filmstrip: [.camera("me")], me: "me"), "a call of two") XCTAssertTrue(Stage.selfFloats(filmstrip: [], me: "me")) XCTAssertFalse(Stage.selfFloats(filmstrip: [.camera("me"), .camera("tom")], me: "me")) XCTAssertFalse(Stage.selfFloats(filmstrip: [.camera("me"), .screen("tom")], me: "me")) } func testStageLabels() { XCTAssertEqual(stageLabel("Tony", onStage: true, pinned: false), "On stage: Tony") XCTAssertEqual(stageLabel("Tony, muted", onStage: true, pinned: true), "On stage: Tony, muted, pinned") XCTAssertEqual(stageLabel("Tony", onStage: false, pinned: true), "Tony") } } final class VideoLayoutTests: XCTestCase { private var defaults: UserDefaults! private let suite = "calls.tests.videoLayout" override func setUp() { defaults = UserDefaults(suiteName: suite) defaults.removePersistentDomain(forName: suite) } override func tearDown() { defaults.removePersistentDomain(forName: suite) } func testFocusBecomesSpeaker() { XCTAssertEqual(VideoLayout.stored("focus"), .speaker) XCTAssertEqual(VideoLayout.stored("grid"), .grid) XCTAssertEqual(VideoLayout.stored("speaker"), .speaker) XCTAssertEqual(VideoLayout.stored(nil), .speaker) XCTAssertEqual(VideoLayout.stored("mosaic"), .speaker) } func testASavedFocusIsRewrittenOnce() { defaults.set("focus", forKey: VideoLayout.key) XCTAssertEqual(VideoLayout.load(defaults), .speaker) XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "speaker") defaults.set("grid", forKey: VideoLayout.key) XCTAssertEqual(VideoLayout.load(defaults), .grid) defaults.removeObject(forKey: VideoLayout.key) XCTAssertEqual(VideoLayout.load(defaults), .speaker) XCTAssertNil(defaults.string(forKey: VideoLayout.key), "a default is not written down") } @MainActor func testChoosingIsKeptAndPinningChoosesSpeaker() { let arrangement = CallArrangement(defaults: defaults) XCTAssertEqual(arrangement.layout, .speaker) arrangement.choose(.grid) XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "grid") XCTAssertEqual(CallArrangement(defaults: defaults).layout, .grid, "the next call starts in Grid") arrangement.pin(.camera("ana")) XCTAssertEqual(arrangement.layout, .speaker) arrangement.show(.grid) XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "speaker", "a fixture's layout is not kept") } } /// The arrangement over a real roster: peer ids in, stable keys kept. @MainActor final class CallArrangementTests: XCTestCase { private let suite = "calls.tests.arrangement" private func roster(_ phase: String, me: String, _ people: [(String, String)]) -> RoomState { let peers = people.map { #"{"peer":"\#($0.0)","name":"\#($0.1)","kind":"member"}"# } let json = #"{"phase":"\#(phase)","peer":"\#(me)","boot":"t","peers":[\#(peers.joined(separator: ","))],"waiting":[]}"# return try! JSONDecoder().decode(RoomState.self, from: Data(json.utf8)) } func testARejoinPutsTheOrderBack() { ParticipantOrders.forgetAll() defer { ParticipantOrders.forgetAll() } let defaults = UserDefaults(suiteName: suite)! let arrangement = CallArrangement(defaults: defaults) arrangement.begin(meeting: "https://x/m1") arrangement.roster(roster("admitted", me: "p1", [("p1", "Paul"), ("p2", "Ana"), ("p3", "Tom")])) XCTAssertEqual(arrangement.people, ["p1", "p2", "p3"]) XCTAssertTrue(arrangement.move("p3", to: 0)) XCTAssertTrue(arrangement.moveLater("p1")) XCTAssertEqual(arrangement.people, ["p3", "p2", "p1"]) XCTAssertEqual(arrangement.others, ["p3", "p2"]) // Leaving: the roster goes, the order is kept. arrangement.roster(nil) // Rejoining: new peer ids, and the roster arriving in pieces. let again = CallArrangement(defaults: defaults) again.begin(meeting: "https://x/m1") again.roster(roster("admitted", me: "q1", [("q1", "Paul")])) again.roster(roster("admitted", me: "q1", [("q1", "Paul"), ("q7", "Ana"), ("q8", "Tom")])) XCTAssertEqual(again.people, ["q8", "q7", "q1"]) // A roster while reconnecting says nothing about who left. again.roster(roster("reconnecting", me: "q1", [("q1", "Paul")])) XCTAssertEqual(again.people, ["q8", "q7", "q1"]) defaults.removePersistentDomain(forName: suite) } func testThePinAndTheStage() { let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } let arrangement = CallArrangement(defaults: defaults) arrangement.roster(roster("admitted", me: "p1", [("p1", "Paul"), ("p2", "Ana"), ("p3", "Tom")])) XCTAssertEqual(arrangement.stage(active: "p3", screens: []), .camera("p3")) arrangement.pin(.camera("p2")) XCTAssertEqual(arrangement.stage(active: "p3", screens: ["p3"]), .camera("p2")) arrangement.togglePin(.camera("p2")) XCTAssertNil(arrangement.pinned) XCTAssertEqual(arrangement.stage(active: "p3", screens: ["p3"]), .screen("p3")) XCTAssertEqual( arrangement.filmstrip(stage: .camera("p3"), screens: []), [.camera("p1"), .camera("p2")]) } }