Files
Termind/TFMTests/WorkspaceLayoutDropTests.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])
}
}