Files
Termind/TFMTests/WorkspaceLayoutDragSourceTests.swift

207 lines
6.8 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
import TermindDomain
import Testing
@testable import Termind
@Suite
@MainActor
struct WorkspaceLayoutDragSourceTests {
private let sourceFrame = CGRect(x: 100, y: 100, width: 600, height: 400)
private let otherFrame = CGRect(x: 800, y: 100, width: 600, height: 400)
private let outside = CGPoint(x: 740, y: 260)
@Test
func releasingOutsideTheSourceTearsOffAndClearsTheLivePayload() {
let model = WindowModel()
let source = source(model: model)
var received: CGPoint?
source.onTearOff = {
received = $0
#expect(model.dragging == source.payload)
}
#expect(source.beginTracking())
finish(source, at: outside)
#expect(received == outside)
#expect(model.dragging == nil)
finish(source, at: outside)
#expect(received == outside)
}
@Test
func releasingWithinTheSourceWindowDoesNotTearOff() {
let model = WindowModel()
let source = source(model: model)
source.onTearOff = { _ in Issue.record("A release inside the source window must not create a window") }
#expect(source.beginTracking())
finish(source, at: CGPoint(x: sourceFrame.midX, y: sourceFrame.midY))
#expect(model.dragging == nil)
}
@Test
func anUnhandledReleaseOverAnotherWindowStillTearsOff() {
let model = WindowModel()
let source = source(model: model)
let point = CGPoint(x: otherFrame.midX, y: otherFrame.midY)
var received: CGPoint?
source.onTearOff = { received = $0 }
#expect(source.beginTracking())
finish(source, at: point)
#expect(received == point)
#expect(model.dragging == nil)
}
@Test
func acceptedDropsAndCancelledDragsNeverTearOff() {
let model = WindowModel()
let source = source(model: model)
source.onTearOff = { _ in Issue.record("An accepted or cancelled drag must not create a window") }
#expect(source.beginTracking())
finish(source, at: outside, operation: .move)
#expect(model.dragging == nil)
#expect(source.beginTracking())
finish(source, at: outside, primaryButtonPressed: true)
#expect(model.dragging == nil)
#expect(source.beginTracking())
finish(source, at: outside, currentEventIsEscape: true)
#expect(model.dragging == nil)
}
@Test
func consumedOrReplacedPayloadDoesNotTearOffOrClearAnotherDrag() {
let model = WindowModel()
let source = source(model: model)
source.onTearOff = { _ in Issue.record("A consumed payload cannot be moved again") }
#expect(source.beginTracking())
model.dragging = nil
finish(source, at: outside)
#expect(model.dragging == nil)
#expect(source.beginTracking())
let replacement = DragPayload.pane(UUID())
model.dragging = replacement
finish(source, at: outside)
#expect(model.dragging == replacement)
}
@Test
func sourceStaysTransparentAndRemovesItsRecognizerWhenDetached() {
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 300, height: 200),
styleMask: .borderless, backing: .buffered, defer: false
)
window.isReleasedWhenClosed = false
defer { window.close() }
let root = NSView(frame: CGRect(x: 0, y: 0, width: 300, height: 200))
window.contentView = root
let model = WindowModel()
let source = source(model: model)
source.frame = CGRect(x: 0, y: 0, width: 200, height: 32)
root.addSubview(source)
#expect(!window.isVisible)
#expect(source.hitTest(CGPoint(x: 10, y: 10)) == nil)
#expect(source.panRecognizer.view === root)
#expect(!source.panRecognizer.delaysPrimaryMouseButtonEvents)
#expect(!source.panRecognizer.delaysSecondaryMouseButtonEvents)
#expect(!source.panRecognizer.delaysKeyEvents)
#expect(source.beginTracking())
source.removeFromSuperview()
#expect(source.panRecognizer.view == nil)
#expect(root.gestureRecognizers.isEmpty)
#expect(model.dragging == nil)
}
@Test
func hiddenSourcesAndTrailingControlsCannotStartDrags() {
let model = WindowModel()
let source = source(model: model)
source.frame = CGRect(x: 0, y: 0, width: 200, height: 32)
source.excludedTrailingWidth = 30
#expect(source.acceptsDragStart(at: CGPoint(x: 20, y: 16)))
#expect(!source.acceptsDragStart(at: CGPoint(x: 185, y: 16)))
#expect(!source.acceptsDragStart(at: CGPoint(x: 20, y: 40)))
source.isHidden = true
#expect(!source.acceptsDragStart(at: CGPoint(x: 20, y: 16)))
}
@Test
func parkedPaneSourcesCannotStartDragsOverTheSelectedWorkspaceOrVault() {
let model = WindowModel()
let source = source(model: model)
let workspace = model.workspaces[0]
source.payload = .pane(workspace.focusedPaneID)
#expect(source.isEligible)
model.open(Workspace.sshSession(host: Host(name: "Other", host: "other.example.invalid")))
#expect(!source.isEligible)
#expect(!source.beginTracking())
model.select(workspace.id)
#expect(source.isEligible)
model.selectVault()
#expect(!source.isEligible)
#expect(!source.beginTracking())
}
@Test
func workspaceSourceRequiresTheVisibleNavigator() {
let model = WindowModel()
let source = source(model: model)
#expect(source.isEligible)
model.sidebarMode = .assistant
#expect(!source.isEligible)
model.sidebarMode = .navigation
model.sidebar.isPresented = false
#expect(!source.isEligible)
model.sidebar.isPresented = true
#expect(source.isEligible)
model.closeForWindow()
#expect(!source.isEligible)
}
private func source(model: WindowModel) -> WorkspaceLayoutDragSourceNSView {
let workspace = model.open(Workspace.sshSession(host: Host(name: "Source", host: "source.example.invalid")))
let source = WorkspaceLayoutDragSourceNSView()
source.model = model
source.payload = .workspace(workspace.id)
return source
}
private func finish(
_ source: WorkspaceLayoutDragSourceNSView,
at point: CGPoint,
operation: NSDragOperation = [],
primaryButtonPressed: Bool = false,
currentEventIsEscape: Bool = false
) {
source.finishTracking(
at: point, operation: operation,
primaryButtonPressed: primaryButtonPressed, currentEventIsEscape: currentEventIsEscape,
windowFrames: [sourceFrame]
)
}
}