| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import SwiftUI |
| 4 | import LiveKitWebRTC |
| 5 | import XCTest |
| 6 | |
| 7 | @testable import CallsKit |
| 8 | |
| 9 | /// The mini window's rules, held without a real call or a person |
| 10 | /// switching apps: the defaults, where each corner lands, and when the |
| 11 | /// window comes and goes. |
| 12 | final class MiniWindowPreferencesTests: XCTestCase { |
| 13 | private func defaults() -> UserDefaults { |
| 14 | let name = "MiniWindowTests.\(UUID().uuidString)" |
| 15 | let defaults = UserDefaults(suiteName: name)! |
| 16 | addTeardownBlock { defaults.removePersistentDomain(forName: name) } |
| 17 | return defaults |
| 18 | } |
| 19 | |
| 20 | func testOnAndBottomRightUntilSomeoneSaysOtherwise() { |
| 21 | let preferences = MiniWindowPreferences(defaults: defaults()) |
| 22 | XCTAssertTrue(preferences.enabled) |
| 23 | XCTAssertEqual(preferences.corner, .bottomRight) |
| 24 | } |
| 25 | |
| 26 | func testChoicesPersist() { |
| 27 | let store = defaults() |
| 28 | MiniWindowPreferences(defaults: store).enabled = false |
| 29 | MiniWindowPreferences(defaults: store).corner = .topLeft |
| 30 | XCTAssertFalse(MiniWindowPreferences(defaults: store).enabled) |
| 31 | XCTAssertEqual(MiniWindowPreferences(defaults: store).corner, .topLeft) |
| 32 | } |
| 33 | |
| 34 | func testAnUnknownCornerFallsBackToTheDefault() { |
| 35 | let store = defaults() |
| 36 | store.set("middle", forKey: MiniWindowPreferences.cornerKey) |
| 37 | XCTAssertEqual(MiniWindowPreferences(defaults: store).corner, .bottomRight) |
| 38 | } |
| 39 | |
| 40 | func testTheCornersAreOfferedInTheOrderTheSettingShowsThem() { |
| 41 | XCTAssertEqual( |
| 42 | MiniWindowCorner.allCases.map(\.title), |
| 43 | ["Bottom right", "Bottom left", "Top right", "Top left"]) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | final class MiniWindowCornerTests: XCTestCase { |
| 48 | // A 1440×900 screen with a 25pt menu bar and a 70pt Dock along the |
| 49 | // bottom: the visible frame AppKit reports for it. |
| 50 | private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| 51 | private let size = MiniWindowRules.size |
| 52 | |
| 53 | func testEachCornerSitsInsetFromTheVisibleFrame() { |
| 54 | XCTAssertEqual( |
| 55 | MiniWindowCorner.bottomRight.frame(size: size, in: visible), |
| 56 | CGRect(x: 1100, y: 90, width: 320, height: 180)) |
| 57 | XCTAssertEqual( |
| 58 | MiniWindowCorner.bottomLeft.frame(size: size, in: visible), |
| 59 | CGRect(x: 20, y: 90, width: 320, height: 180)) |
| 60 | XCTAssertEqual( |
| 61 | MiniWindowCorner.topRight.frame(size: size, in: visible), |
| 62 | CGRect(x: 1100, y: 675, width: 320, height: 180)) |
| 63 | XCTAssertEqual( |
| 64 | MiniWindowCorner.topLeft.frame(size: size, in: visible), |
| 65 | CGRect(x: 20, y: 675, width: 320, height: 180)) |
| 66 | } |
| 67 | |
| 68 | func testASecondScreenLeftOfTheMainOneHasANegativeOrigin() { |
| 69 | let left = CGRect(x: -1920, y: 0, width: 1920, height: 1055) |
| 70 | XCTAssertEqual( |
| 71 | MiniWindowCorner.bottomRight.frame(size: size, in: left), |
| 72 | CGRect(x: -340, y: 20, width: 320, height: 180)) |
| 73 | XCTAssertEqual( |
| 74 | MiniWindowCorner.topLeft.frame(size: size, in: left), |
| 75 | CGRect(x: -1900, y: 855, width: 320, height: 180)) |
| 76 | } |
| 77 | |
| 78 | func testTheWindowIs16By9() { |
| 79 | XCTAssertEqual(size.width / size.height, 16 / 9, accuracy: 0.001) |
| 80 | } |
| 81 | |
| 82 | func testADraggedWindowSnapsToTheCornerItsMiddleIsNearest() { |
| 83 | func dropped(_ x: CGFloat, _ y: CGFloat) -> MiniWindowCorner { |
| 84 | .nearest(to: CGRect(origin: CGPoint(x: x, y: y), size: size), in: visible) |
| 85 | } |
| 86 | XCTAssertEqual(dropped(900, 120), .bottomRight) |
| 87 | XCTAssertEqual(dropped(300, 120), .bottomLeft) |
| 88 | XCTAssertEqual(dropped(900, 600), .topRight) |
| 89 | XCTAssertEqual(dropped(100, 600), .topLeft) |
| 90 | // Half off the screen still belongs to the corner it is nearest. |
| 91 | XCTAssertEqual(dropped(1300, -50), .bottomRight) |
| 92 | // Every corner's own frame snaps back to that corner. |
| 93 | for corner in MiniWindowCorner.allCases { |
| 94 | XCTAssertEqual(.nearest(to: corner.frame(size: size, in: visible), in: visible), corner) |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | final class MiniWindowPolicyTests: XCTestCase { |
| 100 | private let inCall = MiniWindowPolicy.Conditions( |
| 101 | enabled: true, inCall: true, mainWindowVisible: true) |
| 102 | |
| 103 | func testSwitchingAwayDuringACallShowsItAndComingBackRestores() { |
| 104 | var policy = MiniWindowPolicy() |
| 105 | XCTAssertEqual(policy.resignedActive(inCall), .show(hideMain: true)) |
| 106 | XCTAssertTrue(policy.showing) |
| 107 | XCTAssertEqual(policy.becameActive(), .restore(activate: true)) |
| 108 | XCTAssertFalse(policy.showing) |
| 109 | // Coming back again with nothing up is nothing to do. |
| 110 | XCTAssertEqual(policy.becameActive(), .none) |
| 111 | } |
| 112 | |
| 113 | func testASecondResignWhileShowingDoesNotOpenAnother() { |
| 114 | var policy = MiniWindowPolicy() |
| 115 | _ = policy.resignedActive(inCall) |
| 116 | XCTAssertEqual(policy.resignedActive(inCall), .none) |
| 117 | } |
| 118 | |
| 119 | func testNothingAppearsOutsideACallOrWithTheSettingOff() { |
| 120 | var policy = MiniWindowPolicy() |
| 121 | var conditions = inCall |
| 122 | conditions.inCall = false |
| 123 | XCTAssertEqual(policy.resignedActive(conditions), .none) |
| 124 | conditions = inCall |
| 125 | conditions.enabled = false |
| 126 | XCTAssertEqual(policy.resignedActive(conditions), .none) |
| 127 | XCTAssertFalse(policy.showing) |
| 128 | } |
| 129 | |
| 130 | func testACallWindowPutAwayByThePersonStaysAway() { |
| 131 | var policy = MiniWindowPolicy() |
| 132 | var conditions = inCall |
| 133 | conditions.mainWindowMiniaturized = true |
| 134 | XCTAssertEqual(policy.resignedActive(conditions), .none) |
| 135 | conditions = inCall |
| 136 | conditions.mainWindowVisible = false |
| 137 | XCTAssertEqual(policy.resignedActive(conditions), .none) |
| 138 | } |
| 139 | |
| 140 | func testAFullScreenCallWindowIsLeftInItsSpace() { |
| 141 | var policy = MiniWindowPolicy() |
| 142 | var conditions = inCall |
| 143 | conditions.mainWindowFullScreen = true |
| 144 | XCTAssertEqual(policy.resignedActive(conditions), .show(hideMain: false)) |
| 145 | XCTAssertFalse(policy.mainHidden) |
| 146 | XCTAssertEqual(policy.becameActive(), .restore(activate: true)) |
| 147 | } |
| 148 | |
| 149 | func testHidingTheAppBeforeItResignsShowsNothing() { |
| 150 | var policy = MiniWindowPolicy() |
| 151 | XCTAssertEqual(policy.willHide(), .none) |
| 152 | XCTAssertEqual(policy.resignedActive(inCall), .none) |
| 153 | policy.didUnhide() |
| 154 | XCTAssertEqual(policy.becameActive(), .none) |
| 155 | // And once it is back, switching away works as before. |
| 156 | XCTAssertEqual(policy.resignedActive(inCall), .show(hideMain: true)) |
| 157 | } |
| 158 | |
| 159 | func testHidingTheAppAfterItResignedPutsTheCallWindowBackFirst() { |
| 160 | var policy = MiniWindowPolicy() |
| 161 | _ = policy.resignedActive(inCall) |
| 162 | XCTAssertEqual(policy.willHide(), .restore(activate: false)) |
| 163 | XCTAssertEqual(policy.resignedActive(inCall), .none) |
| 164 | } |
| 165 | |
| 166 | func testTheCallEndingPutsItAwayWithoutPullingTheAppForward() { |
| 167 | var policy = MiniWindowPolicy() |
| 168 | _ = policy.resignedActive(inCall) |
| 169 | XCTAssertEqual(policy.callEnded(), .restore(activate: false)) |
| 170 | XCTAssertEqual(policy.becameActive(), .none) |
| 171 | XCTAssertEqual(policy.callEnded(), .none) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | /// Size: 16:9 always, never under 200 pt wide, never over 40% of the |
| 176 | /// screen, and remembered. |
| 177 | final class MiniWindowSizeTests: XCTestCase { |
| 178 | private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| 179 | |
| 180 | func testEverySizeIs16By9() { |
| 181 | for width in [0, 150, 200, 247, 320, 575, 900, 5000] as [CGFloat] { |
| 182 | let size = MiniWindowRules.size(width: width, in: visible) |
| 183 | XCTAssertEqual(size.width / size.height, 16 / 9, accuracy: 0.0001, "\(width)") |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func testTheWidthIsHeldBetweenTheMinimumAnd40PercentOfTheScreen() { |
| 188 | XCTAssertEqual(MiniWindowRules.size(width: 120, in: visible).width, 200) |
| 189 | XCTAssertEqual(MiniWindowRules.size(width: 320, in: visible).width, 320) |
| 190 | XCTAssertEqual(MiniWindowRules.size(width: 900, in: visible).width, 576) // 1440 × 0.4 |
| 191 | // A width chosen on a big display, shown on a small one. |
| 192 | let small = CGRect(x: 0, y: 0, width: 1024, height: 700) |
| 193 | XCTAssertEqual(MiniWindowRules.size(width: 576, in: small).width, 409) |
| 194 | // A screen too small for 40% of it to reach the minimum still gets it. |
| 195 | let tiny = CGRect(x: 0, y: 0, width: 400, height: 300) |
| 196 | XCTAssertEqual(MiniWindowRules.size(width: 900, in: tiny).width, 200) |
| 197 | } |
| 198 | |
| 199 | func testResizingFromACornerKeepsTheOppositeCornerAndTheAspect() { |
| 200 | let start = CGRect(x: 1100, y: 90, width: 320, height: 180) |
| 201 | // The top-left corner, dragged up and left: the bottom right stays. |
| 202 | let grown = MiniWindowResize.frame( |
| 203 | from: start, dragging: [.top, .left], dx: -80, dy: 20, in: visible) |
| 204 | XCTAssertEqual(grown.maxX, start.maxX) |
| 205 | XCTAssertEqual(grown.minY, start.minY) |
| 206 | XCTAssertEqual(grown.width, 400) |
| 207 | XCTAssertEqual(grown.width / grown.height, 16 / 9, accuracy: 0.0001) |
| 208 | // Whichever direction asks for more wins. |
| 209 | let tall = MiniWindowResize.frame( |
| 210 | from: start, dragging: [.top, .left], dx: -10, dy: 90, in: visible) |
| 211 | XCTAssertEqual(tall.height, 270, accuracy: 0.001) |
| 212 | XCTAssertEqual(tall.width, 480) |
| 213 | } |
| 214 | |
| 215 | func testResizingIsClampedAtBothEnds() { |
| 216 | let start = CGRect(x: 100, y: 100, width: 320, height: 180) |
| 217 | let shrunk = MiniWindowResize.frame( |
| 218 | from: start, dragging: .right, dx: -500, dy: 0, in: visible) |
| 219 | XCTAssertEqual(shrunk.size, MiniWindowRules.minimumSize()) |
| 220 | let huge = MiniWindowResize.frame( |
| 221 | from: start, dragging: .bottom, dx: 0, dy: -2000, in: visible) |
| 222 | XCTAssertEqual(huge.size, MiniWindowRules.maximumSize(in: visible)) |
| 223 | XCTAssertEqual(huge.maxY, start.maxY, "a bottom edge grows downward") |
| 224 | // An edge alone grows evenly either side of the middle. |
| 225 | let wide = MiniWindowResize.frame(from: start, dragging: .right, dx: 80, dy: 0, in: visible) |
| 226 | XCTAssertEqual(wide.minX, start.minX) |
| 227 | XCTAssertEqual(wide.midY, start.midY, accuracy: 0.001) |
| 228 | } |
| 229 | |
| 230 | func testThePressTakesHoldOfTheEdgesItIsNear() { |
| 231 | let size = CGSize(width: 320, height: 180) |
| 232 | XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 160, y: 90), in: size), []) |
| 233 | XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 2, y: 90), in: size), .left) |
| 234 | XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 318, y: 178), in: size), [.right, .top]) |
| 235 | XCTAssertEqual(MiniWindowResize.handle(at: CGPoint(x: 3, y: 3), in: size), [.left, .bottom]) |
| 236 | } |
| 237 | |
| 238 | func testTheSizeIsRemembered() { |
| 239 | let name = "MiniWindowSizeTests.\(UUID().uuidString)" |
| 240 | let store = UserDefaults(suiteName: name)! |
| 241 | defer { store.removePersistentDomain(forName: name) } |
| 242 | XCTAssertEqual(MiniWindowPreferences(defaults: store).width, 320) |
| 243 | MiniWindowPreferences(defaults: store).width = 480 |
| 244 | XCTAssertEqual(MiniWindowPreferences(defaults: store).width, 480) |
| 245 | } |
| 246 | |
| 247 | func testSnappingKeepsTheChosenSize() { |
| 248 | let name = "MiniWindowSizeTests.\(UUID().uuidString)" |
| 249 | let store = UserDefaults(suiteName: name)! |
| 250 | defer { store.removePersistentDomain(forName: name) } |
| 251 | var memory = MiniWindowCallMemory() |
| 252 | let dropped = CGRect(x: 800, y: 500, width: 480, height: 270) |
| 253 | let settled = memory.dropped( |
| 254 | dropped, in: visible, display: "1", option: false, |
| 255 | preferences: MiniWindowPreferences(defaults: store)) |
| 256 | XCTAssertEqual(settled, MiniWindowCorner.topRight.frame(size: dropped.size, in: visible)) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | /// The tab: against the nearest edge, off it by dragging, and saying who |
| 261 | /// the call is with. |
| 262 | final class MiniWindowTabTests: XCTestCase { |
| 263 | private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| 264 | private let size = MiniWindowTab.size |
| 265 | |
| 266 | func testEachCornerWindowDocksToItsSide() { |
| 267 | let window = CGSize(width: 320, height: 180) |
| 268 | let (right, tab) = MiniWindowTab.dock( |
| 269 | MiniWindowCorner.bottomRight.frame(size: window, in: visible), in: visible) |
| 270 | XCTAssertEqual(right, .right) |
| 271 | XCTAssertEqual(tab.maxX, visible.maxX, "flush against the edge") |
| 272 | XCTAssertEqual(tab.midY, 90 + 90, accuracy: 0.001, "level with the window's middle") |
| 273 | XCTAssertEqual(tab.size, size) |
| 274 | XCTAssertEqual( |
| 275 | MiniWindowTab.dock(MiniWindowCorner.topLeft.frame(size: window, in: visible), in: visible).0, |
| 276 | .left) |
| 277 | } |
| 278 | |
| 279 | func testAWindowNearerTheTopOrBottomDocksThere() { |
| 280 | let nearBottom = CGRect(x: 600, y: 75, width: 320, height: 180) |
| 281 | let (edge, tab) = MiniWindowTab.dock(nearBottom, in: visible) |
| 282 | XCTAssertEqual(edge, .bottom) |
| 283 | XCTAssertEqual(tab.minY, visible.minY) |
| 284 | XCTAssertEqual(tab.midX, nearBottom.midX, accuracy: 0.001) |
| 285 | let nearTop = CGRect(x: 600, y: 690, width: 320, height: 180) |
| 286 | XCTAssertEqual(MiniWindowTab.dock(nearTop, in: visible).0, .top) |
| 287 | XCTAssertEqual(MiniWindowTab.dock(nearTop, in: visible).1.maxY, visible.maxY) |
| 288 | } |
| 289 | |
| 290 | func testTheTabStaysOnScreen() { |
| 291 | // A window half off the bottom right: the tab is still all on screen. |
| 292 | let off = CGRect(x: 1400, y: 20, width: 320, height: 180) |
| 293 | let tab = MiniWindowTab.dock(off, in: visible).1 |
| 294 | XCTAssertTrue(visible.contains(tab), "\(tab)") |
| 295 | } |
| 296 | |
| 297 | func testDraggingAlongAnEdgeRedocksAndPullingItOutExpands() { |
| 298 | let docked = MiniWindowTab.dock(CGRect(x: 1100, y: 90, width: 320, height: 180), in: visible).1 |
| 299 | // Slid up the right edge, a little off it. |
| 300 | let slid = docked.offsetBy(dx: -20, dy: 300) |
| 301 | guard case .dock(.right, let frame) = MiniWindowTab.release(slid, in: visible) else { |
| 302 | return XCTFail("slid along the edge should dock again") |
| 303 | } |
| 304 | XCTAssertEqual(frame.maxX, visible.maxX) |
| 305 | XCTAssertEqual(frame.minY, slid.minY) |
| 306 | // Pulled into the middle of the screen. |
| 307 | XCTAssertEqual(MiniWindowTab.release(docked.offsetBy(dx: -500, dy: 200), in: visible), .expand) |
| 308 | } |
| 309 | |
| 310 | func testAPulledOutWindowOpensWhereTheTabWasAndOnScreen() { |
| 311 | let tab = CGRect(x: 1284, y: 400, width: size.width, height: size.height) |
| 312 | let frame = MiniWindowTab.expanded( |
| 313 | from: tab, size: CGSize(width: 320, height: 180), in: visible) |
| 314 | XCTAssertEqual(frame.midY, tab.midY, accuracy: 0.001) |
| 315 | XCTAssertEqual(frame.maxX, visible.maxX, "clamped rather than hanging off the edge") |
| 316 | } |
| 317 | |
| 318 | func testTheCaption() { |
| 319 | XCTAssertEqual(MiniWindowTab.caption(others: []), "Just you") |
| 320 | XCTAssertEqual(MiniWindowTab.caption(others: ["Bea"]), "Bea") |
| 321 | XCTAssertEqual(MiniWindowTab.caption(others: ["Bea", "Cai"]), "3 people") |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | /// Placement: corners, anywhere, and ⌥ for one call. |
| 326 | final class MiniWindowPlacementTests: XCTestCase { |
| 327 | private let visible = CGRect(x: 0, y: 70, width: 1440, height: 805) |
| 328 | private let size = CGSize(width: 320, height: 180) |
| 329 | |
| 330 | private func preferences() -> MiniWindowPreferences { |
| 331 | let name = "MiniWindowPlacementTests.\(UUID().uuidString)" |
| 332 | let defaults = UserDefaults(suiteName: name)! |
| 333 | addTeardownBlock { defaults.removePersistentDomain(forName: name) } |
| 334 | return MiniWindowPreferences(defaults: defaults) |
| 335 | } |
| 336 | |
| 337 | func testCornersByDefault() { |
| 338 | let preferences = preferences() |
| 339 | XCTAssertEqual(preferences.placement, .corners) |
| 340 | XCTAssertEqual( |
| 341 | MiniWindowPlacement.allCases.map(\.title), ["Snap to corners", "Anywhere I put it"]) |
| 342 | XCTAssertEqual( |
| 343 | MiniWindowCallMemory().frame(size: size, in: visible, display: "1", preferences: preferences), |
| 344 | MiniWindowCorner.bottomRight.frame(size: size, in: visible)) |
| 345 | } |
| 346 | |
| 347 | func testAnywhereKeepsTheDropExactlyAndRemembersItPerDisplay() { |
| 348 | let preferences = preferences() |
| 349 | preferences.placement = .anywhere |
| 350 | var memory = MiniWindowCallMemory() |
| 351 | let drop = CGRect(x: 612, y: 333, width: 320, height: 180) |
| 352 | XCTAssertEqual( |
| 353 | memory.dropped(drop, in: visible, display: "1", option: false, preferences: preferences), |
| 354 | drop) |
| 355 | XCTAssertEqual(preferences.corner, .bottomRight, "a free drop is not a corner") |
| 356 | // Next call, same display: there. Another display: its own default. |
| 357 | let next = MiniWindowCallMemory() |
| 358 | XCTAssertEqual(next.frame(size: size, in: visible, display: "1", preferences: preferences), drop) |
| 359 | let other = CGRect(x: 1440, y: 0, width: 1920, height: 1055) |
| 360 | XCTAssertEqual( |
| 361 | next.frame(size: size, in: other, display: "2", preferences: preferences), |
| 362 | MiniWindowCorner.bottomRight.frame(size: size, in: other)) |
| 363 | } |
| 364 | |
| 365 | func testAFreePositionFollowsItsDisplayWhenTheArrangementChanges() { |
| 366 | let preferences = preferences() |
| 367 | preferences.placement = .anywhere |
| 368 | var memory = MiniWindowCallMemory() |
| 369 | let right = CGRect(x: 1440, y: 0, width: 1920, height: 1055) |
| 370 | _ = memory.dropped( |
| 371 | CGRect(x: 2000, y: 400, width: 320, height: 180), in: right, display: "2", option: false, |
| 372 | preferences: preferences) |
| 373 | // The display dragged to the left of the main one in Arrange. |
| 374 | let left = CGRect(x: -1920, y: 0, width: 1920, height: 1055) |
| 375 | XCTAssertEqual( |
| 376 | memory.frame(size: size, in: left, display: "2", preferences: preferences), |
| 377 | CGRect(x: -1920 + 560, y: 400, width: 320, height: 180)) |
| 378 | } |
| 379 | |
| 380 | func testAFreePositionIsClampedBackOnAScreenThatShrank() { |
| 381 | let preferences = preferences() |
| 382 | preferences.placement = .anywhere |
| 383 | var memory = MiniWindowCallMemory() |
| 384 | let big = CGRect(x: 0, y: 0, width: 2560, height: 1415) |
| 385 | _ = memory.dropped( |
| 386 | CGRect(x: 2200, y: 1200, width: 320, height: 180), in: big, display: "1", option: false, |
| 387 | preferences: preferences) |
| 388 | // Same display at a lower resolution. |
| 389 | let smaller = CGRect(x: 0, y: 0, width: 1440, height: 875) |
| 390 | let frame = memory.frame(size: size, in: smaller, display: "1", preferences: preferences) |
| 391 | XCTAssertTrue(smaller.contains(frame), "\(frame)") |
| 392 | XCTAssertEqual(frame.maxX, smaller.maxX) |
| 393 | XCTAssertEqual(frame.maxY, smaller.maxY) |
| 394 | // And a drop half off the screen lands on it. |
| 395 | let half = memory.dropped( |
| 396 | CGRect(x: -100, y: -50, width: 320, height: 180), in: smaller, display: "1", option: false, |
| 397 | preferences: preferences) |
| 398 | XCTAssertEqual(half.origin, CGPoint(x: 0, y: 0)) |
| 399 | } |
| 400 | |
| 401 | func testOptionDragPlacesFreelyForThisCallOnly() { |
| 402 | let preferences = preferences() |
| 403 | var memory = MiniWindowCallMemory() |
| 404 | let drop = CGRect(x: 612, y: 333, width: 320, height: 180) |
| 405 | XCTAssertEqual( |
| 406 | memory.dropped(drop, in: visible, display: "1", option: true, preferences: preferences), drop) |
| 407 | XCTAssertEqual(preferences.corner, .bottomRight, "the setting is untouched") |
| 408 | XCTAssertNil(preferences.position(on: "1"), "and nothing is saved") |
| 409 | // Shown again in the same call: where it was dropped. |
| 410 | XCTAssertEqual(memory.frame(size: size, in: visible, display: "1", preferences: preferences), drop) |
| 411 | // Resizing it keeps it placed rather than snapping it. |
| 412 | let bigger = CGRect(x: 612, y: 333, width: 400, height: 225) |
| 413 | XCTAssertEqual( |
| 414 | memory.resized(bigger, in: visible, display: "1", preferences: preferences), bigger) |
| 415 | // The next call starts from the setting again. |
| 416 | XCTAssertEqual( |
| 417 | MiniWindowCallMemory().frame(size: size, in: visible, display: "1", preferences: preferences), |
| 418 | MiniWindowCorner.bottomRight.frame(size: size, in: visible)) |
| 419 | } |
| 420 | |
| 421 | func testAnOrdinaryDragAfterAnOptionDragSnapsAndForgetsTheOneOff() { |
| 422 | let preferences = preferences() |
| 423 | var memory = MiniWindowCallMemory() |
| 424 | _ = memory.dropped( |
| 425 | CGRect(x: 612, y: 333, width: 320, height: 180), in: visible, display: "1", option: true, |
| 426 | preferences: preferences) |
| 427 | let snapped = memory.dropped( |
| 428 | CGRect(x: 100, y: 600, width: 320, height: 180), in: visible, display: "1", option: false, |
| 429 | preferences: preferences) |
| 430 | XCTAssertEqual(snapped, MiniWindowCorner.topLeft.frame(size: size, in: visible)) |
| 431 | XCTAssertEqual(preferences.corner, .topLeft) |
| 432 | XCTAssertEqual(memory.frame(size: size, in: visible, display: "1", preferences: preferences), snapped) |
| 433 | XCTAssertTrue(memory.oneOff.isEmpty) |
| 434 | } |
| 435 | |
| 436 | /// Collapsed, and any ⌥ one-off, last for the call and no longer. |
| 437 | @MainActor func testEachCallStartsExpandedWithNoOneOffs() { |
| 438 | let name = "MiniWindowPlacementTests.\(UUID().uuidString)" |
| 439 | let defaults = UserDefaults(suiteName: name)! |
| 440 | defer { defaults.removePersistentDomain(forName: name) } |
| 441 | let model = CallsModel(defaults: defaults) |
| 442 | let controller = MiniWindowController(preferences: MiniWindowPreferences(defaults: defaults)) |
| 443 | controller.attach(model: model, mainWindow: nil) |
| 444 | model.joining = true |
| 445 | controller.memory.collapsed = true |
| 446 | _ = controller.memory.dropped( |
| 447 | CGRect(x: 612, y: 333, width: 320, height: 180), in: visible, display: "1", option: true, |
| 448 | preferences: MiniWindowPreferences(defaults: defaults)) |
| 449 | XCTAssertNotEqual(controller.memory, MiniWindowCallMemory()) |
| 450 | model.joining = false |
| 451 | XCTAssertEqual(controller.memory, MiniWindowCallMemory()) |
| 452 | XCTAssertFalse(controller.memory.collapsed) |
| 453 | } |
| 454 | |
| 455 | func testForgettingPositionsPutsItBackInTheCorner() { |
| 456 | let preferences = preferences() |
| 457 | preferences.placement = .anywhere |
| 458 | preferences.setPosition(CGPoint(x: 10, y: 10), on: "1") |
| 459 | preferences.forgetPositions() |
| 460 | XCTAssertNil(preferences.position(on: "1")) |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | /// Space-to-mute's rules, and how AppKit's first responder is read. |
| 465 | final class SpaceToMuteTests: XCTestCase { |
| 466 | func testBareSpaceWithNothingFocusedToggles() { |
| 467 | XCTAssertEqual( |
| 468 | SpaceToMute.action(isSpace: true, isRepeat: false, modified: false, focus: .none), .toggle) |
| 469 | } |
| 470 | |
| 471 | func testARepeatIsSwallowedNotToggled() { |
| 472 | XCTAssertEqual( |
| 473 | SpaceToMute.action(isSpace: true, isRepeat: true, modified: false, focus: .none), .swallow) |
| 474 | } |
| 475 | |
| 476 | func testTextFieldsAndControlsKeepTheirSpace() { |
| 477 | for focus in [KeyboardFocus.text, .control] { |
| 478 | for repeating in [false, true] { |
| 479 | XCTAssertEqual( |
| 480 | SpaceToMute.action(isSpace: true, isRepeat: repeating, modified: false, focus: focus), |
| 481 | .pass) |
| 482 | } |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | func testOtherKeysAndModifiedSpaceAreNotOurs() { |
| 487 | XCTAssertEqual( |
| 488 | SpaceToMute.action(isSpace: false, isRepeat: false, modified: false, focus: .none), .pass) |
| 489 | XCTAssertEqual( |
| 490 | SpaceToMute.action(isSpace: true, isRepeat: false, modified: true, focus: .none), .pass) |
| 491 | } |
| 492 | |
| 493 | @MainActor func testTheFirstResponderIsReadConservatively() { |
| 494 | let root = NSView() |
| 495 | XCTAssertEqual(SpaceToMute.focus(of: nil, contentView: root), .none) |
| 496 | XCTAssertEqual(SpaceToMute.focus(of: NSWindow(), contentView: root), .none) |
| 497 | XCTAssertEqual(SpaceToMute.focus(of: root, contentView: root), .none) |
| 498 | // The field editor a SwiftUI TextField (the chat composer) edits |
| 499 | // through, and an editable field itself. |
| 500 | XCTAssertEqual(SpaceToMute.focus(of: NSTextView(), contentView: root), .text) |
| 501 | XCTAssertEqual(SpaceToMute.focus(of: NSTextField(string: ""), contentView: root), .text) |
| 502 | XCTAssertEqual(SpaceToMute.focus(of: NSButton(), contentView: root), .control) |
| 503 | XCTAssertEqual(SpaceToMute.focus(of: NSPopUpButton(), contentView: root), .control) |
| 504 | // Anything unrecognised — SwiftUI's focus proxy for a Button among |
| 505 | // them — keeps its Space. |
| 506 | XCTAssertEqual(SpaceToMute.focus(of: NSView(), contentView: root), .control) |
| 507 | } |
| 508 | |
| 509 | @MainActor func testAKeyEventInAWindowIsDecidedByItsFirstResponder() throws { |
| 510 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 511 | let window = NSWindow( |
| 512 | contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), styleMask: [.titled], |
| 513 | backing: .buffered, defer: false) |
| 514 | window.isReleasedWhenClosed = false |
| 515 | let field = NSTextField(frame: NSRect(x: 10, y: 10, width: 100, height: 24)) |
| 516 | window.contentView?.addSubview(field) |
| 517 | func key(_ code: UInt16, _ characters: String, repeating: Bool = false, flags: NSEvent.ModifierFlags = []) |
| 518 | throws -> NSEvent |
| 519 | { |
| 520 | try XCTUnwrap( |
| 521 | NSEvent.keyEvent( |
| 522 | with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, |
| 523 | windowNumber: window.windowNumber, context: nil, characters: characters, |
| 524 | charactersIgnoringModifiers: characters, isARepeat: repeating, keyCode: code)) |
| 525 | } |
| 526 | window.makeFirstResponder(nil) |
| 527 | XCTAssertEqual(SpaceToMute.action(for: try key(49, " "), in: window), .toggle) |
| 528 | XCTAssertEqual(SpaceToMute.action(for: try key(49, " ", repeating: true), in: window), .swallow) |
| 529 | XCTAssertEqual(SpaceToMute.action(for: try key(49, " ", flags: .command), in: window), .pass) |
| 530 | XCTAssertEqual(SpaceToMute.action(for: try key(49, " ", flags: .capsLock), in: window), .toggle) |
| 531 | XCTAssertEqual(SpaceToMute.action(for: try key(46, "m"), in: window), .pass) |
| 532 | window.makeFirstResponder(field) |
| 533 | XCTAssertEqual(SpaceToMute.action(for: try key(49, " "), in: window), .pass, "typing a space") |
| 534 | window.close() |
| 535 | } |
| 536 | } |
| 537 | |
| 538 | final class MiniWindowPanelTests: XCTestCase { |
| 539 | @MainActor func testThePanelFloatsEverywhereAndStaysOutOfScreenCaptures() throws { |
| 540 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 541 | let screen = try XCTUnwrap(NSScreen.main) |
| 542 | let panel = MiniWindowPanel(call: Call(playAudio: false), actions: MiniWindowActions()) |
| 543 | XCTAssertEqual(panel.sharingType, .none) |
| 544 | XCTAssertEqual(panel.level, .floating) |
| 545 | XCTAssertFalse(panel.hidesOnDeactivate) |
| 546 | XCTAssertTrue(panel.styleMask.contains(.nonactivatingPanel)) |
| 547 | XCTAssertTrue(panel.collectionBehavior.contains(.canJoinAllSpaces)) |
| 548 | XCTAssertTrue(panel.collectionBehavior.contains(.fullScreenAuxiliary)) |
| 549 | XCTAssertTrue(panel.canBecomeKey) |
| 550 | XCTAssertTrue(panel.becomesKeyOnlyIfNeeded, "clicking Mute must not take the keyboard") |
| 551 | let visible = screen.visibleFrame |
| 552 | let big = MiniWindowRules.size(width: 480, in: visible) |
| 553 | let frame = MiniWindowCorner.topLeft.frame(size: big, in: visible) |
| 554 | panel.expand(to: frame, animated: false) |
| 555 | XCTAssertEqual(panel.frame, frame) |
| 556 | XCTAssertFalse(panel.collapsed) |
| 557 | |
| 558 | // The tab is the same window: it keeps every one of those settings. |
| 559 | let tab = MiniWindowTab.dock(frame, in: visible).1 |
| 560 | panel.collapse(to: tab, expanded: frame) |
| 561 | XCTAssertTrue(panel.collapsed) |
| 562 | XCTAssertEqual(panel.frame, tab) |
| 563 | XCTAssertEqual(panel.expandedFrame, frame, "the size to come back to") |
| 564 | XCTAssertEqual(panel.sharingType, .none) |
| 565 | XCTAssertEqual(panel.level, .floating) |
| 566 | XCTAssertTrue(panel.collectionBehavior.contains(.canJoinAllSpaces)) |
| 567 | panel.expand(to: panel.expandedFrame, animated: false) |
| 568 | XCTAssertEqual(panel.frame, frame) |
| 569 | panel.dismiss() |
| 570 | XCTAssertNil(panel.contentView) |
| 571 | XCTAssertFalse(panel.isVisible) |
| 572 | } |
| 573 | |
| 574 | /// The mechanism dismiss() relies on to hand renderers back: once the |
| 575 | /// content stops naming a track, SwiftUI takes the video view out of |
| 576 | /// the hierarchy on the next layout, which is when MacVideoTile |
| 577 | /// removes its renderer from the track. |
| 578 | @MainActor func testTakingTheVideoOutRemovesItsRendererAtOnce() { |
| 579 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 580 | let factory = RTCPeerConnectionFactory() |
| 581 | let track = factory.videoTrack(with: factory.videoSource(), trackId: "mini-test") |
| 582 | let holder = Holder(track: track) |
| 583 | let window = NSWindow( |
| 584 | contentRect: NSRect(origin: .zero, size: MiniWindowRules.size), |
| 585 | styleMask: [.borderless], backing: .buffered, defer: false) |
| 586 | window.isReleasedWhenClosed = false |
| 587 | window.contentView = NSHostingView(rootView: HolderView(holder: holder)) |
| 588 | window.orderFrontRegardless() |
| 589 | window.contentView?.layoutSubtreeIfNeeded() |
| 590 | XCTAssertEqual(renderers(in: window.contentView), 1) |
| 591 | holder.track = nil |
| 592 | window.contentView?.layoutSubtreeIfNeeded() |
| 593 | window.contentView?.displayIfNeeded() |
| 594 | XCTAssertEqual(renderers(in: window.contentView), 0) |
| 595 | window.close() |
| 596 | } |
| 597 | |
| 598 | private func renderers(in view: NSView?) -> Int { |
| 599 | guard let view else { return 0 } |
| 600 | return (view is RTCMTLNSVideoView ? 1 : 0) |
| 601 | + view.subviews.reduce(0) { $0 + renderers(in: $1) } |
| 602 | } |
| 603 | |
| 604 | @MainActor private final class Holder: ObservableObject { |
| 605 | @Published var track: RTCVideoTrack? |
| 606 | init(track: RTCVideoTrack) { self.track = track } |
| 607 | } |
| 608 | |
| 609 | private struct HolderView: View { |
| 610 | @ObservedObject var holder: Holder |
| 611 | var body: some View { |
| 612 | if let track = holder.track { MacVideoTile(track: track, label: "Bea") } else { Color.black } |
| 613 | } |
| 614 | } |
| 615 | } |
| 616 | #endif |
| 617 | |