| 1 | import SwiftUI |
| 2 | import 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. |
| 9 | final 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. |
| 289 | final 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 | |
| 350 | final 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 | |
| 414 | final 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 | |
| 496 | final 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 | |
| 514 | final 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 | |
| 530 | final 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 | |
| 573 | final 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. |
| 617 | final 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 | |