301 lines
14 KiB
Swift
301 lines
14 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AppKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@testable import Termind
|
|
|
|
@Suite
|
|
@MainActor
|
|
struct WorkspaceLayoutDropTests {
|
|
private let size = CGSize(width: 400, height: 240)
|
|
private let trailingPoint = CGPoint(x: 390, y: 120)
|
|
|
|
private func workspace(_ name: String) -> Workspace {
|
|
Workspace.sshSession(host: Host(name: name, host: "\(name.lowercased()).example.invalid"))
|
|
}
|
|
|
|
private func receiver(window: WindowModel, workspace: Workspace, pane: Pane) -> WorkspaceLayoutDropNSView {
|
|
let controller = PaneLayoutDropController(window: window, workspace: workspace, pane: pane)
|
|
let receiver = WorkspaceLayoutDropNSView(frame: CGRect(origin: .zero, size: size))
|
|
receiver.draggedID = { controller.draggedID }
|
|
receiver.preview = { point, size in controller.preview(at: point, in: size) }
|
|
receiver.perform = { point, size in controller.perform(at: point, in: size) }
|
|
return receiver
|
|
}
|
|
|
|
private func pasteboard(for id: UUID) -> NSPasteboard {
|
|
let pasteboard = NSPasteboard.withUniqueName()
|
|
pasteboard.setString(id.uuidString, forType: .string)
|
|
return pasteboard
|
|
}
|
|
|
|
/// Configure the native destinations before pickup, then enter AppKit's
|
|
/// callbacks without another SwiftUI render or representable update.
|
|
@Test(arguments: ["leading", "trailing", "top", "bottom"])
|
|
func selectedWorkspaceDragImmediatelyPreviewsAndMergesIntoItsNeighbour(edge: String) {
|
|
let source = workspace("Source")
|
|
let target = workspace("Target")
|
|
let hidden = workspace("Hidden")
|
|
let sourcePane = source.focusedPane
|
|
let targetPane = target.focusedPane
|
|
let window = WindowModel(workspaces: [source, target, hidden])
|
|
window.select(source.id)
|
|
let sourceReceiver = receiver(window: window, workspace: source, pane: sourcePane)
|
|
let targetReceiver = receiver(window: window, workspace: target, pane: targetPane)
|
|
let hiddenReceiver = receiver(window: window, workspace: hidden, pane: hidden.focusedPane)
|
|
let pasteboard = pasteboard(for: source.id)
|
|
defer { pasteboard.releaseGlobally() }
|
|
|
|
let point: CGPoint
|
|
let expectedPreview: CGRect
|
|
let expectedAxis: SplitAxis
|
|
let expectedPanes: [UUID]
|
|
switch edge {
|
|
case "leading":
|
|
point = CGPoint(x: 10, y: 120)
|
|
expectedPreview = CGRect(x: 0, y: 0, width: 200, height: 240)
|
|
expectedAxis = .horizontal
|
|
expectedPanes = [sourcePane.id, targetPane.id]
|
|
case "trailing":
|
|
point = trailingPoint
|
|
expectedPreview = CGRect(x: 200, y: 0, width: 200, height: 240)
|
|
expectedAxis = .horizontal
|
|
expectedPanes = [targetPane.id, sourcePane.id]
|
|
case "top":
|
|
point = CGPoint(x: 200, y: 10)
|
|
expectedPreview = CGRect(x: 0, y: 0, width: 400, height: 120)
|
|
expectedAxis = .vertical
|
|
expectedPanes = [sourcePane.id, targetPane.id]
|
|
default:
|
|
point = CGPoint(x: 200, y: 230)
|
|
expectedPreview = CGRect(x: 0, y: 120, width: 400, height: 120)
|
|
expectedAxis = .vertical
|
|
expectedPanes = [targetPane.id, sourcePane.id]
|
|
}
|
|
|
|
#expect(window.dragging == nil)
|
|
#expect(targetReceiver.registeredDraggedTypes.contains(.string))
|
|
#expect(targetReceiver.update(at: point, pasteboard: pasteboard).isEmpty)
|
|
window.dragging = .workspace(source.id)
|
|
|
|
#expect(window.displayedWorkspace === target)
|
|
for inactive in [sourceReceiver, hiddenReceiver] {
|
|
#expect(inactive.update(at: point, pasteboard: pasteboard).isEmpty)
|
|
#expect(inactive.previewRect == nil)
|
|
#expect(!inactive.receive(at: point, pasteboard: pasteboard))
|
|
#expect(window.dragging == .workspace(source.id))
|
|
}
|
|
#expect(
|
|
targetReceiver.participates(at: point, currentEventType: .leftMouseDragged, pasteboard: pasteboard))
|
|
#expect(targetReceiver.update(at: point, pasteboard: pasteboard) == .move)
|
|
#expect(targetReceiver.previewRect == expectedPreview)
|
|
#expect(targetReceiver.receive(at: point, pasteboard: pasteboard))
|
|
|
|
#expect(window.workspaces.map(\.id) == [target.id, hidden.id])
|
|
#expect(target.panes.map(\.id) == expectedPanes)
|
|
#expect(target.layout.pane(sourcePane.id) === sourcePane)
|
|
#expect(target.layout.pane(targetPane.id) === targetPane)
|
|
guard case .split(let branch) = target.layout else {
|
|
Issue.record("A workspace dropped on an edge must create a split")
|
|
return
|
|
}
|
|
#expect(branch.axis == expectedAxis)
|
|
#expect(branch.fraction == 0.5)
|
|
#expect(window.selectedWorkspace === target)
|
|
#expect(window.dragging == nil)
|
|
#expect(targetReceiver.previewRect == nil)
|
|
}
|
|
|
|
@Test
|
|
func destinationHiddenAfterHoverRejectsTheDropWithoutAReceiverUpdate() {
|
|
let source = workspace("Source")
|
|
let target = workspace("Target")
|
|
let window = WindowModel(workspaces: [target, source])
|
|
window.select(target.id)
|
|
let receiver = receiver(window: window, workspace: target, pane: target.focusedPane)
|
|
let pasteboard = pasteboard(for: source.id)
|
|
defer { pasteboard.releaseGlobally() }
|
|
window.dragging = .workspace(source.id)
|
|
#expect(receiver.update(at: trailingPoint, pasteboard: pasteboard) == .move)
|
|
#expect(receiver.previewRect != nil)
|
|
|
|
// AppKit may already have chosen this destination when another
|
|
// surface takes the foreground. Release must recheck its live owner.
|
|
window.selectVault()
|
|
|
|
#expect(!receiver.receive(at: trailingPoint, pasteboard: pasteboard))
|
|
#expect(receiver.previewRect == nil)
|
|
#expect(window.workspaces.map(\.id) == [target.id, source.id])
|
|
#expect(target.panes.count == 1)
|
|
#expect(window.dragging == .workspace(source.id))
|
|
}
|
|
|
|
@Test
|
|
func paneDragRearrangesTheLiveLayoutWithoutReconfiguringTheReceiver() {
|
|
let workspace = workspace("First")
|
|
let sourcePane = workspace.focusedPane
|
|
workspace.splitFocusedPane(
|
|
axis: .vertical, with: .sshSession(host: Host(name: "Second", host: "second.example.invalid")))
|
|
let targetPane = workspace.focusedPane
|
|
let window = WindowModel(workspaces: [workspace])
|
|
window.select(workspace.id)
|
|
let sourceReceiver = receiver(window: window, workspace: workspace, pane: sourcePane)
|
|
let targetReceiver = receiver(window: window, workspace: workspace, pane: targetPane)
|
|
let pasteboard = pasteboard(for: sourcePane.id)
|
|
defer { pasteboard.releaseGlobally() }
|
|
|
|
#expect(window.dragging == nil)
|
|
window.dragging = .pane(sourcePane.id)
|
|
|
|
#expect(!sourceReceiver.receive(at: trailingPoint, pasteboard: pasteboard))
|
|
#expect(window.dragging == .pane(sourcePane.id))
|
|
#expect(targetReceiver.update(at: trailingPoint, pasteboard: pasteboard) == .move)
|
|
#expect(targetReceiver.previewRect == CGRect(x: 200, y: 0, width: 200, height: 240))
|
|
#expect(targetReceiver.receive(at: trailingPoint, pasteboard: pasteboard))
|
|
|
|
#expect(window.workspaces.map(\.id) == [workspace.id])
|
|
#expect(workspace.panes.map(\.id) == [targetPane.id, sourcePane.id])
|
|
#expect(workspace.focusedPane === sourcePane)
|
|
guard case .split(let branch) = workspace.layout else {
|
|
Issue.record("Rearranging the two panes must preserve a split")
|
|
return
|
|
}
|
|
#expect(branch.axis == .horizontal)
|
|
#expect(window.dragging == nil)
|
|
#expect(targetReceiver.previewRect == nil)
|
|
}
|
|
|
|
@Test(arguments: [true, false])
|
|
func emptySlotReplacesItsWholeAreaForWorkspaceAndPaneDrags(dragWorkspace: Bool) {
|
|
let source = workspace("Source")
|
|
let sourcePane = source.focusedPane
|
|
let target = dragWorkspace ? workspace("Target") : source
|
|
let retainedPane = target.focusedPane
|
|
target.splitFocusedPane(axis: .horizontal, with: Pane(.empty))
|
|
let slot = target.focusedPane
|
|
let window = WindowModel(workspaces: dragWorkspace ? [target, source] : [target])
|
|
window.select(target.id)
|
|
let receiver = receiver(window: window, workspace: target, pane: slot)
|
|
let draggedID = dragWorkspace ? source.id : sourcePane.id
|
|
let pasteboard = pasteboard(for: draggedID)
|
|
defer { pasteboard.releaseGlobally() }
|
|
|
|
#expect(window.dragging == nil)
|
|
window.dragging = dragWorkspace ? .workspace(source.id) : .pane(sourcePane.id)
|
|
|
|
#expect(receiver.update(at: trailingPoint, pasteboard: pasteboard) == .move)
|
|
#expect(receiver.previewRect == CGRect(origin: .zero, size: size))
|
|
#expect(receiver.receive(at: trailingPoint, pasteboard: pasteboard))
|
|
|
|
#expect(window.workspaces.map(\.id) == [target.id])
|
|
#expect(target.panes.map(\.id) == (dragWorkspace ? [retainedPane.id, sourcePane.id] : [sourcePane.id]))
|
|
#expect(target.layout.pane(slot.id) == nil)
|
|
#expect(target.layout.pane(sourcePane.id) === sourcePane)
|
|
#expect(window.dragging == nil)
|
|
#expect(receiver.previewRect == nil)
|
|
}
|
|
|
|
@Test(arguments: ["text", "unrelatedUUID", "file"])
|
|
func cancelledDragStateDoesNotMakeAnExternalPayloadRearrangeWorkspaces(payload: String) {
|
|
let source = workspace("Source")
|
|
let target = workspace("Target")
|
|
let window = WindowModel(workspaces: [target, source])
|
|
window.select(target.id)
|
|
let receiver = receiver(window: window, workspace: target, pane: target.focusedPane)
|
|
let pasteboard = pasteboard(for: source.id)
|
|
defer { pasteboard.releaseGlobally() }
|
|
window.dragging = .workspace(source.id)
|
|
#expect(receiver.update(at: trailingPoint, pasteboard: pasteboard) == .move)
|
|
#expect(receiver.previewRect != nil)
|
|
|
|
// A release outside the window can leave this model payload behind.
|
|
// The next external drag must still be checked against its own data.
|
|
pasteboard.clearContents()
|
|
switch payload {
|
|
case "text": pasteboard.setString("Text from another application", forType: .string)
|
|
case "unrelatedUUID": pasteboard.setString(UUID().uuidString, forType: .string)
|
|
default: pasteboard.setString("file:///tmp/workspace-drop-test.txt", forType: .fileURL)
|
|
}
|
|
|
|
#expect(
|
|
!receiver.participates(
|
|
at: trailingPoint, currentEventType: .leftMouseDragged, pasteboard: pasteboard))
|
|
#expect(receiver.update(at: trailingPoint, pasteboard: pasteboard).isEmpty)
|
|
#expect(receiver.previewRect == nil)
|
|
#expect(!receiver.receive(at: trailingPoint, pasteboard: pasteboard))
|
|
#expect(window.workspaces.map(\.id) == [target.id, source.id])
|
|
#expect(target.panes.count == 1)
|
|
#expect(window.dragging == .workspace(source.id))
|
|
}
|
|
|
|
@Test
|
|
func endingTheSourceSessionClearsPreviewWithoutAnotherDragEvent() {
|
|
let source = workspace("Source")
|
|
let target = workspace("Target")
|
|
let window = WindowModel(workspaces: [target, source])
|
|
window.select(target.id)
|
|
let receiver = receiver(window: window, workspace: target, pane: target.focusedPane)
|
|
let pasteboard = pasteboard(for: source.id)
|
|
defer { pasteboard.releaseGlobally() }
|
|
window.dragging = .workspace(source.id)
|
|
#expect(receiver.update(at: trailingPoint, pasteboard: pasteboard) == .move)
|
|
#expect(receiver.previewRect != nil)
|
|
|
|
window.dragging = nil
|
|
receiver.clearPreviewIfInactive()
|
|
|
|
#expect(receiver.previewRect == nil)
|
|
#expect(window.workspaces.map(\.id) == [target.id, source.id])
|
|
}
|
|
|
|
@Test
|
|
func assistantConversationUUIDNeverBecomesAWorkspaceLayoutDrop() {
|
|
let workspace = workspace("Target")
|
|
let window = WindowModel(workspaces: [workspace])
|
|
window.select(workspace.id)
|
|
let receiver = receiver(window: window, workspace: workspace, pane: workspace.focusedPane)
|
|
let conversationID = UUID()
|
|
let pasteboard = pasteboard(for: conversationID)
|
|
defer { pasteboard.releaseGlobally() }
|
|
window.dragging = .assistantConversation(conversationID)
|
|
|
|
#expect(
|
|
!receiver.participates(
|
|
at: trailingPoint, currentEventType: .leftMouseDragged, pasteboard: pasteboard))
|
|
#expect(receiver.update(at: trailingPoint, pasteboard: pasteboard).isEmpty)
|
|
#expect(!receiver.receive(at: trailingPoint, pasteboard: pasteboard))
|
|
#expect(receiver.previewRect == nil)
|
|
#expect(window.dragging == .assistantConversation(conversationID))
|
|
#expect(workspace.panes.count == 1)
|
|
}
|
|
|
|
@Test(arguments: [
|
|
NSEvent.EventType.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp,
|
|
.otherMouseDown, .otherMouseUp, .mouseMoved, .cursorUpdate, .scrollWheel,
|
|
])
|
|
func matchingStaleUUIDDoesNotInterceptOrdinaryPointerEvents(event: NSEvent.EventType) {
|
|
let source = workspace("Source")
|
|
let target = workspace("Target")
|
|
let window = WindowModel(workspaces: [target, source])
|
|
window.select(target.id)
|
|
let receiver = receiver(window: window, workspace: target, pane: target.focusedPane)
|
|
let pasteboard = pasteboard(for: source.id)
|
|
defer { pasteboard.releaseGlobally() }
|
|
window.dragging = .workspace(source.id)
|
|
|
|
#expect(!receiver.participates(at: trailingPoint, currentEventType: event, pasteboard: pasteboard))
|
|
#expect(
|
|
!receiver.participates(
|
|
at: CGPoint(x: 500, y: 120), currentEventType: .leftMouseDragged, pasteboard: pasteboard))
|
|
#expect(receiver.previewRect == nil)
|
|
#expect(window.workspaces.map(\.id) == [target.id, source.id])
|
|
}
|
|
}
|