Files
Termind/TFMTests/WorkspaceWindowMoveTests.swift

259 lines
12 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AppKit
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@Suite(.serialized)
@MainActor
struct WorkspaceWindowMoveTests {
@Test
func splitPaneWaitsForDestinationAndPreservesConnectionsAndHistory() throws {
let host = Host(name: "Server", host: "server.example")
let history = ConnectionHistoryModel()
let app = AppModel(hosts: HostLibraryModel(hosts: [host]), connectionHistory: history)
let source = WindowModel()
let target = WindowModel()
let sourceNative = nativeWindow()
let targetNative = nativeWindow()
defer {
sourceNative.close()
targetNative.close()
}
app.mainWindows.register(sourceNative, model: source, requestID: nil)
let sftp = SFTPChannelModel.demo(hostID: host.id, path: "/srv")
let workspace = Workspace(pane: Pane(.sftpChannel(sftp)))
let remaining = workspace.focusedPane
let moved = Pane.sshSession(host: host)
moved.customTitle = "Build"
workspace.splitFocusedPane(axis: .horizontal, with: moved)
source.open(workspace)
let ssh = try #require(moved.sshSession)
ssh.status = .connected
let historyIDs = Set(history.entries().map(\.id))
#expect(historyIDs.count == 2)
var created: [UUID] = []
app.mainWindows.createScene = { created.append($0) }
let request = try #require(app.mainWindows.movePane(moved.id, from: source))
#expect(app.mainWindows.movePane(moved.id, from: source) == request)
#expect(created == [request])
#expect(workspace.panes.count == 2)
#expect(workspace.focusedPane === moved)
#expect(source.sidebar.sessionContext.workingDirectory == nil)
#expect(target.workspaces.isEmpty)
app.mainWindows.register(targetNative, model: target, requestID: request)
let received = try #require(target.selectedWorkspace)
#expect(received !== workspace)
#expect(received.focusedPane === moved)
#expect(received.lifecycleRouter === target)
#expect(moved.customTitle == "Build")
#expect(source.selectedWorkspace === workspace)
#expect(workspace.focusedPane === remaining)
#expect(workspace.lifecycleRouter === source)
#expect(source.sidebar.sessionContext.workingDirectory == "/srv")
#expect(target.sidebar.sessionContext.workingDirectory == nil)
#expect(app.closedWorkspaceRecords.isEmpty)
#expect(ssh.status == .connected && sftp.status == .connected)
#expect(sftp.path == "/srv")
#expect(Set(history.entries().map(\.id)) == historyIDs)
#expect(history.entries().allSatisfy { $0.endedAt == nil })
app.mainWindows.register(targetNative, model: target, requestID: request)
#expect(target.workspaces.count == 1)
sourceNative.close()
#expect(ssh.status == .connected)
#expect(sftp.status == .disconnected)
#expect(history.entries().first { $0.kind == .ssh }?.endedAt == nil)
#expect(history.entries().first { $0.kind == .sftp }?.endedAt != nil)
targetNative.close()
#expect(ssh.status == .disconnected)
#expect(history.entries().allSatisfy { $0.endedAt != nil })
}
@Test
func lonePaneCarriesItsOriginalWorkspaceAndName() throws {
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
workspace.automaticTitle = "Production 2"
workspace.focusedPane.customTitle = "Build"
workspace.sourceTemplateID = UUID()
let source = WindowModel(workspaces: [workspace])
let target = WindowModel()
let app = AppModel(hosts: HostLibraryModel())
app.mainWindows.createScene = { _ in }
let native = nativeWindow()
defer {
source.closeForWindow()
native.close()
}
let request = try #require(app.mainWindows.movePane(workspace.focusedPaneID, from: source))
#expect(source.selectedWorkspace === workspace)
app.mainWindows.register(native, model: target, requestID: request)
#expect(target.selectedWorkspace === workspace)
#expect(workspace.automaticTitle == "Production 2")
#expect(workspace.focusedPane.customTitle == "Build")
#expect(workspace.sourceTemplateID != nil)
#expect(source.workspaces.isEmpty)
#expect(source.selectedDestination == .vault)
#expect(source.vaultDestination == .welcome)
#expect(app.closedWorkspaceRecords.isEmpty)
}
@Test(arguments: [false, true])
func aClosedPaneOrWindowCannotBeResurrectedByLateRegistration(closesWindow: Bool) throws {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let moved = Pane.sshSession(host: host)
workspace.splitFocusedPane(axis: .horizontal, with: moved)
let session = try #require(moved.sshSession)
session.status = .connected
let source = WindowModel(workspaces: [workspace])
let target = WindowModel()
let app = AppModel(hosts: HostLibraryModel())
app.mainWindows.createScene = { _ in }
let sourceNative = nativeWindow()
let targetNative = nativeWindow()
defer {
sourceNative.close()
targetNative.close()
}
app.mainWindows.register(sourceNative, model: source, requestID: nil)
let request = try #require(app.mainWindows.movePane(moved.id, from: source))
if closesWindow {
sourceNative.close()
} else {
source.closeSelected()
}
app.mainWindows.register(targetNative, model: target, requestID: request)
#expect(session.status == .disconnected)
#expect(target.workspaces.isEmpty)
#expect(source.pane(moved.id) == nil)
#expect(app.closedWorkspaceRecords.first?.isWindow == closesWindow)
#expect(app.mainWindows.movePane(moved.id, from: source) == nil)
if !closesWindow {
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: source)
#expect(source.selectedWorkspace?.focusedPane.id == moved.id)
#expect(source.selectedWorkspace?.focusedPane.sshSession !== session)
#expect(target.workspaces.isEmpty)
}
}
@Test
func missingFactoryAndInvalidPanesNeverChangeTheSource() {
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
let empty = Pane(.empty)
workspace.splitFocusedPane(axis: .horizontal, with: empty)
let source = WindowModel(workspaces: [workspace])
defer { source.closeForWindow() }
let app = AppModel(hosts: HostLibraryModel())
let pane = workspace.layout.firstPane
#expect(app.mainWindows.movePane(pane.id, from: source) == nil)
var created: [UUID] = []
app.mainWindows.createScene = { created.append($0) }
#expect(app.mainWindows.movePane(UUID(), from: source) == nil)
#expect(app.mainWindows.movePane(empty.id, from: source) == nil)
#expect(created.isEmpty)
#expect(workspace.panes.count == 2)
#expect(workspace.layout.firstPane === pane)
#expect(source.selectedWorkspace === workspace)
}
@Test
func concurrentPaneMovesCanArriveInReverseRequestOrder() throws {
let host = Host(name: "Server", host: "server.example")
let workspace = Workspace.sshSession(host: host)
let firstPane = workspace.focusedPane
let secondPane = Pane.sshSession(host: host)
workspace.splitFocusedPane(axis: .horizontal, with: secondPane)
let source = WindowModel(workspaces: [workspace])
let first = WindowModel()
let second = WindowModel()
let app = AppModel(hosts: HostLibraryModel())
app.mainWindows.createScene = { _ in }
let firstNative = nativeWindow()
let secondNative = nativeWindow()
defer {
source.closeForWindow()
firstNative.close()
secondNative.close()
}
let firstRequest = try #require(app.mainWindows.movePane(firstPane.id, from: source))
let secondRequest = try #require(app.mainWindows.movePane(secondPane.id, from: source))
#expect(firstRequest != secondRequest)
app.mainWindows.register(secondNative, model: second, requestID: secondRequest)
#expect(source.selectedWorkspace === workspace)
#expect(workspace.focusedPane === firstPane)
app.mainWindows.register(firstNative, model: first, requestID: firstRequest)
#expect(first.selectedWorkspace === workspace)
#expect(first.selectedWorkspace?.focusedPane === firstPane)
#expect(second.selectedWorkspace?.focusedPane === secondPane)
#expect(source.workspaces.isEmpty)
#expect(app.closedWorkspaceRecords.isEmpty)
}
@Test(arguments: [false, true])
func releasedWindowUsesTheDropScreenAndSourcesOrdinarySize(movesPane: Bool) throws {
let screen = try #require(NSScreen.screens.last)
let normalSize = CGSize(width: 900, height: 600)
let app = AppModel(hosts: HostLibraryModel())
app.mainWindows.createScene = { _ in }
let workspace = Workspace.sshSession(host: Host(name: "Server", host: "server.example"))
let source = WindowModel(workspaces: [workspace])
source.sidebar.preferredWidth = 472
source.showsSidebar = false
let target = WindowModel()
let sourceNative = nativeWindow(size: normalSize)
let targetNative = nativeWindow()
defer {
sourceNative.close()
targetNative.close()
}
app.mainWindows.register(sourceNative, model: source, requestID: nil)
NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: sourceNative)
sourceNative.setContentSize(CGSize(width: 1300, height: 800))
NotificationCenter.default.post(name: NSWindow.didEnterFullScreenNotification, object: sourceNative)
let point = CGPoint(x: screen.visibleFrame.maxX - 4, y: screen.visibleFrame.maxY - 4)
let request = try #require(
movesPane
? app.mainWindows.movePane(workspace.focusedPaneID, from: source, at: point)
: app.mainWindows.moveWorkspace(workspace.id, from: source, at: point))
let layout = try #require(app.mainWindows.requestedLayout(for: request))
#expect(layout.isFullScreen)
#expect(layout.size == normalSize)
#expect(layout.sidebarWidth == 472)
#expect(!layout.showsSidebar)
app.mainWindows.register(targetNative, model: target, requestID: request)
#expect(target.selectedWorkspace === workspace)
#expect(target.sidebar.preferredWidth == 472)
#expect(!target.showsSidebar)
#expect(targetNative.frame.size.width == min(normalSize.width, screen.visibleFrame.width))
#expect(targetNative.frame.size.height == min(normalSize.height, screen.visibleFrame.height))
#expect(targetNative.frame.maxX == screen.visibleFrame.maxX)
#expect(targetNative.frame.maxY == max(point.y, screen.visibleFrame.minY + targetNative.frame.height))
#expect(screen.visibleFrame.contains(targetNative.frame))
}
private func nativeWindow(size: CGSize = CGSize(width: 800, height: 600)) -> NSWindow {
let window = NSWindow(
contentRect: CGRect(x: 100, y: 150, width: size.width, height: size.height),
styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.animationBehavior = .none
return window
}
}