#if os(macOS) import AppKit import SwiftUI import XCTest import LiveKitWebRTC @testable @_spi(Fixtures) import CallsKit /// Paul, on 0.3.6: "I should be able to click anywhere on the window to /// resize it." The full-window call has had no title bar since 0.3.5, so /// moving it is the picture's job, and resizing must still work from /// every edge, the top included. final class WindowMoveRuleTests: XCTestCase { func testAShortPressIsAClickAndALongerOneADrag() { XCTAssertEqual(WindowMove.threshold, 4) let start = CGPoint(x: 100, y: 100) XCTAssertFalse(WindowMove.isDrag(from: start, to: start)) XCTAssertFalse(WindowMove.isDrag(from: start, to: CGPoint(x: 102, y: 102)), "a shaky click") XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 104, y: 100))) XCTAssertTrue(WindowMove.isDrag(from: start, to: CGPoint(x: 97, y: 97)), "any direction") } func testThePressFollowsThePointerOnceItIsADrag() { var press = WindowMove.Press(origin: CGPoint(x: 200, y: 300), grabbed: CGPoint(x: 700, y: 650)) XCTAssertNil(press.moved(to: CGPoint(x: 702, y: 651)), "still a click") XCTAssertFalse(press.moving) XCTAssertEqual(press.moved(to: CGPoint(x: 740, y: 625)), CGPoint(x: 240, y: 275)) XCTAssertTrue(press.moving) // Once moving, it goes wherever the pointer goes, back to where it // started included: the window is not left 4 pt short. XCTAssertEqual(press.moved(to: CGPoint(x: 701, y: 650)), CGPoint(x: 201, y: 300)) XCTAssertEqual(press.moved(to: CGPoint(x: 700, y: 650)), CGPoint(x: 200, y: 300)) } func testOnlyAWindowThatCanMoveIsMoved() { XCTAssertTrue(WindowMove.canMove(fullScreen: false, movable: true)) XCTAssertFalse(WindowMove.canMove(fullScreen: true, movable: true), "full screen is the screen") XCTAssertFalse(WindowMove.canMove(fullScreen: false, movable: false)) } } /// The real full-window call in a titled, resizable window like the app's, /// driven with mouse events sent to it: what a press on each part of it /// does to the window. The window is never made key or active: a call /// window behind another app's must move by its picture too. @MainActor final class CallWindowMouseTests: XCTestCase { private var window: NSWindow! private var call: Call! private let start = NSRect(x: 200, y: 200, width: 1000, height: 700) override func setUp() async throws { NSApplication.shared.setActivationPolicy(.prohibited) UserDefaults.standard.removeObject(forKey: PiPCorner.key) } override func tearDown() async throws { window?.close() window = nil UserDefaults.standard.removeObject(forKey: PiPCorner.key) } private func open(_ fixture: CallFixture = .focus, grid: Bool = false, controls: Bool = false) { call = Call(playAudio: false) fixture.apply(to: call) window = NSWindow( contentRect: start, styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.contentView = NSHostingView( rootView: MacCallView( call: call, grid: grid, style: .fullWindow, controlsShown: controls, leave: {}, rejoin: {})) window.orderFrontRegardless() pump(0.8) window.setFrame(start, display: true) pump(0.2) } private func pump(_ seconds: Double) { let until = Date(timeIntervalSinceNow: seconds) while let event = NSApp.nextEvent( matching: .any, until: until, inMode: .default, dequeue: true) { NSApp.sendEvent(event) } } private func event(_ type: NSEvent.EventType, at point: NSPoint, clicks: Int = 1) -> NSEvent { NSEvent.mouseEvent( with: type, location: point, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: clicks, pressure: type == .leftMouseUp ? 0 : 1)! } /// Presses at `point` (window coordinates, y up), drags by `by` in /// steps as a hand would, and lets go; returns how far the window went. private func drag(from point: NSPoint, by: CGVector) -> CGVector { let before = window.frame.origin let from = window.convertPoint(toScreen: point) NSApp.sendEvent(event(.leftMouseDown, at: point)) pump(0.05) for step in 1...6 { let part = CGFloat(step) / 6 let there = NSPoint(x: from.x + by.dx * part, y: from.y + by.dy * part) NSApp.sendEvent(event(.leftMouseDragged, at: window.convertPoint(fromScreen: there))) pump(0.03) } let end = NSPoint(x: from.x + by.dx, y: from.y + by.dy) NSApp.sendEvent(event(.leftMouseUp, at: window.convertPoint(fromScreen: end))) pump(0.2) return CGVector(dx: window.frame.minX - before.x, dy: window.frame.minY - before.y) } private var bounds: NSRect { window.contentView!.bounds } func testTheCallLeavesTheWindowResizableWithNoTitleBar() { open() XCTAssertTrue(window.styleMask.contains(.resizable)) XCTAssertTrue(window.styleMask.contains(.fullSizeContentView)) XCTAssertEqual(window.titleVisibility, .hidden) XCTAssertTrue(window.titlebarAppearsTransparent) } /// AppKit keeps a few points inside each edge, and more at the corners, /// for resizing, whatever the content — the drag strip along the top /// included. Only past them do the call's own views get the click. func testTheEdgesAndCornersBelongToTheWindow() { open() let frameView = window.contentView!.superview! let edges: [(String, NSPoint)] = [ ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1)), ("bottom", NSPoint(x: bounds.midX, y: 1)), ("left", NSPoint(x: 1, y: bounds.midY)), ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY)), ("top left", NSPoint(x: 4, y: bounds.maxY - 4)), ("top right", NSPoint(x: bounds.maxX - 4, y: bounds.maxY - 4)), ("bottom left", NSPoint(x: 4, y: 4)), ("bottom right", NSPoint(x: bounds.maxX - 4, y: 4)), ] for (edge, point) in edges { XCTAssertTrue(frameView.hitTest(point) === frameView, "\(edge) is the window's") } XCTAssertTrue( frameView.hitTest(NSPoint(x: bounds.midX, y: bounds.maxY - 12)) is WindowDragStrip.StripView, "below the edge, the strip") } func testEveryEdgeAndCornerResizesTheWindow() { open() // (where, drag, which edges should move: minX, maxX, minY, maxY) let cases: [(String, NSPoint, CGVector, [Bool])] = [ ("top", NSPoint(x: bounds.midX, y: bounds.maxY - 1), CGVector(dx: 0, dy: 30), [false, false, false, true]), ("bottom", NSPoint(x: bounds.midX, y: 1), CGVector(dx: 0, dy: -30), [false, false, true, false]), ("left", NSPoint(x: 1, y: bounds.midY), CGVector(dx: -30, dy: 0), [true, false, false, false]), ("right", NSPoint(x: bounds.maxX - 1, y: bounds.midY), CGVector(dx: 30, dy: 0), [false, true, false, false]), ("top left", NSPoint(x: 1, y: bounds.maxY - 1), CGVector(dx: -30, dy: 30), [true, false, false, true]), ("top right", NSPoint(x: bounds.maxX - 1, y: bounds.maxY - 1), CGVector(dx: 30, dy: 30), [false, true, false, true]), ] for (edge, point, by, moves) in cases { window.setFrame(start, display: true) pump(0.1) // AppKit's resize reads the events that follow the press itself, // so they are queued first. let from = window.convertPoint(toScreen: point) let to = window.convertPoint(fromScreen: NSPoint(x: from.x + by.dx, y: from.y + by.dy)) NSApp.postEvent(event(.leftMouseDragged, at: to), atStart: false) NSApp.postEvent(event(.leftMouseUp, at: to), atStart: false) NSApp.sendEvent(event(.leftMouseDown, at: point)) pump(0.1) let now = window.frame let moved = [now.minX != start.minX, now.maxX != start.maxX, now.minY != start.minY, now.maxY != start.maxY] XCTAssertEqual(moved, moves, "\(edge): \(start) became \(now)") XCTAssertGreaterThan(now.width * now.height, start.width * start.height, "\(edge) grew") } } func testDraggingThePictureMovesTheWindow() { open() let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 40, dy: -25)) XCTAssertEqual(moved, CGVector(dx: 40, dy: -25)) XCTAssertEqual(window.frame.size, start.size) let again = drag(from: NSPoint(x: 150, y: 300), by: CGVector(dx: -30, dy: 20)) XCTAssertEqual(again, CGVector(dx: -30, dy: 20), "anywhere on it") } func testAClickOnThePictureDoesNotMoveTheWindow() { open() let moved = drag(from: NSPoint(x: bounds.midX, y: bounds.midY), by: CGVector(dx: 2, dy: 1)) XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) } /// A picture arriving is an AppKit view over the tile, which took /// the press for itself before 0.3.7, so the stage's gesture never /// saw it. Behind another app's window too. func testDraggingLiveVideoMovesTheWindow() { let factory = RTCPeerConnectionFactory() let track = factory.videoTrack(with: factory.videoSource(), trackId: "window-move") window = NSWindow( contentRect: start, styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false window.contentView = NSHostingView( rootView: MacVideoTile(track: track, label: "Ana", cornerRadius: 0, compact: true) .movesWindow()) window.orderFrontRegardless() pump(0.8) let point = NSPoint(x: bounds.midX, y: bounds.midY) XCTAssertFalse( window.contentView!.superview!.hitTest(point) is RTCMTLNSVideoView, "the picture takes no clicks") XCTAssertEqual(drag(from: point, by: CGVector(dx: 40, dy: -25)), CGVector(dx: 40, dy: -25)) } /// The self view drags itself to a corner (a gesture of its own, which /// the picture's does not take from it), and the window stays put. func testTheSelfViewIsNotAHandle() { open() let moved = drag(from: NSPoint(x: bounds.maxX - 100, y: 80), by: CGVector(dx: -750, dy: 450)) XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) } /// 0.4.0: a click on a filmstrip tile pins that person to the stage, /// and moves nothing. Ana is on the stage (first in the order, before /// anyone has spoken); you, Tom and Li are the filmstrip along the /// bottom, 117 pt tall and centred. func testAClickOnTheFilmstripPins() { open(.grid) XCTAssertEqual(call.arrangement.layout, .speaker) let before = window.frame.origin NSApp.sendEvent(event(.leftMouseDown, at: NSPoint(x: 716, y: 66))) pump(0.05) NSApp.sendEvent(event(.leftMouseUp, at: NSPoint(x: 716, y: 66))) // A single click is known only once a double click has not come. pump(NSEvent.doubleClickInterval + 0.3) XCTAssertEqual(window.frame.origin, before) XCTAssertEqual(call.arrangement.pinned, .camera("li")) XCTAssertEqual(call.arrangement.layout, .speaker) } /// 0.4.0: a drag on a Grid tile takes it to another's place, and the /// window stays where it is. func testDraggingAGridTileReordersAndDoesNotMoveTheWindow() { open(.grid, grid: true) XCTAssertEqual(call.arrangement.people, ["me", "ana", "tom", "li"]) let moved = drag(from: NSPoint(x: 750, y: 175), by: CGVector(dx: -500, dy: 350)) XCTAssertEqual(moved, CGVector(dx: 0, dy: 0), "a tile is not a handle") XCTAssertEqual(call.arrangement.people, ["li", "me", "ana", "tom"]) } func testTheBarIsNotAHandle() { open(controls: true) let moved = drag(from: NSPoint(x: bounds.midX, y: 40), by: CGVector(dx: 30, dy: 30)) XCTAssertEqual(moved, CGVector(dx: 0, dy: 0)) } } #endif