| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import SwiftUI |
| 4 | import XCTest |
| 5 | |
| 6 | import LiveKitWebRTC |
| 7 | |
| 8 | @testable @_spi(Fixtures) import CallsKit |
| 9 | |
| 10 | /// Paul, on 0.3.6: "I should be able to click anywhere on the window to |
| 11 | /// resize it." The full-window call has had no title bar since 0.3.5, so |
| 12 | /// moving it is the picture's job, and resizing must still work from |
| 13 | /// every edge, the top included. |
| 14 | final class WindowMoveRuleTests: XCTestCase { |
| 15 | func testAShortPressIsAClickAndALongerOneADrag() { |
| 16 | XCTAssertEqual(WindowMove.threshold, 4) |
| 17 | let start = CGPoint(x: 100, y: 100) |
| 18 | XCTAssertFalse(WindowMove.isDrag(from: start, to: start)) |
| 19 | XCTAssertFalse(WindowMove.isDrag(from: start, to: CGPoint(x: 102, y: 102)), "a shaky click") |
| 20 | XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 104, y: 100))) |
| 21 | XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 97, y: 97)), "any direction") |
| 22 | } |
| 23 | |
| 24 | func testThePressFollowsThePointerOnceItIsADrag() { |
| 25 | var press = WindowMove.Press(origin: CGPoint(x: 200, y: 300), grabbed: CGPoint(x: 700, y: 650)) |
| 26 | XCTAssertNil(press.moved(to: CGPoint(x: 702, y: 651)), "still a click") |
| 27 | XCTAssertFalse(press.moving) |
| 28 | XCTAssertEqual(press.moved(to: CGPoint(x: 740, y: 625)), CGPoint(x: 240, y: 275)) |
| 29 | XCTAssertTrue(press.moving) |
| 30 | // Once moving, it goes wherever the pointer goes, back to where it |
| 31 | // started included: the window is not left 4 pt short. |
| 32 | XCTAssertEqual(press.moved(to: CGPoint(x: 701, y: 650)), CGPoint(x: 201, y: 300)) |
| 33 | XCTAssertEqual(press.moved(to: CGPoint(x: 700, y: 650)), CGPoint(x: 200, y: 300)) |
| 34 | } |
| 35 | |
| 36 | func testOnlyAWindowThatCanMoveIsMoved() { |
| 37 | XCTAssertTrue(WindowMove.canMove(fullScreen: false, movable: true)) |
| 38 | XCTAssertFalse(WindowMove.canMove(fullScreen: true, movable: true), "full screen is the screen") |
| 39 | XCTAssertFalse(WindowMove.canMove(fullScreen: false, movable: false)) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// The real full-window call in a titled, resizable window like the app's, |
| 44 | /// driven with mouse events sent to it: what a press on each part of it |
| 45 | /// does to the window. The window is never made key or active: a call |
| 46 | /// window behind another app's must move by its picture too. |
| 47 | @MainActor final class CallWindowMouseTests: XCTestCase { |
| 48 | private var window: NSWindow! |
| 49 | private var call: Call! |
| 50 | private let start = NSRect(x: 200, y: 200, width: 1000, height: 700) |
| 51 | |
| 52 | override func setUp() async throws { |
| 53 | NSApplication.shared.setActivationPolicy(.prohibited) |
| 54 | UserDefaults.standard.removeObject(forKey: PiPCorner.key) |
| 55 | } |
| 56 | |
| 57 | override func tearDown() async throws { |
| 58 | window?.close() |
| 59 | window = nil |
| 60 | UserDefaults.standard.removeObject(forKey: PiPCorner.key) |
| 61 | } |
| 62 | |
| 63 | private func open(_ fixture: CallFixture = .focus, grid: Bool = false, controls: Bool = false) { |
| 64 | call = Call(playAudio: false) |
| 65 | fixture.apply(to: call) |
| 66 | window = NSWindow( |
| 67 | contentRect: start, styleMask: [.titled, .closable, .miniaturizable, .resizable], |
| 68 | backing: .buffered, defer: false) |
| 69 | window.isReleasedWhenClosed = false |
| 70 | window.contentView = NSHostingView( |
| 71 | rootView: MacCallView( |
| 72 | call: call, grid: grid, style: .fullWindow, controlsShown: controls, leave: {}, rejoin: {})) |
| 73 | window.orderFrontRegardless() |
| 74 | pump(0.8) |
| 75 | window.setFrame(start, display: true) |
| 76 | pump(0.2) |
| 77 | } |
| 78 | |
| 79 | private func pump(_ seconds: Double) { |
| 80 | let until = Date(timeIntervalSinceNow: seconds) |
| 81 | while let event = NSApp.nextEvent( |
| 82 | matching: .any, until: until, inMode: .default, dequeue: true) |
| 83 | { |
| 84 | NSApp.sendEvent(event) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | private func event(_ type: NSEvent.EventType, at point: NSPoint, clicks: Int = 1) -> NSEvent { |
| 89 | NSEvent.mouseEvent( |
| 90 | with: type, location: point, modifierFlags: [], |
| 91 | timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, |
| 92 | context: nil, eventNumber: 0, clickCount: clicks, pressure: type == .leftMouseUp ? 0 : 1)! |
| 93 | } |
| 94 | |
| 95 | /// Presses at `point` (window coordinates, y up), drags by `by` in |
| 96 | /// steps as a hand would, and lets go; returns how far the window went. |
| 97 | private func drag(from point: NSPoint, by: CGVector) -> CGVector { |
| 98 | let before = window.frame.origin |
| 99 | let from = window.convertPoint(toScreen: point) |
| 100 | NSApp.sendEvent(event(.leftMouseDown, at: point)) |
| 101 | pump(0.05) |
| 102 | for step in 1...6 { |
| 103 | let part = CGFloat(step) / 6 |
| 104 | let there = NSPoint(x: from.x + by.dx * part, y: from.y + by.dy * part) |
| 105 | NSApp.sendEvent(event(.leftMouseDragged, at: window.convertPoint(fromScreen: there))) |
| 106 | pump(0.03) |
| 107 | } |
| 108 | let end = NSPoint(x: from.x + by.dx, y: from.y + by.dy) |
| 109 | NSApp.sendEvent(event(.leftMouseUp, at: window.convertPoint(fromScreen: end))) |
| 110 | pump(0.2) |
| 111 | return CGVector(dx: window.frame.minX - before.x, dy: window.frame.minY - before.y) |
| 112 | } |
| 113 | |
| 114 | private var bounds: NSRect { window.contentView!.bounds } |
| 115 | |
| 116 | func testTheCallLeavesTheWindowResizableWithNoTitleBar() { |
| 117 | open() |
| 118 | XCTAssertTrue(window.styleMask.contains(.resizable)) |
| 119 | XCTAssertTrue(window.styleMask.contains(.fullSizeContentView)) |
| 120 | XCTAssertEqual(window.titleVisibility, .hidden) |
| 121 | XCTAssertTrue(window.titlebarAppearsTransparent) |
| 122 | } |
| 123 | |
| 124 | /// AppKit keeps a few points inside each edge, and more at the corners, |
| 125 | /// for resizing, whatever the content — the drag strip along the top |
| 126 | /// included. Only past them do the call's own views get the click. |
| 127 | func testTheEdgesAndCornersBelongToTheWindow() { |
| 128 | open() |
| 129 | let frameView = window.contentView!.superview! |
| 130 | let edges: [(String, NSPoint)] = [ |
| 131 | ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1)), |
| 132 | ("bottom", NSPoint(x: bounds.midX, y: 1)), |
| 133 | ("left", NSPoint(x: 1, y: bounds.midY)), |
| 134 | ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY)), |
| 135 | ("top left", NSPoint(x: 4, y: bounds.maxY - 4)), |
| 136 | ("top right", NSPoint(x: bounds.maxX - 4, y: bounds.maxY - 4)), |
| 137 | ("bottom left", NSPoint(x: 4, y: 4)), |
| 138 | ("bottom right", NSPoint(x: bounds.maxX - 4, y: 4)), |
| 139 | ] |
| 140 | for (edge, point) in edges { |
| 141 | XCTAssertTrue(frameView.hitTest(point) === frameView, "\(edge) is the window's") |
| 142 | } |
| 143 | XCTAssertTrue( |
| 144 | frameView.hitTest(NSPoint(x: bounds.midX, y: bounds.maxY - 12)) is WindowDragStrip.StripView, |
| 145 | "below the edge, the strip") |
| 146 | } |
| 147 | |
| 148 | func testEveryEdgeAndCornerResizesTheWindow() { |
| 149 | open() |
| 150 | // (where, drag, which edges should move: minX, maxX, minY, maxY) |
| 151 | let cases: [(String, NSPoint, CGVector, [Bool])] = [ |
| 152 | ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1), CGVector(dx: 0, dy: 30), [false, false, false, true]), |
| 153 | ("bottom", NSPoint(x: bounds.midX, y: 1), CGVector(dx: 0, dy: -30), [false, false, true, false]), |
| 154 | ("left", NSPoint(x: 1, y: bounds.midY), CGVector(dx: -30, dy: 0), [true, false, false, false]), |
| 155 | ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY), CGVector(dx: 30, dy: 0), [false, true, false, false]), |
| 156 | ("top left", NSPoint(x: 1, y: bounds.maxY - 1), CGVector(dx: -30, dy: 30), [true, false, false, true]), |
| 157 | ("top right", NSPoint(x: bounds.maxX - 1, y: bounds.maxY - 1), CGVector(dx: 30, dy: 30), [false, true, false, true]), |
| 158 | ] |
| 159 | for (edge, point, by, moves) in cases { |
| 160 | window.setFrame(start, display: true) |
| 161 | pump(0.1) |
| 162 | // AppKit's resize reads the events that follow the press itself, |
| 163 | // so they are queued first. |
| 164 | let from = window.convertPoint(toScreen: point) |
| 165 | let to = window.convertPoint(fromScreen: NSPoint(x: from.x + by.dx, y: from.y + by.dy)) |
| 166 | NSApp.postEvent(event(.leftMouseDragged, at: to), atStart: false) |
| 167 | NSApp.postEvent(event(.leftMouseUp, at: to), atStart: false) |
| 168 | NSApp.sendEvent(event(.leftMouseDown, at: point)) |
| 169 | pump(0.1) |
| 170 | let now = window.frame |
| 171 | let moved = [now.minX != start.minX, now.maxX != start.maxX, now.minY != start.minY, now.maxY != start.maxY] |
| 172 | XCTAssertEqual(moved, moves, "\(edge): \(start) became \(now)") |
| 173 | XCTAssertGreaterThan(now.width * now.height, start.width * start.height, "\(edge) grew") |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func testDraggingThePictureMovesTheWindow() { |
| 178 | open() |
| 179 | let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 40, dy: -25)) |
| 180 | XCTAssertEqual(moved, CGVector(dx: 40, dy: -25)) |
| 181 | XCTAssertEqual(window.frame.size, start.size) |
| 182 | let again = drag(from: NSPoint(x: 150, y: 300), by: CGVector(dx: -30, dy: 20)) |
| 183 | XCTAssertEqual(again, CGVector(dx: -30, dy: 20), "anywhere on it") |
| 184 | } |
| 185 | |
| 186 | func testAClickOnThePictureDoesNotMoveTheWindow() { |
| 187 | open() |
| 188 | let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 2, dy: 1)) |
| 189 | XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| 190 | } |
| 191 | |
| 192 | /// A picture arriving is an AppKit view over the tile, which took |
| 193 | /// the press for itself before 0.3.7, so the stage's gesture never |
| 194 | /// saw it. Behind another app's window too. |
| 195 | func testDraggingLiveVideoMovesTheWindow() { |
| 196 | let factory = RTCPeerConnectionFactory() |
| 197 | let track = factory.videoTrack(with: factory.videoSource(), trackId: "window-move") |
| 198 | window = NSWindow( |
| 199 | contentRect: start, styleMask: [.titled, .closable, .resizable], backing: .buffered, |
| 200 | defer: false) |
| 201 | window.isReleasedWhenClosed = false |
| 202 | window.contentView = NSHostingView( |
| 203 | rootView: MacVideoTile(track: track, label: "Ana", cornerRadius: 0, compact: true) |
| 204 | .movesWindow()) |
| 205 | window.orderFrontRegardless() |
| 206 | pump(0.8) |
| 207 | let point = NSPoint(x: bounds.midX, y: bounds.midY) |
| 208 | XCTAssertFalse( |
| 209 | window.contentView!.superview!.hitTest(point) is RTCMTLNSVideoView, "the picture takes no clicks") |
| 210 | XCTAssertEqual(drag(from: point, by: CGVector(dx: 40, dy: -25)), CGVector(dx: 40, dy: -25)) |
| 211 | } |
| 212 | |
| 213 | /// The self view drags itself to a corner (a gesture of its own, which |
| 214 | /// the picture's does not take from it), and the window stays put. |
| 215 | func testTheSelfViewIsNotAHandle() { |
| 216 | open() |
| 217 | let moved = drag(from: NSPoint(x: bounds.maxX - 100, y: 80), by: CGVector(dx: -750, dy: 450)) |
| 218 | XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| 219 | } |
| 220 | |
| 221 | /// 0.4.0: a click on a filmstrip tile pins that person to the stage, |
| 222 | /// and moves nothing. Ana is on the stage (first in the order, before |
| 223 | /// anyone has spoken); you, Tom and Li are the filmstrip along the |
| 224 | /// bottom, 117 pt tall and centred. |
| 225 | func testAClickOnTheFilmstripPins() { |
| 226 | open(.grid) |
| 227 | XCTAssertEqual(call.arrangement.layout, .speaker) |
| 228 | let before = window.frame.origin |
| 229 | NSApp.sendEvent(event(.leftMouseDown, at: NSPoint(x: 716, y: 66))) |
| 230 | pump(0.05) |
| 231 | NSApp.sendEvent(event(.leftMouseUp, at: NSPoint(x: 716, y: 66))) |
| 232 | // A single click is known only once a double click has not come. |
| 233 | pump(NSEvent.doubleClickInterval + 0.3) |
| 234 | XCTAssertEqual(window.frame.origin, before) |
| 235 | XCTAssertEqual(call.arrangement.pinned, .camera("li")) |
| 236 | XCTAssertEqual(call.arrangement.layout, .speaker) |
| 237 | } |
| 238 | |
| 239 | /// 0.4.0: a drag on a Grid tile takes it to another's place, and the |
| 240 | /// window stays where it is. |
| 241 | func testDraggingAGridTileReordersAndDoesNotMoveTheWindow() { |
| 242 | open(.grid, grid: true) |
| 243 | XCTAssertEqual(call.arrangement.people, ["me", "ana", "tom", "li"]) |
| 244 | let moved = drag(from: NSPoint(x: 750, y: 175), by: CGVector(dx: -500, dy: 350)) |
| 245 | XCTAssertEqual(moved, CGVector(dx: 0, dy: 0), "a tile is not a handle") |
| 246 | XCTAssertEqual(call.arrangement.people, ["li", "me", "ana", "tom"]) |
| 247 | } |
| 248 | |
| 249 | func testTheBarIsNotAHandle() { |
| 250 | open(controls: true) |
| 251 | let moved = drag(from: NSPoint(x: bounds.midX, y: 40), by: CGVector(dx: 30, dy: 30)) |
| 252 | XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) |
| 253 | } |
| 254 | } |
| 255 | #endif |
| 256 | |