oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2import XCTest
3
4@testable @_spi(Fixtures) import CallsKit
5
6/// The full-window call's rules: when its controls show and hide, where
7/// the self view settles, which notices outlive the controls, and how a
8/// grid fits.
9final class ControlsVisibilityTests: XCTestCase {
10 typealias V = ControlsVisibility
11
12 func testStartsVisibleAndHidesAfterTheDelay() {
13 var pointer = V(input: .pointer, now: 0)
14 XCTAssertTrue(pointer.visible)
15 XCTAssertEqual(pointer.hideAt, 3)
16 pointer.tick(now: 2.9)
17 XCTAssertTrue(pointer.visible, "not before the delay")
18 pointer.tick(now: 3)
19 XCTAssertFalse(pointer.visible)
20 XCTAssertNil(pointer.hideAt)
21
22 var touch = V(input: .touch, now: 10)
23 XCTAssertEqual(touch.hideAt, 13)
24 touch.tick(now: 12.9)
25 XCTAssertTrue(touch.visible)
26 touch.tick(now: 13)
27 XCTAssertFalse(touch.visible)
28 }
29
30 /// Paul, on 0.3.4: "it should hang around for 2–3 seconds before
31 /// fading away".
32 func testTheTimings() {
33 XCTAssertEqual(V.pointerDelay, 3)
34 XCTAssertEqual(V.touchDelay, 3)
35 XCTAssertEqual(V.pointerExitDelay, 1.5)
36 XCTAssertEqual(V.fadeOutDuration, 0.35)
37 XCTAssertEqual(V.fadeDuration(showing: false, reduceMotion: false), 0.35)
38 XCTAssertEqual(V.fadeDuration(showing: true, reduceMotion: false), V.fadeInDuration)
39 }
40
41 func testPointerMovementShowsAndRestartsTheWait() {
42 var state = V(input: .pointer, now: 0)
43 state.tick(now: 3)
44 XCTAssertFalse(state.visible)
45 state.pointerMoved(now: 4)
46 XCTAssertTrue(state.visible)
47 XCTAssertEqual(state.hideAt, 7)
48 state.pointerMoved(now: 5)
49 state.tick(now: 7)
50 XCTAssertTrue(state.visible, "movement at 5 pushed the hide to 8")
51 state.tick(now: 8)
52 XCTAssertFalse(state.visible)
53 }
54
55 func testLeavingTheWindowLingersThenHides() {
56 var state = V(input: .pointer, now: 0)
57 state.pointerMoved(now: 0.2)
58 state.pointerExited(now: 0.3)
59 XCTAssertTrue(state.visible, "not at once")
60 XCTAssertEqual(state.hideAt, 1.8, "1.5 s after leaving, sooner than the 3 s wait")
61 XCTAssertFalse(state.pointerInside)
62 state.tick(now: 1.7)
63 XCTAssertTrue(state.visible)
64 state.tick(now: 1.8)
65 XCTAssertFalse(state.visible)
66 XCTAssertFalse(state.hidesPointer, "no pointer to hide outside the window")
67 }
68
69 func testLeavingTheWindowNeverLengthensTheWait() {
70 var state = V(input: .pointer, now: 0)
71 state.pointerMoved(now: 0)
72 state.pointerExited(now: 2.5)
73 XCTAssertEqual(state.hideAt, 3, "already due at 3")
74 state.tick(now: 3)
75 XCTAssertFalse(state.visible)
76 state.pointerExited(now: 4)
77 XCTAssertFalse(state.visible, "leaving does not bring hidden controls back")
78 XCTAssertNil(state.hideAt)
79 }
80
81 func testComingBackBeforeTheLingerEndsKeepsThem() {
82 var state = V(input: .pointer, now: 0)
83 state.pointerExited(now: 1)
84 state.pointerMoved(now: 2)
85 state.tick(now: 2.5)
86 XCTAssertTrue(state.visible)
87 XCTAssertEqual(state.hideAt, 5)
88 }
89
90 func testLeavingTheWindowDoesNotHideHeldControls() {
91 var state = V(input: .pointer, now: 0)
92 state.set(.menuOpen, true, now: 0.1)
93 state.pointerExited(now: 0.2)
94 XCTAssertTrue(state.visible, "a menu is open")
95 state.set(.menuOpen, false, now: 3)
96 XCTAssertTrue(state.visible, "letting go starts the wait rather than hiding at once")
97 XCTAssertEqual(state.hideAt, 6)
98 state.tick(now: 6)
99 XCTAssertFalse(state.visible)
100 }
101
102 func testLeavingTheWindowIsLeavingTheBar() {
103 var state = V(input: .pointer, now: 0)
104 state.set(.pointerOverControls, true, now: 0.1)
105 state.pointerExited(now: 0.2)
106 XCTAssertFalse(state.holds.contains(.pointerOverControls))
107 XCTAssertEqual(state.hideAt, 1.7, "the bar no longer holds them; they linger, then go")
108 state.tick(now: 1.7)
109 XCTAssertFalse(state.visible)
110 }
111
112 /// Every rule that keeps the controls up does so for as long as it
113 /// lasts, however long that is, and each lets go on its own.
114 func testEveryHoldKeepsTheControlsUp() {
115 let holds: [V.Holds] = [.pointerOverControls, .menuOpen, .keyboardFocus, .voiceOver, .alert]
116 for input in [V.Input.pointer, .touch] {
117 for hold in holds {
118 var state = V(input: input, now: 0)
119 state.set(hold, true, now: 0.5)
120 XCTAssertNil(state.hideAt, "\(hold) \(input)")
121 state.tick(now: 1000)
122 XCTAssertTrue(state.visible, "\(hold) \(input) holds")
123 state.set(hold, false, now: 1000)
124 state.tick(now: 1000 + state.delay)
125 XCTAssertFalse(state.visible, "\(hold) \(input) lets go")
126 }
127 }
128 }
129
130 func testAHoldShowsHiddenControls() {
131 var state = V(input: .pointer, now: 0)
132 state.tick(now: 5)
133 XCTAssertFalse(state.visible)
134 state.set(.voiceOver, true, now: 6)
135 XCTAssertTrue(state.visible, "VoiceOver starting brings the controls back")
136 state = V(input: .pointer, now: 0)
137 state.tick(now: 5)
138 state.set(.keyboardFocus, true, now: 6)
139 XCTAssertTrue(state.visible, "focus moving into the bar shows it")
140 }
141
142 func testOneHoldEndingLeavesTheOthers() {
143 var state = V(input: .pointer, now: 0)
144 state.set(.menuOpen, true, now: 0)
145 state.set(.alert, true, now: 0)
146 state.set(.menuOpen, false, now: 1)
147 state.tick(now: 100)
148 XCTAssertTrue(state.visible, "the alert still holds")
149 state.set(.alert, false, now: 100)
150 state.tick(now: 103)
151 XCTAssertFalse(state.visible)
152 }
153
154 func testReleasingAHoldThatWasNotHeldChangesNothing() {
155 var state = V(input: .pointer, now: 0)
156 state.tick(now: 3)
157 state.set(.menuOpen, false, now: 4)
158 XCTAssertFalse(state.visible)
159 XCTAssertNil(state.hideAt)
160 }
161
162 func testVoiceOverNeverLetsATapHideTheControls() {
163 var state = V(input: .touch, now: 0)
164 state.set(.voiceOver, true, now: 0)
165 state.tapped(now: 1)
166 XCTAssertTrue(state.visible)
167 state.tick(now: 1000)
168 XCTAssertTrue(state.visible)
169 var pointer = V(input: .pointer, now: 0)
170 pointer.set(.voiceOver, true, now: 0)
171 pointer.pointerExited(now: 1)
172 pointer.tick(now: 1000)
173 XCTAssertTrue(pointer.visible, "nor the pointer leaving")
174 }
175
176 func testATapToggles() {
177 var state = V(input: .touch, now: 0)
178 state.tapped(now: 0.5)
179 XCTAssertFalse(state.visible, "a tap on showing controls hides them")
180 state.tapped(now: 1)
181 XCTAssertTrue(state.visible)
182 XCTAssertEqual(state.hideAt, 4)
183 state.tick(now: 3.9)
184 XCTAssertTrue(state.visible)
185 state.tick(now: 4)
186 XCTAssertFalse(state.visible)
187 }
188
189 func testATapOnHeldControlsKeepsThem() {
190 var state = V(input: .touch, now: 0)
191 state.set(.menuOpen, true, now: 0)
192 state.tapped(now: 1)
193 XCTAssertTrue(state.visible)
194 XCTAssertNil(state.hideAt)
195 }
196
197 func testActivityShowsAndRestarts() {
198 var state = V(input: .pointer, now: 0)
199 state.tick(now: 3)
200 state.activity(now: 4) // Tab, say
201 XCTAssertTrue(state.visible)
202 XCTAssertEqual(state.hideAt, 7)
203 }
204
205 func testThePointerHidesWithTheControlsOnlyOverTheCall() {
206 var state = V(input: .pointer, now: 0)
207 state.pointerMoved(now: 0)
208 XCTAssertFalse(state.hidesPointer, "controls showing")
209 state.tick(now: 3)
210 XCTAssertTrue(state.hidesPointer)
211 state.pointerMoved(now: 4)
212 XCTAssertFalse(state.hidesPointer, "movement brings it back")
213 var touch = V(input: .touch, now: 0)
214 touch.tick(now: 5)
215 XCTAssertFalse(touch.hidesPointer)
216 }
217
218 func testReduceMotionIsAShortCrossFade() {
219 XCTAssertLessThanOrEqual(V.fadeDuration(showing: true, reduceMotion: true), 0.1)
220 XCTAssertLessThanOrEqual(V.fadeDuration(showing: false, reduceMotion: true), 0.1)
221 XCTAssertGreaterThan(V.fadeDuration(showing: false, reduceMotion: false), 0.1)
222 XCTAssertLessThan(
223 V.fadeDuration(showing: true, reduceMotion: false),
224 V.fadeDuration(showing: false, reduceMotion: false), "they arrive faster than they go")
225 }
226
227 @MainActor func testTheModelHidesOnItsOwnClock() async throws {
228 let model = ControlsVisibilityModel(input: .pointer)
229 XCTAssertTrue(model.visible)
230 try await Task.sleep(for: .seconds(2))
231 XCTAssertTrue(model.visible, "still up two seconds in")
232 try await Task.sleep(for: .seconds(1.3))
233 XCTAssertFalse(model.visible, "hidden by its own timer")
234 model.pointerMoved()
235 XCTAssertTrue(model.visible)
236 model.set(.menuOpen, true)
237 try await Task.sleep(for: .seconds(3.3))
238 XCTAssertTrue(model.visible, "held")
239 }
240
241 @MainActor func testAPinnedModelIgnoresInput() {
242 let hidden = ControlsVisibilityModel(input: .touch, pinned: false)
243 hidden.tapped()
244 XCTAssertFalse(hidden.visible)
245 let shown = ControlsVisibilityModel(input: .pointer, pinned: true)
246 shown.pointerExited()
247 XCTAssertTrue(shown.visible)
248 }
249
250 /// The traffic lights go and come back with the controls, and are left
251 /// alone in full screen.
252 func testTheWindowButtonsFollowTheControls() {
253 var state = V(input: .pointer, now: 0)
254 XCTAssertTrue(state.showsWindowButtons(fullScreen: false))
255 state.tick(now: 3)
256 XCTAssertFalse(state.showsWindowButtons(fullScreen: false), "gone with the controls")
257 XCTAssertTrue(state.showsWindowButtons(fullScreen: true), "full screen's are the system's")
258 state.pointerMoved(now: 4)
259 XCTAssertTrue(state.showsWindowButtons(fullScreen: false), "back as soon as the pointer moves")
260 state.set(.menuOpen, true, now: 4)
261 state.tick(now: 100)
262 XCTAssertTrue(state.showsWindowButtons(fullScreen: false), "held up with the controls")
263 }
264
265 #if os(macOS)
266 /// The drag strip's double click follows System Settings.
267 func testTitleBarDoubleClickFollowsTheSetting() {
268 XCTAssertEqual(TitleBarDoubleClick.from(action: nil, miniaturize: nil), .zoom, "the default")
269 XCTAssertEqual(TitleBarDoubleClick.from(action: "Maximize", miniaturize: nil), .zoom)
270 XCTAssertEqual(TitleBarDoubleClick.from(action: "Fill", miniaturize: nil), .zoom)
271 XCTAssertEqual(TitleBarDoubleClick.from(action: "Minimize", miniaturize: nil), .minimize)
272 XCTAssertEqual(TitleBarDoubleClick.from(action: "None", miniaturize: true), .none)
273 XCTAssertEqual(TitleBarDoubleClick.from(action: nil, miniaturize: true), .minimize)
274 }
275 #endif
276
277 @MainActor func testAPinnedModelPinsTheWindowButtons() {
278 XCTAssertFalse(
279 ControlsVisibilityModel(input: .pointer, pinned: false).showsWindowButtons(fullScreen: false))
280 XCTAssertTrue(
281 ControlsVisibilityModel(input: .pointer, pinned: true).showsWindowButtons(fullScreen: false))
282 }
283}
284
285/// WCAG 2.2 §1.4.11: the overlay's symbols and text against what they sit
286/// on, at 3:1 or better, over the worst video there is for each — pure
287/// white for anything light on the dark surfaces — as well as over black
288/// and mid-grey.
289final class CallOverlayContrastTests: XCTestCase {
290 typealias RGB = (r: Double, g: Double, b: Double)
291
292 /// Gamma-encoded sRGB components, which is what the compositor blends
293 /// and what WCAG's formula starts from.
294 private func rgb(_ color: Color) -> (RGB, Double) {
295 let c = color.resolve(in: EnvironmentValues())
296 return ((Double(c.red), Double(c.green), Double(c.blue)), Double(c.opacity))
297 }
298 private func over(_ top: Color, _ bottom: RGB) -> RGB {
299 let (t, a) = rgb(top)
300 return (t.r * a + bottom.r * (1 - a), t.g * a + bottom.g * (1 - a), t.b * a + bottom.b * (1 - a))
301 }
302 private func luminance(_ c: RGB) -> Double {
303 func linear(_ v: Double) -> Double { v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) }
304 return 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b)
305 }
306 private func ratio(_ a: RGB, _ b: RGB) -> Double {
307 let (x, y) = (luminance(a), luminance(b))
308 return (max(x, y) + 0.05) / (min(x, y) + 0.05)
309 }
310
311 func testEverythingOnTheOverlayIsAtLeastThreeToOne() {
312 // The formula itself, against WebAIM's figures.
313 XCTAssertEqual(ratio((1, 1, 1), (0, 0, 0)), 21, accuracy: 0.01)
314 XCTAssertEqual(ratio((1, 1, 1), (0x77 / 255.0, 0x77 / 255.0, 0x77 / 255.0)), 4.48, accuracy: 0.01)
315 let white: RGB = (1, 1, 1)
316 for (name, video) in [("white", white), ("black", (0, 0, 0)), ("grey", (0.2, 0.2, 0.2))] as [(String, RGB)] {
317 // The black alone, as if neither glass nor blur were drawn: the
318 // rendered glass over white measures darker than this (#5B5B5B).
319 let surface = over(Color.black.opacity(CallOverlay.scrim), video)
320 let hovered = over(CallOverlay.hoverFill, surface)
321 let checks: [(String, Double, Double)] = [
322 ("white on the surface (a resting glyph, a name, the title)", ratio(white, surface), 6),
323 ("a white glyph under the pointer", ratio(white, hovered), 4.5),
324 ("secondary text", ratio(over(CallOverlay.secondaryText, surface), surface), 4.5),
325 ("alert text", ratio(rgb(CallOverlay.alertText).0, surface), 4.5),
326 ("an on capsule against the pane", ratio(rgb(CallOverlay.onFill).0, surface), 3),
327 ("an on capsule's symbol", ratio(rgb(CallOverlay.onSymbol).0, rgb(CallOverlay.onFill).0), 4.5),
328 ("white on amber (off)", ratio(white, rgb(CallOverlay.offFill).0), 4.5),
329 ("white on Leave's red", ratio(white, rgb(CallOverlay.leaveFill).0), 4.5),
330 ("the unread badge", ratio(white, rgb(CallOverlay.badgeFill).0), 4.5),
331 ]
332 for (what, measured, least) in checks {
333 XCTAssertGreaterThanOrEqual(measured, least, "\(what) over \(name) video: \(measured)")
334 }
335 }
336 }
337
338 /// The Devices panel over the picture: opaque, so the video never
339 /// enters into it, with the dark appearance's own label colours.
340 func testTheDevicesPanelIsReadable() {
341 let panel = rgb(CallOverlay.panelFill).0
342 XCTAssertEqual(rgb(CallOverlay.panelFill).1, 1, "opaque")
343 XCTAssertGreaterThanOrEqual(ratio((1, 1, 1), panel), 15, "primary text")
344 // secondaryLabelColor in the dark appearance: white at 55 %.
345 let secondary = over(Color.white.opacity(0.55), panel)
346 XCTAssertGreaterThanOrEqual(ratio(secondary, panel), 4.5, "secondary text")
347 }
348}
349
350final class PiPLayoutTests: XCTestCase {
351 let stage = CGSize(width: 1000, height: 600)
352
353 func testEachCornerSitsTheMarginIn() {
354 let size = CGSize(width: 200, height: 112)
355 let m = PiPLayout.margin
356 XCTAssertEqual(
357 PiPLayout.frame(corner: .topLeading, size: size, in: stage).origin, CGPoint(x: m, y: m))
358 XCTAssertEqual(
359 PiPLayout.frame(corner: .topTrailing, size: size, in: stage).origin,
360 CGPoint(x: 1000 - m - 200, y: m))
361 XCTAssertEqual(
362 PiPLayout.frame(corner: .bottomLeading, size: size, in: stage).origin,
363 CGPoint(x: m, y: 600 - m - 112))
364 XCTAssertEqual(
365 PiPLayout.frame(corner: .bottomTrailing, size: size, in: stage).origin,
366 CGPoint(x: 1000 - m - 200, y: 600 - m - 112))
367 }
368
369 func testInsetsKeepItClearOfTheBarAndSidebar() {
370 let size = CGSize(width: 200, height: 112)
371 let insets = EdgeInsets(top: 32, leading: 0, bottom: 80, trailing: 332)
372 let frame = PiPLayout.frame(corner: .bottomTrailing, size: size, in: stage, insets: insets)
373 XCTAssertEqual(frame.maxY, 600 - 80 - PiPLayout.margin, "above the bar")
374 XCTAssertEqual(frame.maxX, 1000 - 332 - PiPLayout.margin, "beside the chat")
375 let top = PiPLayout.frame(corner: .topLeading, size: size, in: stage, insets: insets)
376 XCTAssertEqual(top.minY, 32 + PiPLayout.margin, "under the title bar")
377 }
378
379 func testATinyStageStillHoldsIt() {
380 let frame = PiPLayout.frame(
381 corner: .bottomTrailing, size: CGSize(width: 200, height: 112),
382 in: CGSize(width: 150, height: 100), insets: EdgeInsets(top: 0, leading: 0, bottom: 300, trailing: 300))
383 XCTAssertGreaterThanOrEqual(frame.minX, 0)
384 XCTAssertGreaterThanOrEqual(frame.minY, 0)
385 }
386
387 func testADropSettlesInTheQuarterItWasHeadedFor() {
388 XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 100, y: 100), in: stage), .topLeading)
389 XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 900, y: 100), in: stage), .topTrailing)
390 XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 100, y: 500), in: stage), .bottomLeading)
391 XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 900, y: 500), in: stage), .bottomTrailing)
392 }
393
394 func testAFlickCarriesItToTheCornerItWasThrownAt() {
395 // Let go just left of centre, moving fast to the right and up.
396 let corner = PiPLayout.corner(
397 droppedAt: CGPoint(x: 480, y: 320), predicted: CGPoint(x: 1300, y: -80), in: stage)
398 XCTAssertEqual(corner, .topTrailing)
399 }
400
401 func testSizes() {
402 let mac = PiPLayout.size(in: stage, touch: false)
403 XCTAssertEqual(mac.width, 200)
404 XCTAssertEqual(mac.height, 113)
405 XCTAssertEqual(PiPLayout.size(in: CGSize(width: 3000, height: 2000), touch: false).width, 260)
406 XCTAssertEqual(PiPLayout.size(in: CGSize(width: 600, height: 400), touch: false).width, 150)
407 let portrait = PiPLayout.size(in: CGSize(width: 390, height: 844), touch: true)
408 XCTAssertGreaterThan(portrait.height, portrait.width, "upright on an upright phone")
409 let landscape = PiPLayout.size(in: CGSize(width: 844, height: 390), touch: true)
410 XCTAssertGreaterThan(landscape.width, landscape.height)
411 }
412}
413
414final class CallNoticesTests: XCTestCase {
415 func testAnOrdinaryCallHasNothingToSay() {
416 XCTAssertEqual(
417 CallNotices.notices(
418 phase: "admitted", status: "In the meeting", sharingScreen: false, screenNote: "",
419 floor: false), [])
420 }
421
422 func testWhatStaysAndWhatFades() {
423 let notices = CallNotices.notices(
424 phase: "reconnecting", status: "Reconnecting…", sharingScreen: true, screenNote: "",
425 floor: true)
426 XCTAssertEqual(notices.map(\.kind), [.reconnecting, .sharing, .floor])
427 XCTAssertEqual(notices.map(\.important), [true, true, false], "the relay line fades")
428 }
429
430 /// The title pill (until 2026-10-02) said how the call was going beside
431 /// the title. It is gone, so every state it carried has to be a notice,
432 /// and one that stays whatever the controls do.
433 func testEveryStateThePillCarriedIsANotice() {
434 let states: [(phase: String?, status: String, kind: CallNotice.Kind)] = [
435 (nil, "Connecting…", .connecting),
436 ("connecting", "Connecting…", .connecting),
437 ("waiting", "Waiting to be let in", .waitingToJoin),
438 ("full", "The meeting is full. Try joining again shortly.", .full),
439 ("reconnecting", "Reconnecting…", .reconnecting),
440 ("ended", "This meeting has ended", .ended),
441 ]
442 for state in states {
443 let notices = CallNotices.notices(
444 phase: state.phase, status: state.status, sharingScreen: false, screenNote: "",
445 floor: false)
446 XCTAssertEqual(notices.map(\.kind), [state.kind], "\(state)")
447 XCTAssertEqual(notices.first?.text, state.status)
448 XCTAssertEqual(notices.first?.important, true, "\(state) stays with the controls hidden")
449 }
450 }
451
452 /// Anything the call says that is not the quiet "In the meeting" is a
453 /// notice now — not only what began "Could not", which was all the pill
454 /// left to the notices before.
455 func testEveryOtherStatusIsAProblemNotice() {
456 for status in [
457 "Could not send to the meeting. Try again.", "A participant’s media could not connect.",
458 "Keep the message within 2 KB.",
459 ] {
460 let notices = CallNotices.notices(
461 phase: "admitted", status: status, sharingScreen: false, screenNote: "", floor: false)
462 XCTAssertEqual(notices.map(\.kind), [.problem], status)
463 XCTAssertEqual(notices.first?.dismissible, true)
464 }
465 for quiet in ["In the meeting", "Ready to join", "You left the meeting", ""] {
466 XCTAssertEqual(
467 CallNotices.notices(
468 phase: nil, status: quiet, sharingScreen: false, screenNote: "", floor: false), [],
469 quiet)
470 }
471 }
472
473 func testEndedFullAndWaitingAreImportant() {
474 for (phase, kind) in [
475 ("ended", CallNotice.Kind.ended), ("full", .full), ("waiting", .waitingToJoin),
476 ] {
477 let notice = CallNotices.notices(
478 phase: phase, status: "x", sharingScreen: false, screenNote: "", floor: false
479 ).first
480 XCTAssertEqual(notice?.kind, kind)
481 XCTAssertEqual(notice?.important, true)
482 }
483 }
484
485 func testProblemsAreImportantAndDismissible() {
486 let notices = CallNotices.notices(
487 phase: "admitted", status: "Could not send to the meeting. Try again.",
488 sharingScreen: false, screenNote: "Screen sharing was not allowed.", floor: false)
489 XCTAssertEqual(notices.map(\.kind), [.problem, .problem])
490 XCTAssertTrue(notices.allSatisfy { $0.important && $0.dismissible })
491 XCTAssertFalse(
492 CallNotice(kind: .reconnecting, text: "").dismissible, "reconnecting ends on its own")
493 }
494}
495
496final class GridFitTests: XCTestCase {
497 func testOneTileFillsTheStage() {
498 XCTAssertEqual(GridFit.columns(count: 1, in: CGSize(width: 1600, height: 900), aspect: 16 / 9), 1)
499 }
500
501 func testTwoSideBySideOnAWideStageAndStackedOnATallOne() {
502 XCTAssertEqual(GridFit.columns(count: 2, in: CGSize(width: 1600, height: 600), aspect: 16 / 9), 2)
503 XCTAssertEqual(GridFit.columns(count: 2, in: CGSize(width: 390, height: 844), aspect: 3 / 4), 1)
504 }
505
506 func testFourMakeTwoByTwo() {
507 XCTAssertEqual(GridFit.columns(count: 4, in: CGSize(width: 1600, height: 900), aspect: 16 / 9), 2)
508 let tile = GridFit.tile(
509 count: 4, columns: 2, in: CGSize(width: 1604, height: 904), aspect: 16 / 9, spacing: 4)
510 XCTAssertEqual(tile.width, 800, accuracy: 0.01)
511 }
512}
513
514final class CameraFlipTests: XCTestCase {
515 let front = CameraDevice(id: "front", name: "Front Camera", builtIn: true)
516 let back = CameraDevice(id: "back", name: "Back Camera")
517 let wide = CameraDevice(id: "wide", name: "Back Ultra Wide Camera")
518
519 func testFrontAndBackSwap() {
520 XCTAssertEqual(CameraFlip.next(from: "front", in: [front, back, wide]), back)
521 XCTAssertEqual(CameraFlip.next(from: "wide", in: [front, back, wide]), front)
522 }
523
524 func testNothingToFlipTo() {
525 XCTAssertNil(CameraFlip.next(from: "front", in: [front]))
526 XCTAssertNil(CameraFlip.next(from: nil, in: []))
527 }
528}
529
530final class InviteLinkCopyTests: XCTestCase {
531 func testOnlyInACall() {
532 XCTAssertEqual(
533 InviteLinkCopy.rule(inCall: false, phase: nil, standing: "member"), .unavailable)
534 XCTAssertEqual(
535 InviteLinkCopy.rule(inCall: true, phase: "ended", standing: "member"), .unavailable,
536 "an ended meeting has no link to share")
537 }
538
539 func testAMemberCopies() {
540 for phase in ["admitted", "waiting", "reconnecting", nil] {
541 XCTAssertEqual(InviteLinkCopy.rule(inCall: true, phase: phase, standing: "member"), .copy)
542 }
543 }
544
545 func testAGuestIsToldWhyNot() {
546 guard case .explain(let why) = InviteLinkCopy.rule(inCall: true, phase: "admitted", standing: "guest")
547 else { return XCTFail("a guest is told, not beeped at") }
548 XCTAssertTrue(why.contains("Only members"))
549 guard case .explain = InviteLinkCopy.rule(inCall: true, phase: nil, standing: "") else {
550 return XCTFail("before the meeting says who you are")
551 }
552 }
553
554 func testTheCallMenuHasCopyInviteLinkBesideMute() {
555 XCTAssertEqual(CallMenuItem.allCases, [.mute, .copyInviteLink])
556 XCTAssertEqual(CallMenuItem.copyInviteLink.title(muted: false), "Copy Invite Link")
557 XCTAssertEqual(CallMenuItem.copyInviteLink.shortcut.key, "i")
558 XCTAssertEqual(CallMenuItem.copyInviteLink.shortcut.modifiers, [.command, .shift])
559 XCTAssertEqual(CallMenuItem.mute.shortcut.key, "m")
560 XCTAssertEqual(CallMenuItem.mute.shortcut.modifiers, [.command, .shift])
561 }
562
563 @MainActor func testAGuestPressGivesAToastAndNoClipboard() async {
564 let call = Call(playAudio: false)
565 call.showFixture(
566 title: "x", standing: "guest",
567 state: #"{"phase":"admitted","peer":"me","boot":"b","peers":[],"waiting":[]}"#)
568 await call.copyInviteLink()
569 XCTAssertEqual(call.toast, "Only members of this meeting can share its invite link.")
570 }
571}
572
573final class AudioWarningTests: XCTestCase {
574 func report(
575 _ suppression: SuppressionState = .running(engine: "DeepFilterNet", cpu: 0.1),
576 echo: String = "WebRTC", refused: String? = nil, started: Bool = true
577 ) -> ProcessingReport {
578 ProcessingReport(
579 suppression: suppression, echoCancellation: echo, noiseSuppression: "off",
580 gainControl: "Calls, after noise reduction", refused: refused, started: started)
581 }
582 let clean = AudioHealth(concealed: 10, total: 10_000, inserted: 5, removed: 5, jitterBufferMs: 60)
583
584 func testAHealthyCallSaysNothing() {
585 XCTAssertNil(AudioWarning.line(health: clean, processing: report(), reduceNoise: true))
586 XCTAssertNil(AudioWarning.line(health: nil, processing: nil, reduceNoise: true))
587 }
588
589 func testGrowlAndDropouts() {
590 let growl = AudioHealth(concealed: 0, total: 10_000, inserted: 400, removed: 0, jitterBufferMs: 60)
591 XCTAssertTrue(
592 AudioWarning.line(health: growl, processing: report(), reduceNoise: true)!.contains("growling"))
593 let lossy = AudioHealth(concealed: 900, total: 10_000, inserted: 0, removed: 0, jitterBufferMs: 60)
594 XCTAssertEqual(
595 AudioWarning.line(health: lossy, processing: report(), reduceNoise: true),
596 "9% of incoming audio is going missing.")
597 }
598
599 func testProcessingProblems() {
600 XCTAssertNotNil(AudioWarning.line(health: clean, processing: report(echo: "off"), reduceNoise: true))
601 XCTAssertNotNil(
602 AudioWarning.line(health: clean, processing: report(refused: "aec"), reduceNoise: true))
603 XCTAssertEqual(
604 AudioWarning.line(health: clean, processing: report(.unsupportedRate(16_000)), reduceNoise: true),
605 "Noise reduction is bypassed: the microphone runs at 16 kHz.")
606 XCTAssertNil(
607 AudioWarning.line(health: clean, processing: report(.standingAside), reduceNoise: true),
608 "Voice Isolation standing in is a choice, not a fault")
609 XCTAssertNil(
610 AudioWarning.line(health: clean, processing: report(echo: "off", started: false), reduceNoise: true),
611 "before the microphone starts, the defaults mean nothing")
612 }
613}
614
615/// The call's title over the picture (2026-10-02): shown only when someone
616/// chose it, and the window's title either way.
617final class CallTitleTests: XCTestCase {
618 func testANamedMeetingShowsItsTitle() {
619 XCTAssertEqual(CallTitle.shown(title: "Board meeting", named: true), "Board meeting")
620 XCTAssertEqual(CallTitle.shown(title: " Board meeting \n", named: true), "Board meeting")
621 }
622
623 func testAnAutomaticTitleIsNotShown() {
624 XCTAssertNil(CallTitle.shown(title: "Call, 2 Oct 15:40", named: false))
625 // A server from before 0.4.7 says nothing, which reads as not named.
626 XCTAssertNil(CallTitle.shown(title: "Call, 2 Oct 15:40 Europe/Dublin", named: false))
627 XCTAssertNil(CallTitle.shown(title: " ", named: true), "nothing to show is nothing shown")
628 }
629
630 func testTheWindowIsAlwaysTitledForTheMeeting() {
631 XCTAssertEqual(CallTitle.window(title: "Call, 2 Oct 15:40"), "Call, 2 Oct 15:40")
632 XCTAssertEqual(CallTitle.window(title: "Board meeting"), "Board meeting")
633 XCTAssertEqual(CallTitle.window(title: ""), "Calls")
634 }
635}
636